about summary refs log tree commit diff
path: root/MatrixUtils.Web/Pages
diff options
context:
space:
mode:
Diffstat (limited to 'MatrixUtils.Web/Pages')
-rw-r--r--MatrixUtils.Web/Pages/About.razor12
-rw-r--r--MatrixUtils.Web/Pages/Dev/DevOptions.razor71
-rw-r--r--MatrixUtils.Web/Pages/Dev/DevUtilities.razor78
-rw-r--r--MatrixUtils.Web/Pages/Dev/ModalTest.razor88
-rw-r--r--MatrixUtils.Web/Pages/HSAdmin/HSAdmin.razor34
-rw-r--r--MatrixUtils.Web/Pages/HSAdmin/RoomQuery.razor200
-rw-r--r--MatrixUtils.Web/Pages/HSAdmin/RoomQuery.razor.css0
-rw-r--r--MatrixUtils.Web/Pages/Index.razor191
-rw-r--r--MatrixUtils.Web/Pages/Index.razor.css25
-rw-r--r--MatrixUtils.Web/Pages/InvalidSession.razor100
-rw-r--r--MatrixUtils.Web/Pages/LoginPage.razor160
-rw-r--r--MatrixUtils.Web/Pages/ModerationUtilities/UserRoomHistory.razor115
-rw-r--r--MatrixUtils.Web/Pages/Rooms/Create.razor338
-rw-r--r--MatrixUtils.Web/Pages/Rooms/Index.razor250
-rw-r--r--MatrixUtils.Web/Pages/Rooms/PolicyList.razor267
-rw-r--r--MatrixUtils.Web/Pages/Rooms/Space.razor100
-rw-r--r--MatrixUtils.Web/Pages/Rooms/StateEditor.razor144
-rw-r--r--MatrixUtils.Web/Pages/Rooms/StateViewer.razor127
-rw-r--r--MatrixUtils.Web/Pages/Rooms/Timeline.razor60
-rw-r--r--MatrixUtils.Web/Pages/ServerInfo.razor235
-rw-r--r--MatrixUtils.Web/Pages/Tools/CopyPowerlevel.razor84
-rw-r--r--MatrixUtils.Web/Pages/Tools/KnownHomeserverList.razor54
-rw-r--r--MatrixUtils.Web/Pages/Tools/MassJoinRoom.razor110
-rw-r--r--MatrixUtils.Web/Pages/Tools/MediaLocator.razor111
-rw-r--r--MatrixUtils.Web/Pages/Tools/SpaceDebug.razor113
-rw-r--r--MatrixUtils.Web/Pages/Tools/ToolsIndex.razor8
-rw-r--r--MatrixUtils.Web/Pages/User/DMManager.razor62
-rw-r--r--MatrixUtils.Web/Pages/User/DMSpace.razor86
-rw-r--r--MatrixUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage0.razor11
-rw-r--r--MatrixUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage1.razor128
-rw-r--r--MatrixUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage2.razor242
-rw-r--r--MatrixUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage3.razor191
-rw-r--r--MatrixUtils.Web/Pages/User/Profile.razor134
33 files changed, 3929 insertions, 0 deletions
diff --git a/MatrixUtils.Web/Pages/About.razor b/MatrixUtils.Web/Pages/About.razor
new file mode 100644
index 0000000..18d7c3f
--- /dev/null
+++ b/MatrixUtils.Web/Pages/About.razor
@@ -0,0 +1,12 @@
+@page "/About"
+
+<PageTitle>About</PageTitle>
+
+<h3>Rory&::MatrixUtils - About</h3>
+<hr/>
+<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>
+<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
new file mode 100644
index 0000000..6ca0b53
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Dev/DevOptions.razor
@@ -0,0 +1,71 @@
+@page "/Dev/Options"
+@using ArcaneLibs.Extensions
+@using System.Text.Unicode
+@using System.Text
+@using System.Text.Json
+@inject NavigationManager NavigationManager
+@inject ILocalStorageService LocalStorage
+
+<PageTitle>Developer options</PageTitle>
+
+<h3>Rory&::MatrixUtils - Developer options</h3>
+<hr/>
+
+<p>
+    <span>Import local storage: </span>
+    <InputFile OnChange="ImportLocalStorage"></InputFile>
+</p>
+<p>
+    <span>Export local storage: </span>
+    <button @onclick="@ExportLocalStorage">Export</button>
+</p>
+
+@if (userSettings is not null) {
+    <InputCheckbox @bind-Value="@userSettings.DeveloperSettings.EnableLogViewers" @oninput="@LogStuff"></InputCheckbox>
+    <label> Enable log views</label>
+    <br/>
+    <InputCheckbox @bind-Value="@userSettings.DeveloperSettings.EnableConsoleLogging" @oninput="@LogStuff"></InputCheckbox>
+    <label> Enable console logging</label>
+    <br/>
+    <InputCheckbox @bind-Value="@userSettings.DeveloperSettings.EnablePortableDevtools" @oninput="@LogStuff"></InputCheckbox>
+    <label> Enable portable devtools</label>
+    <br/>
+}
+<br/>
+
+@code {
+
+    private RMUStorageWrapper.Settings? userSettings { get; set; }
+    protected override async Task OnInitializedAsync() {
+        // userSettings = await TieredStorage.DataStorageProvider.LoadObjectAsync<RMUStorageWrapper.Settings>("rmu.settings");
+        
+        await base.OnInitializedAsync();
+    }
+
+    private async Task LogStuff() {
+        await Task.Delay(100);
+        Console.WriteLine($"Settings: {userSettings.ToJson()}");
+        await TieredStorage.DataStorageProvider.SaveObjectAsync("rmu.settings", userSettings);
+    }
+
+    private async Task ExportLocalStorage() {
+        var keys = await TieredStorage.DataStorageProvider.GetAllKeysAsync();
+        var data = new Dictionary<string, object>();
+        foreach (var key in keys) {
+            data.Add(key, await TieredStorage.DataStorageProvider.LoadObjectAsync<object>(key));
+        }
+        var dataUri = "data:application/json;base64,";
+        dataUri += Convert.ToBase64String(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(data))); 
+        await JSRuntime.InvokeVoidAsync("window.open", dataUri, "_blank");
+    }
+
+    private async Task ImportLocalStorage(InputFileChangeEventArgs obj) {
+        if (obj.FileCount != 1) return;
+        var data = await JsonSerializer.DeserializeAsync<Dictionary<string, object>>(obj.File.OpenReadStream());
+        foreach (var (key, value) in data) {
+            await TieredStorage.DataStorageProvider.SaveObjectAsync(key, value);
+        }
+        NavigationManager.NavigateTo(NavigationManager.Uri, true, true);
+    }
+
+}
diff --git a/MatrixUtils.Web/Pages/Dev/DevUtilities.razor b/MatrixUtils.Web/Pages/Dev/DevUtilities.razor
new file mode 100644
index 0000000..a6a4a82
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Dev/DevUtilities.razor
@@ -0,0 +1,78 @@
+@page "/Dev/Utilities"
+@using System.Reflection
+@using ArcaneLibs.Extensions
+@using LibMatrix.Extensions
+@using LibMatrix.Homeservers
+@using MatrixUtils.Abstractions
+@inject ILocalStorageService LocalStorage
+@inject NavigationManager NavigationManager
+<h3>Debug Tools</h3>
+<hr/>
+@if (Rooms.Count == 0) {
+    <p>You are not in any rooms!</p>
+    @* <p>Loading progress: @checkedRoomCount/@totalRoomCount</p> *@
+}
+else {
+    <details>
+        <summary>Room List</summary>
+        @foreach (var room in Rooms) {
+            <a style="color: unset; text-decoration: unset;" href="/RoomStateViewer/@room.Replace('.', '~')">
+                <RoomListItem RoomInfo="@(new RoomInfo() { Room = hs.GetRoom(room) })" LoadData="true"></RoomListItem>
+            </a>
+        }
+    </details>
+}
+
+<details open>
+    <summary>Send GET request to URL</summary>
+    <div class="input-group">
+        <input type="text" class="form-control" @bind-value="GetRequestUrl" placeholder="URL">
+        <button class="btn btn-outline-secondary" type="button" @onclick="SendGetRequest">Send</button>
+    </div>
+    <br/>
+    <pre>@GetRequestResult</pre>
+</details>
+
+<div style="margin-bottom: 4em;"></div>
+<LogView></LogView>
+
+@code {
+    public List<string> Rooms { get; set; } = new();
+    public AuthenticatedHomeserverGeneric? hs { get; set; }
+
+    protected override async Task OnInitializedAsync() {
+        await base.OnInitializedAsync();
+        hs = await RMUStorage.GetCurrentSessionOrNavigate();
+        if (hs == null) return;
+        Rooms = (await hs.GetJoinedRooms()).Select(x => x.RoomId).ToList();
+        Console.WriteLine("Fetched joined rooms!");
+    }
+
+    //send req
+    string GetRequestUrl { get; set; } = "";
+    string GetRequestResult { get; set; } = "";
+
+    private async Task SendGetRequest() {
+        var httpClient = hs?.ClientHttpClient;
+        try {
+            var res = await httpClient.GetAsync(GetRequestUrl);
+            if (res.IsSuccessStatusCode) {
+                if (res.Content.Headers.ContentType.MediaType == "application/json")
+                    GetRequestResult = (await res.Content.ReadFromJsonAsync<object>()).ToJson();
+                else
+                    GetRequestResult = await res.Content.ReadAsStringAsync();
+                StateHasChanged();
+                return;
+            }
+            if (res.Content.Headers.ContentType.MediaType == "application/json")
+                GetRequestResult = $"Error: {res.StatusCode}\n" + (await res.Content.ReadFromJsonAsync<object>()).ToJson();
+            else
+                GetRequestResult = $"Error: {res.StatusCode}\n" + await res.Content.ReadAsStringAsync();
+        }
+        catch (Exception e) {
+            GetRequestResult = $"Error: {e}";
+        }
+        StateHasChanged();
+    }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Dev/ModalTest.razor b/MatrixUtils.Web/Pages/Dev/ModalTest.razor
new file mode 100644
index 0000000..4a0487f
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Dev/ModalTest.razor
@@ -0,0 +1,88 @@
+@page "/Dev/ModalTest"
+@inject IJSRuntime JsRuntime
+<h3>ModalTest</h3>
+
+@foreach (var (key, value) in _windowInfos) {
+   @* <ModalWindow X="@value.X" Y="@value.Y" Title="@value.Title">@value.Content</ModalWindow> *@
+}
+@for (var i = 0; i < 5; i++) {
+     var i1 = i;
+     <ModalWindow X="@Random.Shared.Next(1400)" Y="@Random.Shared.Next(1000)" Title="@("Window " + i1)" OnCloseClicked="() => OnCloseClicked(i1)">
+          @for (var j = 0; j < i1; j++) {
+              <h1>@j</h1>
+          }
+     </ModalWindow>
+}
+
+@code {
+
+    private Dictionary<int, WindowInfo> _windowInfos = new();
+
+    private class WindowInfo {
+        public double X;
+        public double Y;
+        public string Title;
+        public RenderFragment Content;
+    }
+
+    protected override async Task OnInitializedAsync() {
+        double _x = 2;
+        double _xv = 20;
+        double _y = 0;
+        double multiplier = 1;
+
+        for (var i = 0; i < 200; i++) {
+            var i1 = i;
+            _windowInfos.Add(_windowInfos.Count, new WindowInfo {
+                X = _x,
+                Y = _y,
+                Title = "Win" + i1,
+                Content = builder => {
+                    builder.OpenComponent<ModalWindow>(0);
+                    builder.AddAttribute(1, "X", _x);
+                    builder.AddAttribute(2, "Y", _y);
+                    builder.AddAttribute(3, "Title", "Win" + i1);
+                    builder.AddAttribute(4, "ChildContent", (RenderFragment)(builder2 => {
+                        builder2.OpenElement(0, "h1");
+                        builder2.AddContent(1, "Hello " + i1);
+                        builder2.CloseElement();
+                    }));
+                    builder.CloseComponent();
+                }
+            });
+            //_x += _xv /= 1000/System.Math.Sqrt((double)_windowInfos.Count)*_windowInfos.Count.ToString().Length*multiplier;
+            _y += 20;
+	    _x += 20;
+            var dimension = await JsRuntime.InvokeAsync<WindowDimension>("getWindowDimensions");
+            if (_x > dimension.Width - 100) _x %= dimension.Width - 100;
+            if (_y > dimension.Height - 50) {
+                _y %= dimension.Height - 50;
+                _xv = 20;
+            }
+            if (
+                (_windowInfos.Count < 10 && _windowInfos.Count % 2 == 0) ||
+                (_windowInfos.Count < 100 && _windowInfos.Count % 10 == 0) ||
+                (_windowInfos.Count < 1000 && _windowInfos.Count % 50 == 0) ||
+                (_windowInfos.Count < 10000 && _windowInfos.Count % 100 == 0)
+                ) {
+                StateHasChanged();
+                await Task.Delay(25);
+            }
+            if(_windowInfos.Count > 750) multiplier = 2;
+            if(_windowInfos.Count > 1500) multiplier = 3;
+
+        }
+
+        await base.OnInitializedAsync();
+    }
+
+    private void OnCloseClicked(int i1) {
+        Console.WriteLine("Close clicked on " + i1);
+    }
+
+    public class WindowDimension {
+        public int Width { get; set; }
+        public int Height { get; set; }
+    }
+
+}
diff --git a/MatrixUtils.Web/Pages/HSAdmin/HSAdmin.razor b/MatrixUtils.Web/Pages/HSAdmin/HSAdmin.razor
new file mode 100644
index 0000000..6499f57
--- /dev/null
+++ b/MatrixUtils.Web/Pages/HSAdmin/HSAdmin.razor
@@ -0,0 +1,34 @@
+@page "/HSAdmin"
+@using LibMatrix.Homeservers
+@using ArcaneLibs.Extensions
+<h3>Homeserver Admininistration</h3>
+<hr/>
+
+@if (Homeserver is null) {
+    <p>Homeserver is null...</p>
+}
+else {
+    @if (Homeserver is AuthenticatedHomeserverSynapse) {
+        <h4>Synapse tools</h4>
+        <hr/>
+        <a href="/HSAdmin/RoomQuery">Query rooms</a>
+    }
+    else {
+        <p>Homeserver type @Homeserver.GetType().Name does not have any administration tools in RMU.</p>
+        <p>Server info:</p>
+        <pre>@ServerVersionResponse?.ToJson(ignoreNull: true)</pre>
+    }
+}
+
+@code {
+    public AuthenticatedHomeserverGeneric? Homeserver { get; set; }
+    public ServerVersionResponse? ServerVersionResponse { get; set; }
+
+    protected override async Task OnInitializedAsync() {
+        Homeserver = await RMUStorage.GetCurrentSessionOrNavigate();
+        if (Homeserver is null) return;
+        ServerVersionResponse = await (Homeserver.FederationClient?.GetServerVersionAsync() ?? Task.FromResult<ServerVersionResponse?>(null));
+        await base.OnInitializedAsync();
+    }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/HSAdmin/RoomQuery.razor b/MatrixUtils.Web/Pages/HSAdmin/RoomQuery.razor
new file mode 100644
index 0000000..10628ad
--- /dev/null
+++ b/MatrixUtils.Web/Pages/HSAdmin/RoomQuery.razor
@@ -0,0 +1,200 @@
+@page "/HSAdmin/RoomQuery"
+@using LibMatrix.Responses.Admin
+@using LibMatrix.Filters
+@using LibMatrix.Homeservers
+@using ArcaneLibs.Extensions
+
+<h3>Homeserver Administration - Room Query</h3>
+
+<label>Search name: </label>
+<InputText @bind-Value="SearchTerm"/><br/>
+<label>Order by: </label>
+<select @bind="OrderBy">
+    @foreach (var item in validOrderBy) {
+        <option value="@item.Key">@item.Value</option>
+    }
+</select><br/>
+<label>Ascending: </label>
+<InputCheckbox @bind-Value="Ascending"/><br/>
+<details>
+    <summary>
+        <span>Local filtering (slow)</span>
+
+    </summary>
+    <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>
+
+        <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>
+
+        <u style="display: block;">Ranges</u>
+        <span class="tile center-children">
+            <InputNumber max="@int.MaxValue" class="int-input" TValue="int" @bind-Value="@Filter.StateEventsGreaterThan"></InputNumber><span class="range-sep">state events</span><InputNumber max="@int.MaxValue" class="int-input" TValue="int" @bind-Value="@Filter.StateEventsLessThan"></InputNumber>
+        </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>
+        </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>
+        </span>
+    </div>
+</details>
+<button class="btn btn-primary" @onclick="Search">Search</button>
+<br/>
+
+@if (Results.Count > 0) {
+    <p>Found @Results.Count rooms</p>
+    <details>
+        <summary>TSV data (copy/paste)</summary>
+        <pre style="font-size: 0.6em;">
+            <table>
+                @foreach (var res in Results) {
+                    <tr>
+                        <td style="padding: 8px;">@res.RoomId@("\t")</td>
+                        <td style="padding: 8px;">@res.CanonicalAlias@("\t")</td>
+                        <td style="padding: 8px;">@res.Creator@("\t")</td>
+                        <td style="padding: 8px;">@res.Name</td>
+                    </tr>
+                }
+            </table>
+        </pre>
+    </details>
+}
+
+@foreach (var res in Results) {
+    <div style="background-color: #ffffff11; border-radius: 0.5em; display: block; margin-top: 4px; padding: 4px;">
+        <RoomListItem RoomName="@res.Name" RoomId="@res.RoomId"></RoomListItem>
+        <p>
+            @if (!string.IsNullOrWhiteSpace(res.CanonicalAlias)) {
+                <span>@res.CanonicalAlias (@res.RoomId)</span>
+                <br/>
+            }
+            else {
+                <span>@res.RoomId</span>
+                <br/>
+            }
+            @if (!string.IsNullOrWhiteSpace(res.Creator)) {
+                <span>Created by <InlineUserItem UserId="@res.Creator"></InlineUserItem></span>
+                <br/>
+            }
+        </p>
+        <span>@res.StateEvents state events</span><br/>
+        <span>@res.JoinedMembers members, of which @res.JoinedLocalMembers are on this server</span>
+        <details>
+            <summary>Full result data</summary>
+            <pre>@res.ToJson(ignoreNull: true)</pre>
+        </details>
+    </div>
+}
+
+<style>
+    .int-input {
+        width: 128px;
+    }
+    .tile {
+        display: inline-block;
+        padding: 4px;
+        border: 1px solid #ffffff22;
+    }
+    .tile280 {
+        min-width: 280px;
+    }
+    .tile150 {
+        min-width: 150px;
+    }
+    .range-sep {
+        display: inline-block;
+        padding: 4px;
+        width: 150px;
+    }
+    .range-sep::before {
+        content: "@("<") ";
+    }
+    .range-sep::after {
+        content: " @("<")";
+    }
+    .center-children {
+        text-align: center;
+    }
+</style>
+
+@code {
+
+    [Parameter]
+    [SupplyParameterFromQuery(Name = "order_by")]
+    public string? OrderBy { get; set; }
+
+    [Parameter]
+    [SupplyParameterFromQuery(Name = "name_search")]
+    public string SearchTerm { get; set; }
+
+    [Parameter]
+    [SupplyParameterFromQuery(Name = "ascending")]
+    public bool Ascending { get; set; }
+
+    public List<AdminRoomListingResult.AdminRoomListingResultRoom> Results { get; set; } = new();
+
+    private string Status { get; set; }
+
+    public LocalRoomQueryFilter Filter { get; set; } = new();
+
+    protected override Task OnParametersSetAsync() {
+        if (Ascending == null)
+            Ascending = true;
+        OrderBy ??= "name";
+        return Task.CompletedTask;
+    }
+
+    private async Task Search() {
+        Results.Clear();
+        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()) {
+                var room = searchRooms.Current;
+                Console.WriteLine("Hit: " + room.ToJson(false));
+                Results.Add(room);
+                if (Results.Count % 10 == 0)
+                    StateHasChanged();
+            }
+        }
+
+    }
+
+    private readonly Dictionary<string, string> validOrderBy = new() {
+        { "name", "Room name" },
+        { "canonical_alias", "Main alias address" },
+        { "joined_members", "Number of members (reversed)" },
+        { "joined_local_members", "Number of local members (reversed)" },
+        { "version", "Room version" },
+        { "creator", "Creator of the room" },
+        { "encryption", "End-to-end encryption algorithm" },
+        { "federatable", "Is room federated" },
+        { "public", "Visibility in room list" },
+        { "join_rules", "Join rules" },
+        { "guest_access", "Guest access" },
+        { "history_visibility", "Visibility of history" },
+        { "state_events", "Number of state events" }
+    };
+
+}
diff --git a/MatrixUtils.Web/Pages/HSAdmin/RoomQuery.razor.css b/MatrixUtils.Web/Pages/HSAdmin/RoomQuery.razor.css
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/MatrixUtils.Web/Pages/HSAdmin/RoomQuery.razor.css
diff --git a/MatrixUtils.Web/Pages/Index.razor b/MatrixUtils.Web/Pages/Index.razor
new file mode 100644
index 0000000..9c1bab6
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Index.razor
@@ -0,0 +1,191 @@
+@page "/"
+@inject ILogger<Index> logger
+@using LibMatrix.Responses
+@using LibMatrix
+@using LibMatrix.Homeservers
+@using ArcaneLibs.Extensions
+@using MatrixUtils.Web.Pages.Dev
+
+<PageTitle>Index</PageTitle>
+
+<h3>Rory&::MatrixUtils</h3>
+Small collection of tools to do not-so-everyday things.
+
+<br/><br/>
+<h5>Signed in accounts - <a href="/Login">Add new account</a></h5>
+<hr/>
+<form>
+    <table>
+        @foreach (var session in _sessions.OrderByDescending(x => x.UserInfo.RoomCount)) {
+            var _auth = session.UserAuth;
+            <tr class="user-entry">
+                <td>
+                    <img class="avatar" src="@session.UserInfo.AvatarUrl"/>
+                </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/>
+                    </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>
+                    }
+                    else {
+                        <p>Not proxied</p>
+                    }
+                    @if (_debug) {
+                        <p>T=@session.Homeserver.GetType().FullName</p>
+                        <p>D=@session.Homeserver.WhoAmI.DeviceId</p>
+                        <p>U=@session.Homeserver.WhoAmI.UserId</p>
+                    }
+                </td>
+                <td>
+                    <p>
+                        <LinkButton OnClick="@(() => ManageUser(_auth))">Manage</LinkButton>
+                        <LinkButton OnClick="@(() => RemoveUser(_auth))">Remove</LinkButton>
+                        <LinkButton OnClick="@(() => RemoveUser(_auth, true))">Log out</LinkButton>
+                    </p>
+                </td>
+            </tr>
+        }
+    </table>
+</form>
+
+@if (_offlineSessions.Count > 0) {
+    <br/>
+    <br/>
+    <h5>Sessions on unreachable servers</h5>
+    <hr/>
+    <form>
+        <table>
+            @foreach (var session in _offlineSessions) {
+                <tr class="user-entry">
+                    <td>
+                        <p>
+                            @{
+                                string[] parts = session.UserId.Split(':');
+                            }
+                            <span>@parts[0][1..]</span> on <span>@parts[1]</span>
+                            @if (!string.IsNullOrWhiteSpace(session.Proxy)) {
+                                <span class="badge badge-info"> (proxied via @session.Proxy)</span>
+                            }
+                        </p>
+                    </td>
+                    <td>
+                        <LinkButton OnClick="@(() => RemoveUser(session))">Remove</LinkButton>
+                    </td>
+                </tr>
+            }
+        </table>
+    </form>
+}
+
+@code
+{
+#if DEBUG
+    private const bool _debug = true;
+#else
+    private const bool _debug = false;
+#endif
+
+    private class AuthInfo {
+        public UserAuth UserAuth { get; set; }
+        public UserInfo UserInfo { get; set; }
+        public ServerVersionResponse ServerVersion { get; set; }
+        public AuthenticatedHomeserverGeneric Homeserver { get; set; }
+    }
+
+    // private Dictionary<UserAuth, UserInfo> _users = new();
+    private readonly List<AuthInfo> _sessions = [];
+    private readonly List<UserAuth> _offlineSessions = [];
+    private LoginResponse? _currentSession;
+
+    protected override async Task OnInitializedAsync() {
+        Console.WriteLine("Index.OnInitializedAsync");
+        _currentSession = await RMUStorage.GetCurrentToken();
+        _sessions.Clear();
+        _offlineSessions.Clear();
+        var tokens = await RMUStorage.GetAllTokens();
+        var profileTasks = tokens.Select(async token => {
+            UserInfo userInfo = new();
+            AuthenticatedHomeserverGeneric hs;
+            Console.WriteLine($"Getting hs for {token.ToJson()}");
+            try {
+                hs = await hsProvider.GetAuthenticatedWithToken(token.Homeserver, token.AccessToken, token.Proxy);
+            }
+            catch (MatrixException e) {
+                if (e.ErrorCode != "M_UNKNOWN_TOKEN") throw;
+                NavigationManager.NavigateTo("/InvalidSession?ctx=" + token.AccessToken);
+                return;
+
+            }
+            catch (Exception e) {
+                logger.LogError(e, $"Failed to instantiate AuthenticatedHomeserver for {token.ToJson()}, homeserver may be offline?", token.UserId);
+                _offlineSessions.Add(token);
+                return;
+            }
+            
+            Console.WriteLine($"Got hs for {token.ToJson()}");
+
+            var roomCountTask = hs.GetJoinedRooms();
+            var profile = await hs.GetProfileAsync(hs.WhoAmI.UserId);
+            userInfo.DisplayName = profile.DisplayName ?? hs.WhoAmI.UserId;
+            Console.WriteLine(profile.ToJson());
+            _sessions.Add(new() {
+                UserInfo = new() {
+                    AvatarUrl = string.IsNullOrWhiteSpace(profile.AvatarUrl) ? "https://api.dicebear.com/6.x/identicon/svg?seed=" + hs.WhoAmI.UserId : hs.ResolveMediaUri(profile.AvatarUrl),
+                    RoomCount = (await roomCountTask).Count,
+                    DisplayName = profile.DisplayName ?? hs.WhoAmI.UserId
+                },
+                UserAuth = token,
+                ServerVersion = await (hs.FederationClient?.GetServerVersionAsync() ?? Task.FromResult<ServerVersionResponse?>(null)),
+                Homeserver = hs
+            });
+        });
+        Console.WriteLine("Waiting for profile tasks");
+        await Task.WhenAll(profileTasks);
+        Console.WriteLine("Done waiting for profile tasks");
+        await base.OnInitializedAsync();
+    }
+
+    private class UserInfo {
+        internal string AvatarUrl { get; set; }
+        internal string DisplayName { get; set; }
+        internal int RoomCount { get; set; }
+    }
+
+    private async Task RemoveUser(UserAuth auth, bool logout = false) {
+        try {
+            if (logout) {
+                await (await hsProvider.GetAuthenticatedWithToken(auth.Homeserver, auth.AccessToken, auth.Proxy)).Logout();
+            }
+        }
+        catch (Exception e) {
+            if (e is MatrixException { ErrorCode: "M_UNKNOWN_TOKEN" }) {
+                //todo: handle this
+                return;
+            }
+
+            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 OnInitializedAsync();
+    }
+
+
+    private async Task SwitchSession(UserAuth auth) {
+        Console.WriteLine($"Switching to {auth.Homeserver} {auth.UserId} via {auth.Proxy}");
+        await RMUStorage.SetCurrentToken(auth);
+        await OnInitializedAsync();
+    }
+
+    private async Task ManageUser(UserAuth auth) {
+        await SwitchSession(auth);
+        NavigationManager.NavigateTo("/User/Profile");
+    }
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Index.razor.css b/MatrixUtils.Web/Pages/Index.razor.css
new file mode 100644
index 0000000..c6b7bd7
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Index.razor.css
@@ -0,0 +1,25 @@
+.user-entry {
+    margin-bottom: 1em;
+}
+
+.avatar {
+    width: 4em;
+    height: 4em;
+    border-radius: 50%;
+    margin-right: 0.5em;
+    vertical-align: middle;
+}
+
+.user-entry > td {
+    margin-right: 0.5em;
+    vertical-align: middle;
+}
+
+.user-info {
+    margin-bottom: 0.5em;
+    display: inline-block;
+    vertical-align: middle;
+}
+.user-info > p {
+    margin: 0;
+}
diff --git a/MatrixUtils.Web/Pages/InvalidSession.razor b/MatrixUtils.Web/Pages/InvalidSession.razor
new file mode 100644
index 0000000..e1a72ea
--- /dev/null
+++ b/MatrixUtils.Web/Pages/InvalidSession.razor
@@ -0,0 +1,100 @@
+@page "/InvalidSession"
+@using LibMatrix
+
+<PageTitle>Invalid session</PageTitle>
+
+<h3>Rory&::MatrixUtils - Invalid session encountered</h3>
+<p>A session was encountered that is no longer valid. This can happen if you have logged out of the account on another device, or if the access token has expired.</p>
+
+@if (_login is not null) {
+    <p>It appears that the affected user is @_login.UserId (@_login.DeviceId) on @_login.Homeserver!</p>
+    <LinkButton OnClick="@(OpenRefreshDialog)">Refresh token</LinkButton>
+    <LinkButton OnClick="@(RemoveUser)">Remove</LinkButton>
+
+    @if (_showRefreshDialog) {
+        <ModalWindow MinWidth="300" X="275" Y="300" Title="@($"Password for {_login.UserId}")">
+            <FancyTextBox IsPassword="true" @bind-Value="@_password"></FancyTextBox><br/>
+            <LinkButton OnClick="TryLogin">Log in</LinkButton>
+            @if (_loginException is not null) {
+                <pre style="color: red;">@_loginException.RawContent</pre>
+            }
+        </ModalWindow>
+    }
+}
+else {
+    <b>Something has gone wrong and the login was not passed along!</b>
+}
+
+@code
+{
+    [Parameter]
+    [SupplyParameterFromQuery(Name = "ctx")]
+    public string Context { get; set; }
+
+    private UserAuth? _login { get; set; }
+
+    private bool _showRefreshDialog { get; set; }
+
+    private string _password { get; set; } = "";
+
+    private MatrixException? _loginException { get; set; }
+
+    protected override async Task OnInitializedAsync() {
+        var tokens = await RMUStorage.GetAllTokens();
+        if (tokens is null || tokens.Count == 0) {
+            NavigationManager.NavigateTo("/Login");
+            return;
+        }
+
+        _login = tokens.FirstOrDefault(x => x.AccessToken == Context);
+
+        if (_login is null) {
+            Console.WriteLine($"Could not find {_login} in stored tokens!");
+        }
+
+        await base.OnInitializedAsync();
+    }
+
+    private async Task RemoveUser() {
+        await RMUStorage.RemoveToken(_login!);
+        if ((await RMUStorage.GetCurrentToken())!.AccessToken == _login!.AccessToken)
+            await RMUStorage.SetCurrentToken((await RMUStorage.GetAllTokens())?.FirstOrDefault());
+        await OnInitializedAsync();
+    }
+
+    private async Task OpenRefreshDialog() {
+        _showRefreshDialog = true;
+        StateHasChanged();
+        await Task.CompletedTask;
+    }
+
+    private async Task SwitchSession(UserAuth auth) {
+        Console.WriteLine($"Switching to {auth.Homeserver} {auth.AccessToken} {auth.UserId}");
+        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));
+            if (result is null) {
+                Console.WriteLine($"Failed to login to {_login.Homeserver} as {_login.UserId}!");
+                return;
+            }
+            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);
+            NavigationManager.NavigateTo("/");
+        }
+        catch (MatrixException e) {
+            Console.WriteLine($"Failed to login to {_login.Homeserver} as {_login.UserId}!");
+            Console.WriteLine(e);
+            _loginException = e;
+            StateHasChanged();
+        }
+    }
+}
diff --git a/MatrixUtils.Web/Pages/LoginPage.razor b/MatrixUtils.Web/Pages/LoginPage.razor
new file mode 100644
index 0000000..ec4f57d
--- /dev/null
+++ b/MatrixUtils.Web/Pages/LoginPage.razor
@@ -0,0 +1,160 @@
+@page "/Login"
+@using System.Text.Json
+@using System.Text.Json.Serialization
+@using LibMatrix
+@inject ILocalStorageService LocalStorage
+@inject IJSRuntime JsRuntime
+<h3>Login</h3>
+<hr/>
+
+<span style="display: block;">
+    <label>User ID:</label>
+    <span>@@</span><!--
+    --><FancyTextBox @bind-Value="@newRecordInput.Username"></FancyTextBox><!--
+    --><span>:</span><!--
+    --><FancyTextBox @bind-Value="@newRecordInput.Homeserver"></FancyTextBox>
+</span>
+<span style="display: block;">
+    <label>Password:</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="@AddRecord">Add account to queue</LinkButton>
+<LinkButton OnClick="@(() => Login(newRecordInput))">Log in</LinkButton>
+<br/>
+<br/>
+
+<h4>Import from TSV</h4>
+<hr/>
+<span>Import credentials from a TSV (Tab Separated Values) file</span><br/>
+<span>Columns: username, homeserver, password, proxy</span><br/>
+<span>Keep in mind there is no column header!</span><br/>
+<br/>
+<InputFile OnChange="@FileChanged" accept=".tsv"></InputFile>
+<br/>
+<br/>
+
+<table border="1">
+    <thead style="border-bottom: 1px solid white;">
+        <th style="min-width: 150px; text-align: center; border-right: 1px solid white;">Username</th>
+        <th style="min-width: 150px; text-align: center; border-right: 1px solid white;">Homeserver</th>
+        <th style="min-width: 150px; text-align: center; border-right: 1px solid white;">Password</th>
+        <th style="min-width: 150px; text-align: center; border-right: 1px solid white;">Proxy</th>
+        <th style="min-width: 150px; text-align: center;">Actions</th>
+    </thead>
+    @foreach (var record in records) {
+        var r = record;
+        <tr style="background-color: @(LoggedInSessions.Any(x => x.UserId == $"@{r.Username}:{r.Homeserver}" && x.Proxy == r.Proxy) ? "green" : "unset")">
+            <td style="border-width: 1px;">
+                <FancyTextBox @bind-Value="@r.Username"></FancyTextBox>
+            </td>
+            <td style="border-width: 1px;">
+                <FancyTextBox @bind-Value="@r.Homeserver"></FancyTextBox>
+            </td>
+            <td style="border-width: 1px;">
+                <FancyTextBox @bind-Value="@r.Password" IsPassword="true"></FancyTextBox>
+            </td>
+            <td style="border-width: 1px;">
+                <FancyTextBox @bind-Value="@r.Proxy"></FancyTextBox>
+            </td>
+            <td style="border-width: 1px;">
+                <a role="button" @onclick="() => records.Remove(r)">Remove</a>
+            </td>
+        </tr>
+        @if (r.Exception is MatrixException me) {
+            <tr>
+                <td style="border-width: 1px;">Exception:</td>
+                <td style="border-width: 1px;">@me.ErrorCode</td>
+                <td style="border-width: 1px;" colspan="3">@me.Error</td>
+            </tr>
+        }
+        else if (r.Exception is { } e) {
+            <tr>
+                <td style="border-width: 1px;">Exception:</td>
+                <td style="border-width: 1px;" colspan="4">@e.Message</td>
+            </tr>
+        }
+    }
+</table>
+<br/>
+<LinkButton OnClick="@LoginAll">Log in</LinkButton>
+
+
+@code {
+    readonly List<LoginStruct> records = new();
+    private LoginStruct newRecordInput = new();
+
+    List<UserAuth>? LoggedInSessions { get; set; } = new();
+
+    async Task LoginAll() {
+        var loginTasks = records.Select(Login);
+        await Task.WhenAll(loginTasks);
+    }
+
+    async Task Login(LoginStruct record) {
+        if (!records.Contains(record)) 
+            records.Add(record);
+        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)) {
+                Proxy = record.Proxy
+            };
+            if (result == null) {
+                Console.WriteLine($"Failed to login to {record.Homeserver} as {record.Username}!");
+                return;
+            }
+
+            Console.WriteLine($"Obtained access token for {result.UserId}!");
+
+            await RMUStorage.AddToken(result);
+            LoggedInSessions = await RMUStorage.GetAllTokens();
+        }
+        catch (Exception e) {
+            Console.WriteLine($"Failed to login to {record.Homeserver} as {record.Username}!");
+            Console.WriteLine(e);
+            record.Exception = e;
+        }
+
+        StateHasChanged();
+    }
+
+    private async Task FileChanged(InputFileChangeEventArgs obj) {
+        LoggedInSessions = await RMUStorage.GetAllTokens();
+        Console.WriteLine(JsonSerializer.Serialize(obj, new JsonSerializerOptions {
+            WriteIndented = true
+        }));
+        await using var rs = obj.File.OpenReadStream();
+        using var sr = new StreamReader(rs);
+        var tsvData = await sr.ReadToEndAsync();
+        records.Clear();
+        foreach (var line in tsvData.Split('\n')) {
+            string?[] parts = line.Split('\t');
+            if (parts.Length < 3)
+                continue;
+            string? via = parts.Length > 3 ? parts[3] : null;
+            records.Add(new() { Homeserver = parts[0], Username = parts[1], Password = parts[2], Proxy = via });
+        }
+    }
+
+    private async Task AddRecord() {
+        LoggedInSessions = await RMUStorage.GetAllTokens();
+        records.Add(newRecordInput);
+        newRecordInput = new();
+    }
+
+    private class LoginStruct {
+        public string? Homeserver { get; set; } = "";
+        public string? Username { get; set; } = "";
+        public string? Password { get; set; } = "";
+        public string? Proxy { get; set; }
+        
+        [JsonIgnore]
+        internal Exception? Exception { get; set; }
+    }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/ModerationUtilities/UserRoomHistory.razor b/MatrixUtils.Web/Pages/ModerationUtilities/UserRoomHistory.razor
new file mode 100644
index 0000000..5ba83e4
--- /dev/null
+++ b/MatrixUtils.Web/Pages/ModerationUtilities/UserRoomHistory.razor
@@ -0,0 +1,115 @@
+@page "/UserRoomHistory/{UserId}"
+@using LibMatrix.Homeservers
+@using LibMatrix
+@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.RoomTypes
+@using ArcaneLibs.Extensions
+@using MatrixUtils.Abstractions
+<h3>UserRoomHistory</h3>
+
+<span>Enter mxid: </span>
+<FancyTextBox @bind-Value="@UserId"></FancyTextBox>
+
+@if (string.IsNullOrWhiteSpace(UserId)) {
+    <p>UserId is null!</p>
+}
+else {
+    <p>Checked @checkedRooms.Count so far...</p>
+    @if (currentHs is not null) {
+        <p>Checking rooms from @currentHs.UserId's perspective</p>
+    }
+    else if (checkedRooms.Count > 1) {
+        <p>Done!</p>
+    }
+    @foreach (var (state, rooms) in matchingStates) {
+        <u>@state</u>
+        <br/>
+        @foreach (var roomInfo in rooms) {
+            <RoomListItem RoomInfo="roomInfo" LoadData="true"></RoomListItem>
+        }
+    }
+}
+
+@code {
+    private string? _userId;
+
+    [Parameter]
+    public string? UserId {
+        get => _userId;
+        set {
+            _userId = value;
+            FindMember(value);
+        }
+    }
+
+    private List<AuthenticatedHomeserverGeneric> hss = new();
+    private AuthenticatedHomeserverGeneric? currentHs { get; set; }
+
+    protected override async Task OnInitializedAsync() {
+        var hs = await RMUStorage.GetCurrentSessionOrNavigate();
+        if (hs is null) return;
+        var sessions = await RMUStorage.GetAllTokens();
+        foreach (var userAuth in sessions) {
+            var session = await RMUStorage.GetSession(userAuth);
+            if (session is not null) {
+                hss.Add(session);
+                StateHasChanged();
+            }
+        }
+
+        StateHasChanged();
+        Console.WriteLine("Rerendered!");
+        await base.OnInitializedAsync();
+        if (!string.IsNullOrWhiteSpace(UserId)) FindMember(UserId);
+    }
+
+    public Dictionary<string, List<RoomInfo>> matchingStates = new();
+    public List<string> checkedRooms = new();
+    private SemaphoreSlim _semaphoreSlim = new(1, 1);
+
+    public async Task FindMember(string mxid) {
+        await _semaphoreSlim.WaitAsync();
+        if (mxid != UserId) {
+            _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();
+            await foreach (var (room, state) in tasks) {
+                if (state is null) continue;
+                if (!matchingStates.ContainsKey(state.Membership))
+                    matchingStates.Add(state.Membership, new());
+                var roomInfo = new RoomInfo() {
+                    Room = room
+                };
+                matchingStates[state.Membership].Add(roomInfo);
+                roomInfo.StateEvents.Add(new() {
+                    Type = RoomNameEventContent.EventId,
+                    TypedContent = new RoomNameEventContent() {
+                        Name = await room.GetNameOrFallbackAsync(4)
+                    },
+                    RoomId = null, Sender = null, EventId = null //TODO implement
+                });
+                StateHasChanged();
+                if (mxid != UserId) {
+                    _semaphoreSlim.Release();
+                    return; //abort if changed
+                }
+            }
+            StateHasChanged();
+        }
+        currentHs = null;
+        StateHasChanged();
+        _semaphoreSlim.Release();
+    }
+
+    public async Task<(GenericRoom roomId, RoomMemberEventContent? content)> GetMembershipAsync(GenericRoom room, string mxid) {
+        return (room, await room.GetStateOrNullAsync<RoomMemberEventContent>(RoomMemberEventContent.EventId, mxid));
+    }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Rooms/Create.razor b/MatrixUtils.Web/Pages/Rooms/Create.razor
new file mode 100644
index 0000000..35b2ffb
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Rooms/Create.razor
@@ -0,0 +1,338 @@
+@page "/Rooms/Create"
+@using System.Text.Json
+@using System.Reflection
+@using ArcaneLibs.Extensions
+@using LibMatrix
+@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.EventTypes.Spec.State.RoomInfo
+@using LibMatrix.Homeservers
+@using LibMatrix.Responses
+@using MatrixUtils.Web.Classes.RoomCreationTemplates
+@* @* ReSharper disable once RedundantUsingDirective - Must not remove this, Rider marks this as "unused" when it's not */ *@
+
+<h3>Room Manager - Create Room</h3>
+
+@* <pre Contenteditable="true" @onkeypress="@JsonChanged" content="JsonString">@JsonString</pre> *@
+<style>
+     table.table-top-first-tr tr td:first-child {
+         vertical-align: top;
+     }
+ </style>
+<table class="table-top-first-tr">
+    <tr style="padding-bottom: 16px;">
+        <td>Preset:</td>
+        <td>
+            @if (Presets is null) {
+                <p style="color: red;">Presets is null!</p>
+            }
+            else {
+                <InputSelect @bind-Value="@RoomPreset">
+                    @foreach (var createRoomRequest in Presets) {
+                        <option value="@createRoomRequest.Key">@createRoomRequest.Key</option>
+                    }
+                </InputSelect>
+            }
+        </td>
+    </tr>
+    @if (creationEvent is not null) {
+        <tr>
+            <td>Room name:</td>
+            <td>
+                @if (creationEvent.Name is null) {
+                    <p style="color: red;">creationEvent.Name is null!</p>
+                }
+                else {
+                    <FancyTextBox @bind-Value="@creationEvent.Name"></FancyTextBox>
+                    <p>(#<FancyTextBox @bind-Value="@creationEvent.RoomAliasName"></FancyTextBox>:@Homeserver.WhoAmI.UserId.Split(':').Last())</p>
+                }
+            </td>
+        </tr>
+        <tr>
+            <td>Room type:</td>
+            <td>
+                @if (creationEvent.CreationContentBaseType is null) {
+                    <p style="color: red;">creationEvent._creationContentBaseType is null!</p>
+                }
+                else {
+                    <InputSelect @bind-Value="@creationEvent.CreationContentBaseType.Type">
+                        <option value="">Room</option>
+                        <option value="m.space">Space</option>
+                    </InputSelect>
+                    <FancyTextBox @bind-Value="@creationEvent.CreationContentBaseType.Type"></FancyTextBox>
+                }
+            </td>
+        </tr>
+        <tr>
+            <td style="padding-top: 16px;">History visibility:</td>
+            <td style="padding-top: 16px;">
+                <InputSelect @bind-Value="@historyVisibility.HistoryVisibility">
+                    <option value="invited">Invited</option>
+                    <option value="joined">Joined</option>
+                    <option value="shared">Shared</option>
+                    <option value="world_readable">World readable</option>
+                </InputSelect>
+            </td>
+        </tr>
+        <tr>
+            <td>Guest access:</td>
+            <td>
+                <ToggleSlider @bind-Value="guestAccessEvent.IsGuestAccessEnabled">
+                    @(guestAccessEvent.IsGuestAccessEnabled ? "Guests can join" : "Guests cannot join") (@guestAccessEvent.GuestAccess)
+                </ToggleSlider>
+                <InputSelect @bind-Value="@guestAccessEvent.GuestAccess">
+                    <option value="can_join">Can join</option>
+                    <option value="forbidden">Forbidden</option>
+                </InputSelect>
+            </td>
+        </tr>
+
+        <tr>
+            <td>Room icon:</td>
+            <td>
+                <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>
+                </div>
+            </td>
+        </tr>
+        <tr>
+            <td>Permissions:</td>
+            <details>
+                <summary>@creationEvent.PowerLevelContentOverride.Users.Count members</summary>
+                @foreach (var user in creationEvent.PowerLevelContentOverride.Events.Keys) {
+                    var _event = user;
+                    <tr>
+                        <td>
+                            <FancyTextBox Formatter="@GetPermissionFriendlyName"
+                                          Value="@_event"
+                                          ValueChanged="val => { creationEvent.PowerLevelContentOverride.Events.ChangeKey(_event, val); }">
+                            </FancyTextBox>:
+                        </td>
+                        <td>
+                            <input type="number" value="@creationEvent.PowerLevelContentOverride.Events[_event]" @oninput="val => { creationEvent.PowerLevelContentOverride.Events[_event] = int.Parse(val.Value.ToString()); }" @onfocusout="() => { creationEvent.PowerLevelContentOverride.Events = creationEvent.PowerLevelContentOverride.Events.OrderByDescending(x => x.Value).ThenBy(x => x.Key).ToDictionary(x => x.Key, x => x.Value); }"/>
+                        </td>
+                    </tr>
+                }
+                @foreach (var user in creationEvent.PowerLevelContentOverride.Users.Keys) {
+                    var _user = user;
+                    <tr>
+                        <td><FancyTextBox Value="@_user" ValueChanged="val => { creationEvent.PowerLevelContentOverride.Users.ChangeKey(_user, val); creationEvent.PowerLevelContentOverride.Users = creationEvent.PowerLevelContentOverride.Users.OrderByDescending(x => x.Value).ThenBy(x => x.Key).ToDictionary(x => x.Key, x => x.Value); }"></FancyTextBox>:</td>
+                        <td>
+                            <input type="number" value="@creationEvent.PowerLevelContentOverride.Users[_user]" @oninput="val => { creationEvent.PowerLevelContentOverride.Users[_user] = int.Parse(val.Value.ToString()); }"/>
+                        </td>
+                    </tr>
+                }
+            </details>
+        </tr>
+        <tr>
+            <td>Server ACLs:</td>
+            <td>
+                @if (serverAcl?.Allow is null) {
+                    <p>No allow rules exist!</p>
+                    <button @onclick="@(() => { serverAcl.Allow = new List<string> { "*" }; })">Create sane defaults</button>
+                }
+                else {
+                    <details>
+                        <summary>@((creationEvent["m.room.server_acls"].TypedContent as RoomServerACLEventContent).Allow.Count) allow rules</summary>
+                        @* <StringListEditor @bind-Items="@serverAcl.Allow"></StringListEditor> *@
+                    </details>
+                }
+                @if (serverAcl?.Deny is null) {
+                    <p>No deny rules exist!</p>
+                    <button @onclick="@(() => { serverAcl.Allow = new List<string>(); })">Create sane defaults</button>
+                }
+                else {
+                    <details>
+                        <summary>@((creationEvent["m.room.server_acls"].TypedContent as RoomServerACLEventContent).Deny.Count) deny rules</summary>
+                        @* <StringListEditor @bind-Items="@serverAcl.Allow"></StringListEditor> *@
+                    </details>
+                }
+            </td>
+        </tr>
+
+        <tr>
+            <td>Invited members:</td>
+            <td>
+                <details>
+                    <summary>@creationEvent.InitialState.Count(x => x.Type == "m.room.member") members</summary>
+                    @* <button @onclick="() => { RuntimeCache.LoginSessions.Select(x => x.Value.LoginResponse.UserId).ToList().ForEach(InviteMember); }">Invite all logged in accounts</button> *@
+                    @foreach (var member in creationEvent.InitialState.Where(x => x.Type == "m.room.member" && x.StateKey != Homeserver.UserId)) {
+                        <UserListItem UserId="@member.StateKey"></UserListItem>
+                    }
+                </details>
+            </td>
+        </tr>
+        @* Initial states, should remain at bottom *@
+        <tr>
+            <td style="vertical-align: top;">Initial states:</td>
+            <td>
+                <details>
+
+                    @code
+                    {
+                        private static readonly string[] ImplementedStates = { "m.room.avatar", "m.room.history_visibility", "m.room.guest_access", "m.room.server_acl" };
+                    }
+
+                    <summary> @creationEvent.InitialState.Count(x => !ImplementedStates.Contains(x.Type)) custom states</summary>
+                    <table>
+                        @foreach (var initialState in creationEvent.InitialState.Where(x => !ImplementedStates.Contains(x.Type))) {
+                            <tr>
+                                <td style="vertical-align: top;">
+                                    @(initialState.Type):
+                                    @if (!string.IsNullOrEmpty(initialState.StateKey)) {
+                                        <br/>
+                                        <span>(@initialState.StateKey)</span>
+                                    }
+
+                                </td>
+                                <td>
+                                    <pre>@JsonSerializer.Serialize(initialState.RawContent, new JsonSerializerOptions { WriteIndented = true })</pre>
+                                </td>
+                            </tr>
+                        }
+                    </table>
+                </details>
+                <details>
+                    <summary> @creationEvent.InitialState.Count initial states</summary>
+                    <table>
+                        @foreach (var initialState in creationEvent.InitialState) {
+                            var _state = initialState;
+                            <tr>
+                                <td style="vertical-align: top;">
+                                    <span>@(_state.Type):</span><br/>
+                                    <button @onclick="() => { creationEvent.InitialState.Remove(_state); StateHasChanged(); }">Remove</button>
+                                </td>
+
+                                <td>
+                                    <pre>@JsonSerializer.Serialize(_state.RawContent, new JsonSerializerOptions { WriteIndented = true })</pre>
+                                </td>
+                            </tr>
+                        }
+                    </table>
+                </details>
+            </td>
+        </tr>
+    }
+</table>
+<button @onclick="CreateRoom">Create room</button>
+<br/>
+<ModalWindow Title="Creation JSON">
+    <pre>
+        @creationEvent.ToJson(ignoreNull: true)
+    </pre>
+</ModalWindow>
+<ModalWindow Title="Creation JSON (with null values)">
+    <pre>
+        @creationEvent.ToJson()
+    </pre>
+</ModalWindow>
+
+@if (_matrixException is not null) {
+    <ModalWindow Title="@("Matrix exception: " + _matrixException.ErrorCode)">
+        <pre>
+            @_matrixException.Message
+        </pre>
+    </ModalWindow>
+}
+
+@code {
+
+    private string RoomPreset {
+        get => Presets.ContainsValue(creationEvent) ? Presets.First(x => x.Value == creationEvent).Key : "Not a preset";
+        set {
+            creationEvent = Presets[value];
+            JsonChanged();
+            StateHasChanged();
+        }
+    }
+
+    private CreateRoomRequest? creationEvent { get; set; }
+
+    private Dictionary<string, CreateRoomRequest>? Presets { get; set; } = new();
+    private AuthenticatedHomeserverGeneric? Homeserver { get; set; }
+
+    private MatrixException? _matrixException { get; set; }
+
+    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 RoomAvatarEventContent? roomAvatarEvent => creationEvent?["m.room.avatar"].TypedContent as RoomAvatarEventContent;
+
+    protected override async Task OnInitializedAsync() {
+        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()) {
+            Console.WriteLine($"Found room creation template in class: {x.FullName}");
+            var instance = (IRoomCreationTemplate)Activator.CreateInstance(x);
+            Presets[instance.Name] = instance.CreateRoomRequest;
+        }
+        Presets = Presets.OrderBy(x => x.Key).ToDictionary(x => x.Key, x => x.Value);
+
+        if (!Presets.ContainsKey("Default")) {
+            Console.WriteLine($"No default room found in {Presets.Count} presets: {string.Join(", ", Presets.Keys)}");
+        }
+        else RoomPreset = "Default";
+
+        await base.OnInitializedAsync();
+    }
+
+    private void JsonChanged() => Console.WriteLine(creationEvent.ToJson());
+
+    private async Task RoomIconFilePicked(InputFileChangeEventArgs obj) {
+        var res = await Homeserver.UploadFile(obj.File.Name, obj.File.OpenReadStream(), obj.File.ContentType);
+        Console.WriteLine(res);
+        (creationEvent["m.room.avatar"].TypedContent as RoomAvatarEventContent).Url = res;
+        StateHasChanged();
+    }
+
+    private async Task CreateRoom() {
+        Console.WriteLine("Create room");
+        Console.WriteLine(creationEvent.ToJson());
+        creationEvent.CreationContent.Add("rory.gay.created_using", "Rory&::MatrixUtils (https://rmu.rory.gay)");
+        try {
+            var id = await Homeserver.CreateRoom(creationEvent);
+        }
+        catch (MatrixException e) {
+            _matrixException = e;
+        }
+    }
+
+    private void InviteMember(string mxid) {
+        if (!creationEvent.InitialState.Any(x => x.Type == "m.room.member" && x.StateKey == mxid) && Homeserver.UserId != mxid)
+            creationEvent.InitialState.Add(new StateEvent {
+                Type = "m.room.member",
+                StateKey = mxid,
+                TypedContent = new RoomMemberEventContent {
+                    Membership = "invite",
+                    Reason = "Automatically invited at room creation time."
+                }
+            });
+    }
+
+    private string GetStateFriendlyName(string key) => key switch {
+        "m.room.history_visibility" => "History visibility",
+        "m.room.guest_access" => "Guest access",
+        "m.room.join_rules" => "Join rules",
+        "m.room.server_acl" => "Server ACL",
+        "m.room.avatar" => "Avatar",
+        _ => key
+        };
+
+    private string GetPermissionFriendlyName(string key) => key switch {
+        "m.reaction" => "Send reaction",
+        "m.room.avatar" => "Change room icon",
+        "m.room.canonical_alias" => "Change room alias",
+        "m.room.encryption" => "Enable encryption",
+        "m.room.history_visibility" => "Change history visibility",
+        "m.room.name" => "Change room name",
+        "m.room.power_levels" => "Change power levels",
+        "m.room.tombstone" => "Upgrade room",
+        "m.room.topic" => "Change room topic",
+        "m.room.pinned_events" => "Pin events",
+        "m.room.server_acl" => "Change server ACLs",
+        _ => key
+        };
+
+}
diff --git a/MatrixUtils.Web/Pages/Rooms/Index.razor b/MatrixUtils.Web/Pages/Rooms/Index.razor
new file mode 100644
index 0000000..0ec9487
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Rooms/Index.razor
@@ -0,0 +1,250 @@
+@page "/Rooms"
+@using LibMatrix.Filters
+@using LibMatrix.Helpers
+@using LibMatrix.Extensions
+@using LibMatrix.Responses
+@using System.Collections.ObjectModel
+@using System.Diagnostics
+@using ArcaneLibs.Extensions
+@using MatrixUtils.Abstractions
+@inject ILogger<Index> logger
+<h3>Room list</h3>
+
+<p>@Status</p>
+<p>@Status2</p>
+
+<LinkButton href="/Rooms/Create">Create new room</LinkButton>
+
+<RoomList Rooms="Rooms" GlobalProfile="@GlobalProfile" @bind-StillFetching="RenderContents"></RoomList>
+
+@code {
+    private ObservableCollection<RoomInfo> Rooms { get; } = new();
+    private UserProfileResponse GlobalProfile { get; set; }
+
+    private AuthenticatedHomeserverGeneric? Homeserver { get; set; }
+
+    private static SyncFilter filter = new() {
+        AccountData = new SyncFilter.EventFilter {
+            NotTypes = new List<string> { "*" },
+            Limit = 1
+        },
+        Presence = new SyncFilter.EventFilter {
+            NotTypes = new List<string> { "*" },
+            Limit = 1
+        },
+        Room = new SyncFilter.RoomFilter {
+            AccountData = new SyncFilter.RoomFilter.StateFilter {
+                NotTypes = new List<string> { "*" },
+                Limit = 1
+            },
+            Ephemeral = new SyncFilter.RoomFilter.StateFilter {
+                NotTypes = new List<string> { "*" },
+                Limit = 1
+            },
+            State = new SyncFilter.RoomFilter.StateFilter {
+                Types = new List<string> {
+                    "m.room.create",
+                    "m.room.name",
+                    "m.room.avatar",
+                    "org.matrix.mjolnir.shortcode",
+                    "m.room.power_levels",
+                }
+            },
+            Timeline = new SyncFilter.RoomFilter.StateFilter {
+                NotTypes = new List<string> { "*" },
+                Limit = 1
+            }
+        }
+    };
+
+    // private static SyncFilter profileUpdateFilter = new() {
+    //     AccountData = new SyncFilter.EventFilter {
+    //         NotTypes = new List<string> { "*" },
+    //         Limit = 1
+    //     },
+    //     Presence = new SyncFilter.EventFilter {
+    //         NotTypes = new List<string> { "*" },
+    //         Limit = 1
+    //     },
+    //     Room = new SyncFilter.RoomFilter {
+    //         AccountData = new SyncFilter.RoomFilter.StateFilter {
+    //             NotTypes = new List<string> { "*" },
+    //             Limit = 1
+    //         },
+    //         Ephemeral = new SyncFilter.RoomFilter.StateFilter {
+    //             NotTypes = new List<string> { "*" },
+    //             Limit = 1
+    //         },
+    //         State = new SyncFilter.RoomFilter.StateFilter {
+    //             Types = new List<string> {
+    //                 "m.room.member"
+    //             },
+    //             Senders = new()
+    //         },
+    //         Timeline = new SyncFilter.RoomFilter.StateFilter {
+    //             NotTypes = new List<string> { "*" },
+    //             Limit = 1
+    //         }
+    //     }
+    // };
+
+    private SyncHelper syncHelper;
+
+    // SyncHelper profileSyncHelper;
+
+    protected override async Task OnInitializedAsync() {
+        Homeserver = await RMUStorage.GetCurrentSessionOrNavigate();
+        if (Homeserver is null) return;
+        var rooms = await Homeserver.GetJoinedRooms();
+        // SemaphoreSlim _semaphore = new(160, 160);
+
+        var roomTasks = rooms.Select(async room => {
+            RoomInfo ri;
+            // await _semaphore.WaitAsync();
+            ri = new() { Room = room };
+            await Task.WhenAll((filter.Room?.State?.Types ?? []).Select(x => ri.GetStateEvent(x)));
+            return ri;
+        }).ToAsyncEnumerable();
+
+        await foreach (var room in roomTasks) {
+            Rooms.Add(room);
+            StateHasChanged();
+            // await Task.Delay(50);
+            // _semaphore.Release();
+        }
+
+        if (rooms.Count >= 150) RenderContents = true;
+
+        GlobalProfile = await Homeserver.GetProfileAsync(Homeserver.WhoAmI.UserId);
+        syncHelper = new SyncHelper(Homeserver, logger) {
+            Timeout = 30000,
+            Filter = filter,
+            MinimumDelay = TimeSpan.FromMilliseconds(5000)
+        };
+        //  profileSyncHelper = new SyncHelper(Homeserver, logger) {
+        //     Timeout = 10000,
+        //     Filter = profileUpdateFilter,
+        //     MinimumDelay = TimeSpan.FromMilliseconds(5000)
+        //  };
+        // profileUpdateFilter.Room.State.Senders.Add(Homeserver.WhoAmI.UserId);
+
+        RunSyncLoop(syncHelper);
+        // RunSyncLoop(profileSyncHelper);
+        RunQueueProcessor();
+
+        await base.OnInitializedAsync();
+    }
+
+    private async Task RunQueueProcessor() {
+        var renderTimeSw = Stopwatch.StartNew();
+        var isInitialSync = true;
+        while (true) {
+            try {
+                while (queue.Count == 0) {
+                    Console.WriteLine("Queue is empty, waiting...");
+                    await Task.Delay(isInitialSync ? 100 : 2500);
+                }
+
+                Console.WriteLine($"Queue no longer empty after {renderTimeSw.Elapsed}!");
+
+                int maxUpdates = 10;
+                isInitialSync = false;
+                while (maxUpdates-- > 0 && queue.TryDequeue(out var queueEntry)) {
+                    var (roomId, roomData) = queueEntry;
+                    Console.WriteLine($"Dequeued room {roomId}");
+                    RoomInfo room;
+
+                    if (Rooms.Any(x => x.Room.RoomId == roomId)) {
+                        room = Rooms.First(x => x.Room.RoomId == roomId);
+                        Console.WriteLine($"QueueWorker: {roomId} already known with {room.StateEvents?.Count ?? 0} state events");
+                    }
+                    else {
+                        Console.WriteLine($"QueueWorker: encountered new room {roomId}!");
+                        room = new RoomInfo() {
+                            Room = Homeserver.GetRoom(roomId)
+                        };
+                        Rooms.Add(room);
+                    }
+
+                    if (room.StateEvents is null) {
+                        Console.WriteLine($"QueueWorker: {roomId} does not have state events on record?");
+                        throw new InvalidDataException("Somehow this is null???");
+                    }
+
+                    if (roomData.State?.Events is { Count: > 0 })
+                        room.StateEvents.MergeStateEventLists(roomData.State.Events);
+                    else {
+                        Console.WriteLine($"QueueWorker: could not merge state for {room.Room.RoomId} as new data contains no state events!");
+                    }
+                }
+                Console.WriteLine($"QueueWorker: {queue.Count} entries left in queue, {maxUpdates} maxUpdates left, RenderContents: {RenderContents}");
+                    Status = $"Got {Rooms.Count} rooms so far! {queue.Count} entries in processing queue...";
+
+                RenderContents |= queue.Count == 0;
+                await Task.Delay(Rooms.Count);
+            }
+            catch (Exception e) {
+                Console.WriteLine("QueueWorker exception: " + e);
+            }
+        }
+    }
+
+    private bool RenderContents { get; set; } = false;
+
+    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 Queue<KeyValuePair<string, SyncResponse.RoomsDataStructure.JoinedRoomDataStructure>> queue = new();
+
+    private async Task RunSyncLoop(SyncHelper syncHelper) {
+        Status = "Initial syncing...";
+        Console.WriteLine("starting sync");
+
+        var syncs = syncHelper.EnumerateSyncAsync();
+        await foreach (var sync in syncs) {
+            Console.WriteLine("trying sync");
+            if (sync is null) continue;
+
+            Status = $"Got sync with {sync.Rooms?.Join?.Count ?? 0} room updates, next batch: {sync.NextBatch}!";
+            if (sync?.Rooms?.Join != null)
+                foreach (var joinedRoom in sync.Rooms.Join)
+                    if ( /*joinedRoom.Value.AccountData?.Events?.Count > 0 ||*/ joinedRoom.Value.State?.Events?.Count > 0) {
+                        joinedRoom.Value.State.Events.RemoveAll(x => x.Type == "m.room.member" && x.StateKey != Homeserver.WhoAmI?.UserId);
+                        // We can't trust servers to give us what we ask for, and this ruins performance
+                        // Thanks, Conduit.
+                        joinedRoom.Value.State.Events.RemoveAll(x => filter.Room?.State?.Types?.Contains(x.Type) == false);
+                        if (filter.Room?.State?.NotSenders?.Any() ?? false)
+                            joinedRoom.Value.State.Events.RemoveAll(x => filter.Room?.State?.NotSenders?.Contains(x.Sender) ?? false);
+
+                        queue.Enqueue(joinedRoom);
+                    }
+            if (sync.Rooms.Leave is {Count: > 0})
+                foreach (var leftRoom in sync.Rooms.Leave)
+                    if (Rooms.Any(x => x.Room.RoomId == leftRoom.Key))
+                        Rooms.Remove(Rooms.First(x => x.Room.RoomId == leftRoom.Key));
+
+            Status = $"Got {Rooms.Count} rooms so far! {queue.Count} entries in processing queue... " +
+                     $"{sync?.Rooms?.Join?.Count ?? 0} new updates!";
+
+            Status2 = $"Next batch: {sync.NextBatch}";
+        }
+    }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Rooms/PolicyList.razor b/MatrixUtils.Web/Pages/Rooms/PolicyList.razor
new file mode 100644
index 0000000..bfc0375
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Rooms/PolicyList.razor
@@ -0,0 +1,267 @@
+@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
+@using System.Collections.Frozen
+@using System.Reflection
+@using ArcaneLibs.Attributes
+@using LibMatrix.EventTypes
+
+@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 {
+    @foreach (var (type, value) in PolicyEventsByType) {
+        <p>
+            @(GetValidPolicyEventsByType(type).Count) active,
+            @(GetInvalidPolicyEventsByType(type).Count) invalid
+            (@value.Count total)
+            @(GetPolicyTypeName(type).ToLower())
+        </p>
+    }
+
+    @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>
+            <table class="table table-striped table-hover" style="width: fit-content; border-width: 1px; vertical-align: middle;">
+                @{
+                    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();
+                }
+                <thead>
+                    <tr>
+                        @foreach (var name in propNames) {
+                            <th style="border-width: 1px">@name</th>
+                        }
+                        <th style="border-width: 1px">Actions</th>
+                    </tr>
+                </thead>
+                <tbody style="border-width: 1px;">
+                    @foreach (var policy in policies.OrderBy(x => x.RawContent?["entity"]?.GetValue<string>())) {
+                        <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>
+                            }
+                            <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>
+                                        }
+                                    }
+                                </div>
+                            </td>
+                        </tr>
+                    }
+                </tbody>
+            </table>
+            <details>
+                <summary>
+                    <u>
+                        @("Invalid " + GetPolicyTypeName(type).ToLower())
+                    </u>
+                </summary>
+                <table class="table table-striped table-hover" style="width: fit-content; border-width: 1px; vertical-align: middle;">
+                    <thead>
+                        <tr>
+                            <th style="border-width: 1px">State key</th>
+                            <th style="border-width: 1px">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>
+    }
+}
+
+@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;
+    //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;
+
+    // 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 {
+        get => _currentlyEditingEvent;
+        set {
+            _currentlyEditingEvent = value;
+            StateHasChanged();
+        }
+    }
+
+    // public bool EnableAvatars {
+    //     get => _enableAvatars;
+    //     set {
+    //         _enableAvatars = value;
+    //         if (value) GetAllAvatars();
+    //     }
+    // }
+
+    private AuthenticatedHomeserverGeneric Homeserver { get; set; }
+    private GenericRoom Room { get; set; }
+    private RoomPowerLevelEventContent PowerLevels { 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())!;
+        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 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();
+
+    private List<StateEventResponse> GetInvalidPolicyEventsByType(Type type) => GetPolicyEventsByType(type)
+        .Where(x => string.IsNullOrWhiteSpace(x.RawContent?["entity"]?.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, new { });
+        PolicyEventsByType[policyEvent.MappedType].Remove(policyEvent);
+        await LoadStatesAsync();
+    }
+
+    private async Task UpdatePolicyAsync(StateEventResponse policyEvent) {
+        await Room.SendStateEventAsync(policyEvent.Type, policyEvent.StateKey, policyEvent.RawContent);
+        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);
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Rooms/Space.razor b/MatrixUtils.Web/Pages/Rooms/Space.razor
new file mode 100644
index 0000000..2dd84a1
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Rooms/Space.razor
@@ -0,0 +1,100 @@
+@page "/Rooms/{RoomId}/Space"
+@using LibMatrix.RoomTypes
+@using ArcaneLibs.Extensions
+@using LibMatrix
+<h3>Room manager - Viewing Space</h3>
+
+<button onclick="@JoinAllRooms">Join all rooms</button>
+@foreach (var room in Rooms) {
+    <RoomListItem Room="room" ShowOwnProfile="true"></RoomListItem>
+}
+
+
+<br/>
+<details style="background: #0002;">
+    <summary style="background: #fff1;">State list</summary>
+    @foreach (var stateEvent in States.OrderBy(x => x.StateKey).ThenBy(x => x.Type)) {
+        <p>@stateEvent.StateKey/@stateEvent.Type:</p>
+        <pre>@stateEvent.RawContent.ToJson()</pre>
+    }
+</details>
+
+@code {
+
+    [Parameter]
+    public string RoomId { get; set; } = "invalid!!!!!!";
+
+    private GenericRoom? Room { get; set; }
+
+    private StateEventResponse[] States { get; set; } = Array.Empty<StateEventResponse>();
+    private List<GenericRoom> Rooms { get; } = new();
+    private List<string> ServersInSpace { get; } = new();
+
+    protected override async Task OnInitializedAsync() {
+        var hs = await RMUStorage.GetCurrentSessionOrNavigate();
+        if (hs is null) return;
+
+        Room = hs.GetRoom(RoomId.Replace('~', '.'));
+
+        var state = Room.GetFullStateAsync();
+        await foreach (var stateEvent in state) {
+            switch (stateEvent.Type) {
+                case "m.space.child": {
+                    var roomId = stateEvent.StateKey;
+                    var room = hs.GetRoom(roomId);
+                    if (room is not null) {
+                        Rooms.Add(room);
+                    }
+                    break;
+                }
+                case "m.room.member": {
+                    var serverName = stateEvent.StateKey.Split(':').Last();
+                    if (!ServersInSpace.Contains(serverName)) {
+                        ServersInSpace.Add(serverName);
+                    }
+                    break;
+                }
+            }
+        }
+        await base.OnInitializedAsync();
+
+    //     var state = await Room.GetStateAsync("");
+    //     if (state is not null) {
+    // // Console.WriteLine(state.Value.ToJson());
+    //         States = state.Value.Deserialize<StateEventResponse[]>()!;
+    //
+    //         foreach (var stateEvent in States) {
+    //             if (stateEvent.Type == "m.space.child") {
+    // // if (stateEvent.Content.ToJson().Length < 5) return;
+    //                 var roomId = stateEvent.StateKey;
+    //                 var room = hs.GetRoom(roomId);
+    //                 if (room is not null) {
+    //                     Rooms.Add(room);
+    //                 }
+    //             }
+    //             else if (stateEvent.Type == "m.room.member") {
+    //                 var serverName = stateEvent.StateKey.Split(':').Last();
+    //                 if (!ServersInSpace.Contains(serverName)) {
+    //                     ServersInSpace.Add(serverName);
+    //                 }
+    //             }
+    //         }
+
+    // if(state.Value.TryGetProperty("Type", out var Type))
+    // {
+    // }
+    // else
+    // {
+    //     //this is fine, apprently...
+    //     //Console.WriteLine($"Room {room.RoomId} has no Content.Type in m.room.create!");
+    // }
+
+    // await base.OnInitializedAsync();
+    }
+
+    private async Task JoinAllRooms() {
+        List<Task<RoomIdResponse>> tasks = Rooms.Select(room => room.JoinAsync(ServersInSpace.ToArray())).ToList();
+        await Task.WhenAll(tasks);
+    }
+
+}
diff --git a/MatrixUtils.Web/Pages/Rooms/StateEditor.razor b/MatrixUtils.Web/Pages/Rooms/StateEditor.razor
new file mode 100644
index 0000000..fc3a310
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Rooms/StateEditor.razor
@@ -0,0 +1,144 @@
+@page "/Rooms/{RoomId}/State/Edit"
+@using ArcaneLibs.Extensions
+@using LibMatrix
+@inject ILocalStorageService LocalStorage
+@inject NavigationManager NavigationManager
+<h3>Room state editor - Editing @RoomId</h3>
+<hr/>
+
+<p>@status</p>
+
+<input type="checkbox" id="showAll" @bind="ShowMembershipEvents"/> Show member events
+<br/>
+<InputSelect @bind-Value="shownStateKey">
+    <option value="">-- State key --</option>
+    @foreach (var stateEvent in FilteredEvents.Where(x => x.StateKey != "").Select(x => x.StateKey).Distinct().OrderBy(x => x)) {
+        <option value="@stateEvent">@stateEvent</option>
+        Console.WriteLine(stateEvent);
+    }
+</InputSelect>
+<br/>
+<InputSelect @bind-Value="shownType">
+    <option value="">-- Type --</option>
+    @foreach (var stateEvent in FilteredEvents.Where(x => x.StateKey != shownStateKey).Select(x => x.Type).Distinct().OrderBy(x => x)) {
+        <option value="@stateEvent">@stateEvent</option>
+    }
+</InputSelect>
+<br/>
+
+<textarea @bind="shownEventJson" style="width: 100%; height: fit-Content;"></textarea>
+
+<LogView></LogView>
+
+@code {
+    //get room list
+    // - sync withroom list filter
+    // Type = support.feline.msc3784
+    //support.feline.policy.lists.msc.v1
+
+    [Parameter]
+    public string? RoomId { get; set; }
+
+    public List<StateEventResponse> FilteredEvents { get; set; } = new();
+    public List<StateEventResponse> Events { get; set; } = new();
+    public string status = "";
+
+    protected override async Task OnInitializedAsync() {
+        await base.OnInitializedAsync();
+        var hs = await RMUStorage.GetCurrentSessionOrNavigate();
+        if (hs is null) return;
+        RoomId = RoomId.Replace('~', '.');
+        await LoadStatesAsync();
+        Console.WriteLine("Policy list editor initialized!");
+    }
+
+    private DateTime _lastUpdate = DateTime.Now;
+
+    private async Task LoadStatesAsync() {
+        var hs = await RMUStorage.GetCurrentSessionOrNavigate();
+
+        var StateLoaded = 0;
+        var response = (hs.GetRoom(RoomId)).GetFullStateAsync();
+        await foreach (var _ev in response) {
+            // var e = new StateEventResponse {
+            //     Type = _ev.Type,
+            //     StateKey = _ev.StateKey,
+            //     OriginServerTs = _ev.OriginServerTs,
+            //     Content = _ev.Content
+            // };
+            Events.Add(_ev);
+            if (string.IsNullOrEmpty(_ev.StateKey)) {
+                FilteredEvents.Add(_ev);
+            }
+            StateLoaded++;
+
+            if (!((DateTime.Now - _lastUpdate).TotalMilliseconds > 100)) continue;
+            _lastUpdate = DateTime.Now;
+            status = $"Loaded {StateLoaded} state events";
+            StateHasChanged();
+            await Task.Delay(0);
+        }
+
+        StateHasChanged();
+    }
+
+    private async Task RebuildFilteredData() {
+        status = "Rebuilding filtered data...";
+        StateHasChanged();
+        await Task.Delay(1);
+        var _FilteredEvents = Events;
+        if (!ShowMembershipEvents)
+            _FilteredEvents = _FilteredEvents.Where(x => x.Type != "m.room.member").ToList();
+
+        status = "Done, rerendering!";
+        StateHasChanged();
+        await Task.Delay(1);
+        FilteredEvents = _FilteredEvents;
+
+        if (_shownType is not null)
+            shownEventJson = _FilteredEvents.First(x => x.Type == _shownType).RawContent.ToJson(indent: true, ignoreNull: true);
+
+        StateHasChanged();
+    }
+
+    public struct PreRenderedStateEvent {
+        public string content { get; set; }
+        public long origin_server_ts { get; set; }
+        public string state_key { get; set; }
+        public string type { get; set; }
+    // public string Sender { get; set; }
+    // public string EventId { get; set; }
+    // public string UserId { get; set; }
+    // public string ReplacesState { get; set; }
+    }
+
+    public bool ShowMembershipEvents {
+        get => _showMembershipEvents;
+        set {
+            _showMembershipEvents = value;
+            RebuildFilteredData();
+        }
+    }
+
+    private bool _showMembershipEvents;
+    private string _shownStateKey;
+    private string _shownType;
+
+    private string shownStateKey {
+        get => _shownStateKey;
+        set {
+            _shownStateKey = value;
+            RebuildFilteredData();
+        }
+    }
+
+    private string shownType {
+        get => _shownType;
+        set {
+            _shownType = value;
+            RebuildFilteredData();
+        }
+    }
+
+    private string shownEventJson { get; set; }
+}
diff --git a/MatrixUtils.Web/Pages/Rooms/StateViewer.razor b/MatrixUtils.Web/Pages/Rooms/StateViewer.razor
new file mode 100644
index 0000000..fabc33c
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Rooms/StateViewer.razor
@@ -0,0 +1,127 @@
+@page "/Rooms/{RoomId}/State/View"
+@using ArcaneLibs.Extensions
+@using LibMatrix
+@inject ILocalStorageService LocalStorage
+@inject NavigationManager NavigationManager
+<h3>Room state viewer - Viewing @RoomId</h3>
+<hr/>
+
+<p>@status</p>
+
+<input type="checkbox" id="showAll" @bind="ShowMembershipEvents"/> Show member events
+
+<table class="table table-striped table-hover" style="width: fit-Content;">
+    <thead>
+    <tr>
+        <th scope="col">Type</th>
+        <th scope="col">Content</th>
+    </tr>
+    </thead>
+    <tbody>
+    @foreach (var stateEvent in FilteredEvents.Where(x => x.StateKey == "").OrderBy(x => x.OriginServerTs)) {
+        <tr>
+            <td>@stateEvent.Type</td>
+            <td style="max-width: fit-Content;">
+                <pre>@stateEvent.RawContent.ToJson()</pre>
+            </td>
+        </tr>
+    }
+    </tbody>
+</table>
+
+@foreach (var group in FilteredEvents.GroupBy(x => x.StateKey).OrderBy(x => x.Key).Where(x => x.Key != "")) {
+    <details>
+        <summary>@group.Key</summary>
+        <table class="table table-striped table-hover" style="width: fit-Content;">
+            <thead>
+            <tr>
+                <th scope="col">Type</th>
+                <th scope="col">Content</th>
+            </tr>
+            </thead>
+            <tbody>
+            @foreach (var stateEvent in group.OrderBy(x => x.OriginServerTs)) {
+                <tr>
+                    <td>@stateEvent.Type</td>
+                    <td style="max-width: fit-Content;">
+                        <pre>@stateEvent.RawContent.ToJson()</pre>
+                    </td>
+                </tr>
+            }
+            </tbody>
+        </table>
+    </details>
+}
+
+<LogView></LogView>
+
+@code {
+    //get room list
+    // - sync withroom list filter
+    // Type = support.feline.msc3784
+    //support.feline.policy.lists.msc.v1
+
+    [Parameter]
+    public string? RoomId { get; set; }
+
+    public List<StateEventResponse> FilteredEvents { get; set; } = new();
+    public List<StateEventResponse> Events { get; set; } = new();
+    public string status = "";
+
+    protected override async Task OnInitializedAsync() {
+        await base.OnInitializedAsync();
+        var hs = await RMUStorage.GetCurrentSessionOrNavigate();
+        if (hs is null) return;
+        await LoadStatesAsync();
+        Console.WriteLine("Policy list editor initialized!");
+    }
+
+    private DateTime _lastUpdate = DateTime.Now;
+
+    private async Task LoadStatesAsync() {
+        var StateLoaded = 0;
+        var hs = await RMUStorage.GetCurrentSessionOrNavigate();
+        if (hs is null) return;
+        var response = (hs.GetRoom(RoomId)).GetFullStateAsync();
+        await foreach (var _ev in response) {
+            Events.Add(_ev);
+            if (string.IsNullOrEmpty(_ev.StateKey)) {
+                FilteredEvents.Add(_ev);
+            }
+            StateLoaded++;
+
+            if (!((DateTime.Now - _lastUpdate).TotalMilliseconds > 100)) continue;
+            _lastUpdate = DateTime.Now;
+            status = $"Loaded {StateLoaded} state events";
+            StateHasChanged();
+            await Task.Delay(0);
+        }
+
+        StateHasChanged();
+    }
+
+    private async Task RebuildFilteredData() {
+        status = "Rebuilding filtered data...";
+        StateHasChanged();
+        await Task.Delay(1);
+        var _FilteredEvents = Events;
+        if (!ShowMembershipEvents)
+            _FilteredEvents = _FilteredEvents.Where(x => x.Type != "m.room.member").ToList();
+
+        status = "Done, rerendering!";
+        StateHasChanged();
+        await Task.Delay(1);
+        FilteredEvents = _FilteredEvents;
+        StateHasChanged();
+    }
+
+    public bool ShowMembershipEvents {
+        get => _showMembershipEvents;
+        set {
+            _showMembershipEvents = value;
+            RebuildFilteredData();
+        }
+    }
+
+    private bool _showMembershipEvents;
+}
diff --git a/MatrixUtils.Web/Pages/Rooms/Timeline.razor b/MatrixUtils.Web/Pages/Rooms/Timeline.razor
new file mode 100644
index 0000000..8d0f731
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Rooms/Timeline.razor
@@ -0,0 +1,60 @@
+@page "/Rooms/{RoomId}/Timeline"
+@using MatrixUtils.Web.Shared.TimelineComponents
+@using LibMatrix
+@using LibMatrix.EventTypes.Spec
+@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.Homeservers
+<h3>RoomManagerTimeline</h3>
+<hr/>
+<p>Loaded @Events.Count events...</p>
+
+@foreach (var evt in Events) {
+    <div type="@evt.Type" key="@evt.StateKey" itemid="@evt.EventId">
+        <DynamicComponent Type="@ComponentType(evt)"
+                          Parameters="@(new Dictionary<string, object> { { "Event", evt }, { "Events", Events }, { "Homeserver", Homeserver!} })">
+        </DynamicComponent>
+    </div>
+}
+
+@code {
+
+    [Parameter]
+    public string RoomId { get; set; }
+
+    private List<MessagesResponse> Messages { get; } = new();
+    private List<StateEventResponse> Events { get; } = new();
+
+    private AuthenticatedHomeserverGeneric? Homeserver { get; set; }
+
+    protected override async Task OnInitializedAsync() {
+        Console.WriteLine("RoomId: " + RoomId);
+        Homeserver = await RMUStorage.GetCurrentSessionOrNavigate();
+        if (Homeserver is null) return;
+        var room = Homeserver.GetRoom(RoomId);
+        MessagesResponse? msgs = null;
+        do {
+            msgs = await room.GetMessagesAsync(limit: 1000, from: msgs?.End, dir: "b");
+            Messages.Add(msgs);
+            Console.WriteLine($"Got {msgs.Chunk.Count} messages");
+            msgs.Chunk.Reverse();
+            Events.InsertRange(0, msgs.Chunk);
+        } while (msgs.End is not null);
+
+
+        await base.OnInitializedAsync();
+    }
+
+    private StateEventResponse GetProfileEventBefore(StateEventResponse Event) => Events.TakeWhile(x => x != Event).Last(e => e.Type == "m.room.member" && e.StateKey == Event.Sender);
+
+    private Type ComponentType(StateEvent Event) => Event.TypedContent switch {
+        RoomCanonicalAliasEventContent => typeof(TimelineCanonicalAliasItem),
+        RoomHistoryVisibilityEventContent => typeof(TimelineHistoryVisibilityItem),
+        RoomTopicEventContent => typeof(TimelineRoomTopicItem),
+        RoomMemberEventContent => typeof(TimelineMemberItem),
+        RoomMessageEventContent => typeof(TimelineMessageItem),
+        RoomCreateEventContent => typeof(TimelineRoomCreateItem),
+        RoomNameEventContent => typeof(TimelineRoomNameItem),
+        _ => typeof(TimelineUnknownItem)
+    };
+
+}
diff --git a/MatrixUtils.Web/Pages/ServerInfo.razor b/MatrixUtils.Web/Pages/ServerInfo.razor
new file mode 100644
index 0000000..71a1980
--- /dev/null
+++ b/MatrixUtils.Web/Pages/ServerInfo.razor
@@ -0,0 +1,235 @@
+@page "/ServerInfo/{Homeserver}"
+@using LibMatrix.Homeservers
+@using LibMatrix.Responses
+@using ArcaneLibs.Extensions
+<h3>ServerInfo</h3>
+<hr/>
+@if (ServerVersionResponse is not null) {
+    <p>Server version: @ServerVersionResponse.Server.Name @ServerVersionResponse.Server.Version</p>
+    <pre>@ServerVersionResponse?.ToJson(ignoreNull: true)</pre>
+    <br/>
+}
+@if (ClientVersionsResponse is not null) {
+    <p>Client versions:</p>
+    <details>
+        <summary>JSON data</summary>
+        <pre>@ClientVersionsResponse?.ToJson(ignoreNull: true)</pre>
+    </details>
+    <u>Spec versions</u>
+    <table>
+        <thead>
+            <td></td>
+            <td>Version</td>
+            <td>Release date</td>
+        </thead>
+        @foreach (var (version, info) in ClientVersions) {
+            <tr>
+                <td>@(ClientVersionsResponse.Versions.Contains(version) ? "\u2714" : "\u274c")</td>
+                <td><a href="@info.SpecUrl">@info.Name</a></td>
+                <td>@info.Released</td>
+            </tr>
+        }
+
+        @foreach (var version in ClientVersionsResponse.Versions) {
+            if (!ClientVersions.ContainsKey(version)) {
+                <tr>
+                    <td>@("\u2714")</td>
+                    <td><a href="https://spec.matrix.org/@version">Unknown version: @version</a></td>
+                    <td></td>
+                </tr>
+            }
+        }
+    </table>
+    <u>Unstable features</u>
+    <table>
+        <thead>
+            <td style="padding-right: 8px;">Supported</td>
+            <td style="padding-right: 8px;">Enabled</td>
+            <td style="padding-right: 8px;">Name</td>
+        </thead>
+        @* @foreach (var (version, info) in ClientVersions) { *@
+        @*     <tr> *@
+        @*          *@
+        @*             <td>@("\u2714")</td> *@
+        @*         <td>@(ClientVersionsResponse.Versions.Contains(version) ? "\u2714" : "\u274c")</td> *@
+        @*         <td>@info.Released</td> *@
+        @*     </tr> *@
+        @* } *@
+
+        @foreach (var version in ClientVersionsResponse.UnstableFeatures) {
+            if (!ClientVersions.ContainsKey(version.Key)) {
+                <tr>
+                    <td>@("\u2714")</td>
+                    <td>@(version.Value ? "\u2714" : "\u274c")</td>
+                    <td>@version.Key</td>
+                </tr>
+            }
+        }
+    </table>
+}
+
+
+@code {
+
+    [Parameter]
+    public string? Homeserver { get; set; }
+
+    public ServerVersionResponse? ServerVersionResponse { get; set; }
+    public ClientVersionsResponse? ClientVersionsResponse { get; set; }
+
+    protected override async Task OnParametersSetAsync() {
+        if (Homeserver is not null) {
+            var rhs = await hsProvider.GetRemoteHomeserver(Homeserver);
+            ServerVersionResponse = await (rhs.FederationClient?.GetServerVersionAsync() ?? Task.FromResult<ServerVersionResponse?>(null));
+            ClientVersionsResponse = await rhs.GetClientVersionsAsync();
+        }
+        base.OnParametersSetAsync();
+    }
+
+    private class ClientVersionInfo {
+        public string Name { get; set; }
+        public string SpecUrl { get; set; }
+        public DateTime Released { get; set; }
+    }
+
+    private Dictionary<string, ClientVersionInfo> ClientVersions = new() {
+        {
+            "legacy",
+            new() {
+                Name = "Legacy: Last draft before  formal release of r0.0.0",
+                Released = DateTime.Parse("2014-07-01 00:00:00 +0000"),
+                SpecUrl = "https://spec.matrix.org/legacy/legacy/"
+            }
+        },
+        {
+            "r0.0.0",
+            new() {
+                Name = "r0.0.0: Initial release: media repo, sync v2",
+                Released = DateTime.Parse("2014-07-01 00:00:00 +0000"),
+                SpecUrl = "https://spec.matrix.org/legacy/r0.0.0/"
+            }
+        },
+        {
+            "r0.0.1",
+            new() {
+                Name = "r0.0.1: User-interactive authentication, groups, read receipts, presence",
+                Released = DateTime.Parse("2014-07-01 00:00:00 +0000"),
+                SpecUrl = "https://spec.matrix.org/legacy/r0.0.1/"
+            }
+        },
+        {
+            "r0.1.0",
+            new() {
+                Name = "r0.1.0: Device management, account data, push rules, VoIP",
+                Released = DateTime.Parse("2014-07-01 00:00:00 +0000"),
+                SpecUrl = "https://spec.matrix.org/legacy/r0.1.0/"
+            }
+        },
+        {
+            "r0.2.0",
+            new() {
+                Name = "r0.2.0: Clarifications",
+                Released = DateTime.Parse("2014-07-01 00:00:00 +0000"),
+                SpecUrl = "https://spec.matrix.org/legacy/client_server/r0.2.0.html"
+            }
+        },
+        {
+            "r0.3.0",
+            new() {
+                Name = "r0.3.0: Device management",
+                Released = DateTime.Parse("2014-07-01 00:00:00 +0000"),
+                SpecUrl = "https://spec.matrix.org/legacy/client_server/r0.3.0.html"
+            }
+        },
+        {
+            "r0.4.0",
+            new() {
+                Name = "r0.4.0: Room directory",
+                Released = DateTime.Parse("2014-07-01 00:00:00 +0000"),
+                SpecUrl = "https://spec.matrix.org/legacy/r0.4.0/"
+            }
+        },
+        {
+            "r0.5.0",
+            new() {
+                Name = "r0.5.0: Push rules, VoIP, groups, read receipts, presence",
+                Released = DateTime.Parse("2014-07-01 00:00:00 +0000"),
+                SpecUrl = "https://spec.matrix.org/legacy/r0.5.0/"
+            }
+        },
+        {
+            "r0.6.0",
+            new() {
+                Name = "r0.6.0: Unbinding 3PIDs, clean up bindings from register",
+                Released = DateTime.Parse("2014-07-01 00:00:00 +0000"),
+                SpecUrl = "https://spec.matrix.org/legacy/r0.6.0/"
+            }
+        },
+        {
+            "r0.6.1",
+            new(){
+                Name = "r0.6.1: Moderation policies, better alias handling",
+                Released = DateTime.Parse("2014-07-01 00:00:00 +0000"),
+                SpecUrl = "https://spec.matrix.org/legacy/r0.6.1/"
+            }
+        },
+        {
+            "v1.1",
+            new() {
+                Name = "v1.1: Key backup, knocking",
+                Released = DateTime.Parse("2021-11-09 00:00:00 +0000"),
+                SpecUrl = "https://spec.matrix.org/v1.1/"
+            }
+        }, {
+            "v1.2",
+            new() {
+                Name = "v1.2: ",
+                Released = DateTime.Parse("2022-02-02 00:00:00 +0000"),
+                SpecUrl = "https://spec.matrix.org/v1.2/"
+            }
+        }, {
+            "v1.3",
+            new() {
+                Name = "v1.3: ",
+                Released = DateTime.Parse("2022-06-15 00:00:00 +0100"),
+                SpecUrl = "https://spec.matrix.org/v1.3/"
+            }
+        }, {
+            "v1.4",
+            new() {
+                Name = "v1.4: ",
+                Released = DateTime.Parse("2022-09-29 00:00:00 +0100"),
+                SpecUrl = "https://spec.matrix.org/v1.4/"
+            }
+        }, {
+            "v1.5",
+            new() {
+                Name = "v1.5: ",
+                Released = DateTime.Parse("2022-11-17 08:22:11 -0700"),
+                SpecUrl = "https://spec.matrix.org/v1.5/"
+            }
+        }, {
+            "v1.6",
+            new () {
+                Name = "v1.6: ",
+                Released = DateTime.Parse("2023-02-14 08:25:40 -0700"),
+                SpecUrl = "https://spec.matrix.org/v1.6"
+            }
+        }, {
+            "v1.7",
+            new () {
+                Name = "v1.7: ",
+                Released = DateTime.Parse("2023-05-25 09:47:21 -0600"),
+                SpecUrl = "https://spec.matrix.org/v1.7"
+            }
+        }, {
+            "v1.8",
+            new () {
+                Name = "v1.8: Room version 11",
+                Released = DateTime.Parse("2023-08-23 09:23:53 -0600"),
+                SpecUrl = "https://spec.matrix.org/v1.8"
+            }
+        }
+    };
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Tools/CopyPowerlevel.razor b/MatrixUtils.Web/Pages/Tools/CopyPowerlevel.razor
new file mode 100644
index 0000000..31f3f23
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Tools/CopyPowerlevel.razor
@@ -0,0 +1,84 @@
+@page "/Tools/CopyPowerlevel"
+@using System.Diagnostics
+@using ArcaneLibs.Extensions
+@using LibMatrix
+@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.Homeservers
+@using LibMatrix.RoomTypes
+<h3>Copy powerlevel</h3>
+<hr/>
+
+<p>Users: </p>
+@foreach (var hs in hss) {
+    <p>@hs.WhoAmI.UserId</p>
+}
+
+<br/>
+<LinkButton OnClick="Execute">Execute</LinkButton>
+<br/>
+@foreach (var line in Enumerable.Reverse(log)) {
+    <p>@line</p>
+}
+
+@code {
+    private List<string> log { get; set; } = new();
+    List<AuthenticatedHomeserverGeneric> hss { get; set; } = new();
+
+    protected override async Task OnInitializedAsync() {
+        var hs = await RMUStorage.GetCurrentSessionOrNavigate();
+        if (hs is null) return;
+        var sessions = await RMUStorage.GetAllTokens();
+        foreach (var userAuth in sessions) {
+            var session = await RMUStorage.GetSession(userAuth);
+            if (session is not null) {
+                hss.Add(session);
+                StateHasChanged();
+            }
+        }
+
+        StateHasChanged();
+        Console.WriteLine("Rerendered!");
+        await base.OnInitializedAsync();
+    }
+
+    private async Task Execute() {
+        foreach (var hs in hss) {
+            var rooms = await hs.GetJoinedRooms();
+            var tasks = rooms.Select(x=>Execute(hs, x)).ToAsyncEnumerable();
+            await foreach (var a in tasks) {
+                if (!string.IsNullOrWhiteSpace(a)) {
+                    log.Add(a);
+                    StateHasChanged();
+                }
+            }
+        }
+    }
+
+    private async Task<string> Execute(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;
+            if (!pls.UserHasStatePermission(hs.WhoAmI.UserId, RoomPowerLevelEventContent.EventId)) return "I do not have permission to send PL in " + room.RoomId;
+            foreach (var ahs in hss) {
+                if (pls.GetUserPowerLevel(hs.WhoAmI.UserId) == pls.GetUserPowerLevel(ahs.WhoAmI.UserId)) {
+                    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}";
+        }
+        catch (Exception e) {
+            return $"Failed to update PLs in {room.RoomId}: {e.Message}";
+        }
+        StateHasChanged();
+        return "";
+    }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Tools/KnownHomeserverList.razor b/MatrixUtils.Web/Pages/Tools/KnownHomeserverList.razor
new file mode 100644
index 0000000..f73215d
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Tools/KnownHomeserverList.razor
@@ -0,0 +1,54 @@
+@page "/Tools/KnownHomeserverList"
+@using System.Diagnostics
+@using ArcaneLibs.Extensions
+@using LibMatrix.Homeservers
+@using LibMatrix.RoomTypes
+<h3>Known Homeserver List</h3>
+<hr/>
+
+@if (!IsFinished) {
+    <p>
+        <b>Loading...</b>
+    </p>
+}
+
+@foreach (var (homeserver, members) in counts.OrderByDescending(x => x.Value)) {
+    <p>@homeserver - @members</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; }
+
+    protected override async Task OnInitializedAsync() {
+        hs = await RMUStorage.GetCurrentSessionOrNavigate();
+        if (hs is null) return;
+        var fetchTasks = (await hs.GetJoinedRooms()).Select(x=>x.GetMembersByHomeserverAsync()).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();
+            // await Task.Delay(250);
+        }
+
+        foreach (var resHomeserver in homeservers.Keys) {
+            homeservers[resHomeserver] = homeservers[resHomeserver].Distinct().ToList();
+            counts[resHomeserver] = homeservers[resHomeserver].Count;
+        }
+
+        IsFinished = true;
+        StateHasChanged();
+        Console.WriteLine("Rerendered!");
+        await base.OnInitializedAsync();
+    }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Tools/MassJoinRoom.razor b/MatrixUtils.Web/Pages/Tools/MassJoinRoom.razor
new file mode 100644
index 0000000..6efb0ae
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Tools/MassJoinRoom.razor
@@ -0,0 +1,110 @@
+@page "/Tools/MassRoomJoin"
+@using System.Diagnostics
+@using ArcaneLibs.Extensions
+@using LibMatrix
+@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.Homeservers
+@using LibMatrix.RoomTypes
+<h3>Mass join room</h3>
+<hr/>
+<p>Room: </p>
+<FancyTextBox @bind-Value="@roomId"></FancyTextBox>
+
+<p>Users: </p>
+@foreach (var hs in hss) {
+    <p>@hs.WhoAmI.UserId</p>
+}
+
+<br/>
+<LinkButton OnClick="Execute">Execute</LinkButton>
+<br/>
+@foreach (var line in Enumerable.Reverse(log)) {
+    <p>@line</p>
+}
+
+@code {
+    private List<string> log { get; set; } = new();
+    List<AuthenticatedHomeserverGeneric> hss { get; set; } = new();
+    string roomId { get; set; }
+
+    protected override async Task OnInitializedAsync() {
+        var hs = await RMUStorage.GetCurrentSessionOrNavigate();
+        if (hs is null) return;
+        var sessions = await RMUStorage.GetAllTokens();
+        foreach (var userAuth in sessions) {
+            var session = await RMUStorage.GetSession(userAuth);
+            if (session is not null) {
+                hss.Add(session);
+                StateHasChanged();
+            }
+        }
+
+        StateHasChanged();
+        Console.WriteLine("Rerendered!");
+        await base.OnInitializedAsync();
+    }
+
+    private async Task Execute() {
+    // foreach (var hs in hss) {
+    // var rooms = await hs.GetJoinedRooms();
+        var tasks = hss.Select(ExecuteInvite).ToAsyncEnumerable();
+        await foreach (var a in tasks) {
+            if (!string.IsNullOrWhiteSpace(a)) {
+                log.Add(a);
+                StateHasChanged();
+            }
+        }
+        tasks = hss.Select(ExecuteJoin).ToAsyncEnumerable();
+        await foreach (var a in tasks) {
+            if (!string.IsNullOrWhiteSpace(a)) {
+                log.Add(a);
+                StateHasChanged();
+            }
+        }
+    // }
+    }
+
+    private async Task<string> ExecuteInvite(AuthenticatedHomeserverGeneric hs) {
+        var room = hs.GetRoom(roomId);
+        try {
+            try {
+                var joinRule = await room.GetJoinRuleAsync();
+                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());
+            log.Add($"Invited to {room.RoomId} to {pls.GetUserPowerLevel(hs.WhoAmI.UserId)}");
+        }
+        catch (MatrixException e) {
+            return $"Failed to invite in {room.RoomId}: {e.Message}";
+        }
+        catch (Exception e) {
+            return $"Failed to invite in {room.RoomId}: {e.Message}";
+        }
+        StateHasChanged();
+        return "";
+    }
+
+    private async Task<string> ExecuteJoin(AuthenticatedHomeserverGeneric hs) {
+        var room = hs.GetRoom(roomId);
+        try {
+            try {
+                var mse = await room.GetStateOrNullAsync<RoomMemberEventContent>(RoomMemberEventContent.EventId, hs.WhoAmI.UserId);
+                if (mse?.Membership == "join") return $"User {hs.WhoAmI.UserId} already in room";
+            }
+            catch { }
+            await room.JoinAsync();
+        }
+        catch (MatrixException e) {
+            return $"Failed to join {hs.WhoAmI.UserId} to {room.RoomId}: {e.Message}";
+        }
+        catch (Exception e) {
+            return $"Failed to join {hs.WhoAmI.UserId} to {room.RoomId}: {e.Message}";
+        }
+        StateHasChanged();
+        return "";
+    }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Tools/MediaLocator.razor b/MatrixUtils.Web/Pages/Tools/MediaLocator.razor
new file mode 100644
index 0000000..38c9b71
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Tools/MediaLocator.razor
@@ -0,0 +1,111 @@
+@page "/Tools/MediaLocator"
+@using LibMatrix.Homeservers
+@inject HttpClient Http
+<h3>Media locator</h3>
+<hr/>
+
+<b>This is going to expose your IP address to all these homeservers!</b>
+<details>
+    <summary>Checked homeserver list (@homeservers.Count entries)</summary>
+    <ul>
+        @foreach (var hs in homeservers) {
+            <li>@hs</li>
+        }
+    </ul>
+</details>
+<button @onclick="addMoreHomeservers">Add more homeservers</button>
+<br/>
+<span>MXC URL: </span>
+<input type="text" @bind="mxcUrl"/>
+<button @onclick="executeSearch">Search</button>
+
+@if (successResults.Count > 0) {
+    <h4>Successes</h4>
+    <ul>
+        @foreach (var result in successResults) {
+            <li>@result</li>
+        }
+    </ul>
+}
+
+@if (errorResults.Count > 0) {
+    <h4>Errors</h4>
+    <ul>
+        @foreach (var result in errorResults) {
+            <li>@result</li>
+        }
+    </ul>
+}
+
+
+@code {
+    string mxcUrl { get; set; }
+    readonly List<string> successResults = new();
+    readonly List<string> errorResults = new();
+    readonly List<string> homeservers = new();
+
+    protected override async Task OnInitializedAsync() {
+        await base.OnInitializedAsync();
+        homeservers.AddRange(new[] {
+            "matrix.org",
+            "feline.support",
+            "rory.gay",
+            "the-apothecary.club",
+            "envs.net",
+            "projectsegfau.lt"
+        });
+    }
+
+    Task executeSearch() {
+        var sem = new SemaphoreSlim(128, 128);
+        homeservers.ForEach(async hs => {
+            await sem.WaitAsync();
+            var httpClient = new HttpClient { BaseAddress = new Uri(hs) };
+            httpClient.Timeout = TimeSpan.FromSeconds(5);
+            var rmu = mxcUrl.Replace("mxc://", $"{hs}/_matrix/media/v3/download/");
+            try {
+                var res = await httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Head, rmu));
+                if (res.IsSuccessStatusCode) {
+                    successResults.Add($"{hs}: found - {res.Content.Headers.ContentLength} bytes");
+                    StateHasChanged();
+                    return;
+                }
+                errorResults.Add($"Error: {hs} - {res.StatusCode}\n" + await res.Content.ReadAsStringAsync());
+            }
+            catch (Exception e) {
+                errorResults.Add($"Error: {e}");
+            }
+            finally {
+                sem.Release();
+            }
+            StateHasChanged();
+        });
+        return Task.CompletedTask;
+    }
+
+    async Task addMoreHomeservers() {
+        var res = await Http.GetAsync("/homeservers.txt");
+        var content = await res.Content.ReadAsStringAsync();
+        homeservers.Clear();
+        var lines = content.Split("\n");
+
+        var rhs = new RemoteHomeserver("rory.gay");
+        var sem = new SemaphoreSlim(128, 128);
+        lines.ToList().ForEach(async line => {
+            await sem.WaitAsync();
+            try {
+                homeservers.Add((await hsResolver.ResolveHomeserverFromWellKnown(line)).Client);
+                StateHasChanged();
+            }
+            catch (Exception e) {
+                Console.WriteLine(e);
+            }
+            finally {
+                sem.Release();
+            }
+        });
+
+        StateHasChanged();
+    }
+
+}
diff --git a/MatrixUtils.Web/Pages/Tools/SpaceDebug.razor b/MatrixUtils.Web/Pages/Tools/SpaceDebug.razor
new file mode 100644
index 0000000..5d9b8eb
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Tools/SpaceDebug.razor
@@ -0,0 +1,113 @@
+@page "/Tools/SpaceDebug"
+@using LibMatrix.Filters
+@using LibMatrix.Helpers
+<h3>SpaceDebug</h3>
+<hr/>
+
+<p>@Status</p>
+
+<b>Has parent:</b>
+<br/>
+
+@foreach (var (roomId, parents) in SpaceParents) {
+    <p>@roomId's parents</p>
+    <ul>
+        @foreach (var parent in parents) {
+            <li>@parent</li>
+        }
+    </ul>
+}
+
+<b>Space children:</b>
+
+@foreach (var (roomId, children) in SpaceChildren) {
+    <p>@roomId's children</p>
+    <ul>
+        @foreach (var child in children) {
+            <li>@child</li>
+        }
+    </ul>
+}
+
+@code {
+    private string _status = "Loading...";
+
+    public string Status {
+        get => _status;
+        set {
+            _status = value;
+            StateHasChanged();
+        }
+    }
+
+    public Dictionary<string, List<string>> SpaceChildren { get; set; } = new();
+    public Dictionary<string, List<string>> SpaceParents { get; set; } = new();
+
+    protected override async Task OnInitializedAsync() {
+        Status = "Getting homeserver...";
+        var hs = await RMUStorage.GetCurrentSessionOrNavigate();
+        if (hs is null) return;
+
+        var syncHelper = new SyncHelper(hs) {
+            Filter = new SyncFilter() {
+                Presence = new(0),
+                Room = new() {
+                    AccountData = new(limit: 0),
+                    Ephemeral = new(limit: 0),
+                    State = new(limit: 1000, types: new() { "m.space.child", "m.space.parent" }),
+                    Timeline = new(limit: 0)
+                },
+                AccountData = new(limit: 0)
+            }
+        };
+        
+        Status = "Syncing...";
+
+        var syncs = syncHelper.EnumerateSyncAsync();
+        await foreach (var sync in syncs) {
+            if (sync is null) {
+                Status = "Sync failed";
+                continue;
+            }
+
+            if (sync.Rooms is null) {
+                Status = "No rooms in sync...";
+                break;
+            }
+
+            if (sync.Rooms.Join is null) {
+                Status = "No joined rooms in sync...";
+                break;
+            }
+
+            if (sync.Rooms.Join.Count == 0) {
+                Status = "Joined rooms list was empty...";
+                break;
+            }
+
+            // nextBatch = sync.NextBatch;
+            foreach (var (roomId, data) in sync.Rooms!.Join!) {
+                data.State?.Events?.ForEach(e => {
+                    if (e.Type == "m.space.child") {
+                        if (!SpaceChildren.ContainsKey(roomId)) SpaceChildren[roomId] = new();
+                        if (e.RawContent is null) e.StateKey += " (null)";
+                        else if (e.RawContent.Count == 0) e.StateKey += " (empty)";
+                        SpaceChildren[roomId].Add(e.StateKey);
+                    }
+                    if (e.Type == "m.space.parent") {
+                        if (!SpaceParents.ContainsKey(roomId)) SpaceParents[roomId] = new();
+                        if (e.RawContent is null) e.StateKey += " (null)";
+                        else if (e.RawContent.Count == 0) e.StateKey += " (empty)";
+                        SpaceParents[roomId].Add(e.StateKey);
+                    }
+                });
+            }
+            Status = $"Synced {sync.Rooms.Join.Count} rooms, found {SpaceChildren.Count} spaces, {SpaceParents.Count} parents";
+        }
+        Status = $"Synced: found {SpaceChildren.Count}->{SpaceChildren.Sum(x => x.Value.Count)} spaces, {SpaceParents.Count}->{SpaceParents.Sum(x => x.Value.Count)} parents!";
+
+        await base.OnInitializedAsync();
+    }
+
+
+}
diff --git a/MatrixUtils.Web/Pages/Tools/ToolsIndex.razor b/MatrixUtils.Web/Pages/Tools/ToolsIndex.razor
new file mode 100644
index 0000000..f4092d7
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Tools/ToolsIndex.razor
@@ -0,0 +1,8 @@
+@page "/Tools"
+<h3>Other tools</h3>
+
+<a href="/Tools/CopyPowerlevel">Copy highest powerlevel across all session</a><br/>
+<a href="/Tools/KnownHomeserverList">Find all homeservers you share a room with</a><br/>
+<a href="/Tools/MassRoomJoin">Join room across all session</a><br/>
+<a href="/Tools/MediaLocator">Locate lost media</a><br/>
+<a href="/Tools/SpaceDebug">Debug space relationships</a><br/>
diff --git a/MatrixUtils.Web/Pages/User/DMManager.razor b/MatrixUtils.Web/Pages/User/DMManager.razor
new file mode 100644
index 0000000..df5cd6b
--- /dev/null
+++ b/MatrixUtils.Web/Pages/User/DMManager.razor
@@ -0,0 +1,62 @@
+@page "/User/DirectMessages"
+@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.Responses
+@using MatrixUtils.Abstractions
+<h3>Direct Messages</h3>
+<hr/>
+
+@foreach (var (targetUser, rooms) in DMRooms) {
+    <div>
+        <InlineUserItem User="targetUser"></InlineUserItem>
+        @foreach (var room in rooms) {
+            <RoomListItem RoomInfo="room" LoadData="true"></RoomListItem>
+        }
+    </div>
+}
+
+@code {
+    private string? _status;
+    private AuthenticatedHomeserverGeneric? Homeserver { get; set; }
+    private Dictionary<UserProfileResponse, List<RoomInfo>> DMRooms { get; set; } = new();
+
+    public string? Status {
+        get => _status;
+        set {
+            _status = value;
+            StateHasChanged();
+        }
+    }
+
+    protected override async Task OnInitializedAsync() {
+        Homeserver = await RMUStorage.GetCurrentSessionOrNavigate();
+        if (Homeserver is null) return;
+        Status = "Loading global profile...";
+        if (Homeserver.WhoAmI?.UserId is null) return;
+
+        Status = "Loading DM list from account data...";
+        var dms = await Homeserver.GetAccountDataAsync<Dictionary<string, List<string>>>("m.direct");
+        DMRooms.Clear();
+        foreach (var (userId, rooms) in dms) {
+            var roomList = new List<RoomInfo>();
+            DMRooms.Add(await Homeserver.GetProfileAsync(userId), roomList);
+            foreach (var room in rooms) {
+                var roomInfo = new RoomInfo() { Room = Homeserver.GetRoom(room) };
+                roomList.Add(roomInfo);
+                roomInfo.StateEvents.Add(new() {
+                    Type = RoomNameEventContent.EventId,
+                    TypedContent = new RoomNameEventContent() {
+                        Name = await Homeserver.GetRoom(room).GetNameOrFallbackAsync(4)
+                    },
+                    RoomId = room, Sender = null, EventId = null
+                });
+            }
+            StateHasChanged();
+        }
+
+        StateHasChanged();
+        Status = null;
+
+        await base.OnInitializedAsync();
+    }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/User/DMSpace.razor b/MatrixUtils.Web/Pages/User/DMSpace.razor
new file mode 100644
index 0000000..3751629
--- /dev/null
+++ b/MatrixUtils.Web/Pages/User/DMSpace.razor
@@ -0,0 +1,86 @@
+@page "/User/DMSpace/Setup"
+@using LibMatrix.Homeservers
+@using LibMatrix
+@using MatrixUtils.LibDMSpace
+@using MatrixUtils.LibDMSpace.StateEvents
+@using MatrixUtils.Web.Pages.User.DMSpaceStages
+<h3>DM Space Management</h3>
+<hr/>
+<CascadingValue Value="@DmSpace">
+    @switch (Stage) {
+        case -1:
+            <p>Initialising...</p>
+            break;
+        case 0:
+            <DMSpaceStage0/>
+            break;
+        case 1:
+            <DMSpaceStage1/>
+            break;
+        case 2:
+            <DMSpaceStage2/>
+            break;
+        case 3:
+            <DMSpaceStage3/>
+            break;
+        default:
+            <p>Stage is unknown value: @Stage!</p>
+            break;
+    }
+</CascadingValue>
+
+@code {
+    private int _stage = -1;
+
+    [Parameter, SupplyParameterFromQuery(Name = "stage")]
+    public int Stage {
+        get => _stage;
+        set {
+            _stage = value;
+            Console.WriteLine($"Stage is now {value}");
+            StateHasChanged();
+        }
+    }
+
+    public AuthenticatedHomeserverGeneric? Homeserver { get; set; }
+
+    public DMSpaceConfiguration? DmSpaceConfiguration { get; set; }
+
+    [Parameter]
+    public DMSpace? DmSpace { get; set; }
+
+    protected override async Task OnInitializedAsync() {
+        if (NavigationManager.Uri.Contains("?stage=")) {
+            NavigationManager.NavigateTo("/User/DMSpace", true);
+        }
+        DmSpace = this;
+        Homeserver ??= await RMUStorage.GetCurrentSessionOrNavigate();
+        if (Homeserver is null) return;
+        try {
+            DmSpaceConfiguration = await Homeserver.GetAccountDataAsync<DMSpaceConfiguration>("gay.rory.dm_space");
+            var room = Homeserver.GetRoom(DmSpaceConfiguration.DMSpaceId);
+            await room.GetStateAsync<object>(DMSpaceInfo.EventId);
+            Stage = 1;
+        }
+        catch (MatrixException e) {
+            if (e.ErrorCode == "M_NOT_FOUND") {
+                Stage = 0;
+                DmSpaceConfiguration = new();
+            }
+            else throw;
+        }
+        catch (Exception e) {
+            throw;
+        }
+        finally {
+            StateHasChanged();
+        }
+        await base.OnInitializedAsync();
+    }
+
+    protected override async Task OnParametersSetAsync() {
+        StateHasChanged();
+        await base.OnParametersSetAsync();
+    }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage0.razor b/MatrixUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage0.razor
new file mode 100644
index 0000000..49fd5b4
--- /dev/null
+++ b/MatrixUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage0.razor
@@ -0,0 +1,11 @@
+<b>
+    <u>Welcome to the DM Space tool!</u>
+</b>
+<p>This wizard will help you set up a DM space.</p>
+<p>This is useful for eg. sharing DM rooms across multiple accounts.</p>
+<br/>
+<LinkButton href="/User/DMSpace?stage=1">Get started</LinkButton>
+
+@code {
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage1.razor b/MatrixUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage1.razor
new file mode 100644
index 0000000..6131617
--- /dev/null
+++ b/MatrixUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage1.razor
@@ -0,0 +1,128 @@
+@using LibMatrix.Homeservers
+@using LibMatrix.RoomTypes
+@using LibMatrix
+@using LibMatrix.Responses
+@using MatrixUtils.LibDMSpace
+@using MatrixUtils.LibDMSpace.StateEvents
+@using Microsoft.Extensions.Primitives
+@using ArcaneLibs.Extensions
+<b>
+    <u>DM Space setup tool - stage 1: Configure space</u>
+</b>
+<p>You will need a space to use for DM rooms.</p>
+@if (DmSpace is not null) {
+    <p>
+        Selected space:
+        <InputSelect @bind-Value="DmSpace.DmSpaceConfiguration.DMSpaceId">
+            @foreach (var (id, name) in spaces) {
+                <option value="@id">@name</option>
+            }
+        </InputSelect>
+    </p>
+    <p>
+        <InputCheckbox @bind-Value="DmSpaceInfo.LayerByUser"></InputCheckbox>
+        Create sub-spaces per user
+    </p>
+}
+else {
+    <b>Error: DmSpaceConfiguration is null!</b>
+}
+
+<br/>
+<LinkButton OnClick="@Execute">Next</LinkButton>
+
+@if (!string.IsNullOrWhiteSpace(Status)) {
+    <p>@Status</p>
+}
+
+@code {
+
+    private string? Status {
+        get => _status;
+        set {
+            _status = value;
+            StateHasChanged();
+        }
+    }
+
+    private Dictionary<string, string> spaces = new() { { "", "New space" } };
+    private string? _status;
+
+    [CascadingParameter]
+    public DMSpace? DmSpace { get; set; }
+
+    public DMSpaceInfo? DmSpaceInfo { get; set; } = new();
+
+    protected override async Task OnInitializedAsync() {
+        await base.OnInitializedAsync();
+    }
+
+    SemaphoreSlim _semaphoreSlim = new(1, 1);
+    protected override async Task OnParametersSetAsync() {
+        if (DmSpace is null)
+            return;
+        await _semaphoreSlim.WaitAsync();
+        DmSpace.DmSpaceConfiguration ??= new();
+        if (spaces.Count == 1) {
+            Status = "Looking for spaces...";
+            var userRoomsEnum = DmSpace.Homeserver.GetJoinedRoomsByType("m.space");
+            List<GenericRoom> userRooms = new(); 
+            await foreach (var room in userRoomsEnum) {
+                userRooms.Add(room);
+            }
+            var roomChecks = userRooms.Select(GetFeasibleSpaces).ToAsyncEnumerable();
+            await foreach(var room in roomChecks)
+                if(room.HasValue)
+                    spaces.TryAdd(room.Value.id, room.Value.name);
+            
+            Status = "Done!";
+        }
+        _semaphoreSlim.Release();
+        await base.OnParametersSetAsync();
+    }
+
+    private async Task Execute() {
+        if (string.IsNullOrWhiteSpace(DmSpace.DmSpaceConfiguration.DMSpaceId)) {
+            var crr = CreateRoomRequest.CreatePrivate(DmSpace.Homeserver, "Direct Messages");
+            crr.CreationContentBaseType.Type = "m.space";
+            DmSpace.DmSpaceConfiguration.DMSpaceId = (await DmSpace.Homeserver.CreateRoom(crr)).RoomId;
+        }
+        await DmSpace.Homeserver!.SetAccountDataAsync(DMSpaceConfiguration.EventId, DmSpace.DmSpaceConfiguration);
+        var space = DmSpace.Homeserver.GetRoom(DmSpace.DmSpaceConfiguration.DMSpaceId);
+        await space.SendStateEventAsync(DMSpaceInfo.EventId, DmSpaceInfo);
+
+        NavigationManager.NavigateTo("/User/DMSpace/Setup?stage=2");
+    }
+
+    public async Task<(string id, string name)?> GetFeasibleSpaces(GenericRoom room) {
+        try {
+            var pls = await room.GetPowerLevelsAsync();
+            if (!pls.UserHasStatePermission(DmSpace.Homeserver.WhoAmI.UserId, "m.space.child")) {
+                Console.WriteLine($"No permission to send m.space.child in {room.RoomId}...");
+                return null;
+            }
+            var roomName = await room.GetNameAsync();
+            Status = $"Found viable space: {roomName}";
+            if (string.IsNullOrWhiteSpace(DmSpace.DmSpaceConfiguration.DMSpaceId)) {
+                try {
+                    var dsi = await DmSpace.Homeserver.GetRoom(room.RoomId).GetStateOrNullAsync<DMSpaceInfo>(DMSpaceInfo.EventId) ?? new DMSpaceInfo();
+                    if (await room.GetStateOrNullAsync<DMSpaceInfo>(DMSpaceInfo.EventId) is not null && dsi is not null) {
+                        DmSpace.DmSpaceConfiguration.DMSpaceId = room.RoomId;
+                        DmSpaceInfo = dsi;
+                    }
+                }
+                catch (MatrixException e) {
+                    if (e.ErrorCode == "M_NOT_FOUND") Console.WriteLine($"{room.RoomId} is not a DM space.");
+                    else throw;
+                }
+            }
+            return (room.RoomId, roomName);
+        }
+        catch (MatrixException e) {
+            if (e.ErrorCode == "M_NOT_FOUND") Console.WriteLine($"m.room.power_levels does not exist in {room.RoomId}!!!");
+            else throw;
+        }
+        return null;
+    }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage2.razor b/MatrixUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage2.razor
new file mode 100644
index 0000000..5a53347
--- /dev/null
+++ b/MatrixUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage2.razor
@@ -0,0 +1,242 @@
+@using LibMatrix.Homeservers
+@using LibMatrix.RoomTypes
+@using LibMatrix
+@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.Responses
+@using MatrixUtils.LibDMSpace
+@using MatrixUtils.LibDMSpace.StateEvents
+@using ArcaneLibs.Extensions
+@using System.Text.Json.Serialization
+@using MatrixUtils.Abstractions
+<b>
+    <u>DM Space setup tool - stage 2: Fix DM room attribution</u>
+</b>
+<p>This is just to make sure that your DMs are attributed to the right person!</p>
+
+@if (!string.IsNullOrWhiteSpace(Status)) {
+    <p>@Status</p>
+}
+
+@if (DmSpace is not null) {
+    @foreach (var (userId, room) in dmRooms.OrderBy(x => x.Key.Id)) {
+        <InlineUserItem User="@userId"></InlineUserItem>
+        @foreach (var roomInfo in room) {
+            <RoomListItem RoomInfo="@roomInfo">
+                <LinkButton Round="true" OnClick="@(async () => DmToReassign = roomInfo)">Reassign</LinkButton>
+            </RoomListItem>
+        }
+    }
+}
+else {
+    <b>Error: DmSpaceConfiguration is null!</b>
+}
+
+<br/>
+<LinkButton OnClick="@Execute">Next</LinkButton>
+
+@{
+    var _offset = 0;
+}
+@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>Users:</p>
+        @foreach (var userProfileResponse in usersList) {
+            <LinkButton OnClick="@(() => SetRoomAssignment(room.Room.RoomId, userProfileResponse.Id))">
+                <span>Assign to </span>
+                <InlineUserItem User="userProfileResponse"></InlineUserItem>
+            </LinkButton>
+            <br/>
+        }
+    </ModalWindow>
+}
+
+@if (DmToReassign is not null) {
+    <ModalWindow Title="Re-assign DM" OnCloseClicked="@(() => DmToReassign = null)">
+        <RoomListItem RoomInfo="@DmToReassign"></RoomListItem>
+        @foreach (var userProfileResponse in roomMembers[DmToReassign]) {
+            <LinkButton OnClick="@(() => SetRoomAssignment(DmToReassign.Room.RoomId, userProfileResponse.Id))">
+                <span>Assign to </span>
+                <InlineUserItem User="userProfileResponse"></InlineUserItem>
+            </LinkButton>
+            <br/>
+        }
+    </ModalWindow>
+}
+
+@code {
+
+    private string newMxid { get; set; } = "";
+
+    private RoomInfo? DmToReassign {
+        get => _dmToReassign;
+        set {
+            _dmToReassign = value;
+            StateHasChanged();
+        }
+    }
+
+    private string? Status {
+        get => _status;
+        set {
+            _status = value;
+            StateHasChanged();
+        }
+    }
+
+    private string? _status;
+    private RoomInfo? _dmToReassign;
+
+    [CascadingParameter]
+    public DMSpace? DmSpace { get; set; }
+
+    private Dictionary<UserProfileWithId, List<RoomInfo>> dmRooms { get; set; } = new();
+    private Dictionary<RoomInfo, List<UserProfileWithId>> duplicateDmRooms { get; set; } = new();
+    private Dictionary<RoomInfo, List<UserProfileWithId>> roomMembers { get; set; } = new();
+
+    protected override async Task OnInitializedAsync() {
+        await base.OnInitializedAsync();
+    }
+
+    SemaphoreSlim _semaphore = new(1, 1);
+
+    protected override async Task OnParametersSetAsync() {
+        if (DmSpace is null)
+            return;
+        await _semaphore.WaitAsync();
+        DmToReassign = null;
+        var hs = DmSpace.Homeserver;
+        Status = "Loading DM list from account data...";
+        var dms = await DmSpace.Homeserver.GetAccountDataAsync<Dictionary<string, List<string>>>("m.direct");
+        Status = "Optimising DM list from account data...";
+        var joinedRooms = (await hs.GetJoinedRooms()).Select(x => x.RoomId).ToList();
+        foreach (var (user, rooms) in dms) {
+            for (var i = rooms.Count - 1; i >= 0; i--) {
+                var roomId = rooms[i];
+                if (!joinedRooms.Contains(roomId))
+                    rooms.RemoveAt(i);
+            }
+            dms[user] = rooms.Distinct().ToList();
+        }
+        dms.RemoveAll((x, y) => y is {Count: 0});
+        await DmSpace.Homeserver.SetAccountDataAsync("m.direct", dms);
+        dmRooms.Clear();
+
+        Status = "DM list optimised, fetching info...";
+        var results = dms.Select(async x => {
+            var (userId, rooms) = x;
+            UserProfileWithId userProfile;
+            try {
+                var profile = await DmSpace.Homeserver.GetProfileAsync(userId);
+                userProfile = new() {
+                    AvatarUrl = profile.AvatarUrl,
+                    Id = userId,
+                    DisplayName = profile.DisplayName
+                };
+            }
+            catch {
+                userProfile = new() {
+                    AvatarUrl = "mxc://feline.support/uUxBwaboPkMGtbZcAGZaIzpK",
+                    DisplayName = userId,
+                    Id = userId
+                };
+            }
+            var roomList = new List<RoomInfo>();
+            var tasks = rooms.Select(x => GetRoomInfo(hs.GetRoom(x))).ToAsyncEnumerable();
+            await foreach (var result in tasks)
+                roomList.Add(result);
+            return (userProfile, roomList);
+    // StateHasChanged();
+        }).ToAsyncEnumerable();
+        await foreach (var res in results) {
+            dmRooms.Add(res.userProfile, res.roomList);
+    // Status = $"Listed {dmRooms.Count} users";
+        }
+        _semaphore.Release();
+        var duplicateDmRoomIds = new Dictionary<string, List<UserProfileWithId>>();
+        foreach (var (user, rooms) in dmRooms) {
+            foreach (var roomInfo in rooms) {
+                if (!duplicateDmRoomIds.ContainsKey(roomInfo.Room.RoomId))
+                    duplicateDmRoomIds.Add(roomInfo.Room.RoomId, new());
+                duplicateDmRoomIds[roomInfo.Room.RoomId].Add(user);
+            }
+        }
+        duplicateDmRoomIds.RemoveAll((x, y) => y.Count == 1);
+        foreach (var (roomId, users) in duplicateDmRoomIds) {
+            duplicateDmRooms.Add(dmRooms.First(x => x.Value.Any(x => x.Room.RoomId == roomId)).Value.First(x => x.Room.RoomId == roomId), users);
+        }
+
+    // StateHasChanged();
+        Status = null;
+        await base.OnParametersSetAsync();
+    }
+
+    private async Task Execute() {
+        NavigationManager.NavigateTo("/User/DMSpace/Setup?stage=3");
+    }
+
+    private async Task<RoomInfo> GetRoomInfo(GenericRoom room) {
+        var roomInfo = new RoomInfo() {
+            Room = room
+        };
+        roomMembers[roomInfo] = new();
+        roomInfo.CreationEventContent = await room.GetCreateEventAsync();
+        try {
+            roomInfo.RoomName = await room.GetNameAsync();
+        }
+        catch { }
+
+        var membersEnum = room.GetMembersEnumerableAsync(true);
+        await foreach (var member in membersEnum)
+            if (member.TypedContent is RoomMemberEventContent memberEvent)
+                roomMembers[roomInfo].Add(new() { DisplayName = memberEvent.DisplayName, AvatarUrl = memberEvent.AvatarUrl, Id = member.StateKey });
+
+        if (string.IsNullOrWhiteSpace(roomInfo.RoomName) || roomInfo.RoomName == room.RoomId) {
+            List<string> displayNames = new List<string>();
+            foreach (var member in roomMembers[roomInfo])
+                if (!string.IsNullOrWhiteSpace(member.DisplayName))
+                    displayNames.Add(member.DisplayName);
+            roomInfo.RoomName = string.Join(", ", displayNames);
+        }
+        try {
+            string? roomIcon = (await room.GetAvatarUrlAsync())?.Url;
+            if (room is not null)
+                roomInfo.RoomIcon = roomIcon;
+        }
+        catch { }
+        return roomInfo;
+    }
+
+    private async Task<List<RoomInfo>> GetRoomInfoForRooms(List<GenericRoom> rooms) {
+        var tasks = rooms.Select(GetRoomInfo).ToList();
+        await Task.WhenAll(tasks);
+        return tasks.Select(x => x.Result).ToList();
+    }
+
+    private async Task SetRoomAssignment(string roomId, string userId) {
+        var hs = DmSpace.Homeserver;
+        Status = "Loading DM list from account data...";
+        var dms = await DmSpace.Homeserver.GetAccountDataAsync<Dictionary<string, List<string>>>("m.direct");
+        Status = "Updating DM list from account data...";
+
+        foreach (var (user, rooms) in dms) {
+            rooms.RemoveAll(x => x == roomId);
+            dms[user] = rooms.Distinct().ToList();
+        }
+        if(!dms.ContainsKey(userId))
+            dms.Add(userId, new());
+        dms[userId].Add(roomId);
+        dms.RemoveAll((x, y) => y is {Count: 0});
+        await DmSpace.Homeserver.SetAccountDataAsync("m.direct", dms);
+
+        duplicateDmRooms.RemoveAll((x, y) => x.Room.RoomId == roomId);
+        StateHasChanged();
+        if (duplicateDmRooms.Count == 0) await OnParametersSetAsync();
+    }
+
+    private class UserProfileWithId : UserProfileResponse {
+        [JsonIgnore]
+        public string Id { get; set; }
+    }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage3.razor b/MatrixUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage3.razor
new file mode 100644
index 0000000..9307f6a
--- /dev/null
+++ b/MatrixUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage3.razor
@@ -0,0 +1,191 @@
+@using LibMatrix.Homeservers
+@using LibMatrix.RoomTypes
+@using LibMatrix
+@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.Responses
+@using MatrixUtils.LibDMSpace
+@using MatrixUtils.LibDMSpace.StateEvents
+@using ArcaneLibs.Extensions
+@using System.Text.Json.Serialization
+@using MatrixUtils.Abstractions
+
+<b>
+    <u>DM Space setup tool - stage 3: Preview space layout</u>
+</b>
+<p>This gives you a preview of how your settings would impact layout!</p>
+
+@if (!string.IsNullOrWhiteSpace(Status)) {
+    <p>@Status</p>
+}
+
+@if (DmSpace is not null) {
+    @if (dmSpaceInfo is not null && dmSpaceRoomInfo is not null) {
+        <p>
+            <InputCheckbox @bind-Value="dmSpaceInfo.LayerByUser"></InputCheckbox>
+            Create sub-spaces per user
+        </p>
+        @if (!dmSpaceInfo.LayerByUser) {
+            <RoomListItem RoomInfo="@dmSpaceRoomInfo"></RoomListItem>
+            @foreach (var (userId, room) in dmRooms.OrderBy(x => x.Key.RoomName)) {
+                @foreach (var roomInfo in room) {
+                    <div style="margin-left: 32px;">
+                        <RoomListItem RoomInfo="@roomInfo"></RoomListItem>
+                    </div>
+                }
+            }
+        }
+        else {
+            <RoomListItem RoomInfo="@dmSpaceRoomInfo"></RoomListItem>
+            @foreach (var (userId, room) in dmRooms.OrderBy(x => x.Key.RoomName)) {
+                <div style="margin-left: 32px;">
+                    <RoomListItem RoomInfo="@userId"></RoomListItem>
+                </div>
+                @foreach (var roomInfo in room) {
+                    <div style="margin-left: 64px;">
+                        <RoomListItem RoomInfo="@roomInfo"></RoomListItem>
+                    </div>
+                }
+            }
+        }
+    }
+    else {
+        <b>Error: dmSpaceInfo is null!</b>
+    }
+}
+else {
+    <b>Error: DmSpaceConfiguration is null!</b>
+}
+
+<br/>
+<LinkButton OnClick="@Execute">Next</LinkButton>
+
+@code {
+
+    private string? Status {
+        get => _status;
+        set {
+            _status = value;
+            StateHasChanged();
+        }
+    }
+
+    private string? _status;
+
+    [CascadingParameter]
+    public DMSpace? DmSpace { get; set; }
+
+    private Dictionary<RoomInfo, List<RoomInfo>> dmRooms { get; set; } = new();
+    private DMSpaceInfo? dmSpaceInfo { get; set; }
+    private RoomInfo? dmSpaceRoomInfo { get; set; }
+
+    protected override async Task OnInitializedAsync() {
+        await base.OnInitializedAsync();
+    }
+
+    SemaphoreSlim _semaphore = new(1, 1);
+
+    protected override async Task OnParametersSetAsync() {
+        if (DmSpace is null)
+            return;
+        await _semaphore.WaitAsync();
+        var hs = DmSpace.Homeserver;
+        var dmSpaceRoom = new DMSpaceRoom(hs, DmSpace.DmSpaceConfiguration.DMSpaceId);
+        dmSpaceRoomInfo = new() {
+            RoomName = await dmSpaceRoom.GetNameAsync(),
+            CreationEventContent = await dmSpaceRoom.GetCreateEventAsync(),
+            RoomIcon = "mxc://feline.support/uUxBwaboPkMGtbZcAGZaIzpK",
+            Room = dmSpaceRoom
+        };
+        dmSpaceInfo = await dmSpaceRoom.GetDmSpaceInfo();
+        Status = "Loading DM list from account data...";
+        var dms = await DmSpace.Homeserver.GetAccountDataAsync<Dictionary<string, List<string>>>("m.direct");
+        dmRooms.Clear();
+
+        Status = "DM list optimised, fetching info...";
+        var results = dms.Select(async x => {
+            var (userId, rooms) = x;
+            UserProfileWithId userProfile;
+            try {
+                var profile = await DmSpace.Homeserver.GetProfileAsync(userId);
+                userProfile = new() {
+                    AvatarUrl = profile.AvatarUrl,
+                    Id = userId,
+                    DisplayName = profile.DisplayName
+                };
+            }
+            catch {
+                userProfile = new() {
+                    AvatarUrl = "mxc://feline.support/uUxBwaboPkMGtbZcAGZaIzpK",
+                    DisplayName = userId,
+                    Id = userId
+                };
+            }
+            var roomList = new List<RoomInfo>();
+            var tasks = rooms.Select(x => GetRoomInfo(hs.GetRoom(x))).ToAsyncEnumerable();
+            await foreach (var result in tasks)
+                roomList.Add(result);
+            return (userProfile, roomList);
+        }).ToAsyncEnumerable();
+        await foreach (var res in results) {
+            dmRooms.Add(new RoomInfo() {
+                Room = dmSpaceRoom,
+                RoomIcon = res.userProfile.AvatarUrl,
+                RoomName = res.userProfile.DisplayName,
+                CreationEventContent = await dmSpaceRoom.GetCreateEventAsync()
+            }, res.roomList);
+        }
+        _semaphore.Release();
+        Status = null;
+        await base.OnParametersSetAsync();
+    }
+
+    private async Task Execute() {
+        var hs = DmSpace.Homeserver;
+        var dmSpaceRoom = new DMSpaceRoom(hs, DmSpace.DmSpaceConfiguration.DMSpaceId);
+        NavigationManager.NavigateTo("/User/DMSpace/Setup?stage=3");
+    }
+
+    private async Task<RoomInfo> GetRoomInfo(GenericRoom room) {
+        var roomInfo = new RoomInfo() {
+            Room = room
+        };
+        var roomMembers = new List<UserProfileWithId>();
+        roomInfo.CreationEventContent = await room.GetCreateEventAsync();
+        try {
+            roomInfo.RoomName = await room.GetNameAsync();
+        }
+        catch { }
+
+        var membersEnum = room.GetMembersEnumerableAsync(true);
+        await foreach (var member in membersEnum)
+            if (member.TypedContent is RoomMemberEventContent memberEvent)
+                roomMembers.Add(new() { DisplayName = memberEvent.DisplayName, AvatarUrl = memberEvent.AvatarUrl, Id = member.StateKey });
+
+        if (string.IsNullOrWhiteSpace(roomInfo.RoomName) || roomInfo.RoomName == room.RoomId) {
+            List<string> displayNames = new List<string>();
+            foreach (var member in roomMembers)
+                if (!string.IsNullOrWhiteSpace(member.DisplayName))
+                    displayNames.Add(member.DisplayName);
+            roomInfo.RoomName = string.Join(", ", displayNames);
+        }
+        try {
+            string? roomIcon = (await room.GetAvatarUrlAsync())?.Url;
+            if (room is not null)
+                roomInfo.RoomIcon = roomIcon;
+        }
+        catch { }
+        return roomInfo;
+    }
+
+    private async Task<List<RoomInfo>> GetRoomInfoForRooms(List<GenericRoom> rooms) {
+        var tasks = rooms.Select(GetRoomInfo).ToList();
+        await Task.WhenAll(tasks);
+        return tasks.Select(x => x.Result).ToList();
+    }
+
+    private class UserProfileWithId : UserProfileResponse {
+        [JsonIgnore]
+        public string Id { get; set; }
+    }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/User/Profile.razor b/MatrixUtils.Web/Pages/User/Profile.razor
new file mode 100644
index 0000000..8cffaab
--- /dev/null
+++ b/MatrixUtils.Web/Pages/User/Profile.razor
@@ -0,0 +1,134 @@
+@page "/User/Profile"
+@using LibMatrix.Homeservers
+@using LibMatrix.EventTypes.Spec.State
+@using ArcaneLibs.Extensions
+@using LibMatrix.Responses
+<h3>Manage Profile - @Homeserver?.WhoAmI?.UserId</h3>
+<hr/>
+
+@if (NewProfile is not null) {
+    <h4>Profile</h4>
+    <hr/>
+    <div>
+        <img src="@Homeserver.ResolveMediaUri(NewProfile.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 @bind-Value="@NewProfile.DisplayName"></FancyTextBox><br/>
+            <span>Avatar URL: </span><FancyTextBox @bind-Value="@NewProfile.AvatarUrl"></FancyTextBox>
+            <InputFile OnChange="@AvatarChanged"></InputFile><br/>
+            <LinkButton OnClick="@(() => UpdateProfile())">Update profile</LinkButton>
+            <LinkButton OnClick="@(() => UpdateProfile(true))">Update profile (restore room overrides)</LinkButton>
+        </div>
+    </div>
+    @if (!string.IsNullOrWhiteSpace(Status)) {
+        <p>@Status</p>
+    }
+
+    <br/>
+
+    @* <details> *@
+    <h4>Room profiles<hr></h4>
+
+    @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 AuthenticatedHomeserverGeneric? Homeserver { get; set; }
+    private UserProfileResponse? NewProfile { get; set; }
+    private UserProfileResponse? OldProfile { get; set; }
+
+    private string? Status {
+        get => _status;
+        set {
+            _status = value;
+            StateHasChanged();
+        }
+    }
+
+    private Dictionary<string, RoomMemberEventContent> RoomProfiles { get; set; } = new();
+    private Dictionary<string, string> RoomNames { get; set; } = new();
+
+    protected override async Task OnInitializedAsync() {
+        Homeserver = await RMUStorage.GetCurrentSessionOrNavigate();
+        if (Homeserver is null) return;
+        Status = "Loading global profile...";
+        if (Homeserver.WhoAmI?.UserId is null) return;
+        NewProfile = (await Homeserver.GetProfileAsync(Homeserver.WhoAmI.UserId)); //.DeepClone();
+        OldProfile = (await Homeserver.GetProfileAsync(Homeserver.WhoAmI.UserId)); //.DeepClone();
+        Status = "Loading room profiles...";
+        var roomProfiles = Homeserver.GetRoomProfilesAsync();
+        await foreach (var (roomId, roomProfile) in roomProfiles) {
+            // Status = $"Got profile for {roomId}...";
+            RoomProfiles[roomId] = roomProfile; //.DeepClone();
+        }
+
+        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();
+        
+        await foreach (var (roomId, roomName) in roomNameTasks) {
+            // Status = $"Got room name for {roomId}: {roomName}";
+            RoomNames[roomId] = roomName;
+        }
+
+        StateHasChanged();
+        Status = null;
+
+        await base.OnInitializedAsync();
+    }
+
+    private async Task AvatarChanged(InputFileChangeEventArgs arg) {
+        var res = await Homeserver.UploadFile(arg.File.Name, arg.File.OpenReadStream(Int64.MaxValue), arg.File.ContentType);
+        Console.WriteLine(res);
+        NewProfile.AvatarUrl = res;
+        StateHasChanged();
+    }
+
+    private async Task UpdateProfile(bool restoreRoomProfiles = false) {
+        Status = "Busy processing global profile update, please do not leave this page...";
+        StateHasChanged();
+        await Homeserver.UpdateProfileAsync(NewProfile, restoreRoomProfiles);
+        Status = null;
+        StateHasChanged();
+        await OnInitializedAsync();
+    }
+
+    private async Task RoomAvatarChanged(InputFileChangeEventArgs arg, string roomId) {
+        var res = await Homeserver.UploadFile(arg.File.Name, arg.File.OpenReadStream(Int64.MaxValue), arg.File.ContentType);
+        Console.WriteLine(res);
+        RoomProfiles[roomId].AvatarUrl = res;
+        StateHasChanged();
+    }
+
+    private async Task UpdateRoomProfile(string roomId) {
+        Status = "Busy processing room profile update, please do not leave this page...";
+        StateHasChanged();
+        var room = Homeserver.GetRoom(roomId);
+        await room.SendStateEventAsync("m.room.member", Homeserver.WhoAmI.UserId, RoomProfiles[roomId]);
+        Status = null;
+        StateHasChanged();
+    }
+
+}
\ No newline at end of file