diff --git a/MatrixUtils.Web/MatrixUtils.Web.csproj b/MatrixUtils.Web/MatrixUtils.Web.csproj
 index 44fce2d..f7ebb62 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>net9.0</TargetFramework>
+        <TargetFramework>net10.0</TargetFramework>
         <Nullable>enable</Nullable>
         <ImplicitUsings>enable</ImplicitUsings>
         <LinkIncremental>true</LinkIncremental>
@@ -13,38 +13,40 @@
         <ServiceWorkerAssetsManifest>service-worker-assets.js</ServiceWorkerAssetsManifest>
         <BlazorCacheBootResources>false</BlazorCacheBootResources>
         <BlazorEnableTimeZoneSupport>false</BlazorEnableTimeZoneSupport>
+        <OverrideHtmlAssetPlaceholders>true</OverrideHtmlAssetPlaceholders>
+        <WasmEnableHotReload>false</WasmEnableHotReload>
     </PropertyGroup>
 
     <!-- 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>-->
+    <!--    <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>
         <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.9" />
-        <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="9.0.9" PrivateAssets="all" />
-        <PackageReference Include="Microsoft.AspNetCore.WebUtilities" Version="9.0.9" />
-        <PackageReference Include="Microsoft.Extensions.Logging.Configuration" Version="9.0.9" />
-        <PackageReference Include="SpawnDev.BlazorJS" Version="2.29.0" />
-        <PackageReference Include="SpawnDev.BlazorJS.WebWorkers" Version="2.19.0" />
+        <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.0-rc.2.25502.107"/>
+        <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.0-rc.2.25502.107" PrivateAssets="all"/>
+        <PackageReference Include="Microsoft.AspNetCore.WebUtilities" Version="10.0.0-rc.2.25502.107"/>
+        <PackageReference Include="Microsoft.Extensions.Logging.Configuration" Version="10.0.0-rc.2.25502.107"/>
+        <PackageReference Include="SpawnDev.BlazorJS" Version="2.38.0"/>
+        <PackageReference Include="SpawnDev.BlazorJS.WebWorkers" Version="2.21.0"/>
     </ItemGroup>
 
     <ItemGroup>
@@ -53,8 +55,8 @@
     </ItemGroup>
 
     <ItemGroup>
-<!--        <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'"/>-->
+        <!--        <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>
 
diff --git a/MatrixUtils.Web/Pages/HSAdmin/Synapse/Components/RoomQuery/SynapseRoomQueryFilter.razor b/MatrixUtils.Web/Pages/HSAdmin/Synapse/Components/RoomQuery/SynapseRoomQueryFilter.razor
 index eb168f4..f1c5907 100644
--- a/MatrixUtils.Web/Pages/HSAdmin/Synapse/Components/RoomQuery/SynapseRoomQueryFilter.razor
+++ b/MatrixUtils.Web/Pages/HSAdmin/Synapse/Components/RoomQuery/SynapseRoomQueryFilter.razor
@@ -1,46 +1,70 @@
 @using LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Filters
+@using MatrixUtils.Web.Shared.FilterComponents
 <div style="margin-left: 8px; margin-bottom: 8px;">
     <u style="display: block;">String contains</u>
-    <span class="tile tile280">Room ID: <FancyTextBox @bind-Value="@Filter.RoomIdContains"></FancyTextBox></span>
-    <span class="tile tile280">Room name: <FancyTextBox @bind-Value="@Filter.NameContains"></FancyTextBox></span>
-    <span class="tile tile280">Canonical alias: <FancyTextBox @bind-Value="@Filter.CanonicalAliasContains"></FancyTextBox></span>
-    <span class="tile tile280">Creator: <FancyTextBox @bind-Value="@Filter.CreatorContains"></FancyTextBox></span>
-    <span class="tile tile280">Room version: <FancyTextBox @bind-Value="@Filter.VersionContains"></FancyTextBox></span>
-    <span class="tile tile280">Encryption algorithm: <FancyTextBox @bind-Value="@Filter.EncryptionContains"></FancyTextBox></span>
-    <span class="tile tile280">Join rules: <FancyTextBox @bind-Value="@Filter.JoinRulesContains"></FancyTextBox></span>
-    <span class="tile tile280">Guest access: <FancyTextBox @bind-Value="@Filter.GuestAccessContains"></FancyTextBox></span>
-    <span class="tile tile280">History visibility: <FancyTextBox @bind-Value="@Filter.HistoryVisibilityContains"></FancyTextBox></span>
+    <span class="tile tile280"><StringFilterComponent Filter="@Filter.RoomId" Label="Room ID"/></span>
+    <span class="tile tile280"><StringFilterComponent Filter="@Filter.Name" Label="Room name"/></span>
+    <span class="tile tile280"><StringFilterComponent Filter="@Filter.CanonicalAlias" Label="Canonical alias"/></span>
+    <span class="tile tile280"><StringFilterComponent Filter="@Filter.Creator" Label="Creator"/></span>
+    <span class="tile tile280"><StringFilterComponent Filter="@Filter.Version" Label="Room version"/></span>
+    <span class="tile tile280"><StringFilterComponent Filter="@Filter.Encryption" Label="Encryption algorithm"/></span>
+    <span class="tile tile280"><StringFilterComponent Filter="@Filter.JoinRules" Label="Join rules"/></span>
+    <span class="tile tile280"><StringFilterComponent Filter="@Filter.GuestAccess" Label="Guest access"/></span>
+    <span class="tile tile280"><StringFilterComponent Filter="@Filter.HistoryVisibility" Label="History visibility"/></span>
+    <span class="tile tile280"><StringFilterComponent Filter="@Filter.Topic" Label="Topic"/></span>
 
     <u style="display: block;">Optional checks</u>
-    <span class="tile tile150">
-        <InputCheckbox @bind-Value="@Filter.CheckFederation"></InputCheckbox> Is federated:
-        @if (Filter.CheckFederation) {
-            <InputCheckbox @bind-Value="@Filter.Federatable"></InputCheckbox>
-        }
-    </span>
-    <span class="tile tile150">
-        <InputCheckbox @bind-Value="@Filter.CheckPublic"></InputCheckbox> Is public:
-        @if (Filter.CheckPublic) {
-            <InputCheckbox @bind-Value="@Filter.Public"></InputCheckbox>
-        }
-    </span>
+    <span class="tile tile150"><BooleanFilterComponent Filter="@Filter.Federation" Label="Is federated"/></span>
+    <span class="tile tile150"><BooleanFilterComponent Filter="@Filter.Public" Label="Is public"/></span>
+    <span class="tile tile150"><BooleanFilterComponent Filter="@Filter.Tombstone" Label="Is tombstoned"/></span>
 
     <u style="display: block;">Ranges</u>
     <span class="tile center-children">
-        <InputNumber max="@int.MaxValue" class="int-input" TValue="int" @bind-Value="@Filter.StateEventsGreaterThan"/>
-        <span class="range-sep">state events</span>
-        <InputNumber max="@int.MaxValue" class="int-input" TValue="int" @bind-Value="@Filter.StateEventsLessThan"/>
+        <InputCheckbox @bind-Value="@Filter.StateEvents.Enabled"/>
+        @if (!Filter.StateEvents.Enabled) {
+            <span>State events</span>
+        }
+        else {
+            <InputCheckbox @bind-Value="@Filter.StateEvents.CheckGreaterThan"/>
+            <span> </span>
+            <InputNumber max="@int.MaxValue" class="int-input" TValue="int" @bind-Value="@Filter.StateEvents.GreaterThan"/>
+            <span class="range-sep">state events</span>
+            <InputCheckbox @bind-Value="@Filter.StateEvents.CheckLessThan"/>
+            <InputNumber max="@int.MaxValue" class="int-input" TValue="int" @bind-Value="@Filter.StateEvents.LessThan"/>
+        }
     </span>
     <span class="tile center-children">
-        <InputNumber max="@int.MaxValue" class="int-input" TValue="int" @bind-Value="@Filter.JoinedMembersGreaterThan"></InputNumber><span class="range-sep">members</span><InputNumber
-            max="@int.MaxValue" class="int-input" TValue="int" @bind-Value="@Filter.JoinedMembersLessThan"></InputNumber>
+        <InputCheckbox @bind-Value="@Filter.JoinedMembers.Enabled"/>
+        @if (!Filter.JoinedMembers.Enabled) {
+            <span>Joined members</span>
+        }
+        else {
+            <InputCheckbox @bind-Value="@Filter.JoinedMembers.CheckGreaterThan"/>
+            <span> </span>
+            <InputNumber max="@int.MaxValue" class="int-input" TValue="int" @bind-Value="@Filter.JoinedMembers.GreaterThan"/>
+            <span class="range-sep">members</span>
+            <InputCheckbox @bind-Value="@Filter.JoinedMembers.CheckLessThan"/>
+            <InputNumber max="@int.MaxValue" class="int-input" TValue="int" @bind-Value="@Filter.JoinedMembers.LessThan"/>
+        }
     </span>
     <span class="tile center-children">
-        <InputNumber max="@int.MaxValue" class="int-input" TValue="int" @bind-Value="@Filter.JoinedLocalMembersGreaterThan"></InputNumber><span
-            class="range-sep">local members</span><InputNumber max="@int.MaxValue" class="int-input" TValue="int"
-                                                               @bind-Value="@Filter.JoinedLocalMembersLessThan"></InputNumber>
+        <InputCheckbox @bind-Value="@Filter.JoinedLocalMembers.Enabled"/>
+        <span> </span>
+        @if (!Filter.JoinedLocalMembers.Enabled) {
+            <span>Joined local members</span>
+        }
+        else {
+            <InputCheckbox @bind-Value="@Filter.JoinedLocalMembers.CheckGreaterThan"/>
+            <InputNumber max="@int.MaxValue" class="int-input" TValue="int" @bind-Value="@Filter.JoinedLocalMembers.GreaterThan"/>
+            <span class="range-sep">local members</span>
+            <InputCheckbox @bind-Value="@Filter.JoinedLocalMembers.CheckLessThan"/>
+            <InputNumber max="@int.MaxValue" class="int-input" TValue="int" @bind-Value="@Filter.JoinedLocalMembers.LessThan"/>
+        }
     </span>
 </div>
+@* @{ *@
+@*     Console.WriteLine($"Rendered SynapseRoomQueryFilter with filter: {Filter.ToJson()}"); *@
+@* } *@
 
 @code {
 
diff --git a/MatrixUtils.Web/Pages/HSAdmin/Synapse/Components/SynapseRoomShutdownWindowContent.razor b/MatrixUtils.Web/Pages/HSAdmin/Synapse/Components/SynapseRoomShutdownWindowContent.razor
 index 48aea86..fc9f8e8 100644
--- a/MatrixUtils.Web/Pages/HSAdmin/Synapse/Components/SynapseRoomShutdownWindowContent.razor
+++ b/MatrixUtils.Web/Pages/HSAdmin/Synapse/Components/SynapseRoomShutdownWindowContent.razor
@@ -1,8 +1,12 @@
+@using System.Text.Json.Serialization
+@using ArcaneLibs.Extensions
+@using LibMatrix
+@using LibMatrix.EventTypes.Spec.State.RoomInfo
 @using LibMatrix.Homeservers.Extensions.NamedCaches
 @using LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Requests
 @using LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses
 
-@if (string.IsNullOrWhiteSpace(Context.DeleteId)) {
+@if (string.IsNullOrWhiteSpace(Context.DeleteId) || EditorOnly) {
     <span>Block room: </span>
     <InputCheckbox @bind-Value="@Context.DeleteRequest.Block"/>
     <br/>
@@ -34,6 +38,12 @@
         <br/>
         <span>Delete <b>ALL</b> local user media: </span>
         <InputCheckbox @bind-Value="@Context.ExtraOptions.DeleteLocalUserMedia"></InputCheckbox>
+        <br/>
+        <span>Follow tombstone (if any): </span>
+        <InputCheckbox @bind-Value="@Context.ExtraOptions.FollowTombstone"/>
+        @if (!EditorOnly) {
+            <LinkButton InlineText="true" OnClickAsync="@FollowTombstoneAsync">Exec</LinkButton>
+        }
     </details>
 
     <details>
@@ -50,7 +60,19 @@
         <br/>
     </details>
 
-    <LinkButton OnClickAsync="@DeleteRoom">Execute</LinkButton>
+    @if (!EditorOnly) {
+        <LinkButton OnClickAsync="@DeleteRoom">Execute</LinkButton>
+    }
+}
+else {
+    <pre>
+        @(_status?.ToJson() ?? "Loading status...")
+    </pre>
+    <br/>
+    <LinkButton InlineText="true" OnClickAsync="@OnComplete">[Stop tracking]</LinkButton>
+    if (_status?.Status == SynapseAdminRoomDeleteStatus.Failed) {
+        <LinkButton InlineText="true" OnClickAsync="@ForceDelete">[Force delete]</LinkButton>
+    }
 }
 
 @code {
@@ -61,14 +83,52 @@
     [Parameter]
     public required AuthenticatedHomeserverSynapse Homeserver { get; set; }
 
+    [Parameter]
+    public bool EditorOnly { get; set; }
+
     private NamedCache<RoomShutdownContext> TaskMap { get; set; } = null!;
+    private SynapseAdminRoomDeleteStatus? _status = null;
+    private bool _isTracking = false;
 
     protected override async Task OnInitializedAsync() {
+        if (EditorOnly) return;
         TaskMap = new NamedCache<RoomShutdownContext>(Homeserver, "gay.rory.matrixutils.synapse_room_shutdown_tasks");
+        var existing = await TaskMap.GetValueAsync(Context.RoomId);
+        if (existing is not null) {
+            Context = existing;
+        }
+
+        if (Context.ExecuteImmediately)
+            await DeleteRoom();
+    }
+
+    protected override async Task OnAfterRenderAsync(bool firstRender) {
+        if (EditorOnly) return;
+        if (!_isTracking) {
+            if (!string.IsNullOrWhiteSpace(Context.DeleteId)) {
+                _isTracking = true;
+                _ = Task.Run(async () => {
+                    do {
+                        _status = await Homeserver.Admin.GetRoomDeleteStatus(Context.DeleteId);
+                        StateHasChanged();
+                        if (_status.Status == SynapseAdminRoomDeleteStatus.Complete) {
+                            await OnComplete();
+                            break;
+                        }
+
+                        await Task.Delay(1000);
+                    } while (_status.Status != SynapseAdminRoomDeleteStatus.Failed && _status.Status != SynapseAdminRoomDeleteStatus.Complete);
+                });
+            }
+        }
     }
 
     public class RoomShutdownContext {
         public required string RoomId { get; set; }
+
+        [JsonIgnore] // do NOT persist - this triggers immediate purging
+        public bool ExecuteImmediately { get; set; }
+
         public string? DeleteId { get; set; }
         public ExtraDeleteOptions ExtraOptions { get; set; } = new();
 
@@ -81,10 +141,11 @@
         public SynapseAdminRoomListResult.SynapseAdminRoomListResultRoom? RoomDetails { get; set; }
 
         public class ExtraDeleteOptions {
-            // room options
+            public bool FollowTombstone { get; set; }
+
+            // media options
             public bool QuarantineLocalMedia { get; set; }
             public bool QuarantineRemoteMedia { get; set; }
-
             public bool DeleteRemoteMedia { get; set; }
 
             // user options
@@ -95,9 +156,14 @@
     }
 
     public async Task OnComplete() {
+        if (EditorOnly) return;
+        Console.WriteLine($"Room shutdown task for {Context.RoomId} completed, removing from map.");
         await OnCompleteLock.WaitAsync();
         try {
-            await TaskMap.RemoveValueAsync(Context.DeleteId!);
+            await TaskMap.RemoveValueAsync(Context.RoomId!);
+        }
+        catch (Exception e) {
+            Console.WriteLine("Failed to remove completed room shutdown task from map: " + e);
         }
         finally {
             OnCompleteLock.Release();
@@ -105,6 +171,11 @@
     }
 
     public async Task DeleteRoom() {
+        if (EditorOnly) return;
+        if (Context.ExtraOptions.FollowTombstone) await FollowTombstoneAsync();
+
+        Console.WriteLine($"Deleting room {Context.RoomId} with options: " + Context.DeleteRequest.ToJson());
+
         var resp = await Homeserver.Admin.DeleteRoom(Context.RoomId, Context.DeleteRequest, false);
         Context.DeleteId = resp.DeleteId;
         await TaskMap.SetValueAsync(Context.RoomId, Context);
@@ -112,4 +183,84 @@
 
     private static readonly SemaphoreSlim OnCompleteLock = new(1, 1);
 
+    private async Task FollowTombstoneAsync() {
+        if (EditorOnly) return;
+        var tomb = await TryGetTombstoneAsync();
+        var content = tomb?.ContentAs<RoomTombstoneEventContent>();
+        if (content != null && !string.IsNullOrWhiteSpace(content.ReplacementRoom)) {
+            Console.WriteLine("Tombstone: " + tomb.ToJson());
+            if (!content.ReplacementRoom.StartsWith('!')) {
+                Console.WriteLine($"Invalid replacement room ID in tombstone: {content.ReplacementRoom}, ignoring!");
+            }
+            else {
+                var oldMembers = await Homeserver.Admin.GetRoomMembersAsync(Context.RoomId, localOnly: true);
+                var isKnownRoom = await Homeserver.Admin.CheckRoomKnownAsync(content.ReplacementRoom);
+                var targetMembers = isKnownRoom
+                    ? await Homeserver.Admin.GetRoomMembersAsync(Context.RoomId, localOnly: true)
+                    : new() { Members = [] };
+
+                var members = oldMembers.Members.Except(targetMembers.Members).ToList();
+                Console.WriteLine("To migrate: " + members.ToJson());
+                foreach (var member in members) {
+                    var success = false;
+                    do {
+                        var sess = member == Homeserver.WhoAmI.UserId ? Homeserver : await Homeserver.Admin.GetHomeserverForUserAsync(member, TimeSpan.FromSeconds(15));
+                        var oldRoom = sess.GetRoom(Context.RoomId);
+                        var room = sess.GetRoom(content.ReplacementRoom);
+                        try {
+                            var servers = (await oldRoom.GetMembersByHomeserverAsync(joinedOnly: true))
+                                .Select(x => new KeyValuePair<string, int>(x.Key, x.Value.Count))
+                                .OrderByDescending(x => x.Key == "matrix.org" ? 0 : x.Value); // try anything else first, to reduce load on matrix.org
+
+                            await room.JoinAsync(servers.Take(10).Select(x => x.Key).ToArray(), reason: "Automatically following tombstone as old room is being purged.", checkIfAlreadyMember: isKnownRoom);
+                            Console.WriteLine($"Migrated {member} from {Context.RoomId} to {content.ReplacementRoom}");
+                            success = true;
+                        }
+                        catch (Exception e) {
+                            if (e is MatrixException { ErrorCode: "M_FORBIDDEN" }) {
+                                Console.WriteLine($"Cannot migrate {member} to {content.ReplacementRoom}: {(e as MatrixException)!.GetAsJson()}");
+                                success = true; // give up
+                                continue;
+                            }
+
+                            Console.WriteLine($"Failed to invite {member} to {content.ReplacementRoom}: {e}");
+                            success = false;
+                            await Task.Delay(1000);
+                        }
+                    } while (!success);
+                }
+            }
+        }
+    }
+
+    private async Task<StateEventResponse?> TryGetTombstoneAsync() {
+        if (EditorOnly) return null;
+        try {
+            return (await Homeserver.Admin.GetRoomStateAsync(Context.RoomId, RoomTombstoneEventContent.EventId)).Events.FirstOrDefault(x => x.StateKey == "");
+        }
+        catch {
+            return null;
+        }
+    }
+
+    private async Task ForceDelete() {
+        if (EditorOnly) return;
+        Console.WriteLine($"Forcing purge for {Context.RoomId}!");
+        await OnCompleteLock.WaitAsync();
+        try {
+            var resp = await Homeserver.Admin.DeleteRoom(Context.RoomId, new() {
+                ForcePurge = true
+            }, waitForCompletion: false);
+            Context.DeleteId = resp.DeleteId;
+            await TaskMap.SetValueAsync(Context.RoomId, Context);
+            StateHasChanged();
+        }
+        catch (Exception e) {
+            Console.WriteLine("Failed to remove completed room shutdown task from map: " + e);
+        }
+        finally {
+            OnCompleteLock.Release();
+        }
+    }
+
 }
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/HSAdmin/Synapse/RoomQuery.razor b/MatrixUtils.Web/Pages/HSAdmin/Synapse/RoomQuery.razor
 index 07a3dd2..05899c8 100644
--- a/MatrixUtils.Web/Pages/HSAdmin/Synapse/RoomQuery.razor
+++ b/MatrixUtils.Web/Pages/HSAdmin/Synapse/RoomQuery.razor
@@ -1,5 +1,7 @@
 @page "/HSAdmin/Synapse/RoomQuery"
 @using System.Diagnostics.CodeAnalysis
+@using System.Text.Json
+@using ArcaneLibs.Blazor.Components.Services
 @using Microsoft.AspNetCore.WebUtilities
 @using ArcaneLibs.Extensions
 @using LibMatrix
@@ -10,6 +12,7 @@
 @using MatrixUtils.Web.Pages.HSAdmin.Synapse.Components
 @using MatrixUtils.Web.Pages.HSAdmin.Synapse.Components.RoomQuery
 @inject ILogger<RoomQuery> Logger
+@inject BlazorSaveFileService BlazorSaveFileService
 
 <h3>Homeserver Administration - Room Query</h3>
 
@@ -21,15 +24,42 @@
         <option value="@item.Key">@item.Value</option>
     }
 </select><br/>
-<label>Ascending: </label>
-<InputCheckbox @bind-Value="Ascending"/><br/>
+<InputCheckbox @bind-Value="Ascending"/>
+<label> Ascending</label><br/>
+<InputCheckbox @bind-Value="FetchV12PlusCreatorServer"/>
+<label> Fetch v12+ room creation homeserver</label>
+<LinkButton InlineText="true" OnClickAsync="FetchV12PlusCreatorServersAsync"> (Execute manually)</LinkButton><br/>
+<InputCheckbox @bind-Value="FetchTombstones"/>
+<label> Check for tombstone events</label>
+<LinkButton InlineText="true" OnClickAsync="FetchTombstoneEventsAsync"> (Execute manually)</LinkButton><br/>
+<InputCheckbox @bind-Value="SummarizeLocalMembers"/>
+<label> Fetch local member list for small rooms</label>
+<LinkButton InlineText="true" OnClickAsync="FetchLocalMemberEventsAsync"> (Execute manually)</LinkButton><br/>
+<InputCheckbox @bind-Value="ShowFullResultData"/>
+<label> Show full result data (JSON)</label><br/>
+<InputCheckbox @bind-Value="EnableMultiPurge"/>
+<label> Enable multi-purge mode</label>
+@if (EnableMultiPurge) {
+    <span> </span>
+    <LinkButton InlineText="true" OnClick="@MultiPurgeInvertSelection">[Invert selection]</LinkButton>
+    <span> </span>
+    <details style="display: inline-block;">
+        <summary>Edit purge options</summary>
+        <SynapseRoomShutdownWindowContent Context="@DefaultShutdownContext" Homeserver="Homeserver" EditorOnly="true"/>
+    </details>
+}
+else {
+    <br/>
+}
 <details>
-    <summary>
-        <span>Local filtering (slow)</span>
-    </summary>
+    <summary>Local filtering (slow)</summary>
     <SynapseRoomQueryFilter Filter="@Filter"/>
 </details>
-<button class="btn btn-primary" @onclick="Search">Search</button>
+<LinkButton OnClickAsync="@Search">Search</LinkButton>
+
+@if (EnableMultiPurge) {
+    <LinkButton Color="#FF8800" OnClick="@PurgeSelection">Purge selected rooms</LinkButton>
+}
 <br/>
 
 @if (Results.Count > 0) {
@@ -40,6 +70,10 @@
     <div class="room-list-item">
         @* <RoomListItem RoomName="@res.Name" RoomId="@res.RoomId"></RoomListItem> *@
         <p>
+            @if (EnableMultiPurge) {
+                <InputCheckbox @bind-Value="@room.MultiPurgeSelected"/>
+                <span> </span>
+            }
             @if (!string.IsNullOrWhiteSpace(room.CanonicalAlias)) {
                 <span>@room.CanonicalAlias - </span>
             }
@@ -57,6 +91,8 @@
         <p>
             <LinkButton OnClickAsync="@(() => DeleteRoom(room))">Delete room</LinkButton>
             <LinkButton target="_blank" href="@($"/HSAdmin/Synapse/ResyncState?roomId={room.RoomId}&via={room.OriginHomeserver}")">Resync state</LinkButton>
+            <LinkButton OnClickAsync="@(() => ExportState(room))">@(room.JoinedLocalMembers == 0 ? "Try to export state" : "Export state")</LinkButton>
+            <LinkButton OnClickAsync="@(() => ForceJoin(room))">Force Join</LinkButton>
         </p>
 
         @{
@@ -119,11 +155,23 @@
                 memberSummary += $": {string.Join(", ", room.LocalMembers)}";
             }
         }
-        <span>@memberSummary</span>
-        <details>
-            <summary>Full result data</summary>
-            <pre>@room.ToJson(ignoreNull: true)</pre>
-        </details>
+        <span>@memberSummary</span><br/>
+        @if (!string.IsNullOrWhiteSpace(room.TopicEvent?.ContentAs<RoomTopicEventContent>()?.Topic)) {
+            <details>
+                <summary>Room topic</summary>
+                <pre>@(room.TopicEvent?.ContentAs<RoomTopicEventContent>()?.Topic)</pre>
+            </details>
+        }
+        @foreach (var ex in room.Exceptions) {
+            <span style="color: red;">@ex</span>
+            <br/>
+        }
+        @if (ShowFullResultData) {
+            <details>
+                <summary>Full result data</summary>
+                <pre>@room.ToJson(ignoreNull: true)</pre>
+            </details>
+        }
     </div>
 }
 @* *@
@@ -148,10 +196,6 @@
     </ModalWindow>
 }
 
-<style>
-
-</style>
-
 @code {
 
     [Parameter]
@@ -166,6 +210,18 @@
     [SupplyParameterFromQuery(Name = "ascending")]
     public bool Ascending { get; set; } = true;
 
+    [Parameter]
+    [SupplyParameterFromQuery(Name = "FetchV12PlusCreatorServer")]
+    public bool FetchV12PlusCreatorServer { get; set; } = true;
+
+    [Parameter]
+    [SupplyParameterFromQuery(Name = "SummarizeLocalMembers")]
+    public bool SummarizeLocalMembers { get; set; } = true;
+
+    [Parameter]
+    [SupplyParameterFromQuery(Name = "FetchTombstones")]
+    public bool FetchTombstones { get; set; } = true;
+
     private List<RoomInfo> Results { get; set; } = new();
 
     private AuthenticatedHomeserverSynapse Homeserver { get; set; } = null!;
@@ -178,6 +234,21 @@
 
     private NamedCache<SynapseRoomShutdownWindowContent.RoomShutdownContext> TaskMap { get; set; } = null!;
 
+    private SynapseRoomShutdownWindowContent.RoomShutdownContext DefaultShutdownContext { get; set; } = new() {
+        RoomId = "",
+        DeleteRequest = new() { Block = true, Purge = true, ForcePurge = false }
+    };
+
+    public bool ShowFullResultData {
+        get;
+        set {
+            field = value;
+            StateHasChanged();
+        }
+    }
+
+    public bool EnableMultiPurge { get; set; }
+
     protected override async Task OnInitializedAsync() {
         var hs = await sessionStore.GetCurrentHomeserver(navigateOnFailure: true);
         if (hs is not AuthenticatedHomeserverSynapse synapse) {
@@ -199,67 +270,95 @@
         foreach (var (key, value) in QueryHelpers.ParseQuery(new Uri(NavigationManager.Uri).Query)) {
             switch (key) {
                 case "RoomIdContains":
-                    Filter.RoomIdContains = value[0]!;
+                    Filter.RoomId.Enabled = Filter.RoomId.CheckValueContains = true;
+                    Filter.RoomId.ValueContains = value[0]!;
                     break;
                 case "NameContains":
-                    Filter.NameContains = value[0]!;
+                    Filter.Name.Enabled = Filter.Name.CheckValueContains = true;
+                    Filter.Name.ValueContains = value[0]!;
                     break;
                 case "CanonicalAliasContains":
-                    Filter.CanonicalAliasContains = value[0]!;
+                    Filter.CanonicalAlias.Enabled = Filter.CanonicalAlias.CheckValueContains = true;
+                    Filter.CanonicalAlias.ValueContains = value[0]!;
                     break;
                 case "VersionContains":
-                    Filter.VersionContains = value[0]!;
+                    Filter.Version.Enabled = Filter.Version.CheckValueContains = true;
+                    Filter.Version.ValueContains = value[0]!;
                     break;
                 case "CreatorContains":
-                    Filter.CreatorContains = value[0]!;
+                    Filter.Creator.Enabled = Filter.Creator.CheckValueContains = true;
+                    Filter.Creator.ValueContains = value[0]!;
                     break;
                 case "EncryptionContains":
-                    Filter.EncryptionContains = value[0]!;
+                    Filter.Encryption.Enabled = Filter.Encryption.CheckValueContains = true;
+                    Filter.Encryption.ValueContains = value[0]!;
                     break;
                 case "JoinRulesContains":
-                    Filter.JoinRulesContains = value[0]!;
+                    Filter.JoinRules.Enabled = Filter.JoinRules.CheckValueContains = true;
+                    Filter.JoinRules.ValueContains = value[0]!;
                     break;
                 case "GuestAccessContains":
-                    Filter.GuestAccessContains = value[0]!;
+                    Filter.GuestAccess.Enabled = Filter.GuestAccess.CheckValueContains = true;
+                    Filter.GuestAccess.ValueContains = value[0]!;
                     break;
                 case "HistoryVisibilityContains":
-                    Filter.HistoryVisibilityContains = value[0]!;
+                    Filter.HistoryVisibility.Enabled = Filter.HistoryVisibility.CheckValueContains = true;
+                    Filter.HistoryVisibility.ValueContains = value[0]!;
                     break;
                 case "Federatable":
-                    Filter.Federatable = bool.Parse(value[0]!);
-                    Filter.CheckFederation = true;
+                    Filter.Federation = new() {
+                        Enabled = true,
+                        Value = bool.Parse(value[0]!)
+                    };
                     break;
                 case "Public":
-                    Filter.Public = value[0] == "true";
-                    Filter.CheckPublic = true;
+                    Filter.Public = new() {
+                        Enabled = true,
+                        Value = bool.Parse(value[0]!)
+                    };
                     break;
                 case "JoinedMembersGreaterThan":
-                    Filter.JoinedMembersGreaterThan = int.Parse(value[0]!);
+                    Filter.JoinedMembers.Enabled = Filter.JoinedLocalMembers.CheckGreaterThan = true;
+                    Filter.JoinedMembers.GreaterThan = int.Parse(value[0]!);
                     break;
                 case "JoinedMembersLessThan":
-                    Filter.JoinedMembersLessThan = int.Parse(value[0]!);
+                    Filter.JoinedMembers.Enabled = Filter.JoinedLocalMembers.CheckLessThan = true;
+                    Filter.JoinedMembers.LessThan = int.Parse(value[0]!);
                     break;
                 case "JoinedLocalMembersGreaterThan":
-                    Filter.JoinedLocalMembersGreaterThan = int.Parse(value[0]!);
+                    Filter.JoinedLocalMembers.Enabled = Filter.JoinedLocalMembers.CheckGreaterThan = true;
+                    Filter.JoinedLocalMembers.GreaterThan = int.Parse(value[0]!);
                     break;
                 case "JoinedLocalMembersLessThan":
-                    Filter.JoinedLocalMembersLessThan = int.Parse(value[0]!);
+                    Filter.JoinedLocalMembers.Enabled = Filter.JoinedLocalMembers.CheckLessThan = true;
+                    Filter.JoinedLocalMembers.LessThan = int.Parse(value[0]!);
                     break;
                 case "StateEventsGreaterThan":
-                    Filter.StateEventsGreaterThan = int.Parse(value[0]!);
+                    Filter.StateEvents.Enabled = Filter.StateEvents.CheckGreaterThan = true;
+                    Filter.StateEvents.GreaterThan = int.Parse(value[0]!);
                     break;
                 case "StateEventsLessThan":
-                    Filter.StateEventsLessThan = int.Parse(value[0]!);
+                    Filter.StateEvents.Enabled = Filter.StateEvents.CheckLessThan = true;
+                    Filter.StateEvents.LessThan = int.Parse(value[0]!);
                     break;
                 case "Execute":
                     execute = true;
                     break;
+                case "order_by":
+                case "name_search":
+                case "ascending":
+                case "FetchV12PlusCreatorServer":
+                case "SummarizeLocalMembers":
+                case "FetchTombstones":
+                    break;
                 default:
                     Console.WriteLine($"Unknown query parameter: {key}");
                     break;
             }
         }
 
+        StateHasChanged();
+
         if (execute)
             _ = Search();
 
@@ -268,7 +367,26 @@
 
     private async Task Search() {
         Results.Clear();
-        var searchRooms = Homeserver.Admin.SearchRoomsAsync(orderBy: OrderBy!, dir: Ascending ? "f" : "b", searchTerm: SearchTerm, localFilter: Filter).GetAsyncEnumerator();
+        Console.WriteLine("Starting search... Parameters: " + new {
+            orderBy = OrderBy!,
+            dir = Ascending ? "f" : "b",
+            searchTerm = SearchTerm,
+            localFilter = Filter,
+            chunkLimit = 1000,
+            fetchTombstones = FetchTombstones,
+            fetchTopics = true,
+            fetchCreateEvents = true
+        }.ToJson());
+        var searchRooms = Homeserver.Admin.SearchRoomsAsync(
+            orderBy: OrderBy!,
+            dir: Ascending ? "f" : "b",
+            searchTerm: SearchTerm,
+            localFilter: Filter,
+            chunkLimit: 1000,
+            fetchTombstones: FetchTombstones,
+            fetchTopics: true,
+            fetchCreateEvents: true
+        ).GetAsyncEnumerator();
         var joinedRooms = await Homeserver.GetJoinedRooms();
         while (await searchRooms.MoveNextAsync()) {
             var room = searchRooms.Current;
@@ -288,15 +406,26 @@
                 StateEvents = room.StateEvents,
                 JoinedMembers = room.JoinedMembers,
                 JoinedLocalMembers = room.JoinedLocalMembers,
-                OriginHomeserver = joinedRooms.Any(x => x.RoomId == room.RoomId)
-                    ? await Homeserver.GetRoom(room.RoomId).GetOriginHomeserverAsync()
-                    : (await Homeserver.Admin.GetRoomStateAsync(room.RoomId, RoomCreateEventContent.EventId)).Events.FirstOrDefault()?.Sender?.Split(':', 2)[1] 
-                      ?? string.Empty
+                OriginHomeserver =
+                    Homeserver.GetRoom(room.RoomId).IsV12PlusRoomId
+                        ? room.RoomId.Split(':', 2).Skip(1).FirstOrDefault(string.Empty)
+                        : string.Empty
             };
 
+            if (string.IsNullOrWhiteSpace(roomInfo.OriginHomeserver) && FetchV12PlusCreatorServer) {
+                try {
+                    if (joinedRooms.Any(x => x.RoomId == room.RoomId))
+                        roomInfo.OriginHomeserver = await Homeserver.GetRoom(room.RoomId).GetOriginHomeserverAsync();
+                    else roomInfo.OriginHomeserver = (await Homeserver.Admin.GetRoomStateAsync(room.RoomId, RoomCreateEventContent.EventId)).Events.FirstOrDefault()?.Sender?.Split(':', 2)[1];
+                }
+                catch (MatrixException e) {
+                    roomInfo.Exceptions.Add($"While getting origin homeserver: {e.GetAsObject().ToJson(indent: false, ignoreNull: true)}");
+                }
+            }
+
             Results.Add(roomInfo);
 
-            if ((Results.Count <= 200 && Results.Count % 10 == 0) || Results.Count % 1000 == 0) {
+            if ((Results.Count <= 200 && Results.Count % 10 == 0 && FetchV12PlusCreatorServer) || Results.Count % 1000 == 0) {
                 StateHasChanged();
                 await Task.Yield();
                 await Task.Delay(1);
@@ -305,97 +434,29 @@
 
         StateHasChanged();
 
-        var getLocalMembersTasks = Results
-            .Where(x => x.JoinedLocalMembers is > 0 and < 100)
-            .Select(async r => {
-                    var members = (await Homeserver.Admin.GetRoomMembersAsync(r.RoomId)).Members.Where(x => x.EndsWith(":" + Homeserver.ServerName)).ToList();
-                    r.LocalMembers = members;
-                }
-            );
-        await Task.WhenAll(getLocalMembersTasks);
-
-        var getTombstoneTasks = Results
-            .Select(async r => {
-                var state = await Homeserver.Admin.GetRoomStateAsync(r.RoomId, type: "m.room.tombstone");
-                var tombstone = state.Events.FirstOrDefault(x => x is { StateKey: "", Type: "m.room.tombstone" });
-                if (tombstone is { } tombstoneEvent) {
-                    r.TombstoneEvent = tombstoneEvent;
-                }
-            });
-        await Task.WhenAll(getTombstoneTasks);
+        if (FetchV12PlusCreatorServer) await FetchV12PlusCreatorServersAsync(false);
+        if (SummarizeLocalMembers) await FetchLocalMemberEventsAsync(false);
+        // if (CheckTombstone) await FetchTombstoneEventsAsync(false);
 
         StateHasChanged();
     }
 
-    Task DeleteRoom(RoomInfo room) {
-        DeleteRequests.TryAdd(room.RoomId, new() { RoomId = room.RoomId, RoomDetails = room, DeleteRequest = new() { Block = true, Purge = true, ForcePurge = false } });
+    private Task DeleteRoom(RoomInfo room, bool executeWithoutConfirmation = false) {
+        var dc = JsonSerializer.Deserialize<SynapseRoomShutdownWindowContent.RoomShutdownContext>(DefaultShutdownContext.ToJson())!;
+        dc.RoomId = room.RoomId;
+        dc.RoomDetails = room;
+        dc.ExecuteImmediately = executeWithoutConfirmation;
+        DeleteRequests.TryAdd(room.RoomId, dc);
         StateHasChanged();
 
         return Task.CompletedTask;
     }
 
-    //
-    // private async Task DeleteRoom() {
-    //     if (DeleteRequest is { } deleteRequest) {
-    //         var media = await Homeserver.Admin.GetRoomMediaAsync(deleteRequest.RoomId);
-    //         if (deleteRequest.DeleteRequest.QuarantineRemoteMedia) {
-    //             foreach (var remoteMedia in media.Remote) {
-    //                 await Homeserver.Admin.QuarantineMediaById(remoteMedia);
-    //             }
-    //         }
-    //
-    //         if (deleteRequest.DeleteRequest.DeleteRemoteMedia) {
-    //             foreach (var remoteMedia in media.Remote) {
-    //                 await Homeserver.Admin.DeleteMediaById(remoteMedia);
-    //             }
-    //         }
-    //         else if (deleteRequest.DeleteRequest.QuarantineLocalMedia) {
-    //             foreach (var localMedia in media.Local) {
-    //                 await Homeserver.Admin.QuarantineMediaById(localMedia);
-    //             }
-    //         }
-    //
-    //         var deleteId = await Homeserver.Admin.DeleteRoom(deleteRequest.RoomId, deleteRequest.DeleteRequest, waitForCompletion: false);
-    //         DeleteRequest = null;
-    //         List<string> alreadyCleanedUsers = [];
-    //         while (true) {
-    //             var status = await Homeserver.Admin.GetRoomDeleteStatus(deleteId.DeleteId);
-    //             DeleteStatuses[deleteRequest.RoomId] = status;
-    //             StateHasChanged();
-    //             await Task.Delay(5000);
-    //             if (status.Status == "complete") {
-    //                 DeleteStatuses.Remove(deleteRequest.RoomId);
-    //                 StateHasChanged();
-    //                 break;
-    //             }
-    //
-    //             if (status.Status == "failed") {
-    //                 deleteId = await Homeserver.Admin.DeleteRoom(deleteRequest.RoomId, deleteRequest.DeleteRequest, waitForCompletion: false);
-    //             }
-    //
-    //             var newCleanedUsers = status.ShutdownRoom?.KickedUsers?.Except(alreadyCleanedUsers).ToList();
-    //             if (newCleanedUsers is not null) {
-    //                 alreadyCleanedUsers.AddRange(newCleanedUsers);
-    //                 foreach (var user in newCleanedUsers) {
-    //                     if (deleteRequest.DeleteRequest.SuspendLocalUsers) {
-    //                         // await Homeserver.Admin.(user);
-    //                     }
-    //
-    //                     if (deleteRequest.DeleteRequest.QuarantineLocalUserMedia) {
-    //                         await Homeserver.Admin.QuarantineMediaByUserId(user);
-    //                     }
-    //
-    //                     if (deleteRequest.DeleteRequest.DeleteLocalUserMedia) {
-    //                         var userMedia = Homeserver.Admin.GetUserMediaEnumerableAsync(user);
-    //                         await foreach (var mediaEntry in userMedia) {
-    //                             await Homeserver.Admin.DeleteMediaById(mediaEntry.MediaId);
-    //                         }
-    //                     }
-    //                 }
-    //             }
-    //         }
-    //     }
-    // }
+    private void PurgeSelection() {
+        foreach (var room in Results.Where(x => x.MultiPurgeSelected)) {
+            DeleteRoom(room, true);
+        }
+    }
 
     private readonly Dictionary<string, string> validOrderBy = new() {
         { "name", "Room name" },
@@ -415,11 +476,143 @@
 
     private class RoomInfo : SynapseAdminRoomListResult.SynapseAdminRoomListResultRoom {
         public List<string>? LocalMembers { get; set; }
-        public StateEventResponse? TombstoneEvent { get; set; }
         public required string OriginHomeserver { get; set; }
 
         [field: AllowNull, MaybeNull]
         public string MemberSummary => field ??= $"{JoinedMembers} members, of which {JoinedLocalMembers} are on this server";
+
+        public List<string> Exceptions { get; set; } = [];
+        public bool MultiPurgeSelected { get; set; }
+    }
+
+    private async Task ExportState(RoomInfo room) {
+        try {
+            var state = await Homeserver.Admin.GetRoomStateAsync(room.RoomId);
+            var json = state.ToJson();
+            await BlazorSaveFileService.SaveFileAsync($"{room.RoomId.Replace(":", "_")}_state.json", System.Text.Encoding.UTF8.GetBytes(json), "application/json");
+        }
+        catch (Exception e) {
+            Logger.LogError(e, "Failed to export room state for {RoomId}", room.RoomId);
+        }
+    }
+
+    private async Task ForceJoin(RoomInfo room) {
+        try {
+            await Homeserver.GetRoom(room.RoomId).JoinAsync([Homeserver.ServerName]);
+        }
+        catch (Exception e) {
+            Logger.LogError(e, "Failed to force-join room {RoomId}", room.RoomId);
+            // await Homeserver.Admin.room
+        }
+    }
+
+    private SemaphoreSlim _concurrencyLimiter = new SemaphoreSlim(16, 16);
+
+    private async Task FetchV12PlusCreatorServersAsync() => await FetchV12PlusCreatorServersAsync(true);
+
+    private async Task FetchV12PlusCreatorServersAsync(bool rerender) {
+        var joinedRooms = await Homeserver.GetJoinedRooms();
+        var tasks = Results
+            .Where(x => string.IsNullOrWhiteSpace(x.OriginHomeserver))
+            .Select(async r => {
+                if (!string.IsNullOrWhiteSpace(r.Creator) && r.Creator.Contains(':')) {
+                    r.OriginHomeserver = r.Creator.Split(':', 2)[1];
+                    return;
+                }
+
+                if (r.CreateEvent != null && !string.IsNullOrWhiteSpace(r.CreateEvent.Sender) && r.CreateEvent.Sender.Contains(':')) {
+                    r.OriginHomeserver = r.CreateEvent.Sender.Split(':', 2)[1];
+                    return;
+                }
+
+                await _concurrencyLimiter.WaitAsync();
+                try {
+                    if (joinedRooms.Any(x => x.RoomId == r.RoomId))
+                        r.OriginHomeserver = await Homeserver.GetRoom(r.RoomId).GetOriginHomeserverAsync();
+                    else r.OriginHomeserver = (await Homeserver.Admin.GetRoomStateAsync(r.RoomId, RoomCreateEventContent.EventId)).Events.FirstOrDefault()?.Sender?.Split(':', 2)[1];
+                }
+                catch (MatrixException e) {
+                    r.Exceptions.Add($"While getting origin homeserver: {e.GetAsObject().ToJson(indent: false, ignoreNull: true)}");
+                }
+                catch (Exception e) {
+                    Console.WriteLine($"Failed to get origin homeserver for {r.RoomId}, unhandled exception: " + e);
+                }
+                finally {
+                    _concurrencyLimiter.Release();
+                }
+            });
+
+        await Task.WhenAll(tasks);
+
+        if (rerender)
+            StateHasChanged();
+    }
+
+    private async Task FetchTombstoneEventsAsync() => await FetchTombstoneEventsAsync(true);
+
+    private async Task FetchTombstoneEventsAsync(bool rerender) {
+        var getTombstoneTasks = Results
+            .Where(x => x.TombstoneEvent is null)
+            .Select(async r => {
+                await _concurrencyLimiter.WaitAsync();
+                try {
+                    var state = await Homeserver.Admin.GetRoomStateAsync(r.RoomId, type: "m.room.tombstone");
+                    var tombstone = state.Events.FirstOrDefault(x => x is { StateKey: "", Type: "m.room.tombstone" });
+                    if (tombstone is { } tombstoneEvent) {
+                        r.TombstoneEvent = tombstoneEvent;
+                    }
+                }
+                catch (MatrixException e) {
+                    r.Exceptions.Add($"While checking for tombstone: {e.GetAsObject().ToJson(indent: false, ignoreNull: true)}");
+                }
+                catch (Exception e) {
+                    Console.WriteLine($"Failed to check tombstone for {r.RoomId}, unhandled exception: " + e);
+                }
+                finally {
+                    _concurrencyLimiter.Release();
+                }
+            });
+
+        await Task.WhenAll(getTombstoneTasks);
+
+        if (rerender)
+            StateHasChanged();
+    }
+
+    private async Task FetchLocalMemberEventsAsync() => await FetchLocalMemberEventsAsync(true);
+
+    private async Task FetchLocalMemberEventsAsync(bool rerender) {
+        var getLocalMembersTasks = Results
+            .Where(x => x.LocalMembers is null && x.JoinedLocalMembers is > 0 and < 100)
+            .Select(async r => {
+                await _concurrencyLimiter.WaitAsync();
+                try {
+                    var members = (await Homeserver.Admin.GetRoomMembersAsync(r.RoomId)).Members.Where(x => x.EndsWith(":" + Homeserver.ServerName)).ToList();
+                    r.LocalMembers = members;
+                }
+                catch (MatrixException e) {
+                    r.Exceptions.Add($"While fetching local members: {e.GetAsObject().ToJson(ignoreNull: true, indent: false)}");
+                }
+                catch (Exception e) {
+                    Console.WriteLine($"Failed to fetch local members for {r.RoomId}, unhandled exception: " + e);
+                }
+                finally {
+                    _concurrencyLimiter.Release();
+                }
+            });
+
+        await Task.WhenAll(getLocalMembersTasks);
+
+        if (rerender)
+            StateHasChanged();
+    }
+
+    private void MultiPurgeInvertSelection() {
+        foreach (var room in Results) {
+            room.MultiPurgeSelected ^= true;
+        }
+
+        StateHasChanged();
     }
 
 }
diff --git a/MatrixUtils.Web/Pages/Labs/DMSpace/DMSpaceStages/DMSpaceStage1.razor b/MatrixUtils.Web/Pages/Labs/DMSpace/DMSpaceStages/DMSpaceStage1.razor
 index 360548d..7199934 100644
--- a/MatrixUtils.Web/Pages/Labs/DMSpace/DMSpaceStages/DMSpaceStage1.razor
+++ b/MatrixUtils.Web/Pages/Labs/DMSpace/DMSpaceStages/DMSpaceStage1.razor
@@ -26,7 +26,7 @@
             <InputCheckbox @bind-Value="SetupData.DmSpaceInfo.LayerByUser"></InputCheckbox>
             Create sub-spaces per user
         </p>
-        
+
         <br/>
         <LinkButton OnClickAsync="@Disband" Color="#FF0000">Disband</LinkButton>
         <LinkButton OnClickAsync="@Execute">Next</LinkButton>
@@ -78,7 +78,7 @@ else {
             userRooms.Add(room);
         }
 
-        var roomChecks = userRooms.Select(GetFeasibleSpaces).ToAsyncEnumerable();
+        var roomChecks = userRooms.Select(GetFeasibleSpaces).ToAsyncResultEnumerable();
         await foreach (var room in roomChecks)
             if (room.HasValue)
                 spaces.TryAdd(room.Value.id, room.Value.roomInfo);
@@ -109,8 +109,8 @@ else {
     public async Task<(string id, RoomInfo roomInfo)?> GetFeasibleSpaces(GenericRoom room) {
         try {
             var ri = new RoomInfo(room);
-            
-            await foreach(var evt in room.GetFullStateAsync())
+
+            await foreach (var evt in room.GetFullStateAsync())
                 ri.StateEvents.Add(evt);
 
             var powerLevels = (await ri.GetStateEvent(RoomPowerLevelEventContent.EventId)).TypedContent as RoomPowerLevelEventContent;
@@ -118,7 +118,7 @@ else {
                 Console.WriteLine($"No permission to send m.space.child in {room.RoomId}...");
                 return null;
             }
-            
+
             Status = $"Found viable space: {ri.RoomName}";
             if (!string.IsNullOrWhiteSpace(SetupData.DmSpaceConfiguration!.DMSpaceId)) {
                 if (await room.GetStateOrNullAsync<DMSpaceInfo>(DMSpaceInfo.EventId) is { } dsi) {
diff --git a/MatrixUtils.Web/Pages/Labs/DMSpace/DMSpaceStages/DMSpaceStage2.razor b/MatrixUtils.Web/Pages/Labs/DMSpace/DMSpaceStages/DMSpaceStage2.razor
 index 25d1629..ed65e94 100644
--- a/MatrixUtils.Web/Pages/Labs/DMSpace/DMSpaceStages/DMSpaceStage2.razor
+++ b/MatrixUtils.Web/Pages/Labs/DMSpace/DMSpaceStages/DMSpaceStage2.razor
@@ -38,7 +38,9 @@ else {
 }
 @foreach (var (room, usersList) in duplicateDmRooms) {
     <ModalWindow Title="Duplicate room found" X="_offset += 30" Y="_offset">
-        <p>Found room assigned to multiple users: <RoomListItem RoomInfo="@room"></RoomListItem></p>
+        <p>Found room assigned to multiple users:
+            <RoomListItem RoomInfo="@room"></RoomListItem>
+        </p>
         <p>Users:</p>
         @foreach (var userProfileResponse in usersList) {
             <LinkButton OnClickAsync="@(() => SetRoomAssignment(room.Room.RoomId, userProfileResponse.Id))">
@@ -141,12 +143,12 @@ else {
             }
 
             var roomList = new List<RoomInfo>();
-            var tasks = rooms.Select(x => GetRoomInfo(hs.GetRoom(x))).ToAsyncEnumerable();
+            var tasks = rooms.Select(x => GetRoomInfo(hs.GetRoom(x))).ToAsyncResultEnumerable();
             await foreach (var result in tasks)
                 roomList.Add(result);
             return (userProfile, roomList);
             // StateHasChanged();
-        }).ToAsyncEnumerable();
+        }).ToAsyncResultEnumerable();
         await foreach (var res in results) {
             SetupData.DMRooms.Add(res.userProfile, res.roomList);
             // Status = $"Listed {dmRooms.Count} users";
@@ -181,8 +183,8 @@ else {
         await roomInfo.FetchAllStateAsync();
         roomMembers[roomInfo] = new();
         // roomInfo.CreationEventContent = await room.GetCreateEventAsync();
-        
-        if(roomInfo.RoomName == room.RoomId)
+
+        if (roomInfo.RoomName == room.RoomId)
             try {
                 roomInfo.RoomName = await room.GetNameOrFallbackAsync();
             }
@@ -192,7 +194,7 @@ else {
         await foreach (var member in membersEnum)
             if (member.TypedContent is RoomMemberEventContent memberEvent)
                 roomMembers[roomInfo].Add(new() { DisplayName = memberEvent.DisplayName, AvatarUrl = memberEvent.AvatarUrl, Id = member.StateKey });
-        
+
         try {
             string? roomIcon = (await room.GetAvatarUrlAsync())?.Url;
             if (room is not null)
diff --git a/MatrixUtils.Web/Pages/Labs/DMSpace/DMSpaceStages/DMSpaceStage3.razor b/MatrixUtils.Web/Pages/Labs/DMSpace/DMSpaceStages/DMSpaceStage3.razor
 index 2bed22e..686894c 100644
--- a/MatrixUtils.Web/Pages/Labs/DMSpace/DMSpaceStages/DMSpaceStage3.razor
+++ b/MatrixUtils.Web/Pages/Labs/DMSpace/DMSpaceStages/DMSpaceStage3.razor
@@ -115,11 +115,11 @@ else {
         //         };
         //     }
         //     var roomList = new List<RoomInfo>();
-        //     var tasks = rooms.Select(x => GetRoomInfo(hs.GetRoom(x))).ToAsyncEnumerable();
+        //     var tasks = rooms.Select(x => GetRoomInfo(hs.GetRoom(x))).ToAsyncResultEnumerable();
         //     await foreach (var result in tasks)
         //         roomList.Add(result);
         //     return (userProfile, roomList);
-        // }).ToAsyncEnumerable();
+        // }).ToAsyncResultEnumerable();
         // await foreach (var res in results) {
         //     dmRooms.Add(new RoomInfo() {
         //         Room = dmSpaceRoom,
diff --git a/MatrixUtils.Web/Pages/Moderation/UserRoomHistory.razor b/MatrixUtils.Web/Pages/Moderation/UserRoomHistory.razor
 index e30adf6..17dd554 100644
--- a/MatrixUtils.Web/Pages/Moderation/UserRoomHistory.razor
+++ b/MatrixUtils.Web/Pages/Moderation/UserRoomHistory.razor
@@ -19,6 +19,7 @@ else {
     else if (checkedRooms.Count > 1) {
         <p>Done!</p>
     }
+
     @foreach (var (state, rooms) in matchingStates) {
         <u>@state</u>
         <br/>
@@ -71,13 +72,14 @@ else {
             _semaphoreSlim.Release();
             return; //abort if changed
         }
+
         matchingStates.Clear();
         foreach (var homeserver in hss) {
             currentHs = homeserver;
             var rooms = await homeserver.GetJoinedRooms();
             rooms.RemoveAll(x => checkedRooms.Contains(x.RoomId));
             checkedRooms.AddRange(rooms.Select(x => x.RoomId));
-            var tasks = rooms.Select(x => GetMembershipAsync(x, mxid)).ToAsyncEnumerable();
+            var tasks = rooms.Select(x => GetMembershipAsync(x, mxid)).ToAsyncResultEnumerable();
             await foreach (var (room, state) in tasks) {
                 if (state is null) continue;
                 if (!matchingStates.ContainsKey(state.Membership))
@@ -97,8 +99,10 @@ else {
                     return; //abort if changed
                 }
             }
+
             StateHasChanged();
         }
+
         currentHs = null;
         StateHasChanged();
         _semaphoreSlim.Release();
diff --git a/MatrixUtils.Web/Pages/Rooms/PolicyList.razor b/MatrixUtils.Web/Pages/Rooms/PolicyList.razor
 index 5876861..92c6ca5 100644
--- a/MatrixUtils.Web/Pages/Rooms/PolicyList.razor
+++ b/MatrixUtils.Web/Pages/Rooms/PolicyList.razor
@@ -381,7 +381,7 @@ else {
             .SelectMany(x => x.ActivePolicies.Values)
             .ToArray();
 
-        await foreach (var modifiedPolicyInfos in tasks.ToAsyncEnumerable()) {
+        await foreach (var modifiedPolicyInfos in tasks.ToAsyncResultEnumerable()) {
             if (modifiedPolicyInfos.Count == 0) continue;
             var applySw = Stopwatch.StartNew();
             // Console.WriteLine($"Main: got {modifiedPolicyInfos.Count} modified policies from worker, time: {scanSw.Elapsed}");
diff --git a/MatrixUtils.Web/Pages/Rooms/PolicyLists.razor b/MatrixUtils.Web/Pages/Rooms/PolicyLists.razor
 index 6df56ba..52c5f30 100644
--- a/MatrixUtils.Web/Pages/Rooms/PolicyLists.razor
+++ b/MatrixUtils.Web/Pages/Rooms/PolicyLists.razor
@@ -12,9 +12,9 @@
 <h3>
     <span>Policy lists </span>
     <LinkButton OnClickAsync="@(() => {
-                             ShowPolicyListCreationWindow = true;
-                             return Task.CompletedTask;
-                         })">
+                                  ShowPolicyListCreationWindow = true;
+                                  return Task.CompletedTask;
+                              })">
         <span class="oi oi-plus" aria-hidden="true"> Create</span>
     </LinkButton>
 </h3>
@@ -137,7 +137,7 @@
                 if (policies.Count == 0) return null;
                 Status2 = $"Found legacy list {room.RoomId}...";
                 return await RoomInfo.FromRoom(room, state, true);
-            }).ToAsyncEnumerable();
+            }).ToAsyncResultEnumerable();
 
         await foreach (var room in rooms) {
             if (room is not null) {
@@ -145,7 +145,7 @@
                 StateHasChanged();
             }
         }
-        
+
         isLoading = false;
         Status = "";
         Status2 = "";
diff --git a/MatrixUtils.Web/Pages/StreamTest.razor b/MatrixUtils.Web/Pages/StreamTest.razor
 index 8b9735e..7740596 100644
--- a/MatrixUtils.Web/Pages/StreamTest.razor
+++ b/MatrixUtils.Web/Pages/StreamTest.razor
@@ -89,7 +89,7 @@
         // 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();
+        // .ToAsyncResultEnumerable();
         await foreach (var result in GetStreamsDelayed(url)) {
             Streams.Add(result);
             // await Task.Delay(100);
@@ -107,7 +107,7 @@
         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();
+                .ToAsyncResultEnumerable();
             await foreach (var result in tasks) {
                 yield return result;
             }
diff --git a/MatrixUtils.Web/Pages/Tools/Debug/MigrateRoom.razor b/MatrixUtils.Web/Pages/Tools/Debug/MigrateRoom.razor
 index b0f7dbf..067036e 100644
--- a/MatrixUtils.Web/Pages/Tools/Debug/MigrateRoom.razor
+++ b/MatrixUtils.Web/Pages/Tools/Debug/MigrateRoom.razor
@@ -53,8 +53,8 @@
         var oldRoom = hs.GetRoom(roomId);
         var newRoom = hs.GetRoom(newRoomId);
         var members = await oldRoom.GetMembersListAsync();
-        var tasks = members.Select(x => ExecuteInvite(hs, newRoom, x.StateKey)).ToAsyncEnumerable();
-        // var tasks = hss.Select(ExecuteInvite).ToAsyncEnumerable();
+        var tasks = members.Select(x => ExecuteInvite(hs, newRoom, x.StateKey)).ToAsyncResultEnumerable();
+        // var tasks = hss.Select(ExecuteInvite).ToAsyncResultEnumerable();
         await foreach (var a in tasks) {
             if (!string.IsNullOrWhiteSpace(a)) {
                 log.Add(a);
diff --git a/MatrixUtils.Web/Pages/Tools/Info/KnownHomeserverList.razor b/MatrixUtils.Web/Pages/Tools/Info/KnownHomeserverList.razor
 index acad827..8ba160a 100644
--- a/MatrixUtils.Web/Pages/Tools/Info/KnownHomeserverList.razor
+++ b/MatrixUtils.Web/Pages/Tools/Info/KnownHomeserverList.razor
@@ -41,13 +41,13 @@
         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(roomId => workerService.TaskPool.Invoke(() => InternalGetMembersByHomeserver(hs.WellKnownUris.Client, hs.AccessToken, roomId.RoomId))).ToList().ToAsyncResultEnumerable();
         // var fetchTasks = rooms.Select(async x => {
-            // await ss.WaitAsync();
-            // var res = await x.GetMembersByHomeserverAsync();
-            // ss.Release();
-            // return res;
-        // }).ToAsyncEnumerable();
+        // await ss.WaitAsync();
+        // var res = await x.GetMembersByHomeserverAsync();
+        // ss.Release();
+        // return res;
+        // }).ToAsyncResultEnumerable();
         await foreach (var result in fetchTasks) {
             foreach (var (resHomeserver, resMembers) in result) {
                 if (!homeservers.TryAdd(resHomeserver, resMembers)) {
diff --git a/MatrixUtils.Web/Pages/Tools/Info/PolicyListActivity.razor b/MatrixUtils.Web/Pages/Tools/Info/PolicyListActivity.razor
 index f8d1d31..bfd5fd3 100644
--- a/MatrixUtils.Web/Pages/Tools/Info/PolicyListActivity.razor
+++ b/MatrixUtils.Web/Pages/Tools/Info/PolicyListActivity.razor
@@ -83,7 +83,7 @@ else
             foreach (var message in response.Chunk) {
                 if (!message.MappedType.IsAssignableTo(typeof(PolicyRuleEventContent))) continue;
                 //OriginServerTs to datetime
-                var dt = DateTimeOffset.FromUnixTimeMilliseconds((long)message.OriginServerTs!.Value).DateTime;
+                var dt = DateTimeOffset.FromUnixTimeMilliseconds(message.OriginServerTs!.Value).DateTime;
                 var date = new DateOnly(dt.Year, dt.Month, dt.Day);
                 if (!RoomData[roomName].ContainsKey(date.Year)) {
                     RoomData[roomName][date.Year] = new();
@@ -100,9 +100,8 @@ else
                 else rgb.B++;
                 RoomData[roomName][date.Year][date] = rgb;
             }
-
-            
         }
+
         var max = RoomData.SelectMany(x => x.Value.Values).Aggregate(new ActivityGraph.RGB(), (current, next) => new() {
             R = Math.Max(current.R, next.Average(x => x.Value.R)),
             G = Math.Max(current.G, next.Average(x => x.Value.G)),
diff --git a/MatrixUtils.Web/Pages/Tools/Info/SessionCount.razor b/MatrixUtils.Web/Pages/Tools/Info/SessionCount.razor
 index fcdb3d0..dc5333b 100644
--- a/MatrixUtils.Web/Pages/Tools/Info/SessionCount.razor
+++ b/MatrixUtils.Web/Pages/Tools/Info/SessionCount.razor
@@ -11,7 +11,8 @@
 <p>Users: </p>
 <InputTextArea @bind-Value="@UserIdString"></InputTextArea>
 <br/>
-<InputText @bind-Value="@ImportFromRoomId"></InputText><LinkButton OnClickAsync="@DoImportFromRoomId">Import from room (ID)</LinkButton>
+<InputText @bind-Value="@ImportFromRoomId"></InputText>
+<LinkButton OnClickAsync="@DoImportFromRoomId">Import from room (ID)</LinkButton>
 
 <details>
     <summary>Rooms to be searched (@rooms.Count)</summary>
@@ -44,9 +45,7 @@
         @foreach (var (userId, events) in matches) {
             <p>
                 <span>@userId.PadRight(col1Width)</span>
-                @foreach (var @event in events) {
-    
-}
+                @foreach (var @event in events) { }
             </p>
         }
     </pre>
@@ -97,7 +96,7 @@
         rooms = new ObservableCollection<GenericRoom>(distinctRooms);
         rooms.CollectionChanged += (sender, args) => StateHasChanged();
 
-        var stateTasks = rooms.Select(async x => (x, await x.GetMembersListAsync())).ToAsyncEnumerable();
+        var stateTasks = rooms.Select(async x => (x, await x.GetMembersListAsync())).ToAsyncResultEnumerable();
 
         await foreach (var (room, state) in stateTasks) {
             roomMembers.Add(room, state);
diff --git a/MatrixUtils.Web/Pages/Tools/Moderation/FindUsersByRegex.razor b/MatrixUtils.Web/Pages/Tools/Moderation/FindUsersByRegex.razor
 index 1fd0ff6..5ad9de4 100644
--- a/MatrixUtils.Web/Pages/Tools/Moderation/FindUsersByRegex.razor
+++ b/MatrixUtils.Web/Pages/Tools/Moderation/FindUsersByRegex.razor
@@ -161,7 +161,7 @@
             }
 
             return null;
-        }).ToAsyncEnumerable();
+        }).ToAsyncResultEnumerable();
         await foreach (var result in results) {
             if (result is not null) {
                 yield return result;
diff --git a/MatrixUtils.Web/Pages/Tools/Moderation/UserTrace.razor b/MatrixUtils.Web/Pages/Tools/Moderation/UserTrace.razor
 index f39a2eb..2261cb8 100644
--- a/MatrixUtils.Web/Pages/Tools/Moderation/UserTrace.razor
+++ b/MatrixUtils.Web/Pages/Tools/Moderation/UserTrace.razor
@@ -26,10 +26,10 @@
 
 <details>
     <summary>Results</summary>
-    @foreach (var (userId, events) in matches.OrderBy(x=>x.Key)) {
+    @foreach (var (userId, events) in matches.OrderBy(x => x.Key)) {
         <h4>@userId</h4>
         <table>
-            @foreach (var match in events.OrderBy(x=>x.RoomName)) {
+            @foreach (var match in events.OrderBy(x => x.RoomName)) {
                 <tr>
                     <td>@match.RoomName (<span>@match.Room.RoomId</span>)</td>
                     <td>
@@ -161,7 +161,7 @@
             }
 
             return null;
-        }).ToAsyncEnumerable();
+        }).ToAsyncResultEnumerable();
         await foreach (var result in results) {
             if (result is not null) {
                 yield return result;
@@ -186,10 +186,9 @@
             _ => $"Unknown membership {membership.Membership}, sent at {time} by {state.Sender} for {membership.Reason}"
         };
     }
-    
+
     private async Task ExportJson() {
         var json = matches.ToJson();
-        
     }
 
 }
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Tools/User/CopyPowerlevel.razor b/MatrixUtils.Web/Pages/Tools/User/CopyPowerlevel.razor
 index b893970..acc86a2 100644
--- a/MatrixUtils.Web/Pages/Tools/User/CopyPowerlevel.razor
+++ b/MatrixUtils.Web/Pages/Tools/User/CopyPowerlevel.razor
@@ -42,7 +42,7 @@
     private async Task Execute() {
         foreach (var hs in hss) {
             var rooms = await hs.GetJoinedRooms();
-            var tasks = rooms.Select(x=>ApplyPowerlevelsInRoom(hs, x)).ToAsyncEnumerable();
+            var tasks = rooms.Select(x => ApplyPowerlevelsInRoom(hs, x)).ToAsyncResultEnumerable();
             await foreach (var a in tasks) {
                 if (!string.IsNullOrWhiteSpace(a)) {
                     log.Add(a);
@@ -62,12 +62,11 @@
                     log.Add("I am same PL in " + room.RoomId);
                     continue;
                 }
-                
+
                 pls.SetUserPowerLevel(ahs.WhoAmI.UserId, pls.GetUserPowerLevel(hs.WhoAmI.UserId));
                 await room.SendStateEventAsync(RoomPowerLevelEventContent.EventId, pls);
                 log.Add($"Updated powerlevel of {room.RoomId} to {pls.GetUserPowerLevel(ahs.WhoAmI.UserId)}");
             }
-
         }
         catch (MatrixException e) {
             return $"Failed to update PLs in {room.RoomId}: {e.Message}";
@@ -75,6 +74,7 @@
         catch (Exception e) {
             return $"Failed to update PLs in {room.RoomId}: {e.Message}";
         }
+
         StateHasChanged();
         return "";
     }
diff --git a/MatrixUtils.Web/Pages/Tools/User/MassJoinRoom.razor b/MatrixUtils.Web/Pages/Tools/User/MassJoinRoom.razor
 index 748f2fb..ee17f1d 100644
--- a/MatrixUtils.Web/Pages/Tools/User/MassJoinRoom.razor
+++ b/MatrixUtils.Web/Pages/Tools/User/MassJoinRoom.razor
@@ -42,23 +42,24 @@
     }
 
     private async Task Execute() {
-    // foreach (var hs in hss) {
-    // var rooms = await hs.GetJoinedRooms();
-        var tasks = hss.Select(ExecuteInvite).ToAsyncEnumerable();
+        // foreach (var hs in hss) {
+        // var rooms = await hs.GetJoinedRooms();
+        var tasks = hss.Select(ExecuteInvite).ToAsyncResultEnumerable();
         await foreach (var a in tasks) {
             if (!string.IsNullOrWhiteSpace(a)) {
                 log.Add(a);
                 StateHasChanged();
             }
         }
-        tasks = hss.Select(ExecuteJoin).ToAsyncEnumerable();
+
+        tasks = hss.Select(ExecuteJoin).ToAsyncResultEnumerable();
         await foreach (var a in tasks) {
             if (!string.IsNullOrWhiteSpace(a)) {
                 log.Add(a);
                 StateHasChanged();
             }
         }
-    // }
+        // }
     }
 
     private async Task<string> ExecuteInvite(AuthenticatedHomeserverGeneric hs) {
@@ -69,6 +70,7 @@
                 if (joinRule.JoinRule == RoomJoinRulesEventContent.JoinRules.Public) return "Room is public, no invite needed";
             }
             catch { }
+
             var pls = await room.GetPowerLevelsAsync();
             if (pls.GetUserPowerLevel(hs.WhoAmI.UserId) < pls.Invite) return "I do not have permission to send invite in " + room.RoomId;
             await room.InviteUsersAsync(hss.Select(x => x.WhoAmI.UserId).ToList());
@@ -80,6 +82,7 @@
         catch (Exception e) {
             return $"Failed to invite in {room.RoomId}: {e.Message}";
         }
+
         StateHasChanged();
         return "";
     }
@@ -92,6 +95,7 @@
                 if (mse?.Membership == "join") return $"User {hs.WhoAmI.UserId} already in room";
             }
             catch { }
+
             await room.JoinAsync();
         }
         catch (MatrixException e) {
@@ -100,6 +104,7 @@
         catch (Exception e) {
             return $"Failed to join {hs.WhoAmI.UserId} to {room.RoomId}: {e.Message}";
         }
+
         StateHasChanged();
         return "";
     }
diff --git a/MatrixUtils.Web/Pages/Tools/User/StickerManager.razor b/MatrixUtils.Web/Pages/Tools/User/StickerManager.razor
 index 984130f..0e838c7 100644
--- a/MatrixUtils.Web/Pages/Tools/User/StickerManager.razor
+++ b/MatrixUtils.Web/Pages/Tools/User/StickerManager.razor
@@ -70,7 +70,7 @@
                 return room.RoomId;
             })
             .ToList();
-        await foreach (var roomScanResult in roomScanTasks.ToAsyncEnumerable()) {
+        await foreach (var roomScanResult in roomScanTasks.ToAsyncResultEnumerable()) {
             _observableProgressState.Label.Value = roomScanResult;
         }
     }
diff --git a/MatrixUtils.Web/Pages/User/Profile.razor b/MatrixUtils.Web/Pages/User/Profile.razor
 index 290b92a..2b7b6cf 100644
--- a/MatrixUtils.Web/Pages/User/Profile.razor
+++ b/MatrixUtils.Web/Pages/User/Profile.razor
@@ -12,9 +12,13 @@
     <div>
         <MxcAvatar Homeserver="@Homeserver" 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>
-            <InputFile OnChange="@AvatarChanged"></InputFile><br/>
+            <span>Display name: </span>
+            <FancyTextBox @bind-Value="@NewProfile.DisplayName"></FancyTextBox>
+            <br/>
+            <span>Avatar URL: </span>
+            <FancyTextBox @bind-Value="@NewProfile.AvatarUrl"></FancyTextBox>
+            <InputFile OnChange="@AvatarChanged"></InputFile>
+            <br/>
             <LinkButton OnClickAsync="@(() => UpdateProfile())">Update profile</LinkButton>
             <LinkButton OnClickAsync="@(() => UpdateProfile(true))">Update profile (restore room overrides)</LinkButton>
         </div>
@@ -26,7 +30,9 @@
     <br/>
 
     @* <details> *@
-    <h4>Room profiles<hr></h4>
+    <h4>Room profiles
+        <hr>
+    </h4>
 
     @foreach (var room in Rooms) {
         <details class="details-compact">
@@ -41,9 +47,15 @@
                 @* <img src="@Homeserver.ResolveMediaUri(room.OwnMembership.AvatarUrl)" style="width: 96px; height: 96px; border-radius: 50%; object-fit: cover;"/> *@
                 <MxcAvatar Homeserver="@Homeserver" 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>
-                    <InputFile OnChange="@(ifcea => RoomAvatarChanged(ifcea, room.Room.RoomId))"></InputFile><br/>
+                    <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>
+                    <InputFile OnChange="@(ifcea => RoomAvatarChanged(ifcea, room.Room.RoomId))"></InputFile>
+                    <br/>
                     <LinkButton OnClickAsync="@(() => UpdateRoomProfile(room.Room.RoomId))">Update profile</LinkButton>
                 </div>
                 <br/>
@@ -117,7 +129,7 @@
             });
             roomInfoTasks.Add(task);
         }
-        
+
         await Task.WhenAll(roomInfoTasks);
 
         StateHasChanged();
@@ -126,7 +138,7 @@
         // 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();
+        // }).ToAsyncResultEnumerable();
 
         // await foreach (var (roomId, roomName) in roomNameTasks) {
         // Status = $"Got room name for {roomId}: {roomName}";
diff --git a/MatrixUtils.Web/Shared/FilterComponents/BooleanFilterComponent.razor b/MatrixUtils.Web/Shared/FilterComponents/BooleanFilterComponent.razor
new file mode 100644
 index 0000000..0730701
--- /dev/null
+++ b/MatrixUtils.Web/Shared/FilterComponents/BooleanFilterComponent.razor
@@ -0,0 +1,17 @@
+@using LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Filters
+<span>
+    <InputCheckbox @bind-Value="@Filter.Enabled"/> @Label:
+    @if (Filter.Enabled) {
+        <InputCheckbox @bind-Value="@Filter.Value"/>
+    }
+</span>
+
+@code {
+
+    [Parameter]
+    public required BoolFilter Filter { get; set; }
+
+    [Parameter]
+    public required string Label { get; set; }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Shared/FilterComponents/StringFilterComponent.razor b/MatrixUtils.Web/Shared/FilterComponents/StringFilterComponent.razor
new file mode 100644
 index 0000000..c5a6e15
--- /dev/null
+++ b/MatrixUtils.Web/Shared/FilterComponents/StringFilterComponent.razor
@@ -0,0 +1,31 @@
+@using LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Filters
+<span style="vertical-align: top;">
+    <InputCheckbox @bind-Value="@Filter.Enabled"/> @Label: 
+</span>
+@if (Filter.Enabled) {
+    <div style="display: inline-block;">
+        <InputCheckbox @bind-Value="@Filter.CheckValueContains"/>
+        Contains
+        <FancyTextBox @bind-Value="@Filter.ValueContains"></FancyTextBox>
+        <br/>
+        <InputCheckbox @bind-Value="@Filter.CheckValueEquals"/>
+        Equals
+        <FancyTextBox @bind-Value="@Filter.ValueEquals"></FancyTextBox>
+        <LinkButton OnClick="@SetEqualsNull" InlineText="true"> [Set null]</LinkButton>
+    </div>
+}
+
+@code {
+
+    [Parameter]
+    public required StringFilter Filter { get; set; }
+
+    [Parameter]
+    public required string Label { get; set; }
+
+    private void SetEqualsNull() {
+        Filter.ValueEquals = null;
+        StateHasChanged();
+    }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/wwwroot/index.html b/MatrixUtils.Web/wwwroot/index.html
 index f25d549..0a80cff 100644
--- a/MatrixUtils.Web/wwwroot/index.html
+++ b/MatrixUtils.Web/wwwroot/index.html
@@ -12,6 +12,7 @@
         <link rel="apple-touch-icon" sizes="512x512" href="icon-512.png"/>
         <link href="favicon.png" rel="icon" type="image/png"/>
         <link href="MatrixUtils.Web.styles.css" rel="stylesheet"/>
+        <link rel="preload" id="webassembly"/>
     </head>
 
     <body>
@@ -49,11 +50,11 @@
             }
 
             setImageStream = async (element, imageStream) => {
-                if(!(element instanceof HTMLElement)) {
+                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);
@@ -65,7 +66,7 @@
             }
         </script>
         <script src="_framework/blazor.webassembly.js"></script>
-<!--        <script>navigator.serviceWorker.register('service-worker.js');</script>-->
+        <!--        <script>navigator.serviceWorker.register('service-worker.js');</script>-->
         <script src="sw-registrator.js"></script>
     </body>
 
diff --git a/MatrixUtils.Web/wwwroot/sw-registrator.js b/MatrixUtils.Web/wwwroot/sw-registrator.js
 index 94b96b2..67aa5cb 100644
--- a/MatrixUtils.Web/wwwroot/sw-registrator.js
+++ b/MatrixUtils.Web/wwwroot/sw-registrator.js
@@ -8,7 +8,7 @@ window.updateAvailable = new Promise((resolve, reject) => {
         return;
     }
 
-    navigator.serviceWorker.register('/service-worker.js')
+    navigator.serviceWorker.register('/service-worker.js', {updateViaCache: 'none'})
         .then(registration => {
             console.info(`Service worker registration successful (scope: ${registration.scope})`);
 
  |