about summary refs log tree commit diff
diff options
context:
space:
mode:
authorRory& <root@rory.gay>2024-02-23 13:57:06 +0100
committerRory& <root@rory.gay>2024-02-23 13:57:06 +0100
commitd0d11db2209a8be65c27e15ca9d8a3b594f1a352 (patch)
treeb42b7de4b09888a1439d0939707ba1331becf626
parentHS emulator (diff)
downloadMatrixUtils-d0d11db2209a8be65c27e15ca9d8a3b594f1a352.tar.xz
Add eons of work because I forgot to push
-rw-r--r--.editorconfig13
-rw-r--r--.idea/.idea.MatrixRoomUtils/.idea/avalonia.xml1
-rw-r--r--.idea/.idea.MatrixRoomUtils/.idea/inspectionProfiles/Project_Default.xml22
m---------LibMatrix0
-rw-r--r--MatrixRoomUtils.sln12
-rw-r--r--MatrixUtils.Abstractions/RoomInfo.cs48
-rw-r--r--MatrixUtils.Desktop/MainWindow.axaml.cs4
-rw-r--r--MatrixUtils.DmSpaced/MatrixUtils.DmSpaced.csproj31
-rw-r--r--MatrixUtils.DmSpaced/ModerationBot.cs298
-rw-r--r--MatrixUtils.DmSpaced/ModerationBotConfiguration.cs10
-rw-r--r--MatrixUtils.DmSpaced/Program.cs40
-rw-r--r--MatrixUtils.DmSpaced/appsettings.Development.json24
-rw-r--r--MatrixUtils.DmSpaced/appsettings.json9
-rw-r--r--MatrixUtils.LibDMSpace/DMSpaceRoom.cs192
-rw-r--r--MatrixUtils.LibDMSpace/HomeserverExtensions.cs10
-rw-r--r--MatrixUtils.LibDMSpace/StateEvents/DMRoomInfo.cs9
-rw-r--r--MatrixUtils.LibDMSpace/StateEvents/DMSpaceChildLayer.cs20
-rw-r--r--MatrixUtils.LibDMSpace/StateEvents/DMSpaceInfo.cs2
-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
-rwxr-xr-xscripts/deploy.sh2
48 files changed, 1917 insertions, 364 deletions
diff --git a/.editorconfig b/.editorconfig
index e98e832..1a3cf7e 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -373,7 +373,8 @@ dotnet_style_qualification_for_field = false:suggestion
 dotnet_style_qualification_for_method = false:suggestion
 dotnet_style_qualification_for_property = false:suggestion
 dotnet_style_require_accessibility_modifiers = for_non_interface_members:error
-file_header_template =  # ReSharper properties
+file_header_template = # ReSharper properties
+
 
 resharper_alignment_tab_fill_style = use_spaces
 resharper_align_first_arg_by_paren = false
@@ -608,8 +609,8 @@ resharper_line_break_before_requires_clause = do_not_change
 resharper_linkage_specification_braces = end_of_line
 resharper_linkage_specification_indentation = none
 resharper_local_function_body = expression_body
-resharper_macro_block_begin = 
-resharper_macro_block_end = 
+resharper_macro_block_begin =
+resharper_macro_block_end =
 resharper_max_array_initializer_elements_on_line = 10000
 resharper_max_attribute_length_for_same_line = 38
 resharper_max_enum_members_on_line = 3
@@ -624,7 +625,7 @@ resharper_new_line_before_catch = true
 resharper_new_line_before_else = true
 resharper_new_line_before_enumerators = true
 resharper_normalize_tag_names = false
-resharper_no_indent_inside_elements = 
+resharper_no_indent_inside_elements =
 resharper_no_indent_inside_if_element_longer_than = 2000000
 resharper_null_checking_pattern_style = not_null_pattern
 resharper_object_creation_when_type_evident = target_typed
@@ -677,7 +678,7 @@ resharper_requires_expression_braces = next_line
 resharper_resx_allow_far_alignment = false
 resharper_resx_attribute_indent = single_indent
 resharper_resx_insert_final_newline = false
-resharper_resx_linebreak_before_elements = 
+resharper_resx_linebreak_before_elements =
 resharper_resx_max_blank_lines_between_tags = 0
 resharper_resx_max_line_length = 2147483647
 resharper_resx_pi_attribute_style = do_not_touch
@@ -904,7 +905,7 @@ resharper_xmldoc_wrap_text = true
 resharper_xml_allow_far_alignment = false
 resharper_xml_attribute_indent = align_by_first_attribute
 resharper_xml_insert_final_newline = false
-resharper_xml_linebreak_before_elements = 
+resharper_xml_linebreak_before_elements =
 resharper_xml_max_blank_lines_between_tags = 2
 resharper_xml_max_line_length = 180
 resharper_xml_pi_attribute_style = do_not_touch
diff --git a/.idea/.idea.MatrixRoomUtils/.idea/avalonia.xml b/.idea/.idea.MatrixRoomUtils/.idea/avalonia.xml
index f74ab1c..0aa65bb 100644
--- a/.idea/.idea.MatrixRoomUtils/.idea/avalonia.xml
+++ b/.idea/.idea.MatrixRoomUtils/.idea/avalonia.xml
@@ -12,6 +12,7 @@
         <entry key="MatrixRoomUtils.Desktop/MainWindow.axaml" value="MatrixRoomUtils.Desktop/MatrixRoomUtils.Desktop.csproj" />
         <entry key="MatrixRoomUtils.Desktop/NavigationStack.axaml" value="MatrixRoomUtils.Desktop/MatrixRoomUtils.Desktop.csproj" />
         <entry key="MatrixRoomUtils.Desktop/RoomListEntry.axaml" value="MatrixRoomUtils.Desktop/MatrixRoomUtils.Desktop.csproj" />
+        <entry key="MatrixUtils.Desktop/Components/RoomListEntry.axaml" value="MatrixUtils.Desktop/MatrixUtils.Desktop.csproj" />
       </map>
     </option>
   </component>
diff --git a/.idea/.idea.MatrixRoomUtils/.idea/inspectionProfiles/Project_Default.xml b/.idea/.idea.MatrixRoomUtils/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 0000000..55540ea
--- /dev/null
+++ b/.idea/.idea.MatrixRoomUtils/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,22 @@
+<component name="InspectionProjectProfileManager">
+  <profile version="1.0">
+    <option name="myName" value="Project Default" />
+    <inspection_tool class="HtmlUnknownTag" enabled="true" level="WARNING" enabled_by_default="true">
+      <option name="myValues">
+        <value>
+          <list size="7">
+            <item index="0" class="java.lang.String" itemvalue="nobr" />
+            <item index="1" class="java.lang.String" itemvalue="noembed" />
+            <item index="2" class="java.lang.String" itemvalue="comment" />
+            <item index="3" class="java.lang.String" itemvalue="noscript" />
+            <item index="4" class="java.lang.String" itemvalue="embed" />
+            <item index="5" class="java.lang.String" itemvalue="script" />
+            <item index="6" class="java.lang.String" itemvalue="width" />
+          </list>
+        </value>
+      </option>
+      <option name="myCustomValuesEnabled" value="true" />
+    </inspection_tool>
+    <inspection_tool class="JsonStandardCompliance" enabled="false" level="ERROR" enabled_by_default="false" />
+  </profile>
+</component>
\ No newline at end of file
diff --git a/LibMatrix b/LibMatrix
-Subproject 3dfb7b81b0fe19d37a7bf1183e248ca10c56277
+Subproject c7b7dbe3d929d787fe0c76015082a117c422227
diff --git a/MatrixRoomUtils.sln b/MatrixRoomUtils.sln
index 9cccf09..924697b 100644
--- a/MatrixRoomUtils.sln
+++ b/MatrixRoomUtils.sln
@@ -55,6 +55,9 @@ EndProject
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibMatrix.DevTestBot", "LibMatrix\Utilities\LibMatrix.DevTestBot\LibMatrix.DevTestBot.csproj", "{5CE239F8-C124-4A96-A0F8-B56B9AE27434}"
 EndProject
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibMatrix.HomeserverEmulator", "LibMatrix\Tests\LibMatrix.HomeserverEmulator\LibMatrix.HomeserverEmulator.csproj", "{6D93DA72-69D8-43BD-BC19-7FFF8A313971}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ArcaneLibs.UsageTest", "LibMatrix\ArcaneLibs\ArcaneLibs.UsageTest\ArcaneLibs.UsageTest.csproj", "{EDFC9BA8-CAA9-4B51-AFAF-834C1D74DCF0}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MatrixUtils.DmSpaced", "MatrixUtils.DmSpaced\MatrixUtils.DmSpaced.csproj", "{B3FEA1EF-6CFE-49C5-A0B2-11DB58D4CD1C}"
 EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -150,6 +153,14 @@ Global
 		{6D93DA72-69D8-43BD-BC19-7FFF8A313971}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{6D93DA72-69D8-43BD-BC19-7FFF8A313971}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{6D93DA72-69D8-43BD-BC19-7FFF8A313971}.Release|Any CPU.Build.0 = Release|Any CPU
+		{EDFC9BA8-CAA9-4B51-AFAF-834C1D74DCF0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{EDFC9BA8-CAA9-4B51-AFAF-834C1D74DCF0}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{EDFC9BA8-CAA9-4B51-AFAF-834C1D74DCF0}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{EDFC9BA8-CAA9-4B51-AFAF-834C1D74DCF0}.Release|Any CPU.Build.0 = Release|Any CPU
+		{B3FEA1EF-6CFE-49C5-A0B2-11DB58D4CD1C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{B3FEA1EF-6CFE-49C5-A0B2-11DB58D4CD1C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{B3FEA1EF-6CFE-49C5-A0B2-11DB58D4CD1C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{B3FEA1EF-6CFE-49C5-A0B2-11DB58D4CD1C}.Release|Any CPU.Build.0 = Release|Any CPU
 	EndGlobalSection
 	GlobalSection(NestedProjects) = preSolution
 		{F4E241C3-0300-4B87-8707-BCBDEF1F0185} = {8F4F6BEC-0C66-486B-A21A-1C35B2EDAD33}
@@ -172,5 +183,6 @@ Global
 		{CC836863-0EE8-44BD-BF39-45076F57C416} = {A4BCBF5F-4936-44B9-BAB3-FAF240BDF40D}
 		{5CE239F8-C124-4A96-A0F8-B56B9AE27434} = {A4BCBF5F-4936-44B9-BAB3-FAF240BDF40D}
 		{6D93DA72-69D8-43BD-BC19-7FFF8A313971} = {7D2C9959-8309-4110-A67F-DEE64E97C1D8}
+		{EDFC9BA8-CAA9-4B51-AFAF-834C1D74DCF0} = {B00C5CB6-6200-4B41-96BE-C6EAF1085A14}
 	EndGlobalSection
 EndGlobal
diff --git a/MatrixUtils.Abstractions/RoomInfo.cs b/MatrixUtils.Abstractions/RoomInfo.cs
index 877246b..53acbee 100644
--- a/MatrixUtils.Abstractions/RoomInfo.cs
+++ b/MatrixUtils.Abstractions/RoomInfo.cs
@@ -11,16 +11,17 @@ using LibMatrix.RoomTypes;
 namespace MatrixUtils.Abstractions;
 
 public class RoomInfo : NotifyPropertyChanged {
-    public required GenericRoom Room { get; set; }
-    public ObservableCollection<StateEventResponse?> StateEvents { get; } = new();
+    public readonly GenericRoom Room;
+    public ObservableCollection<StateEventResponse?> StateEvents { get; private set; } = new();
 
     private static ConcurrentBag<AuthenticatedHomeserverGeneric> homeserversWithoutEventFormatSupport = new();
-    
+    private static SvgIdenticonGenerator identiconGenerator = new();
+
     public async Task<StateEventResponse?> GetStateEvent(string type, string stateKey = "") {
         if (homeserversWithoutEventFormatSupport.Contains(Room.Homeserver)) return await GetStateEventForged(type, stateKey);
         var @event = StateEvents.FirstOrDefault(x => x?.Type == type && x.StateKey == stateKey);
         if (@event is not null) return @event;
-        
+
         try {
             @event = await Room.GetStateEventOrNullAsync(type, stateKey);
             StateEvents.Add(@event);
@@ -30,6 +31,7 @@ public class RoomInfo : NotifyPropertyChanged {
                 homeserversWithoutEventFormatSupport.Add(Room.Homeserver);
                 return await GetStateEventForged(type, stateKey);
             }
+
             Console.Error.WriteLine(e);
             await Task.Delay(1000);
             return await GetStateEvent(type, stateKey);
@@ -79,7 +81,7 @@ public class RoomInfo : NotifyPropertyChanged {
     }
 
     public string? RoomIcon {
-        get => _roomIcon ?? "https://api.dicebear.com/6.x/identicon/svg?seed=" + Room.RoomId;
+        get => _roomIcon ?? _fallbackIcon;
         set => SetField(ref _roomIcon, value);
     }
 
@@ -93,18 +95,17 @@ public class RoomInfo : NotifyPropertyChanged {
         set => SetField(ref _creationEventContent, value);
     }
 
+    private string? _roomCreator;
+
     public string? RoomCreator {
         get => _roomCreator;
         set => SetField(ref _roomCreator, value);
     }
 
-    // public string? GetRoomIcon() => (StateEvents.FirstOrDefault(x => x?.Type == RoomAvatarEventContent.EventId)?.TypedContent as RoomAvatarEventContent)?.Url ??
-    // "mxc://rory.gay/dgP0YPjJEWaBwzhnbyLLwGGv";
-
     private string? _roomIcon;
+    private readonly string _fallbackIcon;
     private string? _roomName;
     private RoomCreateEventContent? _creationEventContent;
-    private string? _roomCreator;
     private string? _overrideRoomType;
     private string? _defaultRoomName;
     private RoomMemberEventContent? _ownMembership;
@@ -130,11 +131,25 @@ public class RoomInfo : NotifyPropertyChanged {
         set => SetField(ref _ownMembership, value);
     }
 
-    public RoomInfo() {
+    public RoomInfo(GenericRoom room) {
+        Room = room;
+        _fallbackIcon = identiconGenerator.GenerateAsDataUri(room.RoomId);
+        registerEventListener();
+    }
+
+    public RoomInfo(GenericRoom room, List<StateEventResponse>? stateEvents) {
+        Room = room;
+        _fallbackIcon = identiconGenerator.GenerateAsDataUri(room.RoomId);
+        if (stateEvents is { Count: > 0 }) StateEvents = new(stateEvents!);
+        registerEventListener();
+    }
+
+    private void registerEventListener() {
         StateEvents.CollectionChanged += (_, args) => {
             if (args.NewItems is { Count: > 0 })
-                foreach (StateEventResponse? newState in args.NewItems) { // TODO: switch statement benchmark?
-                    if(newState is null) continue;
+                foreach (StateEventResponse? newState in args.NewItems) {
+                    // TODO: switch statement benchmark?
+                    if (newState is null) continue;
                     if (newState.Type == RoomNameEventContent.EventId && newState.TypedContent is RoomNameEventContent roomNameContent)
                         RoomName = roomNameContent.Name;
                     else if (newState is { Type: RoomAvatarEventContent.EventId, TypedContent: RoomAvatarEventContent roomAvatarContent })
@@ -146,4 +161,11 @@ public class RoomInfo : NotifyPropertyChanged {
                 }
         };
     }
-}
+
+    public async Task FetchAllStateAsync() {
+        var stateEvents = Room.GetFullStateAsync();
+        await foreach (var stateEvent in stateEvents) {
+            StateEvents.Add(stateEvent);
+        }
+    }
+}
\ No newline at end of file
diff --git a/MatrixUtils.Desktop/MainWindow.axaml.cs b/MatrixUtils.Desktop/MainWindow.axaml.cs
index 562ab1a..9c783e4 100644
--- a/MatrixUtils.Desktop/MainWindow.axaml.cs
+++ b/MatrixUtils.Desktop/MainWindow.axaml.cs
@@ -42,9 +42,7 @@ public partial class MainWindow : Window {
             // roomList.Children.Add(new RoomListEntry(_scopeFactory, new RoomInfo(room)));
 
             windowContent.Push("home", new RoomListEntry() {
-                Room = new RoomInfo() {
-                    Room = room
-                }
+                Room = new RoomInfo(room)
             });
             base.OnLoaded(e);
         }
diff --git a/MatrixUtils.DmSpaced/MatrixUtils.DmSpaced.csproj b/MatrixUtils.DmSpaced/MatrixUtils.DmSpaced.csproj
new file mode 100644
index 0000000..4b0f599
--- /dev/null
+++ b/MatrixUtils.DmSpaced/MatrixUtils.DmSpaced.csproj
@@ -0,0 +1,31 @@
+<Project Sdk="Microsoft.NET.Sdk">

+

+  <PropertyGroup>

+    <OutputType>Exe</OutputType>

+    <TargetFramework>net8.0</TargetFramework>

+    <LangVersion>preview</LangVersion>

+    <ImplicitUsings>enable</ImplicitUsings>

+    <Nullable>enable</Nullable>

+    <PublishAot>false</PublishAot>

+    <InvariantGlobalization>true</InvariantGlobalization>

+    <!--    <PublishTrimmed>true</PublishTrimmed>-->

+    <!--    <PublishReadyToRun>true</PublishReadyToRun>-->

+    <!--    <PublishSingleFile>true</PublishSingleFile>-->

+    <!--    <PublishReadyToRunShowWarnings>true</PublishReadyToRunShowWarnings>-->

+    <!--    <PublishTrimmedShowLinkerSizeComparison>true</PublishTrimmedShowLinkerSizeComparison>-->

+    <!--    <PublishTrimmedShowLinkerSizeComparisonWarnings>true</PublishTrimmedShowLinkerSizeComparisonWarnings>-->

+  </PropertyGroup>

+

+  <ItemGroup>

+    <ProjectReference Include="..\MatrixUtils.LibDMSpace\MatrixUtils.LibDMSpace.csproj" />

+  </ItemGroup>

+

+  <ItemGroup>

+    <PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />

+  </ItemGroup>

+  <ItemGroup>

+    <Content Include="appsettings*.json">

+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>

+    </Content>

+  </ItemGroup>

+</Project>

diff --git a/MatrixUtils.DmSpaced/ModerationBot.cs b/MatrixUtils.DmSpaced/ModerationBot.cs
new file mode 100644
index 0000000..6e534fc
--- /dev/null
+++ b/MatrixUtils.DmSpaced/ModerationBot.cs
@@ -0,0 +1,298 @@
+using ArcaneLibs.Extensions;
+using LibMatrix;
+using LibMatrix.EventTypes.Spec;
+using LibMatrix.EventTypes.Spec.State;
+using LibMatrix.EventTypes.Spec.State.Policy;
+using LibMatrix.Helpers;
+using LibMatrix.Homeservers;
+using LibMatrix.RoomTypes;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace ModerationBot;
+
+public class ModerationBot(AuthenticatedHomeserverGeneric hs, ILogger<ModerationBot> logger, ModerationBotConfiguration configuration, PolicyEngine engine) : IHostedService {
+    private Task _listenerTask;
+
+    // private GenericRoom _policyRoom;
+    private GenericRoom? _logRoom;
+    private GenericRoom? _controlRoom;
+
+    /// <summary>Triggered when the application host is ready to start the service.</summary>
+    /// <param name="cancellationToken">Indicates that the start process has been aborted.</param>
+    public async Task StartAsync(CancellationToken cancellationToken) {
+        _listenerTask = Run(cancellationToken);
+        logger.LogInformation("Bot started!");
+    }
+
+    private async Task Run(CancellationToken cancellationToken) {
+        if (Directory.Exists("bot_data/cache"))
+            Directory.GetFiles("bot_data/cache").ToList().ForEach(File.Delete);
+
+        BotData botData;
+
+        try {
+            logger.LogInformation("Fetching bot account data...");
+            botData = await hs.GetAccountDataAsync<BotData>("gay.rory.moderation_bot_data");
+            logger.LogInformation("Got bot account data...");
+        }
+        catch (Exception e) {
+            logger.LogInformation("Could not fetch bot account data... {}", e.Message);
+            if (e is not MatrixException { ErrorCode: "M_NOT_FOUND" }) {
+                logger.LogError("{}", e.ToString());
+                throw;
+            }
+
+            botData = null;
+        }
+
+        botData = await FirstRunTasks.ConstructBotData(hs, configuration, botData);
+
+        // _policyRoom = hs.GetRoom(botData.PolicyRoom ?? botData.ControlRoom);
+        _logRoom = hs.GetRoom(botData.LogRoom ?? botData.ControlRoom);
+        _controlRoom = hs.GetRoom(botData.ControlRoom);
+        foreach (var configurationAdmin in configuration.Admins) {
+            var pls = await _controlRoom.GetPowerLevelsAsync();
+            if (pls is null) {
+                await _logRoom?.SendMessageEventAsync(MessageFormatter.FormatWarning($"Control room has no m.room.power_levels?"));
+                continue;
+            }
+            pls.SetUserPowerLevel(configurationAdmin, pls.GetUserPowerLevel(hs.UserId));
+            await _controlRoom.SendStateEventAsync(RoomPowerLevelEventContent.EventId, pls);
+        }
+        var syncHelper = new SyncHelper(hs);
+
+        List<string> admins = new();
+
+#pragma warning disable CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed
+        Task.Run(async () => {
+            while (!cancellationToken.IsCancellationRequested) {
+                var controlRoomMembers = _controlRoom.GetMembersEnumerableAsync();
+                var pls = await _controlRoom.GetPowerLevelsAsync();
+                await foreach (var member in controlRoomMembers) {
+                    if ((member.TypedContent as RoomMemberEventContent)?
+                        .Membership == "join" && pls.UserHasTimelinePermission(member.Sender, RoomMessageEventContent.EventId)) admins.Add(member.StateKey);
+                }
+
+                await Task.Delay(TimeSpan.FromMinutes(5), cancellationToken);
+            }
+        }, cancellationToken);
+#pragma warning restore CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed
+
+        syncHelper.InviteReceivedHandlers.Add(async Task (args) => {
+            var inviteEvent =
+                args.Value.InviteState.Events.FirstOrDefault(x =>
+                    x.Type == "m.room.member" && x.StateKey == hs.UserId);
+            logger.LogInformation("Got invite to {RoomId} by {Sender} with reason: {Reason}", args.Key, inviteEvent!.Sender,
+                (inviteEvent.TypedContent as RoomMemberEventContent)!.Reason);
+            await _logRoom.SendMessageEventAsync(MessageFormatter.FormatSuccess($"Bot invited to {MessageFormatter.HtmlFormatMention(args.Key)} by {MessageFormatter.HtmlFormatMention(inviteEvent.Sender)}"));
+            if (admins.Contains(inviteEvent.Sender)) {
+                try {
+                    await _logRoom.SendMessageEventAsync(MessageFormatter.FormatSuccess($"Joining {MessageFormatter.HtmlFormatMention(args.Key)}..."));
+                    var senderProfile = await hs.GetProfileAsync(inviteEvent.Sender);
+                    await hs.GetRoom(args.Key).JoinAsync(reason: $"I was invited by {senderProfile.DisplayName ?? inviteEvent.Sender}!");
+                }
+                catch (Exception e) {
+                    logger.LogError("{}", e.ToString());
+                    await _logRoom.SendMessageEventAsync(MessageFormatter.FormatException("Could not join room", e));
+                    await hs.GetRoom(args.Key).LeaveAsync(reason: "I was unable to join the room: " + e);
+                }
+            }
+        });
+
+        syncHelper.TimelineEventHandlers.Add(async @event => {
+            var room = hs.GetRoom(@event.RoomId);
+            try {
+                logger.LogInformation(
+                    "Got timeline event in {}: {}", @event.RoomId, @event.ToJson(indent: true, ignoreNull: true));
+
+                if (@event != null && (
+                        @event.MappedType.IsAssignableTo(typeof(BasePolicy))
+                        || @event.MappedType.IsAssignableTo(typeof(PolicyRuleEventContent))
+                    )) {
+                    await LogPolicyChange(@event);
+                    await engine.ReloadActivePolicyListById(@event.RoomId);
+                }
+
+                var rules = await engine.GetMatchingPolicies(@event);
+                foreach (var matchedRule in rules) {
+                    await _logRoom.SendMessageEventAsync(MessageFormatter.FormatSuccessJson(
+                        $"{MessageFormatter.HtmlFormatMessageLink(eventId: @event.EventId, roomId: room.RoomId, displayName: "Event")} matched {MessageFormatter.HtmlFormatMessageLink(eventId: @matchedRule.OriginalEvent.EventId, roomId: matchedRule.PolicyList.Room.RoomId, displayName: "rule")}", @matchedRule.OriginalEvent.RawContent));
+                }
+
+                if (configuration.DemoMode) {
+                    // foreach (var matchedRule in rules) {
+                    // await room.SendMessageEventAsync(MessageFormatter.FormatSuccessJson(
+                    // $"{MessageFormatter.HtmlFormatMessageLink(eventId: @event.EventId, roomId: room.RoomId, displayName: "Event")} matched {MessageFormatter.HtmlFormatMessageLink(eventId: @matchedRule.EventId, roomId: matchedRule.RoomId, displayName: "rule")}", @matchedRule.RawContent));
+                    // }
+                    return;
+                }
+                //
+                //                 if (@event is { Type: "m.room.message", TypedContent: RoomMessageEventContent message }) {
+                //                     if (message is { MessageType: "m.image" }) {
+                //                         //check media
+                //                         // var matchedPolicy = await CheckMedia(@event);
+                //                         var matchedPolicy = rules.FirstOrDefault();
+                //                         if (matchedPolicy is null) return;
+                //                         var matchedpolicyData = matchedPolicy.TypedContent as MediaPolicyEventContent;
+                //                         await _logRoom.SendMessageEventAsync(
+                //                             new RoomMessageEventContent(
+                //                                 body:
+                //                                 $"User {MessageFormatter.HtmlFormatMention(@event.Sender)} posted an image in {MessageFormatter.HtmlFormatMention(room.RoomId)} that matched rule {matchedPolicy.StateKey}, applying action {matchedpolicyData.Recommendation}, as described in rule: {matchedPolicy.RawContent!.ToJson(ignoreNull: true)}",
+                //                                 messageType: "m.text") {
+                //                                 Format = "org.matrix.custom.html",
+                //                                 FormattedBody =
+                //                                     $"<font color=\"#FFFF00\">User {MessageFormatter.HtmlFormatMention(@event.Sender)} posted an image in {MessageFormatter.HtmlFormatMention(room.RoomId)} that matched rule {matchedPolicy.StateKey}, applying action {matchedpolicyData.Recommendation}, as described in rule: <pre>{matchedPolicy.RawContent!.ToJson(ignoreNull: true)}</pre></font>"
+                //                             });
+                //                         switch (matchedpolicyData.Recommendation) {
+                //                             case "warn_admins": {
+                //                                 await _controlRoom.SendMessageEventAsync(
+                //                                     new RoomMessageEventContent(
+                //                                         body: $"{string.Join(' ', admins)}\nUser {MessageFormatter.HtmlFormatMention(@event.Sender)} posted a banned image {message.Url}",
+                //                                         messageType: "m.text") {
+                //                                         Format = "org.matrix.custom.html",
+                //                                         FormattedBody = $"{string.Join(' ', admins.Select(u => MessageFormatter.HtmlFormatMention(u)))}\n" +
+                //                                                         $"<font color=\"#FF0000\">User {MessageFormatter.HtmlFormatMention(@event.Sender)} posted a banned image <a href=\"{message.Url}\">{message.Url}</a></font>"
+                //                                     });
+                //                                 break;
+                //                             }
+                //                             case "warn": {
+                //                                 await room.SendMessageEventAsync(
+                //                                     new RoomMessageEventContent(
+                //                                         body: $"Please be careful when posting this image: {matchedpolicyData.Reason ?? "No reason specified"}",
+                //                                         messageType: "m.text") {
+                //                                         Format = "org.matrix.custom.html",
+                //                                         FormattedBody =
+                //                                             $"<font color=\"#FFFF00\">Please be careful when posting this image: {matchedpolicyData.Reason ?? "No reason specified"}</a></font>"
+                //                                     });
+                //                                 break;
+                //                             }
+                //                             case "redact": {
+                //                                 await room.RedactEventAsync(@event.EventId, matchedpolicyData.Reason ?? "No reason specified");
+                //                                 break;
+                //                             }
+                //                             case "spoiler": {
+                //                                 // <blockquote>
+                //                                 //  <a href=\"https://matrix.to/#/@emma:rory.gay\">@emma:rory.gay</a><br>
+                //                                 //  <a href=\"https://codeberg.org/crimsonfork/CN\"></a>
+                //                                 //  <font color=\"#dc143c\" data-mx-color=\"#dc143c\">
+                //                                 //      <b>CN</b>
+                //                                 //  </font>:
+                //                                 //  <a href=\"https://the-apothecary.club/_matrix/media/v3/download/rory.gay/sLkdxUhipiQaFwRkXcPSRwdg\">test</a><br>
+                //                                 //  <span data-mx-spoiler=\"\"><a href=\"https://the-apothecary.club/_matrix/media/v3/download/rory.gay/sLkdxUhipiQaFwRkXcPSRwdg\">
+                //                                 //      <img src=\"mxc://rory.gay/sLkdxUhipiQaFwRkXcPSRwdg\" height=\"69\"></a>
+                //                                 //  </span>
+                //                                 // </blockquote>
+                //                                 await room.SendMessageEventAsync(
+                //                                     new RoomMessageEventContent(
+                //                                         body:
+                //                                         $"Please be careful when posting this image: {matchedpolicyData.Reason}, I have spoilered it for you:",
+                //                                         messageType: "m.text") {
+                //                                         Format = "org.matrix.custom.html",
+                //                                         FormattedBody =
+                //                                             $"<font color=\"#FFFF00\">Please be careful when posting this image: {matchedpolicyData.Reason}, I have spoilered it for you:</a></font>"
+                //                                     });
+                //                                 var imageUrl = message.Url;
+                //                                 await room.SendMessageEventAsync(
+                //                                     new RoomMessageEventContent(body: $"CN: {imageUrl}",
+                //                                         messageType: "m.text") {
+                //                                         Format = "org.matrix.custom.html",
+                //                                         FormattedBody = $"""
+                //                                                              <blockquote>
+                //                                                                 <font color=\"#dc143c\" data-mx-color=\"#dc143c\">
+                //                                                                     <b>CN</b>
+                //                                                                 </font>:
+                //                                                                 <a href=\"{imageUrl}\">{matchedpolicyData.Reason}</a><br>
+                //                                                                 <span data-mx-spoiler=\"\">
+                //                                                                     <a href=\"{imageUrl}\">
+                //                                                                         <img src=\"{imageUrl}\" height=\"69\">
+                //                                                                     </a>
+                //                                                                 </span>
+                //                                                              </blockquote>
+                //                                                          """
+                //                                     });
+                //                                 await room.RedactEventAsync(@event.EventId, "Automatically spoilered: " + matchedpolicyData.Reason);
+                //                                 break;
+                //                             }
+                //                             case "mute": {
+                //                                 await room.RedactEventAsync(@event.EventId, matchedpolicyData.Reason);
+                //                                 //change powerlevel to -1
+                //                                 var currentPls = await room.GetPowerLevelsAsync();
+                //                                 if (currentPls is null) {
+                //                                     logger.LogWarning("Unable to get power levels for {room}", room.RoomId);
+                //                                     await _logRoom.SendMessageEventAsync(
+                //                                         MessageFormatter.FormatError($"Unable to get power levels for {MessageFormatter.HtmlFormatMention(room.RoomId)}"));
+                //                                     return;
+                //                                 }
+                //
+                //                                 currentPls.Users ??= new();
+                //                                 currentPls.Users[@event.Sender] = -1;
+                //                                 await room.SendStateEventAsync("m.room.power_levels", currentPls);
+                //                                 break;
+                //                             }
+                //                             case "kick": {
+                //                                 await room.RedactEventAsync(@event.EventId, matchedpolicyData.Reason);
+                //                                 await room.KickAsync(@event.Sender, matchedpolicyData.Reason);
+                //                                 break;
+                //                             }
+                //                             case "ban": {
+                //                                 await room.RedactEventAsync(@event.EventId, matchedpolicyData.Reason);
+                //                                 await room.BanAsync(@event.Sender, matchedpolicyData.Reason);
+                //                                 break;
+                //                             }
+                //                             default: {
+                //                                 throw new ArgumentOutOfRangeException("recommendation",
+                //                                     $"Unknown response type {matchedpolicyData.Recommendation}!");
+                //                             }
+                //                         }
+                //                     }
+                //                 }
+            }
+            catch (Exception e) {
+                logger.LogError("{}", e.ToString());
+                await _controlRoom.SendMessageEventAsync(
+                    MessageFormatter.FormatException($"Unable to process event in {MessageFormatter.HtmlFormatMention(room.RoomId)}", e));
+                await _logRoom.SendMessageEventAsync(
+                    MessageFormatter.FormatException($"Unable to process event in {MessageFormatter.HtmlFormatMention(room.RoomId)}", e));
+                await using var stream = new MemoryStream(e.ToString().AsBytes().ToArray());
+                await _controlRoom.SendFileAsync("error.log.cs", stream);
+                await _logRoom.SendFileAsync("error.log.cs", stream);
+            }
+        });
+        await engine.ReloadActivePolicyLists();
+        await syncHelper.RunSyncLoopAsync();
+    }
+
+    private async Task LogPolicyChange(StateEventResponse changeEvent) {
+        var room = hs.GetRoom(changeEvent.RoomId!);
+        var message = MessageFormatter.FormatWarning($"Policy change detected in {MessageFormatter.HtmlFormatMessageLink(changeEvent.RoomId, changeEvent.EventId, [hs.ServerName], await room.GetNameOrFallbackAsync())}!");
+        message = message.ConcatLine(new RoomMessageEventContent(body: $"Policy type: {changeEvent.Type} -> {changeEvent.MappedType.Name}") {
+            FormattedBody = $"Policy type: {changeEvent.Type} -> {changeEvent.MappedType.Name}"
+        });
+        var isUpdated = changeEvent.Unsigned.PrevContent is { Count: > 0 };
+        var isRemoved = changeEvent.RawContent is not { Count: > 0 };
+        // if (isUpdated) {
+        //     message = message.ConcatLine(MessageFormatter.FormatSuccess("Rule updated!"));
+        //     message = message.ConcatLine(MessageFormatter.FormatSuccessJson("Old rule:", changeEvent.Unsigned.PrevContent!));
+        // }
+        // else if (isRemoved) {
+        //     message = message.ConcatLine(MessageFormatter.FormatWarningJson("Rule removed!", changeEvent.Unsigned.PrevContent!));
+        // }
+        // else {
+        //     message = message.ConcatLine(MessageFormatter.FormatSuccess("New rule added!"));
+        // }
+        message = message.ConcatLine(MessageFormatter.FormatSuccessJson($"{(isUpdated ? "Updated" : isRemoved ? "Removed" : "New")} rule: {changeEvent.StateKey}", changeEvent.RawContent!));
+        if (isRemoved || isUpdated) {
+            message = message.ConcatLine(MessageFormatter.FormatSuccessJson("Old content: ", changeEvent.Unsigned.PrevContent!));
+        }
+        
+        await _logRoom.SendMessageEventAsync(message);
+    }
+
+    /// <summary>Triggered when the application host is performing a graceful shutdown.</summary>
+    /// <param name="cancellationToken">Indicates that the shutdown process should no longer be graceful.</param>
+    public async Task StopAsync(CancellationToken cancellationToken) {
+        logger.LogInformation("Shutting down bot!");
+    }
+
+}
diff --git a/MatrixUtils.DmSpaced/ModerationBotConfiguration.cs b/MatrixUtils.DmSpaced/ModerationBotConfiguration.cs
new file mode 100644
index 0000000..47e6423
--- /dev/null
+++ b/MatrixUtils.DmSpaced/ModerationBotConfiguration.cs
@@ -0,0 +1,10 @@
+using Microsoft.Extensions.Configuration;
+
+namespace ModerationBot;
+
+public class ModerationBotConfiguration {
+    public ModerationBotConfiguration(IConfiguration config) => config.GetRequiredSection("ModerationBot").Bind(this);
+
+    public string Homeserver { get; set; }
+    public string AccessToken { get; set; }
+}
diff --git a/MatrixUtils.DmSpaced/Program.cs b/MatrixUtils.DmSpaced/Program.cs
new file mode 100644
index 0000000..ae352b7
--- /dev/null
+++ b/MatrixUtils.DmSpaced/Program.cs
@@ -0,0 +1,40 @@
+// See https://aka.ms/new-console-template for more information
+
+using LibMatrix.Services;
+using LibMatrix.Utilities.Bot;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using ModerationBot;
+using ModerationBot.Services;
+
+Console.WriteLine("Hello, World!");
+
+var builder = Host.CreateDefaultBuilder(args);
+
+builder.ConfigureHostOptions(host => {
+    host.ServicesStartConcurrently = true;
+    host.ServicesStopConcurrently = true;
+    host.ShutdownTimeout = TimeSpan.FromSeconds(5);
+});
+
+if (Environment.GetEnvironmentVariable("MODERATIONBOT_APPSETTINGS_PATH") is string path)
+    builder.ConfigureAppConfiguration(x => x.AddJsonFile(path));
+
+var host = builder.ConfigureServices((_, services) => {
+    services.AddScoped<TieredStorageService>(x =>
+        new TieredStorageService(
+            cacheStorageProvider: new FileStorageProvider("bot_data/cache/"),
+            dataStorageProvider: new FileStorageProvider("bot_data/data/")
+        )
+    );
+    services.AddSingleton<ModerationBotConfiguration>();
+
+    services.AddRoryLibMatrixServices();
+
+    services.AddSingleton<ModerationBotRoomProvider>();
+
+    services.AddHostedService<ModerationBot.ModerationBot>();
+}).UseConsoleLifetime().Build();
+
+await host.RunAsync();
\ No newline at end of file
diff --git a/MatrixUtils.DmSpaced/appsettings.Development.json b/MatrixUtils.DmSpaced/appsettings.Development.json
new file mode 100644
index 0000000..224d0da
--- /dev/null
+++ b/MatrixUtils.DmSpaced/appsettings.Development.json
@@ -0,0 +1,24 @@
+{
+  "Logging": {
+    "LogLevel": {
+      "Default": "Debug",
+      "System": "Information",
+      "Microsoft": "Information"
+    }
+  },
+  "LibMatrixBot": {
+    // The homeserver to connect to
+    "Homeserver": "rory.gay",
+    // The access token to use
+    "AccessToken": "syt_xxxxxxxxxxxxxxxxx",
+    // The command prefix
+    "Prefix": "?"
+  },
+  "ModerationBot": {
+    // List of people who should be invited to the control room
+    "Admins": [
+      "@emma:conduit.rory.gay",
+      "@emma:rory.gay"
+    ]
+  }
+}
diff --git a/MatrixUtils.DmSpaced/appsettings.json b/MatrixUtils.DmSpaced/appsettings.json
new file mode 100644
index 0000000..6ba02f3
--- /dev/null
+++ b/MatrixUtils.DmSpaced/appsettings.json
@@ -0,0 +1,9 @@
+{
+    "Logging": {
+        "LogLevel": {
+            "Default": "Debug",
+            "System": "Information",
+            "Microsoft": "Information"
+        }
+    }
+}
diff --git a/MatrixUtils.LibDMSpace/DMSpaceRoom.cs b/MatrixUtils.LibDMSpace/DMSpaceRoom.cs
index 3fb0ab6..e2c8192 100644
--- a/MatrixUtils.LibDMSpace/DMSpaceRoom.cs
+++ b/MatrixUtils.LibDMSpace/DMSpaceRoom.cs
@@ -1,6 +1,10 @@
+using System.Net;
 using ArcaneLibs.Extensions;
 using LibMatrix;
+using LibMatrix.EventTypes.Spec.State;
+using LibMatrix.EventTypes.Spec.State.RoomInfo;
 using LibMatrix.Homeservers;
+using LibMatrix.Responses;
 using LibMatrix.RoomTypes;
 using MatrixUtils.LibDMSpace.StateEvents;
 
@@ -9,75 +13,179 @@ namespace MatrixUtils.LibDMSpace;
 public class DMSpaceRoom(AuthenticatedHomeserverGeneric homeserver, string roomId) : SpaceRoom(homeserver, roomId) {
     private readonly GenericRoom _room;
 
-    public async Task<DMSpaceInfo?> GetDmSpaceInfo() {
+    // ReSharper disable once InconsistentNaming
+    public async Task<DMSpaceInfo?> GetDMSpaceInfo() {
         return await GetStateOrNullAsync<DMSpaceInfo>(DMSpaceInfo.EventId);
     }
 
-    public async IAsyncEnumerable<GenericRoom> GetChildrenAsync(bool includeRemoved = false) {
-        var rooms = new List<GenericRoom>();
+    public async Task<Dictionary<string, List<string>>?> ExportNativeDMs() {
         var state = GetFullStateAsync();
-        await foreach (var stateEvent in state) {
-            if (stateEvent!.Type != "m.space.child") continue;
-            if (stateEvent.RawContent!.ToJson() != "{}" || includeRemoved)
-                yield return homeserver.GetRoom(stateEvent.StateKey);
-        }
-    }
-
-    public async Task<EventIdResponse> AddChildAsync(GenericRoom room) {
-        var members = room.GetMembersEnumerableAsync(true);
-        Dictionary<string, int> memberCountByHs = new();
-        await foreach (var member in members) {
-            var server = member.StateKey.Split(':')[1];
-            if (memberCountByHs.ContainsKey(server)) memberCountByHs[server]++;
-            else memberCountByHs[server] = 1;
-        }
+        var mdirect = new Dictionary<string, List<string>>();
+        await foreach (var stateEvent in state) { }
 
-        var resp = await SendStateEventAsync("m.space.child", room.RoomId, new {
-            via = memberCountByHs
-                .OrderByDescending(x => x.Value)
-                .Select(x => x.Key)
-                .Take(10)
-        });
-        return resp;
+        return mdirect;
     }
 
     public async Task ImportNativeDMs() {
-        var dmSpaceInfo = await GetDmSpaceInfo();
+        var dmSpaceInfo = await GetDMSpaceInfo();
         if (dmSpaceInfo is null) throw new NullReferenceException("DM Space is not configured!");
         if (dmSpaceInfo.LayerByUser)
             await ImportNativeDMsIntoLayers();
         else await ImportNativeDMsWithoutLayers();
     }
 
-    #region Import Native DMs
+    public async Task<List<StateEventResponse>> GetAllActiveLayersAsync() {
+        var state = await GetFullStateAsListAsync();
+        return state.Where(x => x.Type == DMSpaceChildLayer.EventId && x.RawContent.ContainsKey("space_id")).ToList();
+    }
+
+#region Import Native DMs
 
     private async Task ImportNativeDMsWithoutLayers() {
         var mdirect = await homeserver.GetAccountDataAsync<Dictionary<string, List<string>>>("m.direct");
         foreach (var (userId, dmRooms) in mdirect) {
             foreach (var roomid in dmRooms) {
                 var dri = new DMRoomInfo() {
-                    RemoteUsers = new() {
-                        userId
-                    }
+                    AttributedUser = userId
                 };
-                // Add all DM room members
-                var members = homeserver.GetRoom(roomid).GetMembersEnumerableAsync();
-                await foreach (var member in members)
-                    if (member.StateKey != userId)
-                        dri.RemoteUsers.Add(member.StateKey);
-                // Remove members of DM space
-                members = GetMembersEnumerableAsync();
-                await foreach (var member in members)
-                    if (dri.RemoteUsers.Contains(member.StateKey))
-                        dri.RemoteUsers.Remove(member.StateKey);
                 await SendStateEventAsync(DMRoomInfo.EventId, roomid, dri);
+                await AddChildAsync(Homeserver.GetRoom(roomid));
             }
         }
     }
 
     private async Task ImportNativeDMsIntoLayers() {
         var mdirect = await homeserver.GetAccountDataAsync<Dictionary<string, List<string>>>("m.direct");
+        var layerTasks = mdirect.Select(async entry => {
+            var (userId, dmRooms) = entry;
+            DMSpaceChildLayer? layer = await GetStateOrNullAsync<DMSpaceChildLayer>(DMSpaceChildLayer.EventId, userId.UrlEncode()) ?? await CreateLayer(userId);
+            return (entry, layer);
+        }).ToAsyncEnumerable();
+
+        await foreach (var ((userId, dmRooms), layer) in layerTasks) {
+            var space = Homeserver.GetRoom(layer.SpaceId).AsSpace;
+            foreach (var roomid in dmRooms) {
+                var dri = new DMRoomInfo() {
+                    AttributedUser = userId
+                };
+                await space.SendStateEventAsync(DMRoomInfo.EventId, roomid, dri);
+                await space.AddChildAsync(Homeserver.GetRoom(roomid));
+            }
+
+            await UpdateLayer(layer, userId);
+        }
+
+        // ensure all spaces are linked
+        Console.WriteLine("Ensuring all child layers are inside space...");
+        var layerAssuranceTasks = (await GetFullStateAsListAsync())
+            .Where(x => x.Type == DMSpaceChildLayer.EventId && (x.RawContent?.ContainsKey("space_id") ?? false))
+            .Select(async layer => {
+                var content = layer.TypedContent as DMSpaceChildLayer;
+                var space = homeserver.GetRoom(content!.SpaceId);
+                try {
+                    var state = await space.GetFullStateAsListAsync();
+                    if (!state.Any(e => e.Type == DMRoomInfo.EventId)) throw new Exception();
+                }
+                catch {
+                    await homeserver.JoinRoomAsync(content!.SpaceId);
+                }
+
+                return AddChildAsync(space);
+            });
+        await Task.WhenAll(layerAssuranceTasks);
+        Console.WriteLine("All child layers should be inside of space, if not, something went horribly wrong!");
+    }
+
+    private async Task<DMSpaceChildLayer> CreateLayer(string userId) {
+        var childCreateRequest = CreateRoomRequest.CreatePrivate(homeserver, userId);
+        childCreateRequest.CreationContent["type"] = "m.space";
+
+        var layer = new DMSpaceChildLayer() {
+            SpaceId = (await homeserver.CreateRoom(childCreateRequest)).RoomId
+        };
+
+        await SendStateEventAsync(DMSpaceChildLayer.EventId, userId[1..], layer);
+        await AddChildAsync(Homeserver.GetRoom(layer.SpaceId));
+        return layer;
+    }
+
+    private async Task UpdateLayerProfilesAsync() {
+        var layers = await GetAllActiveLayersAsync();
+        var getProfileTasks = layers.Select(async x => {
+            UserProfileResponse? profile = null;
+            try {
+                return (x, profile);
+            }
+            catch {
+                return (x, null);
+            }
+
+        }).ToAsyncEnumerable();
+        await foreach (var (layer, profile) in getProfileTasks) {
+            if (profile is null) continue;
+            var layerContent = layer.TypedContent as DMSpaceChildLayer;
+            var space = Homeserver.GetRoom(layerContent!.SpaceId).AsSpace;
+
+            try {
+                await space.SendStateEventAsync(RoomAvatarEventContent.EventId, "", new RoomAvatarEventContent() {
+                    Url = layerContent.OverrideAvatar ?? profile?.AvatarUrl
+                });
+                await space.SendStateEventAsync(RoomNameEventContent.EventId, "", new RoomNameEventContent() {
+                    Name = layerContent.OverrideName ?? profile?.DisplayName ?? "@" + layer.StateKey.UrlDecode()
+                });
+            }
+            catch (MatrixException e) {
+                Console.WriteLine("Failed to update space: {0}", e);
+            }
+        }
+    }
+
+    private async Task UpdateLayer(DMSpaceChildLayer layer, string mxid) {
+        UserProfileResponse? profile = null;
+        var space = Homeserver.GetRoom(layer.SpaceId).AsSpace;
+
+        if (string.IsNullOrWhiteSpace(layer.OverrideAvatar) || string.IsNullOrWhiteSpace(layer.OverrideName)) {
+            try {
+                profile = await homeserver.GetProfileAsync(mxid);
+            }
+            catch (MatrixException e) {
+                // if (e.ErrorCode != "M_NOT_FOUND") throw;
+                Console.Error.WriteLine(e);
+            }
+        }
+
+        try {
+            await space.SendStateEventAsync(RoomAvatarEventContent.EventId, "", new RoomAvatarEventContent() {
+                Url = layer.OverrideAvatar ?? profile?.AvatarUrl
+            });
+            await space.SendStateEventAsync(RoomNameEventContent.EventId, "", new RoomNameEventContent() {
+                Name = layer.OverrideName ?? profile?.DisplayName ?? mxid
+            });
+        }
+        catch (MatrixException e) {
+            Console.WriteLine("Failed to update space: {0}", e);
+        }
+    }
+
+    public async Task DisbandDMSpace() {
+        var state = await GetFullStateAsListAsync();
+        var leaveTasks = state.Select(async x => {
+            if (x.Type != DMSpaceChildLayer.EventId) return;
+            var content = x.TypedContent as DMSpaceChildLayer;
+            if (content?.SpaceId is null) return;
+            var space = homeserver.GetRoom(content.SpaceId);
+            try {
+                await space.LeaveAsync();
+            }
+            catch {
+                // might not be in room, doesnt matter
+            }
+        });
+
+        await LeaveAsync();
+
+        await Task.WhenAll(leaveTasks);
     }
 
-    #endregion
-}
+#endregion
+}
\ No newline at end of file
diff --git a/MatrixUtils.LibDMSpace/HomeserverExtensions.cs b/MatrixUtils.LibDMSpace/HomeserverExtensions.cs
new file mode 100644
index 0000000..2bf5eaa
--- /dev/null
+++ b/MatrixUtils.LibDMSpace/HomeserverExtensions.cs
@@ -0,0 +1,10 @@
+using LibMatrix.Homeservers;
+
+namespace MatrixUtils.LibDMSpace;
+
+public static class HomeserverExtensions {
+    public static async Task<DMSpaceRoom> GetDMSpaceRoomAsync(this AuthenticatedHomeserverGeneric homeserver) {
+        return null; //TODO: implement
+    }
+    
+}
\ No newline at end of file
diff --git a/MatrixUtils.LibDMSpace/StateEvents/DMRoomInfo.cs b/MatrixUtils.LibDMSpace/StateEvents/DMRoomInfo.cs
index 5aa62d7..bc595b5 100644
--- a/MatrixUtils.LibDMSpace/StateEvents/DMRoomInfo.cs
+++ b/MatrixUtils.LibDMSpace/StateEvents/DMRoomInfo.cs
@@ -5,10 +5,13 @@ using LibMatrix.Interfaces;
 namespace MatrixUtils.LibDMSpace.StateEvents;
 
 [MatrixEvent(EventName = EventId)]
-public class DMRoomInfo : TimelineEventContent {
+public class DMRoomInfo : EventContent {
     public const string EventId = "gay.rory.dm_room_info";
-    [JsonPropertyName("remote_users")]
-    public List<string> RemoteUsers { get; set; }
+    // [JsonPropertyName("remote_users")]
+    // public List<string> RemoteUsers { get; set; }
 
+    [JsonPropertyName("attributed_user")]
+    public string AttributedUser { get; set; }
+    
 
 }
diff --git a/MatrixUtils.LibDMSpace/StateEvents/DMSpaceChildLayer.cs b/MatrixUtils.LibDMSpace/StateEvents/DMSpaceChildLayer.cs
new file mode 100644
index 0000000..16c7b70
--- /dev/null
+++ b/MatrixUtils.LibDMSpace/StateEvents/DMSpaceChildLayer.cs
@@ -0,0 +1,20 @@
+using System.Text.Json.Serialization;
+using LibMatrix.EventTypes;
+using LibMatrix.Interfaces;
+
+namespace MatrixUtils.LibDMSpace.StateEvents;
+
+[MatrixEvent(EventName = EventId)]
+public class DMSpaceChildLayer : EventContent {
+    public const string EventId = "gay.rory.dm_space_child_layer";
+
+    [JsonPropertyName("space_id")]
+    public string SpaceId { get; set; }
+    
+    [JsonPropertyName("override_name")]
+    public string? OverrideName { get; set; }
+    
+    [JsonPropertyName("override_avatar")]
+    public string? OverrideAvatar { get; set; }
+    
+}
diff --git a/MatrixUtils.LibDMSpace/StateEvents/DMSpaceInfo.cs b/MatrixUtils.LibDMSpace/StateEvents/DMSpaceInfo.cs
index 0df1913..f5daa74 100644
--- a/MatrixUtils.LibDMSpace/StateEvents/DMSpaceInfo.cs
+++ b/MatrixUtils.LibDMSpace/StateEvents/DMSpaceInfo.cs
@@ -5,7 +5,7 @@ using LibMatrix.Interfaces;
 namespace MatrixUtils.LibDMSpace.StateEvents;
 
 [MatrixEvent(EventName = EventId)]
-public class DMSpaceInfo : TimelineEventContent {
+public class DMSpaceInfo : EventContent {
     public const string EventId = "gay.rory.dm_space_info";
 
     [JsonPropertyName("is_layered")]
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);
         }
 
diff --git a/scripts/deploy.sh b/scripts/deploy.sh
index 4c60728..1abe9e7 100755
--- a/scripts/deploy.sh
+++ b/scripts/deploy.sh
@@ -11,4 +11,4 @@ BASE_DIR=`pwd`
 rm -rf **/bin/Release
 cd MatrixUtils.Web
 dotnet publish -c Release
-rsync -raP bin/Release/net8.0/publish/wwwroot/ rory.gay:/data/nginx/html_mru/
+rsync --delete -raP bin/Release/net8.0/publish/wwwroot/ rory.gay:/data/nginx/html_mru/