diff options
Diffstat (limited to 'MatrixUtils.Web/Pages')
33 files changed, 3929 insertions, 0 deletions
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 |