diff --git a/MatrixUtils.Web/Pages/Rooms/Create.razor b/MatrixUtils.Web/Pages/Rooms/Create.razor
new file mode 100644
index 0000000..35b2ffb
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Rooms/Create.razor
@@ -0,0 +1,338 @@
+@page "/Rooms/Create"
+@using System.Text.Json
+@using System.Reflection
+@using ArcaneLibs.Extensions
+@using LibMatrix
+@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.EventTypes.Spec.State.RoomInfo
+@using LibMatrix.Homeservers
+@using LibMatrix.Responses
+@using MatrixUtils.Web.Classes.RoomCreationTemplates
+@* @* ReSharper disable once RedundantUsingDirective - Must not remove this, Rider marks this as "unused" when it's not */ *@
+
+<h3>Room Manager - Create Room</h3>
+
+@* <pre Contenteditable="true" @onkeypress="@JsonChanged" content="JsonString">@JsonString</pre> *@
+<style>
+ table.table-top-first-tr tr td:first-child {
+ vertical-align: top;
+ }
+ </style>
+<table class="table-top-first-tr">
+ <tr style="padding-bottom: 16px;">
+ <td>Preset:</td>
+ <td>
+ @if (Presets is null) {
+ <p style="color: red;">Presets is null!</p>
+ }
+ else {
+ <InputSelect @bind-Value="@RoomPreset">
+ @foreach (var createRoomRequest in Presets) {
+ <option value="@createRoomRequest.Key">@createRoomRequest.Key</option>
+ }
+ </InputSelect>
+ }
+ </td>
+ </tr>
+ @if (creationEvent is not null) {
+ <tr>
+ <td>Room name:</td>
+ <td>
+ @if (creationEvent.Name is null) {
+ <p style="color: red;">creationEvent.Name is null!</p>
+ }
+ else {
+ <FancyTextBox @bind-Value="@creationEvent.Name"></FancyTextBox>
+ <p>(#<FancyTextBox @bind-Value="@creationEvent.RoomAliasName"></FancyTextBox>:@Homeserver.WhoAmI.UserId.Split(':').Last())</p>
+ }
+ </td>
+ </tr>
+ <tr>
+ <td>Room type:</td>
+ <td>
+ @if (creationEvent.CreationContentBaseType is null) {
+ <p style="color: red;">creationEvent._creationContentBaseType is null!</p>
+ }
+ else {
+ <InputSelect @bind-Value="@creationEvent.CreationContentBaseType.Type">
+ <option value="">Room</option>
+ <option value="m.space">Space</option>
+ </InputSelect>
+ <FancyTextBox @bind-Value="@creationEvent.CreationContentBaseType.Type"></FancyTextBox>
+ }
+ </td>
+ </tr>
+ <tr>
+ <td style="padding-top: 16px;">History visibility:</td>
+ <td style="padding-top: 16px;">
+ <InputSelect @bind-Value="@historyVisibility.HistoryVisibility">
+ <option value="invited">Invited</option>
+ <option value="joined">Joined</option>
+ <option value="shared">Shared</option>
+ <option value="world_readable">World readable</option>
+ </InputSelect>
+ </td>
+ </tr>
+ <tr>
+ <td>Guest access:</td>
+ <td>
+ <ToggleSlider @bind-Value="guestAccessEvent.IsGuestAccessEnabled">
+ @(guestAccessEvent.IsGuestAccessEnabled ? "Guests can join" : "Guests cannot join") (@guestAccessEvent.GuestAccess)
+ </ToggleSlider>
+ <InputSelect @bind-Value="@guestAccessEvent.GuestAccess">
+ <option value="can_join">Can join</option>
+ <option value="forbidden">Forbidden</option>
+ </InputSelect>
+ </td>
+ </tr>
+
+ <tr>
+ <td>Room icon:</td>
+ <td>
+ <img src="@Homeserver.ResolveMediaUri(roomAvatarEvent.Url)" style="width: 128px; height: 128px; border-radius: 50%;"/>
+ <div style="display: inline-block; vertical-align: middle;">
+ <FancyTextBox @bind-Value="@roomAvatarEvent.Url"></FancyTextBox><br/>
+ <InputFile OnChange="RoomIconFilePicked"></InputFile>
+ </div>
+ </td>
+ </tr>
+ <tr>
+ <td>Permissions:</td>
+ <details>
+ <summary>@creationEvent.PowerLevelContentOverride.Users.Count members</summary>
+ @foreach (var user in creationEvent.PowerLevelContentOverride.Events.Keys) {
+ var _event = user;
+ <tr>
+ <td>
+ <FancyTextBox Formatter="@GetPermissionFriendlyName"
+ Value="@_event"
+ ValueChanged="val => { creationEvent.PowerLevelContentOverride.Events.ChangeKey(_event, val); }">
+ </FancyTextBox>:
+ </td>
+ <td>
+ <input type="number" value="@creationEvent.PowerLevelContentOverride.Events[_event]" @oninput="val => { creationEvent.PowerLevelContentOverride.Events[_event] = int.Parse(val.Value.ToString()); }" @onfocusout="() => { creationEvent.PowerLevelContentOverride.Events = creationEvent.PowerLevelContentOverride.Events.OrderByDescending(x => x.Value).ThenBy(x => x.Key).ToDictionary(x => x.Key, x => x.Value); }"/>
+ </td>
+ </tr>
+ }
+ @foreach (var user in creationEvent.PowerLevelContentOverride.Users.Keys) {
+ var _user = user;
+ <tr>
+ <td><FancyTextBox Value="@_user" ValueChanged="val => { creationEvent.PowerLevelContentOverride.Users.ChangeKey(_user, val); creationEvent.PowerLevelContentOverride.Users = creationEvent.PowerLevelContentOverride.Users.OrderByDescending(x => x.Value).ThenBy(x => x.Key).ToDictionary(x => x.Key, x => x.Value); }"></FancyTextBox>:</td>
+ <td>
+ <input type="number" value="@creationEvent.PowerLevelContentOverride.Users[_user]" @oninput="val => { creationEvent.PowerLevelContentOverride.Users[_user] = int.Parse(val.Value.ToString()); }"/>
+ </td>
+ </tr>
+ }
+ </details>
+ </tr>
+ <tr>
+ <td>Server ACLs:</td>
+ <td>
+ @if (serverAcl?.Allow is null) {
+ <p>No allow rules exist!</p>
+ <button @onclick="@(() => { serverAcl.Allow = new List<string> { "*" }; })">Create sane defaults</button>
+ }
+ else {
+ <details>
+ <summary>@((creationEvent["m.room.server_acls"].TypedContent as RoomServerACLEventContent).Allow.Count) allow rules</summary>
+ @* <StringListEditor @bind-Items="@serverAcl.Allow"></StringListEditor> *@
+ </details>
+ }
+ @if (serverAcl?.Deny is null) {
+ <p>No deny rules exist!</p>
+ <button @onclick="@(() => { serverAcl.Allow = new List<string>(); })">Create sane defaults</button>
+ }
+ else {
+ <details>
+ <summary>@((creationEvent["m.room.server_acls"].TypedContent as RoomServerACLEventContent).Deny.Count) deny rules</summary>
+ @* <StringListEditor @bind-Items="@serverAcl.Allow"></StringListEditor> *@
+ </details>
+ }
+ </td>
+ </tr>
+
+ <tr>
+ <td>Invited members:</td>
+ <td>
+ <details>
+ <summary>@creationEvent.InitialState.Count(x => x.Type == "m.room.member") members</summary>
+ @* <button @onclick="() => { RuntimeCache.LoginSessions.Select(x => x.Value.LoginResponse.UserId).ToList().ForEach(InviteMember); }">Invite all logged in accounts</button> *@
+ @foreach (var member in creationEvent.InitialState.Where(x => x.Type == "m.room.member" && x.StateKey != Homeserver.UserId)) {
+ <UserListItem UserId="@member.StateKey"></UserListItem>
+ }
+ </details>
+ </td>
+ </tr>
+ @* Initial states, should remain at bottom *@
+ <tr>
+ <td style="vertical-align: top;">Initial states:</td>
+ <td>
+ <details>
+
+ @code
+ {
+ private static readonly string[] ImplementedStates = { "m.room.avatar", "m.room.history_visibility", "m.room.guest_access", "m.room.server_acl" };
+ }
+
+ <summary> @creationEvent.InitialState.Count(x => !ImplementedStates.Contains(x.Type)) custom states</summary>
+ <table>
+ @foreach (var initialState in creationEvent.InitialState.Where(x => !ImplementedStates.Contains(x.Type))) {
+ <tr>
+ <td style="vertical-align: top;">
+ @(initialState.Type):
+ @if (!string.IsNullOrEmpty(initialState.StateKey)) {
+ <br/>
+ <span>(@initialState.StateKey)</span>
+ }
+
+ </td>
+ <td>
+ <pre>@JsonSerializer.Serialize(initialState.RawContent, new JsonSerializerOptions { WriteIndented = true })</pre>
+ </td>
+ </tr>
+ }
+ </table>
+ </details>
+ <details>
+ <summary> @creationEvent.InitialState.Count initial states</summary>
+ <table>
+ @foreach (var initialState in creationEvent.InitialState) {
+ var _state = initialState;
+ <tr>
+ <td style="vertical-align: top;">
+ <span>@(_state.Type):</span><br/>
+ <button @onclick="() => { creationEvent.InitialState.Remove(_state); StateHasChanged(); }">Remove</button>
+ </td>
+
+ <td>
+ <pre>@JsonSerializer.Serialize(_state.RawContent, new JsonSerializerOptions { WriteIndented = true })</pre>
+ </td>
+ </tr>
+ }
+ </table>
+ </details>
+ </td>
+ </tr>
+ }
+</table>
+<button @onclick="CreateRoom">Create room</button>
+<br/>
+<ModalWindow Title="Creation JSON">
+ <pre>
+ @creationEvent.ToJson(ignoreNull: true)
+ </pre>
+</ModalWindow>
+<ModalWindow Title="Creation JSON (with null values)">
+ <pre>
+ @creationEvent.ToJson()
+ </pre>
+</ModalWindow>
+
+@if (_matrixException is not null) {
+ <ModalWindow Title="@("Matrix exception: " + _matrixException.ErrorCode)">
+ <pre>
+ @_matrixException.Message
+ </pre>
+ </ModalWindow>
+}
+
+@code {
+
+ private string RoomPreset {
+ get => Presets.ContainsValue(creationEvent) ? Presets.First(x => x.Value == creationEvent).Key : "Not a preset";
+ set {
+ creationEvent = Presets[value];
+ JsonChanged();
+ StateHasChanged();
+ }
+ }
+
+ private CreateRoomRequest? creationEvent { get; set; }
+
+ private Dictionary<string, CreateRoomRequest>? Presets { get; set; } = new();
+ private AuthenticatedHomeserverGeneric? Homeserver { get; set; }
+
+ private MatrixException? _matrixException { get; set; }
+
+ private RoomHistoryVisibilityEventContent? historyVisibility => creationEvent?["m.room.history_visibility"].TypedContent as RoomHistoryVisibilityEventContent;
+ private RoomGuestAccessEventContent? guestAccessEvent => creationEvent?["m.room.guest_access"].TypedContent as RoomGuestAccessEventContent;
+ private RoomServerACLEventContent? serverAcl => creationEvent?["m.room.server_acls"].TypedContent as RoomServerACLEventContent;
+ private RoomAvatarEventContent? roomAvatarEvent => creationEvent?["m.room.avatar"].TypedContent as RoomAvatarEventContent;
+
+ protected override async Task OnInitializedAsync() {
+ Homeserver = await RMUStorage.GetCurrentSessionOrNavigate();
+ if (Homeserver is null) return;
+
+ foreach (var x in Assembly.GetExecutingAssembly().GetTypes().Where(x => x.IsClass && !x.IsAbstract && x.GetInterfaces().Contains(typeof(IRoomCreationTemplate))).ToList()) {
+ Console.WriteLine($"Found room creation template in class: {x.FullName}");
+ var instance = (IRoomCreationTemplate)Activator.CreateInstance(x);
+ Presets[instance.Name] = instance.CreateRoomRequest;
+ }
+ Presets = Presets.OrderBy(x => x.Key).ToDictionary(x => x.Key, x => x.Value);
+
+ if (!Presets.ContainsKey("Default")) {
+ Console.WriteLine($"No default room found in {Presets.Count} presets: {string.Join(", ", Presets.Keys)}");
+ }
+ else RoomPreset = "Default";
+
+ await base.OnInitializedAsync();
+ }
+
+ private void JsonChanged() => Console.WriteLine(creationEvent.ToJson());
+
+ private async Task RoomIconFilePicked(InputFileChangeEventArgs obj) {
+ var res = await Homeserver.UploadFile(obj.File.Name, obj.File.OpenReadStream(), obj.File.ContentType);
+ Console.WriteLine(res);
+ (creationEvent["m.room.avatar"].TypedContent as RoomAvatarEventContent).Url = res;
+ StateHasChanged();
+ }
+
+ private async Task CreateRoom() {
+ Console.WriteLine("Create room");
+ Console.WriteLine(creationEvent.ToJson());
+ creationEvent.CreationContent.Add("rory.gay.created_using", "Rory&::MatrixUtils (https://rmu.rory.gay)");
+ try {
+ var id = await Homeserver.CreateRoom(creationEvent);
+ }
+ catch (MatrixException e) {
+ _matrixException = e;
+ }
+ }
+
+ private void InviteMember(string mxid) {
+ if (!creationEvent.InitialState.Any(x => x.Type == "m.room.member" && x.StateKey == mxid) && Homeserver.UserId != mxid)
+ creationEvent.InitialState.Add(new StateEvent {
+ Type = "m.room.member",
+ StateKey = mxid,
+ TypedContent = new RoomMemberEventContent {
+ Membership = "invite",
+ Reason = "Automatically invited at room creation time."
+ }
+ });
+ }
+
+ private string GetStateFriendlyName(string key) => key switch {
+ "m.room.history_visibility" => "History visibility",
+ "m.room.guest_access" => "Guest access",
+ "m.room.join_rules" => "Join rules",
+ "m.room.server_acl" => "Server ACL",
+ "m.room.avatar" => "Avatar",
+ _ => key
+ };
+
+ private string GetPermissionFriendlyName(string key) => key switch {
+ "m.reaction" => "Send reaction",
+ "m.room.avatar" => "Change room icon",
+ "m.room.canonical_alias" => "Change room alias",
+ "m.room.encryption" => "Enable encryption",
+ "m.room.history_visibility" => "Change history visibility",
+ "m.room.name" => "Change room name",
+ "m.room.power_levels" => "Change power levels",
+ "m.room.tombstone" => "Upgrade room",
+ "m.room.topic" => "Change room topic",
+ "m.room.pinned_events" => "Pin events",
+ "m.room.server_acl" => "Change server ACLs",
+ _ => key
+ };
+
+}
diff --git a/MatrixUtils.Web/Pages/Rooms/Index.razor b/MatrixUtils.Web/Pages/Rooms/Index.razor
new file mode 100644
index 0000000..0ec9487
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Rooms/Index.razor
@@ -0,0 +1,250 @@
+@page "/Rooms"
+@using LibMatrix.Filters
+@using LibMatrix.Helpers
+@using LibMatrix.Extensions
+@using LibMatrix.Responses
+@using System.Collections.ObjectModel
+@using System.Diagnostics
+@using ArcaneLibs.Extensions
+@using MatrixUtils.Abstractions
+@inject ILogger<Index> logger
+<h3>Room list</h3>
+
+<p>@Status</p>
+<p>@Status2</p>
+
+<LinkButton href="/Rooms/Create">Create new room</LinkButton>
+
+<RoomList Rooms="Rooms" GlobalProfile="@GlobalProfile" @bind-StillFetching="RenderContents"></RoomList>
+
+@code {
+ private ObservableCollection<RoomInfo> Rooms { get; } = new();
+ private UserProfileResponse GlobalProfile { get; set; }
+
+ private AuthenticatedHomeserverGeneric? Homeserver { get; set; }
+
+ private static SyncFilter filter = new() {
+ AccountData = new SyncFilter.EventFilter {
+ NotTypes = new List<string> { "*" },
+ Limit = 1
+ },
+ Presence = new SyncFilter.EventFilter {
+ NotTypes = new List<string> { "*" },
+ Limit = 1
+ },
+ Room = new SyncFilter.RoomFilter {
+ AccountData = new SyncFilter.RoomFilter.StateFilter {
+ NotTypes = new List<string> { "*" },
+ Limit = 1
+ },
+ Ephemeral = new SyncFilter.RoomFilter.StateFilter {
+ NotTypes = new List<string> { "*" },
+ Limit = 1
+ },
+ State = new SyncFilter.RoomFilter.StateFilter {
+ Types = new List<string> {
+ "m.room.create",
+ "m.room.name",
+ "m.room.avatar",
+ "org.matrix.mjolnir.shortcode",
+ "m.room.power_levels",
+ }
+ },
+ Timeline = new SyncFilter.RoomFilter.StateFilter {
+ NotTypes = new List<string> { "*" },
+ Limit = 1
+ }
+ }
+ };
+
+ // private static SyncFilter profileUpdateFilter = new() {
+ // AccountData = new SyncFilter.EventFilter {
+ // NotTypes = new List<string> { "*" },
+ // Limit = 1
+ // },
+ // Presence = new SyncFilter.EventFilter {
+ // NotTypes = new List<string> { "*" },
+ // Limit = 1
+ // },
+ // Room = new SyncFilter.RoomFilter {
+ // AccountData = new SyncFilter.RoomFilter.StateFilter {
+ // NotTypes = new List<string> { "*" },
+ // Limit = 1
+ // },
+ // Ephemeral = new SyncFilter.RoomFilter.StateFilter {
+ // NotTypes = new List<string> { "*" },
+ // Limit = 1
+ // },
+ // State = new SyncFilter.RoomFilter.StateFilter {
+ // Types = new List<string> {
+ // "m.room.member"
+ // },
+ // Senders = new()
+ // },
+ // Timeline = new SyncFilter.RoomFilter.StateFilter {
+ // NotTypes = new List<string> { "*" },
+ // Limit = 1
+ // }
+ // }
+ // };
+
+ private SyncHelper syncHelper;
+
+ // SyncHelper profileSyncHelper;
+
+ protected override async Task OnInitializedAsync() {
+ Homeserver = await RMUStorage.GetCurrentSessionOrNavigate();
+ if (Homeserver is null) return;
+ var rooms = await Homeserver.GetJoinedRooms();
+ // SemaphoreSlim _semaphore = new(160, 160);
+
+ var roomTasks = rooms.Select(async room => {
+ RoomInfo ri;
+ // await _semaphore.WaitAsync();
+ ri = new() { Room = room };
+ await Task.WhenAll((filter.Room?.State?.Types ?? []).Select(x => ri.GetStateEvent(x)));
+ return ri;
+ }).ToAsyncEnumerable();
+
+ await foreach (var room in roomTasks) {
+ Rooms.Add(room);
+ StateHasChanged();
+ // await Task.Delay(50);
+ // _semaphore.Release();
+ }
+
+ if (rooms.Count >= 150) RenderContents = true;
+
+ GlobalProfile = await Homeserver.GetProfileAsync(Homeserver.WhoAmI.UserId);
+ syncHelper = new SyncHelper(Homeserver, logger) {
+ Timeout = 30000,
+ Filter = filter,
+ MinimumDelay = TimeSpan.FromMilliseconds(5000)
+ };
+ // profileSyncHelper = new SyncHelper(Homeserver, logger) {
+ // Timeout = 10000,
+ // Filter = profileUpdateFilter,
+ // MinimumDelay = TimeSpan.FromMilliseconds(5000)
+ // };
+ // profileUpdateFilter.Room.State.Senders.Add(Homeserver.WhoAmI.UserId);
+
+ RunSyncLoop(syncHelper);
+ // RunSyncLoop(profileSyncHelper);
+ RunQueueProcessor();
+
+ await base.OnInitializedAsync();
+ }
+
+ private async Task RunQueueProcessor() {
+ var renderTimeSw = Stopwatch.StartNew();
+ var isInitialSync = true;
+ while (true) {
+ try {
+ while (queue.Count == 0) {
+ Console.WriteLine("Queue is empty, waiting...");
+ await Task.Delay(isInitialSync ? 100 : 2500);
+ }
+
+ Console.WriteLine($"Queue no longer empty after {renderTimeSw.Elapsed}!");
+
+ int maxUpdates = 10;
+ isInitialSync = false;
+ while (maxUpdates-- > 0 && queue.TryDequeue(out var queueEntry)) {
+ var (roomId, roomData) = queueEntry;
+ Console.WriteLine($"Dequeued room {roomId}");
+ RoomInfo room;
+
+ if (Rooms.Any(x => x.Room.RoomId == roomId)) {
+ room = Rooms.First(x => x.Room.RoomId == roomId);
+ Console.WriteLine($"QueueWorker: {roomId} already known with {room.StateEvents?.Count ?? 0} state events");
+ }
+ else {
+ Console.WriteLine($"QueueWorker: encountered new room {roomId}!");
+ room = new RoomInfo() {
+ Room = Homeserver.GetRoom(roomId)
+ };
+ Rooms.Add(room);
+ }
+
+ if (room.StateEvents is null) {
+ Console.WriteLine($"QueueWorker: {roomId} does not have state events on record?");
+ throw new InvalidDataException("Somehow this is null???");
+ }
+
+ if (roomData.State?.Events is { Count: > 0 })
+ room.StateEvents.MergeStateEventLists(roomData.State.Events);
+ else {
+ Console.WriteLine($"QueueWorker: could not merge state for {room.Room.RoomId} as new data contains no state events!");
+ }
+ }
+ Console.WriteLine($"QueueWorker: {queue.Count} entries left in queue, {maxUpdates} maxUpdates left, RenderContents: {RenderContents}");
+ Status = $"Got {Rooms.Count} rooms so far! {queue.Count} entries in processing queue...";
+
+ RenderContents |= queue.Count == 0;
+ await Task.Delay(Rooms.Count);
+ }
+ catch (Exception e) {
+ Console.WriteLine("QueueWorker exception: " + e);
+ }
+ }
+ }
+
+ private bool RenderContents { get; set; } = false;
+
+ private string _status;
+
+ public string Status {
+ get => _status;
+ set {
+ _status = value;
+ StateHasChanged();
+ }
+ }
+
+ private string _status2;
+
+ public string Status2 {
+ get => _status2;
+ set {
+ _status2 = value;
+ StateHasChanged();
+ }
+ }
+
+ private Queue<KeyValuePair<string, SyncResponse.RoomsDataStructure.JoinedRoomDataStructure>> queue = new();
+
+ private async Task RunSyncLoop(SyncHelper syncHelper) {
+ Status = "Initial syncing...";
+ Console.WriteLine("starting sync");
+
+ var syncs = syncHelper.EnumerateSyncAsync();
+ await foreach (var sync in syncs) {
+ Console.WriteLine("trying sync");
+ if (sync is null) continue;
+
+ Status = $"Got sync with {sync.Rooms?.Join?.Count ?? 0} room updates, next batch: {sync.NextBatch}!";
+ if (sync?.Rooms?.Join != null)
+ foreach (var joinedRoom in sync.Rooms.Join)
+ if ( /*joinedRoom.Value.AccountData?.Events?.Count > 0 ||*/ joinedRoom.Value.State?.Events?.Count > 0) {
+ joinedRoom.Value.State.Events.RemoveAll(x => x.Type == "m.room.member" && x.StateKey != Homeserver.WhoAmI?.UserId);
+ // We can't trust servers to give us what we ask for, and this ruins performance
+ // Thanks, Conduit.
+ joinedRoom.Value.State.Events.RemoveAll(x => filter.Room?.State?.Types?.Contains(x.Type) == false);
+ if (filter.Room?.State?.NotSenders?.Any() ?? false)
+ joinedRoom.Value.State.Events.RemoveAll(x => filter.Room?.State?.NotSenders?.Contains(x.Sender) ?? false);
+
+ queue.Enqueue(joinedRoom);
+ }
+ if (sync.Rooms.Leave is {Count: > 0})
+ foreach (var leftRoom in sync.Rooms.Leave)
+ if (Rooms.Any(x => x.Room.RoomId == leftRoom.Key))
+ Rooms.Remove(Rooms.First(x => x.Room.RoomId == leftRoom.Key));
+
+ Status = $"Got {Rooms.Count} rooms so far! {queue.Count} entries in processing queue... " +
+ $"{sync?.Rooms?.Join?.Count ?? 0} new updates!";
+
+ Status2 = $"Next batch: {sync.NextBatch}";
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Rooms/PolicyList.razor b/MatrixUtils.Web/Pages/Rooms/PolicyList.razor
new file mode 100644
index 0000000..bfc0375
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Rooms/PolicyList.razor
@@ -0,0 +1,267 @@
+@page "/Rooms/{RoomId}/Policies"
+@using LibMatrix
+@using ArcaneLibs.Extensions
+@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.EventTypes.Spec.State.Policy
+@using System.Diagnostics
+@using LibMatrix.RoomTypes
+@using System.Collections.Frozen
+@using System.Reflection
+@using ArcaneLibs.Attributes
+@using LibMatrix.EventTypes
+
+@using MatrixUtils.Web.Shared.PolicyEditorComponents
+
+<h3>Policy list editor - Editing @RoomId</h3>
+<hr/>
+@* <InputCheckbox @bind-Value="EnableAvatars"></InputCheckbox><label>Enable avatars (WILL EXPOSE YOUR IP TO TARGET HOMESERVERS!)</label> *@
+<LinkButton OnClick="@(() => { CurrentlyEditingEvent = new() { Type = "", RawContent = new() }; return Task.CompletedTask; })">Create new policy</LinkButton>
+
+@if (Loading) {
+ <p>Loading...</p>
+}
+else if (PolicyEventsByType is not { Count: > 0 }) {
+ <p>No policies yet</p>
+}
+else {
+ @foreach (var (type, value) in PolicyEventsByType) {
+ <p>
+ @(GetValidPolicyEventsByType(type).Count) active,
+ @(GetInvalidPolicyEventsByType(type).Count) invalid
+ (@value.Count total)
+ @(GetPolicyTypeName(type).ToLower())
+ </p>
+ }
+
+ @foreach (var type in KnownPolicyTypes.OrderByDescending(t => GetPolicyEventsByType(t).Count)) {
+ <details>
+ <summary>
+ <span>
+ @($"{GetPolicyTypeName(type)}: {GetPolicyEventsByType(type).Count} policies")
+ </span>
+ <hr style="margin: revert;"/>
+ </summary>
+ <table class="table table-striped table-hover" style="width: fit-content; border-width: 1px; vertical-align: middle;">
+ @{
+ var policies = GetValidPolicyEventsByType(type);
+ var invalidPolicies = GetInvalidPolicyEventsByType(type);
+ // enumerate all properties with friendly name
+ var props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
+ .Where(x => (x.GetFriendlyNameOrNull() ?? x.GetJsonPropertyNameOrNull()) is not null)
+ .Where(x => x.GetCustomAttribute<TableHideAttribute>() is null)
+ .ToFrozenSet();
+ var propNames = props.Select(x => x.GetFriendlyNameOrNull() ?? x.GetJsonPropertyName()!).ToFrozenSet();
+ }
+ <thead>
+ <tr>
+ @foreach (var name in propNames) {
+ <th style="border-width: 1px">@name</th>
+ }
+ <th style="border-width: 1px">Actions</th>
+ </tr>
+ </thead>
+ <tbody style="border-width: 1px;">
+ @foreach (var policy in policies.OrderBy(x => x.RawContent?["entity"]?.GetValue<string>())) {
+ <tr>
+ @{
+ var typedContent = policy.TypedContent!;
+ var proxySafeProps = typedContent.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance)
+ .Where(x => props.Any(y => y.Name == x.Name))
+ .ToFrozenSet();
+ Console.WriteLine($"{proxySafeProps?.Count} proxy safe props found in {policies.FirstOrDefault()?.TypedContent?.GetType()}");
+ }
+ @foreach (var prop in proxySafeProps ?? Enumerable.Empty<PropertyInfo>()) {
+ <td>@prop.GetGetMethod()?.Invoke(typedContent, null)</td>
+ }
+ <td>
+ <div style="display: ruby;">
+ @if (PowerLevels.UserHasStatePermission(Homeserver.WhoAmI.UserId, policy.Type)) {
+ <LinkButton OnClick="@(() => { CurrentlyEditingEvent = policy; return Task.CompletedTask; })">Edit</LinkButton>
+ <LinkButton OnClick="@(() => RemovePolicyAsync(policy))">Remove</LinkButton>
+ @if (policy.IsLegacyType) {
+ <LinkButton OnClick="@(() => RemovePolicyAsync(policy))">Update policy type</LinkButton>
+ }
+ }
+ </div>
+ </td>
+ </tr>
+ }
+ </tbody>
+ </table>
+ <details>
+ <summary>
+ <u>
+ @("Invalid " + GetPolicyTypeName(type).ToLower())
+ </u>
+ </summary>
+ <table class="table table-striped table-hover" style="width: fit-content; border-width: 1px; vertical-align: middle;">
+ <thead>
+ <tr>
+ <th style="border-width: 1px">State key</th>
+ <th style="border-width: 1px">Json contents</th>
+ </tr>
+ </thead>
+ <tbody>
+ @foreach (var policy in invalidPolicies) {
+ <tr>
+ <td>@policy.StateKey</td>
+ <td>
+ <pre>@policy.RawContent.ToJson(true, false)</pre>
+ </td>
+ </tr>
+ }
+ </tbody>
+ </table>
+ </details>
+ </details>
+ }
+}
+
+@if (CurrentlyEditingEvent is not null) {
+ <PolicyEditorModal PolicyEvent="@CurrentlyEditingEvent" OnClose="@(() => CurrentlyEditingEvent = null)" OnSave="@(e => UpdatePolicyAsync(e))"></PolicyEditorModal>
+}
+
+@code {
+
+#if DEBUG
+ private const bool Debug = true;
+#else
+ private const bool Debug = false;
+#endif
+
+ private bool Loading { get; set; } = true;
+ //get room list
+ // - sync withroom list filter
+ // Type = support.feline.msc3784
+ //support.feline.policy.lists.msc.v1
+
+ [Parameter]
+ public string RoomId { get; set; } = null!;
+
+ private bool _enableAvatars;
+ private StateEventResponse? _currentlyEditingEvent;
+
+ // static readonly Dictionary<string, string?> Avatars = new();
+ // static readonly Dictionary<string, RemoteHomeserver> Servers = new();
+
+ // private static List<StateEventResponse> PolicyEvents { get; set; } = new();
+ private Dictionary<Type, List<StateEventResponse>> PolicyEventsByType { get; set; } = new();
+
+ private StateEventResponse? CurrentlyEditingEvent {
+ get => _currentlyEditingEvent;
+ set {
+ _currentlyEditingEvent = value;
+ StateHasChanged();
+ }
+ }
+
+ // public bool EnableAvatars {
+ // get => _enableAvatars;
+ // set {
+ // _enableAvatars = value;
+ // if (value) GetAllAvatars();
+ // }
+ // }
+
+ private AuthenticatedHomeserverGeneric Homeserver { get; set; }
+ private GenericRoom Room { get; set; }
+ private RoomPowerLevelEventContent PowerLevels { get; set; }
+
+ protected override async Task OnInitializedAsync() {
+ var sw = Stopwatch.StartNew();
+ await base.OnInitializedAsync();
+ Homeserver = (await RMUStorage.GetCurrentSessionOrNavigate())!;
+ if (Homeserver is null) return;
+ Room = Homeserver.GetRoom(RoomId!);
+ PowerLevels = (await Room.GetPowerLevelsAsync())!;
+ await LoadStatesAsync();
+ Console.WriteLine($"Policy list editor initialized in {sw.Elapsed}!");
+ }
+
+ private async Task LoadStatesAsync() {
+ Loading = true;
+ var states = Room.GetFullStateAsync();
+ PolicyEventsByType.Clear();
+ await foreach (var state in states) {
+ if (state is null) continue;
+ if (!state.MappedType.IsAssignableTo(typeof(PolicyRuleEventContent))) continue;
+ if (!PolicyEventsByType.ContainsKey(state.MappedType)) PolicyEventsByType.Add(state.MappedType, new());
+ PolicyEventsByType[state.MappedType].Add(state);
+ }
+
+ Loading = false;
+ StateHasChanged();
+ }
+
+ // private async Task GetAllAvatars() {
+ // // if (!_enableAvatars) return;
+ // Console.WriteLine("Getting avatars...");
+ // var users = GetValidPolicyEventsByType(typeof(UserPolicyRuleEventContent)).Select(x => x.RawContent!["entity"]!.GetValue<string>()).Where(x => x.Contains(':') && !x.Contains("*")).ToList();
+ // Console.WriteLine($"Got {users.Count} users!");
+ // var usersByHomeServer = users.GroupBy(x => x!.Split(':')[1]).ToDictionary(x => x.Key!, x => x.ToList());
+ // Console.WriteLine($"Got {usersByHomeServer.Count} homeservers!");
+ // var homeserverTasks = usersByHomeServer.Keys.Select(x => RemoteHomeserver.TryCreate(x)).ToAsyncEnumerable();
+ // await foreach (var server in homeserverTasks) {
+ // if (server is null) continue;
+ // var profileTasks = usersByHomeServer[server.BaseUrl].Select(x => TryGetProfile(server, x)).ToList();
+ // await Task.WhenAll(profileTasks);
+ // profileTasks.RemoveAll(x => x.Result is not { Value: { AvatarUrl: not null } });
+ // foreach (var profile in profileTasks.Select(x => x.Result!.Value)) {
+ // // if (profile is null) continue;
+ // if (!string.IsNullOrWhiteSpace(profile.Value.AvatarUrl)) {
+ // var url = await hsResolver.ResolveMediaUri(server.BaseUrl, profile.Value.AvatarUrl);
+ // Avatars.TryAdd(profile.Key, url);
+ // }
+ // else Avatars.TryAdd(profile.Key, null);
+ // }
+ //
+ // StateHasChanged();
+ // }
+ // }
+ //
+ // private async Task<KeyValuePair<string, UserProfileResponse>?> TryGetProfile(RemoteHomeserver server, string mxid) {
+ // try {
+ // return new KeyValuePair<string, UserProfileResponse>(mxid, await server.GetProfileAsync(mxid));
+ // }
+ // catch {
+ // return null;
+ // }
+ // }
+
+ private List<StateEventResponse> GetPolicyEventsByType(Type type) => PolicyEventsByType.ContainsKey(type) ? PolicyEventsByType[type] : [];
+
+ private List<StateEventResponse> GetValidPolicyEventsByType(Type type) => GetPolicyEventsByType(type)
+ .Where(x => !string.IsNullOrWhiteSpace(x.RawContent?["entity"]?.GetValue<string>())).ToList();
+
+ private List<StateEventResponse> GetInvalidPolicyEventsByType(Type type) => GetPolicyEventsByType(type)
+ .Where(x => string.IsNullOrWhiteSpace(x.RawContent?["entity"]?.GetValue<string>())).ToList();
+
+ private string? GetPolicyTypeNameOrNull(Type type) => type.GetFriendlyNamePluralOrNull()
+ ?? type.GetCustomAttributes<MatrixEventAttribute>()
+ .FirstOrDefault(x => !string.IsNullOrWhiteSpace(x.EventName))?.EventName;
+
+ private string GetPolicyTypeName(Type type) => GetPolicyTypeNameOrNull(type) ?? type.Name;
+
+ private async Task RemovePolicyAsync(StateEventResponse policyEvent) {
+ await Room.SendStateEventAsync(policyEvent.Type, policyEvent.StateKey, new { });
+ PolicyEventsByType[policyEvent.MappedType].Remove(policyEvent);
+ await LoadStatesAsync();
+ }
+
+ private async Task UpdatePolicyAsync(StateEventResponse policyEvent) {
+ await Room.SendStateEventAsync(policyEvent.Type, policyEvent.StateKey, policyEvent.RawContent);
+ await LoadStatesAsync();
+ }
+
+ private async Task UpgradePolicyAsync(StateEventResponse policyEvent) {
+ policyEvent.RawContent["upgraded_from_type"] = policyEvent.Type;
+ await LoadStatesAsync();
+ }
+
+ private static FrozenSet<Type> KnownPolicyTypes = StateEvent.KnownStateEventTypes.Where(x => x.IsAssignableTo(typeof(PolicyRuleEventContent))).ToFrozenSet();
+
+ // event types, unnamed
+ private static Dictionary<string, Type> PolicyTypes = KnownPolicyTypes
+ .ToDictionary(x => x.GetCustomAttributes<MatrixEventAttribute>().First(y => !string.IsNullOrWhiteSpace(y.EventName)).EventName, x => x);
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Rooms/Space.razor b/MatrixUtils.Web/Pages/Rooms/Space.razor
new file mode 100644
index 0000000..2dd84a1
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Rooms/Space.razor
@@ -0,0 +1,100 @@
+@page "/Rooms/{RoomId}/Space"
+@using LibMatrix.RoomTypes
+@using ArcaneLibs.Extensions
+@using LibMatrix
+<h3>Room manager - Viewing Space</h3>
+
+<button onclick="@JoinAllRooms">Join all rooms</button>
+@foreach (var room in Rooms) {
+ <RoomListItem Room="room" ShowOwnProfile="true"></RoomListItem>
+}
+
+
+<br/>
+<details style="background: #0002;">
+ <summary style="background: #fff1;">State list</summary>
+ @foreach (var stateEvent in States.OrderBy(x => x.StateKey).ThenBy(x => x.Type)) {
+ <p>@stateEvent.StateKey/@stateEvent.Type:</p>
+ <pre>@stateEvent.RawContent.ToJson()</pre>
+ }
+</details>
+
+@code {
+
+ [Parameter]
+ public string RoomId { get; set; } = "invalid!!!!!!";
+
+ private GenericRoom? Room { get; set; }
+
+ private StateEventResponse[] States { get; set; } = Array.Empty<StateEventResponse>();
+ private List<GenericRoom> Rooms { get; } = new();
+ private List<string> ServersInSpace { get; } = new();
+
+ protected override async Task OnInitializedAsync() {
+ var hs = await RMUStorage.GetCurrentSessionOrNavigate();
+ if (hs is null) return;
+
+ Room = hs.GetRoom(RoomId.Replace('~', '.'));
+
+ var state = Room.GetFullStateAsync();
+ await foreach (var stateEvent in state) {
+ switch (stateEvent.Type) {
+ case "m.space.child": {
+ var roomId = stateEvent.StateKey;
+ var room = hs.GetRoom(roomId);
+ if (room is not null) {
+ Rooms.Add(room);
+ }
+ break;
+ }
+ case "m.room.member": {
+ var serverName = stateEvent.StateKey.Split(':').Last();
+ if (!ServersInSpace.Contains(serverName)) {
+ ServersInSpace.Add(serverName);
+ }
+ break;
+ }
+ }
+ }
+ await base.OnInitializedAsync();
+
+ // var state = await Room.GetStateAsync("");
+ // if (state is not null) {
+ // // Console.WriteLine(state.Value.ToJson());
+ // States = state.Value.Deserialize<StateEventResponse[]>()!;
+ //
+ // foreach (var stateEvent in States) {
+ // if (stateEvent.Type == "m.space.child") {
+ // // if (stateEvent.Content.ToJson().Length < 5) return;
+ // var roomId = stateEvent.StateKey;
+ // var room = hs.GetRoom(roomId);
+ // if (room is not null) {
+ // Rooms.Add(room);
+ // }
+ // }
+ // else if (stateEvent.Type == "m.room.member") {
+ // var serverName = stateEvent.StateKey.Split(':').Last();
+ // if (!ServersInSpace.Contains(serverName)) {
+ // ServersInSpace.Add(serverName);
+ // }
+ // }
+ // }
+
+ // if(state.Value.TryGetProperty("Type", out var Type))
+ // {
+ // }
+ // else
+ // {
+ // //this is fine, apprently...
+ // //Console.WriteLine($"Room {room.RoomId} has no Content.Type in m.room.create!");
+ // }
+
+ // await base.OnInitializedAsync();
+ }
+
+ private async Task JoinAllRooms() {
+ List<Task<RoomIdResponse>> tasks = Rooms.Select(room => room.JoinAsync(ServersInSpace.ToArray())).ToList();
+ await Task.WhenAll(tasks);
+ }
+
+}
diff --git a/MatrixUtils.Web/Pages/Rooms/StateEditor.razor b/MatrixUtils.Web/Pages/Rooms/StateEditor.razor
new file mode 100644
index 0000000..fc3a310
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Rooms/StateEditor.razor
@@ -0,0 +1,144 @@
+@page "/Rooms/{RoomId}/State/Edit"
+@using ArcaneLibs.Extensions
+@using LibMatrix
+@inject ILocalStorageService LocalStorage
+@inject NavigationManager NavigationManager
+<h3>Room state editor - Editing @RoomId</h3>
+<hr/>
+
+<p>@status</p>
+
+<input type="checkbox" id="showAll" @bind="ShowMembershipEvents"/> Show member events
+<br/>
+<InputSelect @bind-Value="shownStateKey">
+ <option value="">-- State key --</option>
+ @foreach (var stateEvent in FilteredEvents.Where(x => x.StateKey != "").Select(x => x.StateKey).Distinct().OrderBy(x => x)) {
+ <option value="@stateEvent">@stateEvent</option>
+ Console.WriteLine(stateEvent);
+ }
+</InputSelect>
+<br/>
+<InputSelect @bind-Value="shownType">
+ <option value="">-- Type --</option>
+ @foreach (var stateEvent in FilteredEvents.Where(x => x.StateKey != shownStateKey).Select(x => x.Type).Distinct().OrderBy(x => x)) {
+ <option value="@stateEvent">@stateEvent</option>
+ }
+</InputSelect>
+<br/>
+
+<textarea @bind="shownEventJson" style="width: 100%; height: fit-Content;"></textarea>
+
+<LogView></LogView>
+
+@code {
+ //get room list
+ // - sync withroom list filter
+ // Type = support.feline.msc3784
+ //support.feline.policy.lists.msc.v1
+
+ [Parameter]
+ public string? RoomId { get; set; }
+
+ public List<StateEventResponse> FilteredEvents { get; set; } = new();
+ public List<StateEventResponse> Events { get; set; } = new();
+ public string status = "";
+
+ protected override async Task OnInitializedAsync() {
+ await base.OnInitializedAsync();
+ var hs = await RMUStorage.GetCurrentSessionOrNavigate();
+ if (hs is null) return;
+ RoomId = RoomId.Replace('~', '.');
+ await LoadStatesAsync();
+ Console.WriteLine("Policy list editor initialized!");
+ }
+
+ private DateTime _lastUpdate = DateTime.Now;
+
+ private async Task LoadStatesAsync() {
+ var hs = await RMUStorage.GetCurrentSessionOrNavigate();
+
+ var StateLoaded = 0;
+ var response = (hs.GetRoom(RoomId)).GetFullStateAsync();
+ await foreach (var _ev in response) {
+ // var e = new StateEventResponse {
+ // Type = _ev.Type,
+ // StateKey = _ev.StateKey,
+ // OriginServerTs = _ev.OriginServerTs,
+ // Content = _ev.Content
+ // };
+ Events.Add(_ev);
+ if (string.IsNullOrEmpty(_ev.StateKey)) {
+ FilteredEvents.Add(_ev);
+ }
+ StateLoaded++;
+
+ if (!((DateTime.Now - _lastUpdate).TotalMilliseconds > 100)) continue;
+ _lastUpdate = DateTime.Now;
+ status = $"Loaded {StateLoaded} state events";
+ StateHasChanged();
+ await Task.Delay(0);
+ }
+
+ StateHasChanged();
+ }
+
+ private async Task RebuildFilteredData() {
+ status = "Rebuilding filtered data...";
+ StateHasChanged();
+ await Task.Delay(1);
+ var _FilteredEvents = Events;
+ if (!ShowMembershipEvents)
+ _FilteredEvents = _FilteredEvents.Where(x => x.Type != "m.room.member").ToList();
+
+ status = "Done, rerendering!";
+ StateHasChanged();
+ await Task.Delay(1);
+ FilteredEvents = _FilteredEvents;
+
+ if (_shownType is not null)
+ shownEventJson = _FilteredEvents.First(x => x.Type == _shownType).RawContent.ToJson(indent: true, ignoreNull: true);
+
+ StateHasChanged();
+ }
+
+ public struct PreRenderedStateEvent {
+ public string content { get; set; }
+ public long origin_server_ts { get; set; }
+ public string state_key { get; set; }
+ public string type { get; set; }
+ // public string Sender { get; set; }
+ // public string EventId { get; set; }
+ // public string UserId { get; set; }
+ // public string ReplacesState { get; set; }
+ }
+
+ public bool ShowMembershipEvents {
+ get => _showMembershipEvents;
+ set {
+ _showMembershipEvents = value;
+ RebuildFilteredData();
+ }
+ }
+
+ private bool _showMembershipEvents;
+ private string _shownStateKey;
+ private string _shownType;
+
+ private string shownStateKey {
+ get => _shownStateKey;
+ set {
+ _shownStateKey = value;
+ RebuildFilteredData();
+ }
+ }
+
+ private string shownType {
+ get => _shownType;
+ set {
+ _shownType = value;
+ RebuildFilteredData();
+ }
+ }
+
+ private string shownEventJson { get; set; }
+}
diff --git a/MatrixUtils.Web/Pages/Rooms/StateViewer.razor b/MatrixUtils.Web/Pages/Rooms/StateViewer.razor
new file mode 100644
index 0000000..fabc33c
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Rooms/StateViewer.razor
@@ -0,0 +1,127 @@
+@page "/Rooms/{RoomId}/State/View"
+@using ArcaneLibs.Extensions
+@using LibMatrix
+@inject ILocalStorageService LocalStorage
+@inject NavigationManager NavigationManager
+<h3>Room state viewer - Viewing @RoomId</h3>
+<hr/>
+
+<p>@status</p>
+
+<input type="checkbox" id="showAll" @bind="ShowMembershipEvents"/> Show member events
+
+<table class="table table-striped table-hover" style="width: fit-Content;">
+ <thead>
+ <tr>
+ <th scope="col">Type</th>
+ <th scope="col">Content</th>
+ </tr>
+ </thead>
+ <tbody>
+ @foreach (var stateEvent in FilteredEvents.Where(x => x.StateKey == "").OrderBy(x => x.OriginServerTs)) {
+ <tr>
+ <td>@stateEvent.Type</td>
+ <td style="max-width: fit-Content;">
+ <pre>@stateEvent.RawContent.ToJson()</pre>
+ </td>
+ </tr>
+ }
+ </tbody>
+</table>
+
+@foreach (var group in FilteredEvents.GroupBy(x => x.StateKey).OrderBy(x => x.Key).Where(x => x.Key != "")) {
+ <details>
+ <summary>@group.Key</summary>
+ <table class="table table-striped table-hover" style="width: fit-Content;">
+ <thead>
+ <tr>
+ <th scope="col">Type</th>
+ <th scope="col">Content</th>
+ </tr>
+ </thead>
+ <tbody>
+ @foreach (var stateEvent in group.OrderBy(x => x.OriginServerTs)) {
+ <tr>
+ <td>@stateEvent.Type</td>
+ <td style="max-width: fit-Content;">
+ <pre>@stateEvent.RawContent.ToJson()</pre>
+ </td>
+ </tr>
+ }
+ </tbody>
+ </table>
+ </details>
+}
+
+<LogView></LogView>
+
+@code {
+ //get room list
+ // - sync withroom list filter
+ // Type = support.feline.msc3784
+ //support.feline.policy.lists.msc.v1
+
+ [Parameter]
+ public string? RoomId { get; set; }
+
+ public List<StateEventResponse> FilteredEvents { get; set; } = new();
+ public List<StateEventResponse> Events { get; set; } = new();
+ public string status = "";
+
+ protected override async Task OnInitializedAsync() {
+ await base.OnInitializedAsync();
+ var hs = await RMUStorage.GetCurrentSessionOrNavigate();
+ if (hs is null) return;
+ await LoadStatesAsync();
+ Console.WriteLine("Policy list editor initialized!");
+ }
+
+ private DateTime _lastUpdate = DateTime.Now;
+
+ private async Task LoadStatesAsync() {
+ var StateLoaded = 0;
+ var hs = await RMUStorage.GetCurrentSessionOrNavigate();
+ if (hs is null) return;
+ var response = (hs.GetRoom(RoomId)).GetFullStateAsync();
+ await foreach (var _ev in response) {
+ Events.Add(_ev);
+ if (string.IsNullOrEmpty(_ev.StateKey)) {
+ FilteredEvents.Add(_ev);
+ }
+ StateLoaded++;
+
+ if (!((DateTime.Now - _lastUpdate).TotalMilliseconds > 100)) continue;
+ _lastUpdate = DateTime.Now;
+ status = $"Loaded {StateLoaded} state events";
+ StateHasChanged();
+ await Task.Delay(0);
+ }
+
+ StateHasChanged();
+ }
+
+ private async Task RebuildFilteredData() {
+ status = "Rebuilding filtered data...";
+ StateHasChanged();
+ await Task.Delay(1);
+ var _FilteredEvents = Events;
+ if (!ShowMembershipEvents)
+ _FilteredEvents = _FilteredEvents.Where(x => x.Type != "m.room.member").ToList();
+
+ status = "Done, rerendering!";
+ StateHasChanged();
+ await Task.Delay(1);
+ FilteredEvents = _FilteredEvents;
+ StateHasChanged();
+ }
+
+ public bool ShowMembershipEvents {
+ get => _showMembershipEvents;
+ set {
+ _showMembershipEvents = value;
+ RebuildFilteredData();
+ }
+ }
+
+ private bool _showMembershipEvents;
+}
diff --git a/MatrixUtils.Web/Pages/Rooms/Timeline.razor b/MatrixUtils.Web/Pages/Rooms/Timeline.razor
new file mode 100644
index 0000000..8d0f731
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Rooms/Timeline.razor
@@ -0,0 +1,60 @@
+@page "/Rooms/{RoomId}/Timeline"
+@using MatrixUtils.Web.Shared.TimelineComponents
+@using LibMatrix
+@using LibMatrix.EventTypes.Spec
+@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.Homeservers
+<h3>RoomManagerTimeline</h3>
+<hr/>
+<p>Loaded @Events.Count events...</p>
+
+@foreach (var evt in Events) {
+ <div type="@evt.Type" key="@evt.StateKey" itemid="@evt.EventId">
+ <DynamicComponent Type="@ComponentType(evt)"
+ Parameters="@(new Dictionary<string, object> { { "Event", evt }, { "Events", Events }, { "Homeserver", Homeserver!} })">
+ </DynamicComponent>
+ </div>
+}
+
+@code {
+
+ [Parameter]
+ public string RoomId { get; set; }
+
+ private List<MessagesResponse> Messages { get; } = new();
+ private List<StateEventResponse> Events { get; } = new();
+
+ private AuthenticatedHomeserverGeneric? Homeserver { get; set; }
+
+ protected override async Task OnInitializedAsync() {
+ Console.WriteLine("RoomId: " + RoomId);
+ Homeserver = await RMUStorage.GetCurrentSessionOrNavigate();
+ if (Homeserver is null) return;
+ var room = Homeserver.GetRoom(RoomId);
+ MessagesResponse? msgs = null;
+ do {
+ msgs = await room.GetMessagesAsync(limit: 1000, from: msgs?.End, dir: "b");
+ Messages.Add(msgs);
+ Console.WriteLine($"Got {msgs.Chunk.Count} messages");
+ msgs.Chunk.Reverse();
+ Events.InsertRange(0, msgs.Chunk);
+ } while (msgs.End is not null);
+
+
+ await base.OnInitializedAsync();
+ }
+
+ private StateEventResponse GetProfileEventBefore(StateEventResponse Event) => Events.TakeWhile(x => x != Event).Last(e => e.Type == "m.room.member" && e.StateKey == Event.Sender);
+
+ private Type ComponentType(StateEvent Event) => Event.TypedContent switch {
+ RoomCanonicalAliasEventContent => typeof(TimelineCanonicalAliasItem),
+ RoomHistoryVisibilityEventContent => typeof(TimelineHistoryVisibilityItem),
+ RoomTopicEventContent => typeof(TimelineRoomTopicItem),
+ RoomMemberEventContent => typeof(TimelineMemberItem),
+ RoomMessageEventContent => typeof(TimelineMessageItem),
+ RoomCreateEventContent => typeof(TimelineRoomCreateItem),
+ RoomNameEventContent => typeof(TimelineRoomNameItem),
+ _ => typeof(TimelineUnknownItem)
+ };
+
+}
|