diff --git a/LibMatrix/Extensions/CanonicalJsonSerializer.cs b/LibMatrix/Extensions/CanonicalJsonSerializer.cs
new file mode 100644
index 0000000..a6fbcf4
--- /dev/null
+++ b/LibMatrix/Extensions/CanonicalJsonSerializer.cs
@@ -0,0 +1,91 @@
+using System.Collections.Frozen;
+using System.Reflection;
+using System.Security.Cryptography;
+using System.Text.Encodings.Web;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.Json.Serialization;
+using System.Text.Json.Serialization.Metadata;
+using System.Text.Unicode;
+using ArcaneLibs.Extensions;
+
+namespace LibMatrix.Extensions;
+
+public static class CanonicalJsonSerializer {
+ // TODO: Alphabetise dictionaries
+ private static JsonSerializerOptions _options => new() {
+ WriteIndented = false,
+ Encoder = UnicodeJsonEncoder.Singleton,
+ };
+
+ private static readonly FrozenSet<PropertyInfo> JsonSerializerOptionsProperties = typeof(JsonSerializerOptions)
+ .GetProperties(BindingFlags.Public | BindingFlags.Instance)
+ .Where(x => x.SetMethod != null && x.GetMethod != null)
+ .ToFrozenSet();
+
+ private static JsonSerializerOptions MergeOptions(JsonSerializerOptions? inputOptions) {
+ var newOptions = _options;
+ if (inputOptions == null)
+ return newOptions;
+
+ foreach (var property in JsonSerializerOptionsProperties) {
+ if(property.Name == nameof(JsonSerializerOptions.Encoder))
+ continue;
+ if (property.Name == nameof(JsonSerializerOptions.WriteIndented))
+ continue;
+
+ var value = property.GetValue(inputOptions);
+ // if (value == null)
+ // continue;
+ property.SetValue(newOptions, value);
+ }
+
+ return newOptions;
+ }
+
+#region STJ API
+
+ public static String Serialize<TValue>(TValue value, JsonSerializerOptions? options = null) {
+ var newOptions = MergeOptions(options);
+
+ return System.Text.Json.JsonSerializer.SerializeToNode(value, options) // We want to allow passing custom converters for eg. double/float -> string here...
+ .SortProperties()!
+ .CanonicalizeNumbers()!
+ .ToJsonString(newOptions);
+
+
+ // System.Text.Json.JsonSerializer.SerializeToNode(System.Text.Json.JsonSerializer.Deserialize<dynamic>("{\n \"a\": -0,\n \"b\": 1e10\n}")).ToJsonString();
+
+ }
+
+ public static String Serialize(object value, Type inputType, JsonSerializerOptions? options = null) => JsonSerializer.Serialize(value, inputType, _options);
+ // public static String Serialize<TValue>(TValue value, JsonTypeInfo<TValue> jsonTypeInfo) => JsonSerializer.Serialize(value, jsonTypeInfo, _options);
+ // public static String Serialize(Object value, JsonTypeInfo jsonTypeInfo)
+
+#endregion
+
+ private static partial class JsonExtensions {
+ public static Action<JsonTypeInfo> AlphabetizeProperties(Type type) {
+ return typeInfo => {
+ if (typeInfo.Kind != JsonTypeInfoKind.Object || !type.IsAssignableFrom(typeInfo.Type))
+ return;
+ AlphabetizeProperties()(typeInfo);
+ };
+ }
+
+ public static Action<JsonTypeInfo> AlphabetizeProperties() {
+ return static typeInfo => {
+ if (typeInfo.Kind == JsonTypeInfoKind.Dictionary) { }
+
+ if (typeInfo.Kind != JsonTypeInfoKind.Object)
+ return;
+ var properties = typeInfo.Properties.OrderBy(p => p.Name, StringComparer.Ordinal).ToList();
+ typeInfo.Properties.Clear();
+ for (int i = 0; i < properties.Count; i++) {
+ properties[i].Order = i;
+ typeInfo.Properties.Add(properties[i]);
+ }
+ };
+ }
+ }
+}
\ No newline at end of file
diff --git a/LibMatrix/Extensions/EnumerableExtensions.cs b/LibMatrix/Extensions/EnumerableExtensions.cs
index 42d9491..ace2c0c 100644
--- a/LibMatrix/Extensions/EnumerableExtensions.cs
+++ b/LibMatrix/Extensions/EnumerableExtensions.cs
@@ -1,29 +1,91 @@
+using System.Collections.Frozen;
+using System.Collections.Immutable;
+
namespace LibMatrix.Extensions;
public static class EnumerableExtensions {
+ public static int insertions = 0;
+ public static int replacements = 0;
+
public static void MergeStateEventLists(this IList<StateEvent> oldState, IList<StateEvent> newState) {
- foreach (var stateEvent in newState) {
- var old = oldState.FirstOrDefault(x => x.Type == stateEvent.Type && x.StateKey == stateEvent.StateKey);
- if (old is null) {
- oldState.Add(stateEvent);
- continue;
+ // foreach (var stateEvent in newState) {
+ // var old = oldState.FirstOrDefault(x => x.Type == stateEvent.Type && x.StateKey == stateEvent.StateKey);
+ // if (old is null) {
+ // oldState.Add(stateEvent);
+ // continue;
+ // }
+ //
+ // oldState.Remove(old);
+ // oldState.Add(stateEvent);
+ // }
+
+ foreach (var e in newState) {
+ switch (FindIndex(e)) {
+ case -1:
+ oldState.Add(e);
+ break;
+ case var index:
+ oldState[index] = e;
+ break;
}
+ }
- oldState.Remove(old);
- oldState.Add(stateEvent);
+ int FindIndex(StateEvent needle) {
+ for (int i = 0; i < oldState.Count; i++) {
+ var old = oldState[i];
+ if (old.Type == needle.Type && old.StateKey == needle.StateKey)
+ return i;
+ }
+
+ return -1;
}
}
public static void MergeStateEventLists(this IList<StateEventResponse> oldState, IList<StateEventResponse> newState) {
- foreach (var stateEvent in newState) {
- var old = oldState.FirstOrDefault(x => x.Type == stateEvent.Type && x.StateKey == stateEvent.StateKey);
- if (old is null) {
- oldState.Add(stateEvent);
- continue;
+ foreach (var e in newState) {
+ switch (FindIndex(e)) {
+ case -1:
+ oldState.Add(e);
+ break;
+ case var index:
+ oldState[index] = e;
+ break;
+ }
+ }
+
+ int FindIndex(StateEventResponse needle) {
+ for (int i = 0; i < oldState.Count; i++) {
+ var old = oldState[i];
+ if (old.Type == needle.Type && old.StateKey == needle.StateKey)
+ return i;
+ }
+
+ return -1;
+ }
+ }
+
+ public static void MergeStateEventLists(this List<StateEventResponse> oldState, List<StateEventResponse> newState) {
+ foreach (var e in newState) {
+ switch (FindIndex(e)) {
+ case -1:
+ oldState.Add(e);
+ insertions++;
+ break;
+ case var index:
+ oldState[index] = e;
+ replacements++;
+ break;
+ }
+ }
+
+ int FindIndex(StateEventResponse needle) {
+ for (int i = 0; i < oldState.Count; i++) {
+ var old = oldState[i];
+ if (old.Type == needle.Type && old.StateKey == needle.StateKey)
+ return i;
}
- oldState.Remove(old);
- oldState.Add(stateEvent);
+ return -1;
}
}
}
\ No newline at end of file
diff --git a/LibMatrix/Extensions/MatrixHttpClient.Single.cs b/LibMatrix/Extensions/MatrixHttpClient.Single.cs
index 3c8aea4..0e6d467 100644
--- a/LibMatrix/Extensions/MatrixHttpClient.Single.cs
+++ b/LibMatrix/Extensions/MatrixHttpClient.Single.cs
@@ -2,6 +2,7 @@
// #define SYNC_HTTPCLIENT // Only allow one request as a time, for debugging
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
+using System.Net;
using System.Net.Http.Headers;
using System.Reflection;
using System.Security.Cryptography.X509Certificates;
@@ -14,7 +15,7 @@ using ArcaneLibs.Extensions;
namespace LibMatrix.Extensions;
#if SINGLE_HTTPCLIENT
-// TODO: Add URI wrapper for
+// TODO: Add URI wrapper for
public class MatrixHttpClient {
private static readonly HttpClient Client;
@@ -27,7 +28,7 @@ public class MatrixHttpClient {
};
Client = new HttpClient(handler) {
DefaultRequestVersion = new Version(3, 0),
- Timeout = TimeSpan.FromHours(1)
+ Timeout = TimeSpan.FromDays(1)
};
}
catch (PlatformNotSupportedException e) {
@@ -50,6 +51,7 @@ public class MatrixHttpClient {
internal SemaphoreSlim _rateLimitSemaphore { get; } = new(1, 1);
#endif
+ private const bool LogRequests = true;
public Dictionary<string, string> AdditionalQueryParameters { get; set; } = new();
public Uri? BaseAddress { get; set; }
@@ -70,21 +72,31 @@ public class MatrixHttpClient {
public async Task<HttpResponseMessage> SendUnhandledAsync(HttpRequestMessage request, CancellationToken cancellationToken) {
if (request.RequestUri is null) throw new NullReferenceException("RequestUri is null");
- // if (!request.RequestUri.IsAbsoluteUri)
- request.RequestUri = request.RequestUri.EnsureAbsolute(BaseAddress!);
+ // if (!request.RequestUri.IsAbsoluteUri)
+ request.RequestUri = request.RequestUri.EnsureAbsolute(BaseAddress!);
var swWait = Stopwatch.StartNew();
#if SYNC_HTTPCLIENT
await _rateLimitSemaphore.WaitAsync(cancellationToken);
#endif
+
+ Console.WriteLine($"Sending {request.Method} {BaseAddress}{request.RequestUri} ({Util.BytesToString(request.GetContentLength())})");
+
+ if (request.RequestUri is null) throw new NullReferenceException("RequestUri is null");
+ if (!request.RequestUri.IsAbsoluteUri) request.RequestUri = new Uri(BaseAddress, request.RequestUri);
swWait.Stop();
var swExec = Stopwatch.StartNew();
-
+
foreach (var (key, value) in AdditionalQueryParameters) request.RequestUri = request.RequestUri.AddQuery(key, value);
- foreach (var (key, value) in DefaultRequestHeaders) request.Headers.Add(key, value);
+ foreach (var (key, value) in DefaultRequestHeaders) {
+ if (request.Headers.Contains(key)) continue;
+ request.Headers.Add(key, value);
+ }
+
request.Options.Set(new HttpRequestOptionsKey<bool>("WebAssemblyEnableStreamingResponse"), true);
- Console.WriteLine("Sending " + request.Summarise(includeHeaders:true, includeQuery: true, includeContentIfText: true));
-
+ if (LogRequests)
+ Console.WriteLine("Sending " + request.Summarise(includeHeaders: true, includeQuery: true, includeContentIfText: true, hideHeaders: ["Accept"]));
+
HttpResponseMessage? responseMessage;
try {
responseMessage = await Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
@@ -101,19 +113,25 @@ public class MatrixHttpClient {
#endif
// Console.WriteLine($"Sending {request.Method} {request.RequestUri} ({Util.BytesToString(request.Content?.Headers.ContentLength ?? 0)}) -> {(int)responseMessage.StatusCode} {responseMessage.StatusCode} ({Util.BytesToString(responseMessage.GetContentLength())}, WAIT={swWait.ElapsedMilliseconds}ms, EXEC={swExec.ElapsedMilliseconds}ms)");
- Console.WriteLine("Received " + responseMessage.Summarise(includeHeaders: true, includeContentIfText: false, hideHeaders: [
- "Server",
- "Date",
- "Transfer-Encoding",
- "Connection",
- "Vary",
- "Content-Length",
- "Access-Control-Allow-Origin",
- "Access-Control-Allow-Methods",
- "Access-Control-Allow-Headers",
- "Access-Control-Expose-Headers",
- "Cache-Control"
- ]));
+ if (LogRequests)
+ Console.WriteLine("Received " + responseMessage.Summarise(includeHeaders: true, includeContentIfText: false, hideHeaders: [
+ "Server",
+ "Date",
+ "Transfer-Encoding",
+ "Connection",
+ "Vary",
+ "Content-Length",
+ "Access-Control-Allow-Origin",
+ "Access-Control-Allow-Methods",
+ "Access-Control-Allow-Headers",
+ "Access-Control-Expose-Headers",
+ "Cache-Control",
+ "Cross-Origin-Resource-Policy",
+ "X-Content-Security-Policy",
+ "Referrer-Policy",
+ "X-Robots-Tag",
+ "Content-Security-Policy"
+ ]));
return responseMessage;
}
@@ -122,6 +140,12 @@ public class MatrixHttpClient {
var responseMessage = await SendUnhandledAsync(request, cancellationToken);
if (responseMessage.IsSuccessStatusCode) return responseMessage;
+ //retry on gateway timeout
+ if (responseMessage.StatusCode == HttpStatusCode.GatewayTimeout) {
+ request.ResetSendStatus();
+ return await SendAsync(request, cancellationToken);
+ }
+
//error handling
var content = await responseMessage.Content.ReadAsStringAsync(cancellationToken);
if (content.Length == 0)
@@ -248,4 +272,4 @@ public class MatrixHttpClient {
await SendAsync(request);
}
}
-#endif
\ No newline at end of file
+#endif
diff --git a/LibMatrix/Extensions/UnicodeJsonEncoder.cs b/LibMatrix/Extensions/UnicodeJsonEncoder.cs
new file mode 100644
index 0000000..ae58263
--- /dev/null
+++ b/LibMatrix/Extensions/UnicodeJsonEncoder.cs
@@ -0,0 +1,173 @@
+// LibMatrix: File sourced from https://github.com/dotnet/runtime/pull/87147/files under the MIT license.
+
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Text;
+using System.Text.Encodings.Web;
+
+namespace LibMatrix.Extensions;
+
+internal sealed class UnicodeJsonEncoder : JavaScriptEncoder
+{
+ internal static readonly UnicodeJsonEncoder Singleton = new UnicodeJsonEncoder();
+
+ private readonly bool _preferHexEscape;
+ private readonly bool _preferUppercase;
+
+ public UnicodeJsonEncoder()
+ : this(preferHexEscape: false, preferUppercase: false)
+ {
+ }
+
+ public UnicodeJsonEncoder(bool preferHexEscape, bool preferUppercase)
+ {
+ _preferHexEscape = preferHexEscape;
+ _preferUppercase = preferUppercase;
+ }
+
+ public override int MaxOutputCharactersPerInputCharacter => 6; // "\uXXXX" for a single char ("\uXXXX\uYYYY" [12 chars] for supplementary scalar value)
+
+ public override unsafe int FindFirstCharacterToEncode(char* text, int textLength)
+ {
+ for (int index = 0; index < textLength; ++index)
+ {
+ char value = text[index];
+
+ if (NeedsEncoding(value))
+ {
+ return index;
+ }
+ }
+
+ return -1;
+ }
+
+ public override unsafe bool TryEncodeUnicodeScalar(int unicodeScalar, char* buffer, int bufferLength, out int numberOfCharactersWritten)
+ {
+ bool encode = WillEncode(unicodeScalar);
+
+ if (!encode)
+ {
+ Span<char> span = new Span<char>(buffer, bufferLength);
+ int spanWritten;
+ bool succeeded = new Rune(unicodeScalar).TryEncodeToUtf16(span, out spanWritten);
+ numberOfCharactersWritten = spanWritten;
+ return succeeded;
+ }
+
+ if (!_preferHexEscape && unicodeScalar <= char.MaxValue && HasTwoCharacterEscape((char)unicodeScalar))
+ {
+ if (bufferLength < 2)
+ {
+ numberOfCharactersWritten = 0;
+ return false;
+ }
+
+ buffer[0] = '\\';
+ buffer[1] = GetTwoCharacterEscapeSuffix((char)unicodeScalar);
+ numberOfCharactersWritten = 2;
+ return true;
+ }
+ else
+ {
+ if (bufferLength < 6)
+ {
+ numberOfCharactersWritten = 0;
+ return false;
+ }
+
+ buffer[0] = '\\';
+ buffer[1] = 'u';
+ buffer[2] = '0';
+ buffer[3] = '0';
+ buffer[4] = ToHexDigit((unicodeScalar & 0xf0) >> 4, _preferUppercase);
+ buffer[5] = ToHexDigit(unicodeScalar & 0xf, _preferUppercase);
+ numberOfCharactersWritten = 6;
+ return true;
+ }
+ }
+
+ public override bool WillEncode(int unicodeScalar)
+ {
+ if (unicodeScalar > char.MaxValue)
+ {
+ return false;
+ }
+
+ return NeedsEncoding((char)unicodeScalar);
+ }
+
+ // https://datatracker.ietf.org/doc/html/rfc8259#section-7
+ private static bool NeedsEncoding(char value)
+ {
+ if (value == '"' || value == '\\')
+ {
+ return true;
+ }
+
+ return value <= '\u001f';
+ }
+
+ private static bool HasTwoCharacterEscape(char value)
+ {
+ // RFC 8259, Section 7, "char = " BNF
+ switch (value)
+ {
+ case '"':
+ case '\\':
+ case '/':
+ case '\b':
+ case '\f':
+ case '\n':
+ case '\r':
+ case '\t':
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ private static char GetTwoCharacterEscapeSuffix(char value)
+ {
+ // RFC 8259, Section 7, "char = " BNF
+ switch (value)
+ {
+ case '"':
+ return '"';
+ case '\\':
+ return '\\';
+ case '/':
+ return '/';
+ case '\b':
+ return 'b';
+ case '\f':
+ return 'f';
+ case '\n':
+ return 'n';
+ case '\r':
+ return 'r';
+ case '\t':
+ return 't';
+ default:
+ throw new ArgumentOutOfRangeException(nameof(value));
+ }
+ }
+
+ private static char ToHexDigit(int value, bool uppercase)
+ {
+ if (value > 0xf)
+ {
+ throw new ArgumentOutOfRangeException(nameof(value));
+ }
+
+ if (value < 10)
+ {
+ return (char)(value + '0');
+ }
+ else
+ {
+ return (char)(value - 0xa + (uppercase ? 'A' : 'a'));
+ }
+ }
+}
\ No newline at end of file
diff --git a/LibMatrix/Helpers/MessageBuilder.cs b/LibMatrix/Helpers/MessageBuilder.cs
index cfda6b3..d3bd6a5 100644
--- a/LibMatrix/Helpers/MessageBuilder.cs
+++ b/LibMatrix/Helpers/MessageBuilder.cs
@@ -91,6 +91,18 @@ public class MessageBuilder(string msgType = "m.text", string format = "org.matr
return this;
}
+ public MessageBuilder WithMention(string id, string? displayName = null) {
+ Content.Body += $"@{displayName ?? id}";
+ Content.FormattedBody += $"<a href=\"https://matrix.to/#/{id}\">{displayName ?? id}</a>";
+ return this;
+ }
+
+ public MessageBuilder WithNewline() {
+ Content.Body += "\n";
+ Content.FormattedBody += "<br>";
+ return this;
+ }
+
public MessageBuilder WithTable(Action<TableBuilder> tableBuilder) {
var tb = new TableBuilder(this);
WithHtmlTag("table", msb => tableBuilder(tb));
diff --git a/LibMatrix/Helpers/SyncHelper.cs b/LibMatrix/Helpers/SyncHelper.cs
index 7d5364b..f95d6f8 100644
--- a/LibMatrix/Helpers/SyncHelper.cs
+++ b/LibMatrix/Helpers/SyncHelper.cs
@@ -1,10 +1,14 @@
using System.Diagnostics;
using System.Net.Http.Json;
+using System.Text.Json;
+using ArcaneLibs.Collections;
+using System.Text.Json.Nodes;
using ArcaneLibs.Extensions;
using LibMatrix.Filters;
using LibMatrix.Homeservers;
using LibMatrix.Interfaces.Services;
using LibMatrix.Responses;
+using LibMatrix.Utilities;
using Microsoft.Extensions.Logging;
namespace LibMatrix.Helpers;
@@ -18,6 +22,7 @@ public class SyncHelper(AuthenticatedHomeserverGeneric homeserver, ILogger? logg
public string? Since { get; set; }
public int Timeout { get; set; } = 30000;
public string? SetPresence { get; set; } = "online";
+ public bool UseInternalStreamingSync { get; set; } = true;
public string? FilterId {
get => _filterId;
@@ -43,6 +48,7 @@ public class SyncHelper(AuthenticatedHomeserverGeneric homeserver, ILogger? logg
_filter = value;
_filterIsDirty = true;
_filterId = null;
+ _namedFilterName = null;
}
}
@@ -110,13 +116,22 @@ public class SyncHelper(AuthenticatedHomeserverGeneric homeserver, ILogger? logg
// logger?.LogInformation("SyncHelper: Calling: {}", url);
try {
- var httpResp = await homeserver.ClientHttpClient.GetAsync(url, cancellationToken ?? CancellationToken.None);
- if (httpResp is null) throw new NullReferenceException("Failed to send HTTP request");
- logger?.LogInformation("Got sync response: {} bytes, {} elapsed", httpResp.GetContentLength(), sw.Elapsed);
- var deserializeSw = Stopwatch.StartNew();
- var resp = await httpResp.Content.ReadFromJsonAsync(cancellationToken: cancellationToken ?? CancellationToken.None,
- jsonTypeInfo: SyncResponseSerializerContext.Default.SyncResponse);
- logger?.LogInformation("Deserialized sync response: {} bytes, {} elapsed, {} total", httpResp.GetContentLength(), deserializeSw.Elapsed, sw.Elapsed);
+ SyncResponse? resp = null;
+ if (UseInternalStreamingSync) {
+ resp = await homeserver.ClientHttpClient.GetFromJsonAsync<SyncResponse>(url, cancellationToken: cancellationToken ?? CancellationToken.None);
+ logger?.LogInformation("Got sync response: ~{} bytes, {} elapsed", resp.ToJson(false, true, true).Length, sw.Elapsed);
+ }
+ else {
+ var httpResp = await homeserver.ClientHttpClient.GetAsync(url, cancellationToken ?? CancellationToken.None);
+ if (httpResp is null) throw new NullReferenceException("Failed to send HTTP request");
+ logger?.LogInformation("Got sync response: {} bytes, {} elapsed", httpResp.GetContentLength(), sw.Elapsed);
+ var deserializeSw = Stopwatch.StartNew();
+ // var jsonResp = await httpResp.Content.ReadFromJsonAsync<JsonObject>(cancellationToken: cancellationToken ?? CancellationToken.None);
+ // var resp = jsonResp.Deserialize<SyncResponse>();
+ resp = await httpResp.Content.ReadFromJsonAsync(cancellationToken: cancellationToken ?? CancellationToken.None,
+ jsonTypeInfo: SyncResponseSerializerContext.Default.SyncResponse);
+ logger?.LogInformation("Deserialized sync response: {} bytes, {} elapsed, {} total", httpResp.GetContentLength(), deserializeSw.Elapsed, sw.Elapsed);
+ }
var timeToWait = MinimumDelay.Subtract(sw.Elapsed);
if (timeToWait.TotalMilliseconds > 0)
@@ -242,4 +257,4 @@ public class SyncHelper(AuthenticatedHomeserverGeneric homeserver, ILogger? logg
if (logger is null) Console.WriteLine(message);
else logger.LogInformation(message);
}
-}
\ No newline at end of file
+}
diff --git a/LibMatrix/Helpers/SyncStateResolver.cs b/LibMatrix/Helpers/SyncStateResolver.cs
index 5e34628..282d26f 100644
--- a/LibMatrix/Helpers/SyncStateResolver.cs
+++ b/LibMatrix/Helpers/SyncStateResolver.cs
@@ -1,6 +1,7 @@
using System.Collections.Frozen;
using System.Collections.Immutable;
using System.Diagnostics;
+using System.Text;
using ArcaneLibs.Extensions;
using LibMatrix.Extensions;
using LibMatrix.Filters;
@@ -40,15 +41,16 @@ public class SyncStateResolver(AuthenticatedHomeserverGeneric homeserver, ILogge
return (sync, MergedState);
}
- public async Task OptimiseStore() {
+ public async Task OptimiseStore(Action<int, int>? progressCallback = null) {
if (storageProvider is null) return;
if (!await storageProvider.ObjectExistsAsync("init")) return;
var totalSw = Stopwatch.StartNew();
Console.Write("Optimising sync store...");
var initLoadTask = storageProvider.LoadObjectAsync<SyncResponse>("init");
- var keys = (await storageProvider.GetAllKeysAsync()).Where(x=>!x.StartsWith("old/")).ToFrozenSet();
+ var keys = (await storageProvider.GetAllKeysAsync()).Where(x => !x.StartsWith("old/")).ToFrozenSet();
var count = keys.Count - 1;
+ int total = count;
Console.WriteLine($"Found {count} entries to optimise.");
var merged = await initLoadTask;
@@ -64,6 +66,7 @@ public class SyncStateResolver(AuthenticatedHomeserverGeneric homeserver, ILogge
var moveTasks = new List<Task>();
+ Dictionary<string, Dictionary<string, TimeSpan>> traces = [];
while (keys.Contains(merged.NextBatch)) {
Console.Write($"Merging {merged.NextBatch}, {--count} remaining... ");
var sw = Stopwatch.StartNew();
@@ -77,28 +80,36 @@ public class SyncStateResolver(AuthenticatedHomeserverGeneric homeserver, ILogge
moveTasks.Add(storageProvider.MoveObjectAsync(merged.NextBatch, $"{oldPath}/{merged.NextBatch}"));
Console.Write($"Move {sw.GetElapsedAndRestart().TotalMilliseconds}ms... ");
- merged = MergeSyncs(merged, next);
+ var trace = new Dictionary<string, TimeSpan>();
+ traces[merged.NextBatch] = trace;
+ merged = MergeSyncs(merged, next, trace);
Console.Write($"Merge {sw.GetElapsedAndRestart().TotalMilliseconds}ms... ");
Console.WriteLine($"Total {swt.Elapsed.TotalMilliseconds}ms");
// Console.WriteLine($"Merged {merged.NextBatch}, {--count} remaining...");
+ progressCallback?.Invoke(count, total);
}
+ var traceString = string.Join("\n", traces.Select(x => $"{x.Key}\t{x.Value.ToJson(indent: false)}"));
+ var ms = new MemoryStream(Encoding.UTF8.GetBytes(traceString));
+ await storageProvider.SaveStreamAsync($"traces/{oldPath}", ms);
+
await storageProvider.SaveObjectAsync("init", merged);
await Task.WhenAll(moveTasks);
-
+
Console.WriteLine($"Optimised store in {totalSw.Elapsed.TotalMilliseconds}ms");
+ Console.WriteLine($"Insertions: {EnumerableExtensions.insertions}, replacements: {EnumerableExtensions.replacements}");
}
/// <summary>
/// Remove all but initial sync and last checkpoint
/// </summary>
public async Task RemoveOldSnapshots() {
- if(storageProvider is null) return;
+ if (storageProvider is null) return;
var sw = Stopwatch.StartNew();
var map = await GetCheckpointMap();
if (map is null) return;
- if(map.Count < 3) return;
+ if (map.Count < 3) return;
var toRemove = map.Keys.Skip(1).Take(map.Count - 2).ToList();
Console.Write("Cleaning up old snapshots: ");
@@ -109,6 +120,7 @@ public class SyncStateResolver(AuthenticatedHomeserverGeneric homeserver, ILogge
await storageProvider?.DeleteObjectAsync(path);
}
}
+
Console.WriteLine("Done!");
Console.WriteLine($"Removed {toRemove.Count} old snapshots in {sw.Elapsed.TotalMilliseconds}ms");
}
@@ -129,46 +141,6 @@ public class SyncStateResolver(AuthenticatedHomeserverGeneric homeserver, ILogge
}
public async Task dev() {
- // var keys = (await storageProvider?.GetAllKeysAsync()).ToFrozenSet();
- // var times = new Dictionary<long, List<string>>();
- // var values = keys.Select(async x => Task.Run(async () => (x, await storageProvider?.LoadObjectAsync<SyncResponse>(x)))).ToAsyncEnumerable();
- // await foreach (var task in values) {
- // var (key, data) = await task;
- // if (data is null) continue;
- // var derivTime = data.GetDerivedSyncTime();
- // if (!times.ContainsKey(derivTime)) times[derivTime] = new();
- // times[derivTime].Add(key);
- // }
- //
- // foreach (var (time, ckeys) in times.OrderBy(x => x.Key)) {
- // Console.WriteLine($"{time}: {ckeys.Count} keys");
- // }
-
- // var map = await GetCheckpointMap();
- // if (map is null) return;
- //
- // var times = new Dictionary<long, List<string>>();
- // foreach (var (time, keys) in map) {
- // Console.WriteLine($"{time}: {keys.Count} keys - calculating times");
- // Dictionary<string, Task<SyncResponse?>?> tasks = keys.ToDictionary(x => x, x => storageProvider?.LoadObjectAsync<SyncResponse>(x));
- // var nextKey = "init";
- // long lastTime = 0;
- // while (tasks.ContainsKey(nextKey)) {
- // var data = await tasks[nextKey];
- // if (data is null) break;
- // var derivTime = data.GetDerivedSyncTime();
- // if (derivTime == 0) derivTime = lastTime + 1;
- // if (!times.ContainsKey(derivTime)) times[derivTime] = new();
- // times[derivTime].Add(nextKey);
- // lastTime = derivTime;
- // nextKey = data.NextBatch;
- // }
- // }
- //
- // foreach (var (time, ckeys) in times.OrderBy(x => x.Key)) {
- // Console.WriteLine($"{time}: {ckeys.Count} keys");
- // }
-
int i = 0;
var sw = Stopwatch.StartNew();
var hist = GetSerialisedHistory();
@@ -177,6 +149,7 @@ public class SyncStateResolver(AuthenticatedHomeserverGeneric homeserver, ILogge
// Console.WriteLine($"[{++i}] {key} -> {resp.NextBatch} ({resp.GetDerivedSyncTime()})");
i++;
}
+
Console.WriteLine($"Iterated {i} syncResponses in {sw.Elapsed}");
Environment.Exit(0);
}
@@ -228,7 +201,7 @@ public class SyncStateResolver(AuthenticatedHomeserverGeneric homeserver, ILogge
if (resp.GetDerivedSyncTime() > unixTime) break;
merged = MergeSyncs(merged, resp);
}
-
+
return merged;
}
@@ -240,8 +213,6 @@ public class SyncStateResolver(AuthenticatedHomeserverGeneric homeserver, ILogge
if (!key.StartsWith("old/")) continue;
var parts = key.Split('/');
if (parts.Length < 3) continue;
- // if (!map.ContainsKey(parts[1])) map[parts[1]] = new();
- // map[parts[1]].Add(parts[2]);
if (!ulong.TryParse(parts[1], out var checkpoint)) continue;
if (!map.ContainsKey(checkpoint)) map[checkpoint] = new();
map[checkpoint].Add(parts[2]);
@@ -250,29 +221,29 @@ public class SyncStateResolver(AuthenticatedHomeserverGeneric homeserver, ILogge
return map.OrderBy(x => x.Key).ToImmutableSortedDictionary(x => x.Key, x => x.Value.ToFrozenSet());
}
- private SyncResponse MergeSyncs(SyncResponse oldSync, SyncResponse newSync) {
+ private SyncResponse MergeSyncs(SyncResponse oldSync, SyncResponse newSync, Dictionary<string, TimeSpan>? trace = null) {
+ var sw = Stopwatch.StartNew();
oldSync.NextBatch = newSync.NextBatch ?? oldSync.NextBatch;
- oldSync.AccountData ??= new EventList();
- oldSync.AccountData.Events ??= [];
- if (newSync.AccountData?.Events is not null)
- oldSync.AccountData.Events.MergeStateEventLists(newSync.AccountData?.Events ?? []);
+ oldSync.AccountData = MergeEventList(oldSync.AccountData, newSync.AccountData);
+ trace?.Add("AccountData", sw.GetElapsedAndRestart());
- oldSync.Presence ??= new();
- oldSync.Presence.Events?.ReplaceBy(newSync.Presence?.Events ?? [], (oldState, newState) => oldState.Sender == newState.Sender && oldState.Type == newState.Type);
+ oldSync.Presence = MergeEventListBy(oldSync.Presence, newSync.Presence, (oldState, newState) => oldState.Sender == newState.Sender && oldState.Type == newState.Type);
+ trace?.Add("Presence", sw.GetElapsedAndRestart());
+ // TODO: can this be cleaned up?
oldSync.DeviceOneTimeKeysCount ??= new();
if (newSync.DeviceOneTimeKeysCount is not null)
foreach (var (key, value) in newSync.DeviceOneTimeKeysCount)
oldSync.DeviceOneTimeKeysCount[key] = value;
+ trace?.Add("DeviceOneTimeKeysCount", sw.GetElapsedAndRestart());
if (newSync.Rooms is not null)
- oldSync.Rooms = MergeRoomsDataStructure(oldSync.Rooms, newSync.Rooms);
+ oldSync.Rooms = MergeRoomsDataStructure(oldSync.Rooms, newSync.Rooms, trace);
+ trace?.Add("Rooms", sw.GetElapsedAndRestart());
- oldSync.ToDevice ??= new EventList();
- oldSync.ToDevice.Events ??= [];
- if (newSync.ToDevice?.Events is not null)
- oldSync.ToDevice.Events.MergeStateEventLists(newSync.ToDevice?.Events ?? []);
+ oldSync.ToDevice = MergeEventList(oldSync.ToDevice, newSync.ToDevice);
+ trace?.Add("ToDevice", sw.GetElapsedAndRestart());
oldSync.DeviceLists ??= new SyncResponse.DeviceListsDataStructure();
oldSync.DeviceLists.Changed ??= [];
@@ -283,125 +254,171 @@ public class SyncStateResolver(AuthenticatedHomeserverGeneric homeserver, ILogge
oldSync.DeviceLists.Changed.Add(s);
}
+ trace?.Add("DeviceLists.Changed", sw.GetElapsedAndRestart());
+
if (newSync.DeviceLists?.Left is not null)
foreach (var s in newSync.DeviceLists.Left!) {
oldSync.DeviceLists.Changed.Remove(s);
oldSync.DeviceLists.Left.Add(s);
}
- return oldSync;
- }
-
- private List<StateEventResponse>? MergePresenceEvents(List<StateEventResponse>? oldEvents, List<StateEventResponse>? newEvents) {
- if (oldEvents is null) return newEvents;
- if (newEvents is null) return oldEvents;
+ trace?.Add("DeviceLists.Left", sw.GetElapsedAndRestart());
- foreach (var newEvent in newEvents) {
- oldEvents.RemoveAll(x => x.Sender == newEvent.Sender && x.Type == newEvent.Type);
- oldEvents.Add(newEvent);
- }
-
- return oldEvents;
+ return oldSync;
}
#region Merge rooms
- private SyncResponse.RoomsDataStructure MergeRoomsDataStructure(SyncResponse.RoomsDataStructure? oldState, SyncResponse.RoomsDataStructure newState) {
+ private SyncResponse.RoomsDataStructure MergeRoomsDataStructure(SyncResponse.RoomsDataStructure? oldState, SyncResponse.RoomsDataStructure newState,
+ Dictionary<string, TimeSpan>? trace) {
+ var sw = Stopwatch.StartNew();
if (oldState is null) return newState;
- oldState.Join ??= new Dictionary<string, SyncResponse.RoomsDataStructure.JoinedRoomDataStructure>();
- foreach (var (key, value) in newState.Join ?? new Dictionary<string, SyncResponse.RoomsDataStructure.JoinedRoomDataStructure>())
- if (!oldState.Join.ContainsKey(key)) oldState.Join[key] = value;
- else oldState.Join[key] = MergeJoinedRoomDataStructure(oldState.Join[key], value);
-
- oldState.Invite ??= new Dictionary<string, SyncResponse.RoomsDataStructure.InvitedRoomDataStructure>();
- foreach (var (key, value) in newState.Invite ?? new Dictionary<string, SyncResponse.RoomsDataStructure.InvitedRoomDataStructure>())
- if (!oldState.Invite.ContainsKey(key)) oldState.Invite[key] = value;
- else oldState.Invite[key] = MergeInvitedRoomDataStructure(oldState.Invite[key], value);
-
- oldState.Leave ??= new Dictionary<string, SyncResponse.RoomsDataStructure.LeftRoomDataStructure>();
- foreach (var (key, value) in newState.Leave ?? new Dictionary<string, SyncResponse.RoomsDataStructure.LeftRoomDataStructure>()) {
- if (!oldState.Leave.ContainsKey(key)) oldState.Leave[key] = value;
- else oldState.Leave[key] = MergeLeftRoomDataStructure(oldState.Leave[key], value);
- if (oldState.Invite.ContainsKey(key)) oldState.Invite.Remove(key);
- if (oldState.Join.ContainsKey(key)) oldState.Join.Remove(key);
- }
+
+ if (newState.Join is { Count: > 0 })
+ if (oldState.Join is null)
+ oldState.Join = newState.Join;
+ else
+ foreach (var (key, value) in newState.Join)
+ if (!oldState.Join.TryAdd(key, value))
+ oldState.Join[key] = MergeJoinedRoomDataStructure(oldState.Join[key], value, trace);
+ trace?.Add("MergeRoomsDataStructure.Join", sw.GetElapsedAndRestart());
+
+ if (newState.Invite is { Count: > 0 })
+ if (oldState.Invite is null)
+ oldState.Invite = newState.Invite;
+ else
+ foreach (var (key, value) in newState.Invite)
+ if (!oldState.Invite.TryAdd(key, value))
+ oldState.Invite[key] = MergeInvitedRoomDataStructure(oldState.Invite[key], value, trace);
+ trace?.Add("MergeRoomsDataStructure.Invite", sw.GetElapsedAndRestart());
+
+ if (newState.Leave is { Count: > 0 })
+ if (oldState.Leave is null)
+ oldState.Leave = newState.Leave;
+ else
+ foreach (var (key, value) in newState.Leave) {
+ if (!oldState.Leave.TryAdd(key, value))
+ oldState.Leave[key] = MergeLeftRoomDataStructure(oldState.Leave[key], value, trace);
+ if (oldState.Invite?.ContainsKey(key) ?? false) oldState.Invite.Remove(key);
+ if (oldState.Join?.ContainsKey(key) ?? false) oldState.Join.Remove(key);
+ }
+ trace?.Add("MergeRoomsDataStructure.Leave", sw.GetElapsedAndRestart());
return oldState;
}
private static SyncResponse.RoomsDataStructure.LeftRoomDataStructure MergeLeftRoomDataStructure(SyncResponse.RoomsDataStructure.LeftRoomDataStructure oldData,
- SyncResponse.RoomsDataStructure.LeftRoomDataStructure newData) {
- oldData.AccountData ??= new EventList();
- oldData.AccountData.Events ??= [];
- oldData.Timeline ??= new SyncResponse.RoomsDataStructure.JoinedRoomDataStructure.TimelineDataStructure();
- oldData.Timeline.Events ??= [];
- oldData.State ??= new EventList();
- oldData.State.Events ??= [];
-
- if (newData.AccountData?.Events is not null)
- oldData.AccountData.Events.MergeStateEventLists(newData.AccountData?.Events ?? []);
-
- if (newData.Timeline?.Events is not null)
- oldData.Timeline.Events.MergeStateEventLists(newData.Timeline?.Events ?? []);
+ SyncResponse.RoomsDataStructure.LeftRoomDataStructure newData, Dictionary<string, TimeSpan>? trace) {
+ var sw = Stopwatch.StartNew();
+
+ oldData.AccountData = MergeEventList(oldData.AccountData, newData.AccountData);
+ trace?.Add($"LeftRoomDataStructure.AccountData/{oldData.GetHashCode()}", sw.GetElapsedAndRestart());
+
+ oldData.Timeline = AppendEventList(oldData.Timeline, newData.Timeline) as SyncResponse.RoomsDataStructure.JoinedRoomDataStructure.TimelineDataStructure
+ ?? throw new InvalidOperationException("Merged room timeline was not TimelineDataStructure");
oldData.Timeline.Limited = newData.Timeline?.Limited ?? oldData.Timeline.Limited;
oldData.Timeline.PrevBatch = newData.Timeline?.PrevBatch ?? oldData.Timeline.PrevBatch;
+ trace?.Add($"LeftRoomDataStructure.Timeline/{oldData.GetHashCode()}", sw.GetElapsedAndRestart());
- if (newData.State?.Events is not null)
- oldData.State.Events.MergeStateEventLists(newData.State?.Events ?? []);
+ oldData.State = MergeEventList(oldData.State, newData.State);
+ trace?.Add($"LeftRoomDataStructure.State/{oldData.GetHashCode()}", sw.GetElapsedAndRestart());
return oldData;
}
private static SyncResponse.RoomsDataStructure.InvitedRoomDataStructure MergeInvitedRoomDataStructure(SyncResponse.RoomsDataStructure.InvitedRoomDataStructure oldData,
- SyncResponse.RoomsDataStructure.InvitedRoomDataStructure newData) {
- oldData.InviteState ??= new EventList();
- oldData.InviteState.Events ??= [];
- if (newData.InviteState?.Events is not null)
- oldData.InviteState.Events.MergeStateEventLists(newData.InviteState?.Events ?? []);
+ SyncResponse.RoomsDataStructure.InvitedRoomDataStructure newData, Dictionary<string, TimeSpan>? trace) {
+ var sw = Stopwatch.StartNew();
+ oldData.InviteState = MergeEventList(oldData.InviteState, newData.InviteState);
+ trace?.Add($"InvitedRoomDataStructure.InviteState/{oldData.GetHashCode()}", sw.GetElapsedAndRestart());
return oldData;
}
private static SyncResponse.RoomsDataStructure.JoinedRoomDataStructure MergeJoinedRoomDataStructure(SyncResponse.RoomsDataStructure.JoinedRoomDataStructure oldData,
- SyncResponse.RoomsDataStructure.JoinedRoomDataStructure newData) {
- oldData.AccountData ??= new EventList();
- oldData.AccountData.Events ??= [];
- oldData.Timeline ??= new SyncResponse.RoomsDataStructure.JoinedRoomDataStructure.TimelineDataStructure();
- oldData.Timeline.Events ??= [];
- oldData.State ??= new EventList();
- oldData.State.Events ??= [];
- oldData.Ephemeral ??= new EventList();
- oldData.Ephemeral.Events ??= [];
-
- if (newData.AccountData?.Events is not null)
- oldData.AccountData.Events.MergeStateEventLists(newData.AccountData?.Events ?? []);
-
- if (newData.Timeline?.Events is not null)
- oldData.Timeline.Events.MergeStateEventLists(newData.Timeline?.Events ?? []);
+ SyncResponse.RoomsDataStructure.JoinedRoomDataStructure newData, Dictionary<string, TimeSpan>? trace) {
+ var sw = Stopwatch.StartNew();
+
+ oldData.AccountData = MergeEventList(oldData.AccountData, newData.AccountData);
+ trace?.Add($"JoinedRoomDataStructure.AccountData/{oldData.GetHashCode()}", sw.GetElapsedAndRestart());
+
+ oldData.Timeline = AppendEventList(oldData.Timeline, newData.Timeline) as SyncResponse.RoomsDataStructure.JoinedRoomDataStructure.TimelineDataStructure
+ ?? throw new InvalidOperationException("Merged room timeline was not TimelineDataStructure");
oldData.Timeline.Limited = newData.Timeline?.Limited ?? oldData.Timeline.Limited;
oldData.Timeline.PrevBatch = newData.Timeline?.PrevBatch ?? oldData.Timeline.PrevBatch;
+ trace?.Add($"JoinedRoomDataStructure.Timeline/{oldData.GetHashCode()}", sw.GetElapsedAndRestart());
- if (newData.State?.Events is not null)
- oldData.State.Events.MergeStateEventLists(newData.State?.Events ?? []);
+ oldData.State = MergeEventList(oldData.State, newData.State);
+ trace?.Add($"JoinedRoomDataStructure.State/{oldData.GetHashCode()}", sw.GetElapsedAndRestart());
- if (newData.Ephemeral?.Events is not null)
- oldData.Ephemeral.Events.MergeStateEventLists(newData.Ephemeral?.Events ?? []);
+ oldData.Ephemeral = MergeEventList(oldData.Ephemeral, newData.Ephemeral);
+ trace?.Add($"JoinedRoomDataStructure.Ephemeral/{oldData.GetHashCode()}", sw.GetElapsedAndRestart());
oldData.UnreadNotifications ??= new SyncResponse.RoomsDataStructure.JoinedRoomDataStructure.UnreadNotificationsDataStructure();
oldData.UnreadNotifications.HighlightCount = newData.UnreadNotifications?.HighlightCount ?? oldData.UnreadNotifications.HighlightCount;
oldData.UnreadNotifications.NotificationCount = newData.UnreadNotifications?.NotificationCount ?? oldData.UnreadNotifications.NotificationCount;
+ trace?.Add($"JoinedRoom$DataStructure.UnreadNotifications/{oldData.GetHashCode()}", sw.GetElapsedAndRestart());
+
+ if (oldData.Summary is null)
+ oldData.Summary = newData.Summary;
+ else {
+ oldData.Summary.Heroes = newData.Summary?.Heroes ?? oldData.Summary.Heroes;
+ oldData.Summary.JoinedMemberCount = newData.Summary?.JoinedMemberCount ?? oldData.Summary.JoinedMemberCount;
+ oldData.Summary.InvitedMemberCount = newData.Summary?.InvitedMemberCount ?? oldData.Summary.InvitedMemberCount;
+ }
- oldData.Summary ??= new SyncResponse.RoomsDataStructure.JoinedRoomDataStructure.SummaryDataStructure {
- Heroes = newData.Summary?.Heroes ?? oldData.Summary.Heroes,
- JoinedMemberCount = newData.Summary?.JoinedMemberCount ?? oldData.Summary.JoinedMemberCount,
- InvitedMemberCount = newData.Summary?.InvitedMemberCount ?? oldData.Summary.InvitedMemberCount
- };
- oldData.Summary.Heroes = newData.Summary?.Heroes ?? oldData.Summary.Heroes;
- oldData.Summary.JoinedMemberCount = newData.Summary?.JoinedMemberCount ?? oldData.Summary.JoinedMemberCount;
- oldData.Summary.InvitedMemberCount = newData.Summary?.InvitedMemberCount ?? oldData.Summary.InvitedMemberCount;
+ trace?.Add($"JoinedRoomDataStructure.Summary/{oldData.GetHashCode()}", sw.GetElapsedAndRestart());
return oldData;
}
#endregion
+
+ private static EventList? MergeEventList(EventList? oldState, EventList? newState) {
+ if (newState is null) return oldState;
+ if (oldState is null) {
+ return newState;
+ }
+
+ if (newState.Events is null) return oldState;
+ if (oldState.Events is null) {
+ oldState.Events = newState.Events;
+ return oldState;
+ }
+
+ oldState.Events.MergeStateEventLists(newState.Events);
+ return oldState;
+ }
+
+ private static EventList? MergeEventListBy(EventList? oldState, EventList? newState, Func<StateEventResponse, StateEventResponse, bool> comparer) {
+ if (newState is null) return oldState;
+ if (oldState is null) {
+ return newState;
+ }
+
+ if (newState.Events is null) return oldState;
+ if (oldState.Events is null) {
+ oldState.Events = newState.Events;
+ return oldState;
+ }
+
+ oldState.Events.ReplaceBy(newState.Events, comparer);
+ return oldState;
+ }
+
+ private static EventList? AppendEventList(EventList? oldState, EventList? newState) {
+ if (newState is null) return oldState;
+ if (oldState is null) {
+ return newState;
+ }
+
+ if (newState.Events is null) return oldState;
+ if (oldState.Events is null) {
+ oldState.Events = newState.Events;
+ return oldState;
+ }
+
+ oldState.Events.AddRange(newState.Events);
+ return oldState;
+ }
}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/AuthenticatedHomeserverGeneric.cs b/LibMatrix/Homeservers/AuthenticatedHomeserverGeneric.cs
index c729a44..77a72c8 100644
--- a/LibMatrix/Homeservers/AuthenticatedHomeserverGeneric.cs
+++ b/LibMatrix/Homeservers/AuthenticatedHomeserverGeneric.cs
@@ -48,7 +48,6 @@ public class AuthenticatedHomeserverGeneric : RemoteHomeserver {
public HsNamedCaches NamedCaches { get; set; } = null!;
public GenericRoom GetRoom(string roomId) {
- if (roomId is null || !roomId.StartsWith("!")) throw new ArgumentException("Room ID must start with !", nameof(roomId));
return new GenericRoom(this, roomId);
}
@@ -186,6 +185,17 @@ public class AuthenticatedHomeserverGeneric : RemoteHomeserver {
#endregion
+#region MSC 4133
+
+ public async Task UpdateProfilePropertyAsync(string name, object? value) {
+ var caps = await GetCapabilitiesAsync();
+ if(caps is null) throw new Exception("Failed to get capabilities");
+
+ }
+
+#endregion
+
+ [Obsolete("This method assumes no support for MSC 4069 and MSC 4133")]
public async Task UpdateProfileAsync(UserProfileResponse? newProfile, bool preserveCustomRoomProfile = true) {
if (newProfile is null) return;
Console.WriteLine($"Updating profile for {WhoAmI.UserId} to {newProfile.ToJson(ignoreNull: true)} (preserving room profiles: {preserveCustomRoomProfile})");
@@ -406,4 +416,115 @@ public class AuthenticatedHomeserverGeneric : RemoteHomeserver {
public NamedFilterCache FilterCache { get; init; }
public NamedFileCache FileCache { get; init; }
}
-}
\ No newline at end of file
+
+#region Authenticated Media
+
+ // TODO: implement /_matrix/client/v1/media/config when it's actually useful - https://spec.matrix.org/v1.11/client-server-api/#get_matrixclientv1mediaconfig
+
+ private (string ServerName, string MediaId) ParseMxcUri(string mxcUri) {
+ if (!mxcUri.StartsWith("mxc://")) throw new ArgumentException("Matrix Content URIs must start with 'mxc://'", nameof(mxcUri));
+ var parts = mxcUri[6..].Split('/');
+ if (parts.Length != 2) throw new ArgumentException($"Invalid Matrix Content URI '{mxcUri}' passed! Matrix Content URIs must exist of only 2 parts!", nameof(mxcUri));
+ return (parts[0], parts[1]);
+ }
+
+ public async Task<Stream> GetMediaStreamAsync(string mxcUri, string? filename = null, int? timeout = null) {
+ var (serverName, mediaId) = ParseMxcUri(mxcUri);
+ try {
+ var uri = $"/_matrix/client/v1/media/download/{serverName}/{mediaId}";
+ if (!string.IsNullOrWhiteSpace(filename)) uri += $"/{HttpUtility.UrlEncode(filename)}";
+ if (timeout is not null) uri += $"?timeout_ms={timeout}";
+ var res = await ClientHttpClient.GetAsync(uri);
+ return await res.Content.ReadAsStreamAsync();
+ }
+ catch (MatrixException e) {
+ if (e is not { ErrorCode: "M_UNKNOWN" }) throw;
+ }
+
+ //fallback to legacy media
+ try {
+ var uri = $"/_matrix/media/v1/download/{serverName}/{mediaId}";
+ if (!string.IsNullOrWhiteSpace(filename)) uri += $"/{HttpUtility.UrlEncode(filename)}";
+ if (timeout is not null) uri += $"?timeout_ms={timeout}";
+ var res = await ClientHttpClient.GetAsync(uri);
+ return await res.Content.ReadAsStreamAsync();
+ }
+ catch (MatrixException e) {
+ if (e is not { ErrorCode: "M_UNKNOWN" }) throw;
+ }
+
+ throw new LibMatrixException() {
+ ErrorCode = LibMatrixException.ErrorCodes.M_UNSUPPORTED,
+ Error = "Failed to download media"
+ };
+ // return default;
+ }
+
+ public async Task<Stream> GetThumbnailStreamAsync(string mxcUri, int width, int height, string? method = null, int? timeout = null) {
+ var (serverName, mediaId) = ParseMxcUri(mxcUri);
+ try {
+ var uri = new Uri($"/_matrix/client/v1/thumbnail/{serverName}/{mediaId}");
+ uri = uri.AddQuery("width", width.ToString());
+ uri = uri.AddQuery("height", height.ToString());
+ if (!string.IsNullOrWhiteSpace(method)) uri = uri.AddQuery("method", method);
+ if (timeout is not null) uri = uri.AddQuery("timeout_ms", timeout.ToString());
+
+ var res = await ClientHttpClient.GetAsync(uri.ToString());
+ return await res.Content.ReadAsStreamAsync();
+ }
+ catch (MatrixException e) {
+ if (e is not { ErrorCode: "M_UNKNOWN" }) throw;
+ }
+
+ //fallback to legacy media
+ try {
+ var uri = new Uri($"/_matrix/media/v1/thumbnail/{serverName}/{mediaId}");
+ uri = uri.AddQuery("width", width.ToString());
+ uri = uri.AddQuery("height", height.ToString());
+ if (!string.IsNullOrWhiteSpace(method)) uri = uri.AddQuery("method", method);
+ if (timeout is not null) uri = uri.AddQuery("timeout_ms", timeout.ToString());
+
+ var res = await ClientHttpClient.GetAsync(uri.ToString());
+ return await res.Content.ReadAsStreamAsync();
+ }
+ catch (MatrixException e) {
+ if (e is not { ErrorCode: "M_UNKNOWN" }) throw;
+ }
+
+ throw new LibMatrixException() {
+ ErrorCode = LibMatrixException.ErrorCodes.M_UNSUPPORTED,
+ Error = "Failed to download media"
+ };
+ // return default;
+ }
+
+ public async Task<Dictionary<string, JsonValue>?> GetUrlPreviewAsync(string url) {
+ try {
+ var res = await ClientHttpClient.GetAsync($"/_matrix/client/v1/media/preview_url?url={HttpUtility.UrlEncode(url)}");
+ return await res.Content.ReadFromJsonAsync<Dictionary<string, JsonValue>>();
+ }
+ catch (MatrixException e) {
+ if (e is not { ErrorCode: "M_UNRECOGNIZED" }) throw;
+ }
+
+ //fallback to legacy media
+ try {
+ var res = await ClientHttpClient.GetAsync($"/_matrix/media/v1/preview_url?url={HttpUtility.UrlEncode(url)}");
+ return await res.Content.ReadFromJsonAsync<Dictionary<string, JsonValue>>();
+ }
+ catch (MatrixException e) {
+ if (e is not { ErrorCode: "M_UNRECOGNIZED" }) throw;
+ }
+
+ throw new LibMatrixException() {
+ ErrorCode = LibMatrixException.ErrorCodes.M_UNSUPPORTED,
+ Error = "Failed to download URL preview"
+ };
+ }
+
+#endregion
+ private class CapabilitiesResponse {
+ [JsonPropertyName("capabilities")]
+ public Dictionary<string, object>? Capabilities { get; set; }
+ }
+}
diff --git a/LibMatrix/Homeservers/RemoteHomeServer.cs b/LibMatrix/Homeservers/RemoteHomeServer.cs
index ecf3e3a..adaac6d 100644
--- a/LibMatrix/Homeservers/RemoteHomeServer.cs
+++ b/LibMatrix/Homeservers/RemoteHomeServer.cs
@@ -55,6 +55,8 @@ public class RemoteHomeserver {
return data;
}
+
+ // TODO: Do we need to support retrieving individual profile properties? Is there any use for that besides just getting the full profile?
public async Task<ClientVersionsResponse> GetClientVersionsAsync() {
var resp = await ClientHttpClient.GetAsync($"/_matrix/client/versions");
@@ -107,6 +109,7 @@ public class RemoteHomeserver {
#endregion
+ [Obsolete("This call uses the deprecated unauthenticated media endpoints, please switch to the relevant AuthenticatedHomeserver methods instead.", true)]
public string? ResolveMediaUri(string? mxcUri) {
if (mxcUri is null) return null;
if (mxcUri.StartsWith("https://")) return mxcUri;
diff --git a/LibMatrix/LibMatrix.csproj b/LibMatrix/LibMatrix.csproj
index d0511ea..b992ad6 100644
--- a/LibMatrix/LibMatrix.csproj
+++ b/LibMatrix/LibMatrix.csproj
@@ -1,33 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
- <TargetFramework>net8.0</TargetFramework>
+ <TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<Optimize>true</Optimize>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
+ <AllowUnsafeBlocks>true</AllowUnsafeBlocks> <!-- Required for UnicodeJsonEncoder... -->
</PropertyGroup>
<ItemGroup>
- <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1" />
- <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" />
+ <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0"/>
+ <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0"/>
+ <ProjectReference Include="..\LibMatrix.EventTypes\LibMatrix.EventTypes.csproj"/>
</ItemGroup>
<ItemGroup>
- <ProjectReference Condition="Exists('..\ArcaneLibs\ArcaneLibs\ArcaneLibs.csproj')" Include="..\ArcaneLibs\ArcaneLibs\ArcaneLibs.csproj"/>
- <!-- This is dangerous, but eases development since locking the version will drift out of sync without noticing,
- which causes build errors due to missing functions.
- Using the NuGet version in development is annoying due to delays between pushing and being able to consume.
- If you want to use a time-appropriate version of the library, recursively clone https://cgit.rory.gay/matrix/MatrixUtils.git
- instead, since this will be locked by the MatrixUtils project, which contains both LibMatrix and ArcaneLibs as a submodule. -->
- <PackageReference Condition="!Exists('..\ArcaneLibs\ArcaneLibs\ArcaneLibs.csproj')" Include="ArcaneLibs" Version="*-preview*"/>
- <ProjectReference Include="..\LibMatrix.EventTypes\LibMatrix.EventTypes.csproj"/>
+ <PackageReference Include="ArcaneLibs" Version="1.0.0-preview.20241122-053825" Condition="'$(Configuration)' == 'Release'"/>
+ <ProjectReference Include="..\ArcaneLibs\ArcaneLibs\ArcaneLibs.csproj" Condition="'$(Configuration)' == 'Debug'"/>
</ItemGroup>
- <Target Name="ArcaneLibsNugetWarning" AfterTargets="AfterBuild">
- <Warning Text="ArcaneLibs is being referenced from NuGet, which is dangerous. Please read the warning in LibMatrix.csproj!" Condition="!Exists('..\ArcaneLibs\ArcaneLibs\ArcaneLibs.csproj')"/>
- </Target>
-
</Project>
diff --git a/LibMatrix/Responses/DeviceKeysUploadRequest.cs b/LibMatrix/Responses/DeviceKeysUploadRequest.cs
new file mode 100644
index 0000000..c93c4c6
--- /dev/null
+++ b/LibMatrix/Responses/DeviceKeysUploadRequest.cs
@@ -0,0 +1,24 @@
+using System.Text.Json.Serialization;
+
+namespace LibMatrix.Responses;
+
+public class DeviceKeysUploadRequest {
+ [JsonPropertyName("device_keys")]
+ public DeviceKeysSchema DeviceKeys { get; set; }
+
+
+ [JsonPropertyName("one_time_keys")]
+ public Dictionary<string, OneTimeKey> OneTimeKeys { get; set; }
+
+ public class DeviceKeysSchema {
+ [JsonPropertyName("algorithms")]
+ public List<string> Algorithms { get; set; }
+ }
+ public class OneTimeKey {
+ [JsonPropertyName("key")]
+ public string Key { get; set; }
+
+ [JsonPropertyName("signatures")]
+ public Dictionary<string, Dictionary<string, string>> Signatures { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/LibMatrix/Responses/SyncResponse.cs b/LibMatrix/Responses/SyncResponse.cs
index 2d3d3f8..a4391b7 100644
--- a/LibMatrix/Responses/SyncResponse.cs
+++ b/LibMatrix/Responses/SyncResponse.cs
@@ -39,6 +39,10 @@ public class SyncResponse {
}
// supporting classes
+ public class PresenceDataStructure {
+ [JsonPropertyName("events")]
+ public List<StateEventResponse>? Events { get; set; }
+ }
public class RoomsDataStructure {
[JsonPropertyName("join")]
@@ -86,7 +90,7 @@ public class SyncResponse {
[JsonPropertyName("summary")]
public SummaryDataStructure? Summary { get; set; }
- public class TimelineDataStructure {
+ public class TimelineDataStructure : EventList {
public TimelineDataStructure() { }
public TimelineDataStructure(List<StateEventResponse>? events, bool? limited) {
@@ -94,8 +98,8 @@ public class SyncResponse {
Limited = limited;
}
- [JsonPropertyName("events")]
- public List<StateEventResponse>? Events { get; set; }
+ // [JsonPropertyName("events")]
+ // public List<StateEventResponse>? Events { get; set; }
[JsonPropertyName("prev_batch")]
public string? PrevBatch { get; set; }
@@ -140,4 +144,4 @@ public class SyncResponse {
Rooms?.Leave?.Values?.Max(x => x.Timeline?.Events?.Max(y => y.OriginServerTs)) ?? 0
]).Max();
}
-}
\ No newline at end of file
+}
diff --git a/LibMatrix/Responses/UserDirectoryResponse.cs b/LibMatrix/Responses/UserDirectoryResponse.cs
new file mode 100644
index 0000000..13235d9
--- /dev/null
+++ b/LibMatrix/Responses/UserDirectoryResponse.cs
@@ -0,0 +1,30 @@
+using System.Text.Json.Serialization;
+
+namespace LibMatrix.Responses;
+
+public class UserDirectoryResponse {
+ [JsonPropertyName("limited")]
+ public bool Limited { get; set; }
+
+ [JsonPropertyName("results")]
+ public List<UserDirectoryResult> Results { get; set; }
+
+ public class UserDirectoryResult {
+ [JsonPropertyName("avatar_url")]
+ public string? AvatarUrl { get; set; }
+
+ [JsonPropertyName("display_name")]
+ public string? DisplayName { get; set; }
+
+ [JsonPropertyName("user_id")]
+ public string UserId { get; set; }
+ }
+}
+
+public class UserDirectoryRequest {
+ [JsonPropertyName("search_term")]
+ public string SearchTerm { get; set; }
+
+ [JsonPropertyName("limit")]
+ public int? Limit { get; set; }
+}
\ No newline at end of file
diff --git a/LibMatrix/Responses/UserProfileResponse.cs b/LibMatrix/Responses/UserProfileResponse.cs
index 6c9380f..30e4c32 100644
--- a/LibMatrix/Responses/UserProfileResponse.cs
+++ b/LibMatrix/Responses/UserProfileResponse.cs
@@ -1,3 +1,4 @@
+using System.Text.Json;
using System.Text.Json.Serialization;
namespace LibMatrix.Responses;
@@ -8,4 +9,18 @@ public class UserProfileResponse {
[JsonPropertyName("displayname")]
public string? DisplayName { get; set; }
+
+ // MSC 4133 - Extending User Profile API with Key:Value pairs
+ [JsonExtensionData]
+ public Dictionary<string, JsonElement>? CustomKeys { get; set; }
+
+ public JsonElement? this[string key] {
+ get => CustomKeys?[key];
+ set {
+ if (value is null)
+ CustomKeys?.Remove(key);
+ else
+ (CustomKeys ??= [])[key] = value.Value;
+ }
+ }
}
\ No newline at end of file
diff --git a/LibMatrix/RoomTypes/GenericRoom.cs b/LibMatrix/RoomTypes/GenericRoom.cs
index 4f6a4e9..84a3d30 100644
--- a/LibMatrix/RoomTypes/GenericRoom.cs
+++ b/LibMatrix/RoomTypes/GenericRoom.cs
@@ -1,6 +1,7 @@
using System.Collections.Frozen;
using System.Diagnostics;
using System.Net.Http.Json;
+using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
@@ -211,10 +212,11 @@ public class GenericRoom {
public async Task<RoomIdResponse> JoinAsync(string[]? homeservers = null, string? reason = null, bool checkIfAlreadyMember = true) {
if (checkIfAlreadyMember)
try {
- _ = await GetCreateEventAsync();
- return new RoomIdResponse {
- RoomId = RoomId
- };
+ var ser = await GetStateEventOrNullAsync(RoomMemberEventContent.EventId, Homeserver.UserId);
+ if (ser?.TypedContent is RoomMemberEventContent { Membership: "join" })
+ return new RoomIdResponse {
+ RoomId = RoomId
+ };
}
catch { } //ignore
@@ -317,12 +319,12 @@ public class GenericRoom {
public Task<RoomPowerLevelEventContent?> GetPowerLevelsAsync() =>
GetStateAsync<RoomPowerLevelEventContent>("m.room.power_levels");
+ [Obsolete("This method will be merged into GetNameAsync() in the future.")]
public async Task<string> GetNameOrFallbackAsync(int maxMemberNames = 2) {
try {
var name = await GetNameAsync();
- if (!string.IsNullOrWhiteSpace(name))
- return name;
- throw new Exception("No name");
+ if (!string.IsNullOrEmpty(name)) return name;
+ throw new();
}
catch {
try {
@@ -357,22 +359,6 @@ public class GenericRoom {
return Task.WhenAll(tasks);
}
- public async Task<string?> GetResolvedRoomAvatarUrlAsync(bool useOriginHomeserver = false) {
- var avatar = await GetAvatarUrlAsync();
- if (avatar?.Url is null) return null;
- if (!avatar.Url.StartsWith("mxc://")) return avatar.Url;
- if (useOriginHomeserver)
- try {
- var hs = avatar.Url.Split('/', 3)[1];
- return await new HomeserverResolverService(NullLogger<HomeserverResolverService>.Instance).ResolveMediaUri(hs, avatar.Url);
- }
- catch (Exception e) {
- Console.WriteLine(e);
- }
-
- return Homeserver.ResolveMediaUri(avatar.Url);
- }
-
#endregion
#region Simple calls
@@ -393,12 +379,12 @@ public class GenericRoom {
await Homeserver.ClientHttpClient.PostAsJsonAsync($"/_matrix/client/v3/rooms/{RoomId}/ban",
new UserIdAndReason { UserId = userId, Reason = reason });
- public async Task UnbanAsync(string userId) =>
+ public async Task UnbanAsync(string userId, string? reason = null) =>
await Homeserver.ClientHttpClient.PostAsJsonAsync($"/_matrix/client/v3/rooms/{RoomId}/unban",
- new UserIdAndReason { UserId = userId });
+ new UserIdAndReason { UserId = userId, Reason = reason });
public async Task InviteUserAsync(string userId, string? reason = null, bool skipExisting = true) {
- if (skipExisting && await GetStateAsync<RoomMemberEventContent>("m.room.member", userId) is not null)
+ if (skipExisting && await GetStateOrNullAsync<RoomMemberEventContent>("m.room.member", userId) is not null)
return;
await Homeserver.ClientHttpClient.PostAsJsonAsync($"/_matrix/client/v3/rooms/{RoomId}/invite", new UserIdAndReason(userId, reason));
}
@@ -412,7 +398,7 @@ public class GenericRoom {
.Content.ReadFromJsonAsync<EventIdResponse>();
public async Task<EventIdResponse?> SendStateEventAsync(string eventType, string stateKey, object content) =>
- await (await Homeserver.ClientHttpClient.PutAsJsonAsync($"/_matrix/client/v3/rooms/{RoomId}/state/{eventType}/{stateKey}", content))
+ await (await Homeserver.ClientHttpClient.PutAsJsonAsync($"/_matrix/client/v3/rooms/{RoomId}/state/{eventType.UrlEncode()}/{stateKey.UrlEncode()}", content))
.Content.ReadFromJsonAsync<EventIdResponse>();
public async Task<EventIdResponse> SendTimelineEventAsync(string eventType, TimelineEventContent content) {
@@ -448,6 +434,16 @@ public class GenericRoom {
return await res.Content.ReadFromJsonAsync<T>();
}
+ public async Task<T?> GetRoomAccountDataOrNullAsync<T>(string key) {
+ try {
+ return await GetRoomAccountDataAsync<T>(key);
+ }
+ catch (MatrixException e) {
+ if (e.ErrorCode == "M_NOT_FOUND") return default;
+ throw;
+ }
+ }
+
public async Task SetRoomAccountDataAsync(string key, object data) {
var res = await Homeserver.ClientHttpClient.PutAsJsonAsync($"/_matrix/client/v3/user/{Homeserver.UserId}/rooms/{RoomId}/account_data/{key}", data);
if (!res.IsSuccessStatusCode) {
@@ -459,10 +455,17 @@ public class GenericRoom {
public Task<StateEventResponse> GetEventAsync(string eventId) =>
Homeserver.ClientHttpClient.GetFromJsonAsync<StateEventResponse>($"/_matrix/client/v3/rooms/{RoomId}/event/{eventId}");
- public async Task<EventIdResponse> RedactEventAsync(string eventToRedact, string reason) {
+ public async Task<EventIdResponse> RedactEventAsync(string eventToRedact, string? reason = null) {
var data = new { reason };
- return (await (await Homeserver.ClientHttpClient.PutAsJsonAsync(
- $"/_matrix/client/v3/rooms/{RoomId}/redact/{eventToRedact}/{Guid.NewGuid()}", data)).Content.ReadFromJsonAsync<EventIdResponse>())!;
+ var url = $"/_matrix/client/v3/rooms/{RoomId}/redact/{eventToRedact}/{Guid.NewGuid().ToString()}";
+ while (true) {
+ try {
+ return (await (await Homeserver.ClientHttpClient.PutAsJsonAsync(url, data)).Content.ReadFromJsonAsync<EventIdResponse>())!;
+ } catch (MatrixException e) {
+ if (e is { ErrorCode: MatrixException.ErrorCodes.M_FORBIDDEN }) throw;
+ throw;
+ }
+ }
}
#endregion
@@ -553,4 +556,4 @@ public class GenericRoom {
public class RoomIdResponse {
[JsonPropertyName("room_id")]
public string RoomId { get; set; } = null!;
-}
\ No newline at end of file
+}
diff --git a/LibMatrix/RoomTypes/SpaceRoom.cs b/LibMatrix/RoomTypes/SpaceRoom.cs
index b40ccc6..4563ed3 100644
--- a/LibMatrix/RoomTypes/SpaceRoom.cs
+++ b/LibMatrix/RoomTypes/SpaceRoom.cs
@@ -4,6 +4,8 @@ using LibMatrix.Homeservers;
namespace LibMatrix.RoomTypes;
public class SpaceRoom(AuthenticatedHomeserverGeneric homeserver, string roomId) : GenericRoom(homeserver, roomId) {
+ public const string TypeName = "m.space";
+
public async IAsyncEnumerable<GenericRoom> GetChildrenAsync(bool includeRemoved = false) {
// var rooms = new List<GenericRoom>();
var state = GetFullStateAsync();
@@ -31,7 +33,7 @@ public class SpaceRoom(AuthenticatedHomeserverGeneric homeserver, string roomId)
});
return resp;
}
-
+
public async Task<EventIdResponse> AddChildByIdAsync(string id) {
return await AddChildAsync(Homeserver.GetRoom(id));
}
diff --git a/LibMatrix/Services/ServiceInstaller.cs b/LibMatrix/Services/ServiceInstaller.cs
index 06ea9de..8b7e54b 100644
--- a/LibMatrix/Services/ServiceInstaller.cs
+++ b/LibMatrix/Services/ServiceInstaller.cs
@@ -5,23 +5,13 @@ namespace LibMatrix.Services;
public static class ServiceInstaller {
public static IServiceCollection AddRoryLibMatrixServices(this IServiceCollection services, RoryLibMatrixConfiguration? config = null) {
- //Check required services
- // if (!services.Any(x => x.ServiceType == typeof(TieredStorageService)))
- // throw new Exception("[RMUCore/DI] No TieredStorageService has been registered!");
//Add config
services.AddSingleton(config ?? new RoryLibMatrixConfiguration());
//Add services
services.AddSingleton<HomeserverResolverService>(sp => new HomeserverResolverService(sp.GetRequiredService<ILogger<HomeserverResolverService>>()));
-
- // if (services.First(x => x.ServiceType == typeof(TieredStorageService)).Lifetime == ServiceLifetime.Singleton) {
services.AddSingleton<HomeserverProviderService>();
- // }
- // else {
- // services.AddScoped<HomeserverProviderService>();
- // }
- // services.AddScoped<MatrixHttpClient>();
return services;
}
}
diff --git a/LibMatrix/StateEvent.cs b/LibMatrix/StateEvent.cs
index 869e420..dc76622 100644
--- a/LibMatrix/StateEvent.cs
+++ b/LibMatrix/StateEvent.cs
@@ -31,23 +31,6 @@ public class StateEvent {
public static Type GetStateEventType(string? type) =>
string.IsNullOrWhiteSpace(type) ? typeof(UnknownEventContent) : KnownStateEventTypesByName.GetValueOrDefault(type) ?? typeof(UnknownEventContent);
- [JsonPropertyName("state_key")]
- public string? StateKey { get; set; }
-
- [JsonPropertyName("type")]
- public string Type { get; set; }
-
- [JsonPropertyName("replaces_state")]
- public string? ReplacesState { get; set; }
-
- private JsonObject? _rawContent;
-
- [JsonPropertyName("content")]
- public JsonObject? RawContent {
- get => _rawContent;
- set => _rawContent = value;
- }
-
[JsonIgnore]
public Type MappedType => GetStateEventType(Type);
@@ -61,6 +44,7 @@ public class StateEvent {
public string FriendlyTypeNamePlural => MappedType.GetFriendlyNamePluralOrNull() ?? Type;
private static readonly JsonSerializerOptions TypedContentSerializerOptions = new() {
+ // We need these, NumberHandling covers other number types that we don't want to convert
Converters = {
new JsonFloatStringConverter(),
new JsonDoubleStringConverter(),
@@ -72,10 +56,6 @@ public class StateEvent {
[SuppressMessage("ReSharper", "PropertyCanBeMadeInitOnly.Global")]
public EventContent? TypedContent {
get {
- ClassCollector<EventContent>.ResolveFromAllAccessibleAssemblies();
- // if (Type == "m.receipt") {
- // return null;
- // }
try {
var mappedType = GetStateEventType(Type);
if (mappedType == typeof(UnknownEventContent))
@@ -99,6 +79,35 @@ public class StateEvent {
}
}
+ public T? ContentAs<T>() {
+ try {
+ return RawContent.Deserialize<T>(TypedContentSerializerOptions)!;
+ }
+ catch (JsonException e) {
+ Console.WriteLine(e);
+ Console.WriteLine("Content:\n" + (RawContent?.ToJson() ?? "null"));
+ }
+
+ return default;
+ }
+
+ [JsonPropertyName("state_key")]
+ public string? StateKey { get; set; }
+
+ [JsonPropertyName("type")]
+ public string Type { get; set; }
+
+ [JsonPropertyName("replaces_state")]
+ public string? ReplacesState { get; set; }
+
+ private JsonObject? _rawContent;
+
+ [JsonPropertyName("content")]
+ public JsonObject? RawContent {
+ get => _rawContent;
+ set => _rawContent = value;
+ }
+
//debug
[JsonIgnore]
public string InternalSelfTypeName {
@@ -224,4 +233,23 @@ public class StateEventContentPolymorphicTypeInfoResolver : DefaultJsonTypeInfoR
}
*/
-#endregion
\ No newline at end of file
+#endregion
+
+/*
+public class ForgivingObjectConverter<T> : JsonConverter<T> where T : new() {
+ public override T? Read(ref Utf8JsonReader reader, Type type, JsonSerializerOptions options) {
+ try {
+ var text = JsonDocument.ParseValue(ref reader).RootElement.GetRawText();
+ return JsonSerializer.Deserialize<T>(text, options);
+ }
+ catch (JsonException ex) {
+ Console.WriteLine(ex);
+ return null;
+ }
+ }
+
+ public override bool CanConvert(Type typeToConvert) => true;
+
+ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
+ => JsonSerializer.Serialize<T>(writer, value, options);
+}*/
|