about summary refs log tree commit diff
path: root/MatrixUtils.Web/Shared
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/Shared
parentMake RMU installable (diff)
downloadMatrixUtils-03313562d21d5db9bf6a14ebbeab80e06c883d3a.tar.xz
MRU->RMU, fixes, cleanup
Diffstat (limited to 'MatrixUtils.Web/Shared')
-rw-r--r--MatrixUtils.Web/Shared/EditablePre.razor19
-rw-r--r--MatrixUtils.Web/Shared/InlineUserItem.razor71
-rw-r--r--MatrixUtils.Web/Shared/LogView.razor41
-rw-r--r--MatrixUtils.Web/Shared/MainLayout.razor19
-rw-r--r--MatrixUtils.Web/Shared/MainLayout.razor.css81
-rw-r--r--MatrixUtils.Web/Shared/MxcImage.razor53
-rw-r--r--MatrixUtils.Web/Shared/NavMenu.razor96
-rw-r--r--MatrixUtils.Web/Shared/NavMenu.razor.css68
-rw-r--r--MatrixUtils.Web/Shared/PolicyEditorComponents/PolicyEditorModal.razor92
-rw-r--r--MatrixUtils.Web/Shared/RoomList.razor97
-rw-r--r--MatrixUtils.Web/Shared/RoomList.razor.css8
-rw-r--r--MatrixUtils.Web/Shared/RoomListComponents/RoomListCategory.razor63
-rw-r--r--MatrixUtils.Web/Shared/RoomListComponents/RoomListPolicyRoom.razor13
-rw-r--r--MatrixUtils.Web/Shared/RoomListComponents/RoomListSpace.razor60
-rw-r--r--MatrixUtils.Web/Shared/RoomListItem.razor196
-rw-r--r--MatrixUtils.Web/Shared/RoomListItem.razor.css48
-rw-r--r--MatrixUtils.Web/Shared/TimelineComponents/BaseTimelineItem.razor33
-rw-r--r--MatrixUtils.Web/Shared/TimelineComponents/TimelineCanonicalAliasItem.razor27
-rw-r--r--MatrixUtils.Web/Shared/TimelineComponents/TimelineHistoryVisibilityItem.razor27
-rw-r--r--MatrixUtils.Web/Shared/TimelineComponents/TimelineMemberItem.razor53
-rw-r--r--MatrixUtils.Web/Shared/TimelineComponents/TimelineMessageItem.razor34
-rw-r--r--MatrixUtils.Web/Shared/TimelineComponents/TimelineRoomCreateItem.razor18
-rw-r--r--MatrixUtils.Web/Shared/TimelineComponents/TimelineRoomNameItem.razor27
-rw-r--r--MatrixUtils.Web/Shared/TimelineComponents/TimelineRoomTopicItem.razor37
-rw-r--r--MatrixUtils.Web/Shared/TimelineComponents/TimelineUnknownItem.razor16
-rw-r--r--MatrixUtils.Web/Shared/UserListItem.razor44
26 files changed, 1341 insertions, 0 deletions
diff --git a/MatrixUtils.Web/Shared/EditablePre.razor b/MatrixUtils.Web/Shared/EditablePre.razor
new file mode 100644
index 0000000..acb477c
--- /dev/null
+++ b/MatrixUtils.Web/Shared/EditablePre.razor
@@ -0,0 +1,19 @@
+@inherits InputBase<string>
+<pre id="@Id" class="@CssClass" @onkeyup="Callback" contenteditable="true">@CurrentValue</pre>
+
+@code {
+
+    protected override bool TryParseValueFromString(string? value, out string result, out string? validationErrorMessage) {
+        result = value;
+        validationErrorMessage = null;
+        return true;
+    }
+
+    public object Id { get; set; }
+
+    private Task Callback() {
+        Console.WriteLine("beep");
+        return Task.CompletedTask;
+    }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Shared/InlineUserItem.razor b/MatrixUtils.Web/Shared/InlineUserItem.razor
new file mode 100644
index 0000000..6df2307
--- /dev/null
+++ b/MatrixUtils.Web/Shared/InlineUserItem.razor
@@ -0,0 +1,71 @@
+@using LibMatrix
+@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.Helpers
+@using LibMatrix.Homeservers
+@using LibMatrix.Responses
+<div style="background-color: #ffffff11; border-radius: 0.5em; height: 1em; display: inline-block; vertical-align: middle;" alt="@UserId">
+    <img style="@(ChildContent is not null ? "vertical-align: baseline;" : "vertical-align: top;") width: 1em; height: 1em; border-radius: 50%;" src="@ProfileAvatar"/>
+    <span style="position: relative; top: -5px;">@ProfileName</span>
+
+    <div style="display: inline-block;">
+        @if (ChildContent is not null) {
+            @ChildContent
+        }
+    </div>
+
+</div>
+
+@code {
+
+    [Parameter]
+    public RenderFragment? ChildContent { get; set; }
+
+    [Parameter]
+    public UserProfileResponse? User { get; set; }
+
+    [Parameter]
+    public RoomMemberEventContent? MemberEvent { get; set; }
+
+    [Parameter]
+    public string? UserId { get; set; }
+
+    [Parameter]
+    public string? ProfileAvatar { get; set; } = null;
+
+    [Parameter]
+    public string? ProfileName { get; set; } = null;
+
+    [Parameter]
+    public AuthenticatedHomeserverGeneric? Homeserver { get; set; }
+
+    private static SemaphoreSlim _semaphoreSlim = new(128);
+
+    protected override async Task OnInitializedAsync() {
+        await base.OnInitializedAsync();
+        Homeserver ??= await RMUStorage.GetCurrentSessionOrNavigate();
+        if(Homeserver is null) return;
+
+        await _semaphoreSlim.WaitAsync();
+
+        if (User == null && UserId == null && MemberEvent != null)
+            throw new ArgumentNullException(nameof(UserId));
+
+        if (MemberEvent != null) {
+            User = new UserProfileResponse {
+                AvatarUrl = MemberEvent.AvatarUrl,
+                DisplayName = MemberEvent.DisplayName
+            };
+        }
+
+        if (User is null && UserId is not null) {
+            User ??= await Homeserver.GetProfileAsync(UserId);
+        }
+
+
+        ProfileAvatar ??= Homeserver.ResolveMediaUri(User.AvatarUrl);
+        ProfileName ??= User.DisplayName;
+
+        _semaphoreSlim.Release();
+    }
+
+}
diff --git a/MatrixUtils.Web/Shared/LogView.razor b/MatrixUtils.Web/Shared/LogView.razor
new file mode 100644
index 0000000..d541b82
--- /dev/null
+++ b/MatrixUtils.Web/Shared/LogView.razor
@@ -0,0 +1,41 @@
+@* @using System.Text *@
+@* @if (LocalStorageWrapper.Settings.DeveloperSettings.EnableLogViewers) { *@
+@*     <u>Logs</u> *@
+@*     <br/> *@
+@*     <pre> *@
+@*         @_stringBuilder *@
+@*     </pre> *@
+@* } *@
+@* *@
+@* @code { *@
+@*     StringBuilder _stringBuilder = new(); *@
+@* *@
+@*     protected override async Task OnInitializedAsync() { *@
+@*         if (!LocalStorageWrapper.Settings.DeveloperSettings.EnableConsoleLogging) { *@
+@*             Console.WriteLine("Console logging disabled!"); *@
+@*             var _sw = new StringWriter(); *@
+@*             Console.SetOut(_sw); *@
+@*             Console.SetError(_sw); *@
+@*             return; *@
+@*         } *@
+@*         if (!LocalStorageWrapper.Settings.DeveloperSettings.EnableLogViewers) return; *@
+@*     //intecept stdout with textwriter to get logs *@
+@*         var sw = new StringWriter(_stringBuilder); *@
+@*         Console.SetOut(sw); *@
+@*         Console.SetError(sw); *@
+@*     //keep updated *@
+@*         var length = 0; *@
+@*         Task.Run(async () => { *@
+@*             while (true) { *@
+@*                 await Task.Delay(100); *@
+@*                 if (_stringBuilder.Length != length) { *@
+@*                     StateHasChanged(); *@
+@*                     length = _stringBuilder.Length; *@
+@*                 } *@
+@*             } *@
+@*     // ReSharper disable once FunctionNeverReturns - This is intentional behavior *@
+@*         }); *@
+@*         await base.OnInitializedAsync(); *@
+@*     } *@
+@* *@
+@* } *@
\ No newline at end of file
diff --git a/MatrixUtils.Web/Shared/MainLayout.razor b/MatrixUtils.Web/Shared/MainLayout.razor
new file mode 100644
index 0000000..92194b2
--- /dev/null
+++ b/MatrixUtils.Web/Shared/MainLayout.razor
@@ -0,0 +1,19 @@
+@inherits LayoutComponentBase
+
+<div class="page">
+    <div class="sidebar">
+        <NavMenu/>
+    </div>
+
+    <main>
+        <div class="top-row px-4">
+            <PortableDevTools></PortableDevTools>
+            <a href="https://cgit.rory.gay/matrix/MatrixRoomUtils.git/" target="_blank">Git</a>
+            <a href="https://matrix.to/#/%23mru%3Arory.gay?via=rory.gay&via=matrix.org&via=feline.support" target="_blank">Matrix</a>
+        </div>
+
+        <article class="Content px-4">
+            @Body
+        </article>
+    </main>
+</div>
\ No newline at end of file
diff --git a/MatrixUtils.Web/Shared/MainLayout.razor.css b/MatrixUtils.Web/Shared/MainLayout.razor.css
new file mode 100644
index 0000000..01a5066
--- /dev/null
+++ b/MatrixUtils.Web/Shared/MainLayout.razor.css
@@ -0,0 +1,81 @@
+.page {
+    position: relative;
+    display: flex;
+    flex-direction: column;
+}
+
+main {
+    flex: 1;
+}
+
+.sidebar {
+    background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
+}
+
+.top-row {
+    background-color: #f7f7f7;
+    border-bottom: 1px solid #d6d5d5;
+    justify-content: flex-end;
+    height: 3.5rem;
+    display: flex;
+    align-items: center;
+}
+
+.top-row ::deep a, .top-row ::deep .btn-link {
+    white-space: nowrap;
+    margin-left: 1.5rem;
+    text-decoration: none;
+}
+
+.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
+    text-decoration: underline;
+}
+
+.top-row ::deep a:first-child {
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+
+@media (max-width: 640.98px) {
+    .top-row:not(.auth) {
+        display: none;
+    }
+
+    .top-row.auth {
+        justify-content: space-between;
+    }
+
+    .top-row ::deep a, .top-row ::deep .btn-link {
+        margin-left: 0;
+    }
+}
+
+@media (min-width: 641px) {
+    .page {
+        flex-direction: row;
+    }
+
+    .sidebar {
+        width: 250px;
+        height: 100vh;
+        position: sticky;
+        top: 0;
+    }
+
+    .top-row {
+        position: sticky;
+        top: 0;
+        z-index: 1;
+    }
+
+    .top-row.auth ::deep a:first-child {
+        flex: 1;
+        text-align: right;
+        width: 0;
+    }
+
+    .top-row, article {
+        padding-left: 2rem !important;
+        padding-right: 1.5rem !important;
+    }
+}
diff --git a/MatrixUtils.Web/Shared/MxcImage.razor b/MatrixUtils.Web/Shared/MxcImage.razor
new file mode 100644
index 0000000..fb8c248
--- /dev/null
+++ b/MatrixUtils.Web/Shared/MxcImage.razor
@@ -0,0 +1,53 @@
+<img class="@Class" src="@ResolvedUri" style="@Style"/>
+@code {
+    private string _mxcUri;
+    private string _style;
+    private string _resolvedUri;
+
+    [Parameter]
+    public string MxcUri {
+        get => _mxcUri ?? "";
+        set {
+            Console.WriteLine($"New MXC uri: {value}");
+            _mxcUri = value;
+            UriHasChanged(value);
+        }
+    }
+    
+    //mxcuri binding
+    
+    
+    [Parameter]
+    public string Style {
+        get => _style;
+        set {
+            _style = value;
+            StateHasChanged();
+        }
+    }
+    [Parameter]
+    public RemoteHomeserver? Homeserver { get; set; }
+
+    private string ResolvedUri {
+        get => _resolvedUri;
+        set {
+            _resolvedUri = value;
+            StateHasChanged();
+        }
+    }
+
+    private async Task UriHasChanged(string value) {
+        var uri = value[5..].Split('/');
+        Console.WriteLine($"UriHasChanged: {value} {uri[0]}");
+        if (Homeserver is null) {
+            Console.WriteLine($"Homeserver is null, creating new remotehomeserver for {uri[0]}");
+            Homeserver = await hsProvider.GetRemoteHomeserver(uri[0]);
+        }
+        ResolvedUri = Homeserver.ResolveMediaUri(value);
+        Console.WriteLine($"ResolvedUri: {ResolvedUri}");
+    }
+
+    [Parameter]
+    public string Class { get; set; }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Shared/NavMenu.razor b/MatrixUtils.Web/Shared/NavMenu.razor
new file mode 100644
index 0000000..43e2237
--- /dev/null
+++ b/MatrixUtils.Web/Shared/NavMenu.razor
@@ -0,0 +1,96 @@
+<div class="top-row ps-3 navbar navbar-dark">
+    <div class="container-fluid">
+        <a class="navbar-brand" href="">Rory&::MatrixUtils</a>
+        <button title="Navigation menu" class="navbar-toggler" @onclick="ToggleNavMenu">
+            <span class="navbar-toggler-icon"></span>
+        </button>
+    </div>
+</div>
+
+<div class="@NavMenuCssClass nav-scrollable" @onclick="ToggleNavMenu">
+    <nav class="flex-column">
+        <div class="nav-item px-3">
+            <NavLink class="nav-link" href="" Match="NavLinkMatch.All">
+                <span class="oi oi-home" aria-hidden="true"></span> Home
+            </NavLink>
+        </div>
+        
+        <div class="nav-item px-3">
+            <NavLink class="nav-link" href="About">
+                <span class="oi oi-info" aria-hidden="true"></span> About RMU
+            </NavLink>
+        </div>
+
+        <!-- Main tools -->
+        
+        <div class="nav-item px-3">
+            <h5 style="margin-left: 1em;">Main tools</h5>
+            <hr style="margin-bottom: 0em;"/>
+        </div>
+
+        <div class="nav-item px-3">
+            <NavLink class="nav-link" href="Rooms">
+                <span class="oi oi-plus" aria-hidden="true"></span> Room list
+            </NavLink>
+        </div>
+
+        <div class="nav-item px-3">
+            <NavLink class="nav-link" href="User/Profile">
+                <span class="oi oi-plus" aria-hidden="true"></span> Manage profile
+            </NavLink>
+        </div>
+        
+        <div class="nav-item px-3">
+            <NavLink class="nav-link" href="User/DirectMessages">
+                <span class="oi oi-plus" aria-hidden="true"></span> Manage DMs
+            </NavLink>
+        </div>
+
+        <!-- Extra tools -->
+        
+        <div class="nav-item px-3">
+            <h5 style="margin-left: 1em;">Extra tools</h5>
+            <hr style="margin-bottom: 0em;"/>
+        </div>
+
+        <div class="nav-item px-3">
+            <NavLink class="nav-link" href="HSAdmin">
+                <span class="oi oi-plus" aria-hidden="true"></span> Homeserver admin
+            </NavLink>
+        </div>
+
+        <div class="nav-item px-3">
+            <NavLink class="nav-link" href="Tools">
+                <span class="oi oi-plus" aria-hidden="true"></span> Other tools
+            </NavLink>
+        </div>
+
+        <!-- RMU -->
+        
+        <div class="nav-item px-3">
+            <h5 style="margin-left: 1em;">RMU</h5>
+            <hr style="margin-bottom: 0em;"/>
+        </div>
+        
+        <div class="nav-item px-3">
+            <NavLink class="nav-link" href="Dev/Options">
+                <span class="oi oi-plus" aria-hidden="true"></span> Developer options
+            </NavLink>
+        </div>
+        
+        <div class="nav-item px-3">
+            <NavLink class="nav-link" href="Dev/Utilities">
+                <span class="oi oi-plus" aria-hidden="true"></span> Developer utilities
+            </NavLink>
+        </div>
+    </nav>
+</div>
+
+@code {
+    private bool collapseNavMenu = true;
+
+    private string? NavMenuCssClass => collapseNavMenu ? "collapse" : null;
+
+    private void ToggleNavMenu() => collapseNavMenu = !collapseNavMenu;
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Shared/NavMenu.razor.css b/MatrixUtils.Web/Shared/NavMenu.razor.css
new file mode 100644
index 0000000..447f2df
--- /dev/null
+++ b/MatrixUtils.Web/Shared/NavMenu.razor.css
@@ -0,0 +1,68 @@
+.navbar-toggler {
+    background-color: rgba(255, 255, 255, 0.1);
+}
+
+.top-row {
+    height: 3.5rem;
+    background-color: rgba(0, 0, 0, 0.4);
+}
+
+.navbar-brand {
+    font-size: 1.1rem;
+}
+
+.oi {
+    width: 2rem;
+    font-size: 1.1rem;
+    vertical-align: text-top;
+    top: -2px;
+}
+
+.nav-item {
+    font-size: 0.9rem;
+    padding-bottom: 0.5rem;
+}
+
+.nav-item:first-of-type {
+    padding-top: 1rem;
+}
+
+.nav-item:last-of-type {
+    padding-bottom: 1rem;
+}
+
+.nav-item ::deep a {
+    color: #d7d7d7;
+    border-radius: 4px;
+    height: 3rem;
+    display: flex;
+    align-items: center;
+    line-height: 3rem;
+}
+
+.nav-item ::deep a.active {
+    background-color: rgba(255, 255, 255, 0.25);
+    color: white;
+}
+
+.nav-item ::deep a:hover {
+    background-color: rgba(255, 255, 255, 0.1);
+    color: white;
+}
+
+@media (min-width: 641px) {
+    .navbar-toggler {
+        display: none;
+    }
+
+    .collapse {
+        /* Never collapse the sidebar for wide screens */
+        display: block;
+    }
+
+    .nav-scrollable {
+        /* Allow sidebar to scroll for tall menus */
+        height: calc(100vh - 3.5rem);
+        overflow-y: auto;
+    }
+}
diff --git a/MatrixUtils.Web/Shared/PolicyEditorComponents/PolicyEditorModal.razor b/MatrixUtils.Web/Shared/PolicyEditorComponents/PolicyEditorModal.razor
new file mode 100644
index 0000000..4fd151d
--- /dev/null
+++ b/MatrixUtils.Web/Shared/PolicyEditorComponents/PolicyEditorModal.razor
@@ -0,0 +1,92 @@
+@using LibMatrix.EventTypes.Spec.State.Policy
+@using System.Reflection
+@using ArcaneLibs.Attributes
+@using ArcaneLibs.Extensions
+@using LibMatrix
+@using System.Collections.Frozen
+@using LibMatrix.EventTypes
+<ModalWindow Title="@((string.IsNullOrWhiteSpace(PolicyEvent.EventId) ? "Creating new " : "Editing ") + (PolicyEvent.MappedType.GetFriendlyNameOrNull()?.ToLower() ?? "event"))"
+             OnCloseClicked="@OnClose" X="60" Y="60" MinWidth="300">
+    @{
+        var policyData = (PolicyEvent.TypedContent as PolicyRuleEventContent)!;
+    }
+    @if (string.IsNullOrWhiteSpace(PolicyEvent.EventId)) {
+        <span>Policy type:</span>
+        <select @bind="@PolicyEvent.Type">
+            <option>Select a value</option>
+            @foreach (var (type, mappedType) in PolicyTypes) {
+                <option value="@type">@mappedType.GetFriendlyName().ToLower()</option>
+            }
+        </select>
+    }
+
+
+    @{
+        // enumerate all properties with friendly name
+        var props = PolicyEvent.MappedType.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();
+    }
+    <table>
+        <thead style="border-bottom: solid #ffffff44 1px;">
+            <tr>
+                <th>Property</th>
+                <th>Value</th>
+            </tr>
+        </thead>
+        <tbody>
+            @foreach (var prop in props) {
+                <tr>
+                    <td style="padding-right: 8px;">
+                        <span>@prop.GetFriendlyName()</span>
+                        @if (Nullable.GetUnderlyingType(prop.PropertyType) is not null) {
+                            <span style="color: red;">*</span>
+                        }
+                    </td>
+                    @{
+                        var getter = prop.GetGetMethod();
+                        var setter = prop.GetSetMethod();
+                    }
+                    @switch (Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType) {
+                        case Type t when t == typeof(string):
+                            <FancyTextBox Value="@(getter?.Invoke(policyData, null) as string)" ValueChanged="@(e => { Console.WriteLine($"{prop.Name} ({setter is not null}) -> {e}"); setter?.Invoke(policyData, [e]); StateHasChanged(); })"></FancyTextBox>
+                            break;
+                        default:
+                            <p style="color: red;">Unsupported type: @prop.PropertyType</p>
+                            break;
+                    }
+                </tr>
+            }
+        </tbody>
+    </table>
+    <br/>
+    <pre>
+        @PolicyEvent.ToJson(true, false)
+    </pre>
+    <LinkButton OnClick="@(() => { OnClose.Invoke(); return Task.CompletedTask; })">Cancel</LinkButton>
+    <LinkButton OnClick="@(() => { OnSave.Invoke(PolicyEvent); return Task.CompletedTask; })">Save</LinkButton>
+    @* <span>Target entity: </span> *@
+    @* <FancyTextBox @bind-Value="@policyData.Entity"></FancyTextBox><br/> *@
+    @* <span>Reason: </span> *@
+    @* <FancyTextBox @bind-Value="@policyData.Reason"></FancyTextBox> *@
+</ModalWindow>
+
+@code {
+
+    [Parameter]
+    public StateEventResponse? PolicyEvent { get; set; }
+
+    [Parameter]
+    public required Action OnClose { get; set; }
+    
+    [Parameter]
+    public required Action<StateEventResponse> OnSave { get; set; }
+
+    private static FrozenSet<Type> KnownPolicyTypes = StateEvent.KnownStateEventTypes.Where(x => x.IsAssignableTo(typeof(PolicyRuleEventContent))).ToFrozenSet();
+
+    private static Dictionary<string, Type> PolicyTypes = KnownPolicyTypes
+        .ToDictionary(x => x.GetCustomAttributes<MatrixEventAttribute>().First(y => !string.IsNullOrWhiteSpace(y.EventName)).EventName, x => x);
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Shared/RoomList.razor b/MatrixUtils.Web/Shared/RoomList.razor
new file mode 100644
index 0000000..ed443dd
--- /dev/null
+++ b/MatrixUtils.Web/Shared/RoomList.razor
@@ -0,0 +1,97 @@
+@using MatrixUtils.Web.Shared.RoomListComponents;
+@using LibMatrix
+@using LibMatrix.Extensions
+@using ArcaneLibs.Extensions
+@using LibMatrix.EventTypes.Spec.State
+@using System.Collections.ObjectModel
+@using LibMatrix.Responses
+@using MatrixUtils.Abstractions
+@using _Imports = MatrixUtils.Web._Imports
+@if (!StillFetching) {
+    <p>Fetching room details... @RoomsWithTypes.Sum(x => x.Value.Count) out of @Rooms.Count done!</p>
+    @foreach (var category in RoomsWithTypes.OrderBy(x => x.Value.Count)) {
+        <p>@category.Key (@category.Value.Count)</p>
+    }
+}
+else {
+    @foreach (var category in RoomsWithTypes.OrderBy(x => x.Value.Count)) {
+        <RoomListCategory Category="@category" GlobalProfile="@GlobalProfile"></RoomListCategory>
+    }
+}
+
+@code {
+
+    [Parameter]
+    public ObservableCollection<RoomInfo> Rooms { get; set; }
+
+    [Parameter]
+    public UserProfileResponse? GlobalProfile { get; set; }
+
+    [Parameter]
+    public bool StillFetching { get; set; } = true;
+
+    [Parameter]
+    public EventCallback<bool> StillFetchingChanged { get; set; }
+
+    private Dictionary<string, List<RoomInfo>> RoomsWithTypes => Rooms is null ? new() : Rooms.GroupBy(x => GetRoomTypeName(x.CreationEventContent?.Type)).ToDictionary(x => x.Key, x => x.ToList());
+
+    private bool hooked;
+    protected override async Task OnParametersSetAsync() {
+        var hs = await RMUStorage.GetCurrentSessionOrNavigate();
+        if (hs is null) return;
+        if (!hooked) {
+            Rooms.CollectionChanged += (_, args) => {
+                foreach (RoomInfo item in args.NewItems) {
+                    item.PropertyChanged += (_, args2) => {
+                        // Console.WriteLine(args2);
+                        
+                        if (args2.PropertyName == nameof(item.CreationEventContent))
+                            StateHasChanged();
+                    };
+                }
+            };
+            hooked = true;
+        }
+
+        // GlobalProfile ??= await hs.GetProfileAsync(hs.WhoAmI.UserId);
+
+        await base.OnParametersSetAsync();
+    }
+
+    private string GetRoomTypeName(string? roomType) => roomType switch {
+        null => "Room",
+        "m.space" => "Space",
+        "msc3588.stories.stories-room" => "Story room",
+        "support.feline.policy.lists.msc.v1" => "MSC3784 Policy list (v1)",
+        _ => roomType
+        };
+
+    // private static SemaphoreSlim _semaphoreSlim = new(8, 8);
+
+    // private async Task ProcessRoom(RoomInfo room) {
+    //     await _semaphoreSlim.WaitAsync();
+    //     string roomType;
+    //     try {
+    //         var createEvent = (await room.GetStateEvent("m.room.create")).TypedContent as RoomCreateEventContent;
+    //         roomType = GetRoomTypeName(createEvent.Type);
+    //
+    //         if (roomType == "Room") {
+    //             var mjolnirData = await room.GetStateEvent("org.matrix.mjolnir.shortcode");
+    //             if (mjolnirData?.RawContent?.ToJson(ignoreNull: true) is not null and not "{}")
+    //                 roomType = "Legacy policy room";
+    //         }
+    //     }
+    //     catch (MatrixException e) {
+    //         roomType = $"Error: {e.ErrorCode}";
+    //     }
+    //
+    //     // if (!RoomsWithTypes.ContainsKey(roomType)) {
+    //         // RoomsWithTypes.Add(roomType, new List<RoomInfo>());
+    //     // }
+    //     // RoomsWithTypes[roomType].Add(room);
+    //
+    //     StateHasChanged();
+    //     _semaphoreSlim.Release();
+    // }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Shared/RoomList.razor.css b/MatrixUtils.Web/Shared/RoomList.razor.css
new file mode 100644
index 0000000..a159305
--- /dev/null
+++ b/MatrixUtils.Web/Shared/RoomList.razor.css
@@ -0,0 +1,8 @@
+.room-list-item {
+    background-color: #ffffff11;
+    border-radius: 0.5em;
+    display: block;
+    margin-top: 4px;
+    padding: 4px;
+    width: fit-content;
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Shared/RoomListComponents/RoomListCategory.razor b/MatrixUtils.Web/Shared/RoomListComponents/RoomListCategory.razor
new file mode 100644
index 0000000..3d0070f
--- /dev/null
+++ b/MatrixUtils.Web/Shared/RoomListComponents/RoomListCategory.razor
@@ -0,0 +1,63 @@
+@using MatrixUtils.Web.Classes.Constants
+@using LibMatrix
+@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.Homeservers
+@using LibMatrix.Responses
+@using MatrixUtils.Abstractions
+<details>
+    <summary>@RoomType (@Rooms.Count)</summary>
+    @foreach (var room in Rooms) {
+        <div class="room-list-item">
+            <RoomListItem RoomInfo="@room" ShowOwnProfile="@(RoomType == "Room")"></RoomListItem>
+            @* @if (RoomVersionDangerLevel(room) != 0 && *@
+            @*      (room.StateEvents.FirstOrDefault(x=>x.Type == "m.room.power_levels")?.TypedContent is RoomPowerLevelEventContent powerLevels && powerLevels.UserHasPermission(Homeserver.UserId, "m.room.tombstone"))) { *@
+            @*     <MatrixUtils.Web.Shared.SimpleComponents.LinkButton Color="@(RoomVersionDangerLevel(room) == 2 ? "#ff0000" : "#ff8800")" href="@($"/Rooms/Create?Import={room.Room.RoomId}")">Upgrade room</MatrixUtils.Web.Shared.SimpleComponents.LinkButton> *@
+            @* } *@
+            <LinkButton href="@($"/Rooms/{room.Room.RoomId}/Timeline")">View timeline</LinkButton>
+            <LinkButton href="@($"/Rooms/{room.Room.RoomId}/State/View")">View state</LinkButton>
+            <LinkButton href="@($"/Rooms/{room.Room.RoomId}/State/Edit")">Edit state</LinkButton>
+
+            @if (room.CreationEventContent?.Type == "m.space") {
+                <RoomListSpace Space="@room"></RoomListSpace>
+            }
+            else if (room.CreationEventContent?.Type == "support.feline.policy.lists.msc.v1" || RoomType == "org.matrix.mjolnir.policy") {
+                <LinkButton href="@($"/Rooms/{room.Room.RoomId}/Policies")">Manage policies</LinkButton>
+            }
+        </div>
+    }
+</details>
+<br/>
+
+@code {
+
+    [Parameter]
+    public KeyValuePair<string, List<RoomInfo>> Category { get; set; }
+
+    [Parameter]
+    public UserProfileResponse? GlobalProfile { get; set; }
+
+    [CascadingParameter]
+    public AuthenticatedHomeserverGeneric Homeserver { get; set; } = null!;
+
+    private string RoomType => Category.Key;
+    private List<RoomInfo> Rooms => Category.Value;
+
+    private int RoomVersionDangerLevel(RoomInfo room) {
+        var creationEvent = room.StateEvents.FirstOrDefault(x => x?.Type == "m.room.create");
+        if (creationEvent is null) return 0;
+        return creationEvent.TypedContent is not RoomCreateEventContent roomVersionContent ? 0
+            : RoomConstants.DangerousRoomVersions.Contains(roomVersionContent.RoomVersion) ? 2
+                : roomVersionContent.RoomVersion != RoomConstants.RecommendedRoomVersion ? 1 : 0;
+    }
+    
+    public static string GetRoomTypeName(string roomType) {
+        return roomType switch {
+            null => "Room",
+            "m.space" => "Space",
+            "org.matrix.mjolnir.policy" => "Policy room",
+            
+            _ => roomType
+        };
+    }
+
+}
diff --git a/MatrixUtils.Web/Shared/RoomListComponents/RoomListPolicyRoom.razor b/MatrixUtils.Web/Shared/RoomListComponents/RoomListPolicyRoom.razor
new file mode 100644
index 0000000..7afdc8c
--- /dev/null
+++ b/MatrixUtils.Web/Shared/RoomListComponents/RoomListPolicyRoom.razor
@@ -0,0 +1,13 @@
+@using LibMatrix.RoomTypes
+<LinkButton href="@($"/Rooms/{Room.RoomId}/Policies")">Manage policies</LinkButton>
+
+@code {
+
+    [Parameter]
+    public GenericRoom Room { get; set; }
+
+    protected override async Task OnInitializedAsync() {
+        await base.OnInitializedAsync();
+    }
+
+}
diff --git a/MatrixUtils.Web/Shared/RoomListComponents/RoomListSpace.razor b/MatrixUtils.Web/Shared/RoomListComponents/RoomListSpace.razor
new file mode 100644
index 0000000..895d642
--- /dev/null
+++ b/MatrixUtils.Web/Shared/RoomListComponents/RoomListSpace.razor
@@ -0,0 +1,60 @@
+@using System.Collections.ObjectModel
+@using MatrixUtils.Abstractions
+<MatrixUtils.Web.Shared.SimpleComponents.LinkButton href="@($"/Rooms/{Space.Room.RoomId}/Space")">Manage space</MatrixUtils.Web.Shared.SimpleComponents.LinkButton>
+
+<br/>
+<details @ontoggle="SpaceChildrenOpened">
+    <summary>@Children.Count children</summary>
+    @if (_shouldRenderChildren) {
+        <p>Breadcrumb: @Breadcrumbs</p>
+        <div style="margin-left: 8px;">
+            <RoomList Rooms="Children"></RoomList>
+        </div>
+    }
+</details>
+
+@code {
+
+    [Parameter]
+    public RoomInfo Space { get; set; }
+
+    [Parameter, CascadingParameter]
+    public List<RoomInfo> KnownRooms { get; set; } = new();
+
+    [Parameter, CascadingParameter]
+    public string? Breadcrumbs {
+        get => _breadcrumbs + Space.Room.RoomId;
+        set => _breadcrumbs = value;
+    }
+
+    private ObservableCollection<RoomInfo> Children { get; set; } = new();
+
+    protected override async Task OnInitializedAsync() {
+        if (Breadcrumbs == null) throw new ArgumentNullException(nameof(Breadcrumbs));
+        await Task.Delay(Random.Shared.Next(1000, 10000));
+        var rooms = Space.Room.AsSpace.GetChildrenAsync();
+        await foreach (var room in rooms) {
+            if (Breadcrumbs.Contains(room.RoomId)) continue;
+            var roomInfo = KnownRooms.FirstOrDefault(x => x.Room.RoomId == room.RoomId);
+            if (roomInfo is null) {
+                roomInfo = new RoomInfo() {
+                    Room = room
+                };
+                KnownRooms.Add(roomInfo);
+            }
+            Children.Add(roomInfo);
+        }
+        await base.OnInitializedAsync();
+    }
+
+    private bool _shouldRenderChildren = false;
+    private string? _breadcrumbs;
+
+    private Task SpaceChildrenOpened() {
+        if (_shouldRenderChildren) return Task.CompletedTask;
+        _shouldRenderChildren = true;
+        Console.WriteLine($"[RoomList] Rendering children of {Space.Room.RoomId}");
+        return Task.CompletedTask;
+    }
+
+}
diff --git a/MatrixUtils.Web/Shared/RoomListItem.razor b/MatrixUtils.Web/Shared/RoomListItem.razor
new file mode 100644
index 0000000..1046dd1
--- /dev/null
+++ b/MatrixUtils.Web/Shared/RoomListItem.razor
@@ -0,0 +1,196 @@
+@using System.Text.Json
+@using LibMatrix
+@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.Helpers
+@using LibMatrix.Homeservers
+@using LibMatrix.Responses
+@using LibMatrix.RoomTypes
+@using MatrixUtils.Abstractions
+@using MatrixUtils.Web.Classes.Constants
+@if (RoomInfo is not null) {
+    <div class="roomListItem @(HasDangerousRoomVersion ? "dangerousRoomVersion" : HasOldRoomVersion ? "oldRoomVersion" : "")" id="@RoomInfo.Room.RoomId">
+        @if (OwnMemberState != null) {
+            <MxcImage Class="@("avatar32" + (OwnMemberState?.AvatarUrl != GlobalProfile?.AvatarUrl ? " highlightChange" : "") + (ChildContent is not null ? " vcenter" : ""))"
+                      MxcUri="@(OwnMemberState.AvatarUrl ?? GlobalProfile.AvatarUrl)"/>
+            <span class="centerVertical border75 @(OwnMemberState?.AvatarUrl != GlobalProfile?.AvatarUrl ? "highlightChange" : "")">
+                @(OwnMemberState?.DisplayName ?? GlobalProfile?.DisplayName ?? "Loading...")
+            </span>
+            <span class="centerVertical noLeftPadding">-></span>
+        }
+        <MxcImage Class="avatar32" MxcUri="@RoomInfo.RoomIcon" Style="@(ChildContent is not null ? "vertical-align: middle;" : "")"/>
+        <div class="inlineBlock">
+            <span class="centerVertical">@RoomInfo.RoomName</span>
+            @if (ChildContent is not null) {
+                @ChildContent
+            }
+        </div>
+
+    </div>
+}
+else {
+    <p>Warning: RoomInfo is null!</p>
+}
+
+@code {
+
+    [Parameter]
+    public RenderFragment? ChildContent { get; set; }
+
+    [Parameter]
+    public RoomInfo? RoomInfo {
+        get => _roomInfo;
+        set {
+            _roomInfo = value;
+            OnParametersSetAsync();
+        }
+    }
+
+    [Parameter]
+    public bool ShowOwnProfile { get; set; } = false;
+
+    [Parameter]
+    public RoomMemberEventContent? OwnMemberState { get; set; }
+
+    [CascadingParameter]
+    public UserProfileResponse? GlobalProfile { get; set; }
+
+    [Parameter]
+    public bool LoadData {
+        get => _loadData;
+        set {
+            _loadData = value;
+            OnParametersSetAsync();
+        }
+    }
+
+    private bool HasOldRoomVersion { get; set; } = false;
+    private bool HasDangerousRoomVersion { get; set; } = false;
+
+    private static SemaphoreSlim _semaphoreSlim = new(8);
+    private RoomInfo? _roomInfo;
+    private bool _loadData = false;
+    private static AuthenticatedHomeserverGeneric? hs { get; set; }
+
+    private bool _hooked;
+    protected override async Task OnParametersSetAsync() {
+        if (RoomInfo != null) {
+            if (!_hooked) {
+                _hooked = true;
+                RoomInfo.PropertyChanged += (_, a) => {
+                    Console.WriteLine(a.PropertyName);
+                    StateHasChanged();
+                };
+            }
+
+            if (LoadData) {
+                try {
+                    await RoomInfo.GetStateEvent("m.room.create");
+                    if (ShowOwnProfile)
+                        OwnMemberState ??= (await RoomInfo.GetStateEvent("m.room.member", hs.WhoAmI.UserId)).TypedContent as RoomMemberEventContent;
+
+                    await RoomInfo.GetStateEvent("m.room.name");
+                    await RoomInfo.GetStateEvent("m.room.avatar");
+                }
+                catch (MatrixException e) {
+                    if (e.ErrorCode == "M_FORBIDDEN") {
+                        LoadData = false;
+                        RoomInfo.StateEvents.Add(new() {
+                            Type = "m.room.create",
+                            TypedContent = new RoomCreateEventContent() { RoomVersion = "0" }, 
+                            RoomId = null, Sender = null, EventId = null //TODO: implement
+                        });
+                        RoomInfo.StateEvents.Add(new() {
+                            Type = "m.room.name",
+                            TypedContent = new RoomNameEventContent() {
+                                Name = "M_FORBIDDEN: Are you a member of this room? " + RoomInfo.Room.RoomId
+                            },
+                            RoomId = null, Sender = null, EventId = null //TODO: implement
+                        });
+                    }
+                }
+            }
+        }
+
+        await base.OnParametersSetAsync();
+    }
+
+    protected override async Task OnInitializedAsync() {
+        await base.OnInitializedAsync();
+
+        await _semaphoreSlim.WaitAsync();
+
+        hs ??= await RMUStorage.GetCurrentSessionOrNavigate();
+        if (hs is null) return;
+
+        try {
+            await CheckRoomVersion();
+    // await GetRoomInfo();
+    // await LoadOwnProfile();
+        }
+        catch (MatrixException e) {
+            if (e is not { ErrorCode: "M_FORBIDDEN" }) {
+                throw;
+            }
+    // RoomName = "Error: " + e.Message;
+    // RoomIcon = "/blobfox_outage.gif";
+        }
+        catch (Exception e) {
+            Console.WriteLine($"Failed to load room info for {RoomInfo.Room.RoomId}: {e.Message}");
+        }
+        _semaphoreSlim.Release();
+    }
+
+    private async Task LoadOwnProfile() {
+        if (!ShowOwnProfile) return;
+        try {
+    // OwnMemberState ??= (await RoomInfo.GetStateEvent("m.room.member", hs.UserId)).TypedContent as RoomMemberEventContent;
+            GlobalProfile ??= await hs.GetProfileAsync(hs.UserId);
+        }
+        catch (MatrixException e) {
+            if (e is { ErrorCode: "M_FORBIDDEN" }) {
+                Console.WriteLine($"Failed to get profile for {hs.UserId}: {e.Message}");
+                ShowOwnProfile = false;
+            }
+            else {
+                throw;
+            }
+        }
+    }
+
+    private async Task CheckRoomVersion() {
+        while (RoomInfo?.CreationEventContent is null) {
+            Console.WriteLine($"Room creation event content for {RoomInfo.Room.RoomId} is null...");
+            await Task.Delay(Random.Shared.Next(1000, 2500));
+        }
+        var ce = RoomInfo.CreationEventContent;
+        if (int.TryParse(ce.RoomVersion, out var rv)) {
+            if (rv < 10)
+                HasOldRoomVersion = true;
+        }
+        else // treat unstable room versions as dangerous
+            HasDangerousRoomVersion = true;
+
+        if (RoomConstants.DangerousRoomVersions.Contains(ce.RoomVersion)) {
+            HasDangerousRoomVersion = true;
+    // RoomName = "Dangerous room: " + RoomName;
+        }
+    }
+
+    // private async Task GetRoomInfo() {
+    //     try {
+    //         RoomName ??= ((await RoomInfo.GetStateEvent("m.room.name"))?.TypedContent as RoomNameEventContent)?.Name ?? RoomId;
+    //
+    //         var state = (await RoomInfo.GetStateEvent("m.room.avatar")).TypedContent as RoomAvatarEventContent;
+    //         if (state?.Url is { } url) {
+    //             RoomIcon = await hsResolver.ResolveMediaUri(hs.ServerName, url);
+    // // Console.WriteLine($"Got avatar for room {RoomId}: {roomIcon} ({url})");
+    //         }
+    //     }
+    //     catch (MatrixException e) {
+    //         if (e is not { ErrorCode: "M_FORBIDDEN" }) {
+    //             throw;
+    //         }
+    //     }
+    // }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Shared/RoomListItem.razor.css b/MatrixUtils.Web/Shared/RoomListItem.razor.css
new file mode 100644
index 0000000..13de656
--- /dev/null
+++ b/MatrixUtils.Web/Shared/RoomListItem.razor.css
@@ -0,0 +1,48 @@
+.roomListItem {
+    background-color: #ffffff11;
+    border-radius: 25px;
+    margin: 8px;
+    width: fit-Content;
+}
+
+.roomListItem.dangerousRoomVersion {
+    border: red 4px solid;
+}
+
+.roomListItem.oldRoomVersion {
+    border: #FF0 1px solid;
+}
+
+.avatar32 {
+    width: 32px;
+    height: 32px;
+    border-radius: 50%;
+}
+
+.avatar32.vcenter {
+    vertical-align: baseline;
+}
+
+.highlightChange {
+    background-color: red;
+    border-color: red;
+    border-width: 3px;
+    border-style: dashed;
+}
+
+.inlineBlock {
+    display: inline-block;
+}
+
+.centerVertical {
+    vertical-align: middle;
+    padding-right: 8px;
+}
+
+.noLeftPadding {
+    padding-left: 0px;
+}
+
+.border75 {
+    border-radius: 75px;
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Shared/TimelineComponents/BaseTimelineItem.razor b/MatrixUtils.Web/Shared/TimelineComponents/BaseTimelineItem.razor
new file mode 100644
index 0000000..8d608e3
--- /dev/null
+++ b/MatrixUtils.Web/Shared/TimelineComponents/BaseTimelineItem.razor
@@ -0,0 +1,33 @@
+@using LibMatrix
+@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.Homeservers
+@using LibMatrix.Responses
+<h3>BaseTimelineItem</h3>
+
+@code {
+
+    [Parameter]
+    public StateEventResponse Event { get; set; }
+
+    [Parameter]
+    public List<StateEventResponse> Events { get; set; }
+
+    [Parameter]
+    public AuthenticatedHomeserverGeneric Homeserver { get; set; }
+
+    public List<StateEventResponse> EventsBefore => Events.TakeWhile(e => e.EventId != Event.EventId).ToList();
+
+    public List<StateEventResponse> MatchingEventsBefore => EventsBefore.Where(x => x.Type == Event.Type && x.StateKey == Event.StateKey).ToList();
+
+    public StateEventResponse? PreviousState => MatchingEventsBefore.LastOrDefault();
+
+    public RoomMemberEventContent? CurrentSenderMemberEventContent => EventsBefore.LastOrDefault(x => x.Type == "m.room.member" && x.StateKey == Event.Sender)?
+        .TypedContent as RoomMemberEventContent;
+
+    public UserProfileResponse CurrentSenderProfile => new() { DisplayName = CurrentSenderMemberEventContent?.DisplayName, AvatarUrl = CurrentSenderMemberEventContent?.AvatarUrl };
+
+    public bool HasPreviousMessage => EventsBefore.Last() is { Type: "m.room.message" } response && response.Sender == Event.Sender;
+
+
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Shared/TimelineComponents/TimelineCanonicalAliasItem.razor b/MatrixUtils.Web/Shared/TimelineComponents/TimelineCanonicalAliasItem.razor
new file mode 100644
index 0000000..1213432
--- /dev/null
+++ b/MatrixUtils.Web/Shared/TimelineComponents/TimelineCanonicalAliasItem.razor
@@ -0,0 +1,27 @@
+@using ArcaneLibs.Extensions
+@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.Responses
+@inherits BaseTimelineItem
+
+@if (currentEventContent is not null) {
+    @if (previousEventContent is null) {
+        <i><InlineUserItem User="@CurrentSenderProfile" Homeserver="@Homeserver" UserId="@Event.StateKey"></InlineUserItem> set the room alias to "@currentEventContent.Alias"</i>
+    }
+    else {
+        <i><InlineUserItem User="@CurrentSenderProfile" Homeserver="@Homeserver" UserId="@Event.StateKey"></InlineUserItem> changed the room name from "@previousEventContent.Alias" to "@currentEventContent.Alias"</i>
+    }
+}
+else {
+    <details>
+        <summary>Unknown event @Event.Type (@Event.StateKey)</summary>
+        <pre>
+            @Event.ToJson()
+        </pre>
+    </details>
+}
+
+@code {
+    private RoomCanonicalAliasEventContent? previousEventContent => PreviousState?.TypedContent as RoomCanonicalAliasEventContent;
+
+    private RoomCanonicalAliasEventContent? currentEventContent => Event.TypedContent as RoomCanonicalAliasEventContent;
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Shared/TimelineComponents/TimelineHistoryVisibilityItem.razor b/MatrixUtils.Web/Shared/TimelineComponents/TimelineHistoryVisibilityItem.razor
new file mode 100644
index 0000000..172a38c
--- /dev/null
+++ b/MatrixUtils.Web/Shared/TimelineComponents/TimelineHistoryVisibilityItem.razor
@@ -0,0 +1,27 @@
+@using ArcaneLibs.Extensions
+@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.Responses
+@inherits BaseTimelineItem
+
+@if (currentEventContent is not null) {
+    @if (previousEventContent is null) {
+        <i><InlineUserItem User="@CurrentSenderProfile" Homeserver="@Homeserver" UserId="@Event.StateKey"></InlineUserItem> set the history visibility to "@currentEventContent.HistoryVisibility"</i>
+    }
+    else {
+        <i><InlineUserItem User="@CurrentSenderProfile" Homeserver="@Homeserver" UserId="@Event.StateKey"></InlineUserItem> changed the history visibility from "@previousEventContent.HistoryVisibility" to "@currentEventContent.HistoryVisibility"</i>
+    }
+}
+else {
+    <details>
+        <summary>Unknown event @Event.Type (@Event.StateKey)</summary>
+        <pre>
+            @Event.ToJson()
+        </pre>
+    </details>
+}
+
+@code {
+    private RoomHistoryVisibilityEventContent? previousEventContent => PreviousState?.TypedContent as RoomHistoryVisibilityEventContent;
+
+    private RoomHistoryVisibilityEventContent? currentEventContent => Event.TypedContent as RoomHistoryVisibilityEventContent;
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Shared/TimelineComponents/TimelineMemberItem.razor b/MatrixUtils.Web/Shared/TimelineComponents/TimelineMemberItem.razor
new file mode 100644
index 0000000..3b18b95
--- /dev/null
+++ b/MatrixUtils.Web/Shared/TimelineComponents/TimelineMemberItem.razor
@@ -0,0 +1,53 @@
+@using ArcaneLibs.Extensions
+@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.Responses
+@inherits BaseTimelineItem
+
+@if (roomMemberData is not null) {
+    @switch (roomMemberData.Membership) {
+        case "ban":
+            <i>@Event.StateKey was banned</i>
+            break;
+        case "invite":
+            <i>@Event.StateKey was invited</i>
+            break;
+        case "join" when Event.ReplacesState is not null:
+            <i>@Event.StateKey changed their display name to @(roomMemberData.DisplayName ?? Event.Sender)</i>
+            break;
+        case "join":
+            @if (prevRoomMemberData is null) {
+                <i><InlineUserItem User="@(new UserProfileResponse() { DisplayName = roomMemberData.DisplayName, AvatarUrl = roomMemberData.AvatarUrl })" Homeserver="@Homeserver" UserId="@Event.StateKey"></InlineUserItem> joined</i>
+            }
+            else {
+                <i><InlineUserItem User="@(new UserProfileResponse() { DisplayName = prevRoomMemberData.DisplayName, AvatarUrl = prevRoomMemberData.AvatarUrl })" Homeserver="@Homeserver" UserId="@Event.StateKey"></InlineUserItem> changed their profile to <InlineUserItem User="@(new UserProfileResponse() { DisplayName = roomMemberData.DisplayName, AvatarUrl = roomMemberData.AvatarUrl })" Homeserver="@Homeserver" UserId="@Event.StateKey"></InlineUserItem></i>
+            }
+            break;
+        case "leave":
+            <i>@Event.StateKey left</i>
+            break;
+        case "knock":
+            <i>@Event.StateKey knocked</i>
+            break;
+        default:
+            <i>@Event.StateKey has an unknown state:</i>
+            <pre>
+                @Event.ToJson()
+            </pre>
+            break;
+    }
+}
+else {
+    <details>
+        <summary>Unknown membership event for @Event.StateKey</summary>
+        <pre>
+            @Event.ToJson()
+        </pre>
+    </details>
+}
+
+@code {
+
+    private RoomMemberEventContent? roomMemberData => Event.TypedContent as RoomMemberEventContent;
+    private RoomMemberEventContent? prevRoomMemberData => PreviousState?.TypedContent as RoomMemberEventContent;
+
+}
diff --git a/MatrixUtils.Web/Shared/TimelineComponents/TimelineMessageItem.razor b/MatrixUtils.Web/Shared/TimelineComponents/TimelineMessageItem.razor
new file mode 100644
index 0000000..81956b0
--- /dev/null
+++ b/MatrixUtils.Web/Shared/TimelineComponents/TimelineMessageItem.razor
@@ -0,0 +1,34 @@
+@using ArcaneLibs.Extensions
+@using LibMatrix.EventTypes.Spec
+@inherits BaseTimelineItem
+
+<span>
+    @if (!HasPreviousMessage) {
+        <span><InlineUserItem User="@CurrentSenderProfile" Homeserver="@Homeserver" UserId="@Event.StateKey"></InlineUserItem>:</span><br/>
+    }
+    @switch (currentEventContent.MessageType) {
+        case "m.text": {
+            @foreach (var line in currentEventContent.Body.Split('\n')) {
+                <span>@line</span><br/>
+            }
+            break;
+        }
+        case "m.image": {
+            <i>@currentEventContent.Body</i><br/>
+            <img src="@Homeserver.ResolveMediaUri(currentEventContent.Url)">
+            break;
+        }
+        default: {
+            <pre>
+               @Event.RawContent?.ToJson(indent: false)
+            </pre>
+            break;
+        }
+    }
+</span>
+
+@code {
+    private RoomMessageEventContent? previousEventContent => PreviousState?.TypedContent as RoomMessageEventContent;
+
+    private RoomMessageEventContent? currentEventContent => Event.TypedContent as RoomMessageEventContent;
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Shared/TimelineComponents/TimelineRoomCreateItem.razor b/MatrixUtils.Web/Shared/TimelineComponents/TimelineRoomCreateItem.razor
new file mode 100644
index 0000000..f3e6c7e
--- /dev/null
+++ b/MatrixUtils.Web/Shared/TimelineComponents/TimelineRoomCreateItem.razor
@@ -0,0 +1,18 @@
+@using ArcaneLibs.Extensions
+@using LibMatrix.EventTypes.Spec.State
+@inherits BaseTimelineItem
+
+<i>
+    @Event.Sender created the room with room version @CreationEventContent.RoomVersion
+    @(CreationEventContent.Federate ?? true ? "and" : "without") federating with other servers.<br/>
+    This room is of type @(CreationEventContent.Type ?? "Untyped room (usually a chat room)")
+</i>
+<pre>
+    @Event.RawContent?.ToJson(indent: false)
+</pre>
+
+@code {
+
+    private RoomCreateEventContent CreationEventContent => Event.TypedContent as RoomCreateEventContent;
+
+}
diff --git a/MatrixUtils.Web/Shared/TimelineComponents/TimelineRoomNameItem.razor b/MatrixUtils.Web/Shared/TimelineComponents/TimelineRoomNameItem.razor
new file mode 100644
index 0000000..eeec3de
--- /dev/null
+++ b/MatrixUtils.Web/Shared/TimelineComponents/TimelineRoomNameItem.razor
@@ -0,0 +1,27 @@
+@using ArcaneLibs.Extensions
+@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.Responses
+@inherits BaseTimelineItem
+
+@if (currentEventContent is not null) {
+    @if (previousEventContent is null) {
+        <i><InlineUserItem User="@CurrentSenderProfile" Homeserver="@Homeserver" UserId="@Event.StateKey"></InlineUserItem> set the room name to "@currentEventContent.Name"</i>
+    }
+    else {
+        <i><InlineUserItem User="@CurrentSenderProfile" Homeserver="@Homeserver" UserId="@Event.StateKey"></InlineUserItem> changed the room name from "@previousEventContent.Name" to "@currentEventContent.Name"</i>
+    }
+}
+else {
+    <details>
+        <summary>Unknown event @Event.Type (@Event.StateKey)</summary>
+        <pre>
+            @Event.ToJson()
+        </pre>
+    </details>
+}
+
+@code {
+    private RoomNameEventContent? previousEventContent => PreviousState?.TypedContent as RoomNameEventContent;
+
+    private RoomNameEventContent? currentEventContent => Event.TypedContent as RoomNameEventContent;
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Shared/TimelineComponents/TimelineRoomTopicItem.razor b/MatrixUtils.Web/Shared/TimelineComponents/TimelineRoomTopicItem.razor
new file mode 100644
index 0000000..7ef17a8
--- /dev/null
+++ b/MatrixUtils.Web/Shared/TimelineComponents/TimelineRoomTopicItem.razor
@@ -0,0 +1,37 @@
+@using ArcaneLibs.Extensions
+@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.Responses
+@inherits BaseTimelineItem
+
+@if (currentEventContent is not null) {
+    @if (previousEventContent is null) {
+        <i><InlineUserItem User="@CurrentSenderProfile" Homeserver="@Homeserver" UserId="@Event.StateKey"></InlineUserItem> set the room topic to</i><br/>
+        <pre>
+            @currentEventContent.Topic
+        </pre>
+    }
+    else {
+        <i><InlineUserItem User="@CurrentSenderProfile" Homeserver="@Homeserver" UserId="@Event.StateKey"></InlineUserItem> changed the room topic from</i><br/>
+        <pre>
+            @previousEventContent.Topic
+        </pre><br/>
+        <i>to</i><br/>
+        <pre>
+            @currentEventContent.Topic
+        </pre>
+    }
+}
+else {
+    <details>
+        <summary>Unknown event @Event.Type (@Event.StateKey)</summary>
+        <pre>
+            @Event.ToJson()
+        </pre>
+    </details>
+}
+
+@code {
+    private RoomTopicEventContent? previousEventContent => PreviousState?.TypedContent as RoomTopicEventContent;
+
+    private RoomTopicEventContent? currentEventContent => Event.TypedContent as RoomTopicEventContent;
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Shared/TimelineComponents/TimelineUnknownItem.razor b/MatrixUtils.Web/Shared/TimelineComponents/TimelineUnknownItem.razor
new file mode 100644
index 0000000..4f05b30
--- /dev/null
+++ b/MatrixUtils.Web/Shared/TimelineComponents/TimelineUnknownItem.razor
@@ -0,0 +1,16 @@
+@using ArcaneLibs.Extensions
+@inherits BaseTimelineItem
+
+<div>
+    <details style="display: inline;">
+        <summary>
+            <i style="color: red;">Unknown event type: <pre style="display: inline;">@Event.Type</pre></i>
+        </summary>
+        <pre>@Event.ToJson(ignoreNull: true)</pre>
+    </details>
+</div>
+
+@code {
+
+
+}
diff --git a/MatrixUtils.Web/Shared/UserListItem.razor b/MatrixUtils.Web/Shared/UserListItem.razor
new file mode 100644
index 0000000..525296e
--- /dev/null
+++ b/MatrixUtils.Web/Shared/UserListItem.razor
@@ -0,0 +1,44 @@
+@using LibMatrix.Helpers
+@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.Homeservers
+@using LibMatrix.Responses
+<div style="background-color: #ffffff11; border-radius: 25px; margin: 8px; width: fit-Content;">
+    <img style="@(ChildContent is not null ? "vertical-align: baseline;" : "") width: 32px; height:  32px; border-radius: 50%;" src="@(string.IsNullOrWhiteSpace(User?.AvatarUrl) ? "https://api.dicebear.com/6.x/identicon/svg?seed=" + UserId : User.AvatarUrl)"/>
+    <span style="vertical-align: middle; margin-right: 8px; border-radius: 75px;">@User?.DisplayName</span>
+
+    <div style="display: inline-block;">
+        @if (ChildContent is not null) {
+            @ChildContent
+        }
+    </div>
+
+</div>
+
+@code {
+
+    [Parameter]
+    public RenderFragment? ChildContent { get; set; }
+
+    [Parameter]
+    public UserProfileResponse? User { get; set; }
+
+    [Parameter]
+    public string UserId { get; set; }
+
+    private AuthenticatedHomeserverGeneric _homeserver = null!;
+
+    protected override async Task OnInitializedAsync() {
+        _homeserver = await RMUStorage.GetCurrentSessionOrNavigate();
+        if (_homeserver is null) return;
+
+        if (User == null) {
+            if (UserId == null) {
+                throw new ArgumentNullException(nameof(UserId));
+            }
+            User = await _homeserver.GetProfileAsync(UserId);
+        }
+
+        await base.OnInitializedAsync();
+    }
+
+}
\ No newline at end of file