about summary refs log tree commit diff
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--.gitignore1
-rw-r--r--.idea/.idea.MatrixRoomUtils/.idea/inspectionProfiles/Project_Default.xml22
-rw-r--r--.idea/.idea.MatrixUtils/.idea/.gitignore (renamed from .idea/.idea.MatrixRoomUtils/.idea/.gitignore)0
-rw-r--r--.idea/.idea.MatrixUtils/.idea/.name1
-rw-r--r--.idea/.idea.MatrixUtils/.idea/avalonia.xml (renamed from .idea/.idea.MatrixRoomUtils/.idea/avalonia.xml)0
-rw-r--r--.idea/.idea.MatrixUtils/.idea/codeStyles/codeStyleConfig.xml (renamed from .idea/.idea.MatrixRoomUtils/.idea/codeStyles/codeStyleConfig.xml)0
-rw-r--r--.idea/.idea.MatrixUtils/.idea/encodings.xml (renamed from .idea/.idea.MatrixRoomUtils/.idea/encodings.xml)0
-rw-r--r--.idea/.idea.MatrixUtils/.idea/indexLayout.xml (renamed from .idea/.idea.MatrixRoomUtils/.idea/indexLayout.xml)0
-rw-r--r--.idea/.idea.MatrixUtils/.idea/inspectionProfiles/Project_Default.xml53
-rw-r--r--.idea/.idea.MatrixUtils/.idea/vcs.xml (renamed from .idea/.idea.MatrixRoomUtils/.idea/vcs.xml)0
-rw-r--r--Benchmarks/.gitignore3
-rw-r--r--Benchmarks/Benchmarks.csproj17
-rw-r--r--Benchmarks/Program.cs300
m---------LibMatrix0
-rw-r--r--MatrixRoomUtils.sln232
-rw-r--r--MatrixUtils.Abstractions/FileStorageProvider.cs1
-rw-r--r--MatrixUtils.Abstractions/MatrixUtils.Abstractions.csproj6
-rw-r--r--MatrixUtils.Abstractions/RoomInfo.cs1
-rw-r--r--MatrixUtils.Desktop/Components/RoomListEntry.axaml.cs8
-rw-r--r--MatrixUtils.Desktop/MatrixUtils.Desktop.csproj18
-rw-r--r--MatrixUtils.DmSpaced/MatrixUtils.DmSpaced.csproj2
-rw-r--r--MatrixUtils.DmSpaced/Program.cs14
-rw-r--r--MatrixUtils.LibDMSpace/DMSpaceRoom.cs2
-rw-r--r--MatrixUtils.LibDMSpace/MatrixUtils.LibDMSpace.csproj4
-rw-r--r--MatrixUtils.LibDMSpace/StateEvents/DMRoomInfo.cs1
-rw-r--r--MatrixUtils.LibDMSpace/StateEvents/DMSpaceChildLayer.cs1
-rw-r--r--MatrixUtils.LibDMSpace/StateEvents/DMSpaceInfo.cs1
-rw-r--r--MatrixUtils.Web.Server/MatrixUtils.Web.Server.csproj4
-rw-r--r--MatrixUtils.Web/App.razor3
-rw-r--r--MatrixUtils.Web/Classes/Constants/RoomConstants.cs5
-rw-r--r--MatrixUtils.Web/Classes/RMUStorageWrapper.cs17
-rw-r--r--MatrixUtils.Web/Classes/RoomCreationTemplates/DefaultRoomCreationTemplate.cs5
-rw-r--r--MatrixUtils.Web/MatrixUtils.Web.csproj86
-rw-r--r--MatrixUtils.Web/Pages/About.razor4
-rw-r--r--MatrixUtils.Web/Pages/Dev/DevOptions.razor1
-rw-r--r--MatrixUtils.Web/Pages/Dev/DevUtilities.razor2
-rw-r--r--MatrixUtils.Web/Pages/Dev/ModalTest.razor12
-rw-r--r--MatrixUtils.Web/Pages/HSAdmin/HSAdmin.razor2
-rw-r--r--MatrixUtils.Web/Pages/HSAdmin/RoomQuery.razor2
-rw-r--r--MatrixUtils.Web/Pages/HSEInit.razor2
-rw-r--r--MatrixUtils.Web/Pages/Index.razor80
-rw-r--r--MatrixUtils.Web/Pages/InvalidSession.razor18
-rw-r--r--MatrixUtils.Web/Pages/Labs/Client/Index.razor4
-rw-r--r--MatrixUtils.Web/Pages/Labs/DMSpace/DMSpace.razor2
-rw-r--r--MatrixUtils.Web/Pages/Labs/DMSpace/DMSpaceStages/DMSpaceStage1.razor3
-rw-r--r--MatrixUtils.Web/Pages/Labs/DMSpace/DMSpaceStages/DMSpaceStage2.razor2
-rw-r--r--MatrixUtils.Web/Pages/Labs/DMSpace/DMSpaceStages/DMSpaceStage3.razor2
-rw-r--r--MatrixUtils.Web/Pages/Labs/Rooms2/Index2.razor2
-rw-r--r--MatrixUtils.Web/Pages/Labs/Rooms2/Index2Components/MainTabComponents/MainTabSpaceItem.razor2
-rw-r--r--MatrixUtils.Web/Pages/Labs/Rooms2/Index2Components/RoomsIndex2MainTab.razor39
-rw-r--r--MatrixUtils.Web/Pages/Labs/Rooms2/Index2Components/RoomsIndex2SyncContainer.razor2
-rw-r--r--MatrixUtils.Web/Pages/LoginPage.razor54
-rw-r--r--MatrixUtils.Web/Pages/Moderation/UserRoomHistory.razor8
-rw-r--r--MatrixUtils.Web/Pages/Rooms/Create.razor12
-rw-r--r--MatrixUtils.Web/Pages/Rooms/Index.razor4
-rw-r--r--MatrixUtils.Web/Pages/Rooms/PolicyList.razor284
-rw-r--r--MatrixUtils.Web/Pages/Rooms/PolicyList.razor.css9
-rw-r--r--MatrixUtils.Web/Pages/Rooms/PolicyList2.razor240
-rw-r--r--MatrixUtils.Web/Pages/Rooms/PolicyList2.razor.css32
-rw-r--r--MatrixUtils.Web/Pages/Rooms/PolicyLists.razor176
-rw-r--r--MatrixUtils.Web/Pages/Rooms/PolicyLists.razor.css6
-rw-r--r--MatrixUtils.Web/Pages/Rooms/Space.razor53
-rw-r--r--MatrixUtils.Web/Pages/Rooms/StateEditor.razor4
-rw-r--r--MatrixUtils.Web/Pages/Rooms/StateViewer.razor4
-rw-r--r--MatrixUtils.Web/Pages/Rooms/Timeline.razor4
-rw-r--r--MatrixUtils.Web/Pages/ServerInfo.razor2
-rw-r--r--MatrixUtils.Web/Pages/StreamTest.razor119
-rw-r--r--MatrixUtils.Web/Pages/Tools/Debug/LeaveRoom.razor2
-rw-r--r--MatrixUtils.Web/Pages/Tools/Debug/MediaLocator.razor2
-rw-r--r--MatrixUtils.Web/Pages/Tools/Debug/MigrateRoom.razor6
-rw-r--r--MatrixUtils.Web/Pages/Tools/Debug/SpaceDebug.razor2
-rw-r--r--MatrixUtils.Web/Pages/Tools/Index.razor2
-rw-r--r--MatrixUtils.Web/Pages/Tools/Info/KnownHomeserverList.razor48
-rw-r--r--MatrixUtils.Web/Pages/Tools/Info/PolicyListActivity.razor31
-rw-r--r--MatrixUtils.Web/Pages/Tools/Info/SessionCount.razor8
-rw-r--r--MatrixUtils.Web/Pages/Tools/InviteCounter.razor31
-rw-r--r--MatrixUtils.Web/Pages/Tools/MassCMEBan.razor8
-rw-r--r--MatrixUtils.Web/Pages/Tools/Moderation/Draupnir/DraupnirProtectedRoomsEditor.razor138
-rw-r--r--MatrixUtils.Web/Pages/Tools/Moderation/Draupnir/DraupnirProtectionsEditor.razor (renamed from MatrixUtils.Web/Pages/Tools/Moderation/DraupnirProtectedRoomsEditor.razor)47
-rw-r--r--MatrixUtils.Web/Pages/Tools/Moderation/Draupnir/DraupnirWatchedListsEditor.razor139
-rw-r--r--MatrixUtils.Web/Pages/Tools/Moderation/FindUsersByRegex.razor192
-rw-r--r--MatrixUtils.Web/Pages/Tools/Moderation/InviteCounter.razor4
-rw-r--r--MatrixUtils.Web/Pages/Tools/Moderation/MassCMEBan.razor53
-rw-r--r--MatrixUtils.Web/Pages/Tools/Moderation/MembershipHistory.razor680
-rw-r--r--MatrixUtils.Web/Pages/Tools/Moderation/RoomIntersections.razor4
-rw-r--r--MatrixUtils.Web/Pages/Tools/Moderation/UserTrace.razor152
-rw-r--r--MatrixUtils.Web/Pages/Tools/Room/SpaceRestrictedJoins.razor4
-rw-r--r--MatrixUtils.Web/Pages/Tools/User/CopyPowerlevel.razor12
-rw-r--r--MatrixUtils.Web/Pages/Tools/User/MassJoinRoom.razor8
-rw-r--r--MatrixUtils.Web/Pages/Tools/User/ViewAccountData.razor4
-rw-r--r--MatrixUtils.Web/Pages/User/DMManager.razor4
-rw-r--r--MatrixUtils.Web/Pages/User/Profile.razor97
-rw-r--r--MatrixUtils.Web/Program.cs17
-rw-r--r--MatrixUtils.Web/Properties/launchSettings.json2
-rw-r--r--MatrixUtils.Web/Shared/InlineUserItem.razor6
-rw-r--r--MatrixUtils.Web/Shared/MainLayout.razor5
-rw-r--r--MatrixUtils.Web/Shared/MainLayout.razor.css1
-rw-r--r--MatrixUtils.Web/Shared/MxcAvatar.razor55
-rw-r--r--MatrixUtils.Web/Shared/MxcImage.razor28
-rw-r--r--MatrixUtils.Web/Shared/NavMenu.razor6
-rw-r--r--MatrixUtils.Web/Shared/PolicyEditorComponents/MassPolicyEditorModal.razor102
-rw-r--r--MatrixUtils.Web/Shared/PolicyEditorComponents/PolicyEditorModal.razor60
-rw-r--r--MatrixUtils.Web/Shared/RoomListComponents/RoomListCategory.razor2
-rw-r--r--MatrixUtils.Web/Shared/RoomListComponents/RoomListSpace.razor2
-rw-r--r--MatrixUtils.Web/Shared/RoomListItem.razor39
-rw-r--r--MatrixUtils.Web/Shared/TimelineComponents/BaseTimelineItem.razor2
-rw-r--r--MatrixUtils.Web/Shared/TimelineComponents/TimelineCanonicalAliasItem.razor2
-rw-r--r--MatrixUtils.Web/Shared/TimelineComponents/TimelineHistoryVisibilityItem.razor2
-rw-r--r--MatrixUtils.Web/Shared/TimelineComponents/TimelineMemberItem.razor2
-rw-r--r--MatrixUtils.Web/Shared/TimelineComponents/TimelineMessageItem.razor2
-rw-r--r--MatrixUtils.Web/Shared/TimelineComponents/TimelineRoomCreateItem.razor2
-rw-r--r--MatrixUtils.Web/Shared/TimelineComponents/TimelineRoomNameItem.razor2
-rw-r--r--MatrixUtils.Web/Shared/TimelineComponents/TimelineRoomTopicItem.razor2
-rw-r--r--MatrixUtils.Web/Shared/UserListItem.razor2
-rw-r--r--MatrixUtils.Web/_Imports.razor11
-rw-r--r--MatrixUtils.Web/wwwroot/index.html16
-rw-r--r--MatrixUtils.sln215
m---------MxApiExtensions0
-rw-r--r--global.json2
-rwxr-xr-xscripts/deploy.sh3
120 files changed, 3254 insertions, 1014 deletions
diff --git a/.gitignore b/.gitignore
index 984ec54..67e71e8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,7 @@ MatrixRoomUtils.Bot/bot_data/
 appsettings.Local*.json
 nixpkgs/
 *.DotSettings.user
+**/.DS_Store
 *.patch
 
 test.tsv
diff --git a/.idea/.idea.MatrixRoomUtils/.idea/inspectionProfiles/Project_Default.xml b/.idea/.idea.MatrixRoomUtils/.idea/inspectionProfiles/Project_Default.xml
deleted file mode 100644
index 55540ea..0000000
--- a/.idea/.idea.MatrixRoomUtils/.idea/inspectionProfiles/Project_Default.xml
+++ /dev/null
@@ -1,22 +0,0 @@
-<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/.idea/.idea.MatrixRoomUtils/.idea/.gitignore b/.idea/.idea.MatrixUtils/.idea/.gitignore
index f938d5a..f938d5a 100644
--- a/.idea/.idea.MatrixRoomUtils/.idea/.gitignore
+++ b/.idea/.idea.MatrixUtils/.idea/.gitignore
diff --git a/.idea/.idea.MatrixUtils/.idea/.name b/.idea/.idea.MatrixUtils/.idea/.name
new file mode 100644
index 0000000..65d8b74
--- /dev/null
+++ b/.idea/.idea.MatrixUtils/.idea/.name
@@ -0,0 +1 @@
+MatrixUtils
\ No newline at end of file
diff --git a/.idea/.idea.MatrixRoomUtils/.idea/avalonia.xml b/.idea/.idea.MatrixUtils/.idea/avalonia.xml
index 0aa65bb..0aa65bb 100644
--- a/.idea/.idea.MatrixRoomUtils/.idea/avalonia.xml
+++ b/.idea/.idea.MatrixUtils/.idea/avalonia.xml
diff --git a/.idea/.idea.MatrixRoomUtils/.idea/codeStyles/codeStyleConfig.xml b/.idea/.idea.MatrixUtils/.idea/codeStyles/codeStyleConfig.xml
index a55e7a1..a55e7a1 100644
--- a/.idea/.idea.MatrixRoomUtils/.idea/codeStyles/codeStyleConfig.xml
+++ b/.idea/.idea.MatrixUtils/.idea/codeStyles/codeStyleConfig.xml
diff --git a/.idea/.idea.MatrixRoomUtils/.idea/encodings.xml b/.idea/.idea.MatrixUtils/.idea/encodings.xml
index df87cf9..df87cf9 100644
--- a/.idea/.idea.MatrixRoomUtils/.idea/encodings.xml
+++ b/.idea/.idea.MatrixUtils/.idea/encodings.xml
diff --git a/.idea/.idea.MatrixRoomUtils/.idea/indexLayout.xml b/.idea/.idea.MatrixUtils/.idea/indexLayout.xml
index 4520708..4520708 100644
--- a/.idea/.idea.MatrixRoomUtils/.idea/indexLayout.xml
+++ b/.idea/.idea.MatrixUtils/.idea/indexLayout.xml
diff --git a/.idea/.idea.MatrixUtils/.idea/inspectionProfiles/Project_Default.xml b/.idea/.idea.MatrixUtils/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 0000000..0e61b0a
--- /dev/null
+++ b/.idea/.idea.MatrixUtils/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,53 @@
+<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="HttpUrlsUsage" enabled="true" level="WEAK WARNING" enabled_by_default="true">
+      <option name="ignoredUrls">
+        <list>
+          <option value="http://" />
+          <option value="http://0.0.0.0" />
+          <option value="http://127.0.0.1" />
+          <option value="http://activemq.apache.org/schema/" />
+          <option value="http://cxf.apache.org/schemas/" />
+          <option value="http://java.sun.com/" />
+          <option value="http://javafx.com/fxml" />
+          <option value="http://javafx.com/javafx/" />
+          <option value="http://json-schema.org/draft" />
+          <option value="http://localhost" />
+          <option value="http://maven.apache.org/POM/" />
+          <option value="http://maven.apache.org/xsd/" />
+          <option value="http://primefaces.org/ui" />
+          <option value="http://schema.cloudfoundry.org/spring/" />
+          <option value="http://schemas.xmlsoap.org/" />
+          <option value="http://tiles.apache.org/" />
+          <option value="http://www.ibm.com/webservices/xsd" />
+          <option value="http://www.jboss.com/xml/ns/" />
+          <option value="http://www.jboss.org/j2ee/schema/" />
+          <option value="http://www.springframework.org/schema/" />
+          <option value="http://www.springframework.org/security/tags" />
+          <option value="http://www.springframework.org/tags" />
+          <option value="http://www.thymeleaf.org" />
+          <option value="http://www.w3.org/" />
+          <option value="http://xmlns.jcp.org/" />
+        </list>
+      </option>
+    </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/.idea/.idea.MatrixRoomUtils/.idea/vcs.xml b/.idea/.idea.MatrixUtils/.idea/vcs.xml
index 94a25f7..94a25f7 100644
--- a/.idea/.idea.MatrixRoomUtils/.idea/vcs.xml
+++ b/.idea/.idea.MatrixUtils/.idea/vcs.xml
diff --git a/Benchmarks/.gitignore b/Benchmarks/.gitignore
new file mode 100644
index 0000000..a7d52bf
--- /dev/null
+++ b/Benchmarks/.gitignore
@@ -0,0 +1,3 @@
+
+BenchmarkDotNet.Artifacts
+benchmark.log
diff --git a/Benchmarks/Benchmarks.csproj b/Benchmarks/Benchmarks.csproj
new file mode 100644
index 0000000..5d584c9
--- /dev/null
+++ b/Benchmarks/Benchmarks.csproj
@@ -0,0 +1,17 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+    <PropertyGroup>
+        <OutputType>Exe</OutputType>
+        <TargetFramework>net9.0</TargetFramework>
+        <LangVersion>preview</LangVersion>
+        <ImplicitUsings>enable</ImplicitUsings>
+        <Nullable>enable</Nullable>
+<!--        <PublishAot>true</PublishAot>-->
+        <InvariantGlobalization>true</InvariantGlobalization>
+    </PropertyGroup>
+
+    <ItemGroup>
+      <PackageReference Include="BenchmarkDotNet" Version="0.14.0" />
+    </ItemGroup>
+
+</Project>
diff --git a/Benchmarks/Program.cs b/Benchmarks/Program.cs
new file mode 100644
index 0000000..b85fc24
--- /dev/null
+++ b/Benchmarks/Program.cs
@@ -0,0 +1,300 @@
+// See https://aka.ms/new-console-template for more information
+
+using System.Collections.Frozen;
+using System.Collections.Immutable;
+using BenchmarkDotNet.Attributes;
+using BenchmarkDotNet.Configs;
+using BenchmarkDotNet.Engines;
+using BenchmarkDotNet.Running;
+
+[SimpleJob(RunStrategy.ColdStart, launchCount: 1, warmupCount: 5, iterationCount: 5, id: "FastAndDirtyJob")]
+[InProcess]
+[ProcessCount(4)]
+public class Program {
+    public static void Main(string[] args) {
+        BenchmarkRunner.Run<Program>(args: args);
+    }
+
+    [Params(true, false)]
+    public bool DoDisambiguate { get; set; } = true;
+
+    [Params(true, false)]
+    public bool DisambiguateProfileUpdates {
+        get => field && DoDisambiguate;
+        set;
+    } = true;
+
+    [Params(true, false)]
+    public bool DisambiguateKicks {
+        get => field && DoDisambiguate;
+        set;
+    } = true;
+
+    [Params(true, false)]
+    public bool DisambiguateUnbans {
+        get => field && DoDisambiguate;
+        set;
+    } = true;
+
+    [Params(true, false)]
+    public bool DisambiguateInviteAccepted {
+        get => field && DoDisambiguate && DisambiguateInviteActions;
+        set;
+    } = true;
+
+    [Params(true, false)]
+    public bool DisambiguateInviteRejected {
+        get => field && DoDisambiguate && DisambiguateInviteActions;
+        set;
+    } = true;
+
+    [Params(true, false)]
+    public bool DisambiguateInviteRetracted {
+        get => field && DoDisambiguate && DisambiguateInviteActions;
+        set;
+    } = true;
+
+    [Params(true, false)]
+    public bool DisambiguateKnockAccepted {
+        get => field && DoDisambiguate && DisambiguateKnockActions;
+        set;
+    } = true;
+
+    [Params(true, false)]
+    public bool DisambiguateKnockRejected {
+        get => field && DoDisambiguate && DisambiguateKnockActions;
+        set;
+    } = true;
+
+    [Params(true, false)]
+    public bool DisambiguateKnockRetracted {
+        get => field && DoDisambiguate && DisambiguateKnockActions;
+        set;
+    } = true;
+
+    [Params(true, false)]
+    public bool DisambiguateKnockActions {
+        get => field && DoDisambiguate;
+        set;
+    } = true;
+
+    [Params(true, false)]
+    public bool DisambiguateInviteActions {
+        get => field && DoDisambiguate;
+        set;
+    } = true;
+
+    public enum MembershipTransition : uint {
+        None,
+        Join = 0b0001,
+        Leave = 0b0010,
+        Knock = 0b0100,
+        Invite = 0b1000,
+        Ban = 0b1001,
+
+        // disambiguated
+        ProfileUpdate = 0b0000_0001_0001,
+        Kick = 0b0000_0001_0010,
+        Unban = 0b0000_0010_0010,
+        InviteAccepted = 0b0000_0100_0001,
+        InviteRejected = 0b0000_1000_0010,
+        InviteRetracted = 0b0001_0000_0010,
+        KnockAccepted = 0b0010_0000_1000,
+        KnockRejected = 0b0100_0000_0010,
+        KnockRetracted = 0b1000_0000_0010
+    }
+
+    public readonly struct MembershipEntry {
+        public required MembershipTransition State { get; init; }
+        public string Aba { get; init; }
+        public string Abb { get; init; }
+        public string Abc { get; init; }
+        public string Abd { get; init; }
+    }
+
+    [Params(100, 10_000, 1_000_000)] public int N;
+
+    [GlobalSetup]
+    public void Setup() {
+        entries = Enumerable.Range(0, N).Select(_ => new MembershipEntry() {
+            State = (MembershipTransition)new Random().Next(1, 16),
+            Aba = Guid.NewGuid().ToString(),
+            Abb = Guid.NewGuid().ToString(),
+            Abc = Guid.NewGuid().ToString(),
+            Abd = Guid.NewGuid().ToString()
+        }).ToImmutableList();
+    }
+
+    public ImmutableList<MembershipEntry> entries = ImmutableList<MembershipEntry>.Empty;
+
+    [Benchmark]
+    public void TestTruthyness() {
+        var @switch = AmbiguateMembershipsSwitch().GetEnumerator();
+        var @switchpm = AmbiguateMembershipsSwitchPatternMatching().GetEnumerator();
+        var @if = AmbiguateMembershipsIf().GetEnumerator();
+        var @map = AmbiguateMembershipsStaticMap().GetEnumerator();
+        var @binmask = AmbiguateMembershipsBinMask().GetEnumerator();
+
+        while (@switch.MoveNext() && @map.MoveNext() && @if.MoveNext() && @switchpm.MoveNext() && @binmask.MoveNext()) {
+            if (@switch.Current.State != @map.Current.State || @switch.Current.State != @if.Current.State || @switch.Current.State != @switchpm.Current.State ||
+                @switch.Current.State != @binmask.Current.State) {
+                throw new InvalidOperationException("Results do not match!");
+            }
+        }
+
+        @switch.Dispose();
+        @switchpm.Dispose();
+        @if.Dispose();
+        @map.Dispose();
+        @binmask.Dispose();
+    }
+
+    [Benchmark]
+    public void TestAmbiguateMembershipsSwitchPatternMatching() => AmbiguateMembershipsSwitchPatternMatching().Consume(new Consumer());
+
+    public IEnumerable<MembershipEntry> AmbiguateMembershipsSwitchPatternMatching() {
+        foreach (var entry in entries) {
+            var newState = entry.State switch {
+                MembershipTransition.ProfileUpdate when !DoDisambiguate || !DisambiguateProfileUpdates => MembershipTransition.Join,
+                MembershipTransition.Kick when !DoDisambiguate || !DisambiguateKicks => MembershipTransition.Leave,
+                MembershipTransition.Unban when !DoDisambiguate || !DisambiguateUnbans => MembershipTransition.Leave,
+                MembershipTransition.InviteAccepted when !DoDisambiguate || !DisambiguateInviteActions || !DisambiguateInviteAccepted => MembershipTransition.Join,
+                MembershipTransition.InviteRejected when !DoDisambiguate || !DisambiguateInviteActions || !DisambiguateInviteRejected => MembershipTransition.Leave,
+                MembershipTransition.InviteRetracted when !DoDisambiguate || !DisambiguateInviteActions || !DisambiguateInviteRetracted => MembershipTransition.Leave,
+                MembershipTransition.KnockAccepted when !DoDisambiguate || !DisambiguateKnockActions || !DisambiguateKnockAccepted => MembershipTransition.Invite,
+                MembershipTransition.KnockRejected when !DoDisambiguate || !DisambiguateKnockActions || !DisambiguateKnockRejected => MembershipTransition.Leave,
+                MembershipTransition.KnockRetracted when !DoDisambiguate || !DisambiguateKnockActions || !DisambiguateKnockRetracted => MembershipTransition.Leave,
+                _ => entry.State
+            };
+            yield return newState == entry.State ? entry : entry with { State = newState };
+        }
+    }
+
+    [Benchmark]
+    public void TestAmbiguateMembershipsSwitch() => AmbiguateMembershipsSwitch().Consume(new Consumer());
+
+    public IEnumerable<MembershipEntry> AmbiguateMembershipsSwitch() {
+        foreach (var entry in entries) {
+            if (!DoDisambiguate) {
+                yield return entry;
+                continue;
+            }
+
+            MembershipTransition newState;
+            switch (entry.State) {
+                case MembershipTransition.ProfileUpdate:
+                    newState = !DisambiguateProfileUpdates ? MembershipTransition.Join : entry.State;
+                    break;
+                case MembershipTransition.Kick:
+                    newState = !DisambiguateKicks ? MembershipTransition.Leave : entry.State;
+                    break;
+                case MembershipTransition.Unban when !DisambiguateUnbans:
+                    newState = MembershipTransition.Leave;
+                    break;
+                case MembershipTransition.InviteAccepted when !DisambiguateInviteActions || !DisambiguateInviteAccepted:
+                    newState = MembershipTransition.Join;
+                    break;
+                case MembershipTransition.InviteRejected when !DisambiguateInviteActions || !DisambiguateInviteRejected:
+
+                    newState = MembershipTransition.Leave;
+                    break;
+                case MembershipTransition.InviteRetracted when !DisambiguateInviteActions || !DisambiguateInviteRetracted:
+                    newState = MembershipTransition.Leave;
+                    break;
+                case MembershipTransition.KnockAccepted when !DisambiguateKnockActions || !DisambiguateKnockAccepted:
+                    newState = MembershipTransition.Invite;
+                    break;
+                case MembershipTransition.KnockRejected when !DisambiguateKnockActions || !DisambiguateKnockRejected:
+                    newState = MembershipTransition.Leave;
+                    break;
+                case MembershipTransition.KnockRetracted when !DisambiguateKnockActions || !DisambiguateKnockRetracted:
+                    newState = MembershipTransition.Leave;
+                    break;
+                default:
+                    newState = entry.State;
+                    break;
+            }
+
+            yield return newState == entry.State ? entry : entry with { State = newState };
+        }
+    }
+
+    [Benchmark]
+    public void TestAmbiguateMembershipsIf() => AmbiguateMembershipsIf().Consume(new Consumer());
+
+    public IEnumerable<MembershipEntry> AmbiguateMembershipsIf() {
+        foreach (var entry in entries) {
+            MembershipTransition newState;
+            if (entry.State == MembershipTransition.ProfileUpdate && (!DoDisambiguate || !DisambiguateProfileUpdates))
+                newState = MembershipTransition.Join;
+            else if ((entry.State == MembershipTransition.Kick && (!DoDisambiguate || !DisambiguateKicks)) ||
+                     (entry.State == MembershipTransition.Unban && (!DoDisambiguate || !DisambiguateUnbans)))
+                newState = MembershipTransition.Leave;
+            else if (entry.State == MembershipTransition.InviteAccepted && (!DoDisambiguate || !DisambiguateInviteActions || !DisambiguateInviteAccepted))
+                newState = MembershipTransition.Join;
+            else if ((entry.State == MembershipTransition.InviteRejected && (!DoDisambiguate || !DisambiguateInviteActions || !DisambiguateInviteRejected)) ||
+                     (entry.State == MembershipTransition.InviteRetracted && (!DoDisambiguate || !DisambiguateInviteActions || !DisambiguateInviteRetracted)))
+                newState = MembershipTransition.Leave;
+            else if (entry.State == MembershipTransition.KnockAccepted && (!DoDisambiguate || !DisambiguateKnockActions || !DisambiguateKnockAccepted))
+                newState = MembershipTransition.Invite;
+            else if ((entry.State == MembershipTransition.KnockRejected && (!DoDisambiguate || !DisambiguateKnockActions || !DisambiguateKnockRejected)) ||
+                     (entry.State == MembershipTransition.KnockRetracted && (!DoDisambiguate || !DisambiguateKnockActions || !DisambiguateKnockRetracted)))
+                newState = MembershipTransition.Leave;
+            else
+                newState = entry.State;
+
+            yield return newState == entry.State ? entry : entry with { State = newState };
+        }
+    }
+
+    [Benchmark]
+    public void TestAmbiguateMembershipsStaticMap() => AmbiguateMembershipsStaticMap().Consume(new Consumer());
+
+    public IEnumerable<MembershipEntry> AmbiguateMembershipsStaticMap() {
+        Dictionary<MembershipTransition, MembershipTransition> _map = [];
+        if (!DoDisambiguate || !DisambiguateProfileUpdates) _map[MembershipTransition.ProfileUpdate] = MembershipTransition.Join;
+        if (!DoDisambiguate || !DisambiguateKicks) _map[MembershipTransition.Kick] = MembershipTransition.Leave;
+        if (!DoDisambiguate || !DisambiguateUnbans) _map[MembershipTransition.Unban] = MembershipTransition.Leave;
+        if (!DoDisambiguate || !DisambiguateInviteActions || !DisambiguateInviteAccepted) _map[MembershipTransition.InviteAccepted] = MembershipTransition.Join;
+        if (!DoDisambiguate || !DisambiguateInviteActions || !DisambiguateInviteRejected) _map[MembershipTransition.InviteRejected] = MembershipTransition.Leave;
+        if (!DoDisambiguate || !DisambiguateInviteActions || !DisambiguateInviteRetracted) _map[MembershipTransition.InviteRetracted] = MembershipTransition.Leave;
+        if (!DoDisambiguate || !DisambiguateKnockActions || !DisambiguateKnockAccepted) _map[MembershipTransition.KnockAccepted] = MembershipTransition.Invite;
+        if (!DoDisambiguate || !DisambiguateKnockActions || !DisambiguateKnockRejected) _map[MembershipTransition.KnockRejected] = MembershipTransition.Leave;
+        if (!DoDisambiguate || !DisambiguateKnockActions || !DisambiguateKnockRetracted) _map[MembershipTransition.KnockRetracted] = MembershipTransition.Leave;
+        FrozenDictionary<MembershipTransition, MembershipTransition> map = _map.ToFrozenDictionary();
+        _map = null!;
+        foreach (var entry in entries) {
+            var newState = map.TryGetValue(entry.State, out var value) ? value : entry.State;
+            yield return newState == entry.State ? entry : entry with { State = newState };
+        }
+    }
+
+    [Benchmark]
+    public void TestAmbiguateMembershipsBinMask() => AmbiguateMembershipsBinMask().Consume(new Consumer());
+
+    public IEnumerable<MembershipEntry> AmbiguateMembershipsBinMask() {
+        uint mask = 0;
+        // dont mask last 4 bits
+        if (!DoDisambiguate || !DisambiguateProfileUpdates) mask |= (uint)MembershipTransition.ProfileUpdate >> 4;
+        if (!DoDisambiguate || !DisambiguateKicks) mask |= (uint)MembershipTransition.Kick >> 4;
+        if (!DoDisambiguate || !DisambiguateUnbans) mask |= (uint)MembershipTransition.Unban >> 4;
+        if (!DoDisambiguate || !DisambiguateInviteActions || !DisambiguateInviteAccepted) mask |= (uint)MembershipTransition.InviteAccepted >> 4;
+        if (!DoDisambiguate || !DisambiguateInviteActions || !DisambiguateInviteRejected) mask |= (uint)MembershipTransition.InviteRejected >> 4;
+        if (!DoDisambiguate || !DisambiguateInviteActions || !DisambiguateInviteRetracted) mask |= (uint)MembershipTransition.InviteRetracted >> 4;
+        if (!DoDisambiguate || !DisambiguateKnockActions || !DisambiguateKnockAccepted) mask |= (uint)MembershipTransition.KnockAccepted >> 4;
+        if (!DoDisambiguate || !DisambiguateKnockActions || !DisambiguateKnockRejected) mask |= (uint)MembershipTransition.KnockRejected >> 4;
+        if (!DoDisambiguate || !DisambiguateKnockActions || !DisambiguateKnockRetracted) mask |= (uint)MembershipTransition.KnockRetracted >> 4;
+        mask = (mask << 4) + 0b1111;
+        // Console.WriteLine(mask.ToString("b24"));
+        foreach (var entry in entries) {
+            if (((uint)entry.State & 0b1111_1111_0000) == 0) {
+                yield return entry;
+                continue;
+            }
+
+            var newState = (MembershipTransition)((uint)entry.State & mask);
+            // Console.WriteLine(((uint)newState).ToString("b32"));
+            yield return newState == entry.State ? entry : entry with { State = newState };
+        }
+    }
+}
\ No newline at end of file
diff --git a/LibMatrix b/LibMatrix
-Subproject 16e314ed714f8b3e298c0ecf2ebfe67b48e5f69
+Subproject ca3e6878422b7b55ae52b43f49f89a19546ea51
diff --git a/MatrixRoomUtils.sln b/MatrixRoomUtils.sln
deleted file mode 100644
index 23d86b0..0000000
--- a/MatrixRoomUtils.sln
+++ /dev/null
@@ -1,232 +0,0 @@
-
-Microsoft Visual Studio Solution File, Format Version 12.00
-# 
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MatrixUtils.Web", "MatrixUtils.Web\MatrixUtils.Web.csproj", "{D38DA95D-DD83-4340-96A4-6F59FC6AE3D9}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MatrixUtils.Web.Server", "MatrixUtils.Web.Server\MatrixUtils.Web.Server.csproj", "{F997F26F-2EC1-4D18-B3DD-C46FB2AD65C0}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MatrixUtils.Desktop", "MatrixUtils.Desktop\MatrixUtils.Desktop.csproj", "{27C08A4F-5AF0-4C2C-AFCB-050E3388C116}"
-EndProject
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "LibMatrix", "LibMatrix", "{8F4F6BEC-0C66-486B-A21A-1C35B2EDAD33}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibMatrix", "LibMatrix\LibMatrix\LibMatrix.csproj", "{F4E241C3-0300-4B87-8707-BCBDEF1F0185}"
-EndProject
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "MxApiExtensions", "MxApiExtensions", "{F1376F9A-FB65-4E60-BB9A-62A64F741FF4}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxApiExtensions", "MxApiExtensions\MxApiExtensions\MxApiExtensions.csproj", "{41200A7B-D2DB-4656-B66B-5206A63B367A}"
-EndProject
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ArcaneLibs", "LibMatrix\ArcaneLibs", "{B00C5CB6-6200-4B41-96BE-C6EAF1085A14}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ArcaneLibs", "LibMatrix\ArcaneLibs\ArcaneLibs\ArcaneLibs.csproj", "{B00E29F5-1ED8-40A0-A70D-DE9F23FC572F}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ArcaneLibs.Blazor.Components", "LibMatrix\ArcaneLibs\ArcaneLibs.Blazor.Components\ArcaneLibs.Blazor.Components.csproj", "{2D6F31D7-3139-44EC-9D11-486282DD4ED1}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxApiExtensions.Classes", "MxApiExtensions\MxApiExtensions.Classes\MxApiExtensions.Classes.csproj", "{99C016AA-AFBA-4D32-A687-D1FABC0F5212}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxApiExtensions.Classes.LibMatrix", "MxApiExtensions\MxApiExtensions.Classes.LibMatrix\MxApiExtensions.Classes.LibMatrix.csproj", "{C298E274-5D6C-47C8-9B71-A6B34D0195A3}"
-EndProject
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ExampleBots", "ExampleBots", "{3E0FDE30-8DA8-4E65-A3C6-AA53B5BC70A2}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibMatrix.ExampleBot", "LibMatrix\ExampleBots\LibMatrix.ExampleBot\LibMatrix.ExampleBot.csproj", "{2CB12623-4918-4176-9B4A-88D846CCD3ED}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ModerationBot", "LibMatrix\ExampleBots\ModerationBot\ModerationBot.csproj", "{48DBB05F-B007-4B24-89B3-3CC177C79007}"
-EndProject
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Utilities", "Utilities", "{A4BCBF5F-4936-44B9-BAB3-FAF240BDF40D}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibMatrix.DebugDataValidationApi", "LibMatrix\Utilities\LibMatrix.DebugDataValidationApi\LibMatrix.DebugDataValidationApi.csproj", "{5DEF66C8-B931-435F-B4B5-E1858590D52E}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibMatrix.Utilities.Bot", "LibMatrix\Utilities\LibMatrix.Utilities.Bot\LibMatrix.Utilities.Bot.csproj", "{D8E5C678-3BE5-470C-A3A5-B5D525FC2012}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PluralContactBotPoC", "LibMatrix\ExampleBots\PluralContactBotPoC\PluralContactBotPoC.csproj", "{95052EE6-7513-46FB-91BD-EE82026B42F1}"
-EndProject
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{7D2C9959-8309-4110-A67F-DEE64E97C1D8}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibMatrix.Tests", "LibMatrix\Tests\LibMatrix.Tests\LibMatrix.Tests.csproj", "{E37B78F1-D7A5-4F79-ADBA-E12DF7D0F881}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestDataGenerator", "LibMatrix\Tests\TestDataGenerator\TestDataGenerator.csproj", "{F3312DE9-4335-4E85-A4CF-2616427A651E}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MatrixUtils.LibDMSpace", "MatrixUtils.LibDMSpace\MatrixUtils.LibDMSpace.csproj", "{EDD2FBAB-2DEC-4527-AE9C-20E21D0D6B14}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibMatrix.EventTypes", "LibMatrix\LibMatrix.EventTypes\LibMatrix.EventTypes.csproj", "{1CAA2B6D-0365-4C8B-96EE-26026514FEE2}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MatrixUtils.Abstractions", "MatrixUtils.Abstractions\MatrixUtils.Abstractions.csproj", "{FE20ED20-0D55-4D74-822B-E2AC7A54C487}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibMatrix.JsonSerializerContextGenerator", "LibMatrix\Utilities\LibMatrix.JsonSerializerContextGenerator\LibMatrix.JsonSerializerContextGenerator.csproj", "{CC836863-0EE8-44BD-BF39-45076F57C416}"
-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}"
-EndProject
-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
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ArcaneLibs.Legacy", "LibMatrix\ArcaneLibs\ArcaneLibs.Legacy\ArcaneLibs.Legacy.csproj", "{748D215E-CA40-4D0F-BE1D-D2350D4AB8CA}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ArcaneLibs.Logging", "LibMatrix\ArcaneLibs\ArcaneLibs.Logging\ArcaneLibs.Logging.csproj", "{E33F7CB0-A03D-4ED0-9AE8-95B31A2D7ACC}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ArcaneLibs.StringNormalisation", "LibMatrix\ArcaneLibs\ArcaneLibs.StringNormalisation\ArcaneLibs.StringNormalisation.csproj", "{FDB12B6A-01AA-46C0-A55E-0F984496AB81}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ArcaneLibs.Timings", "LibMatrix\ArcaneLibs\ArcaneLibs.Timings\ArcaneLibs.Timings.csproj", "{84EE78FF-E198-4090-BFE9-C47D266E115E}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ArcaneLib.Tests", "LibMatrix\ArcaneLibs\ArcaneLib.Tests\ArcaneLib.Tests.csproj", "{1607FCA9-7B5B-45B0-8D1F-205ABACB7173}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibMatrix.MxApiExtensions", "LibMatrix\LibMatrix.MxApiExtensions\LibMatrix.MxApiExtensions.csproj", "{56A42391-4514-4352-B22B-622EE7A618AA}"
-EndProject
-Global
-	GlobalSection(SolutionConfigurationPlatforms) = preSolution
-		Debug|Any CPU = Debug|Any CPU
-		Release|Any CPU = Release|Any CPU
-	EndGlobalSection
-	GlobalSection(ProjectConfigurationPlatforms) = postSolution
-		{D38DA95D-DD83-4340-96A4-6F59FC6AE3D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
-		{D38DA95D-DD83-4340-96A4-6F59FC6AE3D9}.Debug|Any CPU.Build.0 = Debug|Any CPU
-		{D38DA95D-DD83-4340-96A4-6F59FC6AE3D9}.Release|Any CPU.ActiveCfg = Release|Any CPU
-		{D38DA95D-DD83-4340-96A4-6F59FC6AE3D9}.Release|Any CPU.Build.0 = Release|Any CPU
-		{F997F26F-2EC1-4D18-B3DD-C46FB2AD65C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
-		{F997F26F-2EC1-4D18-B3DD-C46FB2AD65C0}.Debug|Any CPU.Build.0 = Debug|Any CPU
-		{F997F26F-2EC1-4D18-B3DD-C46FB2AD65C0}.Release|Any CPU.ActiveCfg = Release|Any CPU
-		{F997F26F-2EC1-4D18-B3DD-C46FB2AD65C0}.Release|Any CPU.Build.0 = Release|Any CPU
-		{27C08A4F-5AF0-4C2C-AFCB-050E3388C116}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
-		{27C08A4F-5AF0-4C2C-AFCB-050E3388C116}.Debug|Any CPU.Build.0 = Debug|Any CPU
-		{27C08A4F-5AF0-4C2C-AFCB-050E3388C116}.Release|Any CPU.ActiveCfg = Release|Any CPU
-		{27C08A4F-5AF0-4C2C-AFCB-050E3388C116}.Release|Any CPU.Build.0 = Release|Any CPU
-		{F4E241C3-0300-4B87-8707-BCBDEF1F0185}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
-		{F4E241C3-0300-4B87-8707-BCBDEF1F0185}.Debug|Any CPU.Build.0 = Debug|Any CPU
-		{F4E241C3-0300-4B87-8707-BCBDEF1F0185}.Release|Any CPU.ActiveCfg = Release|Any CPU
-		{F4E241C3-0300-4B87-8707-BCBDEF1F0185}.Release|Any CPU.Build.0 = Release|Any CPU
-		{41200A7B-D2DB-4656-B66B-5206A63B367A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
-		{41200A7B-D2DB-4656-B66B-5206A63B367A}.Debug|Any CPU.Build.0 = Debug|Any CPU
-		{41200A7B-D2DB-4656-B66B-5206A63B367A}.Release|Any CPU.ActiveCfg = Release|Any CPU
-		{41200A7B-D2DB-4656-B66B-5206A63B367A}.Release|Any CPU.Build.0 = Release|Any CPU
-		{B00E29F5-1ED8-40A0-A70D-DE9F23FC572F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
-		{B00E29F5-1ED8-40A0-A70D-DE9F23FC572F}.Debug|Any CPU.Build.0 = Debug|Any CPU
-		{B00E29F5-1ED8-40A0-A70D-DE9F23FC572F}.Release|Any CPU.ActiveCfg = Release|Any CPU
-		{B00E29F5-1ED8-40A0-A70D-DE9F23FC572F}.Release|Any CPU.Build.0 = Release|Any CPU
-		{2D6F31D7-3139-44EC-9D11-486282DD4ED1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
-		{2D6F31D7-3139-44EC-9D11-486282DD4ED1}.Debug|Any CPU.Build.0 = Debug|Any CPU
-		{2D6F31D7-3139-44EC-9D11-486282DD4ED1}.Release|Any CPU.ActiveCfg = Release|Any CPU
-		{2D6F31D7-3139-44EC-9D11-486282DD4ED1}.Release|Any CPU.Build.0 = Release|Any CPU
-		{99C016AA-AFBA-4D32-A687-D1FABC0F5212}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
-		{99C016AA-AFBA-4D32-A687-D1FABC0F5212}.Debug|Any CPU.Build.0 = Debug|Any CPU
-		{99C016AA-AFBA-4D32-A687-D1FABC0F5212}.Release|Any CPU.ActiveCfg = Release|Any CPU
-		{99C016AA-AFBA-4D32-A687-D1FABC0F5212}.Release|Any CPU.Build.0 = Release|Any CPU
-		{C298E274-5D6C-47C8-9B71-A6B34D0195A3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
-		{C298E274-5D6C-47C8-9B71-A6B34D0195A3}.Debug|Any CPU.Build.0 = Debug|Any CPU
-		{C298E274-5D6C-47C8-9B71-A6B34D0195A3}.Release|Any CPU.ActiveCfg = Release|Any CPU
-		{C298E274-5D6C-47C8-9B71-A6B34D0195A3}.Release|Any CPU.Build.0 = Release|Any CPU
-		{2CB12623-4918-4176-9B4A-88D846CCD3ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
-		{2CB12623-4918-4176-9B4A-88D846CCD3ED}.Debug|Any CPU.Build.0 = Debug|Any CPU
-		{2CB12623-4918-4176-9B4A-88D846CCD3ED}.Release|Any CPU.ActiveCfg = Release|Any CPU
-		{2CB12623-4918-4176-9B4A-88D846CCD3ED}.Release|Any CPU.Build.0 = Release|Any CPU
-		{48DBB05F-B007-4B24-89B3-3CC177C79007}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
-		{48DBB05F-B007-4B24-89B3-3CC177C79007}.Debug|Any CPU.Build.0 = Debug|Any CPU
-		{48DBB05F-B007-4B24-89B3-3CC177C79007}.Release|Any CPU.ActiveCfg = Release|Any CPU
-		{48DBB05F-B007-4B24-89B3-3CC177C79007}.Release|Any CPU.Build.0 = Release|Any CPU
-		{5DEF66C8-B931-435F-B4B5-E1858590D52E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
-		{5DEF66C8-B931-435F-B4B5-E1858590D52E}.Debug|Any CPU.Build.0 = Debug|Any CPU
-		{5DEF66C8-B931-435F-B4B5-E1858590D52E}.Release|Any CPU.ActiveCfg = Release|Any CPU
-		{5DEF66C8-B931-435F-B4B5-E1858590D52E}.Release|Any CPU.Build.0 = Release|Any CPU
-		{D8E5C678-3BE5-470C-A3A5-B5D525FC2012}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
-		{D8E5C678-3BE5-470C-A3A5-B5D525FC2012}.Debug|Any CPU.Build.0 = Debug|Any CPU
-		{D8E5C678-3BE5-470C-A3A5-B5D525FC2012}.Release|Any CPU.ActiveCfg = Release|Any CPU
-		{D8E5C678-3BE5-470C-A3A5-B5D525FC2012}.Release|Any CPU.Build.0 = Release|Any CPU
-		{95052EE6-7513-46FB-91BD-EE82026B42F1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
-		{95052EE6-7513-46FB-91BD-EE82026B42F1}.Debug|Any CPU.Build.0 = Debug|Any CPU
-		{95052EE6-7513-46FB-91BD-EE82026B42F1}.Release|Any CPU.ActiveCfg = Release|Any CPU
-		{95052EE6-7513-46FB-91BD-EE82026B42F1}.Release|Any CPU.Build.0 = Release|Any CPU
-		{E37B78F1-D7A5-4F79-ADBA-E12DF7D0F881}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
-		{E37B78F1-D7A5-4F79-ADBA-E12DF7D0F881}.Debug|Any CPU.Build.0 = Debug|Any CPU
-		{E37B78F1-D7A5-4F79-ADBA-E12DF7D0F881}.Release|Any CPU.ActiveCfg = Release|Any CPU
-		{E37B78F1-D7A5-4F79-ADBA-E12DF7D0F881}.Release|Any CPU.Build.0 = Release|Any CPU
-		{F3312DE9-4335-4E85-A4CF-2616427A651E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
-		{F3312DE9-4335-4E85-A4CF-2616427A651E}.Debug|Any CPU.Build.0 = Debug|Any CPU
-		{F3312DE9-4335-4E85-A4CF-2616427A651E}.Release|Any CPU.ActiveCfg = Release|Any CPU
-		{F3312DE9-4335-4E85-A4CF-2616427A651E}.Release|Any CPU.Build.0 = Release|Any CPU
-		{EDD2FBAB-2DEC-4527-AE9C-20E21D0D6B14}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
-		{EDD2FBAB-2DEC-4527-AE9C-20E21D0D6B14}.Debug|Any CPU.Build.0 = Debug|Any CPU
-		{EDD2FBAB-2DEC-4527-AE9C-20E21D0D6B14}.Release|Any CPU.ActiveCfg = Release|Any CPU
-		{EDD2FBAB-2DEC-4527-AE9C-20E21D0D6B14}.Release|Any CPU.Build.0 = Release|Any CPU
-		{1CAA2B6D-0365-4C8B-96EE-26026514FEE2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
-		{1CAA2B6D-0365-4C8B-96EE-26026514FEE2}.Debug|Any CPU.Build.0 = Debug|Any CPU
-		{1CAA2B6D-0365-4C8B-96EE-26026514FEE2}.Release|Any CPU.ActiveCfg = Release|Any CPU
-		{1CAA2B6D-0365-4C8B-96EE-26026514FEE2}.Release|Any CPU.Build.0 = Release|Any CPU
-		{FE20ED20-0D55-4D74-822B-E2AC7A54C487}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
-		{FE20ED20-0D55-4D74-822B-E2AC7A54C487}.Debug|Any CPU.Build.0 = Debug|Any CPU
-		{FE20ED20-0D55-4D74-822B-E2AC7A54C487}.Release|Any CPU.ActiveCfg = Release|Any CPU
-		{FE20ED20-0D55-4D74-822B-E2AC7A54C487}.Release|Any CPU.Build.0 = Release|Any CPU
-		{CC836863-0EE8-44BD-BF39-45076F57C416}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
-		{CC836863-0EE8-44BD-BF39-45076F57C416}.Debug|Any CPU.Build.0 = Debug|Any CPU
-		{CC836863-0EE8-44BD-BF39-45076F57C416}.Release|Any CPU.ActiveCfg = Release|Any CPU
-		{CC836863-0EE8-44BD-BF39-45076F57C416}.Release|Any CPU.Build.0 = Release|Any CPU
-		{5CE239F8-C124-4A96-A0F8-B56B9AE27434}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
-		{5CE239F8-C124-4A96-A0F8-B56B9AE27434}.Debug|Any CPU.Build.0 = Debug|Any CPU
-		{5CE239F8-C124-4A96-A0F8-B56B9AE27434}.Release|Any CPU.ActiveCfg = Release|Any CPU
-		{5CE239F8-C124-4A96-A0F8-B56B9AE27434}.Release|Any CPU.Build.0 = Release|Any CPU
-		{6D93DA72-69D8-43BD-BC19-7FFF8A313971}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
-		{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
-		{748D215E-CA40-4D0F-BE1D-D2350D4AB8CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
-		{748D215E-CA40-4D0F-BE1D-D2350D4AB8CA}.Debug|Any CPU.Build.0 = Debug|Any CPU
-		{748D215E-CA40-4D0F-BE1D-D2350D4AB8CA}.Release|Any CPU.ActiveCfg = Release|Any CPU
-		{748D215E-CA40-4D0F-BE1D-D2350D4AB8CA}.Release|Any CPU.Build.0 = Release|Any CPU
-		{E33F7CB0-A03D-4ED0-9AE8-95B31A2D7ACC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
-		{E33F7CB0-A03D-4ED0-9AE8-95B31A2D7ACC}.Debug|Any CPU.Build.0 = Debug|Any CPU
-		{E33F7CB0-A03D-4ED0-9AE8-95B31A2D7ACC}.Release|Any CPU.ActiveCfg = Release|Any CPU
-		{E33F7CB0-A03D-4ED0-9AE8-95B31A2D7ACC}.Release|Any CPU.Build.0 = Release|Any CPU
-		{FDB12B6A-01AA-46C0-A55E-0F984496AB81}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
-		{FDB12B6A-01AA-46C0-A55E-0F984496AB81}.Debug|Any CPU.Build.0 = Debug|Any CPU
-		{FDB12B6A-01AA-46C0-A55E-0F984496AB81}.Release|Any CPU.ActiveCfg = Release|Any CPU
-		{FDB12B6A-01AA-46C0-A55E-0F984496AB81}.Release|Any CPU.Build.0 = Release|Any CPU
-		{84EE78FF-E198-4090-BFE9-C47D266E115E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
-		{84EE78FF-E198-4090-BFE9-C47D266E115E}.Debug|Any CPU.Build.0 = Debug|Any CPU
-		{84EE78FF-E198-4090-BFE9-C47D266E115E}.Release|Any CPU.ActiveCfg = Release|Any CPU
-		{84EE78FF-E198-4090-BFE9-C47D266E115E}.Release|Any CPU.Build.0 = Release|Any CPU
-		{1607FCA9-7B5B-45B0-8D1F-205ABACB7173}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
-		{1607FCA9-7B5B-45B0-8D1F-205ABACB7173}.Debug|Any CPU.Build.0 = Debug|Any CPU
-		{1607FCA9-7B5B-45B0-8D1F-205ABACB7173}.Release|Any CPU.ActiveCfg = Release|Any CPU
-		{1607FCA9-7B5B-45B0-8D1F-205ABACB7173}.Release|Any CPU.Build.0 = Release|Any CPU
-		{56A42391-4514-4352-B22B-622EE7A618AA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
-		{56A42391-4514-4352-B22B-622EE7A618AA}.Debug|Any CPU.Build.0 = Debug|Any CPU
-		{56A42391-4514-4352-B22B-622EE7A618AA}.Release|Any CPU.ActiveCfg = Release|Any CPU
-		{56A42391-4514-4352-B22B-622EE7A618AA}.Release|Any CPU.Build.0 = Release|Any CPU
-	EndGlobalSection
-	GlobalSection(NestedProjects) = preSolution
-		{F4E241C3-0300-4B87-8707-BCBDEF1F0185} = {8F4F6BEC-0C66-486B-A21A-1C35B2EDAD33}
-		{41200A7B-D2DB-4656-B66B-5206A63B367A} = {F1376F9A-FB65-4E60-BB9A-62A64F741FF4}
-		{B00E29F5-1ED8-40A0-A70D-DE9F23FC572F} = {B00C5CB6-6200-4B41-96BE-C6EAF1085A14}
-		{2D6F31D7-3139-44EC-9D11-486282DD4ED1} = {B00C5CB6-6200-4B41-96BE-C6EAF1085A14}
-		{99C016AA-AFBA-4D32-A687-D1FABC0F5212} = {F1376F9A-FB65-4E60-BB9A-62A64F741FF4}
-		{C298E274-5D6C-47C8-9B71-A6B34D0195A3} = {F1376F9A-FB65-4E60-BB9A-62A64F741FF4}
-		{3E0FDE30-8DA8-4E65-A3C6-AA53B5BC70A2} = {8F4F6BEC-0C66-486B-A21A-1C35B2EDAD33}
-		{2CB12623-4918-4176-9B4A-88D846CCD3ED} = {3E0FDE30-8DA8-4E65-A3C6-AA53B5BC70A2}
-		{48DBB05F-B007-4B24-89B3-3CC177C79007} = {3E0FDE30-8DA8-4E65-A3C6-AA53B5BC70A2}
-		{A4BCBF5F-4936-44B9-BAB3-FAF240BDF40D} = {8F4F6BEC-0C66-486B-A21A-1C35B2EDAD33}
-		{5DEF66C8-B931-435F-B4B5-E1858590D52E} = {A4BCBF5F-4936-44B9-BAB3-FAF240BDF40D}
-		{D8E5C678-3BE5-470C-A3A5-B5D525FC2012} = {A4BCBF5F-4936-44B9-BAB3-FAF240BDF40D}
-		{95052EE6-7513-46FB-91BD-EE82026B42F1} = {3E0FDE30-8DA8-4E65-A3C6-AA53B5BC70A2}
-		{7D2C9959-8309-4110-A67F-DEE64E97C1D8} = {8F4F6BEC-0C66-486B-A21A-1C35B2EDAD33}
-		{E37B78F1-D7A5-4F79-ADBA-E12DF7D0F881} = {7D2C9959-8309-4110-A67F-DEE64E97C1D8}
-		{F3312DE9-4335-4E85-A4CF-2616427A651E} = {7D2C9959-8309-4110-A67F-DEE64E97C1D8}
-		{1CAA2B6D-0365-4C8B-96EE-26026514FEE2} = {8F4F6BEC-0C66-486B-A21A-1C35B2EDAD33}
-		{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}
-		{748D215E-CA40-4D0F-BE1D-D2350D4AB8CA} = {B00C5CB6-6200-4B41-96BE-C6EAF1085A14}
-		{E33F7CB0-A03D-4ED0-9AE8-95B31A2D7ACC} = {B00C5CB6-6200-4B41-96BE-C6EAF1085A14}
-		{FDB12B6A-01AA-46C0-A55E-0F984496AB81} = {B00C5CB6-6200-4B41-96BE-C6EAF1085A14}
-		{84EE78FF-E198-4090-BFE9-C47D266E115E} = {B00C5CB6-6200-4B41-96BE-C6EAF1085A14}
-		{1607FCA9-7B5B-45B0-8D1F-205ABACB7173} = {B00C5CB6-6200-4B41-96BE-C6EAF1085A14}
-		{56A42391-4514-4352-B22B-622EE7A618AA} = {8F4F6BEC-0C66-486B-A21A-1C35B2EDAD33}
-	EndGlobalSection
-EndGlobal
diff --git a/MatrixUtils.Abstractions/FileStorageProvider.cs b/MatrixUtils.Abstractions/FileStorageProvider.cs
index fbe068d..1083002 100644
--- a/MatrixUtils.Abstractions/FileStorageProvider.cs
+++ b/MatrixUtils.Abstractions/FileStorageProvider.cs
@@ -1,7 +1,6 @@
 using System.Diagnostics.CodeAnalysis;
 using System.Text.Json;
 using ArcaneLibs.Extensions;
-using LibMatrix.Extensions;
 using LibMatrix.Interfaces.Services;
 using Microsoft.Extensions.Logging;
 
diff --git a/MatrixUtils.Abstractions/MatrixUtils.Abstractions.csproj b/MatrixUtils.Abstractions/MatrixUtils.Abstractions.csproj
index 1665ff0..96f9fcb 100644
--- a/MatrixUtils.Abstractions/MatrixUtils.Abstractions.csproj
+++ b/MatrixUtils.Abstractions/MatrixUtils.Abstractions.csproj
@@ -1,12 +1,12 @@
 <Project Sdk="Microsoft.NET.Sdk">
 
     <PropertyGroup>
-        <TargetFramework>net8.0</TargetFramework>
+        <TargetFramework>net9.0</TargetFramework>
         <ImplicitUsings>enable</ImplicitUsings>
         <Nullable>enable</Nullable>
     </PropertyGroup>
-    
+
     <ItemGroup>
-        <ProjectReference Include="..\LibMatrix\LibMatrix\LibMatrix.csproj" />
+      <ProjectReference Include="..\LibMatrix\LibMatrix\LibMatrix.csproj" />
     </ItemGroup>
 </Project>
diff --git a/MatrixUtils.Abstractions/RoomInfo.cs b/MatrixUtils.Abstractions/RoomInfo.cs
index aff0e25..81ce388 100644
--- a/MatrixUtils.Abstractions/RoomInfo.cs
+++ b/MatrixUtils.Abstractions/RoomInfo.cs
@@ -3,7 +3,6 @@ using System.Collections.ObjectModel;
 using System.Text.Json.Nodes;
 using ArcaneLibs;
 using LibMatrix;
-using LibMatrix.EventTypes.Spec.State;
 using LibMatrix.EventTypes.Spec.State.RoomInfo;
 using LibMatrix.Homeservers;
 using LibMatrix.RoomTypes;
diff --git a/MatrixUtils.Desktop/Components/RoomListEntry.axaml.cs b/MatrixUtils.Desktop/Components/RoomListEntry.axaml.cs
index 1e4a127..1e6b99f 100644
--- a/MatrixUtils.Desktop/Components/RoomListEntry.axaml.cs
+++ b/MatrixUtils.Desktop/Components/RoomListEntry.axaml.cs
@@ -44,7 +44,7 @@ public partial class RoomListEntry : UserControl {
             var avatarEvent = await Room.GetStateEvent("m.room.avatar");
             if (avatarEvent?.TypedContent is RoomAvatarEventContent avatarData) {
                 var mxcUrl = avatarData.Url;
-                var resolvedUrl = await Room.Room.GetResolvedRoomAvatarUrlAsync();
+                var resolvedUrl = await Room.Room.GetAvatarUrlAsync();
                 
                 // await using var svc = _serviceScopeFactory.CreateAsyncScope();
                 // var hs = await svc.ServiceProvider.GetService<RMUStorageWrapper>()?.GetCurrentSessionOrPrompt()!;
@@ -54,10 +54,10 @@ public partial class RoomListEntry : UserControl {
                 var storage = new FileStorageProvider("cache");
                 var storageKey = $"media/{mxcUrl.Replace("mxc://", "").Replace("/", ".")}";
                 try {
-                    if (!await storage.ObjectExistsAsync(storageKey))
-                        await storage.SaveStreamAsync(storageKey, await hc.GetStreamAsync(resolvedUrl));
+                    // if (!await storage.ObjectExistsAsync(storageKey))
+                        // await storage.SaveStreamAsync(storageKey, await hc.GetStreamAsync(resolvedUrl));
 
-                    RoomIcon.Source = new Bitmap(await storage.LoadStreamAsync(storageKey) ?? throw new NullReferenceException());
+                    // RoomIcon.Source = new Bitmap(await storage.LoadStreamAsync(storageKey) ?? throw new NullReferenceException());
                 }
                 catch (IOException) { }
                 catch (MatrixException e) {
diff --git a/MatrixUtils.Desktop/MatrixUtils.Desktop.csproj b/MatrixUtils.Desktop/MatrixUtils.Desktop.csproj
index ce009d5..a7ff4b9 100644
--- a/MatrixUtils.Desktop/MatrixUtils.Desktop.csproj
+++ b/MatrixUtils.Desktop/MatrixUtils.Desktop.csproj
@@ -1,7 +1,7 @@
 <Project Sdk="Microsoft.NET.Sdk">
     <PropertyGroup>
         <OutputType>WinExe</OutputType>
-        <TargetFramework>net8.0</TargetFramework>
+        <TargetFramework>net9.0</TargetFramework>
         <Nullable>enable</Nullable>
         <BuiltInComInteropSupport>true</BuiltInComInteropSupport>
         <ApplicationManifest>app.manifest</ApplicationManifest>
@@ -20,21 +20,21 @@
 
 
     <ItemGroup>
-        <PackageReference Include="Avalonia" Version="11.0.10" />
-        <PackageReference Include="Avalonia.Desktop" Version="11.0.10" />
-        <PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.10" />
-        <PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.10" />
+        <PackageReference Include="Avalonia" Version="11.2.1" />
+        <PackageReference Include="Avalonia.Desktop" Version="11.2.1" />
+        <PackageReference Include="Avalonia.Fonts.Inter" Version="11.2.1" />
+        <PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.1" />
         <!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
-        <PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.0.10" />
-        <PackageReference Include="Sentry" Version="4.7.0" />
+        <PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.2.1" />
+        <PackageReference Include="Sentry" Version="4.13.0" />
     </ItemGroup>
 
 
 
 
     <ItemGroup>
-        <PackageReference Include="Avalonia.Xaml.Behaviors" Version="11.0.10.9" />
-        <PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
+        <PackageReference Include="Avalonia.Xaml.Behaviors" Version="11.2.0" />
+        <PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.0" />
     </ItemGroup>
     <ItemGroup>
         <Content Include="appsettings*.json">
diff --git a/MatrixUtils.DmSpaced/MatrixUtils.DmSpaced.csproj b/MatrixUtils.DmSpaced/MatrixUtils.DmSpaced.csproj
index 4b0f599..f8dd598 100644
--- a/MatrixUtils.DmSpaced/MatrixUtils.DmSpaced.csproj
+++ b/MatrixUtils.DmSpaced/MatrixUtils.DmSpaced.csproj
@@ -2,7 +2,7 @@
 

   <PropertyGroup>

     <OutputType>Exe</OutputType>

-    <TargetFramework>net8.0</TargetFramework>

+    <TargetFramework>net9.0</TargetFramework>

     <LangVersion>preview</LangVersion>

     <ImplicitUsings>enable</ImplicitUsings>

     <Nullable>enable</Nullable>

diff --git a/MatrixUtils.DmSpaced/Program.cs b/MatrixUtils.DmSpaced/Program.cs
index ae352b7..6ed6cbc 100644
--- a/MatrixUtils.DmSpaced/Program.cs
+++ b/MatrixUtils.DmSpaced/Program.cs
@@ -22,17 +22,17 @@ if (Environment.GetEnvironmentVariable("MODERATIONBOT_APPSETTINGS_PATH") is stri
     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.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.AddSingleton<ModerationBotRoomProvider>();
 
     services.AddHostedService<ModerationBot.ModerationBot>();
 }).UseConsoleLifetime().Build();
diff --git a/MatrixUtils.LibDMSpace/DMSpaceRoom.cs b/MatrixUtils.LibDMSpace/DMSpaceRoom.cs
index e2c8192..646a3f3 100644
--- a/MatrixUtils.LibDMSpace/DMSpaceRoom.cs
+++ b/MatrixUtils.LibDMSpace/DMSpaceRoom.cs
@@ -1,7 +1,5 @@
-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;
diff --git a/MatrixUtils.LibDMSpace/MatrixUtils.LibDMSpace.csproj b/MatrixUtils.LibDMSpace/MatrixUtils.LibDMSpace.csproj
index 72c1666..e39440e 100644
--- a/MatrixUtils.LibDMSpace/MatrixUtils.LibDMSpace.csproj
+++ b/MatrixUtils.LibDMSpace/MatrixUtils.LibDMSpace.csproj
@@ -1,7 +1,7 @@
 <Project Sdk="Microsoft.NET.Sdk">
 
     <PropertyGroup>
-        <TargetFramework>net8.0</TargetFramework>
+        <TargetFramework>net9.0</TargetFramework>
         <Nullable>enable</Nullable>
         <ImplicitUsings>enable</ImplicitUsings>
         <LinkIncremental>true</LinkIncremental>
@@ -10,6 +10,6 @@
     </PropertyGroup>
 
     <ItemGroup>
-        <ProjectReference Include="..\LibMatrix\LibMatrix\LibMatrix.csproj"/>
+      <ProjectReference Include="..\LibMatrix\LibMatrix\LibMatrix.csproj" />
     </ItemGroup>
 </Project>
diff --git a/MatrixUtils.LibDMSpace/StateEvents/DMRoomInfo.cs b/MatrixUtils.LibDMSpace/StateEvents/DMRoomInfo.cs
index bc595b5..f7e1e20 100644
--- a/MatrixUtils.LibDMSpace/StateEvents/DMRoomInfo.cs
+++ b/MatrixUtils.LibDMSpace/StateEvents/DMRoomInfo.cs
@@ -1,6 +1,5 @@
 using System.Text.Json.Serialization;
 using LibMatrix.EventTypes;
-using LibMatrix.Interfaces;
 
 namespace MatrixUtils.LibDMSpace.StateEvents;
 
diff --git a/MatrixUtils.LibDMSpace/StateEvents/DMSpaceChildLayer.cs b/MatrixUtils.LibDMSpace/StateEvents/DMSpaceChildLayer.cs
index 16c7b70..886c34d 100644
--- a/MatrixUtils.LibDMSpace/StateEvents/DMSpaceChildLayer.cs
+++ b/MatrixUtils.LibDMSpace/StateEvents/DMSpaceChildLayer.cs
@@ -1,6 +1,5 @@
 using System.Text.Json.Serialization;
 using LibMatrix.EventTypes;
-using LibMatrix.Interfaces;
 
 namespace MatrixUtils.LibDMSpace.StateEvents;
 
diff --git a/MatrixUtils.LibDMSpace/StateEvents/DMSpaceInfo.cs b/MatrixUtils.LibDMSpace/StateEvents/DMSpaceInfo.cs
index f5daa74..170efc7 100644
--- a/MatrixUtils.LibDMSpace/StateEvents/DMSpaceInfo.cs
+++ b/MatrixUtils.LibDMSpace/StateEvents/DMSpaceInfo.cs
@@ -1,6 +1,5 @@
 using System.Text.Json.Serialization;
 using LibMatrix.EventTypes;
-using LibMatrix.Interfaces;
 
 namespace MatrixUtils.LibDMSpace.StateEvents;
 
diff --git a/MatrixUtils.Web.Server/MatrixUtils.Web.Server.csproj b/MatrixUtils.Web.Server/MatrixUtils.Web.Server.csproj
index f2d47ea..24401ab 100644
--- a/MatrixUtils.Web.Server/MatrixUtils.Web.Server.csproj
+++ b/MatrixUtils.Web.Server/MatrixUtils.Web.Server.csproj
@@ -1,14 +1,14 @@
 <Project Sdk="Microsoft.NET.Sdk.Web">
 
     <PropertyGroup>
-        <TargetFramework>net8.0</TargetFramework>
+        <TargetFramework>net9.0</TargetFramework>
         <Nullable>enable</Nullable>
         <ImplicitUsings>enable</ImplicitUsings>
         <LangVersion>preview</LangVersion>
     </PropertyGroup>
 
     <ItemGroup>
-        <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="8.0.6" />
+        <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="9.0.1" />
     </ItemGroup>
 
     <ItemGroup>
diff --git a/MatrixUtils.Web/App.razor b/MatrixUtils.Web/App.razor
index 5e87bc3..a8cf817 100644
--- a/MatrixUtils.Web/App.razor
+++ b/MatrixUtils.Web/App.razor
@@ -1,5 +1,4 @@
-@using Microsoft.AspNetCore.Components.Routing
-<Router AppAssembly="@typeof(App).Assembly">
+<Router AppAssembly="@typeof(App).Assembly">
     <Found Context="routeData">
         <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"/>
         <FocusOnNavigate RouteData="@routeData" Selector="h1"/>
diff --git a/MatrixUtils.Web/Classes/Constants/RoomConstants.cs b/MatrixUtils.Web/Classes/Constants/RoomConstants.cs
index 5df0d01..dc81d04 100644
--- a/MatrixUtils.Web/Classes/Constants/RoomConstants.cs
+++ b/MatrixUtils.Web/Classes/Constants/RoomConstants.cs
@@ -1,6 +1,7 @@
 namespace MatrixUtils.Web.Classes.Constants;
 
 public class RoomConstants {
-    public static readonly string[] DangerousRoomVersions = { "1", "8" };
-    public const string RecommendedRoomVersion = "10";
+    public static readonly string[] DangerousRoomVersions = ["1", "8"];
+    public static readonly string[] UnsupportedRoomVersions = ["1", "2", "3", "4", "5", "6"];
+    public const string RecommendedRoomVersion = "11";
 }
diff --git a/MatrixUtils.Web/Classes/RMUStorageWrapper.cs b/MatrixUtils.Web/Classes/RMUStorageWrapper.cs
index 45028ba..e63c28e 100644
--- a/MatrixUtils.Web/Classes/RMUStorageWrapper.cs
+++ b/MatrixUtils.Web/Classes/RMUStorageWrapper.cs
@@ -25,6 +25,10 @@ public class RMUStorageWrapper(ILogger<RMUStorageWrapper> logger, TieredStorageS
             await SetCurrentToken(currentToken = allTokens[0]);
         }
 
+        if (currentToken is null) {
+            await SetCurrentToken(currentToken = allTokens[0]);
+        }
+
         if (!allTokens.Any(x => x.AccessToken == currentToken.AccessToken)) {
             await SetCurrentToken(currentToken = allTokens[0]);
         }
@@ -47,12 +51,21 @@ public class RMUStorageWrapper(ILogger<RMUStorageWrapper> logger, TieredStorageS
             return null;
         }
 
-        return await homeserverProviderService.GetAuthenticatedWithToken(token.Homeserver, token.AccessToken, token.Proxy);
+        return await GetSession(token);
     }
 
     public async Task<AuthenticatedHomeserverGeneric?> GetSession(UserAuth userAuth) {
         logger.LogTrace("Getting session.");
-        return await homeserverProviderService.GetAuthenticatedWithToken(userAuth.Homeserver, userAuth.AccessToken, userAuth.Proxy);
+        AuthenticatedHomeserverGeneric hs;
+        try {
+            hs = await homeserverProviderService.GetAuthenticatedWithToken(userAuth.Homeserver, userAuth.AccessToken, userAuth.Proxy);
+        }
+        catch (Exception e) {
+            logger.LogError("Failed to get info for {0} via {1}: {2}", userAuth.UserId, userAuth.Homeserver, e);
+            logger.LogError("Continuing with server-less session");
+            hs = await homeserverProviderService.GetAuthenticatedWithToken(userAuth.Homeserver, userAuth.AccessToken, userAuth.Proxy, useGeneric: true, enableServer: false);
+        }
+        return hs;
     }
 
     public async Task<AuthenticatedHomeserverGeneric?> GetCurrentSessionOrNavigate() {
diff --git a/MatrixUtils.Web/Classes/RoomCreationTemplates/DefaultRoomCreationTemplate.cs b/MatrixUtils.Web/Classes/RoomCreationTemplates/DefaultRoomCreationTemplate.cs
index a627a9c..7078308 100644
--- a/MatrixUtils.Web/Classes/RoomCreationTemplates/DefaultRoomCreationTemplate.cs
+++ b/MatrixUtils.Web/Classes/RoomCreationTemplates/DefaultRoomCreationTemplate.cs
@@ -1,6 +1,5 @@
 using System.Text.Json.Nodes;
 using LibMatrix;
-using LibMatrix.EventTypes.Spec.State;
 using LibMatrix.EventTypes.Spec.State.RoomInfo;
 using LibMatrix.Responses;
 
@@ -34,7 +33,7 @@ public class DefaultRoomCreationTemplate : IRoomCreationTemplate {
                 },
                 new() {
                     Type = "m.room.server_acl",
-                    TypedContent = new RoomServerACLEventContent() {
+                    TypedContent = new RoomServerAclEventContent() {
                         Allow = new List<string>() { "*" },
                         Deny = new List<string>(),
                         AllowIpLiterals = false
@@ -56,7 +55,7 @@ public class DefaultRoomCreationTemplate : IRoomCreationTemplate {
                 Redact = 50,
                 Kick = 50,
                 Ban = 50,
-                NotificationsPl = new RoomPowerLevelEventContent.NotificationsPL {
+                NotificationsPl = new RoomPowerLevelEventContent.NotificationsPowerLevels {
                     Room = 50
                 },
                 Events = new() {
diff --git a/MatrixUtils.Web/MatrixUtils.Web.csproj b/MatrixUtils.Web/MatrixUtils.Web.csproj
index 8760e7a..b472b45 100644
--- a/MatrixUtils.Web/MatrixUtils.Web.csproj
+++ b/MatrixUtils.Web/MatrixUtils.Web.csproj
@@ -1,7 +1,7 @@
 <Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
 
     <PropertyGroup>
-        <TargetFramework>net8.0</TargetFramework>
+        <TargetFramework>net9.0</TargetFramework>
         <Nullable>enable</Nullable>
         <ImplicitUsings>enable</ImplicitUsings>
         <LinkIncremental>true</LinkIncremental>
@@ -12,62 +12,64 @@
         <BlazorEnableCompression>false</BlazorEnableCompression>
         <ServiceWorkerAssetsManifest>service-worker-assets.js</ServiceWorkerAssetsManifest>
         <BlazorCacheBootResources>false</BlazorCacheBootResources>
-<!--        <RunAOTCompilation>true</RunAOTCompilation>-->
+        <BlazorEnableTimeZoneSupport>false</BlazorEnableTimeZoneSupport>
     </PropertyGroup>
 
-    <ItemGroup>
-        <PackageReference Include="Blazored.LocalStorage" Version="4.5.0" />
-        <PackageReference Include="Blazored.SessionStorage" Version="2.4.0" />
-        <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.6" />
-        <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="8.0.6" PrivateAssets="all" />
-        <PackageReference Include="Microsoft.Extensions.Logging.Configuration" Version="8.0.0" />
-    </ItemGroup>
+    <!-- Explicitly disable all the unused runtime things trimming would have removed anyways -->
+    <!-- https://learn.microsoft.com/en-us/dotnet/core/deploying/trimming/trimming-options -->
+    <PropertyGroup>
+        <AutoreleasePoolSupport>false</AutoreleasePoolSupport> <!-- Browser != MacOS -->
+        <MetadataUpdaterSupport>false</MetadataUpdaterSupport> <!-- Unreliable -->
+        <DebuggerSupport>false</DebuggerSupport> <!-- Unreliable -->
+        <InvariantGlobalization>true</InvariantGlobalization> <!-- invariant globalization is fine -->
+        <!-- unused features -->
+        <EventSourceSupport>false</EventSourceSupport>
+        <EnableUnsafeBinaryFormatterSerialization>false</EnableUnsafeBinaryFormatterSerialization>
+        <HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
+        <EnableUnsafeUTF7Encoding>false</EnableUnsafeUTF7Encoding>
+        <MetricsSupport>false</MetricsSupport>
+        <UseNativeHttpHandler>false</UseNativeHttpHandler>
+        <XmlResolverIsNetworkingEnabledByDefault>false</XmlResolverIsNetworkingEnabledByDefault>
+        <BuiltInComInteropSupport>false</BuiltInComInteropSupport>
+        <CustomResourceTypesSupport>false</CustomResourceTypesSupport>
+        <EnableCppCLIHostActivation>false</EnableCppCLIHostActivation>
+        <StartupHookSupport>false</StartupHookSupport>
+    </PropertyGroup>
 
     <ItemGroup>
-        <ProjectReference Condition="Exists('..\LibMatrix\ArcaneLibs\ArcaneLibs.Blazor.Components\ArcaneLibs.Blazor.Components.csproj')" Include="..\LibMatrix\ArcaneLibs\ArcaneLibs.Blazor.Components\ArcaneLibs.Blazor.Components.csproj" />
-        <PackageReference Condition="!Exists('..\LibMatrix\ArcaneLibs\ArcaneLibs.Blazor.Components\ArcaneLibs.Blazor.Components.csproj')" Include="ArcaneLibs.Blazor.Components" Version="*-preview*" />
-        <ProjectReference Include="..\LibMatrix\LibMatrix\LibMatrix.csproj" />
-        <ProjectReference Include="..\MatrixUtils.Abstractions\MatrixUtils.Abstractions.csproj" />
-        <ProjectReference Include="..\MatrixUtils.LibDMSpace\MatrixUtils.LibDMSpace.csproj" />
+        <PackageReference Include="Blazored.LocalStorage" Version="4.5.0"/>
+        <PackageReference Include="Blazored.SessionStorage" Version="2.4.0"/>
+        <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="9.0.1" />
+        <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="9.0.1" PrivateAssets="all" />
+        <PackageReference Include="Microsoft.Extensions.Logging.Configuration" Version="9.0.1" />
+        <PackageReference Include="SpawnDev.BlazorJS.WebWorkers" Version="2.5.36" />
     </ItemGroup>
 
     <ItemGroup>
-      <Content Update="appsettings.Development.json">
-        <CopyToOutputDirectory>Always</CopyToOutputDirectory>
-      </Content>
-      <Content Update="appsettings.json">
-        <CopyToOutputDirectory>Always</CopyToOutputDirectory>
-      </Content>
-      <Content Update="wwwroot\appsettings.json">
-        <CopyToOutputDirectory>Always</CopyToOutputDirectory>
-      </Content>
+        <ProjectReference Include="..\MatrixUtils.Abstractions\MatrixUtils.Abstractions.csproj"/>
+        <ProjectReference Include="..\MatrixUtils.LibDMSpace\MatrixUtils.LibDMSpace.csproj"/>
     </ItemGroup>
 
     <ItemGroup>
-        <ServiceWorker Include="wwwroot\service-worker.js" PublishedContent="wwwroot\service-worker.published.js" />
+<!--        <PackageReference Include="ArcaneLibs.Blazor.Components" Version="1.0.0-preview.20241210-161342" Condition="'$(Configuration)' == 'Release'"/>-->
+<!--        <ProjectReference Include="..\LibMatrix\ArcaneLibs\ArcaneLibs.Blazor.Components\ArcaneLibs.Blazor.Components.csproj" Condition="'$(Configuration)' == 'Debug'"/>-->
+        <ProjectReference Include="..\LibMatrix\ArcaneLibs\ArcaneLibs.Blazor.Components\ArcaneLibs.Blazor.Components.csproj"/>
     </ItemGroup>
 
     <ItemGroup>
-      <_ContentIncludedByDefault Remove="Pages\Client\ClientComponents\ClientRoomList.razor" />
-      <_ContentIncludedByDefault Remove="Pages\Client\ClientComponents\ClientStatusList.razor" />
-      <_ContentIncludedByDefault Remove="Pages\Client\ClientComponents\MatrixClient.razor" />
-      <_ContentIncludedByDefault Remove="Pages\Client\Index.razor" />
+        <Content Update="appsettings.Development.json">
+            <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+        </Content>
+        <Content Update="appsettings.json">
+            <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+        </Content>
+        <Content Update="wwwroot\appsettings.json">
+            <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+        </Content>
     </ItemGroup>
 
     <ItemGroup>
-      <AdditionalFiles Include="Pages\Labs\Client\ClientComponents\ClientRoomList.razor" />
-      <AdditionalFiles Include="Pages\Labs\Client\ClientComponents\ClientStatusList.razor" />
-      <AdditionalFiles Include="Pages\Labs\Client\ClientComponents\MatrixClient.razor" />
-      <AdditionalFiles Include="Pages\Labs\Client\Index.razor" />
-      <AdditionalFiles Include="Pages\Labs\DMSpace\DMSpaceStages\DMSpaceStage0.razor" />
-      <AdditionalFiles Include="Pages\Labs\DMSpace\DMSpaceStages\DMSpaceStage1.razor" />
-      <AdditionalFiles Include="Pages\Labs\DMSpace\DMSpaceStages\DMSpaceStage2.razor" />
-      <AdditionalFiles Include="Pages\Labs\DMSpace\DMSpaceStages\DMSpaceStage3.razor" />
-      <AdditionalFiles Include="Pages\Labs\Rooms2\Index2Components\MainTabComponents\MainTabSpaceItem.razor" />
-      <AdditionalFiles Include="Pages\Labs\Rooms2\Index2Components\RoomsIndex2ByRoomTypeTab.razor" />
-      <AdditionalFiles Include="Pages\Labs\Rooms2\Index2Components\RoomsIndex2DMsTab.razor" />
-      <AdditionalFiles Include="Pages\Labs\Rooms2\Index2Components\RoomsIndex2MainTab.razor" />
-      <AdditionalFiles Include="Pages\Labs\Rooms2\Index2Components\RoomsIndex2SyncContainer.razor" />
+        <ServiceWorker Include="wwwroot\service-worker.js" PublishedContent="wwwroot\service-worker.published.js"/>
     </ItemGroup>
-    
+
 </Project>
diff --git a/MatrixUtils.Web/Pages/About.razor b/MatrixUtils.Web/Pages/About.razor
index 18d7c3f..9f83991 100644
--- a/MatrixUtils.Web/Pages/About.razor
+++ b/MatrixUtils.Web/Pages/About.razor
@@ -7,6 +7,6 @@
 <p>Rory&::MatrixUtils is a "small" collection of tools to do not-so-everyday things.</p>
 <p>These range from joining rooms on dead homeservers, to managing your accounts and rooms, and creating rooms based on templates.</p>
 
-<br/><br/>
-<p>You can find the source code on <a href="https://cgit.rory.gay/matrix/MatrixRoomUtils.git/">cgit.rory.gay</a>.<br/></p>
+<br/>
+<p>You can find the source code on <a href="https://cgit.rory.gay/matrix/tools/MatrixUtils.git/about/">cgit.rory.gay</a>.<br/></p>
 <p>You can also join the <a href="https://matrix.to/#/%23mru%3Arory.gay?via=rory.gay&via=matrix.org&via=feline.support">Matrix room</a> for this project.</p>
diff --git a/MatrixUtils.Web/Pages/Dev/DevOptions.razor b/MatrixUtils.Web/Pages/Dev/DevOptions.razor
index 7b646d1..e94cf76 100644
--- a/MatrixUtils.Web/Pages/Dev/DevOptions.razor
+++ b/MatrixUtils.Web/Pages/Dev/DevOptions.razor
@@ -2,7 +2,6 @@
 @using ArcaneLibs.Extensions
 @using System.Text
 @using System.Text.Json
-@using Microsoft.JSInterop
 @inject NavigationManager NavigationManager
 @inject IJSRuntime JSRuntime
 @inject TieredStorageService TieredStorage
diff --git a/MatrixUtils.Web/Pages/Dev/DevUtilities.razor b/MatrixUtils.Web/Pages/Dev/DevUtilities.razor
index bf5a396..d81a790 100644
--- a/MatrixUtils.Web/Pages/Dev/DevUtilities.razor
+++ b/MatrixUtils.Web/Pages/Dev/DevUtilities.razor
@@ -38,7 +38,7 @@ else {
 
     protected override async Task OnInitializedAsync() {
         await base.OnInitializedAsync();
-        hs = await RMUStorage.GetCurrentSessionOrNavigate();
+        hs = await RmuStorage.GetCurrentSessionOrNavigate();
         if (hs == null) return;
         Rooms = (await hs.GetJoinedRooms()).Select(x => x.RoomId).ToList();
         Console.WriteLine("Fetched joined rooms!");
diff --git a/MatrixUtils.Web/Pages/Dev/ModalTest.razor b/MatrixUtils.Web/Pages/Dev/ModalTest.razor
new file mode 100644
index 0000000..665f548
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Dev/ModalTest.razor
@@ -0,0 +1,12 @@
+@page "/Dev/ModalTest"
+
+<PageTitle>Modal test</PageTitle>
+
+<h3>Rory&::MatrixUtils - Modal test</h3>
+<hr/>
+@for (int i = 0; i < 10; i++)
+{
+    <ModalWindow X="i*75" Y="i*75">
+        <h1>Hello, world!</h1>
+    </ModalWindow>
+}
diff --git a/MatrixUtils.Web/Pages/HSAdmin/HSAdmin.razor b/MatrixUtils.Web/Pages/HSAdmin/HSAdmin.razor
index 9c61431..409d582 100644
--- a/MatrixUtils.Web/Pages/HSAdmin/HSAdmin.razor
+++ b/MatrixUtils.Web/Pages/HSAdmin/HSAdmin.razor
@@ -24,7 +24,7 @@ else {
     public ServerVersionResponse? ServerVersionResponse { get; set; }
 
     protected override async Task OnInitializedAsync() {
-        Homeserver = await RMUStorage.GetCurrentSessionOrNavigate();
+        Homeserver = await RmuStorage.GetCurrentSessionOrNavigate();
         if (Homeserver is null) return;
         ServerVersionResponse = await (Homeserver.FederationClient?.GetServerVersionAsync() ?? Task.FromResult<ServerVersionResponse?>(null));
         await base.OnInitializedAsync();
diff --git a/MatrixUtils.Web/Pages/HSAdmin/RoomQuery.razor b/MatrixUtils.Web/Pages/HSAdmin/RoomQuery.razor
index 11df261..1e63e16 100644
--- a/MatrixUtils.Web/Pages/HSAdmin/RoomQuery.razor
+++ b/MatrixUtils.Web/Pages/HSAdmin/RoomQuery.razor
@@ -167,7 +167,7 @@
 
     private async Task Search() {
         Results.Clear();
-        var hs = await RMUStorage.GetCurrentSessionOrNavigate();
+        var hs = await RmuStorage.GetCurrentSessionOrNavigate();
         if (hs is AuthenticatedHomeserverSynapse synapse) {
             var searchRooms = synapse.Admin.SearchRoomsAsync(orderBy: OrderBy!, dir: Ascending ? "f" : "b", searchTerm: SearchTerm, localFilter: Filter).GetAsyncEnumerator();
             while (await searchRooms.MoveNextAsync()) {
diff --git a/MatrixUtils.Web/Pages/HSEInit.razor b/MatrixUtils.Web/Pages/HSEInit.razor
index cabc671..1eb556a 100644
--- a/MatrixUtils.Web/Pages/HSEInit.razor
+++ b/MatrixUtils.Web/Pages/HSEInit.razor
@@ -19,7 +19,7 @@
 
     async Task<UserAuth?> Login() {
         try {
-            var result = new UserAuth(await hsProvider.Login("http://localhost:5298", $"{Guid.NewGuid().ToString()}", ""));
+            var result = new UserAuth(await HsProvider.Login("http://localhost:5298", $"{Guid.NewGuid().ToString()}", ""));
             if (result == null) {
                 Console.WriteLine($"Failed to login!");
                 return null;
diff --git a/MatrixUtils.Web/Pages/Index.razor b/MatrixUtils.Web/Pages/Index.razor
index a7619ae..8847467 100644
--- a/MatrixUtils.Web/Pages/Index.razor
+++ b/MatrixUtils.Web/Pages/Index.razor
@@ -22,20 +22,28 @@ Small collection of tools to do not-so-everyday things.
 <form>
     <table>
         @foreach (var session in _sessions.OrderByDescending(x => x.UserInfo.RoomCount)) {
-            var _auth = session.UserAuth;
+            var auth = session.UserAuth;
             <tr class="user-entry">
                 <td>
-                    <img class="avatar" src="@session.UserInfo.AvatarUrl" crossorigin="anonymous"/>
+                    @if (!string.IsNullOrWhiteSpace(@session.UserInfo.AvatarUrl)) {
+                        <MxcAvatar Homeserver="session.Homeserver" MxcUri="@session.UserInfo.AvatarUrl" Circular="true" Size="4" SizeUnit="em"/>
+                    }
+                    else {
+                        <img class="avatar" src="@_identiconGenerator.GenerateAsDataUri(session.Homeserver.WhoAmI.UserId)"/>
+                    }
+                    @* <img class="avatar" src="@session.UserInfo.AvatarUrl" crossorigin="anonymous"/> *@
                 </td>
                 <td class="user-info">
                     <p>
-                        <input type="radio" name="csa" checked="@(_currentSession.AccessToken == _auth.AccessToken)" @onclick="@(() => SwitchSession(_auth))" style="text-decoration-line: unset;"/>
-                        <b>@session.UserInfo.DisplayName</b> on <b>@_auth.Homeserver</b><br/>
+                        <input type="radio" name="csa" checked="@(_currentSession.AccessToken == auth.AccessToken)" @onclick="@(() => SwitchSession(auth))"
+                               style="text-decoration-line: unset;"/>
+                        <b>@session.UserInfo.DisplayName</b> on <b>@auth.Homeserver</b><br/>
                     </p>
                     <span style="display: inline-block; width: 128px;">@session.UserInfo.RoomCount rooms</span>
-                    <a style="color: #888888" href="@("/ServerInfo/" + session.Homeserver?.ServerName + "/")">@session.ServerVersion?.Server.Name @session.ServerVersion?.Server.Version</a>
-                    @if (_auth.Proxy != null) {
-                        <span class="badge badge-info"> (proxied via @_auth.Proxy)</span>
+                    <a style="color: #888888"
+                       href="@("/ServerInfo/" + session.Homeserver?.ServerName + "/")">@session.ServerVersion?.Server.Name @session.ServerVersion?.Server.Version</a>
+                    @if (auth.Proxy != null) {
+                        <span class="badge badge-info"> (proxied via @auth.Proxy)</span>
                     }
                     else {
                         <p>Not proxied</p>
@@ -48,9 +56,9 @@ Small collection of tools to do not-so-everyday things.
                 </td>
                 <td>
                     <p>
-                        <LinkButton OnClick="@(() => ManageUser(_auth))">Manage</LinkButton>
-                        <LinkButton OnClick="@(() => RemoveUser(_auth))">Remove</LinkButton>
-                        <LinkButton OnClick="@(() => RemoveUser(_auth, true))">Log out</LinkButton>
+                        <LinkButton OnClick="@(() => ManageUser(auth))">Manage</LinkButton>
+                        <LinkButton OnClick="@(() => RemoveUser(auth))">Remove</LinkButton>
+                        <LinkButton OnClick="@(() => RemoveUser(auth, true))">Log out</LinkButton>
                     </p>
                 </td>
             </tr>
@@ -108,7 +116,7 @@ Small collection of tools to do not-so-everyday things.
                         </p>
                     </td>
                     <td>
-                        <LinkButton OnClick="@(() => Task.Run(()=>NavigationManager.NavigateTo($"/InvalidSession?ctx={session.AccessToken}")))">Re-login</LinkButton>
+                        <LinkButton OnClick="@(() => Task.Run(() => NavigationManager.NavigateTo($"/InvalidSession?ctx={session.AccessToken}")))">Re-login</LinkButton>
                     </td>
                     <td>
                         <LinkButton OnClick="@(() => RemoveUser(session))">Remove</LinkButton>
@@ -138,23 +146,23 @@ Small collection of tools to do not-so-everyday things.
     private readonly List<UserAuth> _offlineSessions = [];
     private readonly List<UserAuth> _invalidSessions = [];
     private LoginResponse? _currentSession;
-    int scannedSessions = 0, totalSessions = 1;
+    int scannedSessions, totalSessions = 1;
     private SvgIdenticonGenerator _identiconGenerator = new();
 
     protected override async Task OnInitializedAsync() {
         Console.WriteLine("Index.OnInitializedAsync");
         logger.LogDebug("Initialising index page");
-        _currentSession = await RMUStorage.GetCurrentToken();
+        _currentSession = await RmuStorage.GetCurrentToken();
         _sessions.Clear();
         _offlineSessions.Clear();
-        var tokens = await RMUStorage.GetAllTokens();
+        var tokens = await RmuStorage.GetAllTokens();
         scannedSessions = 0;
         totalSessions = tokens.Count;
         logger.LogDebug("Found {0} tokens", totalSessions);
         if (tokens is not { Count: > 0 }) {
             Console.WriteLine("No tokens found, trying migration from MRU...");
-            await RMUStorage.MigrateFromMRU();
-            tokens = await RMUStorage.GetAllTokens();
+            await RmuStorage.MigrateFromMRU();
+            tokens = await RmuStorage.GetAllTokens();
             if (tokens is not { Count: > 0 }) {
                 Console.WriteLine("No tokens found");
                 return;
@@ -162,26 +170,34 @@ Small collection of tools to do not-so-everyday things.
         }
 
         List<string> offlineServers = [];
-        var sema = new SemaphoreSlim(64, 64);
+        var sema = new SemaphoreSlim(8, 8);
         var updateSw = Stopwatch.StartNew();
         var tasks = tokens.Select(async token => {
             await sema.WaitAsync();
-            if ((!string.IsNullOrWhiteSpace(token.Proxy) && offlineServers.Contains(token.Proxy)) || offlineServers.Contains(token.Homeserver)) {
-                _offlineSessions.Add(token);
-                sema.Release();
-                scannedSessions++;
-                return;
-            }
 
             AuthenticatedHomeserverGeneric hs;
             try {
-                hs = await hsProvider.GetAuthenticatedWithToken(token.Homeserver, token.AccessToken, token.Proxy);
+                Task<ServerVersionResponse> serverVersionTask = Task.FromResult<ServerVersionResponse>(new() {
+                    Server = new() {
+                        Name = "Unknown",
+                        Version = "0.0.0"
+                    }
+                });
+                try {
+                    hs = await HsProvider.GetAuthenticatedWithToken(token.Homeserver, token.AccessToken, token.Proxy);
+                    serverVersionTask = hs.FederationClient?.GetServerVersionAsync() ?? serverVersionTask!;
+                }
+                catch (Exception e) {
+                    logger.LogError("Failed to get info for {0} via {1}: {2}", token.UserId, token.Homeserver, e);
+                    logger.LogError("Continuing with server-less session");
+                    hs = await HsProvider.GetAuthenticatedWithToken(token.Homeserver, token.AccessToken, token.Proxy, useGeneric: true, enableServer: false);
+                }
+
                 var joinedRoomsTask = hs.GetJoinedRooms();
                 var profileTask = hs.GetProfileAsync(hs.WhoAmI.UserId);
-                var serverVersionTask = hs.FederationClient?.GetServerVersionAsync();
                 _sessions.Add(new() {
                     UserInfo = new() {
-                        AvatarUrl = string.IsNullOrWhiteSpace((await profileTask).AvatarUrl) ? _identiconGenerator.GenerateAsDataUri(hs.WhoAmI.UserId) : hs.ResolveMediaUri((await profileTask).AvatarUrl),
+                        AvatarUrl = (await profileTask).AvatarUrl,
                         RoomCount = (await joinedRoomsTask).Count,
                         DisplayName = (await profileTask).DisplayName ?? hs.WhoAmI.UserId
                     },
@@ -226,7 +242,7 @@ Small collection of tools to do not-so-everyday things.
     }
 
     private class UserInfo {
-        internal string AvatarUrl { get; set; }
+        internal string? AvatarUrl { get; set; }
         internal string DisplayName { get; set; }
         internal int RoomCount { get; set; }
     }
@@ -234,7 +250,7 @@ Small collection of tools to do not-so-everyday things.
     private async Task RemoveUser(UserAuth auth, bool logout = false) {
         try {
             if (logout) {
-                await (await hsProvider.GetAuthenticatedWithToken(auth.Homeserver, auth.AccessToken, auth.Proxy)).Logout();
+                await (await HsProvider.GetAuthenticatedWithToken(auth.Homeserver, auth.AccessToken, auth.Proxy)).Logout();
             }
         }
         catch (Exception e) {
@@ -246,15 +262,15 @@ Small collection of tools to do not-so-everyday things.
             Console.WriteLine(e);
         }
 
-        await RMUStorage.RemoveToken(auth);
-        if ((await RMUStorage.GetCurrentToken())?.AccessToken == auth.AccessToken)
-            await RMUStorage.SetCurrentToken((await RMUStorage.GetAllTokens() ?? throw new InvalidOperationException()).FirstOrDefault());
+        await RmuStorage.RemoveToken(auth);
+        if ((await RmuStorage.GetCurrentToken())?.AccessToken == auth.AccessToken)
+            await RmuStorage.SetCurrentToken((await RmuStorage.GetAllTokens() ?? throw new InvalidOperationException()).FirstOrDefault());
         StateHasChanged();
     }
 
     private async Task SwitchSession(UserAuth auth) {
         Console.WriteLine($"Switching to {auth.Homeserver} {auth.UserId} via {auth.Proxy}");
-        await RMUStorage.SetCurrentToken(auth);
+        await RmuStorage.SetCurrentToken(auth);
         _currentSession = auth;
         StateHasChanged();
     }
diff --git a/MatrixUtils.Web/Pages/InvalidSession.razor b/MatrixUtils.Web/Pages/InvalidSession.razor
index e1a72ea..b63c14f 100644
--- a/MatrixUtils.Web/Pages/InvalidSession.razor
+++ b/MatrixUtils.Web/Pages/InvalidSession.razor
@@ -40,7 +40,7 @@ else {
     private MatrixException? _loginException { get; set; }
 
     protected override async Task OnInitializedAsync() {
-        var tokens = await RMUStorage.GetAllTokens();
+        var tokens = await RmuStorage.GetAllTokens();
         if (tokens is null || tokens.Count == 0) {
             NavigationManager.NavigateTo("/Login");
             return;
@@ -56,9 +56,9 @@ else {
     }
 
     private async Task RemoveUser() {
-        await RMUStorage.RemoveToken(_login!);
-        if ((await RMUStorage.GetCurrentToken())!.AccessToken == _login!.AccessToken)
-            await RMUStorage.SetCurrentToken((await RMUStorage.GetAllTokens())?.FirstOrDefault());
+        await RmuStorage.RemoveToken(_login!);
+        if ((await RmuStorage.GetCurrentToken())!.AccessToken == _login!.AccessToken)
+            await RmuStorage.SetCurrentToken((await RmuStorage.GetAllTokens())?.FirstOrDefault());
         await OnInitializedAsync();
     }
 
@@ -70,14 +70,14 @@ else {
 
     private async Task SwitchSession(UserAuth auth) {
         Console.WriteLine($"Switching to {auth.Homeserver} {auth.AccessToken} {auth.UserId}");
-        await RMUStorage.SetCurrentToken(auth);
+        await RmuStorage.SetCurrentToken(auth);
         await OnInitializedAsync();
     }
 
     private async Task TryLogin() {
         if(_login is null) throw new NullReferenceException("Login is null!");
         try {
-            var result = new UserAuth(await hsProvider.Login(_login.Homeserver, _login.UserId, _password));
+            var result = new UserAuth(await HsProvider.Login(_login.Homeserver, _login.UserId, _password));
             if (result is null) {
                 Console.WriteLine($"Failed to login to {_login.Homeserver} as {_login.UserId}!");
                 return;
@@ -85,9 +85,9 @@ else {
             Console.WriteLine($"Obtained access token for {result.UserId}!");
 
             await RemoveUser();
-            await RMUStorage.AddToken(result);
-            if (result.UserId == (await RMUStorage.GetCurrentToken())?.UserId)
-                await RMUStorage.SetCurrentToken(result);
+            await RmuStorage.AddToken(result);
+            if (result.UserId == (await RmuStorage.GetCurrentToken())?.UserId)
+                await RmuStorage.SetCurrentToken(result);
             NavigationManager.NavigateTo("/");
         }
         catch (MatrixException e) {
diff --git a/MatrixUtils.Web/Pages/Labs/Client/Index.razor b/MatrixUtils.Web/Pages/Labs/Client/Index.razor
index ef4a0b9..4656fcb 100644
--- a/MatrixUtils.Web/Pages/Labs/Client/Index.razor
+++ b/MatrixUtils.Web/Pages/Labs/Client/Index.razor
@@ -40,11 +40,11 @@
     }
 
     protected override async Task OnInitializedAsync() {
-        var tokens = await RMUStorage.GetAllTokens();
+        var tokens = await RmuStorage.GetAllTokens();
         var tasks = tokens.Select(async token => {
             try {
                 var cc = new ClientContext() {
-                    Homeserver = await RMUStorage.GetSession(token)
+                    Homeserver = await RmuStorage.GetSession(token)
                 };
                 cc.SyncWrapper = new ClientSyncWrapper(cc.Homeserver);
 
diff --git a/MatrixUtils.Web/Pages/Labs/DMSpace/DMSpace.razor b/MatrixUtils.Web/Pages/Labs/DMSpace/DMSpace.razor
index c0dc8a6..a382729 100644
--- a/MatrixUtils.Web/Pages/Labs/DMSpace/DMSpace.razor
+++ b/MatrixUtils.Web/Pages/Labs/DMSpace/DMSpace.razor
@@ -52,7 +52,7 @@
             NavigationManager.NavigateTo(NavigationManager.Uri.Replace("stage=", ""), true); //"/User/DMSpace/Setup"
         }
         DMSpaceRootPage = this;
-        SetupData.Homeserver ??= await RMUStorage.GetCurrentSessionOrNavigate();
+        SetupData.Homeserver ??= await RmuStorage.GetCurrentSessionOrNavigate();
         if (SetupData.Homeserver is null) return;
         try {
             SetupData.DmSpaceConfiguration = await SetupData.Homeserver.GetAccountDataAsync<DMSpaceConfiguration>("gay.rory.dm_space");
diff --git a/MatrixUtils.Web/Pages/Labs/DMSpace/DMSpaceStages/DMSpaceStage1.razor b/MatrixUtils.Web/Pages/Labs/DMSpace/DMSpaceStages/DMSpaceStage1.razor
index 55e17d6..a974a8f 100644
--- a/MatrixUtils.Web/Pages/Labs/DMSpace/DMSpaceStages/DMSpaceStage1.razor
+++ b/MatrixUtils.Web/Pages/Labs/DMSpace/DMSpaceStages/DMSpaceStage1.razor
@@ -4,7 +4,8 @@
 @using MatrixUtils.LibDMSpace
 @using MatrixUtils.LibDMSpace.StateEvents
 @using ArcaneLibs.Extensions
-@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.EventTypes.Spec.State.RoomInfo
+@using LibMatrix.EventTypes.Spec.State.Space
 @using MatrixUtils.Abstractions
 <b>
     <u>DM Space setup tool - stage 1: Configure space</u>
diff --git a/MatrixUtils.Web/Pages/Labs/DMSpace/DMSpaceStages/DMSpaceStage2.razor b/MatrixUtils.Web/Pages/Labs/DMSpace/DMSpaceStages/DMSpaceStage2.razor
index be6027a..b8eb257 100644
--- a/MatrixUtils.Web/Pages/Labs/DMSpace/DMSpaceStages/DMSpaceStage2.razor
+++ b/MatrixUtils.Web/Pages/Labs/DMSpace/DMSpaceStages/DMSpaceStage2.razor
@@ -1,6 +1,6 @@
 @using LibMatrix.RoomTypes
-@using LibMatrix.EventTypes.Spec.State
 @using ArcaneLibs.Extensions
+@using LibMatrix.EventTypes.Spec.State.RoomInfo
 @using MatrixUtils.Abstractions
 <b>
     <u>DM Space setup tool - stage 2: Fix DM room attribution</u>
diff --git a/MatrixUtils.Web/Pages/Labs/DMSpace/DMSpaceStages/DMSpaceStage3.razor b/MatrixUtils.Web/Pages/Labs/DMSpace/DMSpaceStages/DMSpaceStage3.razor
index 09de5d3..dac9c49 100644
--- a/MatrixUtils.Web/Pages/Labs/DMSpace/DMSpaceStages/DMSpaceStage3.razor
+++ b/MatrixUtils.Web/Pages/Labs/DMSpace/DMSpaceStages/DMSpaceStage3.razor
@@ -1,8 +1,8 @@
 @using LibMatrix.RoomTypes
-@using LibMatrix.EventTypes.Spec.State
 @using LibMatrix.Responses
 @using MatrixUtils.LibDMSpace
 @using System.Text.Json.Serialization
+@using LibMatrix.EventTypes.Spec.State.RoomInfo
 @using MatrixUtils.Abstractions
 
 <b>
diff --git a/MatrixUtils.Web/Pages/Labs/Rooms2/Index2.razor b/MatrixUtils.Web/Pages/Labs/Rooms2/Index2.razor
index 3392960..f9da6eb 100644
--- a/MatrixUtils.Web/Pages/Labs/Rooms2/Index2.razor
+++ b/MatrixUtils.Web/Pages/Labs/Rooms2/Index2.razor
@@ -55,7 +55,7 @@
     public RoomListViewData Data { get; set; } = new RoomListViewData();
 
     protected override async Task OnInitializedAsync() {
-        Data.Homeserver = await RMUStorage.GetCurrentSessionOrNavigate();
+        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);
diff --git a/MatrixUtils.Web/Pages/Labs/Rooms2/Index2Components/MainTabComponents/MainTabSpaceItem.razor b/MatrixUtils.Web/Pages/Labs/Rooms2/Index2Components/MainTabComponents/MainTabSpaceItem.razor
index 6483f01..596d63d 100644
--- a/MatrixUtils.Web/Pages/Labs/Rooms2/Index2Components/MainTabComponents/MainTabSpaceItem.razor
+++ b/MatrixUtils.Web/Pages/Labs/Rooms2/Index2Components/MainTabComponents/MainTabSpaceItem.razor
@@ -8,7 +8,7 @@
             <span onclick="@ToggleSpace">▶ </span>
         }
 
-        <MxcImage Circular="true" Height="32" Width="32" Homeserver="Space.Room.Homeserver" MxcUri="@Space.RoomIcon"></MxcImage>
+        <MxcImage Circular="true" Height="32" Width="32" MxcUri="@Space.RoomIcon"></MxcImage>
         <span class="spaceNameEllipsis">@Space.RoomName</span>
     </div>
     @if (IsSpaceOpened()) {
diff --git a/MatrixUtils.Web/Pages/Labs/Rooms2/Index2Components/RoomsIndex2MainTab.razor b/MatrixUtils.Web/Pages/Labs/Rooms2/Index2Components/RoomsIndex2MainTab.razor
index 7ccfae2..6bf542f 100644
--- a/MatrixUtils.Web/Pages/Labs/Rooms2/Index2Components/RoomsIndex2MainTab.razor
+++ b/MatrixUtils.Web/Pages/Labs/Rooms2/Index2Components/RoomsIndex2MainTab.razor
@@ -1,6 +1,6 @@
 @using MatrixUtils.Abstractions
 @using System.ComponentModel
-@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.EventTypes.Spec.State.Space
 @using MatrixUtils.Web.Pages.Labs.Rooms2.Index2Components.MainTabComponents
 <h3>RoomsIndex2MainTab</h3>
 
@@ -22,26 +22,27 @@
 @*     </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) *@
-                <MainTabSpaceItem Space="space" OpenedSpaces="OpenedSpaces" @bind-SelectedSpace="SelectedSpace" />
-            }
-        </div>
-        <div class="col-9" style="background-color: #ff00ff66;">
-            <p>Placeholder for rooms list...</p>
-            @if (SelectedSpace != null) {
-                foreach (var room in GetSpaceChildRooms(SelectedSpace)) {
-                    <p>@room.RoomName</p>
+<CascadingValue Name="Homeserver" Value="@Data.Homeserver">
+    <div>
+        <div class="row">
+            <div class="col-3" style="background-color: #ffffff22;">
+                <LinkButton>Uncategorised rooms</LinkButton>
+                @foreach (var space in GetTopLevelSpaces()) {
+                    @* @RecursingSpaceChildren(space) *@
+                    <MainTabSpaceItem Space="space" OpenedSpaces="OpenedSpaces" @bind-SelectedSpace="SelectedSpace"/>
+                }
+            </div>
+            <div class="col-9" style="background-color: #ff00ff66;">
+                <p>Placeholder for rooms list...</p>
+                @if (SelectedSpace != null) {
+                    foreach (var room in GetSpaceChildRooms(SelectedSpace)) {
+                        <p>@room.RoomName</p>
+                    }
                 }
-            }
+            </div>
         </div>
     </div>
-</div>
-
+</CascadingValue>
 
 @code {
 
@@ -118,7 +119,7 @@
         var childSpaces = children.Where(x => x.RoomType == "m.space").ToList();
         return childSpaces;
     }
-    
+
     private List<RoomInfo> GetSpaceChildRooms(RoomInfo space) {
         var children = GetSpaceChildren(space);
         var childRooms = children.Where(x => x.RoomType != "m.space").ToList();
diff --git a/MatrixUtils.Web/Pages/Labs/Rooms2/Index2Components/RoomsIndex2SyncContainer.razor b/MatrixUtils.Web/Pages/Labs/Rooms2/Index2Components/RoomsIndex2SyncContainer.razor
index 91f228d..ae57521 100644
--- a/MatrixUtils.Web/Pages/Labs/Rooms2/Index2Components/RoomsIndex2SyncContainer.razor
+++ b/MatrixUtils.Web/Pages/Labs/Rooms2/Index2Components/RoomsIndex2SyncContainer.razor
@@ -2,11 +2,11 @@
 @using LibMatrix.Responses
 @using MatrixUtils.Abstractions
 @using System.Diagnostics
-@using LibMatrix.EventTypes.Spec.State
 @using LibMatrix.Extensions
 @using LibMatrix.Utilities
 @using System.Collections.ObjectModel
 @using ArcaneLibs
+@using LibMatrix.EventTypes.Spec.State.Space
 @inject ILogger<RoomsIndex2SyncContainer> logger
 <pre>RoomsIndex2SyncContainer</pre>
 @foreach (var (name, value) in _statusList) {
diff --git a/MatrixUtils.Web/Pages/LoginPage.razor b/MatrixUtils.Web/Pages/LoginPage.razor
index 6c869ac..bc989c2 100644
--- a/MatrixUtils.Web/Pages/LoginPage.razor
+++ b/MatrixUtils.Web/Pages/LoginPage.razor
@@ -27,6 +27,27 @@
 <br/>
 <br/>
 
+
+<h4>Add with access token</h4>
+<hr/>
+
+<span style="display: block;">
+    <label>Homeserver:</label>
+    <FancyTextBox @bind-Value="@newRecordInput.Homeserver"></FancyTextBox>
+</span>
+<span style="display: block;">
+    <label>Access token:</label>
+    <FancyTextBox @bind-Value="@newRecordInput.Password" IsPassword="true"></FancyTextBox>
+</span>
+<span style="display: block">
+    <label>Proxy (<a href="https://cgit.rory.gay/matrix/MxApiExtensions.git">MxApiExtensions</a> or similar):</label>
+    <FancyTextBox @bind-Value="@newRecordInput.Proxy"></FancyTextBox>
+</span>
+<br/>
+<LinkButton OnClick="@(() => AddWithAccessToken(newRecordInput))">Add session</LinkButton>
+<br/>
+<br/>
+
 <h4>Import from TSV</h4>
 <hr/>
 <span>Import credentials from a TSV (Tab Separated Values) file</span><br/>
@@ -100,7 +121,7 @@
         if (LoggedInSessions.Any(x => x.UserId == $"@{record.Username}:{record.Homeserver}" && x.Proxy == record.Proxy)) return;
         StateHasChanged();
         try {
-            var result = new UserAuth(await hsProvider.Login(record.Homeserver, record.Username, record.Password, record.Proxy)) {
+            var result = new UserAuth(await HsProvider.Login(record.Homeserver, record.Username, record.Password, record.Proxy)) {
                 Proxy = record.Proxy
             };
             if (result == null) {
@@ -110,8 +131,8 @@
 
             Console.WriteLine($"Obtained access token for {result.UserId}!");
 
-            await RMUStorage.AddToken(result);
-            LoggedInSessions = await RMUStorage.GetAllTokens();
+            await RmuStorage.AddToken(result);
+            LoggedInSessions = await RmuStorage.GetAllTokens();
         }
         catch (Exception e) {
             Console.WriteLine($"Failed to login to {record.Homeserver} as {record.Username}!");
@@ -123,7 +144,7 @@
     }
 
     private async Task FileChanged(InputFileChangeEventArgs obj) {
-        LoggedInSessions = await RMUStorage.GetAllTokens();
+        LoggedInSessions = await RmuStorage.GetAllTokens();
         Console.WriteLine(JsonSerializer.Serialize(obj, new JsonSerializerOptions {
             WriteIndented = true
         }));
@@ -141,7 +162,7 @@
     }
 
     private async Task AddRecord() {
-        LoggedInSessions = await RMUStorage.GetAllTokens();
+        LoggedInSessions = await RmuStorage.GetAllTokens();
         records.Add(newRecordInput);
         newRecordInput = new();
     }
@@ -156,4 +177,27 @@
         internal Exception? Exception { get; set; }
     }
 
+    private async Task AddWithAccessToken(LoginStruct record) {
+        try {
+            var session = await HsProvider.GetAuthenticatedWithToken(record.Homeserver, record.Password, record.Proxy);
+            if (session == null) {
+                Console.WriteLine($"Failed to login to {record.Homeserver} as {record.Username}!");
+                return;
+            }
+            
+            await RmuStorage.AddToken(new UserAuth() {
+                UserId = session.WhoAmI.UserId,
+                AccessToken = session.AccessToken,
+                Proxy = record.Proxy,
+                DeviceId = session.WhoAmI.DeviceId
+            });
+            LoggedInSessions = await RmuStorage.GetAllTokens();
+        }
+        catch (Exception e) {
+            Console.WriteLine($"Failed to login to {record.Homeserver} as {record.Username}!");
+            Console.WriteLine(e);
+            record.Exception = e;
+        }
+    }
+
 }
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Moderation/UserRoomHistory.razor b/MatrixUtils.Web/Pages/Moderation/UserRoomHistory.razor
index 9218c8c..9bb20f0 100644
--- a/MatrixUtils.Web/Pages/Moderation/UserRoomHistory.razor
+++ b/MatrixUtils.Web/Pages/Moderation/UserRoomHistory.razor
@@ -1,7 +1,7 @@
 @page "/Moderation/UserRoomHistory/{UserId}"
-@using LibMatrix.EventTypes.Spec.State
 @using LibMatrix.RoomTypes
 @using ArcaneLibs.Extensions
+@using LibMatrix.EventTypes.Spec.State.RoomInfo
 @using MatrixUtils.Abstractions
 <h3>UserRoomHistory</h3>
 
@@ -44,11 +44,11 @@ else {
     private AuthenticatedHomeserverGeneric? currentHs { get; set; }
 
     protected override async Task OnInitializedAsync() {
-        var hs = await RMUStorage.GetCurrentSessionOrNavigate();
+        var hs = await RmuStorage.GetCurrentSessionOrNavigate();
         if (hs is null) return;
-        var sessions = await RMUStorage.GetAllTokens();
+        var sessions = await RmuStorage.GetAllTokens();
         foreach (var userAuth in sessions) {
-            var session = await RMUStorage.GetSession(userAuth);
+            var session = await RmuStorage.GetSession(userAuth);
             if (session is not null) {
                 hss.Add(session);
                 StateHasChanged();
diff --git a/MatrixUtils.Web/Pages/Rooms/Create.razor b/MatrixUtils.Web/Pages/Rooms/Create.razor
index f2dfb01..a36ccf8 100644
--- a/MatrixUtils.Web/Pages/Rooms/Create.razor
+++ b/MatrixUtils.Web/Pages/Rooms/Create.razor
@@ -3,11 +3,9 @@
 @using System.Reflection
 @using ArcaneLibs.Extensions
 @using LibMatrix
-@using LibMatrix.EventTypes.Spec.State
 @using LibMatrix.EventTypes.Spec.State.RoomInfo
 @using LibMatrix.Responses
 @using MatrixUtils.Web.Classes.RoomCreationTemplates
-@using Microsoft.AspNetCore.Components.Forms
 @* @* ReSharper disable once RedundantUsingDirective - Must not remove this, Rider marks this as "unused" when it's not */ *@
 
 <h3>Room Manager - Create Room</h3>
@@ -89,7 +87,7 @@
         <tr>
             <td>Room icon:</td>
             <td>
-                <img src="@Homeserver.ResolveMediaUri(roomAvatarEvent.Url)" style="width: 128px; height: 128px; border-radius: 50%;"/>
+                @* <img src="@Homeserver.ResolveMediaUri(roomAvatarEvent.Url)" style="width: 128px; height: 128px; border-radius: 50%;"/> *@
                 <div style="display: inline-block; vertical-align: middle;">
                     <FancyTextBox @bind-Value="@roomAvatarEvent.Url"></FancyTextBox><br/>
                     <InputFile OnChange="RoomIconFilePicked"></InputFile>
@@ -134,7 +132,7 @@
                 }
                 else {
                     <details>
-                        <summary>@((creationEvent["m.room.server_acls"].TypedContent as RoomServerACLEventContent).Allow.Count) allow rules</summary>
+                        <summary>@((creationEvent["m.room.server_acls"].TypedContent as RoomServerAclEventContent).Allow.Count) allow rules</summary>
                         @* <StringListEditor @bind-Items="@serverAcl.Allow"></StringListEditor> *@
                     </details>
                 }
@@ -144,7 +142,7 @@
                 }
                 else {
                     <details>
-                        <summary>@((creationEvent["m.room.server_acls"].TypedContent as RoomServerACLEventContent).Deny.Count) deny rules</summary>
+                        <summary>@((creationEvent["m.room.server_acls"].TypedContent as RoomServerAclEventContent).Deny.Count) deny rules</summary>
                         @* <StringListEditor @bind-Items="@serverAcl.Allow"></StringListEditor> *@
                     </details>
                 }
@@ -256,11 +254,11 @@
 
     private RoomHistoryVisibilityEventContent? historyVisibility => creationEvent?["m.room.history_visibility"].TypedContent as RoomHistoryVisibilityEventContent;
     private RoomGuestAccessEventContent? guestAccessEvent => creationEvent?["m.room.guest_access"].TypedContent as RoomGuestAccessEventContent;
-    private RoomServerACLEventContent? serverAcl => creationEvent?["m.room.server_acls"].TypedContent as RoomServerACLEventContent;
+    private RoomServerAclEventContent? serverAcl => creationEvent?["m.room.server_acls"].TypedContent as RoomServerAclEventContent;
     private RoomAvatarEventContent? roomAvatarEvent => creationEvent?["m.room.avatar"].TypedContent as RoomAvatarEventContent;
 
     protected override async Task OnInitializedAsync() {
-        Homeserver = await RMUStorage.GetCurrentSessionOrNavigate();
+        Homeserver = await RmuStorage.GetCurrentSessionOrNavigate();
         if (Homeserver is null) return;
 
         foreach (var x in Assembly.GetExecutingAssembly().GetTypes().Where(x => x.IsClass && !x.IsAbstract && x.GetInterfaces().Contains(typeof(IRoomCreationTemplate))).ToList()) {
diff --git a/MatrixUtils.Web/Pages/Rooms/Index.razor b/MatrixUtils.Web/Pages/Rooms/Index.razor
index 28c4de2..5dd8189 100644
--- a/MatrixUtils.Web/Pages/Rooms/Index.razor
+++ b/MatrixUtils.Web/Pages/Rooms/Index.razor
@@ -68,7 +68,7 @@
     // SyncHelper profileSyncHelper;
 
     protected override async Task OnInitializedAsync() {
-        Homeserver = await RMUStorage.GetCurrentSessionOrNavigate();
+        Homeserver = await RmuStorage.GetCurrentSessionOrNavigate();
         if (Homeserver is null) return;
         // var rooms = await Homeserver.GetJoinedRooms();
         // SemaphoreSlim _semaphore = new(160, 160);
@@ -170,7 +170,7 @@
         }
     }
 
-    private bool RenderContents { get; set; } = false;
+    private bool RenderContents { get; set; }
 
     private string _status;
 
diff --git a/MatrixUtils.Web/Pages/Rooms/PolicyList.razor b/MatrixUtils.Web/Pages/Rooms/PolicyList.razor
index b7ebae2..94113dd 100644
--- a/MatrixUtils.Web/Pages/Rooms/PolicyList.razor
+++ b/MatrixUtils.Web/Pages/Rooms/PolicyList.razor
@@ -1,7 +1,6 @@
 @page "/Rooms/{RoomId}/Policies"
 @using LibMatrix
 @using ArcaneLibs.Extensions
-@using LibMatrix.EventTypes.Spec.State
 @using LibMatrix.EventTypes.Spec.State.Policy
 @using System.Diagnostics
 @using LibMatrix.RoomTypes
@@ -9,13 +8,25 @@
 @using System.Reflection
 @using ArcaneLibs.Attributes
 @using LibMatrix.EventTypes
+@using LibMatrix.EventTypes.Common
+@using LibMatrix.EventTypes.Interop.Draupnir
+@using LibMatrix.EventTypes.Spec.State.RoomInfo
 
 @using MatrixUtils.Web.Shared.PolicyEditorComponents
+@using SpawnDev.BlazorJS.WebWorkers
+@inject WebWorkerService WebWorkerService
 
-<h3>Policy list editor - Editing @RoomId</h3>
+<h3>Policy list editor - Editing @(RoomName ?? RoomId)</h3>
+@if (!string.IsNullOrWhiteSpace(DraupnirShortcode)) {
+    <span style="margin-right: 2em;">Shortcode: @DraupnirShortcode</span>
+}
+@if (!string.IsNullOrWhiteSpace(RoomAlias)) {
+    <span>Alias: @RoomAlias</span>
+}
 <hr/>
 @* <InputCheckbox @bind-Value="EnableAvatars"></InputCheckbox><label>Enable avatars (WILL EXPOSE YOUR IP TO TARGET HOMESERVERS!)</label> *@
 <LinkButton OnClick="@(() => { CurrentlyEditingEvent = new() { Type = "", RawContent = new() }; return Task.CompletedTask; })">Create new policy</LinkButton>
+<LinkButton OnClick="@(() => { MassCreatePolicies = true; return Task.CompletedTask; })">Create many new policies</LinkButton>
 
 @if (Loading) {
     <p>Loading...</p>
@@ -24,6 +35,8 @@ else if (PolicyEventsByType is not { Count: > 0 }) {
     <p>No policies yet</p>
 }
 else {
+    var renderSw = Stopwatch.StartNew();
+    var renderTotalSw = Stopwatch.StartNew();
     @foreach (var (type, value) in PolicyEventsByType) {
         <p>
             @(GetValidPolicyEventsByType(type).Count) active,
@@ -33,6 +46,8 @@ else {
         </p>
     }
 
+    Console.WriteLine($"Rendered hearder in {renderSw.GetElapsedAndRestart()}");
+
     @foreach (var type in KnownPolicyTypes.OrderByDescending(t => GetPolicyEventsByType(t).Count)) {
         <details>
             <summary>
@@ -41,7 +56,7 @@ else {
                 </span>
                 <hr style="margin: revert;"/>
             </summary>
-            <table class="table table-striped table-hover" style="width: fit-content; border-width: 1px; vertical-align: middle;">
+            <table class="table table-striped table-hover">
                 @{
                     var policies = GetValidPolicyEventsByType(type);
                     var invalidPolicies = GetInvalidPolicyEventsByType(type);
@@ -51,13 +66,18 @@ else {
                         .Where(x => x.GetCustomAttribute<TableHideAttribute>() is null)
                         .ToFrozenSet();
                     var propNames = props.Select(x => x.GetFriendlyNameOrNull() ?? x.GetJsonPropertyName()!).ToFrozenSet();
+
+                    var proxySafeProps = type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
+                        .Where(x => props.Any(y => y.Name == x.Name))
+                        .ToFrozenSet();
+                    Console.WriteLine($"{proxySafeProps?.Count} proxy safe props found in {policies.FirstOrDefault()?.TypedContent?.GetType()}");
                 }
                 <thead>
                     <tr>
                         @foreach (var name in propNames) {
-                            <th style="border-width: 1px">@name</th>
+                            <th>@name</th>
                         }
-                        <th style="border-width: 1px">Actions</th>
+                        <th>Actions</th>
                     </tr>
                 </thead>
                 <tbody style="border-width: 1px;">
@@ -65,10 +85,6 @@ else {
                         <tr>
                             @{
                                 var typedContent = policy.TypedContent!;
-                                var proxySafeProps = typedContent.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance)
-                                    .Where(x => props.Any(y => y.Name == x.Name))
-                                    .ToFrozenSet();
-                                Console.WriteLine($"{proxySafeProps?.Count} proxy safe props found in {policies.FirstOrDefault()?.TypedContent?.GetType()}");
                             }
                             @foreach (var prop in proxySafeProps ?? Enumerable.Empty<PropertyInfo>()) {
                                 <td>@prop.GetGetMethod()?.Invoke(typedContent, null)</td>
@@ -81,6 +97,16 @@ else {
                                         @if (policy.IsLegacyType) {
                                             <LinkButton OnClick="@(() => RemovePolicyAsync(policy))">Update policy type</LinkButton>
                                         }
+
+                                        @if (PolicyTypeIds[typeof(ServerPolicyRuleEventContent)].Contains(policy.Type)) {
+                                            <LinkButton OnClick="@(() => { ServerPolicyToMakePermanent = policy; return Task.CompletedTask; })">Make permanent</LinkButton>
+                                            @if (CurrentUserIsDraupnir) {
+                                                <LinkButton Color="@(ActiveKicks.ContainsKey(policy) ? "#FF0000" : null)" OnClick="@(() => DraupnirKickMatching(policy))">Kick users @(ActiveKicks.ContainsKey(policy) ? $"({ActiveKicks[policy]})" : null)</LinkButton>
+                                            }
+                                        }
+                                    }
+                                    else {
+                                        <p>No permission to modify</p>
                                     }
                                 </div>
                             </td>
@@ -94,11 +120,11 @@ else {
                         @("Invalid " + GetPolicyTypeName(type).ToLower())
                     </u>
                 </summary>
-                <table class="table table-striped table-hover" style="width: fit-content; border-width: 1px; vertical-align: middle;">
+                <table class="table table-striped table-hover">
                     <thead>
                         <tr>
-                            <th style="border-width: 1px">State key</th>
-                            <th style="border-width: 1px">Json contents</th>
+                            <th>State key</th>
+                            <th>Json contents</th>
                         </tr>
                     </thead>
                     <tbody>
@@ -115,12 +141,25 @@ else {
             </details>
         </details>
     }
+
+    Console.WriteLine($"Rendered policies in {renderSw.GetElapsedAndRestart()}");
+    Console.WriteLine($"Rendered in {renderTotalSw.Elapsed}");
 }
 
 @if (CurrentlyEditingEvent is not null) {
     <PolicyEditorModal PolicyEvent="@CurrentlyEditingEvent" OnClose="@(() => CurrentlyEditingEvent = null)" OnSave="@(e => UpdatePolicyAsync(e))"></PolicyEditorModal>
 }
 
+@if (ServerPolicyToMakePermanent is not null) {
+    <ModalWindow Title="Make policy permanent">
+
+    </ModalWindow>
+}
+
+@if (MassCreatePolicies) {
+    <MassPolicyEditorModal Room="@Room" OnClose="@(() => MassCreatePolicies = false)" OnSaved="@(() => { MassCreatePolicies = false; LoadStatesAsync(); })"></MassPolicyEditorModal>
+}
+
 @code {
 
 #if DEBUG
@@ -130,21 +169,15 @@ else {
 #endif
 
     private bool Loading { get; set; } = true;
-    //get room list
-    // - sync withroom list filter
-    // Type = support.feline.msc3784
-    //support.feline.policy.lists.msc.v1
 
     [Parameter]
     public string RoomId { get; set; } = null!;
 
     private bool _enableAvatars;
     private StateEventResponse? _currentlyEditingEvent;
+    private bool _massCreatePolicies;
+    private StateEventResponse? _serverPolicyToMakePermanent;
 
-    // static readonly Dictionary<string, string?> Avatars = new();
-    // static readonly Dictionary<string, RemoteHomeserver> Servers = new();
-
-    // private static List<StateEventResponse> PolicyEvents { get; set; } = new();
     private Dictionary<Type, List<StateEventResponse>> PolicyEventsByType { get; set; } = new();
 
     private StateEventResponse? CurrentlyEditingEvent {
@@ -155,25 +188,44 @@ else {
         }
     }
 
-    // public bool EnableAvatars {
-    //     get => _enableAvatars;
-    //     set {
-    //         _enableAvatars = value;
-    //         if (value) GetAllAvatars();
-    //     }
-    // }
+    public StateEventResponse? ServerPolicyToMakePermanent {
+        get => _serverPolicyToMakePermanent;
+        set {
+            _serverPolicyToMakePermanent = value;
+            StateHasChanged();
+        }
+    }
 
     private AuthenticatedHomeserverGeneric Homeserver { get; set; }
     private GenericRoom Room { get; set; }
     private RoomPowerLevelEventContent PowerLevels { get; set; }
+    public bool CurrentUserIsDraupnir { get; set; }
+    public string? RoomName { get; set; }
+    public string? RoomAlias { get; set; }
+    public string? DraupnirShortcode { get; set; }
+    public Dictionary<StateEventResponse, int> ActiveKicks { get; set; } = [];
+
+    public bool MassCreatePolicies {
+        get => _massCreatePolicies;
+        set {
+            _massCreatePolicies = value;
+            StateHasChanged();
+        }
+    }
 
     protected override async Task OnInitializedAsync() {
         var sw = Stopwatch.StartNew();
         await base.OnInitializedAsync();
-        Homeserver = (await RMUStorage.GetCurrentSessionOrNavigate())!;
+        Homeserver = (await RmuStorage.GetCurrentSessionOrNavigate())!;
         if (Homeserver is null) return;
         Room = Homeserver.GetRoom(RoomId!);
-        PowerLevels = (await Room.GetPowerLevelsAsync())!;
+        await Task.WhenAll([
+            Task.Run(async () => { PowerLevels = (await Room.GetPowerLevelsAsync())!; }),
+            Task.Run(async () => { DraupnirShortcode = (await Room.GetStateOrNullAsync<MjolnirShortcodeEventContent>(MjolnirShortcodeEventContent.EventId))?.Shortcode; }),
+            Task.Run(async () => { RoomAlias = (await Room.GetCanonicalAliasAsync())?.Alias; }),
+            Task.Run(async () => { RoomName = await Room.GetNameOrFallbackAsync(); }),
+            Task.Run(async () => { CurrentUserIsDraupnir = (await Homeserver.GetAccountDataOrNullAsync<object>("org.matrix.mjolnir.protected_rooms")) is not null; }),
+        ]);
         await LoadStatesAsync();
         Console.WriteLine($"Policy list editor initialized in {sw.Elapsed}!");
     }
@@ -193,48 +245,13 @@ else {
         StateHasChanged();
     }
 
-    // private async Task GetAllAvatars() {
-    //     // if (!_enableAvatars) return;
-    //     Console.WriteLine("Getting avatars...");
-    //     var users = GetValidPolicyEventsByType(typeof(UserPolicyRuleEventContent)).Select(x => x.RawContent!["entity"]!.GetValue<string>()).Where(x => x.Contains(':') && !x.Contains("*")).ToList();
-    //     Console.WriteLine($"Got {users.Count} users!");
-    //     var usersByHomeServer = users.GroupBy(x => x!.Split(':')[1]).ToDictionary(x => x.Key!, x => x.ToList());
-    //     Console.WriteLine($"Got {usersByHomeServer.Count} homeservers!");
-    //     var homeserverTasks = usersByHomeServer.Keys.Select(x => RemoteHomeserver.TryCreate(x)).ToAsyncEnumerable();
-    //     await foreach (var server in homeserverTasks) {
-    //         if (server is null) continue;
-    //         var profileTasks = usersByHomeServer[server.BaseUrl].Select(x => TryGetProfile(server, x)).ToList();
-    //         await Task.WhenAll(profileTasks);
-    //         profileTasks.RemoveAll(x => x.Result is not { Value: { AvatarUrl: not null } });
-    //         foreach (var profile in profileTasks.Select(x => x.Result!.Value)) {
-    //             // if (profile is null) continue;
-    //             if (!string.IsNullOrWhiteSpace(profile.Value.AvatarUrl)) {
-    //                 var url = await hsResolver.ResolveMediaUri(server.BaseUrl, profile.Value.AvatarUrl);
-    //                 Avatars.TryAdd(profile.Key, url);
-    //             }
-    //             else Avatars.TryAdd(profile.Key, null);
-    //         }
-    //
-    //         StateHasChanged();
-    //     }
-    // }
-    //
-    // private async Task<KeyValuePair<string, UserProfileResponse>?> TryGetProfile(RemoteHomeserver server, string mxid) {
-    //     try {
-    //         return new KeyValuePair<string, UserProfileResponse>(mxid, await server.GetProfileAsync(mxid));
-    //     }
-    //     catch {
-    //         return null;
-    //     }
-    // }
-
     private List<StateEventResponse> GetPolicyEventsByType(Type type) => PolicyEventsByType.ContainsKey(type) ? PolicyEventsByType[type] : [];
 
     private List<StateEventResponse> GetValidPolicyEventsByType(Type type) => GetPolicyEventsByType(type)
-        .Where(x => !string.IsNullOrWhiteSpace(x.RawContent?["entity"]?.GetValue<string>())).ToList();
+        .Where(x => !string.IsNullOrWhiteSpace(x.RawContent?["recommendation"]?.GetValue<string>())).ToList();
 
     private List<StateEventResponse> GetInvalidPolicyEventsByType(Type type) => GetPolicyEventsByType(type)
-        .Where(x => string.IsNullOrWhiteSpace(x.RawContent?["entity"]?.GetValue<string>())).ToList();
+        .Where(x => string.IsNullOrWhiteSpace(x.RawContent?["recommendation"]?.GetValue<string>())).ToList();
 
     private string? GetPolicyTypeNameOrNull(Type type) => type.GetFriendlyNamePluralOrNull()
                                                           ?? type.GetCustomAttributes<MatrixEventAttribute>()
@@ -265,4 +282,139 @@ else {
     private static Dictionary<string, Type> PolicyTypes = KnownPolicyTypes
         .ToDictionary(x => x.GetCustomAttributes<MatrixEventAttribute>().First(y => !string.IsNullOrWhiteSpace(y.EventName)).EventName, x => x);
 
+    private static Dictionary<Type, string[]> PolicyTypeIds = KnownPolicyTypes
+        .ToDictionary(x => x, x => x.GetCustomAttributes<MatrixEventAttribute>().Select(y => y.EventName).ToArray());
+
+#region Draupnir interop
+
+    private SemaphoreSlim ss = new(16, 16);
+
+    private async Task DraupnirKickMatching(StateEventResponse policy) {
+        try {
+            var content = policy.TypedContent! as PolicyRuleEventContent;
+            if (content is null) return;
+            if (string.IsNullOrWhiteSpace(content.Entity)) return;
+
+            var data = await Homeserver.GetAccountDataAsync<DraupnirProtectedRoomsData>(DraupnirProtectedRoomsData.EventId);
+            var rooms = data.Rooms.Select(Homeserver.GetRoom).ToList();
+
+            ActiveKicks.Add(policy, rooms.Count);
+            StateHasChanged();
+            await Task.Delay(500);
+
+            // for (int i = 0; i < 12; i++) {
+            // _ = WebWorkerService.TaskPool.Invoke(WasteCpu);
+            // }
+
+            // static async Task runKicks(string roomId, PolicyRuleEventContent content) {
+            //     Console.WriteLine($"Checking {roomId}...");
+            //     // Console.WriteLine($"Checking {room.RoomId}...");
+            //     //
+            //     // try {
+            //     //     var members = await room.GetMembersListAsync();
+            //     //     foreach (var member in members) {
+            //     //         var membership = member.ContentAs<RoomMemberEventContent>();
+            //     //         if (member.StateKey == room.Homeserver.WhoAmI.UserId) continue;
+            //     //         if (membership?.Membership is "leave" or "ban") continue;
+            //     //
+            //     //         if (content.EntityMatches(member.StateKey!))
+            //     //             // await room.KickAsync(member.StateKey, content.Reason ?? "No reason given");
+            //     //             Console.WriteLine($"Would kick {member.StateKey} from {room.RoomId} (EntityMatches)");
+            //     //     }
+            //     // }
+            //     // finally {
+            //     //     Console.WriteLine($"Finished checking {room.RoomId}...");
+            //     // }
+            // }
+            //
+            // try {
+            //     var tasks = rooms.Select(room => WebWorkerService.TaskPool.Invoke(runKicks, room.RoomId, content)).ToList();
+            //
+            //     await Task.WhenAll(tasks);
+            // }
+            // catch (Exception e) {
+            //     Console.WriteLine(e);
+            // }
+
+            await NastyInternalsPleaseIgnore.ExecuteKickWithWasmWorkers(WebWorkerService, Homeserver, policy, data.Rooms);
+            // await Task.Run(async () => {
+            //     foreach (var room in rooms) {
+            //         try {
+            //             Console.WriteLine($"Checking {room.RoomId}...");
+            //             var members = await room.GetMembersListAsync();
+            //             foreach (var member in members) {
+            //                 var membership = member.ContentAs<RoomMemberEventContent>();
+            //                 if (member.StateKey == room.Homeserver.WhoAmI.UserId) continue;
+            //                 if (membership?.Membership is "leave" or "ban") continue;
+            //
+            //                 if (content.EntityMatches(member.StateKey!))
+            //                     // await room.KickAsync(member.StateKey, content.Reason ?? "No reason given");
+            //                     Console.WriteLine($"Would kick {member.StateKey} from {room.RoomId} (EntityMatches)");
+            //             }
+            //             ActiveKicks[policy]--;
+            //             StateHasChanged();
+            //         }
+            //         finally {
+            //             Console.WriteLine($"Finished checking {room.RoomId}...");
+            //         }
+            //     }
+            // });
+        }
+        finally {
+            ActiveKicks.Remove(policy);
+            StateHasChanged();
+            await Task.Delay(500);
+        }
+    }
+
+#region Nasty, nasty internals, please ignore!
+
+    private static class NastyInternalsPleaseIgnore {
+        public static async Task ExecuteKickWithWasmWorkers(WebWorkerService workerService, AuthenticatedHomeserverGeneric hs, StateEventResponse evt, List<string> roomIds) {
+            try {
+                // var tasks = roomIds.Select(roomId => workerService.TaskPool.Invoke(ExecuteKickInternal, hs.WellKnownUris.Client, hs.AccessToken, roomId, content.Entity)).ToList();
+                var tasks = roomIds.Select(roomId => workerService.TaskPool.Invoke(ExecuteKickInternal2, hs.WellKnownUris, hs.AccessToken, roomId, evt)).ToList();
+                // workerService.TaskPool.Invoke(ExecuteKickInternal, hs.BaseUrl, hs.AccessToken, roomIds, content.Entity);
+                await Task.WhenAll(tasks);
+            }
+            catch (Exception e) {
+                Console.WriteLine(e);
+            }
+        }
+
+        private static async Task ExecuteKickInternal(string homeserverBaseUrl, string accessToken, string roomId, string entity) {
+            try {
+                Console.WriteLine("args: " + string.Join(", ", homeserverBaseUrl, accessToken, roomId, entity));
+                Console.WriteLine($"Checking {roomId}...");
+                var hs = new AuthenticatedHomeserverGeneric(homeserverBaseUrl, new() { Client = homeserverBaseUrl }, null, accessToken);
+                Console.WriteLine($"Got HS...");
+                var room = hs.GetRoom(roomId);
+                Console.WriteLine($"Got room...");
+                var members = await room.GetMembersListAsync();
+                Console.WriteLine($"Got members...");
+                // foreach (var member in members) {
+                //     var membership = member.ContentAs<RoomMemberEventContent>();
+                //     if (member.StateKey == hs.WhoAmI.UserId) continue;
+                //     if (membership?.Membership is "leave" or "ban") continue;
+                //
+                //     if (entity == member.StateKey)
+                //         // await room.KickAsync(member.StateKey, content.Reason ?? "No reason given");
+                //         Console.WriteLine($"Would kick {member.StateKey} from {room.RoomId} (EntityMatches)");
+                // }
+            }
+            catch (Exception e) {
+                Console.WriteLine(e);
+            }
+        }
+
+        private async static Task ExecuteKickInternal2(HomeserverResolverService.WellKnownUris wellKnownUris, string accessToken, string roomId, StateEventResponse policy) {
+            Console.WriteLine($"Checking {roomId}...");
+            Console.WriteLine(policy.EventId);
+        }
+    }
+
+#endregion
+
+#endregion
+
 }
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Rooms/PolicyList.razor.css b/MatrixUtils.Web/Pages/Rooms/PolicyList.razor.css
new file mode 100644
index 0000000..afe9fb0
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Rooms/PolicyList.razor.css
@@ -0,0 +1,9 @@
+th {
+    border-width: 1px;
+}
+
+table {
+    width: fit-content;
+    border-width: 1px;
+    vertical-align: middle;
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Rooms/PolicyList2.razor b/MatrixUtils.Web/Pages/Rooms/PolicyList2.razor
new file mode 100644
index 0000000..50f304a
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Rooms/PolicyList2.razor
@@ -0,0 +1,240 @@
+@page "/Rooms/{RoomId}/Policies2"
+@using LibMatrix
+@using ArcaneLibs.Extensions
+@using LibMatrix.EventTypes.Spec.State.Policy
+@using System.Diagnostics
+@using LibMatrix.RoomTypes
+@using System.Collections.Frozen
+@using System.Reflection
+@using ArcaneLibs.Attributes
+@using LibMatrix.EventTypes
+@using LibMatrix.EventTypes.Spec.State.RoomInfo
+
+@using MatrixUtils.Web.Shared.PolicyEditorComponents
+
+<h3>Policy list editor - Editing @RoomId</h3>
+<hr/>
+@* <InputCheckbox @bind-Value="EnableAvatars"></InputCheckbox><label>Enable avatars (WILL EXPOSE YOUR IP TO TARGET HOMESERVERS!)</label> *@
+<LinkButton OnClick="@(() => { CurrentlyEditingEvent = new() { Type = "", RawContent = new() }; return Task.CompletedTask; })">Create new policy</LinkButton>
+
+@if (Loading) {
+    <p>Loading...</p>
+}
+else if (PolicyEventsByType is not { Count: > 0 }) {
+    <p>No policies yet</p>
+}
+else {
+    var renderSw = Stopwatch.StartNew();
+    var renderTotalSw = Stopwatch.StartNew();
+    @foreach (var (type, value) in PolicyEventsByType) {
+        <p>
+            @(GetValidPolicyEventsByType(type).Count) active,
+            @(GetInvalidPolicyEventsByType(type).Count) invalid
+            (@value.Count total)
+            @(GetPolicyTypeName(type).ToLower())
+        </p>
+    }
+
+    Console.WriteLine($"Rendered hearder in {renderSw.GetElapsedAndRestart()}");
+
+    @foreach (var type in KnownPolicyTypes.OrderByDescending(t => GetPolicyEventsByType(t).Count)) {
+        <details>
+            <summary>
+                <span>
+                    @($"{GetPolicyTypeName(type)}: {GetPolicyEventsByType(type).Count} policies")
+                </span>
+                <hr style="margin: revert;"/>
+            </summary>
+            <div class="flex-grid">
+                @{
+                    var policies = GetValidPolicyEventsByType(type);
+                    var invalidPolicies = GetInvalidPolicyEventsByType(type);
+                    // enumerate all properties with friendly name
+                    var props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
+                        .Where(x => (x.GetFriendlyNameOrNull() ?? x.GetJsonPropertyNameOrNull()) is not null)
+                        .Where(x => x.GetCustomAttribute<TableHideAttribute>() is null)
+                        .ToFrozenSet();
+                    var propNames = props.Select(x => x.GetFriendlyNameOrNull() ?? x.GetJsonPropertyName()!).ToFrozenSet();
+
+                    var proxySafeProps = type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
+                        .Where(x => props.Any(y => y.Name == x.Name))
+                        .ToFrozenSet();
+                    Console.WriteLine($"{proxySafeProps?.Count} proxy safe props found in {policies.FirstOrDefault()?.TypedContent?.GetType()}");
+                }
+                @foreach (var policy in policies.OrderBy(x => x.RawContent?["entity"]?.GetValue<string>())) {
+                    <div class="flex-item">
+                        @{
+                            var typedContent = policy.TypedContent!;
+                        }
+                        @foreach (var prop in proxySafeProps ?? Enumerable.Empty<PropertyInfo>()) {
+                            <td>@prop.GetGetMethod()?.Invoke(typedContent, null)</td>
+                        }
+                        <div style="display: ruby;">
+                            @if (PowerLevels.UserHasStatePermission(Homeserver.WhoAmI.UserId, policy.Type)) {
+                                <LinkButton OnClick="@(() => { CurrentlyEditingEvent = policy; return Task.CompletedTask; })">Edit</LinkButton>
+                                <LinkButton OnClick="@(() => RemovePolicyAsync(policy))">Remove</LinkButton>
+                                @if (policy.IsLegacyType) {
+                                    <LinkButton OnClick="@(() => RemovePolicyAsync(policy))">Update policy type</LinkButton>
+                                }
+
+                                @if (PolicyTypeIds[typeof(ServerPolicyRuleEventContent)].Contains(policy.EventId)) {
+                                    <LinkButton OnClick="@(() => { ServerPolicyToMakePermanent = policy; return Task.CompletedTask; })">Make permanent (wildcard)</LinkButton>
+                                    @if (CurrentUserIsDraupnir) {
+                                        <LinkButton OnClick="@(() => UpgradePolicyAsync(policy))">Kick matching users</LinkButton>
+                                    }
+                                }
+                                else {
+                                    <p>meow</p>
+                                }
+                            }
+                            else {
+                                <p>No permission to modify</p>
+                            }
+                        </div>
+                    </div>
+                }
+            </div>
+            <details>
+                <summary>
+                    <u>
+                        @("Invalid " + GetPolicyTypeName(type).ToLower())
+                    </u>
+                </summary>
+                <table class="table table-striped table-hover">
+                    <thead>
+                        <tr>
+                            <th>State key</th>
+                            <th>Json contents</th>
+                        </tr>
+                    </thead>
+                    <tbody>
+                        @foreach (var policy in invalidPolicies) {
+                            <tr>
+                                <td>@policy.StateKey</td>
+                                <td>
+                                    <pre>@policy.RawContent.ToJson(true, false)</pre>
+                                </td>
+                            </tr>
+                        }
+                    </tbody>
+                </table>
+            </details>
+        </details>
+    }
+
+    Console.WriteLine($"Rendered policies in {renderSw.GetElapsedAndRestart()}");
+    Console.WriteLine($"Rendered in {renderTotalSw.Elapsed}");
+}
+
+@if (CurrentlyEditingEvent is not null) {
+    <PolicyEditorModal PolicyEvent="@CurrentlyEditingEvent" OnClose="@(() => CurrentlyEditingEvent = null)" OnSave="@(e => UpdatePolicyAsync(e))"></PolicyEditorModal>
+}
+
+@code {
+
+#if DEBUG
+    private const bool Debug = true;
+#else
+    private const bool Debug = false;
+#endif
+
+    private bool Loading { get; set; } = true;
+
+    [Parameter]
+    public string RoomId { get; set; } = null!;
+
+    private bool _enableAvatars;
+    private StateEventResponse? _currentlyEditingEvent;
+    private StateEventResponse? _serverPolicyToMakePermanent;
+
+    private Dictionary<Type, List<StateEventResponse>> PolicyEventsByType { get; set; } = new();
+
+    private StateEventResponse? CurrentlyEditingEvent {
+        get => _currentlyEditingEvent;
+        set {
+            _currentlyEditingEvent = value;
+            StateHasChanged();
+        }
+    }
+
+    private StateEventResponse? ServerPolicyToMakePermanent {
+        get => _serverPolicyToMakePermanent;
+        set {
+            _serverPolicyToMakePermanent = value;
+            StateHasChanged();
+        }
+    }
+
+    private AuthenticatedHomeserverGeneric Homeserver { get; set; }
+    private GenericRoom Room { get; set; }
+    private RoomPowerLevelEventContent PowerLevels { get; set; }
+    private bool CurrentUserIsDraupnir { get; set; }
+
+    protected override async Task OnInitializedAsync() {
+        var sw = Stopwatch.StartNew();
+        await base.OnInitializedAsync();
+        Homeserver = (await RmuStorage.GetCurrentSessionOrNavigate())!;
+        if (Homeserver is null) return;
+        Room = Homeserver.GetRoom(RoomId!);
+        PowerLevels = (await Room.GetPowerLevelsAsync())!;
+        CurrentUserIsDraupnir = (await Homeserver.GetAccountDataOrNullAsync<object>("org.matrix.mjolnir.protected_rooms")) is not null;
+        await LoadStatesAsync();
+        Console.WriteLine($"Policy list editor initialized in {sw.Elapsed}!");
+    }
+
+    private async Task LoadStatesAsync() {
+        Loading = true;
+        var states = Room.GetFullStateAsync();
+        PolicyEventsByType.Clear();
+        await foreach (var state in states) {
+            if (state is null) continue;
+            if (!state.MappedType.IsAssignableTo(typeof(PolicyRuleEventContent))) continue;
+            if (!PolicyEventsByType.ContainsKey(state.MappedType)) PolicyEventsByType.Add(state.MappedType, new());
+            PolicyEventsByType[state.MappedType].Add(state);
+        }
+
+        Loading = false;
+        StateHasChanged();
+    }
+
+    private List<StateEventResponse> GetPolicyEventsByType(Type type) => PolicyEventsByType.ContainsKey(type) ? PolicyEventsByType[type] : [];
+
+    private List<StateEventResponse> GetValidPolicyEventsByType(Type type) => GetPolicyEventsByType(type)
+        .Where(x => !string.IsNullOrWhiteSpace(x.RawContent?["recommendation"]?.GetValue<string>())).ToList();
+
+    private List<StateEventResponse> GetInvalidPolicyEventsByType(Type type) => GetPolicyEventsByType(type)
+        .Where(x => string.IsNullOrWhiteSpace(x.RawContent?["recommendation"]?.GetValue<string>())).ToList();
+
+    private string? GetPolicyTypeNameOrNull(Type type) => type.GetFriendlyNamePluralOrNull()
+                                                          ?? type.GetCustomAttributes<MatrixEventAttribute>()
+                                                              .FirstOrDefault(x => !string.IsNullOrWhiteSpace(x.EventName))?.EventName;
+
+    private string GetPolicyTypeName(Type type) => GetPolicyTypeNameOrNull(type) ?? type.Name;
+
+    private async Task RemovePolicyAsync(StateEventResponse policyEvent) {
+        await Room.SendStateEventAsync(policyEvent.Type, policyEvent.StateKey.UrlEncode(), new { });
+        PolicyEventsByType[policyEvent.MappedType].Remove(policyEvent);
+        await LoadStatesAsync();
+    }
+
+    private async Task UpdatePolicyAsync(StateEventResponse policyEvent) {
+        await Room.SendStateEventAsync(policyEvent.Type, policyEvent.StateKey.UrlEncode(), policyEvent.RawContent);
+        CurrentlyEditingEvent = null;
+        await LoadStatesAsync();
+    }
+
+    private async Task UpgradePolicyAsync(StateEventResponse policyEvent) {
+        policyEvent.RawContent["upgraded_from_type"] = policyEvent.Type;
+        await LoadStatesAsync();
+    }
+
+    private static FrozenSet<Type> KnownPolicyTypes = StateEvent.KnownStateEventTypes.Where(x => x.IsAssignableTo(typeof(PolicyRuleEventContent))).ToFrozenSet();
+
+    // event types, unnamed
+    private static Dictionary<string, Type> PolicyTypes = KnownPolicyTypes
+        .ToDictionary(x => x.GetCustomAttributes<MatrixEventAttribute>().First(y => !string.IsNullOrWhiteSpace(y.EventName)).EventName, x => x);
+
+    private static Dictionary<Type, string[]> PolicyTypeIds = KnownPolicyTypes
+        .ToDictionary(x => x, x => x.GetCustomAttributes<MatrixEventAttribute>().Select(y => y.EventName).ToArray());
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Rooms/PolicyList2.razor.css b/MatrixUtils.Web/Pages/Rooms/PolicyList2.razor.css
new file mode 100644
index 0000000..d224737
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Rooms/PolicyList2.razor.css
@@ -0,0 +1,32 @@
+th {
+    border-width: 1px;
+}
+
+table {
+    width: fit-content;
+    border-width: 1px;
+    vertical-align: middle;
+}
+
+.flex-grid {
+    display: grid;
+    /*grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));*/
+    /*// fit based on content max width*/
+    grid-template-columns: repeat(auto-fill, minmax(min-content, 1fr));
+    
+    gap: 10px;
+}
+
+.flex-item {
+    /*flex: 1 1 30%;*/
+    /*margin: 0.25rem;*/
+    /*position: relative;*/
+    /*display: flex;*/
+    /*flex-direction: column;*/
+    min-width: 0;
+    word-wrap: break-word;
+    background-color: #fff1;
+    background-clip: border-box;
+    border: 1px solid rgba(0, 0, 0, .125);
+    border-radius: .5rem
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Rooms/PolicyLists.razor b/MatrixUtils.Web/Pages/Rooms/PolicyLists.razor
new file mode 100644
index 0000000..4f06822
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Rooms/PolicyLists.razor
@@ -0,0 +1,176 @@
+@page "/PolicyLists"
+@using ArcaneLibs.Extensions
+@using LibMatrix
+@using LibMatrix.EventTypes
+@using LibMatrix.EventTypes.Common
+@using LibMatrix.EventTypes.Spec.State.Policy
+@using LibMatrix.RoomTypes
+@inject ILogger<Index> logger
+<h3>Policy lists </h3> @* <LinkButton href="/Rooms/Create">Create new policy list</LinkButton> *@
+
+@if (!string.IsNullOrWhiteSpace(Status)) {
+    <p>@Status</p>
+}
+@if (!string.IsNullOrWhiteSpace(Status2)) {
+    <p>@Status2</p>
+}
+<hr/>
+
+<table>
+    <thead>
+        <tr>
+            <th/>
+            <th>Room name</th>
+            <th>Policies</th>
+        </tr>
+    </thead>
+    <tbody>
+        @foreach (var room in Rooms.OrderByDescending(x => x.PolicyCounts.Sum(y => y.Value))) {
+            <tr>
+                <td>
+                    <LinkButton href="@($"/Rooms/{room.Room.RoomId}/Policies")">
+                        <span class="oi oi-pencil" aria-hidden="true"></span>
+                    </LinkButton>
+                </td>
+                <td style="padding-right: 24px;">
+                    <span>@room.RoomName</span>
+                    @if (room.IsLegacy) {
+                        <span style="color: red;"> (legacy)</span>
+                    }
+                    <br/>
+                    @if (!string.IsNullOrWhiteSpace(room.Shortcode)) {
+                        <span style="font-size: 0.8em;">@room.Shortcode</span>
+                    }
+                    else {
+                        <span style="color: red;">(no shortcode)</span>
+                    }
+                </td>
+                <td>
+                    <span>@(room.PolicyCounts.GetValueOrDefault(RoomInfo.PolicyType.User) ?? 0) user policies</span><br/>
+                    <span>@(room.PolicyCounts.GetValueOrDefault(RoomInfo.PolicyType.Server) ?? 0) server policies</span><br/>
+                    <span>@(room.PolicyCounts.GetValueOrDefault(RoomInfo.PolicyType.Room) ?? 0) room policies</span><br/>
+                </td>
+            </tr>
+        }
+    </tbody>
+</table>
+
+@code {
+
+    private List<RoomInfo> Rooms { get; } = [];
+
+    private AuthenticatedHomeserverGeneric? Homeserver { get; set; }
+
+    protected override async Task OnInitializedAsync() {
+        Homeserver = await RmuStorage.GetCurrentSessionOrNavigate();
+        if (Homeserver is null) return;
+
+        Status = "Fetching rooms...";
+
+        var userEventTypes = EventContent.GetMatchingEventTypes<UserPolicyRuleEventContent>();
+        var serverEventTypes = EventContent.GetMatchingEventTypes<ServerPolicyRuleEventContent>();
+        var roomEventTypes = EventContent.GetMatchingEventTypes<RoomPolicyRuleEventContent>();
+        var knownPolicyTypes = (List<string>) [..userEventTypes, ..serverEventTypes, ..roomEventTypes];
+
+        List<GenericRoom> roomsByType = [];
+        await foreach (var room in Homeserver.GetJoinedRoomsByType("support.feline.policy.lists.msc.v1")) {
+            roomsByType.Add(room);
+            Status2 = $"Found {room.RoomId} (MSC3784)...";
+        }
+
+        List<Task<RoomInfo>> tasks = roomsByType.Select(async room => {
+            Status2 = $"Fetching room {room.RoomId}...";
+            return await RoomInfo.FromRoom(room);
+        }).ToList();
+
+        var results = tasks.ToAsyncEnumerable();
+        await foreach (var result in results) {
+            Rooms.Add(result);
+            StateHasChanged();
+        }
+
+        Status = "Searching for legacy lists...";
+
+        var rooms = (await Homeserver.GetJoinedRooms())
+            .Where(x => !Rooms.Any(y => y.Room.RoomId == x.RoomId))
+            .Select(async room => {
+                var state = await room.GetFullStateAsListAsync();
+                var policies = state
+                    .Where(x => knownPolicyTypes.Contains(x.Type))
+                    .ToList();
+                if (policies.Count == 0) return null;
+                Status2 = $"Found legacy list {room.RoomId}...";
+                return await RoomInfo.FromRoom(room, state, true);
+            })
+            .ToAsyncEnumerable();
+
+        await foreach (var room in rooms) {
+            if (room is not null) {
+                Rooms.Add(room);
+                StateHasChanged();
+            }
+        }
+
+        Status = "";
+        Status2 = "";
+        await base.OnInitializedAsync();
+    }
+
+    private string _status;
+
+    public string Status {
+        get => _status;
+        set {
+            _status = value;
+            StateHasChanged();
+        }
+    }
+
+    private string _status2;
+
+    public string Status2 {
+        get => _status2;
+        set {
+            _status2 = value;
+            StateHasChanged();
+        }
+    }
+
+    private class RoomInfo {
+        public GenericRoom Room { get; set; }
+        public string RoomName { get; set; }
+        public string? Shortcode { get; set; }
+        public Dictionary<PolicyType, int?> PolicyCounts { get; set; }
+        public bool IsLegacy { get; set; }
+
+        public enum PolicyType {
+            User,
+            Room,
+            Server
+        }
+
+        private static readonly List<string> userEventTypes = EventContent.GetMatchingEventTypes<UserPolicyRuleEventContent>();
+        private static readonly List<string> serverEventTypes = EventContent.GetMatchingEventTypes<ServerPolicyRuleEventContent>();
+        private static readonly List<string> roomEventTypes = EventContent.GetMatchingEventTypes<RoomPolicyRuleEventContent>();
+        private static readonly List<string> allKnownPolicyTypes = [..userEventTypes, ..serverEventTypes, ..roomEventTypes];
+
+        public static async Task<RoomInfo> FromRoom(GenericRoom room, List<StateEventResponse>? state = null, bool legacy = false) {
+            state ??= await room.GetFullStateAsListAsync();
+            return new RoomInfo() {
+                Room = room,
+                IsLegacy = legacy,
+                RoomName = await room.GetNameAsync()
+                           ?? (await room.GetCanonicalAliasAsync())?.Alias
+                           ?? (await room.GetStateOrNullAsync<MjolnirShortcodeEventContent>(MjolnirShortcodeEventContent.EventId))?.Shortcode
+                           ?? room.RoomId,
+                Shortcode = (await room.GetStateOrNullAsync<MjolnirShortcodeEventContent>(MjolnirShortcodeEventContent.EventId))?.Shortcode,
+                PolicyCounts = new() {
+                    { PolicyType.User, state.Count(x => userEventTypes.Contains(x.Type)) },
+                    { PolicyType.Server, state.Count(x => serverEventTypes.Contains(x.Type)) },
+                    { PolicyType.Room, state.Count(x => roomEventTypes.Contains(x.Type)) }
+                }
+            };
+        }
+    }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Rooms/PolicyLists.razor.css b/MatrixUtils.Web/Pages/Rooms/PolicyLists.razor.css
new file mode 100644
index 0000000..f9b5b3f
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Rooms/PolicyLists.razor.css
@@ -0,0 +1,6 @@
+table, th, td {
+    border-width: 1px;
+}
+td {
+    padding: 8px;
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Rooms/Space.razor b/MatrixUtils.Web/Pages/Rooms/Space.razor
index 01ab1c4..e3bb4e4 100644
--- a/MatrixUtils.Web/Pages/Rooms/Space.razor
+++ b/MatrixUtils.Web/Pages/Rooms/Space.razor
@@ -2,11 +2,15 @@
 @using LibMatrix.RoomTypes
 @using ArcaneLibs.Extensions
 @using LibMatrix
+@using MatrixUtils.Abstractions
 <h3>Room manager - Viewing Space</h3>
 
+<span>Add new room to space: </span>
+<FancyTextBox @bind-Value="@NewRoomId"></FancyTextBox>
+<button onclick="@AddNewRoom">Add</button>
 <button onclick="@JoinAllRooms">Join all rooms</button>
 @foreach (var room in Rooms) {
-    <RoomListItem Room="room" ShowOwnProfile="true"></RoomListItem>
+    <RoomListItem RoomInfo="room" ShowOwnProfile="true"></RoomListItem>
 }
 
 
@@ -27,11 +31,12 @@
     private GenericRoom? Room { get; set; }
 
     private StateEventResponse[] States { get; set; } = Array.Empty<StateEventResponse>();
-    private List<GenericRoom> Rooms { get; } = new();
+    private List<RoomInfo> Rooms { get; } = new();
     private List<string> ServersInSpace { get; } = new();
+    private string? NewRoomId { get; set; }
 
     protected override async Task OnInitializedAsync() {
-        var hs = await RMUStorage.GetCurrentSessionOrNavigate();
+        var hs = await RmuStorage.GetCurrentSessionOrNavigate();
         if (hs is null) return;
 
         Room = hs.GetRoom(RoomId.Replace('~', '.'));
@@ -43,7 +48,18 @@
                     var roomId = stateEvent.StateKey;
                     var room = hs.GetRoom(roomId);
                     if (room is not null) {
-                        Rooms.Add(room);
+                        Task.Run(async () => {
+                            try {
+                                Rooms.Add(new(Room, await room.GetFullStateAsListAsync()));
+                            }
+                            catch (MatrixException e) {
+                                if (e is { ErrorCode: MatrixException.ErrorCodes.M_FORBIDDEN }) {
+                                    Rooms.Add(new(Room) {
+                                        RoomName = "M_FORBIDDEN"
+                                    });
+                                }
+                            }
+                        });
                     }
                     break;
                 }
@@ -96,8 +112,37 @@
         // List<Task<RoomIdResponse>> tasks = Rooms.Select(room => room.JoinAsync(ServersInSpace.ToArray())).ToList();
         // await Task.WhenAll(tasks);
         foreach (var room in Rooms) {
+            await JoinRecursive(room.Room.RoomId);
+        }
+    }
+
+    private async Task JoinRecursive(string roomId) {
+        var room = Room!.Homeserver.GetRoom(roomId);
+        if (room is null) return;
+        try {
             await room.JoinAsync(ServersInSpace.ToArray());
+            var joined = false;
+            while (!joined) {
+                var ce = await room.GetCreateEventAsync();
+                if(ce is null) continue;
+                if (ce.Type == "m.space") {
+                     var children = room.AsSpace.GetChildrenAsync(false);
+                     await foreach (var child in children) {
+                         JoinRecursive(child.RoomId);
+                     }
+                }
+                joined = true;
+            }
         }
+        catch (Exception e) {
+            Console.WriteLine(e);
+        }
+
+    }
+
+    private async Task AddNewRoom() {
+        if (string.IsNullOrWhiteSpace(NewRoomId)) return;
+        await Room.AsSpace.AddChildByIdAsync(NewRoomId);
     }
 
 }
diff --git a/MatrixUtils.Web/Pages/Rooms/StateEditor.razor b/MatrixUtils.Web/Pages/Rooms/StateEditor.razor
index 6110b83..4d24d47 100644
--- a/MatrixUtils.Web/Pages/Rooms/StateEditor.razor
+++ b/MatrixUtils.Web/Pages/Rooms/StateEditor.razor
@@ -43,7 +43,7 @@
 
     protected override async Task OnInitializedAsync() {
         await base.OnInitializedAsync();
-        var hs = await RMUStorage.GetCurrentSessionOrNavigate();
+        var hs = await RmuStorage.GetCurrentSessionOrNavigate();
         if (hs is null) return;
         RoomId = RoomId.Replace('~', '.');
         await LoadStatesAsync();
@@ -53,7 +53,7 @@
     private DateTime _lastUpdate = DateTime.Now;
 
     private async Task LoadStatesAsync() {
-        var hs = await RMUStorage.GetCurrentSessionOrNavigate();
+        var hs = await RmuStorage.GetCurrentSessionOrNavigate();
 
         var StateLoaded = 0;
         var response = (hs.GetRoom(RoomId)).GetFullStateAsync();
diff --git a/MatrixUtils.Web/Pages/Rooms/StateViewer.razor b/MatrixUtils.Web/Pages/Rooms/StateViewer.razor
index 7c31136..565d97f 100644
--- a/MatrixUtils.Web/Pages/Rooms/StateViewer.razor
+++ b/MatrixUtils.Web/Pages/Rooms/StateViewer.razor
@@ -70,7 +70,7 @@
 
     protected override async Task OnInitializedAsync() {
         await base.OnInitializedAsync();
-        var hs = await RMUStorage.GetCurrentSessionOrNavigate();
+        var hs = await RmuStorage.GetCurrentSessionOrNavigate();
         if (hs is null) return;
         await LoadStatesAsync();
         Console.WriteLine("Policy list editor initialized!");
@@ -80,7 +80,7 @@
 
     private async Task LoadStatesAsync() {
         var StateLoaded = 0;
-        var hs = await RMUStorage.GetCurrentSessionOrNavigate();
+        var hs = await RmuStorage.GetCurrentSessionOrNavigate();
         if (hs is null) return;
         var response = (hs.GetRoom(RoomId)).GetFullStateAsync();
         await foreach (var _ev in response) {
diff --git a/MatrixUtils.Web/Pages/Rooms/Timeline.razor b/MatrixUtils.Web/Pages/Rooms/Timeline.razor
index e6b1248..a064956 100644
--- a/MatrixUtils.Web/Pages/Rooms/Timeline.razor
+++ b/MatrixUtils.Web/Pages/Rooms/Timeline.razor
@@ -2,7 +2,7 @@
 @using MatrixUtils.Web.Shared.TimelineComponents
 @using LibMatrix
 @using LibMatrix.EventTypes.Spec
-@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.EventTypes.Spec.State.RoomInfo
 <h3>RoomManagerTimeline</h3>
 <hr/>
 <p>Loaded @Events.Count events...</p>
@@ -27,7 +27,7 @@
 
     protected override async Task OnInitializedAsync() {
         Console.WriteLine("RoomId: " + RoomId);
-        Homeserver = await RMUStorage.GetCurrentSessionOrNavigate();
+        Homeserver = await RmuStorage.GetCurrentSessionOrNavigate();
         if (Homeserver is null) return;
         var room = Homeserver.GetRoom(RoomId);
         MessagesResponse? msgs = null;
diff --git a/MatrixUtils.Web/Pages/ServerInfo.razor b/MatrixUtils.Web/Pages/ServerInfo.razor
index e6f1f16..8dd7907 100644
--- a/MatrixUtils.Web/Pages/ServerInfo.razor
+++ b/MatrixUtils.Web/Pages/ServerInfo.razor
@@ -78,7 +78,7 @@
 
     protected override async Task OnParametersSetAsync() {
         if (Homeserver is not null) {
-            var rhs = await hsProvider.GetRemoteHomeserver(Homeserver);
+            var rhs = await HsProvider.GetRemoteHomeserver(Homeserver);
             ServerVersionResponse = await (rhs.FederationClient?.GetServerVersionAsync() ?? Task.FromResult<ServerVersionResponse?>(null));
             ClientVersionsResponse = await rhs.GetClientVersionsAsync();
         }
diff --git a/MatrixUtils.Web/Pages/StreamTest.razor b/MatrixUtils.Web/Pages/StreamTest.razor
new file mode 100644
index 0000000..4cec354
--- /dev/null
+++ b/MatrixUtils.Web/Pages/StreamTest.razor
@@ -0,0 +1,119 @@
+@page "/StreamTest"
+@inject ILogger<Index> logger
+@using ArcaneLibs.Extensions
+@using LibMatrix.EventTypes.Spec.State.RoomInfo
+
+<PageTitle>StreamText</PageTitle>
+@if (Homeserver is not null) {
+    <p>Got homeserver @Homeserver.BaseUrl</p>
+
+    @* <img src="@ResolvedUri" @ref="imgElement"/> *@
+    @* <StreamedImage Stream="@Stream"/> *@
+
+    <br/>
+    @foreach (var stream in Streams.OrderBy(x => x.GetHashCode())) {
+        <StreamedImage Stream="@stream" style="width: 12em; height: 12em; object-fit: cover;"/>
+    }
+}
+
+@code
+{
+    private string? _resolvedUri;
+
+    private AuthenticatedHomeserverGeneric? Homeserver { get; set; }
+
+    private string? ResolvedUri {
+        get => _resolvedUri;
+        set {
+            _resolvedUri = value;
+            StateHasChanged();
+        }
+    }
+
+    ElementReference imgElement { get; set; }
+    public Stream? Stream { get; set; }
+    public List<Stream> Streams { get; set; } = new();
+
+    protected override async Task OnInitializedAsync() {
+        Homeserver = await RmuStorage.GetCurrentSessionOrNavigate();
+
+        //await InitOld();
+        await Init2();
+
+        await base.OnInitializedAsync();
+    }
+
+    private async Task Init2() {
+        var roomState = await Homeserver.GetRoom("!dSMpkVKGgQHlgBDSpo:matrix.org").GetFullStateAsListAsync();
+        var members = roomState.Where(x => x.Type == RoomMemberEventContent.EventId).ToList();
+        Console.WriteLine($"Got {members.Count()} members");
+        var ss = new SemaphoreSlim(1, 1);
+        foreach (var stateEventResponse in members) {
+            // Console.WriteLine(stateEventResponse.ToJson());
+            var mc = stateEventResponse.TypedContent as RoomMemberEventContent;
+            if (!string.IsNullOrWhiteSpace(mc?.AvatarUrl)) {
+                var uri = mc.AvatarUrl[6..].Split('/');
+                var url = $"/_matrix/media/v3/download/{uri[0]}/{uri[1]}";
+                // Homeserver.GetMediaStreamAsync(mc?.AvatarUrl).ContinueWith(async x => {
+                // await ss.WaitAsync();
+                // var stream = x.Result;
+                // Streams.Add(stream);
+                // StateHasChanged();
+                await Task.Delay(100);
+                // ss.Release();
+                // });
+                try {
+                    Homeserver.ClientHttpClient.GetStreamAsync(url).ContinueWith(async x => {
+                        // await ss.WaitAsync();
+                        var stream = x.Result;
+                        Streams.Add(stream);
+                        StateHasChanged();
+                        // await Task.Delay(100);
+                        // ss.Release();
+                    });
+                }
+                catch (Exception e) {
+                    Console.WriteLine(e);
+                }
+            }
+        }
+    }
+
+    private async Task InitOld() {
+        // var value = "mxc://rory.gay/AcFYcSpVXhEwbejrPVQrRUqt";
+        // var value = "mxc://rory.gay/oqfCjIUVTAObSQbnMFekQvYR";
+        var value = "mxc://feline.support/LUslNRVIYfeyCdRElqkkumKP";
+        var uri = value[6..].Split('/');
+        var url = $"/_matrix/media/v3/download/{uri[0]}/{uri[1]}";
+        // var res = Homeserver.ClientHttpClient.GetAsync(url);
+        // var res2 = Homeserver.ClientHttpClient.GetAsync(url);
+        // var tasks = Enumerable.Range(1, 128)
+        // .Select(x => Homeserver.ClientHttpClient.GetStreamAsync(url+$"?width={x*128}&height={x*128}"))
+        // .ToAsyncEnumerable();
+        await foreach (var result in GetStreamsDelayed(url)) {
+            Streams.Add(result);
+            // await Task.Delay(100);
+            StateHasChanged();
+        }
+
+        // var stream = await (await res).Content.ReadAsStreamAsync();
+        // Stream = await (await res2).Content.ReadAsStreamAsync();
+        StateHasChanged();
+
+        // await JSRuntime.streamImage(stream, imgElement);
+    }
+
+    private async IAsyncEnumerable<Stream> GetStreamsDelayed(string url) {
+        for (int i = 0; i < 32; i++) {
+            var tasks = Enumerable.Range(1, 4)
+                .Select(x => Homeserver.ClientHttpClient.GetStreamAsync(url + $"?width={x * 128}&height={x * 128}&r={Random.Shared.Next(100000)}"))
+                .ToAsyncEnumerable();
+            await foreach (var result in tasks) {
+                yield return result;
+            }
+            // var resp = await Homeserver.ClientHttpClient.GetAsync(url + $"?width={i * 128}&height={i * 128}");
+            // yield return await resp.Content.ReadAsStreamAsync();
+            // await Task.Delay(250);
+        }
+    }
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Tools/Debug/LeaveRoom.razor b/MatrixUtils.Web/Pages/Tools/Debug/LeaveRoom.razor
index 841552e..8782dbe 100644
--- a/MatrixUtils.Web/Pages/Tools/Debug/LeaveRoom.razor
+++ b/MatrixUtils.Web/Pages/Tools/Debug/LeaveRoom.razor
@@ -17,7 +17,7 @@
     public string? RoomId { get; set; }
 
     protected override async Task OnInitializedAsync() {
-        hs = await RMUStorage.GetCurrentSessionOrNavigate();
+        hs = await RmuStorage.GetCurrentSessionOrNavigate();
         if (hs is null) return;
         Log.CollectionChanged += (sender, args) => StateHasChanged();
         
diff --git a/MatrixUtils.Web/Pages/Tools/Debug/MediaLocator.razor b/MatrixUtils.Web/Pages/Tools/Debug/MediaLocator.razor
index 6e87926..dd8a801 100644
--- a/MatrixUtils.Web/Pages/Tools/Debug/MediaLocator.razor
+++ b/MatrixUtils.Web/Pages/Tools/Debug/MediaLocator.razor
@@ -92,7 +92,7 @@
         lines.ToList().ForEach(async line => {
             await sem.WaitAsync();
             try {
-                homeservers.Add((await hsResolver.ResolveHomeserverFromWellKnown(line)).Client);
+                homeservers.Add((await HsResolver.ResolveHomeserverFromWellKnown(line)).Client);
                 StateHasChanged();
             }
             catch (Exception e) {
diff --git a/MatrixUtils.Web/Pages/Tools/Debug/MigrateRoom.razor b/MatrixUtils.Web/Pages/Tools/Debug/MigrateRoom.razor
index 11d35f1..f21bac2 100644
--- a/MatrixUtils.Web/Pages/Tools/Debug/MigrateRoom.razor
+++ b/MatrixUtils.Web/Pages/Tools/Debug/MigrateRoom.razor
@@ -39,7 +39,7 @@
     private string newRoomId { get; set; }
 
     protected override async Task OnInitializedAsync() {
-        var hs = await RMUStorage.GetCurrentSessionOrNavigate();
+        var hs = await RmuStorage.GetCurrentSessionOrNavigate();
         if (hs is null) return;
 
         StateHasChanged();
@@ -48,7 +48,7 @@
     }
 
     private async Task Execute() {
-        var hs = await RMUStorage.GetCurrentSessionOrNavigate();
+        var hs = await RmuStorage.GetCurrentSessionOrNavigate();
         if (hs is null) return;
         var oldRoom = hs.GetRoom(roomId);
         var newRoom = hs.GetRoom(newRoomId);
@@ -90,7 +90,7 @@
 
     private async Task TryFetchUsers() {
         try {
-            var hs = await RMUStorage.GetCurrentSessionOrNavigate();
+            var hs = await RmuStorage.GetCurrentSessionOrNavigate();
             if (hs is null) return;
             var room = hs.GetRoom(roomId);
             var members = await room.GetMembersListAsync();
diff --git a/MatrixUtils.Web/Pages/Tools/Debug/SpaceDebug.razor b/MatrixUtils.Web/Pages/Tools/Debug/SpaceDebug.razor
index 263879b..70ae27d 100644
--- a/MatrixUtils.Web/Pages/Tools/Debug/SpaceDebug.razor
+++ b/MatrixUtils.Web/Pages/Tools/Debug/SpaceDebug.razor
@@ -45,7 +45,7 @@
 
     protected override async Task OnInitializedAsync() {
         Status = "Getting homeserver...";
-        var hs = await RMUStorage.GetCurrentSessionOrNavigate();
+        var hs = await RmuStorage.GetCurrentSessionOrNavigate();
         if (hs is null) return;
 
         var syncHelper = new SyncHelper(hs) {
diff --git a/MatrixUtils.Web/Pages/Tools/Index.razor b/MatrixUtils.Web/Pages/Tools/Index.razor
index e68bb9a..f99e932 100644
--- a/MatrixUtils.Web/Pages/Tools/Index.razor
+++ b/MatrixUtils.Web/Pages/Tools/Index.razor
@@ -24,7 +24,7 @@
 <a href="/Tools/Moderation/UserTrace">Trace user across rooms</a><br/>
 <a href="/tools/Moderation/MassCMEBan">Mass write policies to Community Moderation Effort</a><br/>
 <a href="/tools/Moderation/RoomIntersections">Find rooms with common users</a><br/>
-<a href="/tools/Moderation/DraupnirProtectedRoomsEditor">Edit Draupnir protected rooms set</a><br/>
+<a href="/tools/Moderation/Draupnir/ProtectedRoomsEditor">Draupnir: edit protected rooms set</a><br/>
 
 
 <h4 class="tool-category">Debugging tools</h4>
diff --git a/MatrixUtils.Web/Pages/Tools/Info/KnownHomeserverList.razor b/MatrixUtils.Web/Pages/Tools/Info/KnownHomeserverList.razor
index ddd7b15..296852a 100644
--- a/MatrixUtils.Web/Pages/Tools/Info/KnownHomeserverList.razor
+++ b/MatrixUtils.Web/Pages/Tools/Info/KnownHomeserverList.razor
@@ -1,45 +1,73 @@
-@page "/Tools/KnownHomeserverList"
+@page "/Tools/Info/KnownHomeserverList"
 @using ArcaneLibs.Extensions
+@using LibMatrix.RoomTypes
+@using SpawnDev.BlazorJS.WebWorkers
+@inject WebWorkerService workerService
 <h3>Known Homeserver List</h3>
 <hr/>
 
 @if (!IsFinished) {
     <p>
-        <b>Loading...</b>
+        <b>Loading... @RoomCount rooms remaining to process...</b>
     </p>
 }
 
-@foreach (var (homeserver, members) in counts.OrderByDescending(x => x.Value)) {
-    <p>@homeserver - @members</p>
+@{
+    var shownCounts = counts.OrderByDescending(x => x.Value).AsEnumerable();
+    if (!IsFinished && counts.Count > 500) {
+        shownCounts = shownCounts.Where(x => x.Value > 5);
+    }
+}
+@foreach (var (homeserver, members) in shownCounts.ToList()) {
+    <p>@homeserver - @members users</p>
 }
 <hr/>
 
 @code {
     Dictionary<string, List<string>> homeservers { get; set; } = new();
+
     Dictionary<string, int> counts { get; set; } = new();
+
     // List<HomeserverInfo> Homeservers = new();
     bool IsFinished { get; set; }
+
     // HomeserverInfoQueryProgress QueryProgress { get; set; } = new();
     AuthenticatedHomeserverGeneric? hs { get; set; }
+    int RoomCount { get; set; } = 0;
 
     protected override async Task OnInitializedAsync() {
-        hs = await RMUStorage.GetCurrentSessionOrNavigate();
+        hs = await RmuStorage.GetCurrentSessionOrNavigate();
         if (hs is null) return;
-        var fetchTasks = (await hs.GetJoinedRooms()).Select(x=>x.GetMembersByHomeserverAsync()).ToAsyncEnumerable();
+        var ss = new SemaphoreSlim(32, 32);
+        var rooms = await hs.GetJoinedRooms();
+        RoomCount = rooms.Count;
+        var fetchTasks = rooms.Select(roomId => workerService.TaskPool.Invoke(() => InternalGetMembersByHomeserver(hs.WellKnownUris.Client, hs.AccessToken, roomId.RoomId))).ToList().ToAsyncEnumerable();
+        // var fetchTasks = rooms.Select(async x => {
+            // await ss.WaitAsync();
+            // var res = await x.GetMembersByHomeserverAsync();
+            // ss.Release();
+            // return res;
+        // }).ToAsyncEnumerable();
         await foreach (var result in fetchTasks) {
             foreach (var (resHomeserver, resMembers) in result) {
                 if (!homeservers.TryAdd(resHomeserver, resMembers)) {
                     homeservers[resHomeserver].AddRange(resMembers);
                 }
+
                 counts[resHomeserver] = homeservers[resHomeserver].Count;
             }
-            // StateHasChanged();
+
+            RoomCount--;
+            StateHasChanged();
             // await Task.Delay(250);
+            await Task.Yield();
         }
 
         foreach (var resHomeserver in homeservers.Keys) {
             homeservers[resHomeserver] = homeservers[resHomeserver].Distinct().ToList();
             counts[resHomeserver] = homeservers[resHomeserver].Count;
+            StateHasChanged();
+            await Task.Yield();
         }
 
         IsFinished = true;
@@ -48,4 +76,10 @@
         await base.OnInitializedAsync();
     }
 
+    private static async Task<Dictionary<string, List<string>>> InternalGetMembersByHomeserver(string homeserverBaseUrl, string accessToken, string roomId) {
+        var hs = new AuthenticatedHomeserverGeneric(homeserverBaseUrl, new() { Client = homeserverBaseUrl }, null, accessToken);
+        var room = hs.GetRoom(roomId);
+        return await room.GetMembersByHomeserverAsync();
+    }
+
 }
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Tools/Info/PolicyListActivity.razor b/MatrixUtils.Web/Pages/Tools/Info/PolicyListActivity.razor
index de0bfe7..8f9f043 100644
--- a/MatrixUtils.Web/Pages/Tools/Info/PolicyListActivity.razor
+++ b/MatrixUtils.Web/Pages/Tools/Info/PolicyListActivity.razor
@@ -5,6 +5,8 @@
 @using LibMatrix.EventTypes.Common
 
 
+
+@* <ActivityGraph Data="TestData"/> *@
 @if (RoomData.Count == 0)
 {
     <p>Loading...</p>
@@ -15,10 +17,8 @@ else
         <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>
+            <span>@year.Key</span>
+            <ActivityGraph Data="@year.Value" GlobalMax="MaxValue" RLabel="removed" GLabel="new" BLabel="updated policies"/>
         }
     }
 
@@ -40,17 +40,24 @@ else
     {
         var sw = Stopwatch.StartNew();
         await base.OnInitializedAsync();
-        Homeserver = (await RMUStorage.GetCurrentSessionOrNavigate())!;
+        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)))
+        for (DateOnly i = new DateOnly(2020, 1, 1); i < new DateOnly(2020, 12, 30); i = i.AddDays(2))
         {
+            // TestData[i] = new()
+            // {
+            //     R = (int)(Random.Shared.NextSingle() * 255),
+            //     G = (int)(Random.Shared.NextSingle() * 255),
+            //     B = (int)(Random.Shared.NextSingle() * 255)
+            // };
+            // rgb based on number of week
             TestData[i] = new()
             {
-                R = (int)(Random.Shared.NextSingle() * 255),
-                G = (int)(Random.Shared.NextSingle() * 255),
-                B = (int)(Random.Shared.NextSingle() * 255)
+                R = i.DayOfYear % 255,
+                G = i.DayOfYear + 96 % 255,
+                B = i.DayOfYear + 192 % 255
             };
         }
 
@@ -109,7 +116,7 @@ else
             }
 
             //use timeline
-            var timeline = room.GetManyMessagesAsync(limit: int.MaxValue, chunkSize: 5000);
+            var timeline = room.GetManyMessagesAsync(limit: int.MaxValue, chunkSize: 2000);
             await foreach (var response in timeline)
             {
                 Console.WriteLine($"Got {response.State.Count} state, {response.Chunk.Count} timeline");
@@ -133,7 +140,7 @@ else
 
                     var rgb = RoomData[roomName][date.Year][date];
                     if (message.RawContent?.Count == 0) rgb.R++;
-                    else if (string.IsNullOrWhiteSpace(message.Unsigned?.ReplacesState)) rgb.G++;
+                    else if (message.Unsigned?.ContainsKey("replaces_state") ?? false) rgb.G++;
                     else rgb.B++;
                     RoomData[roomName][date.Year][date] = rgb;
                 }
diff --git a/MatrixUtils.Web/Pages/Tools/Info/SessionCount.razor b/MatrixUtils.Web/Pages/Tools/Info/SessionCount.razor
index 3b68bfa..1adb440 100644
--- a/MatrixUtils.Web/Pages/Tools/Info/SessionCount.razor
+++ b/MatrixUtils.Web/Pages/Tools/Info/SessionCount.razor
@@ -4,7 +4,7 @@
 @using System.Collections.ObjectModel
 @using LibMatrix
 @using System.Collections.Frozen
-@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.EventTypes.Spec.State.RoomInfo
 <h3>User Trace</h3>
 <hr/>
 
@@ -73,12 +73,12 @@
 
     protected override async Task OnInitializedAsync() {
         log.CollectionChanged += (sender, args) => StateHasChanged();
-        var hs = await RMUStorage.GetCurrentSessionOrNavigate();
+        var hs = await RmuStorage.GetCurrentSessionOrNavigate();
         if (hs is null) return;
         rooms.CollectionChanged += (sender, args) => StateHasChanged();
-        var sessions = await RMUStorage.GetAllTokens();
+        var sessions = await RmuStorage.GetAllTokens();
         foreach (var userAuth in sessions) {
-            var session = await RMUStorage.GetSession(userAuth);
+            var session = await RmuStorage.GetSession(userAuth);
             if (session is not null) {
                 var sessionRooms = await session.GetJoinedRooms();
                 foreach (var room in sessionRooms) {
diff --git a/MatrixUtils.Web/Pages/Tools/InviteCounter.razor b/MatrixUtils.Web/Pages/Tools/InviteCounter.razor
index 8f4b4dd..fa94f18 100644
--- a/MatrixUtils.Web/Pages/Tools/InviteCounter.razor
+++ b/MatrixUtils.Web/Pages/Tools/InviteCounter.razor
@@ -1,11 +1,8 @@
 @page "/Tools/InviteCounter"
-@using ArcaneLibs.Extensions
-@using LibMatrix.RoomTypes
 @using System.Collections.ObjectModel
-@using LibMatrix
-@using System.Collections.Frozen
-@using LibMatrix.EventTypes.Spec.State
-@using MatrixUtils.Abstractions
+@using ArcaneLibs.Extensions
+@using LibMatrix.EventTypes.Spec.State.RoomInfo
+@using LibMatrix.Filters
 <h3>User Trace</h3>
 <hr/>
 
@@ -18,7 +15,7 @@
 
 <details>
     <summary>Results</summary>
-    @foreach (var (userId, events) in invites.OrderByDescending(x=>x.Value).ToList()) {
+    @foreach (var (userId, events) in invites.OrderByDescending(x => x.Value).ToList()) {
         <p>@userId: @events</p>
     }
 </details>
@@ -32,16 +29,15 @@
     private ObservableCollection<string> log { get; set; } = new();
     private Dictionary<string, int> invites { get; set; } = new();
     private AuthenticatedHomeserverGeneric hs { get; set; }
-    
+
     [Parameter, SupplyParameterFromQuery(Name = "room")]
     public string roomId { get; set; }
-    
 
     protected override async Task OnInitializedAsync() {
         log.CollectionChanged += (sender, args) => StateHasChanged();
-        hs = await RMUStorage.GetCurrentSessionOrNavigate();
+        hs = await RmuStorage.GetCurrentSessionOrNavigate();
         if (hs is null) return;
-       
+
         StateHasChanged();
         Console.WriteLine("Rerendered!");
         await base.OnInitializedAsync();
@@ -49,22 +45,21 @@
 
     private async Task<string> Execute() {
         var room = hs.GetRoom(roomId);
-        var events = room.GetManyMessagesAsync(limit: int.MaxValue);
+        var filter = new SyncFilter.EventFilter(types: ["m.room.member"]);
+        var events = room.GetManyMessagesAsync(limit: int.MaxValue, filter: filter.ToJson(indent: false, ignoreNull: true));
         await foreach (var resp in events) {
             var all = resp.State.Concat(resp.Chunk);
             foreach (var evt in all) {
-                if(evt.Type != RoomMemberEventContent.EventId) continue;
+                if (evt.Type != RoomMemberEventContent.EventId) continue;
                 var content = evt.TypedContent as RoomMemberEventContent;
-                if(content.Membership != "invite") continue;
-                if(!invites.ContainsKey(evt.Sender)) invites[evt.Sender] = 0;
+                if (content.Membership != "invite") continue;
+                if (!invites.ContainsKey(evt.Sender)) invites[evt.Sender] = 0;
                 invites[evt.Sender]++;
             }
 
             log.Add($"{resp.State.Count} state, {resp.Chunk.Count} timeline");
         }
-        
-        
-        
+
         StateHasChanged();
 
         return "";
diff --git a/MatrixUtils.Web/Pages/Tools/MassCMEBan.razor b/MatrixUtils.Web/Pages/Tools/MassCMEBan.razor
index cbbca9e..547c586 100644
--- a/MatrixUtils.Web/Pages/Tools/MassCMEBan.razor
+++ b/MatrixUtils.Web/Pages/Tools/MassCMEBan.razor
@@ -1,12 +1,6 @@
 @page "/Tools/MassCMEBan"
-@using ArcaneLibs.Extensions
-@using LibMatrix.RoomTypes
 @using System.Collections.ObjectModel
-@using LibMatrix
-@using System.Collections.Frozen
-@using LibMatrix.EventTypes.Spec.State
 @using LibMatrix.EventTypes.Spec.State.Policy
-@using MatrixUtils.Abstractions
 <h3>User Trace</h3>
 <hr/>
 
@@ -33,7 +27,7 @@
 
     protected override async Task OnInitializedAsync() {
         log.CollectionChanged += (sender, args) => StateHasChanged();
-        hs = await RMUStorage.GetCurrentSessionOrNavigate();
+        hs = await RmuStorage.GetCurrentSessionOrNavigate();
         if (hs is null) return;
        
         StateHasChanged();
diff --git a/MatrixUtils.Web/Pages/Tools/Moderation/Draupnir/DraupnirProtectedRoomsEditor.razor b/MatrixUtils.Web/Pages/Tools/Moderation/Draupnir/DraupnirProtectedRoomsEditor.razor
new file mode 100644
index 0000000..953a1e6
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Tools/Moderation/Draupnir/DraupnirProtectedRoomsEditor.razor
@@ -0,0 +1,138 @@
+@page "/Moderation/DraupnirProtectedRoomsEditor"
+@page "/Tools/Moderation/DraupnirProtectedRoomsEditor"
+@page "/Tools/Moderation/Draupnir/ProtectedRoomsEditor"
+@using LibMatrix
+@using LibMatrix.EventTypes.Interop.Draupnir
+@using LibMatrix.EventTypes.Spec.State.RoomInfo
+@using LibMatrix.RoomTypes
+<h3>Edit Draupnir protected rooms</h3>
+<hr/>
+<p><b>Note:</b> You will need to restart Draupnir after applying changes!</p>
+<p>Minor note: This <i>should</i> also work with Mjolnir, but this hasn't been tested, and as such functionality cannot be guaranteed.</p>
+
+@if (data is not null) {
+    <div class="row">
+        <div class="col-12">
+            <details>
+                <summary>Currently protected room IDs</summary>
+                <ul>
+                    @foreach (var room in data.Rooms) {
+                        <li>@room</li>
+                    }
+                </ul>
+            </details>
+            <hr/>
+            <h4>Tickyboxes</h4>
+            <table class="table">
+                <thead>
+                    <tr>
+                        <th></th> @* Checkbox column *@
+                        <th>Kick?</th> @* PL > kick *@
+                        <th>Ban?</th> @* PL > ban *@
+                        <th>ACL?</th> @* PL > m.room.server_acls event *@
+                        <th>Room ID</th>
+                        <th>Room name</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    @foreach (var room in Rooms.OrderBy(x => x.RoomName)) {
+                        <tr>
+                            <td>
+                                <input type="checkbox" @bind="room.IsProtected"/>
+                            </td>
+                            <td>@(room.PowerLevels.Kick <= room.PowerLevels.GetUserPowerLevel(hs.UserId) ? "X" : "")</td>
+                            <td>@(room.PowerLevels.Ban <= room.PowerLevels.GetUserPowerLevel(hs.UserId) ? "X" : "")</td>
+                            <td>@(room.PowerLevels.UserHasStatePermission(hs.UserId, RoomServerAclEventContent.EventId) ? "X" : "")</td>
+                            <td>@room.Room.RoomId</td>
+                            <td>@room.RoomName</td>
+                        </tr>
+                    }
+                </tbody>
+            </table>
+        </div>
+    </div>
+}
+<br/>
+<LinkButton OnClick="@Apply">Apply</LinkButton>
+
+
+@code {
+    private DraupnirProtectedRoomsData data { get; set; } = new();
+    private List<EditorRoomInfo> Rooms { get; set; } = new();
+    private AuthenticatedHomeserverGeneric hs { get; set; }
+
+    protected override async Task OnInitializedAsync() {
+        hs = await RmuStorage.GetCurrentSessionOrNavigate();
+        if (hs is null) return;
+        data = await hs.GetAccountDataAsync<DraupnirProtectedRoomsData>(DraupnirProtectedRoomsData.EventId);
+        StateHasChanged();
+        var tasks = (await hs.GetJoinedRooms()).Select(async room => {
+            var plTask = room.GetPowerLevelsAsync();
+            var roomNameTask = room.GetNameOrFallbackAsync();
+            var EditorRoomInfo = new EditorRoomInfo {
+                Room = room,
+                IsProtected = data.Rooms.Contains(room.RoomId),
+                RoomName = await roomNameTask,
+                PowerLevels = await plTask
+            };
+
+            Rooms.Add(EditorRoomInfo);
+            StateHasChanged();
+            return Task.CompletedTask;
+        }).ToList();
+        await Task.WhenAll(tasks);
+        await Task.Delay(500);
+
+        foreach (var protectedRoomId in data.Rooms) {
+            if (Rooms.Any(x => x.Room.RoomId == protectedRoomId)) continue;
+            var room = hs.GetRoom(protectedRoomId);
+            var editorRoomInfo = new EditorRoomInfo {
+                Room = room,
+                IsProtected = true
+            };
+
+            try {
+                var pl = await room.GetPowerLevelsAsync();
+                editorRoomInfo.PowerLevels = pl;
+            }
+            catch (MatrixException e) {
+                Console.WriteLine($"Failed to get power levels for {room.RoomId}: {e}");
+            }
+
+            try {
+                editorRoomInfo.RoomName = await room.GetNameOrFallbackAsync();
+            }
+            catch (MatrixException e) {
+                Console.WriteLine($"Failed to get name for {room.RoomId}: {e}");
+            }
+
+            try {
+                var membership = await room.GetStateEventOrNullAsync(hs.UserId);
+                if (membership is not null) {
+                    editorRoomInfo.RoomName = $"(!! {membership.ContentAs<RoomMemberEventContent>()?.Membership ?? "null"} !!) {editorRoomInfo.RoomName}";
+                }
+            }
+            catch (MatrixException e) {
+                Console.WriteLine($"Failed to get membership for {room.RoomId}: {e}");
+            }
+
+            Rooms.Add(editorRoomInfo);
+        }
+
+        StateHasChanged();
+    }
+    
+    private class EditorRoomInfo {
+        public GenericRoom Room { get; set; }
+        public bool IsProtected { get; set; }
+        public string RoomName { get; set; }
+        public RoomPowerLevelEventContent PowerLevels { get; set; }
+    }
+
+    private async Task Apply() {
+        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);
+    }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Tools/Moderation/DraupnirProtectedRoomsEditor.razor b/MatrixUtils.Web/Pages/Tools/Moderation/Draupnir/DraupnirProtectionsEditor.razor
index 805bd40..b4a9d35 100644
--- a/MatrixUtils.Web/Pages/Tools/Moderation/DraupnirProtectedRoomsEditor.razor
+++ b/MatrixUtils.Web/Pages/Tools/Moderation/Draupnir/DraupnirProtectionsEditor.razor
@@ -1,7 +1,7 @@
-@page "/Moderation/DraupnirProtectedRoomsEditor"
-@page "/Tools/Moderation/DraupnirProtectedRoomsEditor"
+@page "/Tools/Moderation/Draupnir/ProtectionsEditor"
 @using System.Text.Json.Serialization
-@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix
+@using LibMatrix.EventTypes.Spec.State.RoomInfo
 @using LibMatrix.RoomTypes
 <h3>Edit Draupnir protected rooms</h3>
 <hr/>
@@ -38,7 +38,7 @@
                             </td>
                             <td>@(room.PowerLevels.Kick <= room.PowerLevels.GetUserPowerLevel(hs.UserId) ? "X" : "")</td>
                             <td>@(room.PowerLevels.Ban <= room.PowerLevels.GetUserPowerLevel(hs.UserId) ? "X" : "")</td>
-                            <td>@(room.PowerLevels.UserHasStatePermission(hs.UserId, RoomServerACLEventContent.EventId) ? "X" : "")</td>
+                            <td>@(room.PowerLevels.UserHasStatePermission(hs.UserId, RoomServerAclEventContent.EventId) ? "X" : "")</td>
                             <td>@room.Room.RoomId</td>
                             <td>@room.RoomName</td>
                         </tr>
@@ -58,7 +58,7 @@
     private AuthenticatedHomeserverGeneric hs { get; set; }
 
     protected override async Task OnInitializedAsync() {
-        hs = await RMUStorage.GetCurrentSessionOrNavigate();
+        hs = await RmuStorage.GetCurrentSessionOrNavigate();
         if (hs is null) return;
         data = await hs.GetAccountDataAsync<DraupnirProtectedRoomsData>("org.matrix.mjolnir.protected_rooms");
         StateHasChanged();
@@ -78,6 +78,43 @@
         }).ToList();
         await Task.WhenAll(tasks);
         await Task.Delay(500);
+
+        foreach (var protectedRoomId in data.Rooms) {
+            if (Rooms.Any(x => x.Room.RoomId == protectedRoomId)) continue;
+            var room = hs.GetRoom(protectedRoomId);
+            var editorRoomInfo = new EditorRoomInfo {
+                Room = room,
+                IsProtected = true
+            };
+
+            try {
+                var pl = await room.GetPowerLevelsAsync();
+                editorRoomInfo.PowerLevels = pl;
+            }
+            catch (MatrixException e) {
+                Console.WriteLine($"Failed to get power levels for {room.RoomId}: {e}");
+            }
+
+            try {
+                editorRoomInfo.RoomName = await room.GetNameOrFallbackAsync();
+            }
+            catch (MatrixException e) {
+                Console.WriteLine($"Failed to get name for {room.RoomId}: {e}");
+            }
+
+            try {
+                var membership = await room.GetStateEventOrNullAsync(hs.UserId);
+                if (membership is not null) {
+                    editorRoomInfo.RoomName = $"(!! {membership.ContentAs<RoomMemberEventContent>()?.Membership ?? "null"} !!) {editorRoomInfo.RoomName}";
+                }
+            }
+            catch (MatrixException e) {
+                Console.WriteLine($"Failed to get membership for {room.RoomId}: {e}");
+            }
+
+            Rooms.Add(editorRoomInfo);
+        }
+
         StateHasChanged();
     }
 
diff --git a/MatrixUtils.Web/Pages/Tools/Moderation/Draupnir/DraupnirWatchedListsEditor.razor b/MatrixUtils.Web/Pages/Tools/Moderation/Draupnir/DraupnirWatchedListsEditor.razor
new file mode 100644
index 0000000..1384a2a
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Tools/Moderation/Draupnir/DraupnirWatchedListsEditor.razor
@@ -0,0 +1,139 @@
+@page "/Tools/Moderation/Draupnir/WatchedListsEditor"
+@using System.Text.Json.Serialization
+@using LibMatrix
+@using LibMatrix.EventTypes.Spec.State.RoomInfo
+@using LibMatrix.RoomTypes
+<h3>Edit Draupnir protected rooms</h3>
+<hr/>
+<p><b>Note:</b> You will need to restart Draupnir after applying changes!</p>
+<p>Minor note: This <i>should</i> also work with Mjolnir, but this hasn't been tested, and as such functionality cannot be guaranteed.</p>
+
+@if (data is not null) {
+    <div class="row">
+        <div class="col-12">
+            <h4>Current rooms</h4>
+            <ul>
+                @foreach (var room in data.Rooms) {
+                    <li>@room</li>
+                }
+            </ul>
+            <hr/>
+            <h4>Tickyboxes</h4>
+            <table class="table">
+                <thead>
+                    <tr>
+                        <th></th> @* Checkbox column *@
+                        <th>Kick?</th> @* PL > kick *@
+                        <th>Ban?</th> @* PL > ban *@
+                        <th>ACL?</th> @* PL > m.room.server_acls event *@
+                        <th>Room ID</th>
+                        <th>Room name</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    @foreach (var room in Rooms.OrderBy(x => x.RoomName)) {
+                        <tr>
+                            <td>
+                                <input type="checkbox" @bind="room.IsProtected"/>
+                            </td>
+                            <td>@(room.PowerLevels.Kick <= room.PowerLevels.GetUserPowerLevel(hs.UserId) ? "X" : "")</td>
+                            <td>@(room.PowerLevels.Ban <= room.PowerLevels.GetUserPowerLevel(hs.UserId) ? "X" : "")</td>
+                            <td>@(room.PowerLevels.UserHasStatePermission(hs.UserId, RoomServerAclEventContent.EventId) ? "X" : "")</td>
+                            <td>@room.Room.RoomId</td>
+                            <td>@room.RoomName</td>
+                        </tr>
+                    }
+                </tbody>
+            </table>
+        </div>
+    </div>
+}
+<br/>
+<LinkButton OnClick="@Apply">Apply</LinkButton>
+
+
+@code {
+    private DraupnirProtectedRoomsData data { get; set; } = new();
+    private List<EditorRoomInfo> Rooms { get; set; } = new();
+    private AuthenticatedHomeserverGeneric hs { get; set; }
+
+    protected override async Task OnInitializedAsync() {
+        hs = await RmuStorage.GetCurrentSessionOrNavigate();
+        if (hs is null) return;
+        data = await hs.GetAccountDataAsync<DraupnirProtectedRoomsData>("org.matrix.mjolnir.protected_rooms");
+        StateHasChanged();
+        var tasks = (await hs.GetJoinedRooms()).Select(async room => {
+            var plTask = room.GetPowerLevelsAsync();
+            var roomNameTask = room.GetNameOrFallbackAsync();
+            var EditorRoomInfo = new EditorRoomInfo {
+                Room = room,
+                IsProtected = data.Rooms.Contains(room.RoomId),
+                RoomName = await roomNameTask,
+                PowerLevels = await plTask
+            };
+
+            Rooms.Add(EditorRoomInfo);
+            StateHasChanged();
+            return Task.CompletedTask;
+        }).ToList();
+        await Task.WhenAll(tasks);
+        await Task.Delay(500);
+
+        foreach (var protectedRoomId in data.Rooms) {
+            if (Rooms.Any(x => x.Room.RoomId == protectedRoomId)) continue;
+            var room = hs.GetRoom(protectedRoomId);
+            var editorRoomInfo = new EditorRoomInfo {
+                Room = room,
+                IsProtected = true
+            };
+
+            try {
+                var pl = await room.GetPowerLevelsAsync();
+                editorRoomInfo.PowerLevels = pl;
+            }
+            catch (MatrixException e) {
+                Console.WriteLine($"Failed to get power levels for {room.RoomId}: {e}");
+            }
+
+            try {
+                editorRoomInfo.RoomName = await room.GetNameOrFallbackAsync();
+            }
+            catch (MatrixException e) {
+                Console.WriteLine($"Failed to get name for {room.RoomId}: {e}");
+            }
+
+            try {
+                var membership = await room.GetStateEventOrNullAsync(hs.UserId);
+                if (membership is not null) {
+                    editorRoomInfo.RoomName = $"(!! {membership.ContentAs<RoomMemberEventContent>()?.Membership ?? "null"} !!) {editorRoomInfo.RoomName}";
+                }
+            }
+            catch (MatrixException e) {
+                Console.WriteLine($"Failed to get membership for {room.RoomId}: {e}");
+            }
+
+            Rooms.Add(editorRoomInfo);
+        }
+
+        StateHasChanged();
+    }
+
+    private class DraupnirProtectedRoomsData {
+        [JsonPropertyName("rooms")]
+        public List<string> Rooms { get; set; } = new();
+    }
+
+    private class EditorRoomInfo {
+        public GenericRoom Room { get; set; }
+        public bool IsProtected { get; set; }
+        public string RoomName { get; set; }
+        public RoomPowerLevelEventContent PowerLevels { get; set; }
+    }
+
+    private async Task Apply() {
+        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);
+    }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Tools/Moderation/FindUsersByRegex.razor b/MatrixUtils.Web/Pages/Tools/Moderation/FindUsersByRegex.razor
new file mode 100644
index 0000000..4b2af68
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Tools/Moderation/FindUsersByRegex.razor
@@ -0,0 +1,192 @@
+@page "/Tools/Moderation/FindUsersByRegex"
+@using System.Collections.Frozen
+@using ArcaneLibs.Extensions
+@using LibMatrix.RoomTypes
+@using System.Collections.ObjectModel
+@using System.Text.RegularExpressions
+@using LibMatrix
+@using LibMatrix.EventTypes.Spec.State.RoomInfo
+@using LibMatrix.Filters
+@using LibMatrix.Helpers
+<h3>Find users by regex</h3>
+<hr/>
+
+<p>Users (regex): </p>
+<InputTextArea @bind-Value="@UserIdString"></InputTextArea>
+
+<LinkButton OnClick="Execute">Execute</LinkButton>
+<br/>
+<LinkButton OnClick="RemoveKicks">Remove kicks</LinkButton>
+<LinkButton OnClick="RemoveBans">Remove bans</LinkButton>
+<br/>
+
+
+<details>
+    <summary>Results</summary>
+    @foreach (var (userId, events) in matches) {
+        <h4>@userId</h4>
+        <ul>
+            @foreach (var match in events) {
+                <li>
+                    <ul>
+                        <li>@match.RoomName (<span>@match.Room.RoomId</span>)</li>
+                        <li>Membership: @(match.Event.RawContent.ToJson(indent: false)) (sent by @match.Event.Sender)</li>
+                    </ul>
+                </li>
+            }
+        </ul>
+    }
+</details>
+
+<br/>
+@foreach (var line in log.Reverse()) {
+    <pre>@line</pre>
+}
+
+@code {
+
+    private ObservableCollection<string> log { get; set; } = new();
+
+    // List<RoomInfo> rooms { get; set; } = new();
+    List<GenericRoom> rooms { get; set; } = [];
+    Dictionary<string, List<Match>> matches = new();
+
+    private string UserIdString {
+        get => string.Join("\n", UserIDs);
+        set => UserIDs = value.Split("\n").Select(x => x.Trim()).Where(x => !string.IsNullOrWhiteSpace(x)).ToList();
+    }
+
+    private List<string> UserIDs { get; set; } = new();
+
+    private AuthenticatedHomeserverGeneric hs { get; set; }
+
+    protected override async Task OnInitializedAsync() {
+        log.CollectionChanged += (sender, args) => StateHasChanged();
+        log.Add("Authenticating");
+        hs = await RmuStorage.GetCurrentSessionOrNavigate();
+        if (hs is null) return;
+
+        StateHasChanged();
+        Console.WriteLine("Rerendered!");
+        await base.OnInitializedAsync();
+    }
+
+    private async Task<string> Execute() {
+        log.Add("Constructing sync helper...");
+        var sh = new SyncHelper(hs) {
+            Filter = new SyncFilter() {
+                AccountData = new(types: []),
+                Presence = new(types: []),
+                Room = new() {
+                    AccountData = new(types: []),
+                    Ephemeral = new(types: []),
+                    State = new(types: [RoomMemberEventContent.EventId]),
+                    Timeline = new(types: []),
+                    IncludeLeave = false
+                },
+            }
+        };
+
+        log.Add("Starting sync...");
+        var res = await sh.SyncAsync();
+
+        log.Add("Got sync response, parsing...");
+
+        var roomNames = (await Task.WhenAll((await hs.GetJoinedRooms()).Select(async room => { return (room.RoomId, await room.GetNameOrFallbackAsync()); }).ToList())).ToFrozenDictionary(x => x.Item1, x => x.Item2);
+
+        foreach (var userIdRegex in UserIDs) {
+            var regex = new Regex(userIdRegex, RegexOptions.Compiled);
+            log.Add($"Searching for {regex}:");
+            foreach (var (roomId, joinedRoom) in res.Rooms.Join) {
+                log.Add($"- Checking room {roomId}...");
+                foreach (var evt in joinedRoom.State.Events) {
+                    if (evt.StateKey is null) continue;
+                    if (evt.Type is not RoomMemberEventContent.EventId) continue;
+
+                    if (regex.IsMatch(evt.StateKey)) {
+                        log.Add($"  - Found match in {roomId} for {evt.StateKey}");
+                        if (!matches.ContainsKey(evt.StateKey)) {
+                            matches[evt.StateKey] = new();
+                        }
+
+                        var room = hs.GetRoom(roomId);
+                        matches[evt.StateKey].Add(new Match {
+                            Room = room,
+                            Event = evt,
+                            RoomName = roomNames[roomId]
+                        });
+                    }
+                }
+            }
+        }
+
+        log.Add("Done!");
+
+        StateHasChanged();
+
+        return "";
+    }
+
+    public string? ImportFromRoomId { get; set; }
+
+    private async Task DoImportFromRoomId() {
+        try {
+            if (ImportFromRoomId is null) return;
+            var room = rooms.FirstOrDefault(x => x.RoomId == ImportFromRoomId);
+            UserIdString = string.Join("\n", (await room.GetMembersListAsync()).Select(x => x.StateKey));
+        }
+        catch (Exception e) {
+            Console.WriteLine(e);
+            log.Add("Could not fetch members list!\n" + e.ToString());
+        }
+
+        StateHasChanged();
+    }
+
+    private class Match {
+        public GenericRoom Room;
+        public StateEventResponse Event;
+        public string RoomName { get; set; }
+    }
+
+    private async IAsyncEnumerable<Match> GetMatches(string userId) {
+        var results = rooms.Select(async room => {
+            var state = await room.GetStateEventOrNullAsync(room.RoomId, userId);
+            if (state is not null) {
+                return new Match {
+                    Room = room,
+                    Event = state,
+                    RoomName = await room.GetNameOrFallbackAsync()
+                };
+            }
+
+            return null;
+        }).ToAsyncEnumerable();
+        await foreach (var result in results) {
+            if (result is not null) {
+                yield return result;
+            }
+        }
+    }
+
+    private Task RemoveKicks() {
+        foreach (var (userId, matches) in matches) {
+            matches.RemoveAll(x => x.Event.ContentAs<RoomMemberEventContent>()!.Membership == "leave" && x.Event.Sender != x.Event.StateKey);
+        }
+
+        matches.RemoveAll((x, y) => y.Count == 0);
+        StateHasChanged();
+        return Task.CompletedTask;
+    }
+
+    private Task RemoveBans() {
+        foreach (var (userId, matches) in matches) {
+            matches.RemoveAll(x => x.Event.ContentAs<RoomMemberEventContent>()!.Membership == "ban" && x.Event.Sender != x.Event.StateKey);
+        }
+
+        matches.RemoveAll((x, y) => y.Count == 0);
+        StateHasChanged();
+        return Task.CompletedTask;
+    }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Tools/Moderation/InviteCounter.razor b/MatrixUtils.Web/Pages/Tools/Moderation/InviteCounter.razor
index 2123d4d..ea47237 100644
--- a/MatrixUtils.Web/Pages/Tools/Moderation/InviteCounter.razor
+++ b/MatrixUtils.Web/Pages/Tools/Moderation/InviteCounter.razor
@@ -1,6 +1,6 @@
 @page "/Tools/Moderation/InviteCounter"
 @using System.Collections.ObjectModel
-@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.EventTypes.Spec.State.RoomInfo
 <h3>Invite counter</h3>
 <hr/>
 
@@ -34,7 +34,7 @@
 
     protected override async Task OnInitializedAsync() {
         log.CollectionChanged += (sender, args) => StateHasChanged();
-        hs = await RMUStorage.GetCurrentSessionOrNavigate();
+        hs = await RmuStorage.GetCurrentSessionOrNavigate();
         if (hs is null) return;
        
         StateHasChanged();
diff --git a/MatrixUtils.Web/Pages/Tools/Moderation/MassCMEBan.razor b/MatrixUtils.Web/Pages/Tools/Moderation/MassCMEBan.razor
index ea1e5f6..b5e5edb 100644
--- a/MatrixUtils.Web/Pages/Tools/Moderation/MassCMEBan.razor
+++ b/MatrixUtils.Web/Pages/Tools/Moderation/MassCMEBan.razor
@@ -1,6 +1,7 @@
 @page "/Tools/Moderation/MassCMEBan"
 @using System.Collections.ObjectModel
 @using LibMatrix.EventTypes.Spec.State.Policy
+@using LibMatrix.RoomTypes
 <h3>User Trace</h3>
 <hr/>
 
@@ -17,19 +18,19 @@
 }
 
 @code {
+
     // TODO: Properly implement page to be more useful
     private ObservableCollection<string> log { get; set; } = new();
     private AuthenticatedHomeserverGeneric hs { get; set; }
-    
+
     [Parameter, SupplyParameterFromQuery(Name = "room")]
     public string roomId { get; set; }
-    
 
     protected override async Task OnInitializedAsync() {
         log.CollectionChanged += (sender, args) => StateHasChanged();
-        hs = await RMUStorage.GetCurrentSessionOrNavigate();
+        hs = await RmuStorage.GetCurrentSessionOrNavigate();
         if (hs is null) return;
-       
+
         StateHasChanged();
         Console.WriteLine("Rerendered!");
         await base.OnInitializedAsync();
@@ -37,33 +38,41 @@
 
     private async Task<string> Execute() {
         var room = hs.GetRoom("!fTjMjIzNKEsFlUIiru:neko.dev");
-        // var room = hs.GetRoom("!yf7OpOiRDXx6zUGpT6:conduit.rory.gay");
-        var users = roomId.Split("\n").Select(x => x.Trim()).Where(x=>x.StartsWith('@')).ToList();
-        foreach (var user in users) {
-            var exists = false;
-            try {
-                exists = !string.IsNullOrWhiteSpace((await room.GetStateAsync<UserPolicyRuleEventContent>(UserPolicyRuleEventContent.EventId, user.Replace('@', '_'))).Entity);
-            } catch (Exception e) {
-                log.Add($"Failed to get {user}");
-            }
+        // var room = hs.GetRoom("!IVSjKMsVbjXsmUTuRR:rory.gay");
+        var users = roomId.Split("\n").Select(x => x.Trim()).Where(x => x.StartsWith('@')).ToList();
+        var tasks = users.Select(x => ExecuteBan(room, x)).ToList();
+        await Task.WhenAll(tasks);
+
+        StateHasChanged();
+
+        return "";
+    }
 
-            if (!exists) {
+    private async Task ExecuteBan(GenericRoom room, string user) {
+        var exists = false;
+        try {
+            exists = !string.IsNullOrWhiteSpace((await room.GetStateAsync<UserPolicyRuleEventContent>(UserPolicyRuleEventContent.EventId, user.Replace('@', '_'))).Entity);
+        }
+        catch (Exception e) {
+            log.Add($"Failed to get {user}");
+        }
+
+        if (!exists) {
+            try {
                 var evt = await room.SendStateEventAsync(UserPolicyRuleEventContent.EventId, user.Replace('@', '_'), new UserPolicyRuleEventContent() {
                     Entity = user,
-                    Reason = "spam (invite)",
+                    Reason = "spam",
                     Recommendation = "m.ban"
                 });
                 log.Add($"Sent {evt.EventId} to ban {user}");
             }
-            else {
-                log.Add($"User {user} already exists");
+            catch (Exception e) {
+                log.Add($"Failed to ban {user}: {e}");
             }
         }
-        
-        
-        StateHasChanged();
-
-        return "";
+        else {
+            log.Add($"User {user} already exists");
+        }
     }
 
 }
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Tools/Moderation/MembershipHistory.razor b/MatrixUtils.Web/Pages/Tools/Moderation/MembershipHistory.razor
index e5ba004..6b5b5e4 100644
--- a/MatrixUtils.Web/Pages/Tools/Moderation/MembershipHistory.razor
+++ b/MatrixUtils.Web/Pages/Tools/Moderation/MembershipHistory.razor
@@ -1,30 +1,158 @@
 @page "/Tools/Moderation/MembershipHistory"
+@using System.Collections.Frozen
 @using System.Collections.ObjectModel
+@using System.Diagnostics
 @using LibMatrix
-@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.EventTypes.Spec.State.RoomInfo
+@{
+    var sw = Stopwatch.StartNew();
+    Console.WriteLine("Start render");
+}
 <h3>Membership history viewer</h3>
 <hr/>
-
 <br/>
 <span>Room ID: </span>
-<InputText @bind-Value="@roomId"></InputText>
+<InputText @bind-Value="@RoomId"></InputText>
 <LinkButton OnClick="@Execute">Execute</LinkButton>
-<p><InputCheckbox @bind-Value="ChronologicalOrder"/> Chronological order</p>
+<p>
+    <span><InputCheckbox @bind-Value="ChronologicalOrder"/>Chronological order</span>
+    <span><InputCheckbox @bind-Value="DoDisambiguate"/>Enable extended filters</span>
+</p>
 <p>
     <span>Show </span>
-    <InputCheckbox @bind-Value="ShowJoins"/> joins
-    <InputCheckbox @bind-Value="ShowLeaves"/> leaves
-    <InputCheckbox @bind-Value="ShowUpdates"/> profile updates
-    <InputCheckbox @bind-Value="ShowKnocks"/> knocks
-    <InputCheckbox @bind-Value="ShowInvites"/> invites
-    <InputCheckbox @bind-Value="ShowKicks"/> kicks
-    <InputCheckbox @bind-Value="ShowBans"/> bans
+    <span><InputCheckbox @bind-Value="ShowJoins"/> joins</span>
+    <span><InputCheckbox @bind-Value="ShowLeaves"/> leaves</span>
+    <span><InputCheckbox @bind-Value="ShowKnocks"/> knocks</span>
+    <span><InputCheckbox @bind-Value="ShowInvites"/> invites</span>
+    <span><InputCheckbox @bind-Value="ShowBans"/> bans</span>
+</p>
+<p>
+    <LinkButton OnClick="@(async () => {
+                             ShowJoins = ShowLeaves = ShowKnocks = ShowInvites = ShowBans = false;
+                             StateHasChanged();
+                         })">Hide all
+    </LinkButton>
+    <LinkButton OnClick="@(async () => {
+                             ShowJoins = ShowLeaves = ShowKnocks = ShowInvites = ShowBans = true;
+                             StateHasChanged();
+                         })">Show all
+    </LinkButton>
+    <LinkButton OnClick="@(async () => {
+                             ShowJoins ^= true;
+                             ShowLeaves ^= true;
+                             ShowKnocks ^= true;
+                             ShowInvites ^= true;
+                             ShowBans ^= true;
+                             StateHasChanged();
+                         })">Toggle all
+    </LinkButton>
 </p>
 <p>
-    <LinkButton OnClick="@(async () => { ShowJoins = ShowLeaves = ShowUpdates = ShowKnocks = ShowInvites = ShowKicks = ShowBans = false; })">Hide all</LinkButton>
-    <LinkButton OnClick="@(async () => { ShowJoins = ShowLeaves = ShowUpdates = ShowKnocks = ShowInvites = ShowKicks = ShowBans = true; })">Show all</LinkButton>
-    <LinkButton OnClick="@(async () => { ShowJoins ^= true; ShowLeaves ^= true; ShowUpdates ^= true; ShowKnocks ^= true; ShowInvites ^= true; ShowKicks ^= true; ShowBans ^= true; })">Toggle all</LinkButton>
+    <span><InputCheckbox @bind-Value="DoDisambiguate"/> Disambiguate </span>
+    @if (DoDisambiguate) {
+        <span><InputCheckbox @bind-Value="DisambiguateKicks"/> kicks</span>
+        <span><InputCheckbox @bind-Value="DisambiguateUnbans"/> unbans</span>
+        <span><InputCheckbox @bind-Value="DisambiguateProfileUpdates"/> profile updates</span>
+        <details style="display: inline-block; vertical-align: top;">
+            <summary>
+                <InputCheckbox @bind-Value="DisambiguateInviteActions"/>
+                invite actions
+            </summary>
+            <span><InputCheckbox @bind-Value="DisambiguateInviteAccepted"/> accepted</span>
+            <span><InputCheckbox @bind-Value="DisambiguateInviteRejected"/> rejected</span>
+            <span><InputCheckbox @bind-Value="DisambiguateInviteRetracted"/> retracted</span>
+        </details>
+        <details style="display: inline-block; vertical-align: top;">
+            <summary>
+                <InputCheckbox @bind-Value="DisambiguateKnockActions"/>
+                knock actions
+            </summary>
+            <span><InputCheckbox @bind-Value="DisambiguateKnockAccepted"/> accepted</span>
+            <span><InputCheckbox @bind-Value="DisambiguateKnockRejected"/> rejected</span>
+            <span><InputCheckbox @bind-Value="DisambiguateKnockRetracted"/> retracted</span>
+        </details>
+    }
 </p>
+@if (DoDisambiguate) {
+    <p>
+        <span>Show </span>
+        @if (DisambiguateKicks) {
+            <span><InputCheckbox @bind-Value="ShowKicks"/> kicks</span>
+        }
+        @if (DisambiguateUnbans) {
+            <span><InputCheckbox @bind-Value="ShowUnbans"/> unbans</span>
+        }
+        @if (DisambiguateProfileUpdates) {
+            <span><InputCheckbox @bind-Value="ShowProfileUpdates"/> profile updates</span>
+        }
+        @if (DisambiguateInviteActions) {
+        <details style="display: inline-block; vertical-align: top;">
+            <summary>
+                <InputCheckbox @bind-Value="ShowInviteActions"/>
+                invite actions
+            </summary>
+            @if (DisambiguateInviteAccepted) {
+                <span><InputCheckbox @bind-Value="ShowInviteAccepted"/> accepted</span>
+            }
+
+            @if (DisambiguateInviteRejected) {
+                <span><InputCheckbox @bind-Value="ShowInviteRejected"/> rejected</span>
+            }
+
+            @if (DisambiguateInviteRetracted) {
+                <span><InputCheckbox @bind-Value="ShowInviteRetracted"/> retracted</span>
+            }
+        </details>
+    }
+    @if (DisambiguateKnockActions) {
+        <details style="display: inline-block; vertical-align: top;">
+            <summary>
+                <InputCheckbox @bind-Value="ShowKnockActions"/>
+                knock actions
+            </summary>
+            @if (DisambiguateKnockAccepted) {
+                <span><InputCheckbox @bind-Value="ShowKnockAccepted"/> accepted</span>
+            }
+
+            @if (DisambiguateKnockRejected) {
+                <span><InputCheckbox @bind-Value="ShowKnockRejected"/> rejected</span>
+            }
+
+            @if (DisambiguateKnockRetracted) {
+                <span><InputCheckbox @bind-Value="ShowKnockRetracted"/> retracted</span>
+            }
+        </details>
+    }
+    </p>
+
+    <p>
+        <LinkButton OnClick="@(async () => {
+                                 DoDisambiguate = DisambiguateProfileUpdates = DisambiguateKicks = DisambiguateUnbans = DisambiguateInviteAccepted = DisambiguateInviteRejected = DisambiguateInviteRetracted = DisambiguateKnockAccepted = DisambiguateKnockRejected = DisambiguateKnockRetracted = DisambiguateKnockActions = DisambiguateInviteActions = false;
+                                 StateHasChanged();
+                             })">Un-disambiguate all
+        </LinkButton>
+        <LinkButton OnClick="@(async () => {
+                                 DoDisambiguate = DisambiguateProfileUpdates = DisambiguateKicks = DisambiguateUnbans = DisambiguateInviteAccepted = DisambiguateInviteRejected = DisambiguateInviteRetracted = DisambiguateKnockAccepted = DisambiguateKnockRejected = DisambiguateKnockRetracted = DisambiguateKnockActions = DisambiguateInviteActions = true;
+                                 StateHasChanged();
+                             })">Disambiguate all
+        </LinkButton>
+        <LinkButton OnClick="@(async () => {
+                                 DisambiguateProfileUpdates ^= true;
+                                 DisambiguateKicks ^= true;
+                                 DisambiguateUnbans ^= true;
+                                 DisambiguateInviteAccepted ^= true;
+                                 DisambiguateInviteRejected ^= true;
+                                 DisambiguateInviteRetracted ^= true;
+                                 DisambiguateKnockAccepted ^= true;
+                                 DisambiguateKnockRejected ^= true;
+                                 DisambiguateKnockRetracted ^= true;
+                                 DisambiguateKnockActions ^= true;
+                                 DisambiguateInviteActions ^= true;
+                                 StateHasChanged();
+                             })">Toggle all
+        </LinkButton>
+    </p>
+}
 <p>
     <span>Sender: </span>
     <InputSelect @bind-Value="Sender">
@@ -44,92 +172,121 @@
     </InputSelect>
 </p>
 
-
+@{ Console.WriteLine($"Rendering took {sw.Elapsed} for {Memberships.Count} items"); }
 <br/>
 
-<details>
+<details open>
     <summary>Results</summary>
     @{
-        Dictionary<string, StateEventResponse> previousMemberships = [];
-        var filteredMemberships = Memberships.AsEnumerable();
-        if (ChronologicalOrder) {
-            filteredMemberships = filteredMemberships.Reverse();
-        }
-        if(!string.IsNullOrWhiteSpace(Sender)) {
-            filteredMemberships = filteredMemberships.Where(x => x.Sender == Sender);
-        }
-        if(!string.IsNullOrWhiteSpace(User)) {
-            filteredMemberships = filteredMemberships.Where(x => x.StateKey == User);
-        }
-
-        @foreach (var membership in filteredMemberships) {
-            RoomMemberEventContent content = membership.TypedContent as RoomMemberEventContent;
-            @switch (content.Membership) {
-                case RoomMemberEventContent.MembershipTypes.Invite: {
-                    if (_showInvites) {
-                        <p style="color: green;">@membership.Sender invited @membership.StateKey @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reason: {content.Reason})")</p>
-                    }
-
-                    break;
-                }
-                case RoomMemberEventContent.MembershipTypes.Ban: {
-                    if (_showBans) {
-                        <p style="color: red;">@membership.Sender banned @membership.StateKey @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reason: {content.Reason})")</p>
-                    }
-
-                    break;
-                }
-                case RoomMemberEventContent.MembershipTypes.Leave: {
-                    if (membership.Sender == membership.StateKey) {
-                        if (_showLeaves) {
-                            <p style="color: #C66;">@membership.Sender left the room</p>
-                        }
-                    }
-                    else {
-                        if (_showKicks) {
-                            <p style="color: darkorange;">@membership.Sender kicked @membership.StateKey @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reason: {content.Reason})")</p>
-                        }
-                    }
-
-                    break;
-                }
-                case RoomMemberEventContent.MembershipTypes.Knock: {
-                    if (_showKnocks) {
-                        <p>@membership.Sender knocked @membership.StateKey @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reason: {content.Reason})")</p>
-                    }
-
-                    break;
-                }
-                case RoomMemberEventContent.MembershipTypes.Join: {
-                    if (previousMemberships.TryGetValue(membership.StateKey, out var previous)
-                        && (previous.TypedContent as RoomMemberEventContent).Membership == RoomMemberEventContent.MembershipTypes.Join) {
-                        if (_showUpdates) {
-                            <p style="color: #777;">@membership.Sender changed their profile</p>
-                        }
-                    }
-                    else {
-                        if (_showJoins) {
-                            <p style="color: #6C6;">@membership.Sender joined the room @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reason: {content.Reason})")</p>
-                        }
+        var filteredMemberships = GetFilteredMemberships();
+    }
+    <table>
+        @foreach (var membershipEntry in filteredMemberships) {
+            var (transition, membership, previousMembership) = membershipEntry;
+            RoomMemberEventContent content = membership.TypedContent as RoomMemberEventContent ?? throw new InvalidOperationException("Event is not a RoomMemberEventContent!");
+            RoomMemberEventContent? previousContent = previousMembership?.TypedContent as RoomMemberEventContent;
+
+            <tr>
+                <td>@DateTimeOffset.FromUnixTimeMilliseconds(membership.OriginServerTs ?? 0).ToString("g")</td>
+                <td>
+                    @switch (transition) {
+                        case MembershipTransition.None:
+                            <b>Unknown membership! Got None</b>
+                            break;
+                        case MembershipTransition.Join:
+                            <p style="color: #6C6;">
+                                @membership.StateKey joined the room @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reason: {content.Reason})")<br/>
+                                Display name: @content.DisplayName<br/>
+                                Avatar URL: @content.AvatarUrl
+                            </p>
+                            break;
+                        case MembershipTransition.Leave:
+                            <p style="color: #C66;">
+                                @membership.StateKey left the room
+                            </p>
+                            break;
+                        case MembershipTransition.Knock:
+                            <p style="color: #426">
+                                @membership.StateKey knocked @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reason: {content.Reason})")
+                            </p>
+                            break;
+                        case MembershipTransition.Invite:
+                            <p style="color: #262;">
+                                @membership.Sender invited @membership.StateKey @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reason: {content.Reason})")
+                            </p>
+                            break;
+                        case MembershipTransition.Ban:
+                            <p style="color: red;">
+                                @membership.Sender banned @membership.StateKey @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reason: {content.Reason})")
+                            </p>
+                            break;
+                        @* disambiguated *@
+                        case MembershipTransition.Kick:
+                            <p style="color: darkorange;">
+                                @membership.Sender kicked @membership.StateKey @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reason: {content.Reason})")
+                            </p>
+                            break;
+                        case MembershipTransition.ProfileUpdate:
+                            <p style="color: #777;">
+                                @membership.Sender changed their profile<br/>
+                                Display name: @previousContent!.DisplayName -> @content.DisplayName<br/>
+                                Avatar URL: @previousContent.AvatarUrl -> @content.AvatarUrl
+                            </p>
+                            break;
+                        case MembershipTransition.InviteAccepted:
+                            <p style="color: #084;">
+                                @membership.StateKey accepted the invite
+                                from @previousMembership!.Sender @(string.IsNullOrWhiteSpace(previousContent?.Reason) ? "" : $"(invite reason: {previousContent.Reason})") @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(accept reason: {content.Reason})")
+                            </p>
+                            break;
+                        case MembershipTransition.KnockAccepted:
+                            <p style="color: #288;">
+                                @membership.StateKey's knock was accepted
+                                by @previousMembership!.Sender @(string.IsNullOrWhiteSpace(previousContent?.Reason) ? "" : $"(knock reason: {previousContent.Reason})") @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(accept reason: {content.Reason})")
+                            </p>
+                            break;
+                        case MembershipTransition.KnockRejected:
+                            <p style="color: #828;">
+                                @membership.StateKey's knock was rejected
+                                by @previousMembership!.Sender @(string.IsNullOrWhiteSpace(previousContent?.Reason) ? "" : $"(knock reason: {previousContent.Reason})") @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reject reason: {content.Reason})")
+                            </p>
+                            break;
+                        case MembershipTransition.Unban:
+                            <p style="color: #0C0;">
+                                @membership.Sender unbanned @membership.StateKey @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reason: {content.Reason})")
+                            </p>
+                            break;
+                        case MembershipTransition.InviteRejected:
+                            <p style="color: #733;">
+                                @membership.StateKey rejected the invite
+                                from @previousMembership!.Sender @(string.IsNullOrWhiteSpace(previousContent?.Reason) ? "" : $"(invite reason: {previousContent.Reason})") @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reject reason: {content.Reason})")
+                            </p>
+                            break;
+                        case MembershipTransition.InviteRetracted:
+                            <p style="color: #844;">
+                                @membership.Sender retracted the invite
+                                for @membership.StateKey @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reason: {content.Reason})")
+                            </p>
+                            break;
+                        case MembershipTransition.KnockRetracted:
+                            <p style="color: #b55;">
+                                @membership.Sender retracted the knock
+                                for @membership.StateKey @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reason: {content.Reason})")
+                            </p>
+                            break;
+                        default:
+                            throw new ArgumentOutOfRangeException();
                     }
-
-                    break;
-                }
-                default: {
-                    <b>Unknown membership @content.Membership!</b>
-                    break;
-                }
-            }
-
-            previousMemberships[membership.StateKey] = membership;
+                </td>
+            </tr>
         }
-    }
+    </table>
 </details>
 
 <br/>
 <details open>
     <summary>Log</summary>
-    @foreach (var line in log.Reverse()) {
+    @foreach (var line in Log.Reverse()) {
         <pre>@line</pre>
     }
 </details>
@@ -138,139 +295,280 @@
 
 #region Filter bindings
 
-    private bool _chronologicalOrder = false;
-
-    private bool ChronologicalOrder {
-        get => _chronologicalOrder;
-        set {
-            _chronologicalOrder = value;
-            StateHasChanged();
-        }
-    }
-
-    private bool _showJoins = true;
-
-    private bool ShowJoins {
-        get => _showJoins;
-        set {
-            _showJoins = value;
-            StateHasChanged();
-        }
-    }
-
-    private bool _showLeaves = true;
-
-    private bool ShowLeaves {
-        get => _showLeaves;
-        set {
-            _showLeaves = value;
-            StateHasChanged();
-        }
-    }
-
-    private bool _showUpdates = true;
-
-    private bool ShowUpdates {
-        get => _showUpdates;
-        set {
-            _showUpdates = value;
-            StateHasChanged();
-        }
-    }
-
-    private bool _showKnocks = true;
-
-    private bool ShowKnocks {
-        get => _showKnocks;
-        set {
-            _showKnocks = value;
-            StateHasChanged();
-        }
-    }
-
-    private bool _showInvites = true;
-
-    private bool ShowInvites {
-        get => _showInvites;
-        set {
-            _showInvites = value;
-            StateHasChanged();
-        }
-    }
+    private bool ChronologicalOrder { get; set; }
+    private bool ShowJoins { get; set; } = true;
+    private bool ShowLeaves { get; set; } = true;
+    private bool ShowKnocks { get; set; } = true;
+    private bool ShowInvites { get; set; } = true;
+    private bool ShowBans { get; set; } = true;
+
+    private bool DoDisambiguate { get; set; } = true;
+    private bool DisambiguateProfileUpdates { get => field && DoDisambiguate; set; } = true;
+    private bool DisambiguateKicks { get => field && DoDisambiguate; set; } = true;
+    private bool DisambiguateUnbans { get => field && DoDisambiguate; set; } = true;
+    private bool DisambiguateInviteAccepted { get => field && DoDisambiguate && DisambiguateInviteActions; set; } = true;
+    private bool DisambiguateInviteRejected { get => field && DoDisambiguate && DisambiguateInviteActions; set; } = true;
+    private bool DisambiguateInviteRetracted { get => field && DoDisambiguate && DisambiguateInviteActions; set; } = true;
+    private bool DisambiguateKnockAccepted { get => field && DoDisambiguate && DisambiguateKnockActions; set; } = true;
+    private bool DisambiguateKnockRejected { get => field && DoDisambiguate && DisambiguateKnockActions; set; } = true;
+    private bool DisambiguateKnockRetracted { get => field && DoDisambiguate && DisambiguateKnockActions; set; } = true;
+    
+    private bool DisambiguateKnockActions { get => field && DoDisambiguate; set; } = true;
+    private bool DisambiguateInviteActions { get => field && DoDisambiguate; set; } = true;
 
-    private bool _showKicks = true;
+    private bool ShowProfileUpdates {
+        get => field && DisambiguateProfileUpdates;
+        set;
+    } = true;
 
     private bool ShowKicks {
-        get => _showKicks;
-        set {
-            _showKicks = value;
-            StateHasChanged();
-        }
-    }
-
-    private bool _showBans = true;
-
-    private bool ShowBans {
-        get => _showBans;
-        set {
-            _showBans = value;
-            StateHasChanged();
-        }
-    }
-    
-    private string sender = "";
-    
-    private string Sender {
-        get => sender;
-        set {
-            sender = value;
-            StateHasChanged();
-        }
-    }
-    
-    private string user = "";
-    
-    private string User {
-        get => user;
+        get => field && DisambiguateKicks;
+        set;
+    } = true;
+
+    private bool ShowUnbans {
+        get => field && DisambiguateUnbans;
+        set;
+    } = true;
+
+    private bool ShowInviteAccepted {
+        get => field && DisambiguateInviteAccepted;
+        set;
+    } = true;
+
+    private bool ShowInviteRejected {
+        get => field && DisambiguateInviteRejected;
+        set;
+    } = true;
+
+    private bool ShowInviteRetracted {
+        get => field && DisambiguateInviteRetracted;
+        set;
+    } = true;
+
+    private bool ShowKnockAccepted {
+        get => field && DisambiguateKnockAccepted;
+        set;
+    } = true;
+
+    private bool ShowKnockRejected {
+        get => field && DisambiguateKnockRejected;
+        set;
+    } = true;
+
+    private bool ShowKnockRetracted {
+        get => field && DisambiguateKnockRetracted;
+        set;
+    } = true;
+
+    private bool ShowKnockActions {
+        get => field && DisambiguateKnockActions;
+        set;
+    } = true;
+
+    private bool ShowInviteActions {
+        get => field && DisambiguateInviteActions;
+        set;
+    } = true;
+
+    [Parameter, SupplyParameterFromQuery(Name = "sender")]
+    public string Sender { get; set; } = "";
+
+    [Parameter, SupplyParameterFromQuery(Name = "user")]
+    public string User { get; set; } = "";
+
+    [Parameter, SupplyParameterFromQuery(Name = "filter")]
+    public string Filter {
+        get;
         set {
-            user = value;
+            field = value;
+            if (string.IsNullOrWhiteSpace(value)) return;
+            var parts = value.Split(',');
+            ShowJoins = parts.Contains("join");
+            ShowLeaves = parts.Contains("leave");
+            ShowKnocks = parts.Contains("knock");
+            ShowInvites = parts.Contains("invite");
+            ShowBans = parts.Contains("ban");
             StateHasChanged();
         }
-    }
+    } = "";
 
 #endregion
 
-    private ObservableCollection<string> log { get; set; } = new();
+    private ObservableCollection<string> Log { get; set; } = new();
     private List<StateEventResponse> Memberships { get; set; } = [];
-    private AuthenticatedHomeserverGeneric hs { get; set; }
+    private AuthenticatedHomeserverGeneric Homeserver { get; set; } = null!;
 
     [Parameter, SupplyParameterFromQuery(Name = "room")]
-    public string roomId { get; set; }
+    public string RoomId { get; set; } = "";
 
     protected override async Task OnInitializedAsync() {
-        log.CollectionChanged += (sender, args) => StateHasChanged();
-        hs = await RMUStorage.GetCurrentSessionOrNavigate();
-        if (hs is null) return;
+        Log.CollectionChanged += (sender, args) => StateHasChanged();
+        Homeserver = await RmuStorage.GetCurrentSessionOrNavigate();
+        if (Homeserver is null) return;
 
         StateHasChanged();
         Console.WriteLine("Rerendered!");
         await base.OnInitializedAsync();
-        if (!string.IsNullOrWhiteSpace(roomId))
+        if (!string.IsNullOrWhiteSpace(RoomId))
             await Execute();
     }
 
     private async Task Execute() {
         Memberships.Clear();
-        var room = hs.GetRoom(roomId);
+        var room = Homeserver.GetRoom(RoomId);
         var events = room.GetManyMessagesAsync(limit: int.MaxValue, chunkSize: 5000);
         await foreach (var resp in events) {
             var all = resp.State.Concat(resp.Chunk);
             Memberships.AddRange(all.Where(x => x.Type == RoomMemberEventContent.EventId));
 
-            log.Add($"{resp.State.Count} state, {resp.Chunk.Count} timeline");
+            Log.Add($"Got {resp.State.Count} state and {resp.Chunk.Count} timeline events.");
         }
 
+        Log.Add("Reached end of timeline!");
+
         StateHasChanged();
     }
 
+    private readonly struct MembershipEntry {
+        public required MembershipTransition State { get; init; }
+        public required StateEventResponse Event { get; init; }
+        public required StateEventResponse? Previous { get; init; }
+
+        public void Deconstruct(out MembershipTransition transition, out StateEventResponse evt, out StateEventResponse? prev) {
+            transition = State;
+            evt = Event;
+            prev = Previous;
+        }
+    }
+
+    private enum MembershipTransition : byte {
+        None,
+        Join,
+        Leave,
+        Knock,
+        Invite,
+        Ban,
+
+        // disambiguated
+        ProfileUpdate,
+        Kick,
+        Unban,
+        InviteAccepted,
+        InviteRejected,
+        InviteRetracted,
+        KnockAccepted,
+        KnockRejected,
+        KnockRetracted
+    }
+
+    private static IEnumerable<MembershipEntry> GetTransitions(List<StateEventResponse> evts) {
+        Dictionary<string, MembershipEntry> transitions = new();
+        foreach (var evt in evts.OrderBy(x => x.OriginServerTs)) {
+            var content = evt.TypedContent as RoomMemberEventContent ?? throw new InvalidOperationException("Event is not a RoomMemberEventContent!");
+            var prev = transitions.GetValueOrDefault(evt.StateKey!) as MembershipEntry?;
+            transitions[evt.StateKey ?? throw new Exception("Member event has no state key??")] = new MembershipEntry {
+                Event = evt,
+                Previous = prev?.Event,
+                State = content.Membership switch {
+                    RoomMemberEventContent.MembershipTypes.Join =>
+                        prev?.State switch {
+                            MembershipTransition.Join or MembershipTransition.InviteAccepted => MembershipTransition.ProfileUpdate,
+                            MembershipTransition.Invite => MembershipTransition.InviteAccepted,
+                            _ => MembershipTransition.Join
+                        },
+                    RoomMemberEventContent.MembershipTypes.Leave =>
+                        evt.Sender == evt.StateKey
+                            ? prev?.State switch {
+                                MembershipTransition.Knock => MembershipTransition.KnockRetracted,
+                                MembershipTransition.Invite => MembershipTransition.InviteRejected,
+                                _ => MembershipTransition.Leave
+                            }
+                            : prev?.State switch {
+                                // not self
+                                MembershipTransition.Knock => MembershipTransition.KnockRejected,
+                                MembershipTransition.Invite => MembershipTransition.InviteRetracted,
+                                _ => MembershipTransition.Kick,
+                            },
+                    RoomMemberEventContent.MembershipTypes.Invite =>
+                        prev?.State switch {
+                            MembershipTransition.Knock => MembershipTransition.KnockAccepted,
+                            _ => MembershipTransition.Invite
+                        },
+                    RoomMemberEventContent.MembershipTypes.Knock => MembershipTransition.Knock,
+                    RoomMemberEventContent.MembershipTypes.Ban => MembershipTransition.Ban,
+                    _ => MembershipTransition.None
+                }
+            };
+            yield return transitions[evt.StateKey];
+        }
+    }
+
+    private IEnumerable<MembershipEntry> Disambiguated(IEnumerable<MembershipEntry> entries) {
+        FrozenDictionary<MembershipTransition, MembershipTransition> disambiguated = new Dictionary<MembershipTransition, MembershipTransition>() {
+            { MembershipTransition.ProfileUpdate, MembershipTransition.Join },
+            { MembershipTransition.Kick, MembershipTransition.Leave },
+            { MembershipTransition.Unban, MembershipTransition.Leave },
+            { MembershipTransition.InviteAccepted, MembershipTransition.Join },
+            { MembershipTransition.InviteRejected, MembershipTransition.Leave },
+            { MembershipTransition.InviteRetracted, MembershipTransition.Leave },
+            { MembershipTransition.KnockAccepted, MembershipTransition.Invite },
+            { MembershipTransition.KnockRejected, MembershipTransition.Leave },
+            { MembershipTransition.KnockRetracted, MembershipTransition.Leave }
+        }.ToFrozenDictionary();
+        
+        foreach (var entry in entries) {
+            if (!DoDisambiguate) {
+                yield return entry;
+                continue;
+            }
+
+            var newState = entry.State switch {
+                MembershipTransition.ProfileUpdate when !DoDisambiguate || !DisambiguateProfileUpdates => MembershipTransition.Join,
+                MembershipTransition.Kick when !DoDisambiguate || !DisambiguateKicks => MembershipTransition.Leave,
+                MembershipTransition.Unban when !DoDisambiguate || !DisambiguateUnbans => MembershipTransition.Leave,
+                MembershipTransition.InviteAccepted when !DoDisambiguate || !DisambiguateInviteActions || !DisambiguateInviteAccepted => MembershipTransition.Join,
+                MembershipTransition.InviteRejected when !DoDisambiguate || !DisambiguateInviteActions || !DisambiguateInviteRejected => MembershipTransition.Leave,
+                MembershipTransition.InviteRetracted when !DoDisambiguate || !DisambiguateInviteActions || !DisambiguateInviteRetracted => MembershipTransition.Leave,
+                MembershipTransition.KnockAccepted when !DoDisambiguate || !DisambiguateKnockActions || !DisambiguateKnockAccepted => MembershipTransition.Invite,
+                MembershipTransition.KnockRejected when !DoDisambiguate || !DisambiguateKnockActions || !DisambiguateKnockRejected => MembershipTransition.Leave,
+                MembershipTransition.KnockRetracted when !DoDisambiguate || !DisambiguateKnockActions || !DisambiguateKnockRetracted => MembershipTransition.Leave,
+                _ => entry.State
+            };
+            if (newState != entry.State) {
+                yield return entry with { State = newState };
+            }
+            else yield return entry;
+        }
+    }
+
+    private IEnumerable<MembershipEntry> GetFilteredMemberships() {
+        var filteredMemberships = GetTransitions(Memberships);
+        if (!string.IsNullOrWhiteSpace(Sender)) filteredMemberships = filteredMemberships.Where(x => x.Event.Sender == Sender);
+        if (!string.IsNullOrWhiteSpace(User)) filteredMemberships = filteredMemberships.Where(x => x.Event.StateKey == User);
+        filteredMemberships = Disambiguated(filteredMemberships);
+
+        if (!ShowJoins) filteredMemberships = filteredMemberships.Where(x => x.State != MembershipTransition.Join);
+        if (!ShowLeaves) filteredMemberships = filteredMemberships.Where(x => x.State != MembershipTransition.Leave);
+        if (!ShowKnocks) filteredMemberships = filteredMemberships.Where(x => x.State != MembershipTransition.Knock);
+        if (!ShowInvites) filteredMemberships = filteredMemberships.Where(x => x.State != MembershipTransition.Invite);
+        if (!ShowBans) filteredMemberships = filteredMemberships.Where(x => x.State != MembershipTransition.Ban);
+        // extended filters
+        if (DoDisambiguate) {
+            if (!DisambiguateProfileUpdates || !ShowProfileUpdates) filteredMemberships = filteredMemberships.Where(x => x.State != MembershipTransition.ProfileUpdate);
+            if (!DisambiguateKicks || !ShowKicks) filteredMemberships = filteredMemberships.Where(x => x.State != MembershipTransition.Kick);
+            if (!DisambiguateUnbans || !ShowUnbans) filteredMemberships = filteredMemberships.Where(x => x.State != MembershipTransition.Unban);
+            if (!DisambiguateInviteActions || !ShowInviteActions || !DisambiguateInviteAccepted || !ShowInviteAccepted) filteredMemberships = filteredMemberships.Where(x => x.State != MembershipTransition.InviteAccepted);
+            if (!DisambiguateInviteActions || !ShowInviteActions || !DisambiguateInviteRejected || !ShowInviteRejected) filteredMemberships = filteredMemberships.Where(x => x.State != MembershipTransition.InviteRejected);
+            if (!DisambiguateInviteActions || !ShowInviteActions || !DisambiguateInviteRetracted || !ShowInviteRetracted) filteredMemberships = filteredMemberships.Where(x => x.State != MembershipTransition.InviteRetracted);
+            if (!DisambiguateKnockActions || !ShowKnockActions || !DisambiguateKnockAccepted || !ShowKnockAccepted) filteredMemberships = filteredMemberships.Where(x => x.State != MembershipTransition.KnockAccepted);
+            if (!DisambiguateKnockActions || !ShowKnockActions || !DisambiguateKnockRejected || !ShowKnockRejected) filteredMemberships = filteredMemberships.Where(x => x.State != MembershipTransition.KnockRejected);
+            if (!DisambiguateKnockActions || !ShowKnockActions || !DisambiguateKnockRetracted || !ShowKnockRetracted) filteredMemberships = filteredMemberships.Where(x => x.State != MembershipTransition.KnockRetracted);
+        }
+
+        if (!ChronologicalOrder) filteredMemberships = filteredMemberships.Reverse();
+
+        return filteredMemberships;
+    }
+
 }
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Tools/Moderation/RoomIntersections.razor b/MatrixUtils.Web/Pages/Tools/Moderation/RoomIntersections.razor
index b8baeb8..15eea15 100644
--- a/MatrixUtils.Web/Pages/Tools/Moderation/RoomIntersections.razor
+++ b/MatrixUtils.Web/Pages/Tools/Moderation/RoomIntersections.razor
@@ -2,7 +2,7 @@
 @using LibMatrix.RoomTypes
 @using System.Collections.ObjectModel
 @using LibMatrix
-@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.EventTypes.Spec.State.RoomInfo
 <h3>Room intersections</h3>
 <hr/>
 
@@ -113,7 +113,7 @@
 
     protected override async Task OnInitializedAsync() {
         Log.CollectionChanged += (sender, args) => StateHasChanged();
-        hs = await RMUStorage.GetCurrentSessionOrNavigate();
+        hs = await RmuStorage.GetCurrentSessionOrNavigate();
         if (hs is null) return;
 
         StateHasChanged();
diff --git a/MatrixUtils.Web/Pages/Tools/Moderation/UserTrace.razor b/MatrixUtils.Web/Pages/Tools/Moderation/UserTrace.razor
index 915f8dc..0d622cc 100644
--- a/MatrixUtils.Web/Pages/Tools/Moderation/UserTrace.razor
+++ b/MatrixUtils.Web/Pages/Tools/Moderation/UserTrace.razor
@@ -3,13 +3,15 @@
 @using LibMatrix.RoomTypes
 @using System.Collections.ObjectModel
 @using LibMatrix
+@using LibMatrix.EventTypes.Spec.State.RoomInfo
 <h3>User Trace</h3>
 <hr/>
 
 <p>Users: </p>
 <InputTextArea @bind-Value="@UserIdString"></InputTextArea>
 <br/>
-<InputText @bind-Value="@ImportFromRoomId"></InputText><LinkButton OnClick="@DoImportFromRoomId">Import from room (ID)</LinkButton>
+<InputText @bind-Value="@ImportFromRoomId"></InputText>
+<LinkButton OnClick="@DoImportFromRoomId">Import from room (ID)</LinkButton>
 
 <details>
     <summary>Rooms to be searched (@rooms.Count)</summary>
@@ -24,18 +26,21 @@
 
 <details>
     <summary>Results</summary>
-    @foreach (var (userId, events) in matches) {
+    @foreach (var (userId, events) in matches.OrderBy(x=>x.Key)) {
         <h4>@userId</h4>
-        <ul>
-            @foreach (var match in events) {
-                <li>
-                    <ul>
-                        <li>@match.RoomName (<span>@match.Room.RoomId</span>)</li>
-                        <li>Membership: @(match.Event.RawContent.ToJson(indent: false))</li>
-                    </ul>
-                </li>
+        <table>
+            @foreach (var match in events.OrderBy(x=>x.RoomName)) {
+                <tr>
+                    <td>@match.RoomName (<span>@match.Room.RoomId</span>)</td>
+                    <td>
+                        <details>
+                            <summary>@SummarizeMembership(match.Event)</summary>
+                            <pre>@match.Event.RawContent.ToJson(indent: true)</pre>
+                        </details>
+                    </td>
+                </tr>
             }
-        </ul>
+        </table>
     }
 </details>
 
@@ -61,56 +66,34 @@
 
     protected override async Task OnInitializedAsync() {
         log.CollectionChanged += (sender, args) => StateHasChanged();
-        var hs = await RMUStorage.GetCurrentSessionOrNavigate();
+        var hs = await RmuStorage.GetCurrentSessionOrNavigate();
         if (hs is null) return;
-        // var sessions = await RMUStorage.GetAllTokens();
-        // var baseRooms = new List<GenericRoom>();
-        // foreach (var userAuth in sessions) {
-        //     var session = await RMUStorage.GetSession(userAuth);
-        //     if (session is not null) {
-        //         baseRooms.AddRange(await session.GetJoinedRooms());
-        //         var sessionRooms = (await session.GetJoinedRooms()).Where(x => !rooms.Any(y => y.Room.RoomId == x.RoomId)).ToList();
-        //         StateHasChanged();
-        //         log.Add($"Got {sessionRooms.Count} rooms for {userAuth.UserId}");
-        //     }
-        // }
-        //
-        // log.Add("Done fetching rooms!");
-        //
-        // baseRooms = baseRooms.DistinctBy(x => x.RoomId).ToList();
-        //
-        // // rooms.CollectionChanged += (sender, args) => StateHasChanged();
-        // var tasks = baseRooms.Select(async newRoom => {
-        //     bool success = false;
-        //     while (!success)
-        //         try {
-        //             var state = await newRoom.GetFullStateAsListAsync();
-        //             var newRoomInfo = new RoomInfo(newRoom, state);
-        //             rooms.Add(newRoomInfo);
-        //             log.Add($"Got {newRoomInfo.StateEvents.Count} events for {newRoomInfo.RoomName}");
-        //             success = true;
-        //         }
-        //         catch (MatrixException e) {
-        //             log.Add($"Failed to fetch room {newRoom.RoomId}! {e}");
-        //             throw;
-        //         }
-        //         catch (HttpRequestException e) {
-        //             log.Add($"Failed to fetch room {newRoom.RoomId}! {e}");
-        //         }
-        // });
-        // await Task.WhenAll(tasks);
-        //
-        // log.Add($"Done fetching members!");
-        //
-        // UserIDs.RemoveAll(x => sessions.Any(y => y.UserId == x));
-
-        foreach (var session in await RMUStorage.GetAllTokens()) {
-            var _hs = await RMUStorage.GetSession(session);
-            if (_hs is not null) {
-                rooms.AddRange(await _hs.GetJoinedRooms());
-                log.Add($"Got {rooms.Count} rooms after adding {_hs.UserId}");
+
+        var sessions = await RmuStorage.GetAllTokens();
+        var tasks = sessions.Select(async session => {
+            try {
+                var _hs = await RmuStorage.GetSession(session);
+                if (_hs is not null) {
+                    try {
+                        var _rooms = await _hs.GetJoinedRooms();
+                        if (!_rooms.Any()) return;
+                        // Check if homeserver supports `?format=event`:
+                        await _rooms.First().GetStateEventAsync(RoomMemberEventContent.EventId, session.UserId);
+                        rooms.AddRange(_rooms);
+                        log.Add($"Got {_rooms.Count} rooms for {_hs.UserId}, total {rooms.Count}");
+                    }
+                    catch (Exception e) {
+                        if (e is LibMatrixException { ErrorCode: LibMatrixException.ErrorCodes.M_UNSUPPORTED })
+                            log.Add($"Homeserver {_hs.UserId} does not support `?format=event`! Skipping...");
+                        else log.Add($"Failed to fetch rooms for {_hs.UserId}! {e}");
+                    }
+                }
             }
-        }
+            catch (Exception e) {
+                log.Add($"Failed to fetch rooms for {session.UserId}! {e}");
+            }
+        });
+        await Task.WhenAll(tasks);
 
         //get distinct rooms evenly distributed per session, accounting for count per session
         rooms = rooms.OrderBy(x => rooms.Count(y => y.Homeserver == x.Homeserver)).DistinctBy(x => x.RoomId).ToList();
@@ -125,17 +108,6 @@
         foreach (var userId in UserIDs) {
             matches.Add(userId, new List<Match>());
 
-            // foreach (var room in rooms) {
-            //     var state = room.StateEvents.Where(x => x!.Type == RoomMemberEventContent.EventId).ToList();
-            //     if (state!.Any(x => x.StateKey == userId)) {
-            //         matches[userId].Add(new() {
-            //             Event = state.First(x => x.StateKey == userId),
-            //             Room = room.Room,
-            //             RoomName = room.RoomName ?? "No name"
-            //         });
-            //     }
-            // }
-
             log.Add($"Searching for {userId}...");
             await foreach (var match in GetMatches(userId)) {
                 matches[userId].Add(match);
@@ -173,13 +145,19 @@
 
     private async IAsyncEnumerable<Match> GetMatches(string userId) {
         var results = rooms.Select(async room => {
-            var state = await room.GetStateEventOrNullAsync(room.RoomId, userId);
-            if (state is not null) {
-                return new Match {
-                    Room = room,
-                    Event = state,
-                    RoomName = await room.GetNameOrFallbackAsync()
-                };
+            try {
+                var state = await room.GetStateEventOrNullAsync(RoomMemberEventContent.EventId, userId);
+                if (state is not null) {
+                    log.Add($"Found {userId} in {room.RoomId} with membership {state.RawContent.ToJson(indent: false)}");
+                    return new Match {
+                        Room = room,
+                        Event = state,
+                        RoomName = await room.GetNameOrFallbackAsync()
+                    };
+                }
+            }
+            catch (Exception e) {
+                log.Add($"Failed to fetch state for {userId} in {room.RoomId}! {e}");
             }
 
             return null;
@@ -191,4 +169,24 @@
         }
     }
 
+    public string SummarizeMembership(StateEventResponse state) {
+        var membership = state.ContentAs<RoomMemberEventContent>();
+        var time = DateTimeOffset.FromUnixTimeMilliseconds(state.OriginServerTs!.Value);
+        return membership switch {
+            { Membership: "invite", Reason: null } => $"Invited by {state.Sender} at {time}",
+            { Membership: "invite", Reason: not null } => $"Invited by {state.Sender} at {time} for {membership.Reason}",
+            { Membership: "join", Reason: null } => $"Joined at {time}",
+            { Membership: "join", Reason: not null } => $"Joined at {time} for {membership.Reason}",
+            { Membership: "leave", Reason: null } => state.Sender == state.StateKey ? $"Left at {time}" : $"Kicked by {state.Sender} at {time}",
+            { Membership: "leave", Reason: not null } => state.Sender == state.StateKey ? $"Left at {time} with reason {membership.Reason}" : $"Kicked by {state.Sender} at {time} for {membership.Reason}",
+            { Membership: "ban", Reason: null } => $"Banned by {state.Sender} at {time}",
+            { Membership: "ban", Reason: not null } => $"Banned by {state.Sender} at {time} for {membership.Reason}",
+        };
+    }
+    
+    private async Task ExportJson() {
+        var json = matches.ToJson();
+        
+    }
+
 }
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Tools/Room/SpaceRestrictedJoins.razor b/MatrixUtils.Web/Pages/Tools/Room/SpaceRestrictedJoins.razor
index 80a03f2..b57810a 100644
--- a/MatrixUtils.Web/Pages/Tools/Room/SpaceRestrictedJoins.razor
+++ b/MatrixUtils.Web/Pages/Tools/Room/SpaceRestrictedJoins.razor
@@ -1,6 +1,6 @@
 @page "/Tools/Room/SpaceRestrictedJoins"
 @using System.Collections.ObjectModel
-@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.EventTypes.Spec.State.RoomInfo
 <h3>Allow space to restricted join children</h3>
 <hr/>
 
@@ -31,7 +31,7 @@
 
     protected override async Task OnInitializedAsync() {
         log.CollectionChanged += (sender, args) => StateHasChanged();
-        hs = await RMUStorage.GetCurrentSessionOrNavigate();
+        hs = await RmuStorage.GetCurrentSessionOrNavigate();
         if (hs is null) return;
 
         StateHasChanged();
diff --git a/MatrixUtils.Web/Pages/Tools/User/CopyPowerlevel.razor b/MatrixUtils.Web/Pages/Tools/User/CopyPowerlevel.razor
index 667b518..64dbfcf 100644
--- a/MatrixUtils.Web/Pages/Tools/User/CopyPowerlevel.razor
+++ b/MatrixUtils.Web/Pages/Tools/User/CopyPowerlevel.razor
@@ -1,7 +1,7 @@
 @page "/Tools/CopyPowerlevel"
 @using ArcaneLibs.Extensions
 @using LibMatrix
-@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.EventTypes.Spec.State.RoomInfo
 @using LibMatrix.RoomTypes
 <h3>Copy powerlevel</h3>
 <hr/>
@@ -23,11 +23,11 @@
     List<AuthenticatedHomeserverGeneric> hss { get; set; } = new();
 
     protected override async Task OnInitializedAsync() {
-        var hs = await RMUStorage.GetCurrentSessionOrNavigate();
+        var hs = await RmuStorage.GetCurrentSessionOrNavigate();
         if (hs is null) return;
-        var sessions = await RMUStorage.GetAllTokens();
+        var sessions = await RmuStorage.GetAllTokens();
         foreach (var userAuth in sessions) {
-            var session = await RMUStorage.GetSession(userAuth);
+            var session = await RmuStorage.GetSession(userAuth);
             if (session is not null) {
                 hss.Add(session);
                 StateHasChanged();
@@ -42,7 +42,7 @@
     private async Task Execute() {
         foreach (var hs in hss) {
             var rooms = await hs.GetJoinedRooms();
-            var tasks = rooms.Select(x=>Execute(hs, x)).ToAsyncEnumerable();
+            var tasks = rooms.Select(x=>ApplyPowerlevelsInRoom(hs, x)).ToAsyncEnumerable();
             await foreach (var a in tasks) {
                 if (!string.IsNullOrWhiteSpace(a)) {
                     log.Add(a);
@@ -52,7 +52,7 @@
         }
     }
 
-    private async Task<string> Execute(AuthenticatedHomeserverGeneric hs, GenericRoom room) {
+    private async Task<string> ApplyPowerlevelsInRoom(AuthenticatedHomeserverGeneric hs, GenericRoom room) {
         try {
             var pls = await room.GetPowerLevelsAsync();
             // if (pls.GetUserPowerLevel(hs.WhoAmI.UserId) == pls.UsersDefault) return "I am default PL in " + room.RoomId;
diff --git a/MatrixUtils.Web/Pages/Tools/User/MassJoinRoom.razor b/MatrixUtils.Web/Pages/Tools/User/MassJoinRoom.razor
index a2ad388..e352c91 100644
--- a/MatrixUtils.Web/Pages/Tools/User/MassJoinRoom.razor
+++ b/MatrixUtils.Web/Pages/Tools/User/MassJoinRoom.razor
@@ -1,7 +1,7 @@
 @page "/Tools/MassRoomJoin"
 @using ArcaneLibs.Extensions
 @using LibMatrix
-@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.EventTypes.Spec.State.RoomInfo
 <h3>Mass join room</h3>
 <hr/>
 <p>Room: </p>
@@ -25,11 +25,11 @@
     string roomId { get; set; }
 
     protected override async Task OnInitializedAsync() {
-        var hs = await RMUStorage.GetCurrentSessionOrNavigate();
+        var hs = await RmuStorage.GetCurrentSessionOrNavigate();
         if (hs is null) return;
-        var sessions = await RMUStorage.GetAllTokens();
+        var sessions = await RmuStorage.GetAllTokens();
         foreach (var userAuth in sessions) {
-            var session = await RMUStorage.GetSession(userAuth);
+            var session = await RmuStorage.GetSession(userAuth);
             if (session is not null) {
                 hss.Add(session);
                 StateHasChanged();
diff --git a/MatrixUtils.Web/Pages/Tools/User/ViewAccountData.razor b/MatrixUtils.Web/Pages/Tools/User/ViewAccountData.razor
index d8b02bb..b73b5ac 100644
--- a/MatrixUtils.Web/Pages/Tools/User/ViewAccountData.razor
+++ b/MatrixUtils.Web/Pages/Tools/User/ViewAccountData.razor
@@ -1,4 +1,4 @@
-@page "/Tools/ViewAccountData"
+@page "/Tools/User/ViewAccountData"
 @using ArcaneLibs.Extensions
 @using LibMatrix
 <h3>View account data</h3>
@@ -16,7 +16,7 @@
     Dictionary<string, EventList?> perRoomAccountData = new();
 
     protected override async Task OnInitializedAsync() {
-        var hs = await RMUStorage.GetCurrentSessionOrNavigate();
+        var hs = await RmuStorage.GetCurrentSessionOrNavigate();
         if (hs is null) return;
         perRoomAccountData = await hs.EnumerateAccountDataPerRoom();
         globalAccountData = await hs.EnumerateAccountData();
diff --git a/MatrixUtils.Web/Pages/User/DMManager.razor b/MatrixUtils.Web/Pages/User/DMManager.razor
index 80bf3b2..fe45eb8 100644
--- a/MatrixUtils.Web/Pages/User/DMManager.razor
+++ b/MatrixUtils.Web/Pages/User/DMManager.razor
@@ -1,8 +1,8 @@
 @page "/User/DirectMessages"
-@using LibMatrix.EventTypes.Spec.State
 @using LibMatrix.Responses
 @using MatrixUtils.Abstractions
 @using LibMatrix
+@using LibMatrix.EventTypes.Spec.State.RoomInfo
 <h3>Direct Messages</h3>
 <hr/>
 
@@ -29,7 +29,7 @@
     }
 
     protected override async Task OnInitializedAsync() {
-        Homeserver = await RMUStorage.GetCurrentSessionOrNavigate();
+        Homeserver = await RmuStorage.GetCurrentSessionOrNavigate();
         if (Homeserver is null) return;
         Status = "Loading global profile...";
         if (Homeserver.WhoAmI?.UserId is null) return;
diff --git a/MatrixUtils.Web/Pages/User/Profile.razor b/MatrixUtils.Web/Pages/User/Profile.razor
index 49af22f..d0af2c8 100644
--- a/MatrixUtils.Web/Pages/User/Profile.razor
+++ b/MatrixUtils.Web/Pages/User/Profile.razor
@@ -1,10 +1,8 @@
 @page "/User/Profile"
-@using LibMatrix.EventTypes.Spec.State
-@using ArcaneLibs.Extensions
 @using LibMatrix
+@using LibMatrix.EventTypes.Spec.State.RoomInfo
 @using LibMatrix.Responses
 @using MatrixUtils.Abstractions
-@using Microsoft.AspNetCore.Components.Forms
 <h3>Manage Profile - @Homeserver?.WhoAmI?.UserId</h3>
 <hr/>
 
@@ -12,7 +10,7 @@
     <h4>Profile</h4>
     <hr/>
     <div>
-        <img src="@Homeserver.ResolveMediaUri(NewProfile.AvatarUrl)" style="width: 96px; height: 96px; border-radius: 50%; object-fit: cover;"/>
+        <MxcAvatar MxcUri="@NewProfile.AvatarUrl" Circular="true" Size="96"/>
         <div style="display: inline-block; vertical-align: middle;">
             <span>Display name: </span><FancyTextBox @bind-Value="@NewProfile.DisplayName"></FancyTextBox><br/>
             <span>Avatar URL: </span><FancyTextBox @bind-Value="@NewProfile.AvatarUrl"></FancyTextBox>
@@ -35,12 +33,13 @@
             <summary style="@(room.OwnMembership?.DisplayName == OldProfile.DisplayName && room.OwnMembership?.AvatarUrl == OldProfile.AvatarUrl ? "" : "#ffff0033")">
                 <div style="display: inline-block; width: calc(100% - 50px); vertical-align: middle; margin-top: -8px; margin-bottom: -8px;">
                     <CascadingValue Value="OldProfile">
-                        <RoomListItem ShowOwnProfile="true" RoomInfo="@room" OwnMemberState="@room.OwnMembership"></RoomListItem>
+                        <RoomListItem Homeserver="Homeserver" ShowOwnProfile="true" RoomInfo="@room" OwnMemberState="@room.OwnMembership"></RoomListItem>
                     </CascadingValue>
                 </div>
             </summary>
             @if (room.OwnMembership is not null) {
-                <img src="@Homeserver.ResolveMediaUri(room.OwnMembership.AvatarUrl)" style="width: 96px; height: 96px; border-radius: 50%; object-fit: cover;"/>
+                @* <img src="@Homeserver.ResolveMediaUri(room.OwnMembership.AvatarUrl)" style="width: 96px; height: 96px; border-radius: 50%; object-fit: cover;"/> *@
+                <MxcAvatar MxcUri="@room.OwnMembership.AvatarUrl" Circular="true" Size="96"/>
                 <div style="display: inline-block; vertical-align: middle;">
                     <span>Display name: </span><FancyTextBox BackgroundColor="@(room.OwnMembership.DisplayName == OldProfile.DisplayName ? "" : "#ffff0033")" @bind-Value="@room.OwnMembership.DisplayName"></FancyTextBox><br/>
                     <span>Avatar URL: </span><FancyTextBox BackgroundColor="@(room.OwnMembership.AvatarUrl == OldProfile.AvatarUrl ? "" : "#ffff0033")" @bind-Value="@room.OwnMembership.AvatarUrl"></FancyTextBox>
@@ -58,29 +57,11 @@
         </details>
         <br/>
     }
-
-    @foreach (var (roomId, roomProfile) in RoomProfiles.OrderBy(x => RoomNames.TryGetValue(x.Key, out var _name) ? _name : x.Key)) {
-        <details class="details-compact">
-            <summary style="@(roomProfile.DisplayName == OldProfile.DisplayName && roomProfile.AvatarUrl == OldProfile.AvatarUrl ? "" : "#ffff0033")">@(RoomNames.TryGetValue(roomId, out var name) ? name : roomId)</summary>
-            <img src="@Homeserver.ResolveMediaUri(roomProfile.AvatarUrl)" style="width: 96px; height: 96px; border-radius: 50%; object-fit: cover;"/>
-            <div style="display: inline-block; vertical-align: middle;">
-                <span>Display name: </span><FancyTextBox BackgroundColor="@(roomProfile.DisplayName == OldProfile.DisplayName ? "" : "#ffff0033")" @bind-Value="@roomProfile.DisplayName"></FancyTextBox><br/>
-                <span>Avatar URL: </span><FancyTextBox BackgroundColor="@(roomProfile.AvatarUrl == OldProfile.AvatarUrl ? "" : "#ffff0033")" @bind-Value="@roomProfile.AvatarUrl"></FancyTextBox>
-                <InputFile OnChange="@(ifcea => RoomAvatarChanged(ifcea, roomId))"></InputFile><br/>
-                <LinkButton OnClick="@(() => UpdateRoomProfile(roomId))">Update profile</LinkButton>
-            </div>
-            <br/>
-            @if (!string.IsNullOrWhiteSpace(Status)) {
-                <p>@Status</p>
-            }
-        </details>
-        <br/>
-    }
     // </details>
 }
 
 @code {
-    private string? _status = null;
+    private string? _status;
 
     private AuthenticatedHomeserverGeneric? Homeserver { get; set; }
     private UserProfileResponse? NewProfile { get; set; }
@@ -99,7 +80,7 @@
     private Dictionary<string, string> RoomNames { get; set; } = new();
 
     protected override async Task OnInitializedAsync() {
-        Homeserver = await RMUStorage.GetCurrentSessionOrNavigate();
+        Homeserver = await RmuStorage.GetCurrentSessionOrNavigate();
         if (Homeserver is null) return;
         Status = "Loading global profile...";
         if (Homeserver.WhoAmI?.UserId is null) return;
@@ -107,44 +88,50 @@
         OldProfile = (await Homeserver.GetProfileAsync(Homeserver.WhoAmI.UserId)); //.DeepClone();
         Status = "Loading room profiles...";
         var roomProfiles = Homeserver.GetRoomProfilesAsync();
+        List<Task> roomInfoTasks = [];
         await foreach (var (roomId, roomProfile) in roomProfiles) {
-            var room = Homeserver.GetRoom(roomId);
-            var roomNameTask = room.GetNameOrFallbackAsync();
-            var roomIconTask = room.GetAvatarUrlAsync();
-            var roomInfo = new RoomInfo(room) {
-                OwnMembership = roomProfile
-            };
-            try {
-                roomInfo.RoomIcon = (await roomIconTask).Url;
-            }
-            catch (MatrixException e) {
-                if (e is not { ErrorCode: "M_NOT_FOUND" }) throw;
-            }
+            var task = Task.Run(async () => {
+                var room = Homeserver.GetRoom(roomId);
+                var roomNameTask = room.GetNameOrFallbackAsync();
+                var roomIconTask = room.GetAvatarUrlAsync();
+                var roomInfo = new RoomInfo(room) {
+                    OwnMembership = roomProfile
+                };
+                try {
+                    roomInfo.RoomIcon = (await roomIconTask).Url;
+                }
+                catch (MatrixException e) {
+                    if (e is not { ErrorCode: "M_NOT_FOUND" }) throw;
+                }
 
-            try {
-                roomInfo.RoomName = await roomNameTask;
-            }
-            catch (MatrixException e) {
-                if (e is not { ErrorCode: "M_NOT_FOUND" }) throw;
-            }
+                try {
+                    RoomNames[roomId] = roomInfo.RoomName = await roomNameTask;
+                }
+                catch (MatrixException e) {
+                    if (e is not { ErrorCode: "M_NOT_FOUND" }) throw;
+                }
 
-            Rooms.Add(roomInfo);
-            // Status = $"Got profile for {roomId}...";
-            RoomProfiles[roomId] = roomProfile; //.DeepClone();
+                Rooms.Add(roomInfo);
+                // Status = $"Got profile for {roomId}...";
+                RoomProfiles[roomId] = roomProfile; //.DeepClone();
+            });
+            roomInfoTasks.Add(task);
         }
+        
+        await Task.WhenAll(roomInfoTasks);
 
         StateHasChanged();
         Status = "Room profiles loaded, loading room names...";
 
-        var roomNameTasks = RoomProfiles.Keys.Select(x => Homeserver.GetRoom(x)).Select(async x => {
-            var name = await x.GetNameOrFallbackAsync();
-            return new KeyValuePair<string, string?>(x.RoomId, name);
-        }).ToAsyncEnumerable();
+        // var roomNameTasks = RoomProfiles.Keys.Select(x => Homeserver.GetRoom(x)).Select(async x => {
+        // var name = await x.GetNameOrFallbackAsync();
+        // return new KeyValuePair<string, string?>(x.RoomId, name);
+        // }).ToAsyncEnumerable();
 
-        await foreach (var (roomId, roomName) in roomNameTasks) {
-            // Status = $"Got room name for {roomId}: {roomName}";
-            RoomNames[roomId] = roomName;
-        }
+        // await foreach (var (roomId, roomName) in roomNameTasks) {
+        // Status = $"Got room name for {roomId}: {roomName}";
+        // RoomNames[roomId] = roomName;
+        // }
 
         StateHasChanged();
         Status = null;
diff --git a/MatrixUtils.Web/Program.cs b/MatrixUtils.Web/Program.cs
index 1b8960c..8bc2c8f 100644
--- a/MatrixUtils.Web/Program.cs
+++ b/MatrixUtils.Web/Program.cs
@@ -8,6 +8,8 @@ using MatrixUtils.Web;
 using MatrixUtils.Web.Classes;
 using Microsoft.AspNetCore.Components.Web;
 using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
+using SpawnDev.BlazorJS;
+using SpawnDev.BlazorJS.WebWorkers;
 
 var builder = WebAssemblyHostBuilder.CreateDefault(args);
 builder.RootComponents.Add<App>("#app");
@@ -16,6 +18,18 @@ builder.RootComponents.Add<HeadOutlet>("head::after");
 // builder.Logging.SetMinimumLevel(LogLevel.Trace);
 
 builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
+builder.Services.AddBlazorJSRuntime();
+builder.Services.AddWebWorkerService(webWorkerService =>
+{
+    // Optionally configure the WebWorkerService service before it is used
+    // Default WebWorkerService.TaskPool settings: PoolSize = 0, MaxPoolSize = 1, AutoGrow = true
+    // Below sets TaskPool max size to 2. By default the TaskPool size will grow as needed up to the max pool size.
+    // Setting max pool size to -1 will set it to the value of navigator.hardwareConcurrency
+    webWorkerService.TaskPool.MaxPoolSize = 2;
+    // Below is telling the WebWorkerService TaskPool to set the initial size to 2 if running in a Window scope and 0 otherwise
+    // This starts up 2 WebWorkers to handle TaskPool tasks as needed
+    webWorkerService.TaskPool.PoolSize = webWorkerService.GlobalScope == GlobalScope.Window ? 2 : 0;
+});
 
 try {
     builder.Configuration.AddJsonStream(await new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }.GetStreamAsync("/appsettings.json"));
@@ -65,4 +79,5 @@ builder.Services.AddScoped<TieredStorageService>(x =>
 
 builder.Services.AddRoryLibMatrixServices();
 builder.Services.AddScoped<RMUStorageWrapper>();
-await builder.Build().RunAsync();
\ No newline at end of file
+// await builder.Build().RunAsync();
+await builder.Build().BlazorJSRunAsync();
\ No newline at end of file
diff --git a/MatrixUtils.Web/Properties/launchSettings.json b/MatrixUtils.Web/Properties/launchSettings.json
index aa41dc8..660211d 100644
--- a/MatrixUtils.Web/Properties/launchSettings.json
+++ b/MatrixUtils.Web/Properties/launchSettings.json
@@ -13,7 +13,7 @@
       "dotnetRunMessages": true,
       "launchBrowser": false,
       "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
-      "applicationUrl": "http://localhost:5117",
+      "applicationUrl": "http://*:5117",
       "environmentVariables": {
         "ASPNETCORE_ENVIRONMENT": "Development"
       }
diff --git a/MatrixUtils.Web/Shared/InlineUserItem.razor b/MatrixUtils.Web/Shared/InlineUserItem.razor
index 9c6608a..50fa9e1 100644
--- a/MatrixUtils.Web/Shared/InlineUserItem.razor
+++ b/MatrixUtils.Web/Shared/InlineUserItem.razor
@@ -1,4 +1,4 @@
-@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.EventTypes.Spec.State.RoomInfo
 @using LibMatrix.Responses
 <div style="background-color: #ffffff11; border-radius: 0.5em; height: 1em; display: inline-block; vertical-align: middle;" alt="@UserId">
     <img style="@(ChildContent is not null ? "vertical-align: baseline;" : "vertical-align: top;") width: 1em; height: 1em; border-radius: 50%;" src="@ProfileAvatar"/>
@@ -39,7 +39,7 @@
 
     protected override async Task OnInitializedAsync() {
         await base.OnInitializedAsync();
-        Homeserver ??= await RMUStorage.GetCurrentSessionOrNavigate();
+        Homeserver ??= await RmuStorage.GetCurrentSessionOrNavigate();
         if(Homeserver is null) return;
 
         await _semaphoreSlim.WaitAsync();
@@ -59,7 +59,7 @@
         }
 
 
-        ProfileAvatar ??= Homeserver.ResolveMediaUri(User.AvatarUrl);
+        // ProfileAvatar ??= Homeserver.ResolveMediaUri(User.AvatarUrl);
         ProfileName ??= User.DisplayName;
 
         _semaphoreSlim.Release();
diff --git a/MatrixUtils.Web/Shared/MainLayout.razor b/MatrixUtils.Web/Shared/MainLayout.razor
index c67f73c..0392d9a 100644
--- a/MatrixUtils.Web/Shared/MainLayout.razor
+++ b/MatrixUtils.Web/Shared/MainLayout.razor
@@ -1,5 +1,4 @@
-@using ArcaneLibs
-@inherits LayoutComponentBase
+@inherits LayoutComponentBase
 
 <div class="page">
     <div class="sidebar">
@@ -10,7 +9,7 @@
         <div class="top-row px-4">
             @* <PortableDevTools/> *@
             @* <ResourceUsage/> *@
-            <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://cgit.rory.gay/matrix/tools/MatrixUtils.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>
 
diff --git a/MatrixUtils.Web/Shared/MainLayout.razor.css b/MatrixUtils.Web/Shared/MainLayout.razor.css
index 01a5066..924393b 100644
--- a/MatrixUtils.Web/Shared/MainLayout.razor.css
+++ b/MatrixUtils.Web/Shared/MainLayout.razor.css
@@ -57,6 +57,7 @@ main {
 
     .sidebar {
         width: 250px;
+        min-width: 250px;
         height: 100vh;
         position: sticky;
         top: 0;
diff --git a/MatrixUtils.Web/Shared/MxcAvatar.razor b/MatrixUtils.Web/Shared/MxcAvatar.razor
new file mode 100644
index 0000000..02aff72
--- /dev/null
+++ b/MatrixUtils.Web/Shared/MxcAvatar.razor
@@ -0,0 +1,55 @@
+<StreamedImage Stream="@_stream" style="@StyleString"/>
+
+@code {
+    private string _mxcUri;
+    private string _style;
+    private Stream _stream;
+    
+    [Parameter]
+    public string MxcUri {
+        get => _mxcUri ?? "";
+        set {
+            if(_mxcUri == value) return;
+            _mxcUri = value;
+            UriHasChanged(value);
+        }
+    }
+
+    [Parameter]
+    public bool Circular { get; set; }
+
+    [Parameter]
+    public int Size { get; set; } = 48;
+
+    [Parameter]
+    public string SizeUnit { get; set; } = "px";
+
+    [Parameter]
+    public AuthenticatedHomeserverGeneric? Homeserver { get; set; }
+    
+    private string StyleString => $"{(Circular ? "border-radius: 50%;" : "")} width: {Size}{SizeUnit}; height: {Size}{SizeUnit}; object-fit: cover;";
+
+    private static readonly string Prefix = "mxc://";
+    private static readonly int PrefixLength = Prefix.Length;
+
+    private async Task UriHasChanged(string value) {
+        if (!value.StartsWith(Prefix)) {
+            // Console.WriteLine($"UriHasChanged: {value} does not start with {Prefix}, passing as resolved URI!!!");
+            // ResolvedUri = value;
+            return;
+        }
+
+        if (Homeserver is null) {
+            Console.WriteLine("Homeserver is required for MxcAvatar");
+            return;
+        }
+
+        var uri = value[PrefixLength..].Split('/');
+        // Console.WriteLine($"UriHasChanged: {value} {uri[0]}");
+        var url = $"/_matrix/media/v3/download/{uri[0]}/{uri[1]}";
+        Console.WriteLine($"ResolvedUri: {url}");
+        _stream = await Homeserver.ClientHttpClient.GetStreamAsync(url);
+        StateHasChanged();
+    }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Shared/MxcImage.razor b/MatrixUtils.Web/Shared/MxcImage.razor
index e651c3f..e7cb2e0 100644
--- a/MatrixUtils.Web/Shared/MxcImage.razor
+++ b/MatrixUtils.Web/Shared/MxcImage.razor
@@ -31,7 +31,7 @@
         }
     }
     
-    [Parameter]
+    [CascadingParameter, Parameter]
     public RemoteHomeserver? Homeserver { get; set; }
 
     private string ResolvedUri {
@@ -48,19 +48,19 @@
     private static readonly int PrefixLength = Prefix.Length;
 
     private async Task UriHasChanged(string value) {
-        if (!value.StartsWith(Prefix)) {
-            Console.WriteLine($"UriHasChanged: {value} does not start with {Prefix}, passing as resolved URI!!!");
-            ResolvedUri = value;
-            return;
-        }
-        var uri = value[PrefixLength..].Split('/');
-        Console.WriteLine($"UriHasChanged: {value} {uri[0]}");
-        if (Homeserver is null) {
-            Console.WriteLine($"Homeserver is null, creating new remotehomeserver for {uri[0]}");
-            Homeserver = await hsProvider.GetRemoteHomeserver(uri[0]);
-        }
-        ResolvedUri = Homeserver.ResolveMediaUri(value);
-        Console.WriteLine($"ResolvedUri: {ResolvedUri}");
+        // if (!value.StartsWith(Prefix)) {
+        //     Console.WriteLine($"UriHasChanged: {value} does not start with {Prefix}, passing as resolved URI!!!");
+        //     ResolvedUri = value;
+        //     return;
+        // }
+        // var uri = value[PrefixLength..].Split('/');
+        // Console.WriteLine($"UriHasChanged: {value} {uri[0]}");
+        // if (Homeserver is null) {
+        //     Console.WriteLine($"Homeserver is null, creating new remotehomeserver for {uri[0]}");
+        //     Homeserver = await hsProvider.GetRemoteHomeserver(uri[0]);
+        // }
+        // ResolvedUri = Homeserver.ResolveMediaUri(value);
+        // Console.WriteLine($"ResolvedUri: {ResolvedUri}");
     }
 
     // [Parameter]
diff --git a/MatrixUtils.Web/Shared/NavMenu.razor b/MatrixUtils.Web/Shared/NavMenu.razor
index 770a246..7371e66 100644
--- a/MatrixUtils.Web/Shared/NavMenu.razor
+++ b/MatrixUtils.Web/Shared/NavMenu.razor
@@ -37,6 +37,12 @@
         </div>
 
         <div class="nav-item px-3">
+            <NavLink class="nav-link" href="PolicyLists">
+                <span class="oi oi-ban" aria-hidden="true"></span> Manage policy lists
+            </NavLink>
+        </div>
+
+        <div class="nav-item px-3">
             <NavLink class="nav-link" href="User/Profile">
                 <span class="oi oi-person" aria-hidden="true"></span> Manage profile
             </NavLink>
diff --git a/MatrixUtils.Web/Shared/PolicyEditorComponents/MassPolicyEditorModal.razor b/MatrixUtils.Web/Shared/PolicyEditorComponents/MassPolicyEditorModal.razor
new file mode 100644
index 0000000..11ba18a
--- /dev/null
+++ b/MatrixUtils.Web/Shared/PolicyEditorComponents/MassPolicyEditorModal.razor
@@ -0,0 +1,102 @@
+@using LibMatrix.EventTypes.Spec.State.Policy
+@using System.Reflection
+@using ArcaneLibs.Attributes
+@using LibMatrix
+@using System.Collections.Frozen
+@using LibMatrix.EventTypes
+@using LibMatrix.RoomTypes
+<ModalWindow Title="@("Creating many new " + (PolicyTypes.ContainsKey(MappedType??"") ? PolicyTypes[MappedType!].GetFriendlyNamePluralOrNull()?.ToLower() ?? PolicyTypes[MappedType!].Name : "event"))"
+             OnCloseClicked="@OnClose" X="60" Y="60" MinWidth="600">
+    <span>Policy type:</span>
+    <select @bind="@MappedType">
+        <option>Select a value</option>
+        @foreach (var (type, mappedType) in PolicyTypes) {
+            <option value="@type">@mappedType.GetFriendlyName().ToLower()</option>
+        }
+    </select><br/>
+    
+    <span>Reason:</span>
+    <FancyTextBox @bind-Value="@Reason"></FancyTextBox><br/>
+    
+    <span>Recommendation:</span>
+    <FancyTextBox @bind-Value="@Recommendation"></FancyTextBox><br/>
+
+    <span>Entities:</span><br/>
+    <InputTextArea @bind-Value="@Users" style="width: 500px;"></InputTextArea><br/>
+    
+    
+    @* <details> *@
+    @*     <summary>JSON data</summary> *@
+    @*     <pre> *@
+    @*             $1$ @PolicyEvent.ToJson(true, true) #1# *@
+    @*     </pre> *@
+    @* </details> *@
+    <LinkButton OnClick="@(() => { OnClose.Invoke(); return Task.CompletedTask; })"> Cancel </LinkButton>
+    <LinkButton OnClick="@(() => { _ = Save(); return Task.CompletedTask; })"> Save </LinkButton>
+
+</ModalWindow>
+
+@code {
+
+    [Parameter]
+    public required Action OnClose { get; set; }
+
+    [Parameter]
+    public required Action OnSaved { get; set; }
+
+    [Parameter]
+    public required GenericRoom Room { get; set; }
+
+    public string Recommendation { get; set; } = "m.ban";
+    public string Reason { get; set; } = "spam";
+    public string Users { get; set; } = "";
+
+    private static FrozenSet<Type> KnownPolicyTypes = StateEvent.KnownStateEventTypes.Where(x => x.IsAssignableTo(typeof(PolicyRuleEventContent))).ToFrozenSet();
+
+    private static Dictionary<string, Type> PolicyTypes = KnownPolicyTypes
+        .ToDictionary(x => x.GetCustomAttributes<MatrixEventAttribute>().First(y => !string.IsNullOrWhiteSpace(y.EventName)).EventName, x => x);
+
+    private string? MappedType { get; set; }
+
+    private async Task Save() {
+        try {
+            await DoActualSave();
+        }
+        catch (Exception e) {
+            Console.WriteLine($"Failed to save: {e}");
+        }
+    }
+
+    private async Task DoActualSave() {
+        Console.WriteLine($"Saving ---");
+        Console.WriteLine($"Users = {Users}");
+        var users = Users.Split("\n").Select(x => x.Trim()).Where(x => x.StartsWith('@')).ToList();
+        var tasks = users.Select(x => ExecuteBan(Room, x)).ToList();
+        await Task.WhenAll(tasks);
+        
+        OnSaved.Invoke();
+    }
+
+    private async Task ExecuteBan(GenericRoom room, string entity) {
+        bool success = false;
+        while (!success) {
+            try {
+                var content = Activator.CreateInstance(PolicyTypes[MappedType!]) as PolicyRuleEventContent;
+                content.Recommendation = Recommendation;
+                content.Reason = Reason;
+                content.Entity = entity;
+                await room.SendStateEventAsync(MappedType!, content.GetDraupnir2StateKey(), content);
+                success = true;
+            }
+            catch (MatrixException e) {
+                if (e is not { ErrorCode: MatrixException.ErrorCodes.M_FORBIDDEN }) throw;
+                Console.WriteLine(e);
+            }
+            catch (Exception e) {
+                //ignored
+                Console.WriteLine(e);
+            }
+        }
+    }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Shared/PolicyEditorComponents/PolicyEditorModal.razor b/MatrixUtils.Web/Shared/PolicyEditorComponents/PolicyEditorModal.razor
index 1bd00d1..a1d870c 100644
--- a/MatrixUtils.Web/Shared/PolicyEditorComponents/PolicyEditorModal.razor
+++ b/MatrixUtils.Web/Shared/PolicyEditorComponents/PolicyEditorModal.razor
@@ -35,39 +35,61 @@
             </thead>
             <tbody>
                 @foreach (var prop in props) {
+                    var isNullable = Nullable.GetUnderlyingType(prop.PropertyType) is not null;
                     <tr>
                         <td style="padding-right: 8px;">
                             <span>@prop.GetFriendlyName()</span>
-                            @if (Nullable.GetUnderlyingType(prop.PropertyType) is not null) {
+                            @if (Nullable.GetUnderlyingType(prop.PropertyType) is null) {
                                 <span style="color: red;">*</span>
                             }
                         </td>
                         @{
                             var getter = prop.GetGetMethod();
                             var setter = prop.GetSetMethod();
-                        }
-                        @switch (Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType) {
-                            case Type t when t == typeof(string):
-                                <FancyTextBox Value="@(getter?.Invoke(PolicyData, null) as string)" ValueChanged="@(e => { Console.WriteLine($"{prop.Name} ({setter is not null}) -> {e}"); setter?.Invoke(PolicyData, [e]); PolicyEvent.TypedContent = PolicyData; StateHasChanged(); })"></FancyTextBox>
-                                break;
-                            default:
-                                <p style="color: red;">Unsupported type: @prop.PropertyType</p>
-                                break;
+                            if (getter is null) {
+                                <p style="color: red;">Missing property getter: @prop.Name</p>
+                            }
+                            else {
+                                switch (Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType) {
+                                    case Type t when t == typeof(string):
+                                        <FancyTextBox Value="@(getter?.Invoke(PolicyData, null) as string)" ValueChanged="@((string e) => { Console.WriteLine($"{prop.Name} ({setter is not null}) -> {e}"); setter?.Invoke(PolicyData, [e]); PolicyEvent.TypedContent = PolicyData; StateHasChanged(); })"></FancyTextBox>
+                                        break;
+                                    case Type t when t == typeof(DateTime):
+                                        if (!isNullable) {
+                                            <InputDate TValue="DateTime" Value="@(getter?.Invoke(PolicyData, null) as DateTime? ?? new DateTime())" ValueChanged="@(e => { Console.WriteLine($"{prop.Name} ({setter is not null}) -> {e}"); setter?.Invoke(PolicyData, [e]); PolicyEvent.TypedContent = PolicyData; StateHasChanged(); })"></InputDate>
+                                        }
+                                        else {
+                                            var value = getter?.Invoke(PolicyData, null) as DateTime?;
+                                            if (value is null) {
+                                                <button @onclick="() => { setter?.Invoke(PolicyData, [DateTime.Now]); PolicyEvent.TypedContent = PolicyData; StateHasChanged(); }">Add value</button>
+                                            }
+                                            else {
+                                                var notNullValue = Nullable.GetValueRefOrDefaultRef(ref value);
+                                                Console.WriteLine($"Value: {value?.ToString() ?? "null"}");
+                                                <InputDate TValue="DateTime" ValueExpression="@(() => notNullValue)" ValueChanged="@(e => { Console.WriteLine($"{prop.Name} ({setter is not null}) -> {e}"); setter?.Invoke(PolicyData, [e]); PolicyEvent.TypedContent = PolicyData; StateHasChanged(); })"></InputDate>
+                                                <button @onclick="() => { setter?.Invoke(PolicyData, [null]); PolicyEvent.TypedContent = PolicyData; StateHasChanged(); }">Remove value</button>
+                                            }
+                                        }
+
+                                        break;
+                                    default:
+                                        <p style="color: red;">Unsupported type: @prop.PropertyType</p>
+                                        break;
+                                }
+                            }
                         }
                     </tr>
                 }
             </tbody>
         </table>
-        <br/>
-        <pre>
-            @PolicyEvent.ToJson(true, false)
-        </pre>
+        <details>
+            <summary>JSON data</summary>
+            <pre>
+                @PolicyEvent.ToJson(true, true)
+            </pre>
+        </details>
         <LinkButton OnClick="@(() => { OnClose.Invoke(); return Task.CompletedTask; })"> Cancel </LinkButton>
         <LinkButton OnClick="@(() => { OnSave.Invoke(PolicyEvent); return Task.CompletedTask; })"> Save </LinkButton>
-        @* <span>Target entity: </span> *@
-        @* <FancyTextBox @bind-Value="@policyData.Entity"></FancyTextBox><br/> *@
-        @* <span>Reason: </span> *@
-        @* <FancyTextBox @bind-Value="@policyData.Reason"></FancyTextBox> *@
     }
     else {
         <p>Policy data is null</p>
@@ -102,7 +124,7 @@
         .ToDictionary(x => x.GetCustomAttributes<MatrixEventAttribute>().First(y => !string.IsNullOrWhiteSpace(y.EventName)).EventName, x => x);
 
     private StateEventResponse? _policyEvent;
-    
+
     private string? MappedType {
         get => _policyEvent?.Type;
         set {
@@ -110,9 +132,9 @@
                 PolicyEvent.Type = value;
                 PolicyEvent.TypedContent ??= Activator.CreateInstance(PolicyTypes[value]) as PolicyRuleEventContent;
                 PolicyData = PolicyEvent.TypedContent as PolicyRuleEventContent;
+                PolicyData.Recommendation ??= "m.ban";
             }
         }
     }
-    
 
 }
\ No newline at end of file
diff --git a/MatrixUtils.Web/Shared/RoomListComponents/RoomListCategory.razor b/MatrixUtils.Web/Shared/RoomListComponents/RoomListCategory.razor
index 1f5ce89..555b1f1 100644
--- a/MatrixUtils.Web/Shared/RoomListComponents/RoomListCategory.razor
+++ b/MatrixUtils.Web/Shared/RoomListComponents/RoomListCategory.razor
@@ -1,5 +1,5 @@
+@using LibMatrix.EventTypes.Spec.State.RoomInfo
 @using MatrixUtils.Web.Classes.Constants
-@using LibMatrix.EventTypes.Spec.State
 @using LibMatrix.Responses
 @using MatrixUtils.Abstractions
 <details open>
diff --git a/MatrixUtils.Web/Shared/RoomListComponents/RoomListSpace.razor b/MatrixUtils.Web/Shared/RoomListComponents/RoomListSpace.razor
index 6954990..11f9040 100644
--- a/MatrixUtils.Web/Shared/RoomListComponents/RoomListSpace.razor
+++ b/MatrixUtils.Web/Shared/RoomListComponents/RoomListSpace.razor
@@ -42,7 +42,7 @@
         if (Breadcrumbs == null) throw new ArgumentNullException(nameof(Breadcrumbs));
         await Task.Delay(Random.Shared.Next(1000, 10000));
         var rooms = Space.Room.AsSpace.GetChildrenAsync();
-        var hs = await RMUStorage.GetCurrentSessionOrNavigate();
+        var hs = await RmuStorage.GetCurrentSessionOrNavigate();
         var joinedRooms = await hs.GetJoinedRooms();
         await foreach (var room in rooms) {
             if (Breadcrumbs.Contains(room.RoomId)) continue;
diff --git a/MatrixUtils.Web/Shared/RoomListItem.razor b/MatrixUtils.Web/Shared/RoomListItem.razor
index bfaa900..d75d159 100644
--- a/MatrixUtils.Web/Shared/RoomListItem.razor
+++ b/MatrixUtils.Web/Shared/RoomListItem.razor
@@ -1,5 +1,5 @@
 @using LibMatrix
-@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.EventTypes.Spec.State.RoomInfo
 @using LibMatrix.Responses
 @using MatrixUtils.Abstractions
 @using MatrixUtils.Web.Classes.Constants
@@ -7,13 +7,15 @@
     <div class="roomListItem @(HasDangerousRoomVersion ? "dangerousRoomVersion" : HasOldRoomVersion ? "oldRoomVersion" : "")" id="@RoomInfo.Room.RoomId">
         @if (OwnMemberState != null) {
             @* Class="@("avatar32" + (OwnMemberState?.AvatarUrl != GlobalProfile?.AvatarUrl ? " highlightChange" : "") + (ChildContent is not null ? " vcenter" : ""))" *@
-            <MxcImage Homeserver="hs" Circular="true" Height="32" Width="32" MxcUri="@(OwnMemberState.AvatarUrl ?? GlobalProfile.AvatarUrl)"/>
+            @* <MxcImage Homeserver="hs" Circular="true" Height="32" Width="32" MxcUri="@(OwnMemberState.AvatarUrl ?? GlobalProfile.AvatarUrl)"/> *@
+            <MxcAvatar Homeserver="Homeserver" Circular="true" Size="32" MxcUri="@(OwnMemberState.AvatarUrl ?? GlobalProfile.AvatarUrl)"/>
             <span class="centerVertical border75 @(OwnMemberState?.AvatarUrl != GlobalProfile?.AvatarUrl ? "highlightChange" : "")">
                 @(OwnMemberState?.DisplayName ?? GlobalProfile?.DisplayName ?? "Loading...")
             </span>
             <span class="centerVertical noLeftPadding">-></span>
         }
-        <MxcImage Circular="true" Height="32" Width="32" MxcUri="@RoomInfo.RoomIcon" Style="@(ChildContent is not null ? "vertical-align: middle;" : "")"/>
+        @* <MxcImage Circular="true" Height="32" Width="32" MxcUri="@RoomInfo.RoomIcon" Style="@(ChildContent is not null ? "vertical-align: middle;" : "")"/> *@
+        <MxcAvatar Homeserver="Homeserver" Circular="true" Size="32" MxcUri="@RoomInfo.RoomIcon"/>
         <div class="inlineBlock">
             <span class="centerVertical">@RoomInfo.RoomName</span>
             @if (ChildContent is not null) {
@@ -42,8 +44,6 @@ else {
         }
     }
 
-    
-
     [Parameter]
     public bool ShowOwnProfile { get; set; } = false;
 
@@ -61,6 +61,9 @@ else {
             OnParametersSetAsync();
         }
     }
+    
+    [Parameter]
+    public AuthenticatedHomeserverGeneric? Homeserver { get; set; }
 
     private bool HasOldRoomVersion { get; set; } = false;
     private bool HasDangerousRoomVersion { get; set; } = false;
@@ -68,20 +71,19 @@ else {
     private static SemaphoreSlim _semaphoreSlim = new(8);
     private RoomInfo? _roomInfo;
     private bool _loadData = false;
-    private static AuthenticatedHomeserverGeneric? hs { get; set; }
 
     private bool _hooked;
-    
+
     private async Task RoomInfoChanged() {
         RoomInfo.PropertyChanged += async (_, a) => {
             if (a.PropertyName == nameof(RoomInfo.CreationEventContent)) {
                 await CheckRoomVersion();
             }
-            
+
             StateHasChanged();
         };
     }
-    
+
     // protected override async Task OnParametersSetAsync() {
     //     if (RoomInfo != null) {
     //         if (!_hooked) {
@@ -127,21 +129,24 @@ else {
     protected override async Task OnInitializedAsync() {
         await base.OnInitializedAsync();
 
-        hs ??= await RMUStorage.GetCurrentSessionOrNavigate();
-        if (hs is null) return;
+        // hs ??= await RmuStorage.GetCurrentSessionOrNavigate();
+        // if (hs is null) return;
 
+        if (Homeserver is null) {
+            Console.WriteLine($"RoomListItem called without homeserver");
+        }
         await CheckRoomVersion();
     }
 
     private async Task LoadOwnProfile() {
         if (!ShowOwnProfile) return;
         try {
-    // OwnMemberState ??= (await RoomInfo.GetStateEvent("m.room.member", hs.UserId)).TypedContent as RoomMemberEventContent;
-            GlobalProfile ??= await hs.GetProfileAsync(hs.UserId);
+            // OwnMemberState ??= (await RoomInfo.GetStateEvent("m.room.member", hs.UserId)).TypedContent as RoomMemberEventContent;
+            GlobalProfile ??= await Homeserver.GetProfileAsync(Homeserver.UserId);
         }
         catch (MatrixException e) {
             if (e is { ErrorCode: "M_FORBIDDEN" }) {
-                Console.WriteLine($"Failed to get profile for {hs.UserId}: {e.Message}");
+                Console.WriteLine($"Failed to get profile for {Homeserver.UserId}: {e.Message}");
                 ShowOwnProfile = false;
             }
             else {
@@ -151,8 +156,8 @@ else {
     }
 
     private async Task CheckRoomVersion() {
-        if (RoomInfo?.CreationEventContent is null) return; 
-        
+        if (RoomInfo?.CreationEventContent is null) return;
+
         var ce = RoomInfo.CreationEventContent;
         if (int.TryParse(ce.RoomVersion, out var rv)) {
             if (rv < 10)
@@ -163,7 +168,7 @@ else {
 
         if (RoomConstants.DangerousRoomVersions.Contains(ce.RoomVersion)) {
             HasDangerousRoomVersion = true;
-    // RoomName = "Dangerous room: " + RoomName;
+            // RoomName = "Dangerous room: " + RoomName;
         }
     }
 
diff --git a/MatrixUtils.Web/Shared/TimelineComponents/BaseTimelineItem.razor b/MatrixUtils.Web/Shared/TimelineComponents/BaseTimelineItem.razor
index 08aeffe..f107eb3 100644
--- a/MatrixUtils.Web/Shared/TimelineComponents/BaseTimelineItem.razor
+++ b/MatrixUtils.Web/Shared/TimelineComponents/BaseTimelineItem.razor
@@ -1,5 +1,5 @@
 @using LibMatrix
-@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.EventTypes.Spec.State.RoomInfo
 @using LibMatrix.Responses
 <h3>BaseTimelineItem</h3>
 
diff --git a/MatrixUtils.Web/Shared/TimelineComponents/TimelineCanonicalAliasItem.razor b/MatrixUtils.Web/Shared/TimelineComponents/TimelineCanonicalAliasItem.razor
index 0488e36..d1984dd 100644
--- a/MatrixUtils.Web/Shared/TimelineComponents/TimelineCanonicalAliasItem.razor
+++ b/MatrixUtils.Web/Shared/TimelineComponents/TimelineCanonicalAliasItem.razor
@@ -1,5 +1,5 @@
 @using ArcaneLibs.Extensions
-@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.EventTypes.Spec.State.RoomInfo
 @inherits BaseTimelineItem
 
 @if (currentEventContent is not null) {
diff --git a/MatrixUtils.Web/Shared/TimelineComponents/TimelineHistoryVisibilityItem.razor b/MatrixUtils.Web/Shared/TimelineComponents/TimelineHistoryVisibilityItem.razor
index bdd6104..5d09603 100644
--- a/MatrixUtils.Web/Shared/TimelineComponents/TimelineHistoryVisibilityItem.razor
+++ b/MatrixUtils.Web/Shared/TimelineComponents/TimelineHistoryVisibilityItem.razor
@@ -1,5 +1,5 @@
 @using ArcaneLibs.Extensions
-@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.EventTypes.Spec.State.RoomInfo
 @inherits BaseTimelineItem
 
 @if (currentEventContent is not null) {
diff --git a/MatrixUtils.Web/Shared/TimelineComponents/TimelineMemberItem.razor b/MatrixUtils.Web/Shared/TimelineComponents/TimelineMemberItem.razor
index 3b18b95..e5a5650 100644
--- a/MatrixUtils.Web/Shared/TimelineComponents/TimelineMemberItem.razor
+++ b/MatrixUtils.Web/Shared/TimelineComponents/TimelineMemberItem.razor
@@ -1,5 +1,5 @@
 @using ArcaneLibs.Extensions
-@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.EventTypes.Spec.State.RoomInfo
 @using LibMatrix.Responses
 @inherits BaseTimelineItem
 
diff --git a/MatrixUtils.Web/Shared/TimelineComponents/TimelineMessageItem.razor b/MatrixUtils.Web/Shared/TimelineComponents/TimelineMessageItem.razor
index 81956b0..98b5a6d 100644
--- a/MatrixUtils.Web/Shared/TimelineComponents/TimelineMessageItem.razor
+++ b/MatrixUtils.Web/Shared/TimelineComponents/TimelineMessageItem.razor
@@ -15,7 +15,7 @@
         }
         case "m.image": {
             <i>@currentEventContent.Body</i><br/>
-            <img src="@Homeserver.ResolveMediaUri(currentEventContent.Url)">
+            @* <img src="@Homeserver.ResolveMediaUri(currentEventContent.Url)"> *@
             break;
         }
         default: {
diff --git a/MatrixUtils.Web/Shared/TimelineComponents/TimelineRoomCreateItem.razor b/MatrixUtils.Web/Shared/TimelineComponents/TimelineRoomCreateItem.razor
index f3e6c7e..aeb987a 100644
--- a/MatrixUtils.Web/Shared/TimelineComponents/TimelineRoomCreateItem.razor
+++ b/MatrixUtils.Web/Shared/TimelineComponents/TimelineRoomCreateItem.razor
@@ -1,5 +1,5 @@
 @using ArcaneLibs.Extensions
-@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.EventTypes.Spec.State.RoomInfo
 @inherits BaseTimelineItem
 
 <i>
diff --git a/MatrixUtils.Web/Shared/TimelineComponents/TimelineRoomNameItem.razor b/MatrixUtils.Web/Shared/TimelineComponents/TimelineRoomNameItem.razor
index 63594a9..c342c83 100644
--- a/MatrixUtils.Web/Shared/TimelineComponents/TimelineRoomNameItem.razor
+++ b/MatrixUtils.Web/Shared/TimelineComponents/TimelineRoomNameItem.razor
@@ -1,5 +1,5 @@
 @using ArcaneLibs.Extensions
-@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.EventTypes.Spec.State.RoomInfo
 @inherits BaseTimelineItem
 
 @if (currentEventContent is not null) {
diff --git a/MatrixUtils.Web/Shared/TimelineComponents/TimelineRoomTopicItem.razor b/MatrixUtils.Web/Shared/TimelineComponents/TimelineRoomTopicItem.razor
index f70d563..467c644 100644
--- a/MatrixUtils.Web/Shared/TimelineComponents/TimelineRoomTopicItem.razor
+++ b/MatrixUtils.Web/Shared/TimelineComponents/TimelineRoomTopicItem.razor
@@ -1,5 +1,5 @@
 @using ArcaneLibs.Extensions
-@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.EventTypes.Spec.State.RoomInfo
 @inherits BaseTimelineItem
 
 @if (currentEventContent is not null) {
diff --git a/MatrixUtils.Web/Shared/UserListItem.razor b/MatrixUtils.Web/Shared/UserListItem.razor
index d4652b2..cf7f24d 100644
--- a/MatrixUtils.Web/Shared/UserListItem.razor
+++ b/MatrixUtils.Web/Shared/UserListItem.razor
@@ -28,7 +28,7 @@
     private SvgIdenticonGenerator _identiconGenerator = new();
 
     protected override async Task OnInitializedAsync() {
-        _homeserver = await RMUStorage.GetCurrentSessionOrNavigate();
+        _homeserver = await RmuStorage.GetCurrentSessionOrNavigate();
         if (_homeserver is null) return;
 
         if (User == null) {
diff --git a/MatrixUtils.Web/_Imports.razor b/MatrixUtils.Web/_Imports.razor
index 81c7874..47c8b36 100644
--- a/MatrixUtils.Web/_Imports.razor
+++ b/MatrixUtils.Web/_Imports.razor
@@ -1,13 +1,10 @@
 @using System.Net.Http
 @using System.Net.Http.Json
-@* @using Blazored.LocalStorage *@
 @using LibMatrix.Services
 @using Microsoft.AspNetCore.Components.Forms
 @using Microsoft.AspNetCore.Components.Routing
 @using Microsoft.AspNetCore.Components.Web
-@* @using Microsoft.AspNetCore.Components.Web.Virtualization *@
 @using Microsoft.AspNetCore.Components.WebAssembly.Http
-@* @using Microsoft.JSInterop *@
 @using MatrixUtils.Web
 @using MatrixUtils.Web.Classes
 @using MatrixUtils.Web.Shared
@@ -16,8 +13,8 @@
 @using Microsoft.JSInterop
 
 @inject NavigationManager NavigationManager
-@inject RMUStorageWrapper RMUStorage
-@inject HomeserverProviderService hsProvider
+@inject RMUStorageWrapper RmuStorage
+@inject HomeserverProviderService HsProvider
 @inject TieredStorageService TieredStorage
-@inject HomeserverResolverService hsResolver
-@inject IJSRuntime JSRuntime
+@inject HomeserverResolverService HsResolver
+@inject IJSRuntime JsRuntime
diff --git a/MatrixUtils.Web/wwwroot/index.html b/MatrixUtils.Web/wwwroot/index.html
index 5182193..7425de2 100644
--- a/MatrixUtils.Web/wwwroot/index.html
+++ b/MatrixUtils.Web/wwwroot/index.html
@@ -57,6 +57,22 @@
                     height: window.innerHeight
                 };
             }
+
+            setImageStream = async (element, imageStream) => {
+                if(!(element instanceof HTMLElement)) {
+                    console.error("Element is not an HTMLElement", element);
+                    return;
+                }
+                
+                const arrayBuffer = await imageStream.arrayBuffer();
+                const blob = new Blob([arrayBuffer]);
+                const url = URL.createObjectURL(blob);
+                const image = document.getElementById(imageElementId);
+                image.onload = () => {
+                    URL.revokeObjectURL(url);
+                }
+                image.src = url;
+            }
         </script>
         <script src="_framework/blazor.webassembly.js"></script>
 <!--        <script>navigator.serviceWorker.register('service-worker.js');</script>-->
diff --git a/MatrixUtils.sln b/MatrixUtils.sln
new file mode 100644
index 0000000..5fb0c1f
--- /dev/null
+++ b/MatrixUtils.sln
@@ -0,0 +1,215 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+#
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MatrixUtils.Web", "MatrixUtils.Web\MatrixUtils.Web.csproj", "{D38DA95D-DD83-4340-96A4-6F59FC6AE3D9}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MatrixUtils.Web.Server", "MatrixUtils.Web.Server\MatrixUtils.Web.Server.csproj", "{F997F26F-2EC1-4D18-B3DD-C46FB2AD65C0}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MatrixUtils.Desktop", "MatrixUtils.Desktop\MatrixUtils.Desktop.csproj", "{27C08A4F-5AF0-4C2C-AFCB-050E3388C116}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MatrixUtils.LibDMSpace", "MatrixUtils.LibDMSpace\MatrixUtils.LibDMSpace.csproj", "{EDD2FBAB-2DEC-4527-AE9C-20E21D0D6B14}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MatrixUtils.Abstractions", "MatrixUtils.Abstractions\MatrixUtils.Abstractions.csproj", "{FE20ED20-0D55-4D74-822B-E2AC7A54C487}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "LibMatrix", "LibMatrix", "{933DC8A6-8B1F-46BF-9046-4B636AA46469}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ArcaneLibs", "ArcaneLibs", "{84BE90C4-2FDE-4A48-B154-58926EF24846}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ArcaneLibs.Tests", "LibMatrix\ArcaneLibs\ArcaneLibs.Tests\ArcaneLibs.Tests.csproj", "{EC5536AB-0613-4CB5-B22B-822A3DBB112A}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ArcaneLibs.Blazor.Components", "LibMatrix\ArcaneLibs\ArcaneLibs.Blazor.Components\ArcaneLibs.Blazor.Components.csproj", "{CF252EDF-C5A1-4030-8666-C78AA0A3B7DE}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ArcaneLibs.Legacy", "LibMatrix\ArcaneLibs\ArcaneLibs.Legacy\ArcaneLibs.Legacy.csproj", "{0C542A8E-54B6-4A20-B7A9-8C7190A0C232}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ArcaneLibs.Logging", "LibMatrix\ArcaneLibs\ArcaneLibs.Logging\ArcaneLibs.Logging.csproj", "{48AF8AC7-5F59-4401-B173-523D37FDD7A8}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ArcaneLibs.StringNormalisation", "LibMatrix\ArcaneLibs\ArcaneLibs.StringNormalisation\ArcaneLibs.StringNormalisation.csproj", "{CFAFFBF1-8C85-4FB2-AB32-B5C17AC7BB5D}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ArcaneLibs.Timings", "LibMatrix\ArcaneLibs\ArcaneLibs.Timings\ArcaneLibs.Timings.csproj", "{ADFBDF2D-0CEC-43C1-8896-75DCE439CF72}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ArcaneLibs.UsageTest", "LibMatrix\ArcaneLibs\ArcaneLibs.UsageTest\ArcaneLibs.UsageTest.csproj", "{D6315791-949B-4501-AA95-50516DE899C1}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ArcaneLibs", "LibMatrix\ArcaneLibs\ArcaneLibs\ArcaneLibs.csproj", "{03466515-77CC-49E4-90E5-9A21EDD0A644}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibMatrix.EventTypes", "LibMatrix\LibMatrix.EventTypes\LibMatrix.EventTypes.csproj", "{0336306C-285A-4810-9253-5C5F0373992E}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibMatrix", "LibMatrix\LibMatrix\LibMatrix.csproj", "{D7E5B226-114C-4747-9277-A4D6341A16FE}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{B37F87A8-B5E2-4724-800C-F5D9A91F35C7}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibMatrix.Tests", "LibMatrix\Tests\LibMatrix.Tests\LibMatrix.Tests.csproj", "{D293AFEC-8322-4FEC-8425-143B5FE10D0F}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Utilities", "Utilities", "{80828C75-9C5B-442F-86A4-8CE9D85E811C}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibMatrix.DebugDataValidationApi", "LibMatrix\Utilities\LibMatrix.DebugDataValidationApi\LibMatrix.DebugDataValidationApi.csproj", "{FA6A9923-419A-40E1-8A32-30DD906E5025}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibMatrix.DevTestBot", "LibMatrix\Utilities\LibMatrix.DevTestBot\LibMatrix.DevTestBot.csproj", "{43ECF2DB-CBA6-4A31-BD6A-B059CEA03CA0}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibMatrix.E2eeTestKit", "LibMatrix\Utilities\LibMatrix.E2eeTestKit\LibMatrix.E2eeTestKit.csproj", "{CC87DFFB-EE19-4147-9212-4FAF16D79AD5}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibMatrix.HomeserverEmulator", "LibMatrix\Utilities\LibMatrix.HomeserverEmulator\LibMatrix.HomeserverEmulator.csproj", "{DBCE6260-052E-46F9-ACCD-059AA51B8A48}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibMatrix.JsonSerializerContextGenerator", "LibMatrix\Utilities\LibMatrix.JsonSerializerContextGenerator\LibMatrix.JsonSerializerContextGenerator.csproj", "{7AA3CDF9-D1F6-4A12-BA47-EB721F353701}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibMatrix.TestDataGenerator", "LibMatrix\Utilities\LibMatrix.TestDataGenerator\LibMatrix.TestDataGenerator.csproj", "{D7F9BDF7-35B7-4C84-A34E-B940C1763CC9}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibMatrix.Utilities.Bot", "LibMatrix\Utilities\LibMatrix.Utilities.Bot\LibMatrix.Utilities.Bot.csproj", "{72D44C6C-1BC7-4310-B1A9-1169C0812E33}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MatrixUtils.DmSpaced", "MatrixUtils.DmSpaced\MatrixUtils.DmSpaced.csproj", "{CDBE012E-B48B-4F9D-8CA4-99F6328E9630}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "MxApiExtensions", "MxApiExtensions", "{0641F1C8-8518-4C67-B385-832745C063FD}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxApiExtensions.Classes.LibMatrix", "MxApiExtensions\MxApiExtensions.Classes.LibMatrix\MxApiExtensions.Classes.LibMatrix.csproj", "{3BD05B05-86DE-4680-A7A0-5A326E41E776}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxApiExtensions.Classes", "MxApiExtensions\MxApiExtensions.Classes\MxApiExtensions.Classes.csproj", "{98BB2D9F-BFB9-4E70-93D5-7C4C1205BD53}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxApiExtensions", "MxApiExtensions\MxApiExtensions\MxApiExtensions.csproj", "{44BFB1AD-62FB-4B5B-A5A8-E7D04D731684}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Benchmarks", "Benchmarks\Benchmarks.csproj", "{CEECE820-1BA9-4E29-8668-25967B3E712B}"
+EndProject
+Global
+	GlobalSection(SolutionConfigurationPlatforms) = preSolution
+		Debug|Any CPU = Debug|Any CPU
+		Release|Any CPU = Release|Any CPU
+	EndGlobalSection
+	GlobalSection(ProjectConfigurationPlatforms) = postSolution
+		{D38DA95D-DD83-4340-96A4-6F59FC6AE3D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{D38DA95D-DD83-4340-96A4-6F59FC6AE3D9}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{D38DA95D-DD83-4340-96A4-6F59FC6AE3D9}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{D38DA95D-DD83-4340-96A4-6F59FC6AE3D9}.Release|Any CPU.Build.0 = Release|Any CPU
+		{F997F26F-2EC1-4D18-B3DD-C46FB2AD65C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{F997F26F-2EC1-4D18-B3DD-C46FB2AD65C0}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{F997F26F-2EC1-4D18-B3DD-C46FB2AD65C0}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{F997F26F-2EC1-4D18-B3DD-C46FB2AD65C0}.Release|Any CPU.Build.0 = Release|Any CPU
+		{27C08A4F-5AF0-4C2C-AFCB-050E3388C116}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{27C08A4F-5AF0-4C2C-AFCB-050E3388C116}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{27C08A4F-5AF0-4C2C-AFCB-050E3388C116}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{27C08A4F-5AF0-4C2C-AFCB-050E3388C116}.Release|Any CPU.Build.0 = Release|Any CPU
+		{EDD2FBAB-2DEC-4527-AE9C-20E21D0D6B14}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{EDD2FBAB-2DEC-4527-AE9C-20E21D0D6B14}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{EDD2FBAB-2DEC-4527-AE9C-20E21D0D6B14}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{EDD2FBAB-2DEC-4527-AE9C-20E21D0D6B14}.Release|Any CPU.Build.0 = Release|Any CPU
+		{FE20ED20-0D55-4D74-822B-E2AC7A54C487}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{FE20ED20-0D55-4D74-822B-E2AC7A54C487}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{FE20ED20-0D55-4D74-822B-E2AC7A54C487}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{FE20ED20-0D55-4D74-822B-E2AC7A54C487}.Release|Any CPU.Build.0 = Release|Any CPU
+		{EC5536AB-0613-4CB5-B22B-822A3DBB112A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{EC5536AB-0613-4CB5-B22B-822A3DBB112A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{EC5536AB-0613-4CB5-B22B-822A3DBB112A}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{EC5536AB-0613-4CB5-B22B-822A3DBB112A}.Release|Any CPU.Build.0 = Release|Any CPU
+		{CF252EDF-C5A1-4030-8666-C78AA0A3B7DE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{CF252EDF-C5A1-4030-8666-C78AA0A3B7DE}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{CF252EDF-C5A1-4030-8666-C78AA0A3B7DE}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{CF252EDF-C5A1-4030-8666-C78AA0A3B7DE}.Release|Any CPU.Build.0 = Release|Any CPU
+		{0C542A8E-54B6-4A20-B7A9-8C7190A0C232}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{0C542A8E-54B6-4A20-B7A9-8C7190A0C232}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{0C542A8E-54B6-4A20-B7A9-8C7190A0C232}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{0C542A8E-54B6-4A20-B7A9-8C7190A0C232}.Release|Any CPU.Build.0 = Release|Any CPU
+		{48AF8AC7-5F59-4401-B173-523D37FDD7A8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{48AF8AC7-5F59-4401-B173-523D37FDD7A8}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{48AF8AC7-5F59-4401-B173-523D37FDD7A8}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{48AF8AC7-5F59-4401-B173-523D37FDD7A8}.Release|Any CPU.Build.0 = Release|Any CPU
+		{CFAFFBF1-8C85-4FB2-AB32-B5C17AC7BB5D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{CFAFFBF1-8C85-4FB2-AB32-B5C17AC7BB5D}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{CFAFFBF1-8C85-4FB2-AB32-B5C17AC7BB5D}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{CFAFFBF1-8C85-4FB2-AB32-B5C17AC7BB5D}.Release|Any CPU.Build.0 = Release|Any CPU
+		{ADFBDF2D-0CEC-43C1-8896-75DCE439CF72}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{ADFBDF2D-0CEC-43C1-8896-75DCE439CF72}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{ADFBDF2D-0CEC-43C1-8896-75DCE439CF72}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{ADFBDF2D-0CEC-43C1-8896-75DCE439CF72}.Release|Any CPU.Build.0 = Release|Any CPU
+		{D6315791-949B-4501-AA95-50516DE899C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{D6315791-949B-4501-AA95-50516DE899C1}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{D6315791-949B-4501-AA95-50516DE899C1}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{D6315791-949B-4501-AA95-50516DE899C1}.Release|Any CPU.Build.0 = Release|Any CPU
+		{03466515-77CC-49E4-90E5-9A21EDD0A644}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{03466515-77CC-49E4-90E5-9A21EDD0A644}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{03466515-77CC-49E4-90E5-9A21EDD0A644}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{03466515-77CC-49E4-90E5-9A21EDD0A644}.Release|Any CPU.Build.0 = Release|Any CPU
+		{0336306C-285A-4810-9253-5C5F0373992E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{0336306C-285A-4810-9253-5C5F0373992E}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{0336306C-285A-4810-9253-5C5F0373992E}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{0336306C-285A-4810-9253-5C5F0373992E}.Release|Any CPU.Build.0 = Release|Any CPU
+		{D7E5B226-114C-4747-9277-A4D6341A16FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{D7E5B226-114C-4747-9277-A4D6341A16FE}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{D7E5B226-114C-4747-9277-A4D6341A16FE}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{D7E5B226-114C-4747-9277-A4D6341A16FE}.Release|Any CPU.Build.0 = Release|Any CPU
+		{D293AFEC-8322-4FEC-8425-143B5FE10D0F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{D293AFEC-8322-4FEC-8425-143B5FE10D0F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{D293AFEC-8322-4FEC-8425-143B5FE10D0F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{D293AFEC-8322-4FEC-8425-143B5FE10D0F}.Release|Any CPU.Build.0 = Release|Any CPU
+		{FA6A9923-419A-40E1-8A32-30DD906E5025}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{FA6A9923-419A-40E1-8A32-30DD906E5025}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{FA6A9923-419A-40E1-8A32-30DD906E5025}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{FA6A9923-419A-40E1-8A32-30DD906E5025}.Release|Any CPU.Build.0 = Release|Any CPU
+		{43ECF2DB-CBA6-4A31-BD6A-B059CEA03CA0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{43ECF2DB-CBA6-4A31-BD6A-B059CEA03CA0}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{43ECF2DB-CBA6-4A31-BD6A-B059CEA03CA0}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{43ECF2DB-CBA6-4A31-BD6A-B059CEA03CA0}.Release|Any CPU.Build.0 = Release|Any CPU
+		{CC87DFFB-EE19-4147-9212-4FAF16D79AD5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{CC87DFFB-EE19-4147-9212-4FAF16D79AD5}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{CC87DFFB-EE19-4147-9212-4FAF16D79AD5}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{CC87DFFB-EE19-4147-9212-4FAF16D79AD5}.Release|Any CPU.Build.0 = Release|Any CPU
+		{DBCE6260-052E-46F9-ACCD-059AA51B8A48}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{DBCE6260-052E-46F9-ACCD-059AA51B8A48}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{DBCE6260-052E-46F9-ACCD-059AA51B8A48}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{DBCE6260-052E-46F9-ACCD-059AA51B8A48}.Release|Any CPU.Build.0 = Release|Any CPU
+		{7AA3CDF9-D1F6-4A12-BA47-EB721F353701}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{7AA3CDF9-D1F6-4A12-BA47-EB721F353701}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{7AA3CDF9-D1F6-4A12-BA47-EB721F353701}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{7AA3CDF9-D1F6-4A12-BA47-EB721F353701}.Release|Any CPU.Build.0 = Release|Any CPU
+		{D7F9BDF7-35B7-4C84-A34E-B940C1763CC9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{D7F9BDF7-35B7-4C84-A34E-B940C1763CC9}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{D7F9BDF7-35B7-4C84-A34E-B940C1763CC9}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{D7F9BDF7-35B7-4C84-A34E-B940C1763CC9}.Release|Any CPU.Build.0 = Release|Any CPU
+		{72D44C6C-1BC7-4310-B1A9-1169C0812E33}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{72D44C6C-1BC7-4310-B1A9-1169C0812E33}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{72D44C6C-1BC7-4310-B1A9-1169C0812E33}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{72D44C6C-1BC7-4310-B1A9-1169C0812E33}.Release|Any CPU.Build.0 = Release|Any CPU
+		{CDBE012E-B48B-4F9D-8CA4-99F6328E9630}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{CDBE012E-B48B-4F9D-8CA4-99F6328E9630}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{CDBE012E-B48B-4F9D-8CA4-99F6328E9630}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{CDBE012E-B48B-4F9D-8CA4-99F6328E9630}.Release|Any CPU.Build.0 = Release|Any CPU
+		{3BD05B05-86DE-4680-A7A0-5A326E41E776}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{3BD05B05-86DE-4680-A7A0-5A326E41E776}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{3BD05B05-86DE-4680-A7A0-5A326E41E776}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{3BD05B05-86DE-4680-A7A0-5A326E41E776}.Release|Any CPU.Build.0 = Release|Any CPU
+		{98BB2D9F-BFB9-4E70-93D5-7C4C1205BD53}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{98BB2D9F-BFB9-4E70-93D5-7C4C1205BD53}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{98BB2D9F-BFB9-4E70-93D5-7C4C1205BD53}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{98BB2D9F-BFB9-4E70-93D5-7C4C1205BD53}.Release|Any CPU.Build.0 = Release|Any CPU
+		{44BFB1AD-62FB-4B5B-A5A8-E7D04D731684}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{44BFB1AD-62FB-4B5B-A5A8-E7D04D731684}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{44BFB1AD-62FB-4B5B-A5A8-E7D04D731684}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{44BFB1AD-62FB-4B5B-A5A8-E7D04D731684}.Release|Any CPU.Build.0 = Release|Any CPU
+		{CEECE820-1BA9-4E29-8668-25967B3E712B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{CEECE820-1BA9-4E29-8668-25967B3E712B}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{CEECE820-1BA9-4E29-8668-25967B3E712B}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{CEECE820-1BA9-4E29-8668-25967B3E712B}.Release|Any CPU.Build.0 = Release|Any CPU
+	EndGlobalSection
+	GlobalSection(NestedProjects) = preSolution
+		{84BE90C4-2FDE-4A48-B154-58926EF24846} = {933DC8A6-8B1F-46BF-9046-4B636AA46469}
+		{EC5536AB-0613-4CB5-B22B-822A3DBB112A} = {84BE90C4-2FDE-4A48-B154-58926EF24846}
+		{CF252EDF-C5A1-4030-8666-C78AA0A3B7DE} = {84BE90C4-2FDE-4A48-B154-58926EF24846}
+		{0C542A8E-54B6-4A20-B7A9-8C7190A0C232} = {84BE90C4-2FDE-4A48-B154-58926EF24846}
+		{48AF8AC7-5F59-4401-B173-523D37FDD7A8} = {84BE90C4-2FDE-4A48-B154-58926EF24846}
+		{CFAFFBF1-8C85-4FB2-AB32-B5C17AC7BB5D} = {84BE90C4-2FDE-4A48-B154-58926EF24846}
+		{ADFBDF2D-0CEC-43C1-8896-75DCE439CF72} = {84BE90C4-2FDE-4A48-B154-58926EF24846}
+		{D6315791-949B-4501-AA95-50516DE899C1} = {84BE90C4-2FDE-4A48-B154-58926EF24846}
+		{03466515-77CC-49E4-90E5-9A21EDD0A644} = {84BE90C4-2FDE-4A48-B154-58926EF24846}
+		{0336306C-285A-4810-9253-5C5F0373992E} = {933DC8A6-8B1F-46BF-9046-4B636AA46469}
+		{D7E5B226-114C-4747-9277-A4D6341A16FE} = {933DC8A6-8B1F-46BF-9046-4B636AA46469}
+		{B37F87A8-B5E2-4724-800C-F5D9A91F35C7} = {933DC8A6-8B1F-46BF-9046-4B636AA46469}
+		{D293AFEC-8322-4FEC-8425-143B5FE10D0F} = {B37F87A8-B5E2-4724-800C-F5D9A91F35C7}
+		{80828C75-9C5B-442F-86A4-8CE9D85E811C} = {933DC8A6-8B1F-46BF-9046-4B636AA46469}
+		{FA6A9923-419A-40E1-8A32-30DD906E5025} = {80828C75-9C5B-442F-86A4-8CE9D85E811C}
+		{43ECF2DB-CBA6-4A31-BD6A-B059CEA03CA0} = {80828C75-9C5B-442F-86A4-8CE9D85E811C}
+		{CC87DFFB-EE19-4147-9212-4FAF16D79AD5} = {80828C75-9C5B-442F-86A4-8CE9D85E811C}
+		{DBCE6260-052E-46F9-ACCD-059AA51B8A48} = {80828C75-9C5B-442F-86A4-8CE9D85E811C}
+		{7AA3CDF9-D1F6-4A12-BA47-EB721F353701} = {80828C75-9C5B-442F-86A4-8CE9D85E811C}
+		{D7F9BDF7-35B7-4C84-A34E-B940C1763CC9} = {80828C75-9C5B-442F-86A4-8CE9D85E811C}
+		{72D44C6C-1BC7-4310-B1A9-1169C0812E33} = {80828C75-9C5B-442F-86A4-8CE9D85E811C}
+		{3BD05B05-86DE-4680-A7A0-5A326E41E776} = {0641F1C8-8518-4C67-B385-832745C063FD}
+		{98BB2D9F-BFB9-4E70-93D5-7C4C1205BD53} = {0641F1C8-8518-4C67-B385-832745C063FD}
+		{44BFB1AD-62FB-4B5B-A5A8-E7D04D731684} = {0641F1C8-8518-4C67-B385-832745C063FD}
+	EndGlobalSection
+EndGlobal
diff --git a/MxApiExtensions b/MxApiExtensions
-Subproject 86e41aa749d961c5731ea52e570cf0f9e8f8d3a
+Subproject b4ef05afcfac87ae197ae69bdbae93c3ca4d46b
diff --git a/global.json b/global.json
index ecc6db8..6d77f62 100644
--- a/global.json
+++ b/global.json
@@ -1,6 +1,6 @@
 {
   "sdk": {
-    "version": "8.0.0",
+    "version": "9.0.0",
     "rollForward": "latestMajor",
     "allowPrerelease": true
   }
diff --git a/scripts/deploy.sh b/scripts/deploy.sh
index 1abe9e7..7c5086f 100755
--- a/scripts/deploy.sh
+++ b/scripts/deploy.sh
@@ -11,4 +11,5 @@ BASE_DIR=`pwd`
 rm -rf **/bin/Release
 cd MatrixUtils.Web
 dotnet publish -c Release
-rsync --delete -raP bin/Release/net8.0/publish/wwwroot/ rory.gay:/data/nginx/html_mru/
+dotnet restore # restore debug deps
+rsync --delete -raP bin/Release/net9.0/publish/wwwroot/ rory.gay:/data/nginx/html_mru/