about summary refs log tree commit diff
diff options
context:
space:
mode:
Diffstat (limited to '')
m---------ArcaneLibs0
m---------LibMatrix0
-rw-r--r--MatrixRoomUtils.LibDMSpace/DMSpaceConfiguration.cs12
-rw-r--r--MatrixRoomUtils.LibDMSpace/DMSpaceRoom.cs83
-rw-r--r--MatrixRoomUtils.LibDMSpace/MatrixRoomUtils.LibDMSpace.csproj16
-rw-r--r--MatrixRoomUtils.LibDMSpace/StateEvents/DMRoomInfo.cs14
-rw-r--r--MatrixRoomUtils.LibDMSpace/StateEvents/DMSpaceInfo.cs14
-rw-r--r--MatrixRoomUtils.Web/Classes/MRUStorageWrapper.cs6
-rw-r--r--MatrixRoomUtils.Web/Classes/RoomInfo.cs12
-rw-r--r--MatrixRoomUtils.Web/MatrixRoomUtils.Web.csproj5
-rw-r--r--MatrixRoomUtils.Web/Pages/Dev/DevOptions.razor55
-rw-r--r--MatrixRoomUtils.Web/Pages/Rooms/Index.razor2
-rw-r--r--MatrixRoomUtils.Web/Pages/Tools/CopyPowerlevel.razor84
-rw-r--r--MatrixRoomUtils.Web/Pages/Tools/MassJoinRoom.razor110
-rw-r--r--MatrixRoomUtils.Web/Pages/User/DMManager.razor56
-rw-r--r--MatrixRoomUtils.Web/Pages/User/DMSpace.razor86
-rw-r--r--MatrixRoomUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage0.razor11
-rw-r--r--MatrixRoomUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage1.razor128
-rw-r--r--MatrixRoomUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage2.razor241
-rw-r--r--MatrixRoomUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage3.razor189
-rw-r--r--MatrixRoomUtils.Web/Pages/User/Manage.razor11
-rw-r--r--MatrixRoomUtils.Web/Shared/InlineUserItem.razor9
-rw-r--r--MatrixRoomUtils.Web/Shared/NavMenu.razor10
-rw-r--r--MatrixRoomUtils.Web/Shared/RoomList.razor3
-rw-r--r--MatrixRoomUtils.Web/Shared/RoomListComponents/RoomListCategory.razor3
-rw-r--r--MatrixRoomUtils.Web/Shared/RoomListItem.razor52
-rw-r--r--MatrixRoomUtils.Web/Shared/TimelineComponents/TimelineMemberItem.razor3
-rw-r--r--MatrixRoomUtils.Web/Shared/UserListItem.razor3
-rwxr-xr-xMatrixRoomUtils.sln6
29 files changed, 1185 insertions, 39 deletions
diff --git a/ArcaneLibs b/ArcaneLibs
-Subproject e9cf8d3061cca5319982c1ef15b78f7c7ca36e8
+Subproject 7c8f845f2376890aeb9d564f078ee93c3157d57
diff --git a/LibMatrix b/LibMatrix
-Subproject 0330ff6706a968400ca8fe2a3e3ccf6237a1556
+Subproject 478fc1b0ef855530e1e93c5212d154280f9d7dd
diff --git a/MatrixRoomUtils.LibDMSpace/DMSpaceConfiguration.cs b/MatrixRoomUtils.LibDMSpace/DMSpaceConfiguration.cs
new file mode 100644
index 0000000..4085367
--- /dev/null
+++ b/MatrixRoomUtils.LibDMSpace/DMSpaceConfiguration.cs
@@ -0,0 +1,12 @@
+using System.Text.Json.Serialization;
+
+namespace MatrixRoomUtils.LibDMSpace;
+
+//gay.rory.dm_space
+public class DMSpaceConfiguration {
+    public const string EventId = "gay.rory.dm_space";
+
+    [JsonPropertyName("dm_space_id")]
+    public string? DMSpaceId { get; set; }
+
+}
\ No newline at end of file
diff --git a/MatrixRoomUtils.LibDMSpace/DMSpaceRoom.cs b/MatrixRoomUtils.LibDMSpace/DMSpaceRoom.cs
new file mode 100644
index 0000000..247ace8
--- /dev/null
+++ b/MatrixRoomUtils.LibDMSpace/DMSpaceRoom.cs
@@ -0,0 +1,83 @@
+using ArcaneLibs.Extensions;
+using LibMatrix;
+using LibMatrix.Homeservers;
+using LibMatrix.RoomTypes;
+using MatrixRoomUtils.LibDMSpace.StateEvents;
+
+namespace MatrixRoomUtils.LibDMSpace;
+
+public class DMSpaceRoom(AuthenticatedHomeserverGeneric homeserver, string roomId) : SpaceRoom(homeserver, roomId) {
+    private readonly GenericRoom _room;
+
+    public async Task<DMSpaceInfo?> GetDmSpaceInfo() {
+        return await GetStateOrNullAsync<DMSpaceInfo>(DMSpaceInfo.EventId);
+    }
+
+    public async IAsyncEnumerable<GenericRoom> GetChildrenAsync(bool includeRemoved = false) {
+        var rooms = new List<GenericRoom>();
+        var state = GetFullStateAsync();
+        await foreach (var stateEvent in state) {
+            if (stateEvent!.Type != "m.space.child") continue;
+            if (stateEvent.RawContent!.ToJson() != "{}" || includeRemoved)
+                yield return homeserver.GetRoom(stateEvent.StateKey);
+        }
+    }
+
+    public async Task<EventIdResponse> AddChildAsync(GenericRoom room) {
+        var members = room.GetMembersAsync(true);
+        Dictionary<string, int> memberCountByHs = new();
+        await foreach (var member in members) {
+            var server = member.StateKey.Split(':')[1];
+            if (memberCountByHs.ContainsKey(server)) memberCountByHs[server]++;
+            else memberCountByHs[server] = 1;
+        }
+
+        var resp = await SendStateEventAsync("m.space.child", room.RoomId, new {
+            via = memberCountByHs
+                .OrderByDescending(x => x.Value)
+                .Select(x => x.Key)
+                .Take(10)
+        });
+        return resp;
+    }
+
+    public async Task ImportNativeDMs() {
+        var dmSpaceInfo = await GetDmSpaceInfo();
+        if (dmSpaceInfo is null) throw new NullReferenceException("DM Space is not configured!");
+        if (dmSpaceInfo.LayerByUser)
+            await ImportNativeDMsIntoLayers();
+        else await ImportNativeDMsWithoutLayers();
+    }
+
+#region Import Native DMs
+
+    private async Task ImportNativeDMsWithoutLayers() {
+        var mdirect = await homeserver.GetAccountDataAsync<Dictionary<string, List<string>>>("m.direct");
+        foreach (var (userId, dmRooms) in mdirect) {
+            foreach (var roomid in dmRooms) {
+                var dri = new DMRoomInfo() {
+                    RemoteUsers = new() {
+                        userId
+                    }
+                };
+                // Add all DM room members
+                var members = homeserver.GetRoom(roomid).GetMembersAsync();
+                await foreach (var member in members)
+                    if (member.StateKey != userId)
+                        dri.RemoteUsers.Add(member.StateKey);
+                // Remove members of DM space
+                members = GetMembersAsync();
+                await foreach (var member in members)
+                    if (dri.RemoteUsers.Contains(member.StateKey))
+                        dri.RemoteUsers.Remove(member.StateKey);
+                await SendStateEventAsync(DMRoomInfo.EventId, roomid, dri);
+            }
+        }
+    }
+
+    private async Task ImportNativeDMsIntoLayers() {
+        var mdirect = await homeserver.GetAccountDataAsync<Dictionary<string, List<string>>>("m.direct");
+    }
+
+#endregion
+}
\ No newline at end of file
diff --git a/MatrixRoomUtils.LibDMSpace/MatrixRoomUtils.LibDMSpace.csproj b/MatrixRoomUtils.LibDMSpace/MatrixRoomUtils.LibDMSpace.csproj
new file mode 100644
index 0000000..70b4ffc
--- /dev/null
+++ b/MatrixRoomUtils.LibDMSpace/MatrixRoomUtils.LibDMSpace.csproj
@@ -0,0 +1,16 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+    <PropertyGroup>
+        <TargetFramework>net7.0</TargetFramework>
+        <ImplicitUsings>enable</ImplicitUsings>
+        <Nullable>enable</Nullable>
+        <LangVersion>preview</LangVersion>
+    </PropertyGroup>
+
+    <ItemGroup>
+        <ProjectReference Condition="Exists('..\ArcaneLibs\ArcaneLibs.Blazor.Components\ArcaneLibs.Blazor.Components.csproj')" Include="..\ArcaneLibs\ArcaneLibs.Blazor.Components\ArcaneLibs.Blazor.Components.csproj"/>
+        <PackageReference Condition="!Exists('..\ArcaneLibs\ArcaneLibs.Blazor.Components\ArcaneLibs.Blazor.Components.csproj')" Include="ArcaneLibs" Version="*-preview*"/>
+        <ProjectReference Include="..\ArcaneLibs\ArcaneLibs\ArcaneLibs.csproj" />
+        <ProjectReference Include="..\LibMatrix\LibMatrix\LibMatrix.csproj"/>
+    </ItemGroup>
+</Project>
diff --git a/MatrixRoomUtils.LibDMSpace/StateEvents/DMRoomInfo.cs b/MatrixRoomUtils.LibDMSpace/StateEvents/DMRoomInfo.cs
new file mode 100644
index 0000000..b88f06a
--- /dev/null
+++ b/MatrixRoomUtils.LibDMSpace/StateEvents/DMRoomInfo.cs
@@ -0,0 +1,14 @@
+using System.Text.Json.Serialization;
+using LibMatrix.EventTypes;
+using LibMatrix.Interfaces;
+
+namespace MatrixRoomUtils.LibDMSpace.StateEvents; 
+
+[MatrixEvent(EventName = EventId)]
+public class DMRoomInfo : EventContent {
+    public const string EventId = "gay.rory.dm_room_info";
+    [JsonPropertyName("remote_users")]
+    public List<string> RemoteUsers { get; set; }
+    
+    
+}
\ No newline at end of file
diff --git a/MatrixRoomUtils.LibDMSpace/StateEvents/DMSpaceInfo.cs b/MatrixRoomUtils.LibDMSpace/StateEvents/DMSpaceInfo.cs
new file mode 100644
index 0000000..7824324
--- /dev/null
+++ b/MatrixRoomUtils.LibDMSpace/StateEvents/DMSpaceInfo.cs
@@ -0,0 +1,14 @@
+using System.Text.Json.Serialization;
+using LibMatrix.EventTypes;
+using LibMatrix.Interfaces;
+
+namespace MatrixRoomUtils.LibDMSpace.StateEvents; 
+
+[MatrixEvent(EventName = EventId)]
+public class DMSpaceInfo : EventContent {
+    public const string EventId = "gay.rory.dm_space_info";
+
+    [JsonPropertyName("is_layered")]
+    public bool LayerByUser { get; set; }
+    
+}
\ No newline at end of file
diff --git a/MatrixRoomUtils.Web/Classes/MRUStorageWrapper.cs b/MatrixRoomUtils.Web/Classes/MRUStorageWrapper.cs
index 3223ec6..b6836c8 100644
--- a/MatrixRoomUtils.Web/Classes/MRUStorageWrapper.cs
+++ b/MatrixRoomUtils.Web/Classes/MRUStorageWrapper.cs
@@ -7,8 +7,6 @@ namespace MatrixRoomUtils.Web.Classes;
 
 public class MRUStorageWrapper(TieredStorageService storageService, HomeserverProviderService homeserverProviderService, NavigationManager navigationManager) {
     public async Task<List<UserAuth>?> GetAllTokens() {
-        if (!await storageService.DataStorageProvider.ObjectExistsAsync("mru.tokens")) { }
-
         return await storageService.DataStorageProvider.LoadObjectAsync<List<UserAuth>>("mru.tokens") ??
                new List<UserAuth>();
     }
@@ -48,6 +46,10 @@ public class MRUStorageWrapper(TieredStorageService storageService, HomeserverPr
         return await homeserverProviderService.GetAuthenticatedWithToken(token.Homeserver, token.AccessToken);
     }
 
+    public async Task<AuthenticatedHomeserverGeneric?> GetSession(UserAuth userAuth) {
+        return await homeserverProviderService.GetAuthenticatedWithToken(userAuth.Homeserver, userAuth.AccessToken);
+    }
+
     public async Task<AuthenticatedHomeserverGeneric?> GetCurrentSessionOrNavigate() {
         AuthenticatedHomeserverGeneric? session = null;
 
diff --git a/MatrixRoomUtils.Web/Classes/RoomInfo.cs b/MatrixRoomUtils.Web/Classes/RoomInfo.cs
index 31943dc..a2fa6f5 100644
--- a/MatrixRoomUtils.Web/Classes/RoomInfo.cs
+++ b/MatrixRoomUtils.Web/Classes/RoomInfo.cs
@@ -1,4 +1,5 @@
 using System.Collections.ObjectModel;
+using System.Text.Json.Nodes;
 using ArcaneLibs;
 using LibMatrix;
 using LibMatrix.EventTypes.Spec.State;
@@ -8,7 +9,7 @@ using LibMatrix.RoomTypes;
 namespace MatrixRoomUtils.Web.Classes;
 
 public class RoomInfo : NotifyPropertyChanged {
-    public GenericRoom Room { get; set; }
+    public GenericRoom? Room { get; set; }
     public ObservableCollection<StateEventResponse?> StateEvents { get; } = new();
 
     public async Task<StateEventResponse?> GetStateEvent(string type, string stateKey = "") {
@@ -19,11 +20,12 @@ public class RoomInfo : NotifyPropertyChanged {
             Type = type,
             StateKey = stateKey
         };
+        if (Room is null) return null;
         try {
-            @event.TypedContent = await Room.GetStateAsync<EventContent>(type, stateKey);
+            @event.RawContent = await Room.GetStateAsync<JsonObject>(type, stateKey);
         }
         catch (MatrixException e) {
-            if (e is { ErrorCode: "M_NOT_FOUND" }) @event.TypedContent = default!;
+            if (e is { ErrorCode: "M_NOT_FOUND" }) @event.RawContent = default!;
             else throw;
         }
 
@@ -37,7 +39,7 @@ public class RoomInfo : NotifyPropertyChanged {
     }
 
     public string? RoomName {
-        get => _roomName ?? Room.RoomId;
+        get => _roomName ?? DefaultRoomName ?? Room.RoomId;
         set => SetField(ref _roomName, value);
     }
 
@@ -58,6 +60,8 @@ public class RoomInfo : NotifyPropertyChanged {
     private string? _roomName;
     private RoomCreateEventContent? _creationEventContent;
     private string? _roomCreator;
+    
+    public string? DefaultRoomName { get; set; }
 
     public RoomInfo() {
         StateEvents.CollectionChanged += (_, args) => {
diff --git a/MatrixRoomUtils.Web/MatrixRoomUtils.Web.csproj b/MatrixRoomUtils.Web/MatrixRoomUtils.Web.csproj
index 625a303..543f8db 100644
--- a/MatrixRoomUtils.Web/MatrixRoomUtils.Web.csproj
+++ b/MatrixRoomUtils.Web/MatrixRoomUtils.Web.csproj
@@ -21,9 +21,14 @@
         <ProjectReference Condition="Exists('..\ArcaneLibs\ArcaneLibs.Blazor.Components\ArcaneLibs.Blazor.Components.csproj')" Include="..\ArcaneLibs\ArcaneLibs.Blazor.Components\ArcaneLibs.Blazor.Components.csproj" />
         <PackageReference Condition="!Exists('..\ArcaneLibs\ArcaneLibs.Blazor.Components\ArcaneLibs.Blazor.Components.csproj')" Include="ArcaneLibs" Version="*-preview*" />
         <ProjectReference Include="..\LibMatrix\LibMatrix\LibMatrix.csproj" />
+        <ProjectReference Include="..\MatrixRoomUtils.LibDMSpace\MatrixRoomUtils.LibDMSpace.csproj" />
 
     </ItemGroup>
 
+    <ItemGroup>
+      <Folder Include="Classes\AccountData\" />
+    </ItemGroup>
+
 
 
 
diff --git a/MatrixRoomUtils.Web/Pages/Dev/DevOptions.razor b/MatrixRoomUtils.Web/Pages/Dev/DevOptions.razor
index 9b0f61c..a1e928f 100644
--- a/MatrixRoomUtils.Web/Pages/Dev/DevOptions.razor
+++ b/MatrixRoomUtils.Web/Pages/Dev/DevOptions.razor
@@ -1,5 +1,8 @@
 @page "/Dev/Options"
 @using ArcaneLibs.Extensions
+@using System.Text.Unicode
+@using System.Text
+@using System.Text.Json
 @inject NavigationManager NavigationManager
 @inject ILocalStorageService LocalStorage
 
@@ -8,23 +11,61 @@
 <h3>Rory&::MatrixUtils - Developer options</h3>
 <hr/>
 
-<InputCheckbox @bind-Value="@settings.DeveloperSettings.EnableLogViewers" @oninput="@LogStuff"></InputCheckbox><label> Enable log views</label><br/>
-<InputCheckbox @bind-Value="@settings.DeveloperSettings.EnableConsoleLogging" @oninput="@LogStuff"></InputCheckbox><label> Enable console logging</label><br/>
-<InputCheckbox @bind-Value="@settings.DeveloperSettings.EnablePortableDevtools" @oninput="@LogStuff"></InputCheckbox><label> Enable portable devtools</label><br/>
+<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 {
 
-    MRUStorageWrapper.Settings settings { get; set; } = new();
+    private MRUStorageWrapper.Settings? userSettings { get; set; }
     protected override async Task OnInitializedAsync() {
-        settings = await TieredStorage.DataStorageProvider.LoadObjectAsync<MRUStorageWrapper.Settings>("mru.settings");
+        // userSettings = await TieredStorage.DataStorageProvider.LoadObjectAsync<MRUStorageWrapper.Settings>("mru.settings");
+        
         await base.OnInitializedAsync();
     }
 
     private async Task LogStuff() {
         await Task.Delay(100);
-        Console.WriteLine($"Settings: {settings.ToJson()}");
-        await TieredStorage.DataStorageProvider.SaveObjectAsync("mru.settings", settings);
+        Console.WriteLine($"Settings: {userSettings.ToJson()}");
+        await TieredStorage.DataStorageProvider.SaveObjectAsync("mru.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/MatrixRoomUtils.Web/Pages/Rooms/Index.razor b/MatrixRoomUtils.Web/Pages/Rooms/Index.razor
index 4d98402..516e9ca 100644
--- a/MatrixRoomUtils.Web/Pages/Rooms/Index.razor
+++ b/MatrixRoomUtils.Web/Pages/Rooms/Index.razor
@@ -22,7 +22,7 @@
 
 @code {
     private ObservableCollection<RoomInfo> Rooms { get; } = new();
-    private ProfileResponseEventContent GlobalProfile { get; set; }
+    private UserProfileResponse GlobalProfile { get; set; }
 
     private AuthenticatedHomeserverGeneric? Homeserver { get; set; }
 
diff --git a/MatrixRoomUtils.Web/Pages/Tools/CopyPowerlevel.razor b/MatrixRoomUtils.Web/Pages/Tools/CopyPowerlevel.razor
new file mode 100644
index 0000000..aaeb5a3
--- /dev/null
+++ b/MatrixRoomUtils.Web/Pages/Tools/CopyPowerlevel.razor
@@ -0,0 +1,84 @@
+@page "/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 MRUStorage.GetCurrentSessionOrNavigate();
+        if (hs is null) return;
+        var sessions = await MRUStorage.GetAllTokens();
+        foreach (var userAuth in sessions) {
+            var session = await MRUStorage.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.UserHasPermission(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/MatrixRoomUtils.Web/Pages/Tools/MassJoinRoom.razor b/MatrixRoomUtils.Web/Pages/Tools/MassJoinRoom.razor
new file mode 100644
index 0000000..bcf8095
--- /dev/null
+++ b/MatrixRoomUtils.Web/Pages/Tools/MassJoinRoom.razor
@@ -0,0 +1,110 @@
+@page "/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 MRUStorage.GetCurrentSessionOrNavigate();
+        if (hs is null) return;
+        var sessions = await MRUStorage.GetAllTokens();
+        foreach (var userAuth in sessions) {
+            var session = await MRUStorage.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 == "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/MatrixRoomUtils.Web/Pages/User/DMManager.razor b/MatrixRoomUtils.Web/Pages/User/DMManager.razor
new file mode 100644
index 0000000..04ff6e5
--- /dev/null
+++ b/MatrixRoomUtils.Web/Pages/User/DMManager.razor
@@ -0,0 +1,56 @@
+@page "/User/DirectMessages"
+@using LibMatrix.Homeservers
+@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.Responses
+<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 MRUStorage.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) {
+                roomList.Add(new RoomInfo() { Room = HomeServer.GetRoom(room) });
+            }
+            StateHasChanged();
+        }
+
+        StateHasChanged();
+        Status = null;
+
+        await base.OnInitializedAsync();
+    }
+
+
+
+}
\ No newline at end of file
diff --git a/MatrixRoomUtils.Web/Pages/User/DMSpace.razor b/MatrixRoomUtils.Web/Pages/User/DMSpace.razor
new file mode 100644
index 0000000..21cc264
--- /dev/null
+++ b/MatrixRoomUtils.Web/Pages/User/DMSpace.razor
@@ -0,0 +1,86 @@
+@page "/User/DMSpace/Setup"
+@using LibMatrix.Homeservers
+@using LibMatrix
+@using MatrixRoomUtils.LibDMSpace
+@using MatrixRoomUtils.LibDMSpace.StateEvents
+@using MatrixRoomUtils.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 MRUStorage.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/MatrixRoomUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage0.razor b/MatrixRoomUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage0.razor
new file mode 100644
index 0000000..49fd5b4
--- /dev/null
+++ b/MatrixRoomUtils.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/MatrixRoomUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage1.razor b/MatrixRoomUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage1.razor
new file mode 100644
index 0000000..3642da5
--- /dev/null
+++ b/MatrixRoomUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage1.razor
@@ -0,0 +1,128 @@
+@using LibMatrix.Homeservers
+@using LibMatrix.RoomTypes
+@using LibMatrix
+@using LibMatrix.Responses
+@using MatrixRoomUtils.LibDMSpace
+@using MatrixRoomUtils.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.UserHasPermission(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/MatrixRoomUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage2.razor b/MatrixRoomUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage2.razor
new file mode 100644
index 0000000..553f46d
--- /dev/null
+++ b/MatrixRoomUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage2.razor
@@ -0,0 +1,241 @@
+@using LibMatrix.Homeservers
+@using LibMatrix.RoomTypes
+@using LibMatrix
+@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.Responses
+@using MatrixRoomUtils.LibDMSpace
+@using MatrixRoomUtils.LibDMSpace.StateEvents
+@using ArcaneLibs.Extensions
+@using System.Text.Json.Serialization
+<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.GetMembersAsync();
+        await foreach (var member in membersEnum)
+            if (member.TypedContent is RoomMemberEventContent memberEvent && !string.IsNullOrWhiteSpace(memberEvent.Membership) && memberEvent.Membership == "join")
+                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/MatrixRoomUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage3.razor b/MatrixRoomUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage3.razor
new file mode 100644
index 0000000..854b09c
--- /dev/null
+++ b/MatrixRoomUtils.Web/Pages/User/DMSpaceStages/DMSpaceStage3.razor
@@ -0,0 +1,189 @@
+@using LibMatrix.Homeservers
+@using LibMatrix.RoomTypes
+@using LibMatrix
+@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.Responses
+@using MatrixRoomUtils.LibDMSpace
+@using MatrixRoomUtils.LibDMSpace.StateEvents
+@using ArcaneLibs.Extensions
+@using System.Text.Json.Serialization
+<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.GetMembersAsync();
+        await foreach (var member in membersEnum)
+            if (member.TypedContent is RoomMemberEventContent memberEvent && !string.IsNullOrWhiteSpace(memberEvent.Membership) && memberEvent.Membership == "join")
+                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/MatrixRoomUtils.Web/Pages/User/Manage.razor b/MatrixRoomUtils.Web/Pages/User/Manage.razor
index 25debf7..eac47e8 100644
--- a/MatrixRoomUtils.Web/Pages/User/Manage.razor
+++ b/MatrixRoomUtils.Web/Pages/User/Manage.razor
@@ -1,8 +1,9 @@
-@page "/User/Manage"
+@page "/User/Profile"
 @using LibMatrix.Homeservers
 @using LibMatrix.EventTypes.Spec.State
 @using ArcaneLibs.Extensions
-<h3>Manage user - @HomeServer?.WhoAmI?.UserId</h3>
+@using LibMatrix.Responses
+<h3>Manage Profile - @HomeServer?.WhoAmI?.UserId</h3>
 <hr/>
 
 @if (Profile is not null) {
@@ -48,8 +49,8 @@
     private string? _status = null;
 
     private AuthenticatedHomeserverGeneric? HomeServer { get; set; }
-    private ProfileResponseEventContent? Profile { get; set; }
-    private ProfileResponseEventContent? OldProfile { get; set; }
+    private UserProfileResponse? Profile { get; set; }
+    private UserProfileResponse? OldProfile { get; set; }
 
     private string? Status {
         get => _status;
@@ -77,7 +78,7 @@
 
         var roomNameTasks = RoomProfiles.Keys.Select(x => HomeServer.GetRoom(x)).Select(async x => {
             var name = await x.GetNameAsync();
-            return new KeyValuePair<string, string>(x.RoomId, name);
+            return new KeyValuePair<string, string?>(x.RoomId, name);
         }).ToAsyncEnumerable();
         await foreach (var (roomId, roomName) in roomNameTasks) {
             // Status = $"Got room name for {roomId}: {roomName}";
diff --git a/MatrixRoomUtils.Web/Shared/InlineUserItem.razor b/MatrixRoomUtils.Web/Shared/InlineUserItem.razor
index 3aea0e0..7bc88e5 100644
--- a/MatrixRoomUtils.Web/Shared/InlineUserItem.razor
+++ b/MatrixRoomUtils.Web/Shared/InlineUserItem.razor
@@ -2,6 +2,7 @@
 @using LibMatrix.EventTypes.Spec.State
 @using LibMatrix.Helpers
 @using LibMatrix.Homeservers
+@using LibMatrix.Responses
 <div style="background-color: #ffffff11; border-radius: 0.5em; height: 1em; display: inline-block; vertical-align: middle;" alt="@UserId">
     <img style="@(ChildContent is not null ? "vertical-align: baseline;" : "vertical-align: top;") width: 1em; height: 1em; border-radius: 50%;" src="@ProfileAvatar"/>
     <span style="position: relative; top: -5px;">@ProfileName</span>
@@ -20,10 +21,10 @@
     public RenderFragment? ChildContent { get; set; }
 
     [Parameter]
-    public ProfileResponseEventContent User { get; set; }
+    public UserProfileResponse? User { get; set; }
 
     [Parameter]
-    public ProfileResponseEventContent MemberEvent { get; set; }
+    public RoomMemberEventContent? MemberEvent { get; set; }
 
     [Parameter]
     public string? UserId { get; set; }
@@ -50,7 +51,7 @@
             throw new ArgumentNullException(nameof(UserId));
 
         if (MemberEvent != null) {
-            User = new ProfileResponseEventContent {
+            User = new UserProfileResponse {
                 AvatarUrl = MemberEvent.AvatarUrl,
                 DisplayName = MemberEvent.DisplayName
             };
@@ -61,7 +62,7 @@
         }
 
 
-        ProfileAvatar ??= await hsResolver.ResolveMediaUri(HomeServer.ServerName, User.AvatarUrl);
+        ProfileAvatar ??= HomeServer.ResolveMediaUri(User.AvatarUrl);
         ProfileName ??= User.DisplayName;
 
         _semaphoreSlim.Release();
diff --git a/MatrixRoomUtils.Web/Shared/NavMenu.razor b/MatrixRoomUtils.Web/Shared/NavMenu.razor
index 68b491d..daa4a52 100644
--- a/MatrixRoomUtils.Web/Shared/NavMenu.razor
+++ b/MatrixRoomUtils.Web/Shared/NavMenu.razor
@@ -35,8 +35,14 @@
         </div>
 
         <div class="nav-item px-3">
-            <NavLink class="nav-link" href="User/Manage">
-                <span class="oi oi-plus" aria-hidden="true"></span> Manage user
+            <NavLink class="nav-link" href="User/Profile">
+                <span class="oi oi-plus" aria-hidden="true"></span> Manage profile
+            </NavLink>
+        </div>
+        
+        <div class="nav-item px-3">
+            <NavLink class="nav-link" href="User/DirectMessages">
+                <span class="oi oi-plus" aria-hidden="true"></span> Manage DMs
             </NavLink>
         </div>
 
diff --git a/MatrixRoomUtils.Web/Shared/RoomList.razor b/MatrixRoomUtils.Web/Shared/RoomList.razor
index 705f68c..f78c7f7 100644
--- a/MatrixRoomUtils.Web/Shared/RoomList.razor
+++ b/MatrixRoomUtils.Web/Shared/RoomList.razor
@@ -4,6 +4,7 @@
 @using ArcaneLibs.Extensions
 @using LibMatrix.EventTypes.Spec.State
 @using System.Collections.ObjectModel
+@using LibMatrix.Responses
 @using _Imports = MatrixRoomUtils.Web._Imports
 @if (!StillFetching) {
     <p>Fetching room details... @RoomsWithTypes.Sum(x => x.Value.Count) out of @Rooms.Count done!</p>
@@ -23,7 +24,7 @@ else {
     public ObservableCollection<RoomInfo> Rooms { get; set; }
 
     [Parameter]
-    public ProfileResponseEventContent? GlobalProfile { get; set; }
+    public UserProfileResponse? GlobalProfile { get; set; }
 
     [Parameter]
     public bool StillFetching { get; set; } = true;
diff --git a/MatrixRoomUtils.Web/Shared/RoomListComponents/RoomListCategory.razor b/MatrixRoomUtils.Web/Shared/RoomListComponents/RoomListCategory.razor
index 27084cc..e2c1285 100644
--- a/MatrixRoomUtils.Web/Shared/RoomListComponents/RoomListCategory.razor
+++ b/MatrixRoomUtils.Web/Shared/RoomListComponents/RoomListCategory.razor
@@ -2,6 +2,7 @@
 @using LibMatrix
 @using LibMatrix.EventTypes.Spec.State
 @using LibMatrix.Homeservers
+@using LibMatrix.Responses
 <details>
     <summary>@roomType (@rooms.Count)</summary>
     @foreach (var room in rooms) {
@@ -29,7 +30,7 @@
     public KeyValuePair<string, List<RoomInfo>> Category { get; set; }
 
     [Parameter]
-    public ProfileResponseEventContent? GlobalProfile { get; set; }
+    public UserProfileResponse? GlobalProfile { get; set; }
 
     [CascadingParameter]
     public AuthenticatedHomeserverGeneric Homeserver { get; set; } = null!;
diff --git a/MatrixRoomUtils.Web/Shared/RoomListItem.razor b/MatrixRoomUtils.Web/Shared/RoomListItem.razor
index 79c7f4e..a24ccad 100644
--- a/MatrixRoomUtils.Web/Shared/RoomListItem.razor
+++ b/MatrixRoomUtils.Web/Shared/RoomListItem.razor
@@ -3,24 +3,25 @@
 @using LibMatrix.EventTypes.Spec.State
 @using LibMatrix.Helpers
 @using LibMatrix.Homeservers
+@using LibMatrix.Responses
 @using LibMatrix.RoomTypes
 @using MatrixRoomUtils.Web.Classes.Constants
 @if (RoomInfo is not null) {
     <div class="roomListItem @(HasDangerousRoomVersion ? "dangerousRoomVersion" : HasOldRoomVersion ? "oldRoomVersion" : "")" id="@RoomInfo.Room.RoomId">
         @if (OwnMemberState != null) {
-            <img class="avatar32 @(OwnMemberState?.AvatarUrl != GlobalProfile?.AvatarUrl ? "highlightChange" : "") " @*@(ChildContent is not null ? "vcenter" : "")*@
+            <img class="avatar32 @(OwnMemberState?.AvatarUrl != GlobalProfile?.AvatarUrl ? "highlightChange" : "") @(ChildContent is not null ? "vcenter" : "")"
                  src="@(hs.ResolveMediaUri(OwnMemberState.AvatarUrl ?? GlobalProfile.AvatarUrl) ?? "/icon-192.png")"/>
             <span class="centerVertical border75 @(OwnMemberState?.AvatarUrl != GlobalProfile?.AvatarUrl ? "highlightChange" : "")">
                 @(OwnMemberState?.DisplayName ?? GlobalProfile?.DisplayName ?? "Loading...")
             </span>
             <span class="centerVertical noLeftPadding">-></span>
         }
-        <img class="avatar32" src="@hs?.ResolveMediaUri(RoomInfo.RoomIcon)"/> @* style="@(ChildContent is not null ? "vertical-align: baseline;" : "")"*@
+        <img class="avatar32" src="@hs?.ResolveMediaUri(RoomInfo.RoomIcon)" style="@(ChildContent is not null ? "vertical-align: middle;" : "")"/>
         <div class="inlineBlock">
             <span class="centerVertical">@RoomInfo.RoomName</span>
-            @* @if (ChildContent is not null) { *@
-            @* @ChildContent *@
-            @* } *@
+            @if (ChildContent is not null) {
+                @ChildContent
+            }
         </div>
 
     </div>
@@ -31,8 +32,8 @@ else {
 
 @code {
 
-    // [Parameter]
-    // public RenderFragment? ChildContent { get; set; }
+    [Parameter]
+    public RenderFragment? ChildContent { get; set; }
 
     [Parameter]
     public RoomInfo? RoomInfo { get; set; }
@@ -44,7 +45,10 @@ else {
     public RoomMemberEventContent? OwnMemberState { get; set; }
 
     [CascadingParameter]
-    public ProfileResponseEventContent? GlobalProfile { get; set; }
+    public UserProfileResponse? GlobalProfile { get; set; }
+
+    [Parameter]
+    public bool LoadData { get; set; } = false;
 
     private bool HasOldRoomVersion { get; set; } = false;
     private bool HasDangerousRoomVersion { get; set; } = false;
@@ -57,6 +61,33 @@ else {
             Console.WriteLine(a.PropertyName);
             StateHasChanged();
         };
+
+        if (LoadData) {
+            try {
+                await RoomInfo.GetStateEvent("m.room.create");
+                if (ShowOwnProfile)
+                    OwnMemberState ??= (await RoomInfo.GetStateEvent("m.room.member", hs.WhoAmI.UserId)).TypedContent as RoomMemberEventContent;
+
+                await RoomInfo.GetStateEvent("m.room.name");
+                await RoomInfo.GetStateEvent("m.room.avatar");
+            }
+            catch (MatrixException e) {
+                if (e.ErrorCode == "M_FORBIDDEN") {
+                    LoadData = false;
+                    RoomInfo.StateEvents.Add(new() {
+                        Type = "m.room.create",
+                        TypedContent = new RoomCreateEventContent() { RoomVersion = "0" }
+                    });
+                    RoomInfo.StateEvents.Add(new() {
+                        Type = "m.room.name",
+                        TypedContent = new RoomNameEventContent() {
+                            Name = "M_FORBIDDEN: Are you a member of this room? " + RoomInfo.Room.RoomId
+                        }
+                    });
+                }
+            }
+        }
+
         await base.OnParametersSetAsync();
     }
 
@@ -69,7 +100,7 @@ else {
         if (hs is null) return;
 
         try {
-    await CheckRoomVersion();
+            await CheckRoomVersion();
     // await GetRoomInfo();
     // await LoadOwnProfile();
         }
@@ -139,4 +170,5 @@ else {
     //     }
     // }
 
-}
\ No newline at end of file
+}
+
diff --git a/MatrixRoomUtils.Web/Shared/TimelineComponents/TimelineMemberItem.razor b/MatrixRoomUtils.Web/Shared/TimelineComponents/TimelineMemberItem.razor
index a454103..075e402 100644
--- a/MatrixRoomUtils.Web/Shared/TimelineComponents/TimelineMemberItem.razor
+++ b/MatrixRoomUtils.Web/Shared/TimelineComponents/TimelineMemberItem.razor
@@ -1,5 +1,6 @@
 @using ArcaneLibs.Extensions
 @using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.Responses
 @inherits BaseTimelineItem
 
 @if (roomMemberData is not null) {
@@ -14,7 +15,7 @@
             <i>@Event.StateKey changed their display name to @(roomMemberData.DisplayName ?? Event.Sender)</i>
             break;
         case "join":
-            <i><InlineUserItem User="@(new ProfileResponseEventContent())" HomeServer="@Homeserver" UserId="@Event.StateKey"></InlineUserItem> joined</i>
+            <i><InlineUserItem User="@(new UserProfileResponse())" HomeServer="@Homeserver" UserId="@Event.StateKey"></InlineUserItem> joined</i>
             break;
         case "leave":
             <i>@Event.StateKey left</i>
diff --git a/MatrixRoomUtils.Web/Shared/UserListItem.razor b/MatrixRoomUtils.Web/Shared/UserListItem.razor
index 96e8e64..167809e 100644
--- a/MatrixRoomUtils.Web/Shared/UserListItem.razor
+++ b/MatrixRoomUtils.Web/Shared/UserListItem.razor
@@ -1,6 +1,7 @@
 @using LibMatrix.Helpers
 @using LibMatrix.EventTypes.Spec.State
 @using LibMatrix.Homeservers
+@using LibMatrix.Responses
 <div style="background-color: #ffffff11; border-radius: 25px; margin: 8px; width: fit-Content;">
     <img style="@(ChildContent is not null ? "vertical-align: baseline;" : "") width: 32px; height:  32px; border-radius: 50%;" src="@(string.IsNullOrWhiteSpace(User?.AvatarUrl) ? "https://api.dicebear.com/6.x/identicon/svg?seed=" + UserId : User.AvatarUrl)"/>
     <span style="vertical-align: middle; margin-right: 8px; border-radius: 75px;">@User?.DisplayName</span>
@@ -19,7 +20,7 @@
     public RenderFragment? ChildContent { get; set; }
 
     [Parameter]
-    public ProfileResponseEventContent? User { get; set; }
+    public UserProfileResponse? User { get; set; }
 
     [Parameter]
     public string UserId { get; set; }
diff --git a/MatrixRoomUtils.sln b/MatrixRoomUtils.sln
index 79650cf..3ea06bf 100755
--- a/MatrixRoomUtils.sln
+++ b/MatrixRoomUtils.sln
@@ -44,6 +44,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibMatrix.Tests", "LibMatri
 EndProject
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestDataGenerator", "LibMatrix\Tests\TestDataGenerator\TestDataGenerator.csproj", "{F3312DE9-4335-4E85-A4CF-2616427A651E}"
 EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MatrixRoomUtils.LibDMSpace", "MatrixRoomUtils.LibDMSpace\MatrixRoomUtils.LibDMSpace.csproj", "{EDD2FBAB-2DEC-4527-AE9C-20E21D0D6B14}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
@@ -114,6 +116,10 @@ Global
 		{F3312DE9-4335-4E85-A4CF-2616427A651E}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{F3312DE9-4335-4E85-A4CF-2616427A651E}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{F3312DE9-4335-4E85-A4CF-2616427A651E}.Release|Any CPU.Build.0 = Release|Any CPU
+		{EDD2FBAB-2DEC-4527-AE9C-20E21D0D6B14}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{EDD2FBAB-2DEC-4527-AE9C-20E21D0D6B14}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{EDD2FBAB-2DEC-4527-AE9C-20E21D0D6B14}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{EDD2FBAB-2DEC-4527-AE9C-20E21D0D6B14}.Release|Any CPU.Build.0 = Release|Any CPU
 	EndGlobalSection
 	GlobalSection(NestedProjects) = preSolution
 		{F4E241C3-0300-4B87-8707-BCBDEF1F0185} = {8F4F6BEC-0C66-486B-A21A-1C35B2EDAD33}