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