about summary refs log tree commit diff
diff options
context:
space:
mode:
authorRory& <root@rory.gay>2024-01-29 10:15:27 +0100
committerRory& <root@rory.gay>2024-01-29 10:15:27 +0100
commit3e6a73599bb58161c08d8675ea23ee6c82c6675c (patch)
treef3c0d1e797a77ed5993478d742751d386e004fb7
parentRoom member migrations (diff)
downloadMatrixUtils-3e6a73599bb58161c08d8675ea23ee6c82c6675c.tar.xz
Room list fixes, migration fix, update available handler
m---------LibMatrix0
-rw-r--r--MatrixUtils.Abstractions/RoomInfo.cs80
-rw-r--r--MatrixUtils.Web/Classes/RMUStorageWrapper.cs31
-rw-r--r--MatrixUtils.Web/MatrixUtils.Web.csproj1
-rw-r--r--MatrixUtils.Web/Pages/Index.razor20
-rw-r--r--MatrixUtils.Web/Pages/Rooms/Index.razor51
-rw-r--r--MatrixUtils.Web/Pages/Tools/Index.razor (renamed from MatrixUtils.Web/Pages/Tools/ToolsIndex.razor)2
-rw-r--r--MatrixUtils.Web/Pages/Tools/LeaveRoom.razor56
-rw-r--r--MatrixUtils.Web/Shared/MainLayout.razor4
-rw-r--r--MatrixUtils.Web/Shared/MxcImage.razor25
-rw-r--r--MatrixUtils.Web/Shared/RoomList.razor81
-rw-r--r--MatrixUtils.Web/Shared/RoomListComponents/RoomListCategory.razor11
-rw-r--r--MatrixUtils.Web/Shared/RoomListComponents/RoomListSpace.razor17
-rw-r--r--MatrixUtils.Web/Shared/RoomListItem.razor2
-rw-r--r--MatrixUtils.Web/Shared/UpdateAvailableDetector.razor38
-rw-r--r--MatrixUtils.Web/Shared/UpdateAvailableDetector.razor.css15
-rw-r--r--MatrixUtils.Web/wwwroot/index.html3
-rw-r--r--MatrixUtils.Web/wwwroot/service-worker.published.js35
-rw-r--r--MatrixUtils.Web/wwwroot/sw-registrator.js41
-rwxr-xr-xscripts/deploy.sh2
20 files changed, 375 insertions, 140 deletions
diff --git a/LibMatrix b/LibMatrix
-Subproject bf2da30c7ae9d4c15a5e22f3ee0b1bae2ca66e4
+Subproject b7dbc011e0eee55c011623d2747e517436d0410
diff --git a/MatrixUtils.Abstractions/RoomInfo.cs b/MatrixUtils.Abstractions/RoomInfo.cs
index 84a5940..0cd4dc1 100644
--- a/MatrixUtils.Abstractions/RoomInfo.cs
+++ b/MatrixUtils.Abstractions/RoomInfo.cs
@@ -13,42 +13,55 @@ public class RoomInfo : NotifyPropertyChanged {
     public ObservableCollection<StateEventResponse?> StateEvents { get; } = new();
 
     public async Task<StateEventResponse?> GetStateEvent(string type, string stateKey = "") {
-        var @event = StateEvents.FirstOrDefault(x => x.Type == type && x.StateKey == stateKey);
+        var @event = StateEvents.FirstOrDefault(x => x?.Type == type && x.StateKey == stateKey);
         if (@event is not null) return @event;
-        @event = new StateEventResponse {
-            RoomId = Room.RoomId,
-            Type = type,
-            StateKey = stateKey,
-            Sender = null, //TODO implement
-            EventId = null
-        };
-        // if (Room is null) return null;
+        // @event = new StateEventResponse {
+        //     RoomId = Room.RoomId,
+        //     Type = type,
+        //     StateKey = stateKey,
+        //     Sender = null, //TODO implement
+        //     EventId = null
+        // };
+        // // if (Room is null) return null;
+        // try {
+        //     @event.RawContent = await Room.GetStateAsync<JsonObject>(type, stateKey);
+        // }
+        // catch (MatrixException e) {
+        //     if (e is { ErrorCode: "M_NOT_FOUND" }) {
+        //         if (type == "m.room.name")
+        //             @event = new() {
+        //                 Type = type,
+        //                 StateKey = stateKey,
+        //                 TypedContent = new RoomNameEventContent() {
+        //                     Name = await Room.GetNameOrFallbackAsync()
+        //                 },
+        //                 //TODO implement
+        //                 RoomId = null,
+        //                 Sender = null,
+        //                 EventId = null
+        //             };
+        //         else
+        //             @event.RawContent = default!;
+        //     }
+        //     else {
+        //         throw;
+        //     }
+        // }
+        // catch (Exception e) {
+        //     await Task.Delay(1000);
+        //     return await GetStateEvent(type, stateKey);
+        // }
+
         try {
-            @event.RawContent = await Room.GetStateAsync<JsonObject>(type, stateKey);
+            @event = await Room.GetStateEventOrNullAsync(type, stateKey);
+            StateEvents.Add(@event);
         }
-        catch (MatrixException e) {
-            if (e is { ErrorCode: "M_NOT_FOUND" }) {
-                if (type == "m.room.name")
-                    @event = new() {
-                        Type = type,
-                        StateKey = stateKey,
-                        TypedContent = new RoomNameEventContent() {
-                            Name = await Room.GetNameOrFallbackAsync()
-                        },
-                        //TODO implement
-                        RoomId = null,
-                        Sender = null,
-                        EventId = null
-                    };
-                else
-                    @event.RawContent = default!;
-            }
-            else {
-                throw;
-            }
+        catch (Exception e) {
+            Console.Error.WriteLine(e);
+            await Task.Delay(1000);
+            return await GetStateEvent(type, stateKey);
         }
 
-        StateEvents.Add(@event);
         return @event;
     }
 
@@ -81,11 +94,14 @@ public class RoomInfo : NotifyPropertyChanged {
     private string? _roomCreator;
 
     public string? DefaultRoomName { get; set; }
+    public string? OverrideRoomType { get; set; }
+    public string? RoomType => OverrideRoomType ?? CreationEventContent?.Type;
 
     public RoomInfo() {
         StateEvents.CollectionChanged += (_, args) => {
             if (args.NewItems is { Count: > 0 })
-                foreach (StateEventResponse newState in args.NewItems) { // TODO: switch statement benchmark?
+                foreach (StateEventResponse? newState in args.NewItems) { // TODO: switch statement benchmark?
+                    if(newState is null) continue;
                     if (newState.Type == RoomNameEventContent.EventId && newState.TypedContent is RoomNameEventContent roomNameContent)
                         RoomName = roomNameContent.Name;
                     else if (newState is { Type: RoomAvatarEventContent.EventId, TypedContent: RoomAvatarEventContent roomAvatarContent })
diff --git a/MatrixUtils.Web/Classes/RMUStorageWrapper.cs b/MatrixUtils.Web/Classes/RMUStorageWrapper.cs
index 31e7734..fa79268 100644
--- a/MatrixUtils.Web/Classes/RMUStorageWrapper.cs
+++ b/MatrixUtils.Web/Classes/RMUStorageWrapper.cs
@@ -12,7 +12,7 @@ public class RMUStorageWrapper(TieredStorageService storageService, HomeserverPr
     }
 
     public async Task<UserAuth?> GetCurrentToken() {
-        var currentToken = await storageService.DataStorageProvider.LoadObjectAsync<UserAuth>("token");
+        var currentToken = await storageService.DataStorageProvider.LoadObjectAsync<UserAuth>("rmu.token");
         var allTokens = await GetAllTokens();
         if (allTokens is null or { Count: 0 }) {
             await SetCurrentToken(null);
@@ -94,5 +94,32 @@ public class RMUStorageWrapper(TieredStorageService storageService, HomeserverPr
         await storageService.DataStorageProvider.SaveObjectAsync("rmu.tokens", tokens);
     }
 
-    public async Task SetCurrentToken(UserAuth? auth) => await storageService.DataStorageProvider.SaveObjectAsync("token", auth);
+    public async Task SetCurrentToken(UserAuth? auth) => await storageService.DataStorageProvider.SaveObjectAsync("rmu.token", auth);
+
+    public async Task MigrateFromMRU() {
+        var dsp = storageService.DataStorageProvider!;
+        if(await dsp.ObjectExistsAsync("token")) {
+            var oldToken = await dsp.LoadObjectAsync<UserAuth>("token");
+            if (oldToken != null) {
+                await dsp.SaveObjectAsync("rmu.token", oldToken);
+                await dsp.DeleteObjectAsync("tokens");
+            }
+        }
+        
+        if(await dsp.ObjectExistsAsync("tokens")) {
+            var oldTokens = await dsp.LoadObjectAsync<List<UserAuth>>("tokens");
+            if (oldTokens != null) {
+                await dsp.SaveObjectAsync("rmu.tokens", oldTokens);
+                await dsp.DeleteObjectAsync("tokens");
+            }
+        }
+        
+        if(await dsp.ObjectExistsAsync("mru.tokens")) {
+            var oldTokens = await dsp.LoadObjectAsync<List<UserAuth>>("mru.tokens");
+            if (oldTokens != null) {
+                await dsp.SaveObjectAsync("rmu.tokens", oldTokens);
+                await dsp.DeleteObjectAsync("mru.tokens");
+            }
+        }
+    }
 }
diff --git a/MatrixUtils.Web/MatrixUtils.Web.csproj b/MatrixUtils.Web/MatrixUtils.Web.csproj
index d5977a0..515b235 100644
--- a/MatrixUtils.Web/MatrixUtils.Web.csproj
+++ b/MatrixUtils.Web/MatrixUtils.Web.csproj
@@ -9,6 +9,7 @@
         <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
 
         <UseBlazorWebAssembly>true</UseBlazorWebAssembly>
+        <BlazorEnableCompression>false</BlazorEnableCompression>
         <ServiceWorkerAssetsManifest>service-worker-assets.js</ServiceWorkerAssetsManifest>
 <!--        <RunAOTCompilation>true</RunAOTCompilation>-->
     </PropertyGroup>
diff --git a/MatrixUtils.Web/Pages/Index.razor b/MatrixUtils.Web/Pages/Index.razor
index 9c1bab6..e1cb60e 100644
--- a/MatrixUtils.Web/Pages/Index.razor
+++ b/MatrixUtils.Web/Pages/Index.razor
@@ -20,7 +20,7 @@ Small collection of tools to do not-so-everyday things.
             var _auth = session.UserAuth;
             <tr class="user-entry">
                 <td>
-                    <img class="avatar" src="@session.UserInfo.AvatarUrl"/>
+                    <img class="avatar" src="@session.UserInfo.AvatarUrl" crossorigin="anonymous"/>
                 </td>
                 <td class="user-info">
                     <p>
@@ -108,6 +108,16 @@ Small collection of tools to do not-so-everyday things.
         _sessions.Clear();
         _offlineSessions.Clear();
         var tokens = await RMUStorage.GetAllTokens();
+        if (tokens is not { Count: > 0 }) {
+            Console.WriteLine("No tokens found, trying migration from MRU...");
+            await RMUStorage.MigrateFromMRU();
+            tokens = await RMUStorage.GetAllTokens();
+            if (tokens is not { Count: > 0 }) {
+                Console.WriteLine("No tokens found");
+                return;
+            }
+        }
+
         var profileTasks = tokens.Select(async token => {
             UserInfo userInfo = new();
             AuthenticatedHomeserverGeneric hs;
@@ -119,14 +129,13 @@ Small collection of tools to do not-so-everyday things.
                 if (e.ErrorCode != "M_UNKNOWN_TOKEN") throw;
                 NavigationManager.NavigateTo("/InvalidSession?ctx=" + token.AccessToken);
                 return;
-
             }
             catch (Exception e) {
                 logger.LogError(e, $"Failed to instantiate AuthenticatedHomeserver for {token.ToJson()}, homeserver may be offline?", token.UserId);
                 _offlineSessions.Add(token);
                 return;
             }
-            
+
             Console.WriteLine($"Got hs for {token.ToJson()}");
 
             var roomCountTask = hs.GetJoinedRooms();
@@ -143,8 +152,8 @@ Small collection of tools to do not-so-everyday things.
                 ServerVersion = await (hs.FederationClient?.GetServerVersionAsync() ?? Task.FromResult<ServerVersionResponse?>(null)),
                 Homeserver = hs
             });
-        });
-        Console.WriteLine("Waiting for profile tasks");
+        }).ToList();
+        Console.WriteLine($"Waiting for {profileTasks.Count} profile tasks");
         await Task.WhenAll(profileTasks);
         Console.WriteLine("Done waiting for profile tasks");
         await base.OnInitializedAsync();
@@ -177,7 +186,6 @@ Small collection of tools to do not-so-everyday things.
         await OnInitializedAsync();
     }
 
-
     private async Task SwitchSession(UserAuth auth) {
         Console.WriteLine($"Switching to {auth.Homeserver} {auth.UserId} via {auth.Proxy}");
         await RMUStorage.SetCurrentToken(auth);
diff --git a/MatrixUtils.Web/Pages/Rooms/Index.razor b/MatrixUtils.Web/Pages/Rooms/Index.razor
index 0ec9487..170f489 100644
--- a/MatrixUtils.Web/Pages/Rooms/Index.razor
+++ b/MatrixUtils.Web/Pages/Rooms/Index.razor
@@ -18,7 +18,13 @@
 <RoomList Rooms="Rooms" GlobalProfile="@GlobalProfile" @bind-StillFetching="RenderContents"></RoomList>
 
 @code {
-    private ObservableCollection<RoomInfo> Rooms { get; } = new();
+    
+    private ObservableCollection<RoomInfo> _rooms = new();
+    private ObservableCollection<RoomInfo> Rooms {
+        get => _rooms;
+        set => _rooms = value;
+    }
+
     private UserProfileResponse GlobalProfile { get; set; }
 
     private AuthenticatedHomeserverGeneric? Homeserver { get; set; }
@@ -97,25 +103,29 @@
         if (Homeserver is null) return;
         var rooms = await Homeserver.GetJoinedRooms();
         // SemaphoreSlim _semaphore = new(160, 160);
+        GlobalProfile = await Homeserver.GetProfileAsync(Homeserver.WhoAmI.UserId);
 
-        var roomTasks = rooms.Select(async room => {
-            RoomInfo ri;
-            // await _semaphore.WaitAsync();
-            ri = new() { Room = room };
-            await Task.WhenAll((filter.Room?.State?.Types ?? []).Select(x => ri.GetStateEvent(x)));
-            return ri;
-        }).ToAsyncEnumerable();
-
-        await foreach (var room in roomTasks) {
-            Rooms.Add(room);
-            StateHasChanged();
-            // await Task.Delay(50);
-            // _semaphore.Release();
+        Rooms = new ObservableCollection<RoomInfo>(rooms.Select(x => new RoomInfo() { Room = x }));
+        foreach (var stateType in filter.Room?.State?.Types ?? []) {
+            var tasks = Rooms.Select(async room => {
+                try {
+                    
+                    await room.GetStateEvent(stateType);
+                }
+                catch (Exception e) {
+                    Console.WriteLine($"Failed to get state event {stateType} for room {room.Room.RoomId}: {e}");
+                }
+            });
+            await Task.WhenAll(tasks);
+            Status = $"Fetched all {stateType} events...";
+            // StateHasChanged();
         }
+        
 
-        if (rooms.Count >= 150) RenderContents = true;
-
-        GlobalProfile = await Homeserver.GetProfileAsync(Homeserver.WhoAmI.UserId);
+        RenderContents = true;
+        Status = "Initial fetch done! Starting initial sync...";
+        // StateHasChanged();
+        await Task.Delay(1000);
         syncHelper = new SyncHelper(Homeserver, logger) {
             Timeout = 30000,
             Filter = filter,
@@ -147,7 +157,7 @@
 
                 Console.WriteLine($"Queue no longer empty after {renderTimeSw.Elapsed}!");
 
-                int maxUpdates = 10;
+                int maxUpdates = 50;
                 isInitialSync = false;
                 while (maxUpdates-- > 0 && queue.TryDequeue(out var queueEntry)) {
                     var (roomId, roomData) = queueEntry;
@@ -176,6 +186,8 @@
                     else {
                         Console.WriteLine($"QueueWorker: could not merge state for {room.Room.RoomId} as new data contains no state events!");
                     }
+
+                    await Task.Delay(100);
                 }
                 Console.WriteLine($"QueueWorker: {queue.Count} entries left in queue, {maxUpdates} maxUpdates left, RenderContents: {RenderContents}");
                     Status = $"Got {Rooms.Count} rooms so far! {queue.Count} entries in processing queue...";
@@ -212,9 +224,10 @@
     }
 
     private Queue<KeyValuePair<string, SyncResponse.RoomsDataStructure.JoinedRoomDataStructure>> queue = new();
+    
 
     private async Task RunSyncLoop(SyncHelper syncHelper) {
-        Status = "Initial syncing...";
+        // Status = "Initial syncing...";
         Console.WriteLine("starting sync");
 
         var syncs = syncHelper.EnumerateSyncAsync();
diff --git a/MatrixUtils.Web/Pages/Tools/ToolsIndex.razor b/MatrixUtils.Web/Pages/Tools/Index.razor
index f4092d7..f1e04a3 100644
--- a/MatrixUtils.Web/Pages/Tools/ToolsIndex.razor
+++ b/MatrixUtils.Web/Pages/Tools/Index.razor
@@ -6,3 +6,5 @@
 <a href="/Tools/MassRoomJoin">Join room across all session</a><br/>
 <a href="/Tools/MediaLocator">Locate lost media</a><br/>
 <a href="/Tools/SpaceDebug">Debug space relationships</a><br/>
+<a href="/Tools/MigrateRoom">Migrate users from a split room to a new room</a><br/>
+<a href="/Tools/LeaveRoom">Leave room by ID</a><br/>
diff --git a/MatrixUtils.Web/Pages/Tools/LeaveRoom.razor b/MatrixUtils.Web/Pages/Tools/LeaveRoom.razor
new file mode 100644
index 0000000..b5df05f
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Tools/LeaveRoom.razor
@@ -0,0 +1,56 @@
+@page "/Tools/LeaveRoom"
+@using System.Diagnostics
+@using ArcaneLibs.Extensions
+@using LibMatrix.Homeservers
+@using LibMatrix.RoomTypes
+@using System.Collections.ObjectModel
+<h3>Leave room</h3>
+<hr/>
+<span>Room ID: </span>
+<InputText @bind-Value="@RoomId"></InputText>
+<br/>
+<LinkButton OnClick="@Leave">Leave</LinkButton>
+<br/><br/>
+@foreach (var line in Log) {
+    <p>@line</p>
+}
+@code {
+    AuthenticatedHomeserverGeneric? hs { get; set; }
+    ObservableCollection<string> Log { get; set; } = new ObservableCollection<string>();
+    [Parameter, SupplyParameterFromQuery(Name = "roomId")]
+    public string? RoomId { get; set; }
+
+    protected override async Task OnInitializedAsync() {
+        hs = await RMUStorage.GetCurrentSessionOrNavigate();
+        if (hs is null) return;
+        Log.CollectionChanged += (sender, args) => StateHasChanged();
+        
+        StateHasChanged();
+        Console.WriteLine("Rerendered!");
+        await base.OnInitializedAsync();
+    }
+
+    private async Task Leave() {
+        if(string.IsNullOrWhiteSpace(RoomId)) return;
+        var room = hs.GetRoom(RoomId);
+        Log.Add("Got room object...");
+        try {
+            await room.LeaveAsync();
+            Log.Add("Left room!");
+        }
+        catch (Exception e) {
+            Log.Add(e.ToString());
+        }
+
+        try {
+            await room.ForgetAsync();
+            Log.Add("Forgot room!");
+        }
+        catch (Exception e) {
+            Log.Add(e.ToString());
+        }
+
+        Log.Add("Done!");
+    }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Shared/MainLayout.razor b/MatrixUtils.Web/Shared/MainLayout.razor
index 92194b2..d8bf411 100644
--- a/MatrixUtils.Web/Shared/MainLayout.razor
+++ b/MatrixUtils.Web/Shared/MainLayout.razor
@@ -16,4 +16,6 @@
             @Body
         </article>
     </main>
-</div>
\ No newline at end of file
+</div>
+
+<UpdateAvailableDetector/>
\ No newline at end of file
diff --git a/MatrixUtils.Web/Shared/MxcImage.razor b/MatrixUtils.Web/Shared/MxcImage.razor
index fb8c248..f31c19f 100644
--- a/MatrixUtils.Web/Shared/MxcImage.razor
+++ b/MatrixUtils.Web/Shared/MxcImage.razor
@@ -1,4 +1,4 @@
-<img class="@Class" src="@ResolvedUri" style="@Style"/>
+<img src="@ResolvedUri" style="@StyleString"/>
 @code {
     private string _mxcUri;
     private string _style;
@@ -13,9 +13,14 @@
             UriHasChanged(value);
         }
     }
+    [Parameter]
+    public bool Circular { get; set; }
     
-    //mxcuri binding
+    [Parameter]
+    public int? Width { get; set; }
     
+    [Parameter]
+    public int? Height { get; set; }
     
     [Parameter]
     public string Style {
@@ -36,8 +41,18 @@
         }
     }
 
+    private string StyleString => $"{Style} {(Circular ? "border-radius: 50%;" : "")} {(Width.HasValue ? $"width: {Width}px;" : "")} {(Height.HasValue ? $"height: {Height}px;" : "")}";
+    
+    private static readonly string Prefix = "mxc://";
+    private static readonly int PrefixLength = Prefix.Length;
+
     private async Task UriHasChanged(string value) {
-        var uri = value[5..].Split('/');
+        if (!value.StartsWith(Prefix)) {
+            Console.WriteLine($"UriHasChanged: {value} does not start with {Prefix}, passing as resolved URI!!!");
+            ResolvedUri = value;
+            return;
+        }
+        var uri = value[PrefixLength..].Split('/');
         Console.WriteLine($"UriHasChanged: {value} {uri[0]}");
         if (Homeserver is null) {
             Console.WriteLine($"Homeserver is null, creating new remotehomeserver for {uri[0]}");
@@ -47,7 +62,7 @@
         Console.WriteLine($"ResolvedUri: {ResolvedUri}");
     }
 
-    [Parameter]
-    public string Class { get; set; }
+    // [Parameter]
+    // public string Class { get; set; }
 
 }
\ No newline at end of file
diff --git a/MatrixUtils.Web/Shared/RoomList.razor b/MatrixUtils.Web/Shared/RoomList.razor
index ed443dd..2ab3cef 100644
--- a/MatrixUtils.Web/Shared/RoomList.razor
+++ b/MatrixUtils.Web/Shared/RoomList.razor
@@ -20,9 +20,24 @@ else {
 }
 
 @code {
+    private ObservableCollection<RoomInfo> _rooms;
 
     [Parameter]
-    public ObservableCollection<RoomInfo> Rooms { get; set; }
+    public ObservableCollection<RoomInfo> Rooms {
+        get => _rooms;
+        set {
+            if(_rooms != value)
+                value.CollectionChanged += (_, args) => {
+                    foreach (RoomInfo item in args.NewItems??(object[])[]) {
+                        item.PropertyChanged += (_, args2) => {
+                            if (args2.PropertyName == nameof(item.CreationEventContent))
+                                StateHasChanged();
+                        };
+                    }
+                };
+            _rooms = value;
+        }
+    }
 
     [Parameter]
     public UserProfileResponse? GlobalProfile { get; set; }
@@ -33,65 +48,21 @@ else {
     [Parameter]
     public EventCallback<bool> StillFetchingChanged { get; set; }
 
-    private Dictionary<string, List<RoomInfo>> RoomsWithTypes => Rooms is null ? new() : Rooms.GroupBy(x => GetRoomTypeName(x.CreationEventContent?.Type)).ToDictionary(x => x.Key, x => x.ToList());
-
-    private bool hooked;
-    protected override async Task OnParametersSetAsync() {
-        var hs = await RMUStorage.GetCurrentSessionOrNavigate();
-        if (hs is null) return;
-        if (!hooked) {
-            Rooms.CollectionChanged += (_, args) => {
-                foreach (RoomInfo item in args.NewItems) {
-                    item.PropertyChanged += (_, args2) => {
-                        // Console.WriteLine(args2);
-                        
-                        if (args2.PropertyName == nameof(item.CreationEventContent))
-                            StateHasChanged();
-                    };
-                }
-            };
-            hooked = true;
-        }
-
-        // GlobalProfile ??= await hs.GetProfileAsync(hs.WhoAmI.UserId);
-
-        await base.OnParametersSetAsync();
-    }
+    
+    private Dictionary<string, List<RoomInfo>> RoomsWithTypes => Rooms is null ? new() : Rooms.GroupBy(x => GetRoomTypeName(x.RoomType)).ToDictionary(x => x.Key, x => x.ToList());
 
     private string GetRoomTypeName(string? roomType) => roomType switch {
         null => "Room",
         "m.space" => "Space",
         "msc3588.stories.stories-room" => "Story room",
-        "support.feline.policy.lists.msc.v1" => "MSC3784 Policy list (v1)",
+        "support.feline.policy.lists.msc.v1" => "MSC3784 policy list (v1)",
+        // custom names
+        "gay.rory.moderation_bot.policy_room" => "Rory&::ModerationBot policy room",
+        "gay.rory.moderation_bot.log_room" => "Rory&::ModerationBot log room",
+        "gay.rory.moderation_bot.control_room" => "Rory&::ModerationBot control room",
+        // fallback
+        "gay.rory.rmu.fallback.policy_list" => "\"Legacy\" policy list (unmarked room)",
         _ => roomType
-        };
-
-    // private static SemaphoreSlim _semaphoreSlim = new(8, 8);
-
-    // private async Task ProcessRoom(RoomInfo room) {
-    //     await _semaphoreSlim.WaitAsync();
-    //     string roomType;
-    //     try {
-    //         var createEvent = (await room.GetStateEvent("m.room.create")).TypedContent as RoomCreateEventContent;
-    //         roomType = GetRoomTypeName(createEvent.Type);
-    //
-    //         if (roomType == "Room") {
-    //             var mjolnirData = await room.GetStateEvent("org.matrix.mjolnir.shortcode");
-    //             if (mjolnirData?.RawContent?.ToJson(ignoreNull: true) is not null and not "{}")
-    //                 roomType = "Legacy policy room";
-    //         }
-    //     }
-    //     catch (MatrixException e) {
-    //         roomType = $"Error: {e.ErrorCode}";
-    //     }
-    //
-    //     // if (!RoomsWithTypes.ContainsKey(roomType)) {
-    //         // RoomsWithTypes.Add(roomType, new List<RoomInfo>());
-    //     // }
-    //     // RoomsWithTypes[roomType].Add(room);
-    //
-    //     StateHasChanged();
-    //     _semaphoreSlim.Release();
-    // }
+    };
 
 }
\ No newline at end of file
diff --git a/MatrixUtils.Web/Shared/RoomListComponents/RoomListCategory.razor b/MatrixUtils.Web/Shared/RoomListComponents/RoomListCategory.razor
index 3d0070f..4b24c18 100644
--- a/MatrixUtils.Web/Shared/RoomListComponents/RoomListCategory.razor
+++ b/MatrixUtils.Web/Shared/RoomListComponents/RoomListCategory.razor
@@ -16,6 +16,7 @@
             <LinkButton href="@($"/Rooms/{room.Room.RoomId}/Timeline")">View timeline</LinkButton>
             <LinkButton href="@($"/Rooms/{room.Room.RoomId}/State/View")">View state</LinkButton>
             <LinkButton href="@($"/Rooms/{room.Room.RoomId}/State/Edit")">Edit state</LinkButton>
+            <LinkButton href="@($"/Tools/LeaveRoom?roomId={room.Room.RoomId}")" Color="#FF0000">Leave room</LinkButton>
 
             @if (room.CreationEventContent?.Type == "m.space") {
                 <RoomListSpace Space="@room"></RoomListSpace>
@@ -49,15 +50,5 @@
             : RoomConstants.DangerousRoomVersions.Contains(roomVersionContent.RoomVersion) ? 2
                 : roomVersionContent.RoomVersion != RoomConstants.RecommendedRoomVersion ? 1 : 0;
     }
-    
-    public static string GetRoomTypeName(string roomType) {
-        return roomType switch {
-            null => "Room",
-            "m.space" => "Space",
-            "org.matrix.mjolnir.policy" => "Policy room",
-            
-            _ => roomType
-        };
-    }
 
 }
diff --git a/MatrixUtils.Web/Shared/RoomListComponents/RoomListSpace.razor b/MatrixUtils.Web/Shared/RoomListComponents/RoomListSpace.razor
index 895d642..9c481e3 100644
--- a/MatrixUtils.Web/Shared/RoomListComponents/RoomListSpace.razor
+++ b/MatrixUtils.Web/Shared/RoomListComponents/RoomListSpace.razor
@@ -1,15 +1,23 @@
 @using System.Collections.ObjectModel
 @using MatrixUtils.Abstractions
-<MatrixUtils.Web.Shared.SimpleComponents.LinkButton href="@($"/Rooms/{Space.Room.RoomId}/Space")">Manage space</MatrixUtils.Web.Shared.SimpleComponents.LinkButton>
+<LinkButton href="@($"/Rooms/{Space.Room.RoomId}/Space")">Manage space</LinkButton>
 
 <br/>
 <details @ontoggle="SpaceChildrenOpened">
     <summary>@Children.Count children</summary>
     @if (_shouldRenderChildren) {
         <p>Breadcrumb: @Breadcrumbs</p>
+        <p>Joined:</p>
         <div style="margin-left: 8px;">
             <RoomList Rooms="Children"></RoomList>
         </div>
+        <p>Unjoined:</p>
+        @foreach (var room in Unjoined) {
+            <p>@room.Room.RoomId</p>
+        }
+        @* <div style="margin-left: 8px;"> *@
+        @*     <RoomList Rooms="Children"></RoomList> *@
+        @* </div> *@
     }
 </details>
 
@@ -28,11 +36,14 @@
     }
 
     private ObservableCollection<RoomInfo> Children { get; set; } = new();
+    private Collection<RoomInfo> Unjoined { get; set; } = new();
 
     protected override async Task OnInitializedAsync() {
         if (Breadcrumbs == null) throw new ArgumentNullException(nameof(Breadcrumbs));
         await Task.Delay(Random.Shared.Next(1000, 10000));
         var rooms = Space.Room.AsSpace.GetChildrenAsync();
+        var hs = await RMUStorage.GetCurrentSessionOrNavigate();
+        var joinedRooms = await hs.GetJoinedRooms();
         await foreach (var room in rooms) {
             if (Breadcrumbs.Contains(room.RoomId)) continue;
             var roomInfo = KnownRooms.FirstOrDefault(x => x.Room.RoomId == room.RoomId);
@@ -42,7 +53,9 @@
                 };
                 KnownRooms.Add(roomInfo);
             }
-            Children.Add(roomInfo);
+            if(joinedRooms.Any(x=>x.RoomId == room.RoomId))
+                Children.Add(roomInfo);
+            else Unjoined.Add(roomInfo);
         }
         await base.OnInitializedAsync();
     }
diff --git a/MatrixUtils.Web/Shared/RoomListItem.razor b/MatrixUtils.Web/Shared/RoomListItem.razor
index 1046dd1..2e7a372 100644
--- a/MatrixUtils.Web/Shared/RoomListItem.razor
+++ b/MatrixUtils.Web/Shared/RoomListItem.razor
@@ -17,7 +17,7 @@
             </span>
             <span class="centerVertical noLeftPadding">-></span>
         }
-        <MxcImage Class="avatar32" MxcUri="@RoomInfo.RoomIcon" Style="@(ChildContent is not null ? "vertical-align: middle;" : "")"/>
+        <MxcImage class="avatar32" MxcUri="@RoomInfo.RoomIcon" Style="@(ChildContent is not null ? "vertical-align: middle;" : "")"/>
         <div class="inlineBlock">
             <span class="centerVertical">@RoomInfo.RoomName</span>
             @if (ChildContent is not null) {
diff --git a/MatrixUtils.Web/Shared/UpdateAvailableDetector.razor b/MatrixUtils.Web/Shared/UpdateAvailableDetector.razor
new file mode 100644
index 0000000..5197a6f
--- /dev/null
+++ b/MatrixUtils.Web/Shared/UpdateAvailableDetector.razor
@@ -0,0 +1,38 @@
+@* Source: https://whuysentruit.medium.com/blazor-wasm-pwa-adding-a-new-update-available-notification-d9f65c4ad13 *@
+@inject IJSRuntime _jsRuntime
+
+@if (_newVersionAvailable)
+{
+    <button type="button" class="btn btn-warning shadow floating-update-button" onclick="window.location.reload()">
+        A new version of the application is available. Click here to reload.
+    </button>
+}
+
+@code {
+
+    private bool _newVersionAvailable = false;
+
+    protected override async Task OnInitializedAsync()
+    {
+        await RegisterForUpdateAvailableNotification();
+    }
+
+    private async Task RegisterForUpdateAvailableNotification()
+    {
+        await _jsRuntime.InvokeAsync<object>(
+            identifier: "registerForUpdateAvailableNotification",
+            DotNetObjectReference.Create(this),
+            nameof(OnUpdateAvailable));
+    }
+
+    [JSInvokable(nameof(OnUpdateAvailable))]
+    public Task OnUpdateAvailable()
+    {
+        _newVersionAvailable = true;
+
+        StateHasChanged();
+
+        return Task.CompletedTask;
+    }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Shared/UpdateAvailableDetector.razor.css b/MatrixUtils.Web/Shared/UpdateAvailableDetector.razor.css
new file mode 100644
index 0000000..32bff09
--- /dev/null
+++ b/MatrixUtils.Web/Shared/UpdateAvailableDetector.razor.css
@@ -0,0 +1,15 @@
+.floating-update-button {
+    position: fixed;
+
+    right: 2rem;
+    bottom: 2rem;
+
+    padding: 1rem 1.5rem;
+
+    animation: fadein 2s ease-out;
+}
+
+@keyframes fadein {
+    from { right: -100%; }
+    to { right: 2rem; }
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/wwwroot/index.html b/MatrixUtils.Web/wwwroot/index.html
index a40f38c..5182193 100644
--- a/MatrixUtils.Web/wwwroot/index.html
+++ b/MatrixUtils.Web/wwwroot/index.html
@@ -59,7 +59,8 @@
             }
         </script>
         <script src="_framework/blazor.webassembly.js"></script>
-        <script>navigator.serviceWorker.register('service-worker.js');</script>
+<!--        <script>navigator.serviceWorker.register('service-worker.js');</script>-->
+        <script src="sw-registrator.js"></script>
     </body>
 
 </html>
diff --git a/MatrixUtils.Web/wwwroot/service-worker.published.js b/MatrixUtils.Web/wwwroot/service-worker.published.js
index 003e3e7..9219755 100644
--- a/MatrixUtils.Web/wwwroot/service-worker.published.js
+++ b/MatrixUtils.Web/wwwroot/service-worker.published.js
@@ -8,8 +8,10 @@ self.addEventListener('fetch', event => event.respondWith(onFetch(event)));
 

 const cacheNamePrefix = 'offline-cache-';

 const cacheName = `${cacheNamePrefix}${self.assetsManifest.version}`;

-const offlineAssetsInclude = [ /\.dll$/, /\.pdb$/, /\.wasm/, /\.html/, /\.js$/, /\.json$/, /\.css$/, /\.woff$/, /\.png$/, /\.jpe?g$/, /\.gif$/, /\.ico$/, /\.blat$/, /\.dat$/ ];

-const offlineAssetsExclude = [ /^service-worker\.js$/ ];

+const offlineAssetsInclude = [// Standard resources

+    /\.dll$/, /\.pdb$/, /\.wasm/, /\.html/, /\.js$/, /\.json$/, /\.css$/, /\.woff$/, /\.png$/, /\.jpe?g$/, /\.gif$/, /\.ico$/, /\.blat$/, /\.dat$/, /* Extra known-static paths */

+    /\/_matrix\/media\/.{2}\/download\//, /api\.dicebear\.com\/6\.x\/identicon\/svg/];

+const offlineAssetsExclude = [/^service-worker\.js$/];

 

 // Replace with your base path if you are hosting on a subfolder. Ensure there is a trailing '/'.

 const base = "/";

@@ -19,11 +21,14 @@ const manifestUrlList = self.assetsManifest.assets.map(asset => new URL(asset.ur
 async function onInstall(event) {

     console.info('Service worker: Install');

 

+    // Activate the new service worker as soon as the old one is retired.

+    self.skipWaiting();

+

     // Fetch and cache all matching items from the assets manifest

     const assetsRequests = self.assetsManifest.assets

         .filter(asset => offlineAssetsInclude.some(pattern => pattern.test(asset.url)))

         .filter(asset => !offlineAssetsExclude.some(pattern => pattern.test(asset.url)))

-        .map(asset => new Request(asset.url, { integrity: asset.hash, cache: 'no-cache' }));

+        .map(asset => new Request(asset.url, {integrity: asset.hash, cache: 'no-cache'}));

     await caches.open(cacheName).then(cache => cache.addAll(assetsRequests));

 }

 

@@ -43,12 +48,32 @@ async function onFetch(event) {
         // For all navigation requests, try to serve index.html from cache,

         // unless that request is for an offline resource.

         // If you need some URLs to be server-rendered, edit the following check to exclude those URLs

-        const shouldServeIndexHtml = event.request.mode === 'navigate'

-            && !manifestUrlList.some(url => url === event.request.url);

+        const shouldServeIndexHtml = event.request.mode === 'navigate' && !manifestUrlList.some(url => url === event.request.url);

 

         const request = shouldServeIndexHtml ? 'index.html' : event.request;

+        const shouldCache = offlineAssetsInclude.some(pattern => pattern.test(request.url));

+

         const cache = await caches.open(cacheName);

         cachedResponse = await cache.match(request);

+        let exception;

+        let fetched;

+        if (!cachedResponse && shouldCache) {

+            console.log("Service worker caching: fetching ", request.url)

+            try {

+                fetched = true;

+                await cache.add(request);

+                cachedResponse = await cache.match(request);

+            } catch (e) {

+                exception = e;

+                console.error("cache.add error: ", e, request.url)

+            }

+        }

+        let consoleLog = {

+            fetched, shouldCache, request, exception, cachedResponse, url: request.url,

+        }

+        Object.keys(consoleLog).forEach(key => consoleLog[key] == null && delete consoleLog[key])

+        if(consoleLog.exception)

+            console.log("Service worker caching: ", consoleLog)

     }

 

     return cachedResponse || fetch(event.request);

diff --git a/MatrixUtils.Web/wwwroot/sw-registrator.js b/MatrixUtils.Web/wwwroot/sw-registrator.js
new file mode 100644
index 0000000..94b96b2
--- /dev/null
+++ b/MatrixUtils.Web/wwwroot/sw-registrator.js
@@ -0,0 +1,41 @@
+// source: https://whuysentruit.medium.com/blazor-wasm-pwa-adding-a-new-update-available-notification-d9f65c4ad13
+
+window.updateAvailable = new Promise((resolve, reject) => {
+    if (!('serviceWorker' in navigator)) {
+        const errorMessage = `This browser doesn't support service workers`;
+        console.error(errorMessage);
+        reject(errorMessage);
+        return;
+    }
+
+    navigator.serviceWorker.register('/service-worker.js')
+        .then(registration => {
+            console.info(`Service worker registration successful (scope: ${registration.scope})`);
+
+            // detect updates every minute
+            setInterval(() => {
+                registration.update();
+            }, 5 * 1000); // 60000ms -> check each minute
+
+            registration.onupdatefound = () => {
+                const installingServiceWorker = registration.installing;
+                installingServiceWorker.onstatechange = () => {
+                    if (installingServiceWorker.state === 'installed') {
+                        resolve(!!navigator.serviceWorker.controller);
+                    }
+                }
+            };
+        })
+        .catch(error => {
+            console.error('Service worker registration failed with error:', error);
+            reject(error);
+        });
+});
+
+window.registerForUpdateAvailableNotification = (caller, methodName) => {
+    window.updateAvailable.then(isUpdateAvailable => {
+        if (isUpdateAvailable) {
+            caller.invokeMethodAsync(methodName).then();
+        }
+    });
+};
\ No newline at end of file
diff --git a/scripts/deploy.sh b/scripts/deploy.sh
index f7117b1..4c60728 100755
--- a/scripts/deploy.sh
+++ b/scripts/deploy.sh
@@ -11,4 +11,4 @@ BASE_DIR=`pwd`
 rm -rf **/bin/Release
 cd MatrixUtils.Web
 dotnet publish -c Release
-rsync -raP bin/Release/net8.0/publish/wwwroot/ rory.gay:/data/nginx/html_mru/
\ No newline at end of file
+rsync -raP bin/Release/net8.0/publish/wwwroot/ rory.gay:/data/nginx/html_mru/