about summary refs log tree commit diff
diff options
context:
space:
mode:
authorEmma@Rory& <root@rory.gay>2023-07-24 20:56:42 +0200
committerEmma@Rory& <root@rory.gay>2023-07-24 20:56:42 +0200
commit5495a35403d285007d67f503042720302efdf94c (patch)
tree3db4dc545ce3ad252f82a577ecad4150320c1ba3
parentChange max line width from 120 to 180 (diff)
downloadMatrixUtils-5495a35403d285007d67f503042720302efdf94c.tar.xz
Code cleanup
-rw-r--r--MatrixRoomUtils.Core/Extensions/ClassCollector.cs10
-rw-r--r--MatrixRoomUtils.Core/Extensions/HttpClientExtensions.cs28
-rw-r--r--MatrixRoomUtils.Core/Extensions/JsonElementExtensions.cs154
-rw-r--r--MatrixRoomUtils.Core/Filters/SyncFilter.cs68
-rw-r--r--MatrixRoomUtils.Core/Responses/CreateRoomRequest.cs2
-rw-r--r--MatrixRoomUtils.Core/Responses/StateEventResponse.cs20
-rw-r--r--MatrixRoomUtils.Core/StateEvent.cs23
-rw-r--r--MatrixRoomUtils.Core/StateEventStruct.cs12
-rw-r--r--MatrixRoomUtils.Core/StateEventTypes/Common/RoomEmotesEventData.cs16
-rw-r--r--MatrixRoomUtils.Core/StateEventTypes/Spec/RoomAvatarEventData.cs18
-rw-r--r--MatrixRoomUtils.Core/StateEventTypes/Spec/RoomCreateEventData.cs14
-rw-r--r--MatrixRoomUtils.Core/StateEventTypes/Spec/RoomPowerLevelEventData.cs (renamed from MatrixRoomUtils.Core/StateEventTypes/Spec/PowerLevelEventData.cs)16
-rw-r--r--MatrixRoomUtils.Web/Classes/RoomCreationTemplates/DefaultRoomCreationTemplate.cs4
-rw-r--r--MatrixRoomUtils.Web/Classes/RoomInfo.cs8
-rw-r--r--MatrixRoomUtils.Web/MatrixRoomUtils.Web.csproj1
-rw-r--r--MatrixRoomUtils.Web/Pages/About.razor30
-rw-r--r--MatrixRoomUtils.Web/Pages/Rooms/Index.razor72
-rw-r--r--MatrixRoomUtils.Web/Pages/Rooms/Space.razor (renamed from MatrixRoomUtils.Web/Pages/RoomManager/RoomManagerSpace.razor)4
-rw-r--r--MatrixRoomUtils.Web/Pages/Rooms/StateEditor.razor (renamed from MatrixRoomUtils.Web/Pages/RoomState/RoomStateEditorPage.razor)0
-rw-r--r--MatrixRoomUtils.Web/Pages/Rooms/StateViewer.razor (renamed from MatrixRoomUtils.Web/Pages/RoomState/RoomStateViewerPage.razor)0
-rw-r--r--MatrixRoomUtils.Web/Shared/RoomListComponents/RoomListCategory.razor16
-rw-r--r--MatrixRoomUtils.Web/Shared/RoomListComponents/RoomListSpace.razor18
-rw-r--r--MatrixRoomUtils.Web/Shared/RoomListItem.razor55
-rw-r--r--MatrixRoomUtils.Web/wwwroot/blobfox_outage.gifbin0 -> 3574 bytes
-rw-r--r--MatrixRoomUtils.Web/wwwroot/index.html2
-rw-r--r--MatrixRoomUtils.sln.DotSettings.user4
-rw-r--r--README.MD12
27 files changed, 376 insertions, 231 deletions
diff --git a/MatrixRoomUtils.Core/Extensions/ClassCollector.cs b/MatrixRoomUtils.Core/Extensions/ClassCollector.cs
index 9d3d3c0..d4ba838 100644
--- a/MatrixRoomUtils.Core/Extensions/ClassCollector.cs
+++ b/MatrixRoomUtils.Core/Extensions/ClassCollector.cs
@@ -19,12 +19,4 @@ public class ClassCollector<T> where T : class {
 
     public List<Type> ResolveFromAssembly(Assembly assembly) => assembly.GetTypes()
         .Where(x => x is { IsClass: true, IsAbstract: false } && x.GetInterfaces().Contains(typeof(T))).ToList();
-    // {
-    //     List<Type> ret = new();
-    //     foreach (var x in assembly.GetTypes().Where(x => x is { IsClass: true, IsAbstract: false } && x.GetInterfaces().Contains(typeof(T))).ToList()) {
-    //         // Console.WriteLine($"[!!] Found class {x.FullName}");
-    //         ret.Add(x);
-    //     }
-    //     return ret;
-    // }
-}
\ No newline at end of file
+}
diff --git a/MatrixRoomUtils.Core/Extensions/HttpClientExtensions.cs b/MatrixRoomUtils.Core/Extensions/HttpClientExtensions.cs
index 695e8e3..9ac9c6b 100644
--- a/MatrixRoomUtils.Core/Extensions/HttpClientExtensions.cs
+++ b/MatrixRoomUtils.Core/Extensions/HttpClientExtensions.cs
@@ -19,40 +19,42 @@ public static class HttpClientExtensions {
 }
 
 public class MatrixHttpClient : HttpClient {
-    public override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) {
-    Console.WriteLine($"Sending request to {request.RequestUri}");
-        try
-        {
-            HttpRequestOptionsKey<bool> WebAssemblyEnableStreamingResponseKey = new HttpRequestOptionsKey<bool>("WebAssemblyEnableStreamingResponse");
+    public override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
+        CancellationToken cancellationToken) {
+        Console.WriteLine($"Sending request to {request.RequestUri}");
+        try {
+            HttpRequestOptionsKey<bool> WebAssemblyEnableStreamingResponseKey =
+                new HttpRequestOptionsKey<bool>("WebAssemblyEnableStreamingResponse");
             request.Options.Set(WebAssemblyEnableStreamingResponseKey, true);
-            // var asm = Assembly.Load("Microsoft.AspNetCore.Components.WebAssembly");
-            // var browserHttpHandlerType = asm.GetType("Microsoft.AspNetCore.Components.WebAssembly.Http.WebAssemblyHttpRequestMessageExtensions", true);
-            // var browserHttpHandlerMethod = browserHttpHandlerType.GetMethod("SetBrowserResponseStreamingEnabled", BindingFlags.Public | BindingFlags.Static);
-            // browserHttpHandlerMethod?.Invoke(null, new object[] {request, true});
         }
-        catch (Exception e)
-        {
+        catch (Exception e) {
             Console.WriteLine("Failed to set browser response streaming:");
             Console.WriteLine(e);
         }
+
         var a = await base.SendAsync(request, cancellationToken);
         if (!a.IsSuccessStatusCode) {
             var content = await a.Content.ReadAsStringAsync(cancellationToken);
             if (content.StartsWith('{')) {
                 var ex = JsonSerializer.Deserialize<MatrixException>(content);
                 ex.RawContent = content;
-            Console.WriteLine($"Failed to send request: {ex}");
+                Console.WriteLine($"Failed to send request: {ex}");
                 if (ex?.RetryAfterMs is not null) {
                     await Task.Delay(ex.RetryAfterMs.Value, cancellationToken);
-                    typeof(HttpRequestMessage).GetField("_sendStatus", BindingFlags.NonPublic | BindingFlags.Instance)?.SetValue(request, 0);
+                    typeof(HttpRequestMessage).GetField("_sendStatus", BindingFlags.NonPublic | BindingFlags.Instance)
+                        ?.SetValue(request, 0);
                     return await SendAsync(request, cancellationToken);
                 }
+
                 throw ex!;
             }
+
             throw new InvalidDataException("Encountered invalid data:\n" + content);
         }
+
         return a;
     }
+
     // GetFromJsonAsync
     public async Task<T> GetFromJsonAsync<T>(string requestUri, CancellationToken cancellationToken = default) {
         var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
diff --git a/MatrixRoomUtils.Core/Extensions/JsonElementExtensions.cs b/MatrixRoomUtils.Core/Extensions/JsonElementExtensions.cs
index 36da644..7701c9e 100644
--- a/MatrixRoomUtils.Core/Extensions/JsonElementExtensions.cs
+++ b/MatrixRoomUtils.Core/Extensions/JsonElementExtensions.cs
@@ -3,37 +3,149 @@ using System.Reflection;
 using System.Text.Json;
 using System.Text.Json.Nodes;
 using System.Text.Json.Serialization;
+using MatrixRoomUtils.Core.Responses;
 
 namespace MatrixRoomUtils.Core.Extensions;
 
 public static class JsonElementExtensions {
-    public static bool FindExtraJsonElementFields([DisallowNull] this JsonElement? res, Type t) {
-        var props = t.GetProperties();
-        var unknownPropertyFound = false;
-        foreach (var field in res.Value.EnumerateObject()) {
-            if (props.Any(x => x.GetCustomAttribute<JsonPropertyNameAttribute>()?.Name == field.Name)) continue;
-            Console.WriteLine($"[!!] Unknown property {field.Name} in {t.Name}!");
+    public static bool FindExtraJsonElementFields(this JsonElement obj, Type objectType, string objectPropertyName) {
+        if (objectPropertyName == "content" && objectType == typeof(JsonObject))
+            objectType = typeof(StateEventResponse);
+        // if (t == typeof(JsonNode))
+        //     return false;
+
+        Console.WriteLine($"{objectType.Name} {objectPropertyName}");
+        bool unknownPropertyFound = false;
+        var mappedPropsDict = objectType.GetProperties()
+            .Where(x => x.GetCustomAttribute<JsonPropertyNameAttribute>() is not null)
+            .ToDictionary(x => x.GetCustomAttribute<JsonPropertyNameAttribute>()!.Name, x => x);
+        objectType.GetProperties().Where(x => !mappedPropsDict.ContainsKey(x.Name))
+            .ToList().ForEach(x => mappedPropsDict.TryAdd(x.Name, x));
+
+        foreach (var field in obj.EnumerateObject()) {
+            if (mappedPropsDict.TryGetValue(field.Name, out var mappedProperty)) {
+                //dictionary
+                if (mappedProperty.PropertyType.IsGenericType &&
+                    mappedProperty.PropertyType.GetGenericTypeDefinition() == typeof(Dictionary<,>)) {
+                    unknownPropertyFound |= _checkDictionary(field, objectType, mappedProperty.PropertyType);
+                    continue;
+                }
+
+                if (mappedProperty.PropertyType.IsGenericType &&
+                    mappedProperty.PropertyType.GetGenericTypeDefinition() == typeof(List<>)) {
+                    unknownPropertyFound |= _checkList(field, objectType, mappedProperty.PropertyType);
+                    continue;
+                }
+
+                if (field.Name == "content" && (objectType == typeof(StateEventResponse) || objectType == typeof(StateEvent))) {
+                    unknownPropertyFound |= field.FindExtraJsonPropertyFieldsByValueKind(
+                        StateEvent.GetStateEventType(obj.GetProperty("type").GetString()),
+                        mappedProperty.PropertyType);
+                    continue;
+                }
+
+                unknownPropertyFound |=
+                    field.FindExtraJsonPropertyFieldsByValueKind(objectType, mappedProperty.PropertyType);
+                continue;
+            }
+
+            Console.WriteLine($"[!!] Unknown property {field.Name} in {objectType.Name}!");
             unknownPropertyFound = true;
         }
 
-        if (unknownPropertyFound) Console.WriteLine(res.Value.ToJson());
-
         return unknownPropertyFound;
     }
-    public static bool FindExtraJsonObjectFields([DisallowNull] this JsonObject? res, Type t) {
-        var props = t.GetProperties();
-        var unknownPropertyFound = false;
-        foreach (var field in res) {
-            if (props.Any(x => x.GetCustomAttribute<JsonPropertyNameAttribute>()?.Name == field.Key)) continue;
-            Console.WriteLine($"[!!] Unknown property {field.Key} in {t.Name}!");
-            unknownPropertyFound = true;
-            // foreach (var propertyInfo in props) {
-            //     Console.WriteLine($"[!!] Known property {propertyInfo.GetCustomAttribute<JsonPropertyNameAttribute>()?.Name} in {t.Name}!");
-            // }
+
+    private static bool FindExtraJsonPropertyFieldsByValueKind(this JsonProperty field, Type containerType,
+        Type propertyType) {
+        if (propertyType.IsGenericType && propertyType.GetGenericTypeDefinition() == typeof(Nullable<>)) {
+            propertyType = propertyType.GetGenericArguments()[0];
         }
 
-        if (unknownPropertyFound) Console.WriteLine(res.ToJson());
+        bool switchResult = false;
+        switch (field.Value.ValueKind) {
+            case JsonValueKind.Array:
+                switchResult = field.Value.EnumerateArray().Aggregate(switchResult,
+                    (current, element) => current | element.FindExtraJsonElementFields(propertyType, field.Name));
+                break;
+            case JsonValueKind.Object:
+                switchResult |= field.Value.FindExtraJsonElementFields(propertyType, field.Name);
+                break;
+            case JsonValueKind.True:
+            case JsonValueKind.False:
+                return _checkBool(field, containerType, propertyType);
+            case JsonValueKind.String:
+                return _checkString(field, containerType, propertyType);
+            case JsonValueKind.Number:
+                return _checkNumber(field, containerType, propertyType);
+            case JsonValueKind.Undefined:
+            case JsonValueKind.Null:
+                break;
+            default:
+                throw new ArgumentOutOfRangeException();
+        }
 
-        return unknownPropertyFound;
+        return switchResult;
+    }
+
+    private static bool _checkBool(this JsonProperty field, Type containerType, Type propertyType) {
+        if (propertyType == typeof(bool)) return true;
+        Console.WriteLine(
+            $"[!!] Encountered bool for {field.Name} in {containerType.Name}, the class defines {propertyType.Name}!");
+        return false;
+    }
+
+    private static bool _checkString(this JsonProperty field, Type containerType, Type propertyType) {
+        if (propertyType == typeof(string)) return true;
+        // ReSharper disable once BuiltInTypeReferenceStyle
+        if (propertyType == typeof(String)) return true;
+        Console.WriteLine(
+            $"[!!] Encountered string for {field.Name} in {containerType.Name}, the class defines {propertyType.Name}!");
+        return false;
+    }
+
+    private static bool _checkNumber(this JsonProperty field, Type containerType, Type propertyType) {
+        if (propertyType == typeof(int) ||
+            propertyType == typeof(double) ||
+            propertyType == typeof(float) ||
+            propertyType == typeof(decimal) ||
+            propertyType == typeof(long) ||
+            propertyType == typeof(short) ||
+            propertyType == typeof(uint) ||
+            propertyType == typeof(ulong) ||
+            propertyType == typeof(ushort) ||
+            propertyType == typeof(byte) ||
+            propertyType == typeof(sbyte))
+            return true;
+        Console.WriteLine(
+            $"[!!] Encountered number for {field.Name} in {containerType.Name}, the class defines {propertyType.Name}!");
+        return false;
+    }
+
+    private static bool _checkDictionary(this JsonProperty field, Type containerType, Type propertyType) {
+        var keyType = propertyType.GetGenericArguments()[0];
+        var valueType = propertyType.GetGenericArguments()[1];
+        valueType = Nullable.GetUnderlyingType(valueType) ?? valueType;
+        Console.WriteLine(
+            $"Encountered dictionary {field.Name} with key type {keyType.Name} and value type {valueType.Name}!");
+
+        return field.Value.EnumerateObject()
+            .Where(key => !valueType.IsPrimitive && valueType != typeof(string))
+            .Aggregate(false, (current, key) =>
+                current | key.FindExtraJsonPropertyFieldsByValueKind(containerType, valueType)
+            );
+    }
+
+    private static bool _checkList(this JsonProperty field, Type containerType, Type propertyType) {
+        var valueType = propertyType.GetGenericArguments()[0];
+        valueType = Nullable.GetUnderlyingType(valueType) ?? valueType;
+        Console.WriteLine(
+            $"Encountered list {field.Name} with value type {valueType.Name}!");
+
+        return field.Value.EnumerateArray()
+            .Where(key => !valueType.IsPrimitive && valueType != typeof(string))
+            .Aggregate(false, (current, key) =>
+                current | key.FindExtraJsonElementFields(valueType, field.Name)
+            );
     }
-}
\ No newline at end of file
+}
diff --git a/MatrixRoomUtils.Core/Filters/SyncFilter.cs b/MatrixRoomUtils.Core/Filters/SyncFilter.cs
index 7957898..bc81101 100644
--- a/MatrixRoomUtils.Core/Filters/SyncFilter.cs
+++ b/MatrixRoomUtils.Core/Filters/SyncFilter.cs
@@ -4,47 +4,50 @@ namespace MatrixRoomUtils.Core.Filters;
 
 public class SyncFilter {
     [JsonPropertyName("account_data")]
-    public AccountDataFilter? AccountData { get; set; }
+    public EventFilter? AccountData { get; set; }
 
     [JsonPropertyName("presence")]
-    public PresenceFilter? Presence { get; set; }
+    public EventFilter? Presence { get; set; }
 
     [JsonPropertyName("room")]
     public RoomFilter? Room { get; set; }
-}
 
-public class PresenceFilter {
-    [JsonPropertyName("not_types")]
-    public List<string>? NotTypes { get; set; }
-}
+    public class RoomFilter {
+        [JsonPropertyName("account_data")]
+        public StateFilter? AccountData { get; set; }
 
-public class RoomFilter {
-    [JsonPropertyName("account_data")]
-    public AccountDataFilter? AccountData { get; set; }
+        [JsonPropertyName("ephemeral")]
+        public StateFilter? Ephemeral { get; set; }
 
-    [JsonPropertyName("ephemeral")]
-    public EphemeralFilter? Ephemeral { get; set; }
+        [JsonPropertyName("state")]
+        public StateFilter? State { get; set; }
 
-    public class EphemeralFilter {
-        [JsonPropertyName("not_types")]
-        public List<string>? NotTypes { get; set; }
-    }
+        [JsonPropertyName("timeline")]
+        public StateFilter? Timeline { get; set; }
 
-    [JsonPropertyName("state")]
-    public StateFilter? State { get; set; }
 
-    public class StateFilter {
-        [JsonPropertyName("lazy_load_members")]
-        public bool? LazyLoadMembers { get; set; }
+        public class StateFilter : EventFilter {
+            [JsonPropertyName("contains_url")]
+            public bool? ContainsUrl { get; set; }
 
-        [JsonPropertyName("types")]
-        public List<string>? Types { get; set; }
-    }
+            [JsonPropertyName("include_redundant_members")]
+            public bool? IncludeRedundantMembers { get; set; }
+
+            [JsonPropertyName("lazy_load_members")]
+            public bool? LazyLoadMembers { get; set; }
+
+            [JsonPropertyName("rooms")]
+            public List<string>? Rooms { get; set; }
+
+            [JsonPropertyName("not_rooms")]
+            public List<string>? NotRooms { get; set; }
 
-    [JsonPropertyName("timeline")]
-    public TimelineFilter? Timeline { get; set; }
+            [JsonPropertyName("unread_thread_notifications")]
+            public bool? UnreadThreadNotifications { get; set; }
+        }
+    }
 
-    public class TimelineFilter {
+    public class EventFilter {
         [JsonPropertyName("limit")]
         public int? Limit { get; set; }
 
@@ -53,10 +56,11 @@ public class RoomFilter {
 
         [JsonPropertyName("not_types")]
         public List<string>? NotTypes { get; set; }
+
+        [JsonPropertyName("senders")]
+        public List<string>? Senders { get; set; }
+
+        [JsonPropertyName("not_senders")]
+        public List<string>? NotSenders { get; set; }
     }
 }
-
-public class AccountDataFilter {
-    [JsonPropertyName("not_types")]
-    public List<string>? NotTypes { get; set; }
-}
\ No newline at end of file
diff --git a/MatrixRoomUtils.Core/Responses/CreateRoomRequest.cs b/MatrixRoomUtils.Core/Responses/CreateRoomRequest.cs
index be78a97..540a323 100644
--- a/MatrixRoomUtils.Core/Responses/CreateRoomRequest.cs
+++ b/MatrixRoomUtils.Core/Responses/CreateRoomRequest.cs
@@ -31,7 +31,7 @@ public class CreateRoomRequest {
     public string Visibility { get; set; } = null!;
 
     [JsonPropertyName("power_level_content_override")]
-    public PowerLevelEventData PowerLevelContentOverride { get; set; } = null!;
+    public RoomPowerLevelEventData PowerLevelContentOverride { get; set; } = null!;
 
     [JsonPropertyName("creation_content")]
     public JsonObject CreationContent { get; set; } = new();
diff --git a/MatrixRoomUtils.Core/Responses/StateEventResponse.cs b/MatrixRoomUtils.Core/Responses/StateEventResponse.cs
index 422a557..a7f9187 100644
--- a/MatrixRoomUtils.Core/Responses/StateEventResponse.cs
+++ b/MatrixRoomUtils.Core/Responses/StateEventResponse.cs
@@ -1,3 +1,4 @@
+using System.Text.Json.Nodes;
 using System.Text.Json.Serialization;
 using MatrixRoomUtils.Core.Interfaces;
 
@@ -25,20 +26,23 @@ public class StateEventResponse : StateEvent {
     [JsonPropertyName("replaces_state")]
     public string ReplacesState { get; set; }
 
-    [JsonPropertyName("prev_content")]
-    public dynamic PrevContent { get; set; }
-
     public class UnsignedData {
         [JsonPropertyName("age")]
         public ulong? Age { get; set; }
 
-        [JsonPropertyName("prev_content")]
-        public dynamic? PrevContent { get; set; }
-
         [JsonPropertyName("redacted_because")]
-        public dynamic? RedactedBecause { get; set; }
+        public object? RedactedBecause { get; set; }
 
         [JsonPropertyName("transaction_id")]
         public string? TransactionId { get; set; }
+
+        [JsonPropertyName("replaces_state")]
+        public string? ReplacesState { get; set; }
+
+        [JsonPropertyName("prev_sender")]
+        public string? PrevSender { get; set; }
+
+        [JsonPropertyName("prev_content")]
+        public JsonObject? PrevContent { get; set; }
     }
-}
\ No newline at end of file
+}
diff --git a/MatrixRoomUtils.Core/StateEvent.cs b/MatrixRoomUtils.Core/StateEvent.cs
index 1c0a1cf..785c637 100644
--- a/MatrixRoomUtils.Core/StateEvent.cs
+++ b/MatrixRoomUtils.Core/StateEvent.cs
@@ -14,6 +14,17 @@ public class StateEvent {
     public static List<Type> KnownStateEventTypes =
         new ClassCollector<IStateEventType>().ResolveFromAllAccessibleAssemblies();
 
+    public static Type GetStateEventType(string type) {
+        if (type == "m.receipt") {
+            return typeof(Dictionary<string, JsonObject>);
+        }
+
+        var eventType = KnownStateEventTypes.FirstOrDefault(x =>
+            x.GetCustomAttributes<MatrixEventAttribute>()?.Any(y => y.EventName == type) ?? false);
+
+        return eventType ?? typeof(object);
+    }
+
     public object TypedContent {
         get {
             try {
@@ -21,8 +32,9 @@ public class StateEvent {
             }
             catch (JsonException e) {
                 Console.WriteLine(e);
-                Console.WriteLine("Content:\n"+ObjectExtensions.ToJson(RawContent));
+                Console.WriteLine("Content:\n" + ObjectExtensions.ToJson(RawContent));
             }
+
             return null;
         }
         set => RawContent = JsonSerializer.Deserialize<JsonObject>(JsonSerializer.Serialize(value));
@@ -65,12 +77,7 @@ public class StateEvent {
     [JsonIgnore]
     public Type GetType {
         get {
-            if (Type == "m.receipt") {
-                return typeof(Dictionary<string, JsonObject>);
-            }
-
-            var type = KnownStateEventTypes.FirstOrDefault(x =>
-                x.GetCustomAttributes<MatrixEventAttribute>()?.Any(y => y.EventName == Type) ?? false);
+            var type = GetStateEventType(Type);
 
             //special handling for some types
             // if (type == typeof(RoomEmotesEventData)) {
@@ -94,7 +101,7 @@ public class StateEvent {
             //     }
             // }
 
-            return type ?? typeof(object);
+            return type;
         }
     }
 
diff --git a/MatrixRoomUtils.Core/StateEventStruct.cs b/MatrixRoomUtils.Core/StateEventStruct.cs
deleted file mode 100644
index cd301ac..0000000
--- a/MatrixRoomUtils.Core/StateEventStruct.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-namespace MatrixRoomUtils.Core;
-
-public struct StateEventStruct {
-    public object content { get; set; }
-    public long origin_server_ts { get; set; }
-    public string sender { get; set; }
-    public string state_key { get; set; }
-    public string type { get; set; }
-    public string event_id { get; set; }
-    public string user_id { get; set; }
-    public string replaces_state { get; set; }
-}
\ No newline at end of file
diff --git a/MatrixRoomUtils.Core/StateEventTypes/Common/RoomEmotesEventData.cs b/MatrixRoomUtils.Core/StateEventTypes/Common/RoomEmotesEventData.cs
index 633998c..c263a3a 100644
--- a/MatrixRoomUtils.Core/StateEventTypes/Common/RoomEmotesEventData.cs
+++ b/MatrixRoomUtils.Core/StateEventTypes/Common/RoomEmotesEventData.cs
@@ -2,25 +2,25 @@ using System.Text.Json.Serialization;
 using MatrixRoomUtils.Core.Extensions;
 using MatrixRoomUtils.Core.Interfaces;
 
-namespace MatrixRoomUtils.Core.StateEventTypes.Common; 
+namespace MatrixRoomUtils.Core.StateEventTypes.Common;
 
 [MatrixEvent(EventName = "im.ponies.room_emotes")]
 public class RoomEmotesEventData : IStateEventType {
     [JsonPropertyName("emoticons")]
     public Dictionary<string, EmoticonData>? Emoticons { get; set; }
-    
+
     [JsonPropertyName("images")]
     public Dictionary<string, EmoticonData>? Images { get; set; }
-    
+
     [JsonPropertyName("pack")]
     public PackInfo? Pack { get; set; }
-    
+
     public class EmoticonData {
         [JsonPropertyName("url")]
         public string? Url { get; set; }
     }
-}
 
-public class PackInfo {
-    
-}
\ No newline at end of file
+    public class PackInfo {
+
+    }
+}
diff --git a/MatrixRoomUtils.Core/StateEventTypes/Spec/RoomAvatarEventData.cs b/MatrixRoomUtils.Core/StateEventTypes/Spec/RoomAvatarEventData.cs
index bab297b..a14e4c5 100644
--- a/MatrixRoomUtils.Core/StateEventTypes/Spec/RoomAvatarEventData.cs
+++ b/MatrixRoomUtils.Core/StateEventTypes/Spec/RoomAvatarEventData.cs
@@ -2,17 +2,27 @@ using System.Text.Json.Serialization;
 using MatrixRoomUtils.Core.Extensions;
 using MatrixRoomUtils.Core.Interfaces;
 
-namespace MatrixRoomUtils.Core.StateEventTypes.Spec; 
+namespace MatrixRoomUtils.Core.StateEventTypes.Spec;
 
 [MatrixEvent(EventName = "m.room.avatar")]
 public class RoomAvatarEventData : IStateEventType {
     [JsonPropertyName("url")]
     public string? Url { get; set; }
-    
+
     [JsonPropertyName("info")]
     public RoomAvatarInfo? Info { get; set; }
 
     public class RoomAvatarInfo {
-        
+        [JsonPropertyName("h")]
+        public int? Height { get; set; }
+
+        [JsonPropertyName("w")]
+        public int? Width { get; set; }
+
+        [JsonPropertyName("mimetype")]
+        public string? MimeType { get; set; }
+
+        [JsonPropertyName("size")]
+        public int? Size { get; set; }
     }
-}
\ No newline at end of file
+}
diff --git a/MatrixRoomUtils.Core/StateEventTypes/Spec/RoomCreateEventData.cs b/MatrixRoomUtils.Core/StateEventTypes/Spec/RoomCreateEventData.cs
index 8b85d69..6127028 100644
--- a/MatrixRoomUtils.Core/StateEventTypes/Spec/RoomCreateEventData.cs
+++ b/MatrixRoomUtils.Core/StateEventTypes/Spec/RoomCreateEventData.cs
@@ -2,7 +2,7 @@ using System.Text.Json.Serialization;
 using MatrixRoomUtils.Core.Extensions;
 using MatrixRoomUtils.Core.Interfaces;
 
-namespace MatrixRoomUtils.Core.StateEventTypes.Spec; 
+namespace MatrixRoomUtils.Core.StateEventTypes.Spec;
 
 [MatrixEvent(EventName = "m.room.create")]
 public class RoomCreateEventData : IStateEventType {
@@ -16,6 +16,12 @@ public class RoomCreateEventData : IStateEventType {
     public RoomCreatePredecessor? Predecessor { get; set; }
     [JsonPropertyName("type")]
     public string? Type { get; set; }
-    
-    public class RoomCreatePredecessor { }
-}
\ No newline at end of file
+
+    public class RoomCreatePredecessor {
+        [JsonPropertyName("room_id")]
+        public string? RoomId { get; set; }
+
+        [JsonPropertyName("event_id")]
+        public string? EventId { get; set; }
+    }
+}
diff --git a/MatrixRoomUtils.Core/StateEventTypes/Spec/PowerLevelEventData.cs b/MatrixRoomUtils.Core/StateEventTypes/Spec/RoomPowerLevelEventData.cs
index 6846db4..1cde660 100644
--- a/MatrixRoomUtils.Core/StateEventTypes/Spec/PowerLevelEventData.cs
+++ b/MatrixRoomUtils.Core/StateEventTypes/Spec/RoomPowerLevelEventData.cs
@@ -5,7 +5,7 @@ using MatrixRoomUtils.Core.Interfaces;
 namespace MatrixRoomUtils.Core.StateEventTypes.Spec;
 
 [MatrixEvent(EventName = "m.room.power_levels")]
-public class PowerLevelEventData : IStateEventType {
+public class RoomPowerLevelEventData : IStateEventType {
     [JsonPropertyName("ban")]
     public int Ban { get; set; } // = 50;
 
@@ -35,14 +35,22 @@ public class PowerLevelEventData : IStateEventType {
 
     [JsonPropertyName("users_default")]
     public int UsersDefault { get; set; } // = 0;
-    
+
     [Obsolete("Historical was a key related to MSC2716, a spec change on backfill that was dropped!", true)]
     [JsonIgnore]
     [JsonPropertyName("historical")]
     public int Historical { get; set; } // = 50;
-    
+
     public class NotificationsPL {
         [JsonPropertyName("room")]
         public int Room { get; set; } = 50;
     }
-}
\ No newline at end of file
+
+    public bool IsUserAdmin(string userId) {
+        return Users.TryGetValue(userId, out var level) && level >= Events.Max(x=>x.Value);
+    }
+
+    public bool UserHasPermission(string userId, string eventType) {
+        return Users.TryGetValue(userId, out var level) && level >= Events.GetValueOrDefault(eventType, EventsDefault);
+    }
+}
diff --git a/MatrixRoomUtils.Web/Classes/RoomCreationTemplates/DefaultRoomCreationTemplate.cs b/MatrixRoomUtils.Web/Classes/RoomCreationTemplates/DefaultRoomCreationTemplate.cs
index a519977..f895173 100644
--- a/MatrixRoomUtils.Web/Classes/RoomCreationTemplates/DefaultRoomCreationTemplate.cs
+++ b/MatrixRoomUtils.Web/Classes/RoomCreationTemplates/DefaultRoomCreationTemplate.cs
@@ -48,7 +48,7 @@ public class DefaultRoomCreationTemplate : IRoomCreationTemplate {
                 }
             },
             Visibility = "public",
-            PowerLevelContentOverride = new PowerLevelEventData {
+            PowerLevelContentOverride = new RoomPowerLevelEventData {
                 UsersDefault = 0,
                 EventsDefault = 100,
                 StateDefault = 50,
@@ -56,7 +56,7 @@ public class DefaultRoomCreationTemplate : IRoomCreationTemplate {
                 Redact = 50,
                 Kick = 50,
                 Ban = 50,
-                NotificationsPl = new PowerLevelEventData.NotificationsPL {
+                NotificationsPl = new RoomPowerLevelEventData.NotificationsPL {
                     Room = 50
                 },
                 Events = new Dictionary<string, int> {
diff --git a/MatrixRoomUtils.Web/Classes/RoomInfo.cs b/MatrixRoomUtils.Web/Classes/RoomInfo.cs
index 5ecc431..f9d5452 100644
--- a/MatrixRoomUtils.Web/Classes/RoomInfo.cs
+++ b/MatrixRoomUtils.Web/Classes/RoomInfo.cs
@@ -1,13 +1,14 @@
 using MatrixRoomUtils.Core;
+using MatrixRoomUtils.Core.Interfaces;
 using MatrixRoomUtils.Core.Responses;
 using MatrixRoomUtils.Core.RoomTypes;
 
-namespace MatrixRoomUtils.Web.Classes; 
+namespace MatrixRoomUtils.Web.Classes;
 
 public class RoomInfo {
     public GenericRoom Room { get; set; }
     public List<StateEventResponse?> StateEvents { get; init; } = new();
-    
+
     public async Task<StateEventResponse?> GetStateEvent(string type, string stateKey = "") {
         var @event = StateEvents.FirstOrDefault(x => x.Type == type && x.StateKey == stateKey);
         if (@event is not null) return @event;
@@ -23,7 +24,8 @@ public class RoomInfo {
             if (e is { ErrorCode: "M_NOT_FOUND" }) @event.TypedContent = default!;
             else throw;
         }
+
         StateEvents.Add(@event);
         return @event;
     }
-}
\ No newline at end of file
+}
diff --git a/MatrixRoomUtils.Web/MatrixRoomUtils.Web.csproj b/MatrixRoomUtils.Web/MatrixRoomUtils.Web.csproj
index 13a120c..4b874c9 100644
--- a/MatrixRoomUtils.Web/MatrixRoomUtils.Web.csproj
+++ b/MatrixRoomUtils.Web/MatrixRoomUtils.Web.csproj
@@ -11,7 +11,6 @@
         <PackageReference Include="Blazored.SessionStorage" Version="2.3.0" />
         <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="7.0.7" />
         <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="7.0.7" PrivateAssets="all" />
-        <PackageReference Include="XtermBlazor" Version="1.9.0" />
     </ItemGroup>
 
     <ItemGroup>
diff --git a/MatrixRoomUtils.Web/Pages/About.razor b/MatrixRoomUtils.Web/Pages/About.razor
index 971bd9b..48c7686 100644
--- a/MatrixRoomUtils.Web/Pages/About.razor
+++ b/MatrixRoomUtils.Web/Pages/About.razor
@@ -3,7 +3,6 @@
 @using System.Net.Sockets
 @inject NavigationManager NavigationManager
 @inject ILocalStorageService LocalStorage
-@using XtermBlazor
 
 <PageTitle>About</PageTitle>
 
@@ -22,10 +21,6 @@
     <p>This deployment also serves a copy of the compiled, hosting-ready binaries at <a href="MRU-SRC.tar.xz">/MRU-SRC.tar.xz</a>!</p>
 }
 
-<Xterm @ref="_terminal" Options="_options" OnFirstRender="@OnFirstRender"  style="max-width: fit-content; overflow-x: hidden;"/>
-
-
-
 @code {
     private bool showBinDownload { get; set; }
     private bool showSrcDownload { get; set; }
@@ -39,29 +34,4 @@
         await base.OnInitializedAsync();
     }
 
-
-    private Xterm _terminal;
-
-    private TerminalOptions _options = new TerminalOptions
-    {
-        CursorBlink = true,
-        CursorStyle = CursorStyle.Block,
-        Theme =
-        {
-            Background = "#17615e",
-        },
-    };
-
-    private async Task OnFirstRender() {
-        var message = "Hello, World!\nThis is a terminal emulator!\n\nYou can type stuff here, and it will be sent to the server!\n\nThis is a test of the emergency broadcast system.\n\nThis is only a t";
-        _terminal.Options.RendererType = RendererType.Dom;
-        _terminal.Options.ScreenReaderMode = true;
-//        TcpClient.
-        for (var i = 0; i < message.Length; i++) {
-            await _terminal.Write(message[i].ToString());
-
-            await Task.Delay(50);
-            _terminal.Options.Theme.Background = $"#{(i * 2):X6}";
-        }
-    }
 }
diff --git a/MatrixRoomUtils.Web/Pages/Rooms/Index.razor b/MatrixRoomUtils.Web/Pages/Rooms/Index.razor
index 816299f..6d12dc2 100644
--- a/MatrixRoomUtils.Web/Pages/Rooms/Index.razor
+++ b/MatrixRoomUtils.Web/Pages/Rooms/Index.razor
@@ -13,45 +13,54 @@
 
 @code {
 
+    public List<RoomInfo> KnownRooms { get; set; } = new();
+
     private List<RoomInfo> Rooms { get; set; } = new();
     private ProfileResponseEventData GlobalProfile { get; set; }
 
-    protected override async Task OnInitializedAsync() {
-        var hs = await MRUStorage.GetCurrentSessionOrNavigate();
-        if (hs is null) return;
-        GlobalProfile = await hs.GetProfile(hs.WhoAmI.UserId);
-        var filter = new SyncFilter() {
+    private SyncFilter filter = new() {
+        AccountData = new() {
+            NotTypes = new() { "*" },
+            Limit = 1
+        },
+        Presence = new() {
+            NotTypes = new() { "*" },
+            Limit = 1
+        },
+        Room = new() {
             AccountData = new() {
-                NotTypes = new() { "*" }
+                NotTypes = new() { "*" },
+                Limit = 1
             },
-            Presence = new() {
-                NotTypes = new() { "*" }
+            Ephemeral = new() {
+                NotTypes = new() { "*" },
+                Limit = 1
             },
-            Room = new RoomFilter() {
-                AccountData = new() {
-                    NotTypes = new() { "*" }
-                },
-                Ephemeral = new() {
-                    NotTypes = new() { "*" }
-                },
-                State = new RoomFilter.StateFilter() {
-                    Types = new List<string>() {
-                        "m.room.name",
-                        "m.room.avatar",
-                        "m.room.create",
-                        "org.matrix.mjolnir.shortcode",
-                    }
-                },
-                Timeline = new() {
-                    NotTypes = new() { "*" },
-                    Limit = 1
+            State = new() {
+                Types = new List<string>() {
+                    "m.room.name",
+                    "m.room.avatar",
+                    "m.room.create",
+                    "org.matrix.mjolnir.shortcode",
+                    "m.room.power_levels"
                 }
+            },
+            Timeline = new() {
+                NotTypes = new() { "*" },
+                Limit = 1
             }
-        };
+        }
+    };
+
+    protected override async Task OnInitializedAsync() {
+        var hs = await MRUStorage.GetCurrentSessionOrNavigate();
+        if (hs is null) return;
+        GlobalProfile = await hs.GetProfile(hs.WhoAmI.UserId);
+
         Status = "Syncing...";
         SyncResult? sync = null;
         string? nextBatch = null;
-        while (sync is null or { Rooms.Join.Count: > 10}) {
+        while (sync is null or { Rooms.Join.Count: >= 1}) {
             sync = await hs.SyncHelper.Sync(since: nextBatch, filter: filter, timeout: 0);
             nextBatch = sync?.NextBatch ?? nextBatch;
             if (sync is null) continue;
@@ -70,11 +79,13 @@
                         StateEvents = new()
                     };
                     Rooms.Add(room);
+                    KnownRooms.Add(room);
                 }
                 room.StateEvents.AddRange(roomData.State.Events);
             }
-            Status = $"Got {Rooms.Count} rooms so far!";
+            Status = $"Got {Rooms.Count} rooms so far! Next batch: {nextBatch}";
             StateHasChanged();
+            await Task.Delay(100);
         }
         Console.WriteLine("Sync done!");
         Status = "Sync complete!";
@@ -103,8 +114,10 @@
         }
         Console.WriteLine("Set stub data!");
         Status = "Set stub data!";
+        SemaphoreSlim semaphore = new(8, 8);
         var memberTasks = Rooms.Select(async roomInfo => {
             if (!roomInfo.StateEvents.Any(x => x.Type == "m.room.member" && x.StateKey == hs.WhoAmI.UserId)) {
+                await semaphore.WaitAsync();
                 roomInfo.StateEvents.Add(new StateEventResponse() {
                     Type = "m.room.member",
                     StateKey = hs.WhoAmI.UserId,
@@ -112,6 +125,7 @@
                         Membership = "unknown"
                     }
                 });
+                semaphore.Release();
             }
         }).ToList();
         await Task.WhenAll(memberTasks);
diff --git a/MatrixRoomUtils.Web/Pages/RoomManager/RoomManagerSpace.razor b/MatrixRoomUtils.Web/Pages/Rooms/Space.razor
index afa39b9..91f97d0 100644
--- a/MatrixRoomUtils.Web/Pages/RoomManager/RoomManagerSpace.razor
+++ b/MatrixRoomUtils.Web/Pages/Rooms/Space.razor
@@ -1,4 +1,4 @@
-@page "/RoomManager/Space/{RoomId}"
+@page "/Rooms/{RoomId}/Space"
 @using System.Text.Json
 @using MatrixRoomUtils.Core.Responses
 <h3>Room manager - Viewing Space</h3>
@@ -93,4 +93,4 @@
         }
     }
 
-}
\ No newline at end of file
+}
diff --git a/MatrixRoomUtils.Web/Pages/RoomState/RoomStateEditorPage.razor b/MatrixRoomUtils.Web/Pages/Rooms/StateEditor.razor
index 8b2ff0c..8b2ff0c 100644
--- a/MatrixRoomUtils.Web/Pages/RoomState/RoomStateEditorPage.razor
+++ b/MatrixRoomUtils.Web/Pages/Rooms/StateEditor.razor
diff --git a/MatrixRoomUtils.Web/Pages/RoomState/RoomStateViewerPage.razor b/MatrixRoomUtils.Web/Pages/Rooms/StateViewer.razor
index 09b38f0..09b38f0 100644
--- a/MatrixRoomUtils.Web/Pages/RoomState/RoomStateViewerPage.razor
+++ b/MatrixRoomUtils.Web/Pages/Rooms/StateViewer.razor
diff --git a/MatrixRoomUtils.Web/Shared/RoomListComponents/RoomListCategory.razor b/MatrixRoomUtils.Web/Shared/RoomListComponents/RoomListCategory.razor
index 709f2d7..b798d49 100644
--- a/MatrixRoomUtils.Web/Shared/RoomListComponents/RoomListCategory.razor
+++ b/MatrixRoomUtils.Web/Shared/RoomListComponents/RoomListCategory.razor
@@ -6,13 +6,14 @@
     @foreach (var room in rooms) {
         <div class="room-list-item">
             <RoomListItem RoomInfo="@room" ShowOwnProfile="@(roomType == "Room")"></RoomListItem>
-            @if (RoomVersionDangerLevel(room) != 0) {
-                <MatrixRoomUtils.Web.Shared.SimpleComponents.LinkButton Color="@(RoomVersionDangerLevel(room) == 2 ? "#ff0000" : "#ff8800")" href="@($"/Rooms/Create?Import={room.Room.RoomId}")">Upgrade room</MatrixRoomUtils.Web.Shared.SimpleComponents.LinkButton>
-            }
+            @* @if (RoomVersionDangerLevel(room) != 0 && *@
+            @*      (room.StateEvents.FirstOrDefault(x=>x.Type == "m.room.power_levels")?.TypedContent is RoomPowerLevelEventData powerLevels && powerLevels.UserHasPermission(HomeServer.UserId, "m.room.tombstone"))) { *@
+            @*     <MatrixRoomUtils.Web.Shared.SimpleComponents.LinkButton Color="@(RoomVersionDangerLevel(room) == 2 ? "#ff0000" : "#ff8800")" href="@($"/Rooms/Create?Import={room.Room.RoomId}")">Upgrade room</MatrixRoomUtils.Web.Shared.SimpleComponents.LinkButton> *@
+            @* } *@
             <MatrixRoomUtils.Web.Shared.SimpleComponents.LinkButton href="@($"/Rooms/{room.Room.RoomId}/Timeline")">View timeline</MatrixRoomUtils.Web.Shared.SimpleComponents.LinkButton>
             <MatrixRoomUtils.Web.Shared.SimpleComponents.LinkButton href="@($"/Rooms/{room.Room.RoomId}/State/View")">View state</MatrixRoomUtils.Web.Shared.SimpleComponents.LinkButton>
             <MatrixRoomUtils.Web.Shared.SimpleComponents.LinkButton href="@($"/Rooms/{room.Room.RoomId}/State/Edit")">Edit state</MatrixRoomUtils.Web.Shared.SimpleComponents.LinkButton>
-            
+
             @if (roomType == "Space") {
                 <RoomListSpace Space="@room"></RoomListSpace>
             }
@@ -25,10 +26,13 @@
 
     [Parameter]
     public KeyValuePair<string, List<RoomInfo>> Category { get; set; }
-    
+
     [Parameter]
     public ProfileResponseEventData? GlobalProfile { get; set; }
 
+    [CascadingParameter]
+    public AuthenticatedHomeServer HomeServer { get; set; } = null!;
+
     private string roomType => Category.Key;
     private List<RoomInfo> rooms => Category.Value;
 
@@ -42,4 +46,4 @@
         return 0;
     }
 
-}
\ No newline at end of file
+}
diff --git a/MatrixRoomUtils.Web/Shared/RoomListComponents/RoomListSpace.razor b/MatrixRoomUtils.Web/Shared/RoomListComponents/RoomListSpace.razor
index 5153658..a113f0b 100644
--- a/MatrixRoomUtils.Web/Shared/RoomListComponents/RoomListSpace.razor
+++ b/MatrixRoomUtils.Web/Shared/RoomListComponents/RoomListSpace.razor
@@ -1,4 +1,4 @@
-<LinkButton href="@($"/Rooms/{Space.Room.RoomId}/Space")">Manage space</LinkButton>
+<MatrixRoomUtils.Web.Shared.SimpleComponents.LinkButton href="@($"/Rooms/{Space.Room.RoomId}/Space")">Manage space</MatrixRoomUtils.Web.Shared.SimpleComponents.LinkButton>
 
 <br/>
 <details @ontoggle="SpaceChildrenOpened">
@@ -17,6 +17,9 @@
     public RoomInfo Space { get; set; }
 
     [Parameter, CascadingParameter]
+    public List<RoomInfo> KnownRooms { get; set; } = new();
+
+    [Parameter, CascadingParameter]
     public string? Breadcrumbs {
         get => _breadcrumbs + Space.Room.RoomId;
         set => _breadcrumbs = value;
@@ -30,9 +33,14 @@
         var rooms = Space.Room.AsSpace.GetRoomsAsync();
         await foreach (var room in rooms) {
             if (Breadcrumbs.Contains(room.RoomId)) continue;
-            Children.Add(new() {
-                Room = room
-            });
+            RoomInfo roomInfo = KnownRooms.FirstOrDefault(x => x.Room.RoomId == room.RoomId);
+            if (roomInfo is null) {
+                roomInfo = new() {
+                    Room = room
+                };
+                KnownRooms.Add(roomInfo);
+            }
+            Children.Add(roomInfo);
         }
         await base.OnInitializedAsync();
     }
@@ -46,4 +54,4 @@
         Console.WriteLine($"[RoomList] Rendering children of {Space.Room.RoomId}");
     }
 
-}
\ No newline at end of file
+}
diff --git a/MatrixRoomUtils.Web/Shared/RoomListItem.razor b/MatrixRoomUtils.Web/Shared/RoomListItem.razor
index b89fb18..e12f622 100644
--- a/MatrixRoomUtils.Web/Shared/RoomListItem.razor
+++ b/MatrixRoomUtils.Web/Shared/RoomListItem.razor
@@ -32,7 +32,7 @@
 
     [Parameter]
     public GenericRoom? Room { get; set; }
-    
+
     [Parameter]
     public RoomInfo? RoomInfo { get; set; }
 
@@ -69,20 +69,32 @@
         if (Room is null && RoomId is null && RoomInfo is null) {
             throw new ArgumentNullException(nameof(RoomId));
         }
-        
+
         // sweep from roominfo to id
         if (RoomInfo is not null) Room = RoomInfo.Room;
         if(Room is not null) RoomId = Room.RoomId;
-        
+
         //sweep from id to roominfo
         if(RoomId is not null) Room ??= await hs.GetRoom(RoomId);
         if(Room is not null) RoomInfo ??= new RoomInfo() {
             Room = Room
         };
 
-        await CheckRoomVersion();
-        await GetRoomInfo();
-        await LoadOwnProfile();
+        try {
+            await CheckRoomVersion();
+            await GetRoomInfo();
+            await LoadOwnProfile();
+        }
+        catch (MatrixException e) {
+            if (e is not { ErrorCode: "M_FORBIDDEN" }) {
+                throw;
+            }
+            roomName = "Error: " + e.Message;
+            roomIcon = "/blobfox_outage.gif";
+        }
+        catch (Exception e) {
+            Console.WriteLine($"Failed to load room info for {RoomId}: {e.Message}");
+        }
         _semaphoreSlim.Release();
     }
 
@@ -104,24 +116,17 @@
     }
 
     private async Task CheckRoomVersion() {
-        try {
-            var ce = (await RoomInfo.GetStateEvent("m.room.create")).TypedContent as RoomCreateEventData;
-            if (int.TryParse(ce.RoomVersion, out var rv)) {
-                if (rv < 10)
-                    hasOldRoomVersion = true;
-            }
-            else // treat unstable room versions as dangerous
-                hasDangerousRoomVersion = true;
-
-            if (RoomConstants.DangerousRoomVersions.Contains(ce.RoomVersion)) {
-                hasDangerousRoomVersion = true;
-                roomName = "Dangerous room: " + roomName;
-            }
+        var ce = (await RoomInfo.GetStateEvent("m.room.create")).TypedContent as RoomCreateEventData;
+        if (int.TryParse(ce.RoomVersion, out var rv)) {
+            if (rv < 10)
+                hasOldRoomVersion = true;
         }
-        catch (MatrixException e) {
-            if (e is not { ErrorCode: "M_FORBIDDEN" }) {
-                throw;
-            }
+        else // treat unstable room versions as dangerous
+            hasDangerousRoomVersion = true;
+
+        if (RoomConstants.DangerousRoomVersions.Contains(ce.RoomVersion)) {
+            hasDangerousRoomVersion = true;
+            roomName = "Dangerous room: " + roomName;
         }
     }
 
@@ -132,7 +137,7 @@
             var state = (await RoomInfo.GetStateEvent("m.room.avatar")).TypedContent as RoomAvatarEventData;
             if (state?.Url is { } url) {
                 roomIcon = MediaResolver.ResolveMediaUri(hs.FullHomeServerDomain, url);
-                Console.WriteLine($"Got avatar for room {RoomId}: {roomIcon} ({url})");
+                // Console.WriteLine($"Got avatar for room {RoomId}: {roomIcon} ({url})");
             }
         }
         catch (MatrixException e) {
@@ -142,4 +147,4 @@
         }
     }
 
-}
\ No newline at end of file
+}
diff --git a/MatrixRoomUtils.Web/wwwroot/blobfox_outage.gif b/MatrixRoomUtils.Web/wwwroot/blobfox_outage.gif
new file mode 100644
index 0000000..6f1e2ae
--- /dev/null
+++ b/MatrixRoomUtils.Web/wwwroot/blobfox_outage.gif
Binary files differdiff --git a/MatrixRoomUtils.Web/wwwroot/index.html b/MatrixRoomUtils.Web/wwwroot/index.html
index 9a85530..0439e62 100644
--- a/MatrixRoomUtils.Web/wwwroot/index.html
+++ b/MatrixRoomUtils.Web/wwwroot/index.html
@@ -10,8 +10,6 @@
     <link href="css/app.css" rel="stylesheet"/>
     <link href="favicon.png" rel="icon" type="image/png"/>
     <link href="MatrixRoomUtils.Web.styles.css" rel="stylesheet"/>
-    <link href="_content/XtermBlazor/XtermBlazor.css" rel="stylesheet" />
-    <script src="_content/XtermBlazor/XtermBlazor.min.js"></script>
 </head>
 
 <body>
diff --git a/MatrixRoomUtils.sln.DotSettings.user b/MatrixRoomUtils.sln.DotSettings.user
index 785af7c..8e084e1 100644
--- a/MatrixRoomUtils.sln.DotSettings.user
+++ b/MatrixRoomUtils.sln.DotSettings.user
@@ -6,8 +6,8 @@
 	<s:Int64 x:Key="/Default/Environment/Hierarchy/Build/SolutionBuilderNext/ParallelProcessesCount2/@EntryValue">12</s:Int64>
 	
 	<s:Boolean x:Key="/Default/Environment/Hierarchy/Build/SolutionBuilderNext/ShouldRestoreNugetPackages/@EntryValue">True</s:Boolean>
-	<s:Boolean x:Key="/Default/UnloadedProject/UnloadedProjects/=244e90fe_002Dee26_002D4f78_002D86eb_002D27529ae48905_0023MatrixRoomUtils/@EntryIndexedValue">True</s:Boolean>
-	<s:Boolean x:Key="/Default/UnloadedProject/UnloadedProjects/=f997f26f_002D2ec1_002D4d18_002Db3dd_002Dc46fb2ad65c0_0023MatrixRoomUtils_002EWeb_002EServer/@EntryIndexedValue">True</s:Boolean>
+	
+	
 	
 	
 	
diff --git a/README.MD b/README.MD
index 7bf6942..4c0a94b 100644
--- a/README.MD
+++ b/README.MD
@@ -11,3 +11,15 @@ git format-patch --output-directory "./patches" @{u}..
 
 # Send patches
 ```
+
+### Developer utility commands
+
+Error reporting upon file save:
+```sh
+inotifywait -rmqe CLOSE_WRITE --include '.*\.cs$' . | while read l; do clear; dotnet build --property WarningLevel=0; done
+```
+
+Hot rebuild on file save:
+```sh
+dotnet watch run --no-hot-reload --property WarningLevel=0
+```