about summary refs log tree commit diff
path: root/MatrixUtils.Web/Pages/User
diff options
context:
space:
mode:
Diffstat (limited to 'MatrixUtils.Web/Pages/User')
-rw-r--r--MatrixUtils.Web/Pages/User/DMManager.razor62
-rw-r--r--MatrixUtils.Web/Pages/User/DMSpace.razor86
-rw-r--r--MatrixUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage0.razor11
-rw-r--r--MatrixUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage1.razor128
-rw-r--r--MatrixUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage2.razor242
-rw-r--r--MatrixUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage3.razor191
-rw-r--r--MatrixUtils.Web/Pages/User/Profile.razor134
7 files changed, 854 insertions, 0 deletions
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