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
|