about summary refs log tree commit diff
path: root/MatrixUtils.Web
diff options
context:
space:
mode:
Diffstat (limited to 'MatrixUtils.Web')
-rw-r--r--MatrixUtils.Web/MatrixUtils.Web.csproj1
-rw-r--r--MatrixUtils.Web/Pages/Dev/DevUtilities.razor6
-rw-r--r--MatrixUtils.Web/Pages/HSEInit.razor2
-rw-r--r--MatrixUtils.Web/Pages/Index.razor58
-rw-r--r--MatrixUtils.Web/Pages/Moderation/DraupnirProtectedRoomsEditor.razor10
-rw-r--r--MatrixUtils.Web/Pages/Moderation/UserRoomHistory.razor4
-rw-r--r--MatrixUtils.Web/Pages/Rooms/Index.razor16
-rw-r--r--MatrixUtils.Web/Pages/Rooms/Index2.razor85
-rw-r--r--MatrixUtils.Web/Pages/Rooms/Index2Components/MainTabComponents/MainTabSpaceItem.razor27
-rw-r--r--MatrixUtils.Web/Pages/Rooms/Index2Components/MainTabComponents/MainTabSpaceItem.razor.css15
-rw-r--r--MatrixUtils.Web/Pages/Rooms/Index2Components/RoomsIndex2DMsTab.razor53
-rw-r--r--MatrixUtils.Web/Pages/Rooms/Index2Components/RoomsIndex2MainTab.razor198
-rw-r--r--MatrixUtils.Web/Pages/Rooms/Index2Components/RoomsIndex2MainTab.razor.css0
-rw-r--r--MatrixUtils.Web/Pages/Rooms/Index2Components/RoomsIndex2SyncContainer.razor202
-rw-r--r--MatrixUtils.Web/Pages/Tools/PolicyListActivity.razor158
-rw-r--r--MatrixUtils.Web/Pages/Tools/UserTrace.razor29
-rw-r--r--MatrixUtils.Web/Pages/User/DMManager.razor22
-rw-r--r--MatrixUtils.Web/Pages/User/DMSpace.razor55
-rw-r--r--MatrixUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage0.razor2
-rw-r--r--MatrixUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage1.razor149
-rw-r--r--MatrixUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage2.razor116
-rw-r--r--MatrixUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage3.razor142
-rw-r--r--MatrixUtils.Web/Pages/User/Profile.razor3
-rw-r--r--MatrixUtils.Web/Shared/ActivityGraph.razor148
-rw-r--r--MatrixUtils.Web/Shared/ActivityGraph.razor.css16
-rw-r--r--MatrixUtils.Web/Shared/MainLayout.razor4
-rw-r--r--MatrixUtils.Web/Shared/MxcImage.razor3
-rw-r--r--MatrixUtils.Web/Shared/RoomListComponents/RoomListSpace.razor4
-rw-r--r--MatrixUtils.Web/Shared/UserListItem.razor6
29 files changed, 1239 insertions, 295 deletions
diff --git a/MatrixUtils.Web/MatrixUtils.Web.csproj b/MatrixUtils.Web/MatrixUtils.Web.csproj
index 515b235..dfb4713 100644
--- a/MatrixUtils.Web/MatrixUtils.Web.csproj
+++ b/MatrixUtils.Web/MatrixUtils.Web.csproj
@@ -11,6 +11,7 @@
         <UseBlazorWebAssembly>true</UseBlazorWebAssembly>
         <BlazorEnableCompression>false</BlazorEnableCompression>
         <ServiceWorkerAssetsManifest>service-worker-assets.js</ServiceWorkerAssetsManifest>
+        <BlazorCacheBootResources>false</BlazorCacheBootResources>
 <!--        <RunAOTCompilation>true</RunAOTCompilation>-->
     </PropertyGroup>
 
diff --git a/MatrixUtils.Web/Pages/Dev/DevUtilities.razor b/MatrixUtils.Web/Pages/Dev/DevUtilities.razor
index 611d4c1..87416a2 100644
--- a/MatrixUtils.Web/Pages/Dev/DevUtilities.razor
+++ b/MatrixUtils.Web/Pages/Dev/DevUtilities.razor
@@ -12,9 +12,9 @@
 else {
     <details>
         <summary>Room List</summary>
-        @foreach (var room in Rooms) {
-            <a style="color: unset; text-decoration: unset;" href="/RoomStateViewer/@room.Replace('.', '~')">
-                <RoomListItem RoomInfo="@(new RoomInfo() { Room = hs.GetRoom(room) })" LoadData="true"></RoomListItem>
+        @foreach (var roomId in Rooms) {
+            <a style="color: unset; text-decoration: unset;" href="/RoomStateViewer/@roomId.Replace('.', '~')">
+                <RoomListItem RoomInfo="@(new RoomInfo(hs.GetRoom(roomId)))" LoadData="true"></RoomListItem>
             </a>
         }
     </details>
diff --git a/MatrixUtils.Web/Pages/HSEInit.razor b/MatrixUtils.Web/Pages/HSEInit.razor
index 3020ff7..b2fc0db 100644
--- a/MatrixUtils.Web/Pages/HSEInit.razor
+++ b/MatrixUtils.Web/Pages/HSEInit.razor
@@ -6,7 +6,7 @@
 @code {
     protected override async Task OnInitializedAsync() {
         await base.OnInitializedAsync();
-        var tasks = Enumerable.Range(0, 5000).Select(i => Login()).ToList();
+        var tasks = Enumerable.Range(0, 50).Select(i => Login()).ToList();
         await Task.WhenAll(tasks);
         Console.WriteLine("All logins complete!");
         var userAuths = tasks.Select(t => t.Result).Where(t => t != null).ToList();
diff --git a/MatrixUtils.Web/Pages/Index.razor b/MatrixUtils.Web/Pages/Index.razor
index 0c0c87a..f216488 100644
--- a/MatrixUtils.Web/Pages/Index.razor
+++ b/MatrixUtils.Web/Pages/Index.razor
@@ -3,6 +3,8 @@
 @using LibMatrix.Responses
 @using LibMatrix
 @using ArcaneLibs.Extensions
+@using ArcaneLibs
+@using System.Diagnostics
 
 <PageTitle>Index</PageTitle>
 
@@ -12,7 +14,10 @@ Small collection of tools to do not-so-everyday things.
 <br/><br/>
 <h5>@totalSessions signed in sessions - <a href="/Login">Add new account</a></h5>
 @if (scannedSessions != totalSessions) {
-    <progress max="@totalSessions" value="@scannedSessions"></progress>
+    <span>
+        <span>@scannedSessions/@totalSessions</span>
+        <progress max="@totalSessions" value="@scannedSessions"></progress>
+    </span>
 }
 <hr/>
 <form>
@@ -103,6 +108,7 @@ Small collection of tools to do not-so-everyday things.
     private readonly List<UserAuth> _offlineSessions = [];
     private LoginResponse? _currentSession;
     int scannedSessions = 0, totalSessions = 1;
+    private SvgIdenticonGenerator _identiconGenerator = new();
 
     protected override async Task OnInitializedAsync() {
         Console.WriteLine("Index.OnInitializedAsync");
@@ -124,6 +130,7 @@ Small collection of tools to do not-so-everyday things.
 
         List<string> offlineServers = [];
         var sema = new SemaphoreSlim(64, 64);
+        var updateSw = Stopwatch.StartNew();
         var tasks = tokens.Select(async token => {
             await sema.WaitAsync();
             scannedSessions++;
@@ -141,7 +148,7 @@ Small collection of tools to do not-so-everyday things.
                 var serverVersionTask = hs.FederationClient?.GetServerVersionAsync();
                 _sessions.Add(new() {
                     UserInfo = new() {
-                        AvatarUrl = "/blobfox_outage.gif",
+                        AvatarUrl = string.IsNullOrWhiteSpace((await profileTask).AvatarUrl) ? _identiconGenerator.GenerateAsDataUri(hs.WhoAmI.UserId) : hs.ResolveMediaUri((await profileTask).AvatarUrl),
                         RoomCount = (await joinedRoomsTask).Count,
                         DisplayName = (await profileTask).DisplayName ?? hs.WhoAmI.UserId
                     },
@@ -149,6 +156,10 @@ Small collection of tools to do not-so-everyday things.
                     ServerVersion = await (serverVersionTask ?? Task.FromResult<ServerVersionResponse?>(null)!),
                     Homeserver = hs
                 });
+                if (updateSw.ElapsedMilliseconds > 250) {
+                    updateSw.Restart();
+                    StateHasChanged();
+                }
             }
             catch (MatrixException e) {
                 if (e is { ErrorCode: "M_UNKNOWN_TOKEN" }) _offlineSessions.Add(token);
@@ -166,50 +177,9 @@ Small collection of tools to do not-so-everyday things.
             }
 
             sema.Release();
-
-            StateHasChanged();
         }).ToList();
         await Task.WhenAll(tasks);
-
-        // var profileTasks = tokens.Select(async token => {
-        //     UserInfo userInfo = new();
-        //     AuthenticatedHomeserverGeneric hs;
-        //     Console.WriteLine($"Getting hs for {token.ToJson()}");
-        //     try {
-        //         hs = await hsProvider.GetAuthenticatedWithToken(token.Homeserver, token.AccessToken, token.Proxy);
-        //     }
-        //     catch (MatrixException e) {
-        //         if (e.ErrorCode != "M_UNKNOWN_TOKEN") throw;
-        //         _offlineSessions.Add(token);
-        //         return;
-        //         NavigationManager.NavigateTo("/InvalidSession?ctx=" + token.AccessToken);
-        //     }
-        //     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();
-        //     var profile = await hs.GetProfileAsync(hs.WhoAmI.UserId);
-        //     userInfo.DisplayName = profile.DisplayName ?? hs.WhoAmI.UserId;
-        //     Console.WriteLine(profile.ToJson());
-        //     _sessions.Add(new() {
-        //         UserInfo = new() {
-        //             AvatarUrl = string.IsNullOrWhiteSpace(profile.AvatarUrl) ? "/blobfox_outage.gif" : hs.ResolveMediaUri(profile.AvatarUrl),
-        //             RoomCount = (await roomCountTask).Count,
-        //             DisplayName = profile.DisplayName ?? hs.WhoAmI.UserId
-        //         },
-        //         UserAuth = token,
-        //         ServerVersion = await (hs.FederationClient?.GetServerVersionAsync() ?? Task.FromResult<ServerVersionResponse?>(null)),
-        //         Homeserver = hs
-        //     });
-        // }).ToList();
-        // Console.WriteLine($"Waiting for {profileTasks.Count} profile tasks");
-        // await Task.WhenAll(profileTasks);
-        // Console.WriteLine("Done waiting for profile tasks");
+        
         await base.OnInitializedAsync();
     }
 
diff --git a/MatrixUtils.Web/Pages/Moderation/DraupnirProtectedRoomsEditor.razor b/MatrixUtils.Web/Pages/Moderation/DraupnirProtectedRoomsEditor.razor
index 3cb9e40..f9cbfa2 100644
--- a/MatrixUtils.Web/Pages/Moderation/DraupnirProtectedRoomsEditor.razor
+++ b/MatrixUtils.Web/Pages/Moderation/DraupnirProtectedRoomsEditor.razor
@@ -59,7 +59,7 @@
         if (hs is null) return;
         data = await hs.GetAccountDataAsync<DraupnirProtectedRoomsData>("org.matrix.mjolnir.protected_rooms");
         StateHasChanged();
-        foreach (var room in await hs.GetJoinedRooms()) {
+        var tasks = (await hs.GetJoinedRooms()).Select(async room => {
             var plTask = room.GetPowerLevelsAsync();
             var roomNameTask = room.GetNameOrFallbackAsync();
             var EditorRoomInfo = new EditorRoomInfo {
@@ -71,7 +71,11 @@
 
             Rooms.Add(EditorRoomInfo);
             StateHasChanged();
-        }
+            return Task.CompletedTask;
+        }).ToList();
+        await Task.WhenAll(tasks);
+        await Task.Delay(500);
+        StateHasChanged();
     }
 
     private class DraupnirProtectedRoomsData {
@@ -87,7 +91,7 @@
     }
 
     private async Task Apply() {
-        Console.WriteLine(string.Join('\n', Rooms.Where(x=>x.IsProtected).Select(x=>x.Room.RoomId)));
+        Console.WriteLine(string.Join('\n', Rooms.Where(x => x.IsProtected).Select(x => x.Room.RoomId)));
         data.Rooms = Rooms.Where(x => x.IsProtected).Select(x => x.Room.RoomId).ToList();
         await hs.SetAccountDataAsync("org.matrix.mjolnir.protected_rooms", data);
     }
diff --git a/MatrixUtils.Web/Pages/Moderation/UserRoomHistory.razor b/MatrixUtils.Web/Pages/Moderation/UserRoomHistory.razor
index 775361a..9218c8c 100644
--- a/MatrixUtils.Web/Pages/Moderation/UserRoomHistory.razor
+++ b/MatrixUtils.Web/Pages/Moderation/UserRoomHistory.razor
@@ -82,9 +82,7 @@ else {
                 if (state is null) continue;
                 if (!matchingStates.ContainsKey(state.Membership))
                     matchingStates.Add(state.Membership, new());
-                var roomInfo = new RoomInfo() {
-                    Room = room
-                };
+                var roomInfo = new RoomInfo(room);
                 matchingStates[state.Membership].Add(roomInfo);
                 roomInfo.StateEvents.Add(new() {
                     Type = RoomNameEventContent.EventId,
diff --git a/MatrixUtils.Web/Pages/Rooms/Index.razor b/MatrixUtils.Web/Pages/Rooms/Index.razor
index 1813908..d7a3569 100644
--- a/MatrixUtils.Web/Pages/Rooms/Index.razor
+++ b/MatrixUtils.Web/Pages/Rooms/Index.razor
@@ -69,14 +69,14 @@
     protected override async Task OnInitializedAsync() {
         Homeserver = await RMUStorage.GetCurrentSessionOrNavigate();
         if (Homeserver is null) return;
-        var rooms = await Homeserver.GetJoinedRooms();
+        // var rooms = await Homeserver.GetJoinedRooms();
         // SemaphoreSlim _semaphore = new(160, 160);
         GlobalProfile = await Homeserver.GetProfileAsync(Homeserver.WhoAmI.UserId);
 
         var filter = await Homeserver.GetOrUploadNamedFilterIdAsync(CommonSyncFilters.GetBasicRoomInfo);
         var filterData = await Homeserver.GetFilterAsync(filter);
         
-        Rooms = new ObservableCollection<RoomInfo>(rooms.Select(x => new RoomInfo() { Room = x }));
+        // Rooms = new ObservableCollection<RoomInfo>(rooms.Select(room => new RoomInfo(room)));
         // foreach (var stateType in filterData.Room?.State?.Types ?? []) {
         //     var tasks = Rooms.Select(async room => {
         //         try {
@@ -126,7 +126,7 @@
 
                 Console.WriteLine($"Queue no longer empty after {renderTimeSw.Elapsed}!");
 
-                int maxUpdates = 50;
+                int maxUpdates = 50000;
                 isInitialSync = false;
                 while (maxUpdates-- > 0 && queue.TryDequeue(out var queueEntry)) {
                     var (roomId, roomData) = queueEntry;
@@ -139,9 +139,7 @@
                     }
                     else {
                         Console.WriteLine($"QueueWorker: encountered new room {roomId}!");
-                        room = new RoomInfo() {
-                            Room = Homeserver.GetRoom(roomId)
-                        };
+                        room = new RoomInfo(Homeserver.GetRoom(roomId), roomData.State?.Events);
                         Rooms.Add(room);
                     }
 
@@ -156,14 +154,14 @@
                         Console.WriteLine($"QueueWorker: could not merge state for {room.Room.RoomId} as new data contains no state events!");
                     }
 
-                    await Task.Delay(100);
+                    // 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...";
 
-                RenderContents |= queue.Count == 0;
-                await Task.Delay(Rooms.Count);
+                // RenderContents |= queue.Count == 0;
+                // await Task.Delay(Rooms.Count);
             }
             catch (Exception e) {
                 Console.WriteLine("QueueWorker exception: " + e);
diff --git a/MatrixUtils.Web/Pages/Rooms/Index2.razor b/MatrixUtils.Web/Pages/Rooms/Index2.razor
new file mode 100644
index 0000000..ae31126
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Rooms/Index2.razor
@@ -0,0 +1,85 @@
+@page "/Rooms2"
+@using LibMatrix.Responses
+@using System.Collections.ObjectModel
+@using System.ComponentModel
+@using MatrixUtils.Abstractions
+@using MatrixUtils.Web.Pages.Rooms.Index2Components
+@inject ILogger<Index> logger
+<h3>Room list</h3>
+
+<RoomsIndex2SyncContainer Data="@Data"></RoomsIndex2SyncContainer>
+@if (Data.Homeserver is null || Data.GlobalProfile is null) {
+    <p>Creating homeserver instance and fetching global profile...</p>
+    return;
+}
+
+<div>
+    <LinkButton Color="@(SelectedTab == Tab.Main ? null : "#0b0e62")" OnClick="() => Task.FromResult(SelectedTab = Tab.Main)">Main</LinkButton>
+    <LinkButton Color="@(SelectedTab == Tab.DMs ? null : "#0b0e62")" OnClick="() => Task.FromResult(SelectedTab = Tab.DMs)">DMs</LinkButton>
+    <LinkButton Color="@(SelectedTab == Tab.ByRoomType ? null : "#0b0e62")" OnClick="() => Task.FromResult(SelectedTab = Tab.ByRoomType)">By room type</LinkButton>
+</div>
+<br/>
+<CascadingValue Value="@Data">
+    @switch (SelectedTab) {
+        case Tab.Main:
+            <h3>Main tab</h3>
+            <RoomsIndex2MainTab></RoomsIndex2MainTab>
+            break;
+        case Tab.DMs:
+            <h3>DMs tab</h3>
+            break;
+        case Tab.ByRoomType:
+            <h3>By room type tab</h3>
+            break;
+        default:
+            throw new InvalidEnumArgumentException();
+    }
+</CascadingValue>
+<br/>
+
+@* <LinkButton href="/Rooms/Create">Create new room</LinkButton> *@
+
+
+@code {
+
+    private Tab SelectedTab {
+        get => _selectedTab;
+        set {
+            _selectedTab = value;
+            StateHasChanged();
+        }
+    }
+
+    public RoomListViewData Data { get; set; } = new RoomListViewData();
+
+    protected override async Task OnInitializedAsync() {
+        Data.Homeserver = await RMUStorage.GetCurrentSessionOrNavigate();
+        if (Data.Homeserver is null) return;
+        var rooms = await Data.Homeserver.GetJoinedRooms();
+        Data.GlobalProfile = await Data.Homeserver.GetProfileAsync(Data.Homeserver.WhoAmI.UserId);
+        
+        foreach (var room in rooms) {
+            Data.Rooms.Add(new RoomInfo(room));
+        }
+        StateHasChanged();
+        
+        await base.OnInitializedAsync();
+    }
+
+    private Tab _selectedTab = Tab.Main;
+
+    private enum Tab {
+        Main,
+        DMs,
+        ByRoomType
+    }
+
+    public class RoomListViewData {
+        public ObservableCollection<RoomInfo> Rooms { get; } = [];
+
+        public UserProfileResponse? GlobalProfile { get; set; }
+
+        public AuthenticatedHomeserverGeneric? Homeserver { get; set; }
+    }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Rooms/Index2Components/MainTabComponents/MainTabSpaceItem.razor b/MatrixUtils.Web/Pages/Rooms/Index2Components/MainTabComponents/MainTabSpaceItem.razor
new file mode 100644
index 0000000..4216824
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Rooms/Index2Components/MainTabComponents/MainTabSpaceItem.razor
@@ -0,0 +1,27 @@
+@using MatrixUtils.Abstractions
+<div class="spaceListItem" onclick="@ToggleSpace">
+    <MxcImage Circular="true" Height="32" Width="32" Homeserver="Space.Room.Homeserver" MxcUri="@Space.RoomIcon"></MxcImage>
+    <span class="spaceNameEllipsis">@Space.RoomName</span>
+</div>
+
+@code {
+
+    [Parameter]
+    public RoomInfo Space { get; set; }
+
+    [Parameter]
+    public List<RoomInfo> OpenedSpaces { get; set; }
+    
+    protected override Task OnInitializedAsync() {
+        Space.PropertyChanged += (sender, args) => { StateHasChanged(); };
+        return base.OnInitializedAsync();
+    }
+
+    public void ToggleSpace() {
+        if (OpenedSpaces.Contains(Space)) {
+            OpenedSpaces.Remove(Space);
+        } else {
+            OpenedSpaces.Add(Space);
+        }
+    }
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Rooms/Index2Components/MainTabComponents/MainTabSpaceItem.razor.css b/MatrixUtils.Web/Pages/Rooms/Index2Components/MainTabComponents/MainTabSpaceItem.razor.css
new file mode 100644
index 0000000..c174567
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Rooms/Index2Components/MainTabComponents/MainTabSpaceItem.razor.css
@@ -0,0 +1,15 @@
+.spaceNameEllipsis {
+    padding-left: 8px;
+    display: inline-block;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    vertical-align: middle;
+    width: calc(100% - 38px);
+}
+
+.spaceListItem {
+    display: block;
+    width: 100%;
+    height: 50px;
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Rooms/Index2Components/RoomsIndex2DMsTab.razor b/MatrixUtils.Web/Pages/Rooms/Index2Components/RoomsIndex2DMsTab.razor
new file mode 100644
index 0000000..f4cf849
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Rooms/Index2Components/RoomsIndex2DMsTab.razor
@@ -0,0 +1,53 @@
+@using MatrixUtils.Abstractions
+@using System.Security.Cryptography
+@using ArcaneLibs.Extensions
+<h3>RoomsIndex2MainTab</h3>
+
+<div>
+    <div class="row">
+        <div class="col-3" style="background-color: #ffffff66;">
+            <LinkButton>Uncategorised rooms</LinkButton>
+            @foreach (var space in Data.Rooms.Where(x => x.RoomType == "m.space")) {
+                <div style="@("width: 100%; height: 50px; background-color: #" + RandomNumberGenerator.GetBytes(3).Append((byte)0x11).ToArray().AsHexString().Replace(" ",""))">
+                    <p>@space.RoomName</p>
+                </div>
+            }
+        </div>
+        <div class="col-9" style="background-color: #ff00ff66;">
+            <p>omae wa mou shindeiru</p>
+        </div>
+    </div>
+</div>
+
+@code {
+
+    [CascadingParameter]
+    public Index2.RoomListViewData Data { get; set; } = null!;
+
+    protected override async Task OnInitializedAsync() {
+        Data.Rooms.CollectionChanged += (sender, args) => {
+            DebouncedStateHasChanged();
+            if (args.NewItems is { Count: > 0 })
+                foreach (var newItem in args.NewItems) {
+                    (newItem as RoomInfo).PropertyChanged += (sender, args) => { DebouncedStateHasChanged(); };
+                }
+        };
+        await base.OnInitializedAsync();
+    }
+
+    //debounce StateHasChanged, we dont want to reredner on every key stroke
+
+    private CancellationTokenSource _debounceCts = new CancellationTokenSource();
+
+    private async Task DebouncedStateHasChanged() {
+        _debounceCts.Cancel();
+        _debounceCts = new CancellationTokenSource();
+        try {
+            await Task.Delay(100, _debounceCts.Token);
+            Console.WriteLine("DebouncedStateHasChanged - Calling StateHasChanged!");
+            StateHasChanged();
+        }
+        catch (TaskCanceledException) { }
+    }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Rooms/Index2Components/RoomsIndex2MainTab.razor b/MatrixUtils.Web/Pages/Rooms/Index2Components/RoomsIndex2MainTab.razor
new file mode 100644
index 0000000..2b7c5ac
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Rooms/Index2Components/RoomsIndex2MainTab.razor
@@ -0,0 +1,198 @@
+@using MatrixUtils.Abstractions
+@using System.Security.Cryptography
+@using ArcaneLibs.Extensions
+@using System.ComponentModel
+@using System.Diagnostics
+@using LibMatrix.EventTypes.Spec.State
+@using MatrixUtils.Web.Pages.Rooms.Index2Components.MainTabComponents
+@using Microsoft.AspNetCore.Components.Rendering
+<h3>RoomsIndex2MainTab</h3>
+
+@* <div> *@
+@*     <div class="row"> *@
+@*         <div class="col-3" style="background-color: #ffffff66;"> *@
+@*             <LinkButton>Uncategorised rooms</LinkButton> *@
+@*             @foreach (var space in GetTopLevelSpaces()) { *@
+@*                 <a style="@("display:block; width: 100%; height: 50px; background-color: #" + RandomNumberGenerator.GetBytes(3).Append((byte)0x11).ToArray().AsHexString().Replace(" ", ""))"> *@
+@*                     <div style="vertical-align: middle;"> *@
+@*                         <div style="overflow:hidden; text-overflow: ellipsis; white-space: nowrap; ">@space.RoomName</div> *@
+@*                     </div> *@
+@*                 </a> *@
+@*             } *@
+@*         </div> *@
+@*         <div class="col-9" style="background-color: #ff00ff66;"> *@
+@*             <p>Placeholder for rooms list...</p> *@
+@*         </div> *@
+@*     </div> *@
+@* </div> *@
+
+<div>
+    <div class="row">
+        <div class="col-3" style="background-color: #ffffff22;">
+            <LinkButton>Uncategorised rooms</LinkButton>
+            @foreach (var space in GetTopLevelSpaces()) {
+                @RecursingSpaceChildren(space)
+            }
+        </div>
+        <div class="col-9" style="background-color: #ff00ff66;">
+            <p>Placeholder for rooms list...</p>
+        </div>
+    </div>
+</div>
+
+
+@code {
+
+    [CascadingParameter]
+    public Index2.RoomListViewData Data { get; set; } = null!;
+
+    protected override async Task OnInitializedAsync() {
+        Data.Rooms.CollectionChanged += (sender, args) => {
+            DebouncedStateHasChanged();
+            if (args.NewItems is { Count: > 0 })
+                foreach (var newItem in args.NewItems) {
+                    (newItem as RoomInfo).PropertyChanged += OnRoomListChanged;
+                    (newItem as RoomInfo).StateEvents.CollectionChanged += (sender, args) => { DebouncedStateHasChanged(); };
+                }
+        };
+        foreach (var newItem in Data.Rooms) {
+            newItem.PropertyChanged += OnRoomListChanged;
+            newItem.StateEvents.CollectionChanged += (sender, args) => { DebouncedStateHasChanged(); };
+        }
+
+        await base.OnInitializedAsync();
+        StateHasChanged();
+    }
+
+    private void OnRoomListChanged(object? sender, PropertyChangedEventArgs e) {
+        if (e.PropertyName == "RoomName" || e.PropertyName == "RoomType")
+            DebouncedStateHasChanged();
+    }
+
+    private CancellationTokenSource _debounceCts = new CancellationTokenSource();
+
+    private async Task DebouncedStateHasChanged() {
+        _debounceCts.Cancel();
+        _debounceCts = new CancellationTokenSource();
+        try {
+            Console.WriteLine("DebouncedStateHasChanged - Waiting 50ms...");
+            await Task.Delay(50, _debounceCts.Token);
+            Console.WriteLine("DebouncedStateHasChanged - Calling StateHasChanged!");
+            StateHasChanged();
+        }
+        catch (TaskCanceledException) { }
+    }
+
+    private List<RoomInfo> GetTopLevelSpaces() {
+        var spaces = Data.Rooms.Where(x => x.RoomType == "m.space").OrderBy(x => x.RoomName).ToList();
+        var allSpaceChildEvents = spaces.SelectMany(x => x.StateEvents.Where(y =>
+            y.Type == SpaceChildEventContent.EventId &&
+            y.RawContent!.Count > 0
+        )).ToList();
+
+        Console.WriteLine($"Child count: {allSpaceChildEvents.Count}");
+
+        spaces.RemoveAll(x => allSpaceChildEvents.Any(y => y.StateKey == x.Room.RoomId));
+
+        if (allSpaceChildEvents.Count == 0) {
+            Console.WriteLine("No space children found, returning nothing...");
+            return [];
+        }
+
+        return spaces.ToList();
+    }
+
+    private List<RoomInfo> GetSpaceChildren(RoomInfo space) {
+        var childEvents = space.StateEvents.Where(x =>
+            x.Type == SpaceChildEventContent.EventId &&
+            x.RawContent!.Count > 0
+        ).ToList();
+        var children = childEvents.Select(x => Data.Rooms.FirstOrDefault(y => y.Room.RoomId == x.StateKey)).Where(x => x is not null).ToList();
+        return children;
+    }
+
+    private List<RoomInfo> GetSpaceChildSpaces(RoomInfo space) {
+        var children = GetSpaceChildren(space);
+        var childSpaces = children.Where(x => x.RoomType == "m.space").ToList();
+        return childSpaces;
+    }
+
+    private RoomInfo? SelectedSpace { get; set; }
+    private List<RoomInfo> OpenedSpaces { get; set; } = new List<RoomInfo>();
+
+    private RenderFragment RecursingSpaceChildren(RoomInfo space, List<RoomInfo>? parents = null, int depth = 0) {
+        parents ??= [];
+        var totalSw = Stopwatch.StartNew();
+        var children = GetSpaceChildSpaces(space);
+
+        var randomColor = RandomNumberGenerator.GetBytes(3).Append((byte)0x33).ToArray().AsHexString().Replace(" ", "");
+        var isExpanded = OpenedSpaces.Contains(space);
+
+        // Console.WriteLine($"RecursingSpaceChildren::FetchData - Depth: {depth}, Space: {space.RoomName}, Children: {children.Count} - {totalSw.Elapsed}");
+
+        // var renderSw = Stopwatch.StartNew();
+        var rf = new RenderFragment(builder => {
+            builder.OpenElement(0, "div");
+            //space list entry render fragment
+            // builder.AddContent(1, SpaceListEntry(space));
+            builder.OpenComponent<MainTabSpaceItem>(1);
+            builder.AddAttribute(2, "Space", space);
+            builder.AddAttribute(2, "OpenedSpaces", OpenedSpaces);
+            builder.CloseComponent();
+            builder.CloseElement();
+            //space children render fragment
+            if (isExpanded) {
+                builder.OpenElement(2, "div");
+                builder.AddAttribute(3, "style", "padding-left: 10px;");
+                foreach (var child in children) {
+                    builder.AddContent(4, RecursingSpaceChildren(child, parents.Append(space).ToList(), depth + 1));
+                }
+
+                builder.CloseElement();
+            }
+        });
+
+        // Console.WriteLine($"RecursingSpaceChildren::Render - Depth: {depth}, Space: {space.RoomName}, Children: {children.Count} - {renderSw.Elapsed}");
+        if (totalSw.ElapsedMilliseconds > 20)
+            Console.WriteLine($"RecursingSpaceChildren::Total - Depth: {depth}, Space: {space.RoomName}, Children: {children.Count} - {totalSw.Elapsed}");
+        // Console.WriteLine($"RecursingSpaceChildren::Total - Depth: {depth}, Space: {space.RoomName}, Children: {children.Count} - {totalSw.Elapsed}");
+        return rf;
+    }
+
+    // private RenderFragment SpaceListEntry(RoomInfo space) {
+    //     return builder => {
+    //         {
+    //             builder.OpenElement(0, "div");
+    //             builder.AddAttribute(1, "style", "display: block; width: 100%; height: 50px;");
+    //             builder.AddAttribute(2, "onclick", EventCallback.Factory.Create(this, () => {
+    //                 if (OpenedSpaces.Contains(space)) {
+    //                     OpenedSpaces.Remove(space);
+    //                 }
+    //                 else {
+    //                     OpenedSpaces.Add(space);
+    //                 }
+    //
+    //                 StateHasChanged();
+    //             }));
+    //             {
+    //                 builder.OpenComponent<MxcImage>(5);
+    //                 builder.AddAttribute(6, "Homeserver", Data.Homeserver);
+    //                 builder.AddAttribute(7, "MxcUri", space.RoomIcon);
+    //                 builder.AddAttribute(8, "Circular", true);
+    //                 builder.AddAttribute(9, "Width", 32);
+    //                 builder.AddAttribute(10, "Height", 32);
+    //                 builder.CloseComponent();
+    //             }
+    //             {
+    //                 // room name, ellipsized
+    //                 builder.OpenElement(11, "span");
+    //                 builder.AddAttribute(12, "class", "spaceNameEllipsis");
+    //                 builder.AddContent(13, space.RoomName);
+    //                 builder.CloseElement();
+    //             }
+    //             builder.CloseElement();
+    //         }
+    //     };
+    // }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Rooms/Index2Components/RoomsIndex2MainTab.razor.css b/MatrixUtils.Web/Pages/Rooms/Index2Components/RoomsIndex2MainTab.razor.css
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Rooms/Index2Components/RoomsIndex2MainTab.razor.css
diff --git a/MatrixUtils.Web/Pages/Rooms/Index2Components/RoomsIndex2SyncContainer.razor b/MatrixUtils.Web/Pages/Rooms/Index2Components/RoomsIndex2SyncContainer.razor
new file mode 100644
index 0000000..bbc63eb
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Rooms/Index2Components/RoomsIndex2SyncContainer.razor
@@ -0,0 +1,202 @@
+@using LibMatrix.Helpers
+@using LibMatrix.Responses
+@using MatrixUtils.Abstractions
+@using System.Diagnostics
+@using System.Diagnostics.CodeAnalysis
+@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.Extensions
+@using LibMatrix.Utilities
+@using System.Collections.ObjectModel
+@using ArcaneLibs
+@inject ILogger<RoomsIndex2SyncContainer> logger
+<pre>RoomsIndex2SyncContainer</pre>
+@foreach (var (name, value) in _statusList) {
+    <pre>[@name] @value.Status</pre>
+}
+
+@code {
+
+    [Parameter]
+    public Index2.RoomListViewData Data { get; set; } = null!;
+
+    private SyncHelper syncHelper;
+
+    private Queue<KeyValuePair<string, SyncResponse.RoomsDataStructure.JoinedRoomDataStructure>> queue = new();
+
+    private ObservableCollection<(string name, ObservableStatus value)> _statusList = new();
+
+    protected override async Task OnInitializedAsync() {
+        _statusList.CollectionChanged += (sender, args) => {
+            StateHasChanged();
+            if (args.NewItems is { Count: > 0 })
+                foreach (var item in args.NewItems) {
+                    if (item is not (string name, ObservableStatus value)) continue;
+                    value.PropertyChanged += (sender, args) => {
+                        if(value.Show) StateHasChanged();
+                    };
+                }
+        };
+
+        while (Data.Homeserver is null) {
+            await Task.Delay(100);
+        }
+
+        await SetUpSync();
+    }
+
+    private async Task SetUpSync() {
+        var status = await GetOrAddStatus("Main");
+        var syncHelpers = new Dictionary<string, SyncHelper>() {
+            ["Main"] = new SyncHelper(Data.Homeserver, logger) {
+                Timeout = 30000,
+                FilterId = await Data.Homeserver.GetOrUploadNamedFilterIdAsync(CommonSyncFilters.GetBasicRoomInfo),
+                // MinimumDelay = TimeSpan.FromMilliseconds(5000)
+            }
+        };
+        status.Status = "Initial sync... Checking server filter capability...";
+        var syncRes = await syncHelpers["Main"].SyncAsync();
+        if (!syncRes.Rooms?.Join?.Any(x => x.Value.State?.Events?.Any(y => y.Type == SpaceChildEventContent.EventId) ?? false) ?? true) {
+            status.Status = "Initial sync indicates that server supports filters, starting helpers!";
+            syncHelpers.Add("SpaceRelations", new SyncHelper(Data.Homeserver, logger) {
+                Timeout = 30000,
+                FilterId = await Data.Homeserver.GetOrUploadNamedFilterIdAsync(CommonSyncFilters.GetSpaceRelations),
+                // MinimumDelay = TimeSpan.FromMilliseconds(5000)
+            });
+
+            syncHelpers.Add("Profile", new SyncHelper(Data.Homeserver, logger) {
+                Timeout = 30000,
+                FilterId = await Data.Homeserver.GetOrUploadNamedFilterIdAsync(CommonSyncFilters.GetOwnMemberEvents),
+                // MinimumDelay = TimeSpan.FromMilliseconds(5000)
+            });
+        }
+        else status.Status = "Initial sync indicates that server does not support filters, continuing without extra filters!";
+
+        await HandleSyncResponse(syncRes);
+
+        //  profileSyncHelper = new SyncHelper(Homeserver, logger) {
+        //     Timeout = 10000,
+        //     Filter = profileUpdateFilter,
+        //     MinimumDelay = TimeSpan.FromMilliseconds(5000)
+        //  };
+        // profileUpdateFilter.Room.State.Senders.Add(Homeserver.WhoAmI.UserId);
+        RunQueueProcessor();
+        foreach (var helper in syncHelpers) {
+            Console.WriteLine($"Starting sync loop for {helper.Key}");
+            RunSyncLoop(helper.Value, helper.Key);
+        }
+    }
+
+    private async Task RunQueueProcessor() {
+        var status = await GetOrAddStatus("QueueProcessor");
+        var statusd = await GetOrAddStatus("QueueProcessor/D", show: false);
+        while (true) {
+            await Task.Delay(1000);
+            try {
+                var renderTimeSw = Stopwatch.StartNew();
+                while (queue.Count == 0) {
+                    var delay = 1000;
+                    Console.WriteLine("Queue is empty, waiting...");
+                    // Status2 = $"Queue is empty, waiting for {delay}ms...";
+                    await Task.Delay(delay);
+                }
+
+                status.Status = $"Queue no longer empty after {renderTimeSw.Elapsed}!";
+                renderTimeSw.Restart();
+
+                int maxUpdates = 5000;
+                while (maxUpdates-- > 0 && queue.TryDequeue(out var queueEntry)) {
+                    var (roomId, roomData) = queueEntry;
+                    statusd.Status = $"Dequeued room {roomId}";
+                    RoomInfo room;
+
+                    if (Data.Rooms.Any(x => x.Room.RoomId == roomId)) {
+                        room = Data.Rooms.First(x => x.Room.RoomId == roomId);
+                        statusd.Status = $"{roomId} already known with {room.StateEvents?.Count ?? 0} state events";
+                    }
+                    else {
+                        statusd.Status = $"Eencountered new room {roomId}!";
+                        room = new RoomInfo(Data.Homeserver!.GetRoom(roomId), roomData.State?.Events);
+                        Data.Rooms.Add(room);
+                    }
+
+                    if (roomData.State?.Events is { Count: > 0 })
+                        room.StateEvents!.MergeStateEventLists(roomData.State.Events);
+                    else {
+                        statusd.Status = $"Could not merge state for {room.Room.RoomId} as new data contains no state events!";
+                    }
+
+                    // await Task.Delay(10);
+                }
+
+                status.Status = $"Got {Data.Rooms.Count} rooms so far! {queue.Count} entries left in processing queue... Parsed last response in {renderTimeSw.Elapsed}";
+
+                // RenderContents |= queue.Count == 0;
+                // await Task.Delay(Data.Rooms.Count);
+            }
+            catch (Exception e) {
+                Console.WriteLine("QueueWorker exception: " + e);
+            }
+        }
+    }
+
+    private async Task RunSyncLoop(SyncHelper syncHelper, string name = "Unknown") {
+        var status = await GetOrAddStatus($"SYNC/{name}");
+        status.Status = $"Initial syncing...";
+
+        var syncs = syncHelper.EnumerateSyncAsync();
+        await foreach (var sync in syncs) {
+            var sw = Stopwatch.StartNew();
+            status.Status = $"[{DateTime.Now}] Got {Data.Rooms.Count} rooms so far! {sync.Rooms?.Join?.Count ?? 0} new updates!";
+
+            await HandleSyncResponse(sync);
+            status.Status += $"\nProcessed sync in {sw.ElapsedMilliseconds}ms, queue length: {queue.Count}";
+        }
+    }
+
+    private async Task HandleSyncResponse(SyncResponse? sync) {
+        if (sync?.Rooms?.Join is { Count: > 0 })
+            foreach (var joinedRoom in sync.Rooms.Join)
+                queue.Enqueue(joinedRoom);
+
+        if (sync.Rooms.Leave is { Count: > 0 })
+            foreach (var leftRoom in sync.Rooms.Leave)
+                if (Data.Rooms.Any(x => x.Room.RoomId == leftRoom.Key))
+                    Data.Rooms.Remove(Data.Rooms.First(x => x.Room.RoomId == leftRoom.Key));
+    }
+
+    private SemaphoreSlim _syncLock = new(1, 1);
+
+    private async Task<ObservableStatus> GetOrAddStatus(string name, bool show = true, bool log = true) {
+        await _syncLock.WaitAsync();
+        try {
+            if (_statusList.Any(x => x.name == name))
+                return _statusList.First(x => x.name == name).value;
+            var status = new ObservableStatus() {
+                Name = name,
+                Log = log,
+                Show = show
+            };
+            _statusList.Add((name, status));
+            return status;
+        }
+        finally {
+            _syncLock.Release();
+        }
+    }
+
+    private class ObservableStatus : NotifyPropertyChanged {
+        private string _status = "Initialising...";
+        public string Name { get; set; } = "Unknown";
+        public bool Show { get; set; } = true;
+        public bool Log { get; set; } = true;
+
+        public string Status {
+            get => _status;
+            set {
+                if(SetField(ref _status, value) && Log)
+                    Console.WriteLine($"[{Name}]: {value}");
+            }
+        }
+    }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Tools/PolicyListActivity.razor b/MatrixUtils.Web/Pages/Tools/PolicyListActivity.razor
new file mode 100644
index 0000000..c94d0b0
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Tools/PolicyListActivity.razor
@@ -0,0 +1,158 @@
+@page "/Tools/PolicyListActivity"
+@using LibMatrix.EventTypes.Spec.State.Policy
+@using System.Diagnostics
+@using LibMatrix.RoomTypes
+@using LibMatrix.EventTypes.Common
+
+
+@if (RoomData.Count == 0)
+{
+    <p>Loading...</p>
+}
+else
+    foreach (var room in RoomData)
+    {
+        <h3>@room.Key</h3>
+        @foreach (var year in room.Value.OrderBy(x => x.Key))
+        {
+            <h5>@year.Key</h5>
+            <ActivityGraph Data="@year.Value" GlobalMax="MaxValue"
+                           RLabel="removed" GLabel="new" BLabel="updated policies">
+            </ActivityGraph>
+        }
+    }
+
+
+@code {
+    public AuthenticatedHomeserverGeneric? Homeserver { get; set; }
+    public List<GenericRoom> FilteredRooms = new();
+
+    public Dictionary<DateOnly, ActivityGraph.RGB> TestData { get; set; } = new();
+
+    public ActivityGraph.RGB MaxValue { get; set; } = new()
+    {
+        R = 255, G = 255, B = 255
+    };
+
+    public Dictionary<string, Dictionary<int, Dictionary<DateOnly, ActivityGraph.RGB>>> RoomData { get; set; } = new();
+
+    protected override async Task OnInitializedAsync()
+    {
+        var sw = Stopwatch.StartNew();
+        await base.OnInitializedAsync();
+        Homeserver = (await RMUStorage.GetCurrentSessionOrNavigate())!;
+        if (Homeserver is null) return;
+
+        //random test data
+        for (DateOnly i = new DateOnly(2020, 1, 1); i < new DateOnly(2020, 12, 30); i = i.AddDays(Random.Shared.Next(5)))
+        {
+            TestData[i] = new()
+            {
+                R = (int)(Random.Shared.NextSingle() * 255),
+                G = (int)(Random.Shared.NextSingle() * 255),
+                B = (int)(Random.Shared.NextSingle() * 255)
+            };
+        }
+
+        StateHasChanged();
+        // return;
+
+        var rooms = await Homeserver.GetJoinedRooms();
+        // foreach (var room in rooms)
+        // {
+        //     var type = await room.GetRoomType();
+        //     if (type == "support.feline.policy.lists.msc.v1")
+        //     {
+        //         Console.WriteLine($"{room.RoomId} is policy list by type");
+        //         FilteredRooms.Add(room);
+        //     }
+        //     else if(await room.GetStateOrNullAsync<MjolnirShortcodeEventContent>(MjolnirShortcodeEventContent.EventId) is not null)
+        //     {
+        //         Console.WriteLine($"{room.RoomId} is policy list by shortcode");
+        //         FilteredRooms.Add(room);
+        //     }
+        // }
+        var roomFilterTasks = rooms.Select(async room =>
+        {
+            var type = await room.GetRoomType();
+            if (type == "support.feline.policy.lists.msc.v1")
+            {
+                Console.WriteLine($"{room.RoomId} is policy list by type");
+                return room;
+            }
+            else if (await room.GetStateOrNullAsync<MjolnirShortcodeEventContent>(MjolnirShortcodeEventContent.EventId) is not null)
+            {
+                Console.WriteLine($"{room.RoomId} is policy list by shortcode");
+                return room;
+            }
+
+            return null;
+        }).ToList();
+        var filteredRooms = await Task.WhenAll(roomFilterTasks);
+        FilteredRooms.AddRange(filteredRooms.Where(x => x is not null).Cast<GenericRoom>());
+        Console.WriteLine($"Filtered {FilteredRooms.Count} rooms in {sw.ElapsedMilliseconds}ms");
+
+        var roomTasks = FilteredRooms.Select(FetchRoomHistory).ToList();
+        await Task.WhenAll(roomTasks);
+
+        Console.WriteLine($"Max value is {MaxValue.R} {MaxValue.G} {MaxValue.B}");
+        Console.WriteLine($"Filtered {FilteredRooms.Count} rooms in {sw.ElapsedMilliseconds}ms");
+    }
+
+    public async Task FetchRoomHistory(GenericRoom room)
+    {
+        var roomName = await room.GetNameOrFallbackAsync();
+            if (string.IsNullOrWhiteSpace(roomName)) roomName = room.RoomId;
+            if (!RoomData.ContainsKey(roomName))
+            {
+                RoomData[roomName] = new();
+            }
+
+            //use timeline
+            var timeline = room.GetManyMessagesAsync(limit: int.MaxValue, chunkSize: 5000);
+            await foreach (var response in timeline)
+            {
+                Console.WriteLine($"Got {response.State.Count} state, {response.Chunk.Count} timeline");
+                if (response.State.Count != 0) throw new Exception("Why the hell did we receive state events?");
+                foreach (var message in response.Chunk)
+                {
+                    if (!message.MappedType.IsAssignableTo(typeof(PolicyRuleEventContent))) continue;
+                    //OriginServerTs to datetime
+                    var dt = DateTimeOffset.FromUnixTimeMilliseconds((long)message.OriginServerTs!.Value).DateTime;
+                    var date = new DateOnly(dt.Year, dt.Month, dt.Day);
+                    if (!RoomData[roomName].ContainsKey(date.Year))
+                    {
+                        RoomData[roomName][date.Year] = new();
+                    }
+
+                    if (!RoomData[roomName][date.Year].ContainsKey(date))
+                    {
+                        // Console.WriteLine($"Adding {date} to {roomName}");
+                        RoomData[roomName][date.Year][date] = new();
+                    }
+
+                    var rgb = RoomData[roomName][date.Year][date];
+                    if (message.RawContent?.Count == 0) rgb.R++;
+                    else if (string.IsNullOrWhiteSpace(message.Unsigned?.ReplacesState)) rgb.G++;
+                    else rgb.B++;
+                    RoomData[roomName][date.Year][date] = rgb;
+                }
+
+                var max = RoomData.SelectMany(x => x.Value.Values).Aggregate(new ActivityGraph.RGB(), (current, next) => new()
+                {
+                    R = Math.Max(current.R, next.Average(x => x.Value.R)),
+                    G = Math.Max(current.G, next.Average(x => x.Value.G)),
+                    B = Math.Max(current.B, next.Average(x => x.Value.B))
+                });
+                MaxValue = new ActivityGraph.RGB(
+                    r: Math.Max(max.R, Math.Max(max.G, max.B)),
+                    g: Math.Max(max.R, Math.Max(max.G, max.B)),
+                    b: Math.Max(max.R, Math.Max(max.G, max.B)));
+                Console.WriteLine($"Max value is {MaxValue.R} {MaxValue.G} {MaxValue.B}");
+                StateHasChanged();
+                await Task.Delay(100);
+            }
+    }
+
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Tools/UserTrace.razor b/MatrixUtils.Web/Pages/Tools/UserTrace.razor
index b3a7487..d78c58a 100644
--- a/MatrixUtils.Web/Pages/Tools/UserTrace.razor
+++ b/MatrixUtils.Web/Pages/Tools/UserTrace.razor
@@ -80,18 +80,33 @@
         Random.Shared.Shuffle(distinctRooms);
         rooms = new ObservableCollection<GenericRoom>(distinctRooms);
         rooms.CollectionChanged += (sender, args) => StateHasChanged();
+        try {
+            var stateTasks = rooms.Select(async x => {
+                for (int i = 0; i < 10; i++) {
+                    try {
+                        return (x, await x.GetMembersListAsync(false));
+                    }
+                    catch {
+                        //
+                    }
+                }
 
-        var stateTasks = rooms.Select(async x => (x, await x.GetMembersListAsync(false))).ToAsyncEnumerable();
+                return (x, new List<StateEventResponse>().ToFrozenSet());
+            }).ToAsyncEnumerable();
 
-        await foreach (var (room, state) in stateTasks) {
-            roomMembers.Add(room, state);
-            log.Add($"Got {state.Count} members for {room.RoomId}...");
+            await foreach (var (room, state) in stateTasks) {
+                roomMembers.Add(room, state);
+                log.Add($"Got {state.Count} members for {room.RoomId}...");
+            }
+        }
+        catch {
+            //
         }
 
         log.Add($"Done fetching members!");
 
-        UserIDs.RemoveAll(x=>sessions.Any(y=>y.UserId == x));
-        
+        UserIDs.RemoveAll(x => sessions.Any(y => y.UserId == x));
+
         StateHasChanged();
         Console.WriteLine("Rerendered!");
         await base.OnInitializedAsync();
@@ -105,7 +120,6 @@
                     matches[userId].Add(new() {
                         Event = events.First(x => x.StateKey == userId && x.Type == RoomMemberEventContent.EventId),
                         Room = room,
-                        
                     });
                 }
             }
@@ -132,6 +146,7 @@
 
     private class Matches {
         public GenericRoom Room;
+
         public StateEventResponse Event;
         // public 
     }
diff --git a/MatrixUtils.Web/Pages/User/DMManager.razor b/MatrixUtils.Web/Pages/User/DMManager.razor
index df5cd6b..80bf3b2 100644
--- a/MatrixUtils.Web/Pages/User/DMManager.razor
+++ b/MatrixUtils.Web/Pages/User/DMManager.razor
@@ -2,6 +2,7 @@
 @using LibMatrix.EventTypes.Spec.State
 @using LibMatrix.Responses
 @using MatrixUtils.Abstractions
+@using LibMatrix
 <h3>Direct Messages</h3>
 <hr/>
 
@@ -36,11 +37,19 @@
         Status = "Loading DM list from account data...";
         var dms = await Homeserver.GetAccountDataAsync<Dictionary<string, List<string>>>("m.direct");
         DMRooms.Clear();
-        foreach (var (userId, rooms) in dms) {
+        var userTasks = dms.Select(async kv => {
+            var (userId, rooms) = kv;
             var roomList = new List<RoomInfo>();
-            DMRooms.Add(await Homeserver.GetProfileAsync(userId), roomList);
+            UserProfileResponse? profile = null;
+            try {
+                profile = await Homeserver.GetProfileAsync(userId);
+            }
+            catch (MatrixException e) {
+                if (e is { ErrorCode: "M_UNKNOWN" }) profile = new UserProfileResponse() { DisplayName = $"{userId}: {e.Error}" };
+            }
+
             foreach (var room in rooms) {
-                var roomInfo = new RoomInfo() { Room = Homeserver.GetRoom(room) };
+                var roomInfo = new RoomInfo(Homeserver.GetRoom(room));
                 roomList.Add(roomInfo);
                 roomInfo.StateEvents.Add(new() {
                     Type = RoomNameEventContent.EventId,
@@ -50,8 +59,13 @@
                     RoomId = room, Sender = null, EventId = null
                 });
             }
+
+            DMRooms.Add(profile ?? new() { DisplayName = userId }, roomList);
             StateHasChanged();
-        }
+        }).ToList();
+        
+        await Task.WhenAll(userTasks);
+        await Task.Delay(500);
 
         StateHasChanged();
         Status = null;
diff --git a/MatrixUtils.Web/Pages/User/DMSpace.razor b/MatrixUtils.Web/Pages/User/DMSpace.razor
index 519cfff..e3dba30 100644
--- a/MatrixUtils.Web/Pages/User/DMSpace.razor
+++ b/MatrixUtils.Web/Pages/User/DMSpace.razor
@@ -1,11 +1,14 @@
 @page "/User/DMSpace/Setup"
 @using LibMatrix
+@using LibMatrix.Responses
+@using MatrixUtils.Abstractions
 @using MatrixUtils.LibDMSpace
 @using MatrixUtils.LibDMSpace.StateEvents
 @using MatrixUtils.Web.Pages.User.DMSpaceStages
+@using System.Text.Json.Serialization
 <h3>DM Space Management</h3>
 <hr/>
-<CascadingValue Value="@DmSpace">
+<CascadingValue Value="@SetupData">
     @switch (Stage) {
         case -1:
             <p>Initialising...</p>
@@ -41,36 +44,29 @@
         }
     }
 
-    public AuthenticatedHomeserverGeneric? Homeserver { get; set; }
 
-    public DMSpaceConfiguration? DmSpaceConfiguration { get; set; }
-
-    [Parameter]
-    public DMSpace? DmSpace { get; set; }
+    public DMSpace? DMSpaceRootPage { get; set; }
 
     protected override async Task OnInitializedAsync() {
         if (NavigationManager.Uri.Contains("?stage=")) {
-            NavigationManager.NavigateTo("/User/DMSpace", true);
+            NavigationManager.NavigateTo("/User/DMSpace/Setup", true);
         }
-        DmSpace = this;
-        Homeserver ??= await RMUStorage.GetCurrentSessionOrNavigate();
-        if (Homeserver is null) return;
+        DMSpaceRootPage = this;
+        SetupData.Homeserver ??= await RMUStorage.GetCurrentSessionOrNavigate();
+        if (SetupData.Homeserver is null) return;
         try {
-            DmSpaceConfiguration = await Homeserver.GetAccountDataAsync<DMSpaceConfiguration>("gay.rory.dm_space");
-            var room = Homeserver.GetRoom(DmSpaceConfiguration.DMSpaceId);
-            await room.GetStateAsync<object>(DMSpaceInfo.EventId);
+            SetupData.DmSpaceConfiguration = await SetupData.Homeserver.GetAccountDataAsync<DMSpaceConfiguration>("gay.rory.dm_space");
+            var room = SetupData.Homeserver.GetRoom(SetupData.DmSpaceConfiguration.DMSpaceId);
+            await room.GetStateAsync<DMSpaceInfo>(DMSpaceInfo.EventId);
             Stage = 1;
         }
         catch (MatrixException e) {
-            if (e.ErrorCode == "M_NOT_FOUND") {
+            if (e.ErrorCode is "M_NOT_FOUND" or "M_FORBIDDEN") {
                 Stage = 0;
-                DmSpaceConfiguration = new();
+                SetupData.DmSpaceConfiguration = new();
             }
             else throw;
         }
-        catch (Exception e) {
-            throw;
-        }
         finally {
             StateHasChanged();
         }
@@ -82,4 +78,27 @@
         await base.OnParametersSetAsync();
     }
 
+    public DMSpaceSetupData SetupData { get; set; } = new();
+
+    public class DMSpaceSetupData {
+        
+        public AuthenticatedHomeserverGeneric? Homeserver { get; set; }
+
+        public DMSpaceConfiguration? DmSpaceConfiguration { get; set; }
+        
+        public DMSpaceInfo? DmSpaceInfo { get; set; } = new();
+        
+        public Dictionary<string, RoomInfo>? Spaces;
+        
+        public Dictionary<UserProfileWithId, List<RoomInfo>>? DMRooms;
+        
+        public RoomInfo? DMSpaceRoomInfo { get; set; }
+
+        
+        public class UserProfileWithId : UserProfileResponse {
+            [JsonIgnore]
+            public string Id { get; set; }
+        }
+    }
+
 }
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage0.razor b/MatrixUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage0.razor
index 49fd5b4..5f6508c 100644
--- a/MatrixUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage0.razor
+++ b/MatrixUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage0.razor
@@ -4,7 +4,7 @@
 <p>This wizard will help you set up a DM space.</p>
 <p>This is useful for eg. sharing DM rooms across multiple accounts.</p>
 <br/>
-<LinkButton href="/User/DMSpace?stage=1">Get started</LinkButton>
+<LinkButton href="/User/DMSpace/Setup?stage=1">Get started</LinkButton>
 
 @code {
 
diff --git a/MatrixUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage1.razor b/MatrixUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage1.razor
index 6131617..2176467 100644
--- a/MatrixUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage1.razor
+++ b/MatrixUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage1.razor
@@ -6,30 +6,40 @@
 @using MatrixUtils.LibDMSpace.StateEvents
 @using Microsoft.Extensions.Primitives
 @using ArcaneLibs.Extensions
+@using LibMatrix.EventTypes.Spec.State
+@using MatrixUtils.Abstractions
 <b>
     <u>DM Space setup tool - stage 1: Configure space</u>
 </b>
 <p>You will need a space to use for DM rooms.</p>
-@if (DmSpace is not null) {
-    <p>
-        Selected space:
-        <InputSelect @bind-Value="DmSpace.DmSpaceConfiguration.DMSpaceId">
-            @foreach (var (id, name) in spaces) {
-                <option value="@id">@name</option>
-            }
-        </InputSelect>
-    </p>
-    <p>
-        <InputCheckbox @bind-Value="DmSpaceInfo.LayerByUser"></InputCheckbox>
-        Create sub-spaces per user
-    </p>
+@if (SetupData is not null) {
+    if (SetupData.Spaces is not null) {
+        <p>
+            Selected space:
+            <InputSelect @bind-Value="SetupData.DmSpaceConfiguration.DMSpaceId">
+                <option value="">New space</option>
+                @foreach (var (id, roomInfo) in SetupData.Spaces) {
+                    <option value="@id">@roomInfo.RoomName</option>
+                }
+            </InputSelect>
+        </p>
+        <p>
+            <InputCheckbox @bind-Value="SetupData.DmSpaceInfo.LayerByUser"></InputCheckbox>
+            Create sub-spaces per user
+        </p>
+        
+        <br/>
+        <LinkButton OnClick="@Disband" Color="#FF0000">Disband</LinkButton>
+        <LinkButton OnClick="@Execute">Next</LinkButton>
+    }
+    else {
+        <p>Discovering spaces, please wait...</p>
+    }
 }
 else {
-    <b>Error: DmSpaceConfiguration is null!</b>
+    <b>Error: Setup data is null!</b>
 }
 
-<br/>
-<LinkButton OnClick="@Execute">Next</LinkButton>
 
 @if (!string.IsNullOrWhiteSpace(Status)) {
     <p>@Status</p>
@@ -45,84 +55,97 @@ else {
         }
     }
 
-    private Dictionary<string, string> spaces = new() { { "", "New space" } };
     private string? _status;
 
     [CascadingParameter]
-    public DMSpace? DmSpace { get; set; }
+    public DMSpace.DMSpaceSetupData SetupData { get; set; }
 
-    public DMSpaceInfo? DmSpaceInfo { get; set; } = new();
+    SemaphoreSlim _semaphoreSlim = new(1, 1);
 
     protected override async Task OnInitializedAsync() {
-        await base.OnInitializedAsync();
-    }
-
-    SemaphoreSlim _semaphoreSlim = new(1, 1);
-    protected override async Task OnParametersSetAsync() {
-        if (DmSpace is null)
+        if (SetupData is null)
             return;
+
         await _semaphoreSlim.WaitAsync();
-        DmSpace.DmSpaceConfiguration ??= new();
-        if (spaces.Count == 1) {
-            Status = "Looking for spaces...";
-            var userRoomsEnum = DmSpace.Homeserver.GetJoinedRoomsByType("m.space");
-            List<GenericRoom> userRooms = new(); 
-            await foreach (var room in userRoomsEnum) {
-                userRooms.Add(room);
-            }
-            var roomChecks = userRooms.Select(GetFeasibleSpaces).ToAsyncEnumerable();
-            await foreach(var room in roomChecks)
-                if(room.HasValue)
-                    spaces.TryAdd(room.Value.id, room.Value.name);
-            
-            Status = "Done!";
+
+        Dictionary<string, RoomInfo> spaces = [];
+        SetupData.DmSpaceConfiguration ??= new();
+
+        Status = "Looking for spaces...";
+        var userRoomsEnum = SetupData.Homeserver!.GetJoinedRoomsByType("m.space");
+
+        List<GenericRoom> userRooms = new();
+        await foreach (var room in userRoomsEnum) {
+            userRooms.Add(room);
         }
+
+        var roomChecks = userRooms.Select(GetFeasibleSpaces).ToAsyncEnumerable();
+        await foreach (var room in roomChecks)
+            if (room.HasValue)
+                spaces.TryAdd(room.Value.id, room.Value.roomInfo);
+
+        SetupData.Spaces = spaces;
+
+        Status = "Done!";
         _semaphoreSlim.Release();
         await base.OnParametersSetAsync();
     }
 
     private async Task Execute() {
-        if (string.IsNullOrWhiteSpace(DmSpace.DmSpaceConfiguration.DMSpaceId)) {
-            var crr = CreateRoomRequest.CreatePrivate(DmSpace.Homeserver, "Direct Messages");
-            crr.CreationContentBaseType.Type = "m.space";
-            DmSpace.DmSpaceConfiguration.DMSpaceId = (await DmSpace.Homeserver.CreateRoom(crr)).RoomId;
+        if (string.IsNullOrWhiteSpace(SetupData!.DmSpaceConfiguration!.DMSpaceId)) {
+            var createRoomRequest = CreateRoomRequest.CreatePrivate(SetupData.Homeserver!, "Direct Messages");
+            createRoomRequest.CreationContentBaseType.Type = "m.space";
+            SetupData.DmSpaceConfiguration.DMSpaceId = (await SetupData.Homeserver!.CreateRoom(createRoomRequest)).RoomId;
         }
-        await DmSpace.Homeserver!.SetAccountDataAsync(DMSpaceConfiguration.EventId, DmSpace.DmSpaceConfiguration);
-        var space = DmSpace.Homeserver.GetRoom(DmSpace.DmSpaceConfiguration.DMSpaceId);
-        await space.SendStateEventAsync(DMSpaceInfo.EventId, DmSpaceInfo);
+
+        await SetupData.Homeserver!.SetAccountDataAsync(DMSpaceConfiguration.EventId, SetupData.DmSpaceConfiguration);
+        var space = SetupData.Homeserver.GetRoom(SetupData.DmSpaceConfiguration.DMSpaceId);
+        await space.SendStateEventAsync(DMSpaceInfo.EventId, SetupData.DmSpaceInfo);
+        SetupData.DMSpaceRoomInfo = new RoomInfo(space);
+        await SetupData.DMSpaceRoomInfo.FetchAllStateAsync();
 
         NavigationManager.NavigateTo("/User/DMSpace/Setup?stage=2");
     }
 
-    public async Task<(string id, string name)?> GetFeasibleSpaces(GenericRoom room) {
+    public async Task<(string id, RoomInfo roomInfo)?> GetFeasibleSpaces(GenericRoom room) {
         try {
-            var pls = await room.GetPowerLevelsAsync();
-            if (!pls.UserHasStatePermission(DmSpace.Homeserver.WhoAmI.UserId, "m.space.child")) {
+            var ri = new RoomInfo(room);
+            
+            await foreach(var evt in room.GetFullStateAsync())
+                ri.StateEvents.Add(evt);
+
+            var powerLevels = (await ri.GetStateEvent(RoomPowerLevelEventContent.EventId)).TypedContent as RoomPowerLevelEventContent;
+            if (!powerLevels.UserHasStatePermission(SetupData.Homeserver.WhoAmI.UserId, SpaceChildEventContent.EventId)) {
                 Console.WriteLine($"No permission to send m.space.child in {room.RoomId}...");
                 return null;
             }
-            var roomName = await room.GetNameAsync();
-            Status = $"Found viable space: {roomName}";
-            if (string.IsNullOrWhiteSpace(DmSpace.DmSpaceConfiguration.DMSpaceId)) {
-                try {
-                    var dsi = await DmSpace.Homeserver.GetRoom(room.RoomId).GetStateOrNullAsync<DMSpaceInfo>(DMSpaceInfo.EventId) ?? new DMSpaceInfo();
-                    if (await room.GetStateOrNullAsync<DMSpaceInfo>(DMSpaceInfo.EventId) is not null && dsi is not null) {
-                        DmSpace.DmSpaceConfiguration.DMSpaceId = room.RoomId;
-                        DmSpaceInfo = dsi;
-                    }
-                }
-                catch (MatrixException e) {
-                    if (e.ErrorCode == "M_NOT_FOUND") Console.WriteLine($"{room.RoomId} is not a DM space.");
-                    else throw;
+            
+            Status = $"Found viable space: {ri.RoomName}";
+            if (!string.IsNullOrWhiteSpace(SetupData.DmSpaceConfiguration!.DMSpaceId)) {
+                if (await room.GetStateOrNullAsync<DMSpaceInfo>(DMSpaceInfo.EventId) is { } dsi) {
+                    SetupData.DmSpaceConfiguration.DMSpaceId = room.RoomId;
+                    SetupData.DmSpaceInfo = dsi;
+                    Console.WriteLine(dsi.ToJson(ignoreNull: true));
                 }
             }
-            return (room.RoomId, roomName);
+
+            if (ri.RoomName == room.RoomId)
+                ri.RoomName = await room.GetNameOrFallbackAsync();
+
+            return (room.RoomId, ri);
         }
         catch (MatrixException e) {
             if (e.ErrorCode == "M_NOT_FOUND") Console.WriteLine($"m.room.power_levels does not exist in {room.RoomId}!!!");
             else throw;
         }
+
         return null;
     }
 
+    private async Task Disband() {
+        var space = new DMSpaceRoom(SetupData.Homeserver, SetupData.DmSpaceConfiguration.DMSpaceId);
+        await space.DisbandDMSpace();
+        NavigationManager.NavigateTo(NavigationManager.Uri, forceLoad: true);
+    }
+
 }
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage2.razor b/MatrixUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage2.razor
index 5a53347..a70e9c5 100644
--- a/MatrixUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage2.razor
+++ b/MatrixUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage2.razor
@@ -17,18 +17,23 @@
     <p>@Status</p>
 }
 
-@if (DmSpace is not null) {
-    @foreach (var (userId, room) in dmRooms.OrderBy(x => x.Key.Id)) {
-        <InlineUserItem User="@userId"></InlineUserItem>
-        @foreach (var roomInfo in room) {
-            <RoomListItem RoomInfo="@roomInfo">
-                <LinkButton Round="true" OnClick="@(async () => DmToReassign = roomInfo)">Reassign</LinkButton>
-            </RoomListItem>
+@if (SetupData is not null) {
+    if (SetupData.DMRooms is { Count: > 0 }) {
+        @foreach (var (userId, room) in SetupData.DMRooms.OrderBy(x => x.Key.Id)) {
+            <InlineUserItem User="@userId"></InlineUserItem>
+            @foreach (var roomInfo in room) {
+                <RoomListItem RoomInfo="@roomInfo">
+                    <LinkButton Round="true" OnClick="@(async () => DmToReassign = roomInfo)">Reassign</LinkButton>
+                </RoomListItem>
+            }
         }
     }
+    else {
+        <p>DM room list is loading, please wait...</p>
+    }
 }
 else {
-    <b>Error: DmSpaceConfiguration is null!</b>
+    <b>Error: DMSpaceRootPage is null!</b>
 }
 
 <br/>
@@ -88,26 +93,21 @@ else {
     private RoomInfo? _dmToReassign;
 
     [CascadingParameter]
-    public DMSpace? DmSpace { get; set; }
+    public DMSpace.DMSpaceSetupData SetupData { get; set; }
 
-    private Dictionary<UserProfileWithId, List<RoomInfo>> dmRooms { get; set; } = new();
-    private Dictionary<RoomInfo, List<UserProfileWithId>> duplicateDmRooms { get; set; } = new();
-    private Dictionary<RoomInfo, List<UserProfileWithId>> roomMembers { get; set; } = new();
-
-    protected override async Task OnInitializedAsync() {
-        await base.OnInitializedAsync();
-    }
+    private Dictionary<RoomInfo, List<DMSpace.DMSpaceSetupData.UserProfileWithId>> duplicateDmRooms { get; set; } = new();
+    private Dictionary<RoomInfo, List<DMSpace.DMSpaceSetupData.UserProfileWithId>> roomMembers { get; set; } = new();
 
     SemaphoreSlim _semaphore = new(1, 1);
 
-    protected override async Task OnParametersSetAsync() {
-        if (DmSpace is null)
+    protected override async Task OnInitializedAsync() {
+        if (SetupData is null)
             return;
         await _semaphore.WaitAsync();
         DmToReassign = null;
-        var hs = DmSpace.Homeserver;
+        var hs = SetupData.Homeserver;
         Status = "Loading DM list from account data...";
-        var dms = await DmSpace.Homeserver.GetAccountDataAsync<Dictionary<string, List<string>>>("m.direct");
+        var dms = await SetupData.Homeserver.GetAccountDataAsync<Dictionary<string, List<string>>>("m.direct");
         Status = "Optimising DM list from account data...";
         var joinedRooms = (await hs.GetJoinedRooms()).Select(x => x.RoomId).ToList();
         foreach (var (user, rooms) in dms) {
@@ -116,18 +116,22 @@ else {
                 if (!joinedRooms.Contains(roomId))
                     rooms.RemoveAt(i);
             }
+
             dms[user] = rooms.Distinct().ToList();
         }
-        dms.RemoveAll((x, y) => y is {Count: 0});
-        await DmSpace.Homeserver.SetAccountDataAsync("m.direct", dms);
-        dmRooms.Clear();
+
+        dms.RemoveAll((x, y) => y is { Count: 0 });
+        await SetupData.Homeserver.SetAccountDataAsync("m.direct", dms);
 
         Status = "DM list optimised, fetching info...";
+
+        SetupData.DMRooms = new Dictionary<DMSpace.DMSpaceSetupData.UserProfileWithId, List<RoomInfo>>();
+
         var results = dms.Select(async x => {
             var (userId, rooms) = x;
-            UserProfileWithId userProfile;
+            DMSpace.DMSpaceSetupData.UserProfileWithId userProfile;
             try {
-                var profile = await DmSpace.Homeserver.GetProfileAsync(userId);
+                var profile = await SetupData.Homeserver.GetProfileAsync(userId);
                 userProfile = new() {
                     AvatarUrl = profile.AvatarUrl,
                     Id = userId,
@@ -141,32 +145,35 @@ else {
                     Id = userId
                 };
             }
+
             var roomList = new List<RoomInfo>();
             var tasks = rooms.Select(x => GetRoomInfo(hs.GetRoom(x))).ToAsyncEnumerable();
             await foreach (var result in tasks)
                 roomList.Add(result);
             return (userProfile, roomList);
-    // StateHasChanged();
+            // StateHasChanged();
         }).ToAsyncEnumerable();
         await foreach (var res in results) {
-            dmRooms.Add(res.userProfile, res.roomList);
-    // Status = $"Listed {dmRooms.Count} users";
+            SetupData.DMRooms.Add(res.userProfile, res.roomList);
+            // Status = $"Listed {dmRooms.Count} users";
         }
+
         _semaphore.Release();
-        var duplicateDmRoomIds = new Dictionary<string, List<UserProfileWithId>>();
-        foreach (var (user, rooms) in dmRooms) {
+        var duplicateDmRoomIds = new Dictionary<string, List<DMSpace.DMSpaceSetupData.UserProfileWithId>>();
+        foreach (var (user, rooms) in SetupData.DMRooms) {
             foreach (var roomInfo in rooms) {
                 if (!duplicateDmRoomIds.ContainsKey(roomInfo.Room.RoomId))
                     duplicateDmRoomIds.Add(roomInfo.Room.RoomId, new());
                 duplicateDmRoomIds[roomInfo.Room.RoomId].Add(user);
             }
         }
+
         duplicateDmRoomIds.RemoveAll((x, y) => y.Count == 1);
         foreach (var (roomId, users) in duplicateDmRoomIds) {
-            duplicateDmRooms.Add(dmRooms.First(x => x.Value.Any(x => x.Room.RoomId == roomId)).Value.First(x => x.Room.RoomId == roomId), users);
+            duplicateDmRooms.Add(SetupData.DMRooms.First(x => x.Value.Any(x => x.Room.RoomId == roomId)).Value.First(x => x.Room.RoomId == roomId), users);
         }
 
-    // StateHasChanged();
+        // StateHasChanged();
         Status = null;
         await base.OnParametersSetAsync();
     }
@@ -176,34 +183,29 @@ else {
     }
 
     private async Task<RoomInfo> GetRoomInfo(GenericRoom room) {
-        var roomInfo = new RoomInfo() {
-            Room = room
-        };
+        var roomInfo = new RoomInfo(room);
+        await roomInfo.FetchAllStateAsync();
         roomMembers[roomInfo] = new();
-        roomInfo.CreationEventContent = await room.GetCreateEventAsync();
-        try {
-            roomInfo.RoomName = await room.GetNameAsync();
-        }
-        catch { }
+        // roomInfo.CreationEventContent = await room.GetCreateEventAsync();
+        
+        if(roomInfo.RoomName == room.RoomId)
+            try {
+                roomInfo.RoomName = await room.GetNameOrFallbackAsync();
+            }
+            catch { }
 
         var membersEnum = room.GetMembersEnumerableAsync(true);
         await foreach (var member in membersEnum)
             if (member.TypedContent is RoomMemberEventContent memberEvent)
                 roomMembers[roomInfo].Add(new() { DisplayName = memberEvent.DisplayName, AvatarUrl = memberEvent.AvatarUrl, Id = member.StateKey });
-
-        if (string.IsNullOrWhiteSpace(roomInfo.RoomName) || roomInfo.RoomName == room.RoomId) {
-            List<string> displayNames = new List<string>();
-            foreach (var member in roomMembers[roomInfo])
-                if (!string.IsNullOrWhiteSpace(member.DisplayName))
-                    displayNames.Add(member.DisplayName);
-            roomInfo.RoomName = string.Join(", ", displayNames);
-        }
+        
         try {
             string? roomIcon = (await room.GetAvatarUrlAsync())?.Url;
             if (room is not null)
                 roomInfo.RoomIcon = roomIcon;
         }
         catch { }
+
         return roomInfo;
     }
 
@@ -214,29 +216,25 @@ else {
     }
 
     private async Task SetRoomAssignment(string roomId, string userId) {
-        var hs = DmSpace.Homeserver;
+        var hs = SetupData.Homeserver;
         Status = "Loading DM list from account data...";
-        var dms = await DmSpace.Homeserver.GetAccountDataAsync<Dictionary<string, List<string>>>("m.direct");
+        var dms = await SetupData.Homeserver.GetAccountDataAsync<Dictionary<string, List<string>>>("m.direct");
         Status = "Updating DM list from account data...";
 
         foreach (var (user, rooms) in dms) {
             rooms.RemoveAll(x => x == roomId);
             dms[user] = rooms.Distinct().ToList();
         }
-        if(!dms.ContainsKey(userId))
+
+        if (!dms.ContainsKey(userId))
             dms.Add(userId, new());
         dms[userId].Add(roomId);
-        dms.RemoveAll((x, y) => y is {Count: 0});
-        await DmSpace.Homeserver.SetAccountDataAsync("m.direct", dms);
+        dms.RemoveAll((x, y) => y is { Count: 0 });
+        await SetupData.Homeserver.SetAccountDataAsync("m.direct", dms);
 
         duplicateDmRooms.RemoveAll((x, y) => x.Room.RoomId == roomId);
         StateHasChanged();
         if (duplicateDmRooms.Count == 0) await OnParametersSetAsync();
     }
 
-    private class UserProfileWithId : UserProfileResponse {
-        [JsonIgnore]
-        public string Id { get; set; }
-    }
-
-}
\ No newline at end of file
+}
diff --git a/MatrixUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage3.razor b/MatrixUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage3.razor
index 9307f6a..865e956 100644
--- a/MatrixUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage3.razor
+++ b/MatrixUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage3.razor
@@ -18,15 +18,15 @@
     <p>@Status</p>
 }
 
-@if (DmSpace is not null) {
-    @if (dmSpaceInfo is not null && dmSpaceRoomInfo is not null) {
+@if (SetupData is not null) {
+    @if (SetupData.DMSpaceRoomInfo is not null) {
         <p>
-            <InputCheckbox @bind-Value="dmSpaceInfo.LayerByUser"></InputCheckbox>
+            <InputCheckbox @bind-Value="SetupData.DmSpaceInfo.LayerByUser"></InputCheckbox>
             Create sub-spaces per user
         </p>
-        @if (!dmSpaceInfo.LayerByUser) {
-            <RoomListItem RoomInfo="@dmSpaceRoomInfo"></RoomListItem>
-            @foreach (var (userId, room) in dmRooms.OrderBy(x => x.Key.RoomName)) {
+        @if (!SetupData.DmSpaceInfo.LayerByUser) {
+            <RoomListItem RoomInfo="@SetupData.DMSpaceRoomInfo"></RoomListItem>
+            @foreach (var (userId, room) in SetupData.DMRooms.OrderBy(x => x.Key.DisplayName)) {
                 @foreach (var roomInfo in room) {
                     <div style="margin-left: 32px;">
                         <RoomListItem RoomInfo="@roomInfo"></RoomListItem>
@@ -35,10 +35,16 @@
             }
         }
         else {
-            <RoomListItem RoomInfo="@dmSpaceRoomInfo"></RoomListItem>
-            @foreach (var (userId, room) in dmRooms.OrderBy(x => x.Key.RoomName)) {
+            <RoomListItem RoomInfo="@SetupData.DMSpaceRoomInfo"></RoomListItem>
+            @foreach (var (user, room) in SetupData.DMRooms.OrderBy(x => x.Key.DisplayName)) {
                 <div style="margin-left: 32px;">
-                    <RoomListItem RoomInfo="@userId"></RoomListItem>
+                    @{
+                        RoomInfo fakeRoom = new(SetupData.DMSpaceRoomInfo.Room) {
+                            RoomName = user.DisplayName ?? user.Id,
+                            RoomIcon = user.AvatarUrl
+                        };
+                    }
+                    <RoomListItem RoomInfo="@fakeRoom"></RoomListItem>
                 </div>
                 @foreach (var roomInfo in room) {
                     <div style="margin-left: 64px;">
@@ -49,11 +55,11 @@
         }
     }
     else {
-        <b>Error: dmSpaceInfo is null!</b>
+        <b>Error: SetupData.DMSpaceRoomInfo is null!</b>
     }
 }
 else {
-    <b>Error: DmSpaceConfiguration is null!</b>
+    <b>Error: DMSpaceRootPageConfiguration is null!</b>
 }
 
 <br/>
@@ -72,83 +78,75 @@ else {
     private string? _status;
 
     [CascadingParameter]
-    public DMSpace? DmSpace { get; set; }
-
-    private Dictionary<RoomInfo, List<RoomInfo>> dmRooms { get; set; } = new();
-    private DMSpaceInfo? dmSpaceInfo { get; set; }
-    private RoomInfo? dmSpaceRoomInfo { get; set; }
-
-    protected override async Task OnInitializedAsync() {
-        await base.OnInitializedAsync();
-    }
+    public DMSpace.DMSpaceSetupData SetupData { get; set; }
 
     SemaphoreSlim _semaphore = new(1, 1);
 
-    protected override async Task OnParametersSetAsync() {
-        if (DmSpace is null)
+    protected override async Task OnInitializedAsync() {
+        if (SetupData is null)
             return;
         await _semaphore.WaitAsync();
-        var hs = DmSpace.Homeserver;
-        var dmSpaceRoom = new DMSpaceRoom(hs, DmSpace.DmSpaceConfiguration.DMSpaceId);
-        dmSpaceRoomInfo = new() {
-            RoomName = await dmSpaceRoom.GetNameAsync(),
-            CreationEventContent = await dmSpaceRoom.GetCreateEventAsync(),
-            RoomIcon = "mxc://feline.support/uUxBwaboPkMGtbZcAGZaIzpK",
-            Room = dmSpaceRoom
-        };
-        dmSpaceInfo = await dmSpaceRoom.GetDmSpaceInfo();
-        Status = "Loading DM list from account data...";
-        var dms = await DmSpace.Homeserver.GetAccountDataAsync<Dictionary<string, List<string>>>("m.direct");
-        dmRooms.Clear();
+        var hs = SetupData.Homeserver;
+        // var dmSpaceRoom = new DMSpaceRoom(hs, SetupData.DmSpaceConfiguration.DMSpaceId);
+        // SetupData.
+        // dmSpaceRoomInfo = new() {
+        // RoomName = await dmSpaceRoom.GetNameAsync(),
+        // CreationEventContent = await dmSpaceRoom.GetCreateEventAsync(),
+        // RoomIcon = "mxc://feline.support/uUxBwaboPkMGtbZcAGZaIzpK",
+        // Room = dmSpaceRoom
+        // };
+        // dmSpaceInfo = await dmSpaceRoom.GetDMSpaceInfo();
+        // Status = "Loading DM list from account data...";
+        // var dms = await SetupData.Homeserver.GetAccountDataAsync<Dictionary<string, List<string>>>("m.direct");
 
         Status = "DM list optimised, fetching info...";
-        var results = dms.Select(async x => {
-            var (userId, rooms) = x;
-            UserProfileWithId userProfile;
-            try {
-                var profile = await DmSpace.Homeserver.GetProfileAsync(userId);
-                userProfile = new() {
-                    AvatarUrl = profile.AvatarUrl,
-                    Id = userId,
-                    DisplayName = profile.DisplayName
-                };
-            }
-            catch {
-                userProfile = new() {
-                    AvatarUrl = "mxc://feline.support/uUxBwaboPkMGtbZcAGZaIzpK",
-                    DisplayName = userId,
-                    Id = userId
-                };
-            }
-            var roomList = new List<RoomInfo>();
-            var tasks = rooms.Select(x => GetRoomInfo(hs.GetRoom(x))).ToAsyncEnumerable();
-            await foreach (var result in tasks)
-                roomList.Add(result);
-            return (userProfile, roomList);
-        }).ToAsyncEnumerable();
-        await foreach (var res in results) {
-            dmRooms.Add(new RoomInfo() {
-                Room = dmSpaceRoom,
-                RoomIcon = res.userProfile.AvatarUrl,
-                RoomName = res.userProfile.DisplayName,
-                CreationEventContent = await dmSpaceRoom.GetCreateEventAsync()
-            }, res.roomList);
-        }
+        // var results = dms.Select(async x => {
+        //     var (userId, rooms) = x;
+        //     UserProfileWithId userProfile;
+        //     try {
+        //         var profile = await SetupData.Homeserver.GetProfileAsync(userId);
+        //         userProfile = new() {
+        //             AvatarUrl = profile.AvatarUrl,
+        //             Id = userId,
+        //             DisplayName = profile.DisplayName
+        //         };
+        //     }
+        //     catch {
+        //         userProfile = new() {
+        //             AvatarUrl = "mxc://feline.support/uUxBwaboPkMGtbZcAGZaIzpK",
+        //             DisplayName = userId,
+        //             Id = userId
+        //         };
+        //     }
+        //     var roomList = new List<RoomInfo>();
+        //     var tasks = rooms.Select(x => GetRoomInfo(hs.GetRoom(x))).ToAsyncEnumerable();
+        //     await foreach (var result in tasks)
+        //         roomList.Add(result);
+        //     return (userProfile, roomList);
+        // }).ToAsyncEnumerable();
+        // await foreach (var res in results) {
+        //     dmRooms.Add(new RoomInfo() {
+        //         Room = dmSpaceRoom,
+        //         RoomIcon = res.userProfile.AvatarUrl,
+        //         RoomName = res.userProfile.DisplayName,
+        //         CreationEventContent = await dmSpaceRoom.GetCreateEventAsync()
+        //     }, res.roomList);
+        // }
+        await SetupData.DMSpaceRoomInfo!.FetchAllStateAsync();
         _semaphore.Release();
         Status = null;
         await base.OnParametersSetAsync();
     }
 
     private async Task Execute() {
-        var hs = DmSpace.Homeserver;
-        var dmSpaceRoom = new DMSpaceRoom(hs, DmSpace.DmSpaceConfiguration.DMSpaceId);
+        var hs = SetupData.Homeserver;
+        var dmSpaceRoom = new DMSpaceRoom(hs, SetupData.DmSpaceConfiguration!.DMSpaceId!);
+        await dmSpaceRoom.ImportNativeDMs();
         NavigationManager.NavigateTo("/User/DMSpace/Setup?stage=3");
     }
 
     private async Task<RoomInfo> GetRoomInfo(GenericRoom room) {
-        var roomInfo = new RoomInfo() {
-            Room = room
-        };
+        var roomInfo = new RoomInfo(room);
         var roomMembers = new List<UserProfileWithId>();
         roomInfo.CreationEventContent = await room.GetCreateEventAsync();
         try {
@@ -168,12 +166,14 @@ else {
                     displayNames.Add(member.DisplayName);
             roomInfo.RoomName = string.Join(", ", displayNames);
         }
+
         try {
             string? roomIcon = (await room.GetAvatarUrlAsync())?.Url;
             if (room is not null)
                 roomInfo.RoomIcon = roomIcon;
         }
         catch { }
+
         return roomInfo;
     }
 
diff --git a/MatrixUtils.Web/Pages/User/Profile.razor b/MatrixUtils.Web/Pages/User/Profile.razor
index 79b83ae..129f706 100644
--- a/MatrixUtils.Web/Pages/User/Profile.razor
+++ b/MatrixUtils.Web/Pages/User/Profile.razor
@@ -110,8 +110,7 @@
             var room = Homeserver.GetRoom(roomId);
             var roomNameTask = room.GetNameOrFallbackAsync();
             var roomIconTask = room.GetAvatarUrlAsync();
-            var roomInfo = new RoomInfo() {
-                Room = room,
+            var roomInfo = new RoomInfo(room) {
                 OwnMembership = roomProfile
             };
             try {
diff --git a/MatrixUtils.Web/Shared/ActivityGraph.razor b/MatrixUtils.Web/Shared/ActivityGraph.razor
new file mode 100644
index 0000000..51fb539
--- /dev/null
+++ b/MatrixUtils.Web/Shared/ActivityGraph.razor
@@ -0,0 +1,148 @@
+@using System.Drawing
+@using System.Runtime.InteropServices
+@using System.Diagnostics
+
+@if (Data is { Count: > 0 })
+{
+    @*                                                      12*5=60 *@
+    <div style="display: grid; grid-template-columns: 35px repeat(60, 1.5em); grid-template-rows: 1.5em repeat(7, 1.5em); gap: 0;">
+        @* row 0: month labels with colspan *@
+        @* @foreach (var month in Enumerable.Range(1, 12)) *@
+        @* { *@
+        @*     <div style="grid-row: 1; grid-column: @((int)(month * 4.3) + 1);"> *@
+        @*         <span aria-hidden="true">@(new DateTime(2021, month, 1).ToString("MMM")[..3])</span> *@
+        @*     </div> *@
+        @* } *@
+
+        @* column 0: day labels *@
+        @* @for (var i = 0; i < 7; i++) *@
+        @* { *@
+        @*     <div style="text-align: left; grid-column: 1; grid-row: @(i + 2)"> *@
+        @*         @(((DayOfWeek)i).ToString()[..3]) *@
+        @*     </div> *@
+        @* } *@
+
+
+        <div style="grid-row: 1; grid-column: 5;">Jan</div>
+        <div style="grid-row: 1; grid-column: 9;">Feb</div>
+        <div style="grid-row: 1; grid-column: 13;">Mar</div>
+        <div style="grid-row: 1; grid-column: 18;">Apr</div>
+        <div style="grid-row: 1; grid-column: 22;">May</div>
+        <div style="grid-row: 1; grid-column: 26;">Jun</div>
+        <div style="grid-row: 1; grid-column: 31;">Jul</div>
+        <div style="grid-row: 1; grid-column: 35;">Aug</div>
+        <div style="grid-row: 1; grid-column: 39;">Sep</div>
+        <div style="grid-row: 1; grid-column: 44;">Oct</div>
+        <div style="grid-row: 1; grid-column: 48;">Nov</div>
+        <div style="grid-row: 1; grid-column: 52;">Dec</div>
+        <div style="text-align: left; grid-column: 1; grid-row: 2">Sun</div>
+        <div style="text-align: left; grid-column: 1; grid-row: 3">Mon</div>
+        <div style="text-align: left; grid-column: 1; grid-row: 4">Tue</div>
+        <div style="text-align: left; grid-column: 1; grid-row: 5">Wed</div>
+        <div style="text-align: left; grid-column: 1; grid-row: 6">Thu</div>
+        <div style="text-align: left; grid-column: 1; grid-row: 7">Fri</div>
+        <div style="text-align: left; grid-column: 1; grid-row: 8">Sat</div>
+
+
+        @* pad activity cell dates... *@
+        <div style="grid-column: 2; grid-row: 2 / span @((int)(new DateOnly(Data.Keys.First().Year, 1, 1).DayOfWeek));"></div>
+
+        @* the actual activity cells *@
+
+        @code{
+            bool needsBorder = false;
+        }
+
+        @for (DateOnly date = new DateOnly(Data.Keys.First().Year, 1, 1); date <= new DateOnly(Data.Keys.First().Year, 1, 1).AddYears(1).AddDays(-1); date = date.AddDays(1))
+        {
+            var hasData = Data.TryGetValue(date, out var color);
+            var needsTopBorder = date.Day == 1 && date.Month != 1 && date.DayOfWeek != DayOfWeek.Sunday;
+            if (date.DayOfWeek == DayOfWeek.Sunday)
+                needsBorder = date.AddDays(7).Day <= 7 && date.Month != 12;
+            var needsLeftBorder = date.Day <= 7;
+
+            <div class="activity-cell-container"
+                 style="grid-row: @((int)date.DayOfWeek + 2); border-@(needsLeftBorder ? "left" : "right"): @(needsBorder ? "2px solid white" : "none"); border-top: @(needsTopBorder ? "2px solid white" : "none");">
+                @if (hasData)
+                {
+                    <div class="activity-cell"
+                         style="background-color: rgb(@(color.R / GlobalMax.R * 255), @(color.G / GlobalMax.G * 255), @(color.B / GlobalMax.B * 255));"
+                         title="@($"{color.R} {RLabel}, {color.G} {GLabel}, and {color.B} {BLabel} on {date.ToString("D")}")">
+                    </div>
+                }
+                else
+                {
+                    <div class="activity-cell"
+                         title="@($"No data on {date.ToString("D")}")">
+                    </div>
+                }
+            </div>
+        }
+    </div>
+}
+
+
+@code {
+    private Dictionary<DateOnly, RGB> _data = new();
+    private RGB? _globalMax = null;
+
+    [Parameter]
+    public Dictionary<DateOnly, RGB> Data
+    {
+        get => _data;
+        set
+        {
+            // var sw = Stopwatch.StartNew();
+            if (value is not { Count: > 0 }) return;
+            // Console.WriteLine($"Recalculating activity graph ({value.Count} datapoints)...");
+
+
+            // var year = (int)value.Keys.Average(x => x.Year);
+            // value = value
+            // .Where(x => x.Key.Year == year)
+            // .OrderBy(x => x.Key)
+            // .ToDictionary(x => x.Key, x => x.Value);
+
+            _data = value;
+            // Console.WriteLine($"Recalculated activity graph in {sw.Elapsed}");
+            // StateHasChanged();
+        }
+    }
+
+    [Parameter]
+    public RGB GlobalMax
+    {
+        get
+        {
+            if (_globalMax is not null) return _globalMax.Value;
+            if (Data is not { Count: > 0 }) return new RGB() { R = 255, G = 255, B = 255 };
+            return new RGB()
+            {
+                R = Data.Values.Max(x => x.R),
+                G = Data.Values.Max(x => x.G),
+                B = Data.Values.Max(x => x.B)
+            };
+        }
+        set => _globalMax = value;
+    }
+
+    [Parameter] public string RLabel { get; set; } = "R";
+    [Parameter] public string GLabel { get; set; } = "G";
+    [Parameter] public string BLabel { get; set; } = "B";
+
+    [StructLayout(LayoutKind.Sequential, Size = sizeof(float) * 3, Pack = 1)]
+    public struct RGB()
+    {
+        public float R = 0;
+        public float G = 0;
+        public float B = 0;
+
+        public RGB(float r, float g, float b) : this()
+        {
+            R = r;
+            G = g;
+            B = b;
+        }
+    }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Shared/ActivityGraph.razor.css b/MatrixUtils.Web/Shared/ActivityGraph.razor.css
new file mode 100644
index 0000000..d8e543c
--- /dev/null
+++ b/MatrixUtils.Web/Shared/ActivityGraph.razor.css
@@ -0,0 +1,16 @@
+.activity-cell-container {
+    width: 100%;
+    height: 100%;
+    align-content: center;
+    justify-content: center;
+}
+
+.activity-cell {
+    width: 85%;
+    height: 85%;
+    border-radius: 5px;
+}
+
+.day-label {
+    grid-column: 1;
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Shared/MainLayout.razor b/MatrixUtils.Web/Shared/MainLayout.razor
index d8bf411..41c3d69 100644
--- a/MatrixUtils.Web/Shared/MainLayout.razor
+++ b/MatrixUtils.Web/Shared/MainLayout.razor
@@ -8,8 +8,8 @@
     <main>
         <div class="top-row px-4">
             <PortableDevTools></PortableDevTools>
-            <a href="https://cgit.rory.gay/matrix/MatrixRoomUtils.git/" target="_blank">Git</a>
-            <a href="https://matrix.to/#/%23mru%3Arory.gay?via=rory.gay&via=matrix.org&via=feline.support" target="_blank">Matrix</a>
+            <a style="color: #ccc; text-decoration: underline" href="https://cgit.rory.gay/matrix/MatrixRoomUtils.git/" target="_blank">Git</a>
+            <a style="color: #ccc; text-decoration: underline" href="https://matrix.to/#/%23mru%3Arory.gay?via=rory.gay&via=matrix.org&via=feline.support" target="_blank">Matrix</a>
         </div>
 
         <article class="Content px-4">
diff --git a/MatrixUtils.Web/Shared/MxcImage.razor b/MatrixUtils.Web/Shared/MxcImage.razor
index f31c19f..e651c3f 100644
--- a/MatrixUtils.Web/Shared/MxcImage.razor
+++ b/MatrixUtils.Web/Shared/MxcImage.razor
@@ -30,6 +30,7 @@
             StateHasChanged();
         }
     }
+    
     [Parameter]
     public RemoteHomeserver? Homeserver { get; set; }
 
@@ -41,7 +42,7 @@
         }
     }
 
-    private string StyleString => $"{Style} {(Circular ? "border-radius: 50%;" : "")} {(Width.HasValue ? $"width: {Width}px;" : "")} {(Height.HasValue ? $"height: {Height}px;" : "")}";
+    private string StyleString => $"{Style} {(Circular ? "border-radius: 50%;" : "")} {(Width.HasValue ? $"width: {Width}px;" : "")} {(Height.HasValue ? $"height: {Height}px;" : "")} object-fit: cover;";
     
     private static readonly string Prefix = "mxc://";
     private static readonly int PrefixLength = Prefix.Length;
diff --git a/MatrixUtils.Web/Shared/RoomListComponents/RoomListSpace.razor b/MatrixUtils.Web/Shared/RoomListComponents/RoomListSpace.razor
index 9c481e3..6954990 100644
--- a/MatrixUtils.Web/Shared/RoomListComponents/RoomListSpace.razor
+++ b/MatrixUtils.Web/Shared/RoomListComponents/RoomListSpace.razor
@@ -48,9 +48,7 @@
             if (Breadcrumbs.Contains(room.RoomId)) continue;
             var roomInfo = KnownRooms.FirstOrDefault(x => x.Room.RoomId == room.RoomId);
             if (roomInfo is null) {
-                roomInfo = new RoomInfo() {
-                    Room = room
-                };
+                roomInfo = new RoomInfo(room);
                 KnownRooms.Add(roomInfo);
             }
             if(joinedRooms.Any(x=>x.RoomId == room.RoomId))
diff --git a/MatrixUtils.Web/Shared/UserListItem.razor b/MatrixUtils.Web/Shared/UserListItem.razor
index 525296e..daa9c9e 100644
--- a/MatrixUtils.Web/Shared/UserListItem.razor
+++ b/MatrixUtils.Web/Shared/UserListItem.razor
@@ -2,8 +2,9 @@
 @using LibMatrix.EventTypes.Spec.State
 @using LibMatrix.Homeservers
 @using LibMatrix.Responses
+@using ArcaneLibs
 <div style="background-color: #ffffff11; border-radius: 25px; margin: 8px; width: fit-Content;">
-    <img style="@(ChildContent is not null ? "vertical-align: baseline;" : "") width: 32px; height:  32px; border-radius: 50%;" src="@(string.IsNullOrWhiteSpace(User?.AvatarUrl) ? "https://api.dicebear.com/6.x/identicon/svg?seed=" + UserId : User.AvatarUrl)"/>
+    <img style="@(ChildContent is not null ? "vertical-align: baseline;" : "") width: 32px; height:  32px; border-radius: 50%;" src="@(string.IsNullOrWhiteSpace(User?.AvatarUrl) ? _identiconGenerator.GenerateAsDataUri(UserId) : User.AvatarUrl)"/>
     <span style="vertical-align: middle; margin-right: 8px; border-radius: 75px;">@User?.DisplayName</span>
 
     <div style="display: inline-block;">
@@ -27,6 +28,8 @@
 
     private AuthenticatedHomeserverGeneric _homeserver = null!;
 
+    private SvgIdenticonGenerator _identiconGenerator = new();
+
     protected override async Task OnInitializedAsync() {
         _homeserver = await RMUStorage.GetCurrentSessionOrNavigate();
         if (_homeserver is null) return;
@@ -35,6 +38,7 @@
             if (UserId == null) {
                 throw new ArgumentNullException(nameof(UserId));
             }
+
             User = await _homeserver.GetProfileAsync(UserId);
         }