about summary refs log tree commit diff
path: root/MatrixUtils.Web/Pages/Labs
diff options
context:
space:
mode:
authorRory& <root@rory.gay>2024-05-14 17:49:09 +0200
committerRory& <root@rory.gay>2024-05-14 17:49:09 +0200
commit41c5a84dacfd036b8d8f01f72226ac5a519995e3 (patch)
treea4bfc76541692cbbb0fc18f34463cf31a57440f5 /MatrixUtils.Web/Pages/Labs
parentImprove the heatmap layout (diff)
downloadMatrixUtils-41c5a84dacfd036b8d8f01f72226ac5a519995e3.tar.xz
Organise tools somewhat, set proper icons for nav menu
Diffstat (limited to 'MatrixUtils.Web/Pages/Labs')
-rw-r--r--MatrixUtils.Web/Pages/Labs/Client/ClientComponents/ClientRoomList.razor15
-rw-r--r--MatrixUtils.Web/Pages/Labs/Client/ClientComponents/ClientStatusList.razor35
-rw-r--r--MatrixUtils.Web/Pages/Labs/Client/ClientComponents/ClientSyncWrapper.cs41
-rw-r--r--MatrixUtils.Web/Pages/Labs/Client/ClientComponents/MatrixClient.razor31
-rw-r--r--MatrixUtils.Web/Pages/Labs/Client/Index.razor72
-rw-r--r--MatrixUtils.Web/Pages/Labs/DMSpace/DMSpace.razor104
-rw-r--r--MatrixUtils.Web/Pages/Labs/DMSpace/DMSpaceStages/DMSpaceStage0.razor11
-rw-r--r--MatrixUtils.Web/Pages/Labs/DMSpace/DMSpaceStages/DMSpaceStage1.razor151
-rw-r--r--MatrixUtils.Web/Pages/Labs/DMSpace/DMSpaceStages/DMSpaceStage2.razor240
-rw-r--r--MatrixUtils.Web/Pages/Labs/DMSpace/DMSpaceStages/DMSpaceStage3.razor191
-rw-r--r--MatrixUtils.Web/Pages/Labs/Index.razor11
-rw-r--r--MatrixUtils.Web/Pages/Labs/Index.razor.css0
-rw-r--r--MatrixUtils.Web/Pages/Labs/Rooms2/Index2.razor87
-rw-r--r--MatrixUtils.Web/Pages/Labs/Rooms2/Index2Components/MainTabComponents/MainTabSpaceItem.razor56
-rw-r--r--MatrixUtils.Web/Pages/Labs/Rooms2/Index2Components/MainTabComponents/MainTabSpaceItem.razor.css29
-rw-r--r--MatrixUtils.Web/Pages/Labs/Rooms2/Index2Components/RoomsIndex2ByRoomTypeTab.razor53
-rw-r--r--MatrixUtils.Web/Pages/Labs/Rooms2/Index2Components/RoomsIndex2DMsTab.razor53
-rw-r--r--MatrixUtils.Web/Pages/Labs/Rooms2/Index2Components/RoomsIndex2MainTab.razor206
-rw-r--r--MatrixUtils.Web/Pages/Labs/Rooms2/Index2Components/RoomsIndex2MainTab.razor.css0
-rw-r--r--MatrixUtils.Web/Pages/Labs/Rooms2/Index2Components/RoomsIndex2SyncContainer.razor201
20 files changed, 1587 insertions, 0 deletions
diff --git a/MatrixUtils.Web/Pages/Labs/Client/ClientComponents/ClientRoomList.razor b/MatrixUtils.Web/Pages/Labs/Client/ClientComponents/ClientRoomList.razor
new file mode 100644
index 0000000..b370080
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Labs/Client/ClientComponents/ClientRoomList.razor
@@ -0,0 +1,15 @@
+@using ClientContext = MatrixUtils.Web.Pages.Labs.Client.Index.ClientContext
+@* user header and room list *@
+@foreach (var room in Data.SyncWrapper.Rooms) {
+    <LinkButton OnClick="@(async () => Data.SelectedRoom = room)" Color="@(Data.SelectedRoom == room ? "#FF00FF" : "")">
+        @room.RoomName
+    </LinkButton>
+    <br/>
+}
+
+@code {
+
+    [Parameter]
+    public ClientContext Data { get; set; } = null!;
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Labs/Client/ClientComponents/ClientStatusList.razor b/MatrixUtils.Web/Pages/Labs/Client/ClientComponents/ClientStatusList.razor
new file mode 100644
index 0000000..c680c13
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Labs/Client/ClientComponents/ClientStatusList.razor
@@ -0,0 +1,35 @@
+@using ClientContext = MatrixUtils.Web.Pages.Labs.Client.Index.ClientContext;
+@using System.Collections.ObjectModel
+
+@foreach (var ctx in Data) {
+    <pre>
+        @ctx.Homeserver.UserId - @ctx.SyncWrapper.Status
+    </pre>
+}
+
+@code {
+
+    [Parameter]
+    public ObservableCollection<ClientContext> Data { get; set; } = null!;
+
+    protected override void OnInitialized() {
+        Data.CollectionChanged += (_, e) => {
+            foreach (var item in e.NewItems?.Cast<ClientContext>() ?? []) {
+                item.SyncWrapper.PropertyChanged += (_, pe) => {
+                    if (pe.PropertyName == nameof(item.SyncWrapper.Status))
+                        StateHasChanged();
+                };
+            }
+
+            StateHasChanged();
+        };
+
+        Data.ToList().ForEach(ctx => {
+            ctx.SyncWrapper.PropertyChanged += (_, pe) => {
+                if (pe.PropertyName == nameof(ctx.SyncWrapper.Status))
+                    StateHasChanged();
+            };
+        });
+    }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Labs/Client/ClientComponents/ClientSyncWrapper.cs b/MatrixUtils.Web/Pages/Labs/Client/ClientComponents/ClientSyncWrapper.cs
new file mode 100644
index 0000000..16051b8
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Labs/Client/ClientComponents/ClientSyncWrapper.cs
@@ -0,0 +1,41 @@
+using System.Collections.ObjectModel;
+using ArcaneLibs;
+using LibMatrix;
+using LibMatrix.Helpers;
+using LibMatrix.Homeservers;
+using LibMatrix.Responses;
+using MatrixUtils.Abstractions;
+
+namespace MatrixUtils.Web.Pages.Client.ClientComponents;
+
+public class ClientSyncWrapper(AuthenticatedHomeserverGeneric homeserver) : NotifyPropertyChanged {
+    private SyncHelper _syncHelper = new SyncHelper(homeserver) {
+        MinimumDelay = TimeSpan.FromMilliseconds(2000),
+        IsInitialSync = false
+    };
+    private string _status = "Loading...";
+
+    public ObservableCollection<StateEvent> AccountData { get; set; } = new();
+    public ObservableCollection<RoomInfo> Rooms { get; set; } = new();
+
+    public string Status {
+        get => _status;
+        set => SetField(ref _status, value);
+    }
+
+    public async Task Start() {
+        Task.Yield();
+        var resp = _syncHelper.EnumerateSyncAsync();
+        Status = $"[{DateTime.Now:s}] Syncing...";
+        await foreach (var response in resp) {
+            Task.Yield();
+            Status = $"[{DateTime.Now:s}] {response.Rooms?.Join?.Count ?? 0 + response.Rooms?.Invite?.Count ?? 0 + response.Rooms?.Leave?.Count ?? 0} rooms, {response.AccountData?.Events?.Count ?? 0} account data, {response.ToDevice?.Events?.Count ?? 0} to-device, {response.DeviceLists?.Changed?.Count ?? 0} device lists, {response.Presence?.Events?.Count ?? 0} presence updates";
+            await HandleSyncResponse(response);
+            await Task.Yield();
+        }
+    }
+
+    private async Task HandleSyncResponse(SyncResponse resp) {
+        
+    }
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Labs/Client/ClientComponents/MatrixClient.razor b/MatrixUtils.Web/Pages/Labs/Client/ClientComponents/MatrixClient.razor
new file mode 100644
index 0000000..7d3e52a
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Labs/Client/ClientComponents/MatrixClient.razor
@@ -0,0 +1,31 @@
+@using Index = MatrixUtils.Web.Pages.Labs.Client.Index
+@using MatrixUtils.Web.Pages.Client.ClientComponents
+
+<div class="container-fluid">
+    <div class="row">
+        <div class="col-3">
+            <ClientRoomList Data="@Data"/>
+        </div>
+        <div class="col-6">
+            @if (Data.SelectedRoom != null) {
+                <Index.RoomHeader Data="@Data"/>
+                <Index.RoomTimeline Data="@Data"/>
+            }
+            else {
+                <p>No room selected</p>
+            }
+        </div>
+        @if (Data.SelectedRoom != null) {
+            <div class="col-3">
+                <Index.UserList Data="@Data"/>
+            </div>
+        }
+    </div>
+</div>
+
+@code {
+
+    [Parameter]
+    public Index.ClientContext Data { get; set; } = null!;
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Labs/Client/Index.razor b/MatrixUtils.Web/Pages/Labs/Client/Index.razor
new file mode 100644
index 0000000..5b489b0
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Labs/Client/Index.razor
@@ -0,0 +1,72 @@
+@page "/Labs/Client"
+@using LibMatrix
+@using MatrixUtils.Abstractions
+@using MatrixUtils.Web.Pages.Client.ClientComponents
+@using System.Collections.ObjectModel
+
+<h3>Client</h3>
+
+
+@foreach (var client in Clients) {
+    <LinkButton Color="@(SelectedClient == client ? "#ff00ff" : "")" OnClick="@(async () => SelectedClient = client)">
+        @client.Homeserver.WhoAmI.UserId
+    </LinkButton>
+}
+<ClientStatusList Data="@Clients"></ClientStatusList>
+
+
+@* @foreach (var client in Clients) { *@
+@*     <div class="card"> *@
+@*         <span>@client.Homeserver.UserId - @client.SyncWrapper.Status</span> *@
+@*     </div> *@
+@* } *@
+
+@if (SelectedClient != null) {
+    <div class="card">
+        <MatrixClient Data="@SelectedClient"/>
+    </div>
+}
+
+@code {
+
+    private static readonly ObservableCollection<ClientContext> Clients = [];
+    private static ClientContext _selectedClient;
+
+    private ClientContext SelectedClient {
+        get => _selectedClient;
+        set {
+            _selectedClient = value;
+            StateHasChanged();
+        }
+    }
+
+    protected override async Task OnInitializedAsync() {
+        var tokens = await RMUStorage.GetAllTokens();
+        var tasks = tokens.Select(async token => {
+            try {
+                var cc = new ClientContext() {
+                    Homeserver = await RMUStorage.GetSession(token)
+                };
+                cc.SyncWrapper = new ClientSyncWrapper(cc.Homeserver);
+
+#pragma warning disable CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed
+                cc.SyncWrapper.Start();
+#pragma warning restore CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed
+
+                Clients.Add(cc);
+                StateHasChanged();
+            }
+            catch { }
+        }).ToList();
+        await Task.WhenAll(tasks);
+    }
+
+    public class ClientContext {
+        public AuthenticatedHomeserverGeneric Homeserver { get; set; }
+        public ClientSyncWrapper SyncWrapper { get; set; }
+
+        public RoomInfo? SelectedRoom { get; set; }
+    }
+
+}
+
diff --git a/MatrixUtils.Web/Pages/Labs/DMSpace/DMSpace.razor b/MatrixUtils.Web/Pages/Labs/DMSpace/DMSpace.razor
new file mode 100644
index 0000000..c0dc8a6
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Labs/DMSpace/DMSpace.razor
@@ -0,0 +1,104 @@
+@page "/Labs/DMSpace/Setup"
+@using LibMatrix
+@using LibMatrix.Responses
+@using MatrixUtils.Abstractions
+@using MatrixUtils.LibDMSpace
+@using MatrixUtils.LibDMSpace.StateEvents
+@using MatrixUtils.Web.Pages.Labs.DMSpace.DMSpaceStages
+@using System.Text.Json.Serialization
+<h3>DM Space Management</h3>
+<hr/>
+<CascadingValue Value="@SetupData">
+    @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 DMSpace? DMSpaceRootPage { get; set; }
+
+    protected override async Task OnInitializedAsync() {
+        if (NavigationManager.Uri.Contains("?stage=")) {
+            NavigationManager.NavigateTo(NavigationManager.Uri.Replace("stage=", ""), true); //"/User/DMSpace/Setup"
+        }
+        DMSpaceRootPage = this;
+        SetupData.Homeserver ??= await RMUStorage.GetCurrentSessionOrNavigate();
+        if (SetupData.Homeserver is null) return;
+        try {
+            SetupData.DmSpaceConfiguration = await SetupData.Homeserver.GetAccountDataAsync<DMSpaceConfiguration>("gay.rory.dm_space");
+            var room = SetupData.Homeserver.GetRoom(SetupData.DmSpaceConfiguration.DMSpaceId);
+            await room.GetStateAsync<DMSpaceInfo>(DMSpaceInfo.EventId);
+            Stage = 1;
+        }
+        catch (MatrixException e) {
+            if (e.ErrorCode is "M_NOT_FOUND" or "M_FORBIDDEN") {
+                Stage = 0;
+                SetupData.DmSpaceConfiguration = new();
+            }
+            else throw;
+        }
+        finally {
+            StateHasChanged();
+        }
+        await base.OnInitializedAsync();
+    }
+
+    protected override async Task OnParametersSetAsync() {
+        StateHasChanged();
+        await base.OnParametersSetAsync();
+    }
+
+    public DMSpaceSetupData SetupData { get; set; } = new();
+
+    public class DMSpaceSetupData {
+        
+        public AuthenticatedHomeserverGeneric? Homeserver { get; set; }
+
+        public DMSpaceConfiguration? DmSpaceConfiguration { get; set; }
+        
+        public DMSpaceInfo? DmSpaceInfo { get; set; } = new();
+        
+        public Dictionary<string, RoomInfo>? Spaces;
+        
+        public Dictionary<UserProfileWithId, List<RoomInfo>>? DMRooms;
+        
+        public RoomInfo? DMSpaceRoomInfo { get; set; }
+
+        
+        public class UserProfileWithId : UserProfileResponse {
+            [JsonIgnore]
+            public string Id { get; set; }
+        }
+    }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Labs/DMSpace/DMSpaceStages/DMSpaceStage0.razor b/MatrixUtils.Web/Pages/Labs/DMSpace/DMSpaceStages/DMSpaceStage0.razor
new file mode 100644
index 0000000..5f6508c
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Labs/DMSpace/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/Setup?stage=1">Get started</LinkButton>
+
+@code {
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Labs/DMSpace/DMSpaceStages/DMSpaceStage1.razor b/MatrixUtils.Web/Pages/Labs/DMSpace/DMSpaceStages/DMSpaceStage1.razor
new file mode 100644
index 0000000..2176467
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Labs/DMSpace/DMSpaceStages/DMSpaceStage1.razor
@@ -0,0 +1,151 @@
+@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
+@using LibMatrix.EventTypes.Spec.State
+@using MatrixUtils.Abstractions
+<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 (SetupData is not null) {
+    if (SetupData.Spaces is not null) {
+        <p>
+            Selected space:
+            <InputSelect @bind-Value="SetupData.DmSpaceConfiguration.DMSpaceId">
+                <option value="">New space</option>
+                @foreach (var (id, roomInfo) in SetupData.Spaces) {
+                    <option value="@id">@roomInfo.RoomName</option>
+                }
+            </InputSelect>
+        </p>
+        <p>
+            <InputCheckbox @bind-Value="SetupData.DmSpaceInfo.LayerByUser"></InputCheckbox>
+            Create sub-spaces per user
+        </p>
+        
+        <br/>
+        <LinkButton OnClick="@Disband" Color="#FF0000">Disband</LinkButton>
+        <LinkButton OnClick="@Execute">Next</LinkButton>
+    }
+    else {
+        <p>Discovering spaces, please wait...</p>
+    }
+}
+else {
+    <b>Error: Setup data is null!</b>
+}
+
+
+@if (!string.IsNullOrWhiteSpace(Status)) {
+    <p>@Status</p>
+}
+
+@code {
+
+    private string? Status {
+        get => _status;
+        set {
+            _status = value;
+            StateHasChanged();
+        }
+    }
+
+    private string? _status;
+
+    [CascadingParameter]
+    public DMSpace.DMSpaceSetupData SetupData { get; set; }
+
+    SemaphoreSlim _semaphoreSlim = new(1, 1);
+
+    protected override async Task OnInitializedAsync() {
+        if (SetupData is null)
+            return;
+
+        await _semaphoreSlim.WaitAsync();
+
+        Dictionary<string, RoomInfo> spaces = [];
+        SetupData.DmSpaceConfiguration ??= new();
+
+        Status = "Looking for spaces...";
+        var userRoomsEnum = SetupData.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.roomInfo);
+
+        SetupData.Spaces = spaces;
+
+        Status = "Done!";
+        _semaphoreSlim.Release();
+        await base.OnParametersSetAsync();
+    }
+
+    private async Task Execute() {
+        if (string.IsNullOrWhiteSpace(SetupData!.DmSpaceConfiguration!.DMSpaceId)) {
+            var createRoomRequest = CreateRoomRequest.CreatePrivate(SetupData.Homeserver!, "Direct Messages");
+            createRoomRequest.CreationContentBaseType.Type = "m.space";
+            SetupData.DmSpaceConfiguration.DMSpaceId = (await SetupData.Homeserver!.CreateRoom(createRoomRequest)).RoomId;
+        }
+
+        await SetupData.Homeserver!.SetAccountDataAsync(DMSpaceConfiguration.EventId, SetupData.DmSpaceConfiguration);
+        var space = SetupData.Homeserver.GetRoom(SetupData.DmSpaceConfiguration.DMSpaceId);
+        await space.SendStateEventAsync(DMSpaceInfo.EventId, SetupData.DmSpaceInfo);
+        SetupData.DMSpaceRoomInfo = new RoomInfo(space);
+        await SetupData.DMSpaceRoomInfo.FetchAllStateAsync();
+
+        NavigationManager.NavigateTo("/User/DMSpace/Setup?stage=2");
+    }
+
+    public async Task<(string id, RoomInfo roomInfo)?> GetFeasibleSpaces(GenericRoom room) {
+        try {
+            var ri = new RoomInfo(room);
+            
+            await foreach(var evt in room.GetFullStateAsync())
+                ri.StateEvents.Add(evt);
+
+            var powerLevels = (await ri.GetStateEvent(RoomPowerLevelEventContent.EventId)).TypedContent as RoomPowerLevelEventContent;
+            if (!powerLevels.UserHasStatePermission(SetupData.Homeserver.WhoAmI.UserId, SpaceChildEventContent.EventId)) {
+                Console.WriteLine($"No permission to send m.space.child in {room.RoomId}...");
+                return null;
+            }
+            
+            Status = $"Found viable space: {ri.RoomName}";
+            if (!string.IsNullOrWhiteSpace(SetupData.DmSpaceConfiguration!.DMSpaceId)) {
+                if (await room.GetStateOrNullAsync<DMSpaceInfo>(DMSpaceInfo.EventId) is { } dsi) {
+                    SetupData.DmSpaceConfiguration.DMSpaceId = room.RoomId;
+                    SetupData.DmSpaceInfo = dsi;
+                    Console.WriteLine(dsi.ToJson(ignoreNull: true));
+                }
+            }
+
+            if (ri.RoomName == room.RoomId)
+                ri.RoomName = await room.GetNameOrFallbackAsync();
+
+            return (room.RoomId, ri);
+        }
+        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;
+    }
+
+    private async Task Disband() {
+        var space = new DMSpaceRoom(SetupData.Homeserver, SetupData.DmSpaceConfiguration.DMSpaceId);
+        await space.DisbandDMSpace();
+        NavigationManager.NavigateTo(NavigationManager.Uri, forceLoad: true);
+    }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Labs/DMSpace/DMSpaceStages/DMSpaceStage2.razor b/MatrixUtils.Web/Pages/Labs/DMSpace/DMSpaceStages/DMSpaceStage2.razor
new file mode 100644
index 0000000..a70e9c5
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Labs/DMSpace/DMSpaceStages/DMSpaceStage2.razor
@@ -0,0 +1,240 @@
+@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 (SetupData is not null) {
+    if (SetupData.DMRooms is { Count: > 0 }) {
+        @foreach (var (userId, room) in SetupData.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 {
+        <p>DM room list is loading, please wait...</p>
+    }
+}
+else {
+    <b>Error: DMSpaceRootPage 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.DMSpaceSetupData SetupData { get; set; }
+
+    private Dictionary<RoomInfo, List<DMSpace.DMSpaceSetupData.UserProfileWithId>> duplicateDmRooms { get; set; } = new();
+    private Dictionary<RoomInfo, List<DMSpace.DMSpaceSetupData.UserProfileWithId>> roomMembers { get; set; } = new();
+
+    SemaphoreSlim _semaphore = new(1, 1);
+
+    protected override async Task OnInitializedAsync() {
+        if (SetupData is null)
+            return;
+        await _semaphore.WaitAsync();
+        DmToReassign = null;
+        var hs = SetupData.Homeserver;
+        Status = "Loading DM list from account data...";
+        var dms = await SetupData.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 SetupData.Homeserver.SetAccountDataAsync("m.direct", dms);
+
+        Status = "DM list optimised, fetching info...";
+
+        SetupData.DMRooms = new Dictionary<DMSpace.DMSpaceSetupData.UserProfileWithId, List<RoomInfo>>();
+
+        var results = dms.Select(async x => {
+            var (userId, rooms) = x;
+            DMSpace.DMSpaceSetupData.UserProfileWithId userProfile;
+            try {
+                var profile = await SetupData.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) {
+            SetupData.DMRooms.Add(res.userProfile, res.roomList);
+            // Status = $"Listed {dmRooms.Count} users";
+        }
+
+        _semaphore.Release();
+        var duplicateDmRoomIds = new Dictionary<string, List<DMSpace.DMSpaceSetupData.UserProfileWithId>>();
+        foreach (var (user, rooms) in SetupData.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(SetupData.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);
+        await roomInfo.FetchAllStateAsync();
+        roomMembers[roomInfo] = new();
+        // roomInfo.CreationEventContent = await room.GetCreateEventAsync();
+        
+        if(roomInfo.RoomName == room.RoomId)
+            try {
+                roomInfo.RoomName = await room.GetNameOrFallbackAsync();
+            }
+            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 });
+        
+        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 = SetupData.Homeserver;
+        Status = "Loading DM list from account data...";
+        var dms = await SetupData.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 SetupData.Homeserver.SetAccountDataAsync("m.direct", dms);
+
+        duplicateDmRooms.RemoveAll((x, y) => x.Room.RoomId == roomId);
+        StateHasChanged();
+        if (duplicateDmRooms.Count == 0) await OnParametersSetAsync();
+    }
+
+}
diff --git a/MatrixUtils.Web/Pages/Labs/DMSpace/DMSpaceStages/DMSpaceStage3.razor b/MatrixUtils.Web/Pages/Labs/DMSpace/DMSpaceStages/DMSpaceStage3.razor
new file mode 100644
index 0000000..865e956
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Labs/DMSpace/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 (SetupData is not null) {
+    @if (SetupData.DMSpaceRoomInfo is not null) {
+        <p>
+            <InputCheckbox @bind-Value="SetupData.DmSpaceInfo.LayerByUser"></InputCheckbox>
+            Create sub-spaces per user
+        </p>
+        @if (!SetupData.DmSpaceInfo.LayerByUser) {
+            <RoomListItem RoomInfo="@SetupData.DMSpaceRoomInfo"></RoomListItem>
+            @foreach (var (userId, room) in SetupData.DMRooms.OrderBy(x => x.Key.DisplayName)) {
+                @foreach (var roomInfo in room) {
+                    <div style="margin-left: 32px;">
+                        <RoomListItem RoomInfo="@roomInfo"></RoomListItem>
+                    </div>
+                }
+            }
+        }
+        else {
+            <RoomListItem RoomInfo="@SetupData.DMSpaceRoomInfo"></RoomListItem>
+            @foreach (var (user, room) in SetupData.DMRooms.OrderBy(x => x.Key.DisplayName)) {
+                <div style="margin-left: 32px;">
+                    @{
+                        RoomInfo fakeRoom = new(SetupData.DMSpaceRoomInfo.Room) {
+                            RoomName = user.DisplayName ?? user.Id,
+                            RoomIcon = user.AvatarUrl
+                        };
+                    }
+                    <RoomListItem RoomInfo="@fakeRoom"></RoomListItem>
+                </div>
+                @foreach (var roomInfo in room) {
+                    <div style="margin-left: 64px;">
+                        <RoomListItem RoomInfo="@roomInfo"></RoomListItem>
+                    </div>
+                }
+            }
+        }
+    }
+    else {
+        <b>Error: SetupData.DMSpaceRoomInfo is null!</b>
+    }
+}
+else {
+    <b>Error: DMSpaceRootPageConfiguration 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.DMSpaceSetupData SetupData { get; set; }
+
+    SemaphoreSlim _semaphore = new(1, 1);
+
+    protected override async Task OnInitializedAsync() {
+        if (SetupData is null)
+            return;
+        await _semaphore.WaitAsync();
+        var hs = SetupData.Homeserver;
+        // var dmSpaceRoom = new DMSpaceRoom(hs, SetupData.DmSpaceConfiguration.DMSpaceId);
+        // SetupData.
+        // 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 SetupData.Homeserver.GetAccountDataAsync<Dictionary<string, List<string>>>("m.direct");
+
+        Status = "DM list optimised, fetching info...";
+        // var results = dms.Select(async x => {
+        //     var (userId, rooms) = x;
+        //     UserProfileWithId userProfile;
+        //     try {
+        //         var profile = await SetupData.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);
+        // }
+        await SetupData.DMSpaceRoomInfo!.FetchAllStateAsync();
+        _semaphore.Release();
+        Status = null;
+        await base.OnParametersSetAsync();
+    }
+
+    private async Task Execute() {
+        var hs = SetupData.Homeserver;
+        var dmSpaceRoom = new DMSpaceRoom(hs, SetupData.DmSpaceConfiguration!.DMSpaceId!);
+        await dmSpaceRoom.ImportNativeDMs();
+        NavigationManager.NavigateTo("/User/DMSpace/Setup?stage=3");
+    }
+
+    private async Task<RoomInfo> GetRoomInfo(GenericRoom room) {
+        var roomInfo = new RoomInfo(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/Labs/Index.razor b/MatrixUtils.Web/Pages/Labs/Index.razor
new file mode 100644
index 0000000..fbe4b62
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Labs/Index.razor
@@ -0,0 +1,11 @@
+@page "/Labs"
+<h3>Index of /Labs</h3>
+<p>Welcome to RMU Laboratories! We wish you a safe and informative time!</p>
+<p>These pages are a work in progress, and may not work <b>or cause permanent account changes!</b></p>
+<p>We do not claim responsibility in case something goes wrong here!</p>
+<p><b style="color: red;">Here be dragons!!</b></p>
+<br/>
+
+<a href="/Labs/Rooms2">Room List v3</a><br/>
+<a href="/Labs/Client">Client implementation attempt</a><br/>
+<a href="/Labs/DMSpace/Setup">DM Space setup</a><br/>
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Labs/Index.razor.css b/MatrixUtils.Web/Pages/Labs/Index.razor.css
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Labs/Index.razor.css
diff --git a/MatrixUtils.Web/Pages/Labs/Rooms2/Index2.razor b/MatrixUtils.Web/Pages/Labs/Rooms2/Index2.razor
new file mode 100644
index 0000000..3392960
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Labs/Rooms2/Index2.razor
@@ -0,0 +1,87 @@
+@page "/Labs/Rooms2"
+@using LibMatrix.Responses
+@using System.Collections.ObjectModel
+@using System.ComponentModel
+@using MatrixUtils.Abstractions
+@using MatrixUtils.Web.Pages.Labs.Rooms2.Index2Components
+@inject ILogger<Index> logger
+<h3>Room list</h3>
+
+<RoomsIndex2SyncContainer Data="@Data"></RoomsIndex2SyncContainer>
+@if (Data.Homeserver is null || Data.GlobalProfile is null) {
+    <p>Creating homeserver instance and fetching global profile...</p>
+    return;
+}
+
+<div>
+    <LinkButton Color="@(SelectedTab == Tab.Main ? null : "#0b0e62")" OnClick="() => Task.FromResult(SelectedTab = Tab.Main)">Main</LinkButton>
+    <LinkButton Color="@(SelectedTab == Tab.DMs ? null : "#0b0e62")" OnClick="() => Task.FromResult(SelectedTab = Tab.DMs)">DMs</LinkButton>
+    <LinkButton Color="@(SelectedTab == Tab.ByRoomType ? null : "#0b0e62")" OnClick="() => Task.FromResult(SelectedTab = Tab.ByRoomType)">By room type</LinkButton>
+</div>
+<br/>
+<CascadingValue Value="@Data">
+    @switch (SelectedTab) {
+        case Tab.Main:
+            <h3>Main tab</h3>
+            <RoomsIndex2MainTab></RoomsIndex2MainTab>
+            break;
+        case Tab.DMs:
+            <h3>DMs tab</h3>
+            <RoomsIndex2DMsTab></RoomsIndex2DMsTab>
+            break;
+        case Tab.ByRoomType:
+            <h3>By room type tab</h3>
+            <RoomsIndex2ByRoomTypeTab></RoomsIndex2ByRoomTypeTab>
+            break;
+        default:
+            throw new InvalidEnumArgumentException();
+    }
+</CascadingValue>
+<br/>
+
+@* <LinkButton href="/Rooms/Create">Create new room</LinkButton> *@
+
+
+@code {
+
+    private Tab SelectedTab {
+        get => _selectedTab;
+        set {
+            _selectedTab = value;
+            StateHasChanged();
+        }
+    }
+
+    public RoomListViewData Data { get; set; } = new RoomListViewData();
+
+    protected override async Task OnInitializedAsync() {
+        Data.Homeserver = await RMUStorage.GetCurrentSessionOrNavigate();
+        if (Data.Homeserver is null) return;
+        var rooms = await Data.Homeserver.GetJoinedRooms();
+        Data.GlobalProfile = await Data.Homeserver.GetProfileAsync(Data.Homeserver.WhoAmI.UserId);
+        
+        foreach (var room in rooms) {
+            Data.Rooms.Add(new RoomInfo(room));
+        }
+        StateHasChanged();
+        
+        await base.OnInitializedAsync();
+    }
+
+    private Tab _selectedTab = Tab.Main;
+
+    private enum Tab {
+        Main,
+        DMs,
+        ByRoomType
+    }
+
+    public class RoomListViewData {
+        public ObservableCollection<RoomInfo> Rooms { get; } = [];
+
+        public UserProfileResponse? GlobalProfile { get; set; }
+
+        public AuthenticatedHomeserverGeneric? Homeserver { get; set; }
+    }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Labs/Rooms2/Index2Components/MainTabComponents/MainTabSpaceItem.razor b/MatrixUtils.Web/Pages/Labs/Rooms2/Index2Components/MainTabComponents/MainTabSpaceItem.razor
new file mode 100644
index 0000000..6483f01
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Labs/Rooms2/Index2Components/MainTabComponents/MainTabSpaceItem.razor
@@ -0,0 +1,56 @@
+@using MatrixUtils.Abstractions
+<div class="spaceListItem" style="@(SelectedSpace == Space ? "background-color: #FFFFFF33;" : "")" onclick="@SelectSpace">
+    <div class="spaceListItemContainer">
+        @if (IsSpaceOpened()) {
+            <span onclick="@ToggleSpace">▼ </span>
+        }
+        else {
+            <span onclick="@ToggleSpace">▶ </span>
+        }
+
+        <MxcImage Circular="true" Height="32" Width="32" Homeserver="Space.Room.Homeserver" MxcUri="@Space.RoomIcon"></MxcImage>
+        <span class="spaceNameEllipsis">@Space.RoomName</span>
+    </div>
+    @if (IsSpaceOpened()) {
+        <span>meow</span>
+    }
+</div>
+
+@code {
+
+    [Parameter]
+    public RoomInfo Space { get; set; }
+
+    [Parameter]
+    public RoomInfo SelectedSpace { get; set; }
+
+    [Parameter]
+    public EventCallback<RoomInfo> SelectedSpaceChanged { get; set; }
+
+    [Parameter]
+    public List<RoomInfo> OpenedSpaces { get; set; }
+
+    protected override Task OnInitializedAsync() {
+        Space.PropertyChanged += (sender, args) => { StateHasChanged(); };
+        return base.OnInitializedAsync();
+    }
+
+    public void ToggleSpace() {
+        if (OpenedSpaces.Contains(Space)) {
+            OpenedSpaces.Remove(Space);
+        }
+        else {
+            OpenedSpaces.Add(Space);
+        }
+    }
+
+    public void SelectSpace() {
+        SelectedSpace = Space;
+        SelectedSpaceChanged.InvokeAsync(Space);
+    }
+
+    public bool IsSpaceOpened() {
+        return OpenedSpaces.Contains(Space);
+    }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Labs/Rooms2/Index2Components/MainTabComponents/MainTabSpaceItem.razor.css b/MatrixUtils.Web/Pages/Labs/Rooms2/Index2Components/MainTabComponents/MainTabSpaceItem.razor.css
new file mode 100644
index 0000000..d6e413f
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Labs/Rooms2/Index2Components/MainTabComponents/MainTabSpaceItem.razor.css
@@ -0,0 +1,29 @@
+.spaceNameEllipsis {
+    padding-left: 8px;
+    display: inline-block;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    vertical-align: middle;
+    width: calc(100% - 64px);
+}
+
+.spaceListItem {
+    display: block;
+    width: 100%;
+    height: 3em;
+}
+
+.spaceListItemContainer {
+    display: flex;
+    align-items: center;
+    vertical-align: center;
+    justify-content: space-between;
+    padding: 0 16px;
+    width: 100%;
+    height: 100%;
+}
+
+.spaceListItem > img {
+    display: inline-block;
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Labs/Rooms2/Index2Components/RoomsIndex2ByRoomTypeTab.razor b/MatrixUtils.Web/Pages/Labs/Rooms2/Index2Components/RoomsIndex2ByRoomTypeTab.razor
new file mode 100644
index 0000000..f4cf849
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Labs/Rooms2/Index2Components/RoomsIndex2ByRoomTypeTab.razor
@@ -0,0 +1,53 @@
+@using MatrixUtils.Abstractions
+@using System.Security.Cryptography
+@using ArcaneLibs.Extensions
+<h3>RoomsIndex2MainTab</h3>
+
+<div>
+    <div class="row">
+        <div class="col-3" style="background-color: #ffffff66;">
+            <LinkButton>Uncategorised rooms</LinkButton>
+            @foreach (var space in Data.Rooms.Where(x => x.RoomType == "m.space")) {
+                <div style="@("width: 100%; height: 50px; background-color: #" + RandomNumberGenerator.GetBytes(3).Append((byte)0x11).ToArray().AsHexString().Replace(" ",""))">
+                    <p>@space.RoomName</p>
+                </div>
+            }
+        </div>
+        <div class="col-9" style="background-color: #ff00ff66;">
+            <p>omae wa mou shindeiru</p>
+        </div>
+    </div>
+</div>
+
+@code {
+
+    [CascadingParameter]
+    public Index2.RoomListViewData Data { get; set; } = null!;
+
+    protected override async Task OnInitializedAsync() {
+        Data.Rooms.CollectionChanged += (sender, args) => {
+            DebouncedStateHasChanged();
+            if (args.NewItems is { Count: > 0 })
+                foreach (var newItem in args.NewItems) {
+                    (newItem as RoomInfo).PropertyChanged += (sender, args) => { DebouncedStateHasChanged(); };
+                }
+        };
+        await base.OnInitializedAsync();
+    }
+
+    //debounce StateHasChanged, we dont want to reredner on every key stroke
+
+    private CancellationTokenSource _debounceCts = new CancellationTokenSource();
+
+    private async Task DebouncedStateHasChanged() {
+        _debounceCts.Cancel();
+        _debounceCts = new CancellationTokenSource();
+        try {
+            await Task.Delay(100, _debounceCts.Token);
+            Console.WriteLine("DebouncedStateHasChanged - Calling StateHasChanged!");
+            StateHasChanged();
+        }
+        catch (TaskCanceledException) { }
+    }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Labs/Rooms2/Index2Components/RoomsIndex2DMsTab.razor b/MatrixUtils.Web/Pages/Labs/Rooms2/Index2Components/RoomsIndex2DMsTab.razor
new file mode 100644
index 0000000..f4cf849
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Labs/Rooms2/Index2Components/RoomsIndex2DMsTab.razor
@@ -0,0 +1,53 @@
+@using MatrixUtils.Abstractions
+@using System.Security.Cryptography
+@using ArcaneLibs.Extensions
+<h3>RoomsIndex2MainTab</h3>
+
+<div>
+    <div class="row">
+        <div class="col-3" style="background-color: #ffffff66;">
+            <LinkButton>Uncategorised rooms</LinkButton>
+            @foreach (var space in Data.Rooms.Where(x => x.RoomType == "m.space")) {
+                <div style="@("width: 100%; height: 50px; background-color: #" + RandomNumberGenerator.GetBytes(3).Append((byte)0x11).ToArray().AsHexString().Replace(" ",""))">
+                    <p>@space.RoomName</p>
+                </div>
+            }
+        </div>
+        <div class="col-9" style="background-color: #ff00ff66;">
+            <p>omae wa mou shindeiru</p>
+        </div>
+    </div>
+</div>
+
+@code {
+
+    [CascadingParameter]
+    public Index2.RoomListViewData Data { get; set; } = null!;
+
+    protected override async Task OnInitializedAsync() {
+        Data.Rooms.CollectionChanged += (sender, args) => {
+            DebouncedStateHasChanged();
+            if (args.NewItems is { Count: > 0 })
+                foreach (var newItem in args.NewItems) {
+                    (newItem as RoomInfo).PropertyChanged += (sender, args) => { DebouncedStateHasChanged(); };
+                }
+        };
+        await base.OnInitializedAsync();
+    }
+
+    //debounce StateHasChanged, we dont want to reredner on every key stroke
+
+    private CancellationTokenSource _debounceCts = new CancellationTokenSource();
+
+    private async Task DebouncedStateHasChanged() {
+        _debounceCts.Cancel();
+        _debounceCts = new CancellationTokenSource();
+        try {
+            await Task.Delay(100, _debounceCts.Token);
+            Console.WriteLine("DebouncedStateHasChanged - Calling StateHasChanged!");
+            StateHasChanged();
+        }
+        catch (TaskCanceledException) { }
+    }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Labs/Rooms2/Index2Components/RoomsIndex2MainTab.razor b/MatrixUtils.Web/Pages/Labs/Rooms2/Index2Components/RoomsIndex2MainTab.razor
new file mode 100644
index 0000000..7ccfae2
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Labs/Rooms2/Index2Components/RoomsIndex2MainTab.razor
@@ -0,0 +1,206 @@
+@using MatrixUtils.Abstractions
+@using System.ComponentModel
+@using LibMatrix.EventTypes.Spec.State
+@using MatrixUtils.Web.Pages.Labs.Rooms2.Index2Components.MainTabComponents
+<h3>RoomsIndex2MainTab</h3>
+
+@* <div> *@
+@*     <div class="row"> *@
+@*         <div class="col-3" style="background-color: #ffffff66;"> *@
+@*             <LinkButton>Uncategorised rooms</LinkButton> *@
+@*             @foreach (var space in GetTopLevelSpaces()) { *@
+@*                 <a style="@("display:block; width: 100%; height: 50px; background-color: #" + RandomNumberGenerator.GetBytes(3).Append((byte)0x11).ToArray().AsHexString().Replace(" ", ""))"> *@
+@*                     <div style="vertical-align: middle;"> *@
+@*                         <div style="overflow:hidden; text-overflow: ellipsis; white-space: nowrap; ">@space.RoomName</div> *@
+@*                     </div> *@
+@*                 </a> *@
+@*             } *@
+@*         </div> *@
+@*         <div class="col-9" style="background-color: #ff00ff66;"> *@
+@*             <p>Placeholder for rooms list...</p> *@
+@*         </div> *@
+@*     </div> *@
+@* </div> *@
+
+<div>
+    <div class="row">
+        <div class="col-3" style="background-color: #ffffff22;">
+            <LinkButton>Uncategorised rooms</LinkButton>
+            @foreach (var space in GetTopLevelSpaces()) {
+                @* @RecursingSpaceChildren(space) *@
+                <MainTabSpaceItem Space="space" OpenedSpaces="OpenedSpaces" @bind-SelectedSpace="SelectedSpace" />
+            }
+        </div>
+        <div class="col-9" style="background-color: #ff00ff66;">
+            <p>Placeholder for rooms list...</p>
+            @if (SelectedSpace != null) {
+                foreach (var room in GetSpaceChildRooms(SelectedSpace)) {
+                    <p>@room.RoomName</p>
+                }
+            }
+        </div>
+    </div>
+</div>
+
+
+@code {
+
+    [CascadingParameter]
+    public Index2.RoomListViewData Data { get; set; } = null!;
+
+    protected override async Task OnInitializedAsync() {
+        Data.Rooms.CollectionChanged += (sender, args) => {
+            DebouncedStateHasChanged();
+            if (args.NewItems is { Count: > 0 })
+                foreach (var newItem in args.NewItems) {
+                    (newItem as RoomInfo).PropertyChanged += OnRoomListChanged;
+                    (newItem as RoomInfo).StateEvents.CollectionChanged += (sender, args) => { DebouncedStateHasChanged(); };
+                }
+        };
+        foreach (var newItem in Data.Rooms) {
+            newItem.PropertyChanged += OnRoomListChanged;
+            newItem.StateEvents.CollectionChanged += (sender, args) => { DebouncedStateHasChanged(); };
+        }
+
+        await base.OnInitializedAsync();
+        StateHasChanged();
+    }
+
+    private void OnRoomListChanged(object? sender, PropertyChangedEventArgs e) {
+        if (e.PropertyName == "RoomName" || e.PropertyName == "RoomType")
+            DebouncedStateHasChanged();
+    }
+
+    private CancellationTokenSource _debounceCts = new CancellationTokenSource();
+
+    private async Task DebouncedStateHasChanged() {
+        _debounceCts.Cancel();
+        _debounceCts = new CancellationTokenSource();
+        try {
+            Console.WriteLine("DebouncedStateHasChanged - Waiting 50ms...");
+            await Task.Delay(50, _debounceCts.Token);
+            Console.WriteLine("DebouncedStateHasChanged - Calling StateHasChanged!");
+            StateHasChanged();
+        }
+        catch (TaskCanceledException) { }
+    }
+
+    private List<RoomInfo> GetTopLevelSpaces() {
+        var spaces = Data.Rooms.Where(x => x.RoomType == "m.space").OrderBy(x => x.RoomName).ToList();
+        var allSpaceChildEvents = spaces.SelectMany(x => x.StateEvents.Where(y =>
+            y.Type == SpaceChildEventContent.EventId &&
+            y.RawContent!.Count > 0
+        )).ToList();
+
+        Console.WriteLine($"Child count: {allSpaceChildEvents.Count}");
+
+        spaces.RemoveAll(x => allSpaceChildEvents.Any(y => y.StateKey == x.Room.RoomId));
+
+        if (allSpaceChildEvents.Count == 0) {
+            Console.WriteLine("No space children found, returning nothing...");
+            return [];
+        }
+
+        return spaces.ToList();
+    }
+
+    private List<RoomInfo> GetSpaceChildren(RoomInfo space) {
+        var childEvents = space.StateEvents.Where(x =>
+            x.Type == SpaceChildEventContent.EventId &&
+            x.RawContent!.Count > 0
+        ).ToList();
+        var children = childEvents.Select(x => Data.Rooms.FirstOrDefault(y => y.Room.RoomId == x.StateKey)).Where(x => x is not null).ToList();
+        return children;
+    }
+
+    private List<RoomInfo> GetSpaceChildSpaces(RoomInfo space) {
+        var children = GetSpaceChildren(space);
+        var childSpaces = children.Where(x => x.RoomType == "m.space").ToList();
+        return childSpaces;
+    }
+    
+    private List<RoomInfo> GetSpaceChildRooms(RoomInfo space) {
+        var children = GetSpaceChildren(space);
+        var childRooms = children.Where(x => x.RoomType != "m.space").ToList();
+        return childRooms;
+    }
+
+    private RoomInfo? SelectedSpace { get; set; }
+    private List<RoomInfo> OpenedSpaces { get; set; } = new List<RoomInfo>();
+
+    // private RenderFragment RecursingSpaceChildren(RoomInfo space, List<RoomInfo>? parents = null, int depth = 0) {
+    //     parents ??= [];
+    //     var totalSw = Stopwatch.StartNew();
+    //     var children = GetSpaceChildSpaces(space);
+    //
+    //     var randomColor = RandomNumberGenerator.GetBytes(3).Append((byte)0x33).ToArray().AsHexString().Replace(" ", "");
+    //     var isExpanded = OpenedSpaces.Contains(space);
+    //
+    //     // Console.WriteLine($"RecursingSpaceChildren::FetchData - Depth: {depth}, Space: {space.RoomName}, Children: {children.Count} - {totalSw.Elapsed}");
+    //
+    //     // var renderSw = Stopwatch.StartNew();
+    //     var rf = new RenderFragment(builder => {
+    //         builder.OpenElement(0, "div");
+    //         //space list entry render fragment
+    //         // builder.AddContent(1, SpaceListEntry(space));
+    //         builder.OpenComponent<MainTabSpaceItem>(1);
+    //         builder.AddAttribute(2, "Space", space);
+    //         builder.AddAttribute(2, "OpenedSpaces", OpenedSpaces);
+    //         builder.CloseComponent();
+    //         builder.CloseElement();
+    //         //space children render fragment
+    //         if (isExpanded) {
+    //             builder.OpenElement(2, "div");
+    //             builder.AddAttribute(3, "style", "padding-left: 10px;");
+    //             foreach (var child in children) {
+    //                 builder.AddContent(4, RecursingSpaceChildren(child, parents.Append(space).ToList(), depth + 1));
+    //             }
+    //
+    //             builder.CloseElement();
+    //         }
+    //     });
+    //
+    //     // Console.WriteLine($"RecursingSpaceChildren::Render - Depth: {depth}, Space: {space.RoomName}, Children: {children.Count} - {renderSw.Elapsed}");
+    //     if (totalSw.ElapsedMilliseconds > 20)
+    //         Console.WriteLine($"RecursingSpaceChildren::Total - Depth: {depth}, Space: {space.RoomName}, Children: {children.Count} - {totalSw.Elapsed}");
+    //     // Console.WriteLine($"RecursingSpaceChildren::Total - Depth: {depth}, Space: {space.RoomName}, Children: {children.Count} - {totalSw.Elapsed}");
+    //     return rf;
+    // }
+
+    // private RenderFragment SpaceListEntry(RoomInfo space) {
+    //     return builder => {
+    //         {
+    //             builder.OpenElement(0, "div");
+    //             builder.AddAttribute(1, "style", "display: block; width: 100%; height: 50px;");
+    //             builder.AddAttribute(2, "onclick", EventCallback.Factory.Create(this, () => {
+    //                 if (OpenedSpaces.Contains(space)) {
+    //                     OpenedSpaces.Remove(space);
+    //                 }
+    //                 else {
+    //                     OpenedSpaces.Add(space);
+    //                 }
+    //
+    //                 StateHasChanged();
+    //             }));
+    //             {
+    //                 builder.OpenComponent<MxcImage>(5);
+    //                 builder.AddAttribute(6, "Homeserver", Data.Homeserver);
+    //                 builder.AddAttribute(7, "MxcUri", space.RoomIcon);
+    //                 builder.AddAttribute(8, "Circular", true);
+    //                 builder.AddAttribute(9, "Width", 32);
+    //                 builder.AddAttribute(10, "Height", 32);
+    //                 builder.CloseComponent();
+    //             }
+    //             {
+    //                 // room name, ellipsized
+    //                 builder.OpenElement(11, "span");
+    //                 builder.AddAttribute(12, "class", "spaceNameEllipsis");
+    //                 builder.AddContent(13, space.RoomName);
+    //                 builder.CloseElement();
+    //             }
+    //             builder.CloseElement();
+    //         }
+    //     };
+    // }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Labs/Rooms2/Index2Components/RoomsIndex2MainTab.razor.css b/MatrixUtils.Web/Pages/Labs/Rooms2/Index2Components/RoomsIndex2MainTab.razor.css
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Labs/Rooms2/Index2Components/RoomsIndex2MainTab.razor.css
diff --git a/MatrixUtils.Web/Pages/Labs/Rooms2/Index2Components/RoomsIndex2SyncContainer.razor b/MatrixUtils.Web/Pages/Labs/Rooms2/Index2Components/RoomsIndex2SyncContainer.razor
new file mode 100644
index 0000000..91f228d
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Labs/Rooms2/Index2Components/RoomsIndex2SyncContainer.razor
@@ -0,0 +1,201 @@
+@using LibMatrix.Helpers
+@using LibMatrix.Responses
+@using MatrixUtils.Abstractions
+@using System.Diagnostics
+@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.Extensions
+@using LibMatrix.Utilities
+@using System.Collections.ObjectModel
+@using ArcaneLibs
+@inject ILogger<RoomsIndex2SyncContainer> logger
+<pre>RoomsIndex2SyncContainer</pre>
+@foreach (var (name, value) in _statusList) {
+    <pre>[@name] @value.Status</pre>
+}
+
+@code {
+
+    [Parameter]
+    public Index2.RoomListViewData Data { get; set; } = null!;
+
+    private SyncHelper syncHelper;
+
+    private Queue<KeyValuePair<string, SyncResponse.RoomsDataStructure.JoinedRoomDataStructure>> queue = new();
+
+    private ObservableCollection<(string name, ObservableStatus value)> _statusList = new();
+
+    protected override async Task OnInitializedAsync() {
+        _statusList.CollectionChanged += (sender, args) => {
+            StateHasChanged();
+            if (args.NewItems is { Count: > 0 })
+                foreach (var item in args.NewItems) {
+                    if (item is not (string name, ObservableStatus value)) continue;
+                    value.PropertyChanged += (sender, args) => {
+                        if(value.Show) StateHasChanged();
+                    };
+                }
+        };
+
+        while (Data.Homeserver is null) {
+            await Task.Delay(100);
+        }
+
+        await SetUpSync();
+    }
+
+    private async Task SetUpSync() {
+        var status = await GetOrAddStatus("Main");
+        var syncHelpers = new Dictionary<string, SyncHelper>() {
+            ["Main"] = new SyncHelper(Data.Homeserver, logger) {
+                Timeout = 30000,
+                FilterId = await Data.Homeserver.NamedCaches.FilterCache.GetOrSetValueAsync(CommonSyncFilters.GetBasicRoomInfo),
+                // MinimumDelay = TimeSpan.FromMilliseconds(5000)
+            }
+        };
+        status.Status = "Initial sync... Checking server filter capability...";
+        var syncRes = await syncHelpers["Main"].SyncAsync();
+        if (!syncRes.Rooms?.Join?.Any(x => x.Value.State?.Events?.Any(y => y.Type == SpaceChildEventContent.EventId) ?? false) ?? true) {
+            status.Status = "Initial sync indicates that server supports filters, starting helpers!";
+            syncHelpers.Add("SpaceRelations", new SyncHelper(Data.Homeserver, logger) {
+                Timeout = 30000,
+                FilterId = await Data.Homeserver.NamedCaches.FilterCache.GetOrSetValueAsync(CommonSyncFilters.GetSpaceRelations),
+                // MinimumDelay = TimeSpan.FromMilliseconds(5000)
+            });
+
+            syncHelpers.Add("Profile", new SyncHelper(Data.Homeserver, logger) {
+                Timeout = 30000,
+                FilterId = await Data.Homeserver.NamedCaches.FilterCache.GetOrSetValueAsync(CommonSyncFilters.GetOwnMemberEvents),
+                // MinimumDelay = TimeSpan.FromMilliseconds(5000)
+            });
+        }
+        else status.Status = "Initial sync indicates that server does not support filters, continuing without extra filters!";
+
+        await HandleSyncResponse(syncRes);
+
+        //  profileSyncHelper = new SyncHelper(Homeserver, logger) {
+        //     Timeout = 10000,
+        //     Filter = profileUpdateFilter,
+        //     MinimumDelay = TimeSpan.FromMilliseconds(5000)
+        //  };
+        // profileUpdateFilter.Room.State.Senders.Add(Homeserver.WhoAmI.UserId);
+        RunQueueProcessor();
+        foreach (var helper in syncHelpers) {
+            Console.WriteLine($"Starting sync loop for {helper.Key}");
+            RunSyncLoop(helper.Value, helper.Key);
+        }
+    }
+
+    private async Task RunQueueProcessor() {
+        var status = await GetOrAddStatus("QueueProcessor");
+        var statusd = await GetOrAddStatus("QueueProcessor/D", show: false);
+        while (true) {
+            await Task.Delay(1000);
+            try {
+                var renderTimeSw = Stopwatch.StartNew();
+                while (queue.Count == 0) {
+                    var delay = 1000;
+                    Console.WriteLine("Queue is empty, waiting...");
+                    // Status2 = $"Queue is empty, waiting for {delay}ms...";
+                    await Task.Delay(delay);
+                }
+
+                status.Status = $"Queue no longer empty after {renderTimeSw.Elapsed}!";
+                renderTimeSw.Restart();
+
+                int maxUpdates = 5000;
+                while (maxUpdates-- > 0 && queue.TryDequeue(out var queueEntry)) {
+                    var (roomId, roomData) = queueEntry;
+                    statusd.Status = $"Dequeued room {roomId}";
+                    RoomInfo room;
+
+                    if (Data.Rooms.Any(x => x.Room.RoomId == roomId)) {
+                        room = Data.Rooms.First(x => x.Room.RoomId == roomId);
+                        statusd.Status = $"{roomId} already known with {room.StateEvents?.Count ?? 0} state events";
+                    }
+                    else {
+                        statusd.Status = $"Eencountered new room {roomId}!";
+                        room = new RoomInfo(Data.Homeserver!.GetRoom(roomId), roomData.State?.Events);
+                        Data.Rooms.Add(room);
+                    }
+
+                    if (roomData.State?.Events is { Count: > 0 })
+                        room.StateEvents!.MergeStateEventLists(roomData.State.Events);
+                    else {
+                        statusd.Status = $"Could not merge state for {room.Room.RoomId} as new data contains no state events!";
+                    }
+
+                    // await Task.Delay(10);
+                }
+
+                status.Status = $"Got {Data.Rooms.Count} rooms so far! {queue.Count} entries left in processing queue... Parsed last response in {renderTimeSw.Elapsed}";
+
+                // RenderContents |= queue.Count == 0;
+                // await Task.Delay(Data.Rooms.Count);
+            }
+            catch (Exception e) {
+                Console.WriteLine("QueueWorker exception: " + e);
+            }
+        }
+    }
+
+    private async Task RunSyncLoop(SyncHelper syncHelper, string name = "Unknown") {
+        var status = await GetOrAddStatus($"SYNC/{name}");
+        status.Status = $"Initial syncing...";
+
+        var syncs = syncHelper.EnumerateSyncAsync();
+        await foreach (var sync in syncs) {
+            var sw = Stopwatch.StartNew();
+            status.Status = $"[{DateTime.Now}] Got {Data.Rooms.Count} rooms so far! {sync.Rooms?.Join?.Count ?? 0} new updates!";
+
+            await HandleSyncResponse(sync);
+            status.Status += $"\nProcessed sync in {sw.ElapsedMilliseconds}ms, queue length: {queue.Count}";
+        }
+    }
+
+    private async Task HandleSyncResponse(SyncResponse? sync) {
+        if (sync?.Rooms?.Join is { Count: > 0 })
+            foreach (var joinedRoom in sync.Rooms.Join)
+                queue.Enqueue(joinedRoom);
+
+        if (sync.Rooms.Leave is { Count: > 0 })
+            foreach (var leftRoom in sync.Rooms.Leave)
+                if (Data.Rooms.Any(x => x.Room.RoomId == leftRoom.Key))
+                    Data.Rooms.Remove(Data.Rooms.First(x => x.Room.RoomId == leftRoom.Key));
+    }
+
+    private SemaphoreSlim _syncLock = new(1, 1);
+
+    private async Task<ObservableStatus> GetOrAddStatus(string name, bool show = true, bool log = true) {
+        await _syncLock.WaitAsync();
+        try {
+            if (_statusList.Any(x => x.name == name))
+                return _statusList.First(x => x.name == name).value;
+            var status = new ObservableStatus() {
+                Name = name,
+                Log = log,
+                Show = show
+            };
+            _statusList.Add((name, status));
+            return status;
+        }
+        finally {
+            _syncLock.Release();
+        }
+    }
+
+    private class ObservableStatus : NotifyPropertyChanged {
+        private string _status = "Initialising...";
+        public string Name { get; set; } = "Unknown";
+        public bool Show { get; set; } = true;
+        public bool Log { get; set; } = true;
+
+        public string Status {
+            get => _status;
+            set {
+                if(SetField(ref _status, value) && Log)
+                    Console.WriteLine($"[{Name}]: {value}");
+            }
+        }
+    }
+
+}
\ No newline at end of file