about summary refs log tree commit diff
path: root/MatrixUtils.Web/Pages/Rooms
diff options
context:
space:
mode:
authorRory& <root@rory.gay>2024-01-24 02:31:56 +0100
committerRory& <root@rory.gay>2024-01-24 17:05:25 +0100
commit03313562d21d5db9bf6a14ebbeab80e06c883d3a (patch)
treee000546a2ee8e6a886a7ed9fd01ad674178fb7cb /MatrixUtils.Web/Pages/Rooms
parentMake RMU installable (diff)
downloadMatrixUtils-03313562d21d5db9bf6a14ebbeab80e06c883d3a.tar.xz
MRU->RMU, fixes, cleanup
Diffstat (limited to 'MatrixUtils.Web/Pages/Rooms')
-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
7 files changed, 1286 insertions, 0 deletions
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)
+    };
+
+}