about summary refs log tree commit diff
diff options
context:
space:
mode:
authorRory& <root@rory.gay>2024-09-04 05:02:20 +0200
committerRory& <root@rory.gay>2024-09-04 05:02:20 +0200
commitd10417339b76bf2750f3e54f4e3b714dd3ed369a (patch)
tree2f0a51900228f3d4e241f9d8ac3001632ed5305d
parentcgit url for libmatrix (diff)
downloadModerationClient-master.tar.xz
-rw-r--r--.gitignore12
-rw-r--r--.idea/.idea.ModerationClient/.idea/avalonia.xml3
-rw-r--r--FilesystemBenchmark/Benchmarks.cs134
-rw-r--r--FilesystemBenchmark/FilesystemBenchmark.csproj19
-rw-r--r--FilesystemBenchmark/Program.cs8
m---------LibMatrix0
-rw-r--r--ModerationClient.sln14
-rw-r--r--ModerationClient/App.axaml.cs12
-rw-r--r--ModerationClient/Models/SpaceTreeNodes/RoomNode.cs14
-rw-r--r--ModerationClient/Models/SpaceTreeNodes/SpaceNode.cs15
-rw-r--r--ModerationClient/ModerationClient.csproj1
-rw-r--r--ModerationClient/Program.cs19
-rw-r--r--ModerationClient/Services/ClientContainer.cs8
-rw-r--r--ModerationClient/Services/CommandLineConfiguration.cs33
-rw-r--r--ModerationClient/Services/FileStorageProvider.cs68
-rw-r--r--ModerationClient/Services/MatrixAuthenticationService.cs4
-rw-r--r--ModerationClient/Services/ModerationClientConfiguration.cs5
-rw-r--r--ModerationClient/Services/StatusBarService.cs19
-rw-r--r--ModerationClient/Services/TestRunner.cs36
-rw-r--r--ModerationClient/ViewModels/ClientViewModel.cs155
-rw-r--r--ModerationClient/ViewModels/MainWindowViewModel.cs5
-rw-r--r--ModerationClient/ViewModels/UserManagement/UserManagementViewModel.cs13
-rw-r--r--ModerationClient/Views/MainWindow/ClientView.axaml20
-rw-r--r--ModerationClient/Views/MainWindow/ClientView.axaml.cs1
-rw-r--r--ModerationClient/Views/MainWindow/LoginView.axaml.cs2
-rw-r--r--ModerationClient/Views/MainWindow/MainWindow.axaml37
-rw-r--r--ModerationClient/Views/MainWindow/MainWindow.axaml.cs113
-rw-r--r--Test/Program.cs21
-rw-r--r--Test/Test.csproj18
29 files changed, 657 insertions, 152 deletions
diff --git a/.gitignore b/.gitignore
index a6807bd..668274e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,12 +1,12 @@
-# Dotnet build outputs
+# Regular dotnet things
 **/bin/
 **/obj/
+**/*.[Dd]ot[Ss]ettings.[Uu]ser
+**/BenchmarkDotNet.Artifacts/
 
-# User files
-*.DotSettings.user
-*.patch
+# Local files
 /patches/
-
-# Local files:
 appsettings.Local*.json
+appservice.yaml
+appservice.json
 .run/local/*.run.xml
\ No newline at end of file
diff --git a/.idea/.idea.ModerationClient/.idea/avalonia.xml b/.idea/.idea.ModerationClient/.idea/avalonia.xml
index 6bc9c45..b045202 100644
--- a/.idea/.idea.ModerationClient/.idea/avalonia.xml
+++ b/.idea/.idea.ModerationClient/.idea/avalonia.xml
@@ -8,6 +8,9 @@
         <entry key="ModerationClient/Views/LoginView.axaml" value="ModerationClient/ModerationClient.csproj" />
         <entry key="ModerationClient/Views/LoginWindow.axaml" value="ModerationClient/ModerationClient.csproj" />
         <entry key="ModerationClient/Views/MainWindow.axaml" value="ModerationClient/ModerationClient.csproj" />
+        <entry key="ModerationClient/Views/MainWindow/ClientView.axaml" value="ModerationClient/ModerationClient.csproj" />
+        <entry key="ModerationClient/Views/MainWindow/LoginView.axaml" value="ModerationClient/ModerationClient.csproj" />
+        <entry key="ModerationClient/Views/MainWindow/MainWindow.axaml" value="ModerationClient/ModerationClient.csproj" />
         <entry key="ModerationClient/Views/MainWindow/NotificationPopupWindow.axaml" value="ModerationClient/ModerationClient.csproj" />
         <entry key="ModerationClient/Views/UserManagementWindow.axaml" value="ModerationClient/ModerationClient.csproj" />
         <entry key="ModerationClient/Windows/LoginWindow.axaml" value="ModerationClient/ModerationClient.csproj" />
diff --git a/FilesystemBenchmark/Benchmarks.cs b/FilesystemBenchmark/Benchmarks.cs
new file mode 100644
index 0000000..9ab044a
--- /dev/null
+++ b/FilesystemBenchmark/Benchmarks.cs
@@ -0,0 +1,134 @@
+using System.Collections.Concurrent;
+using System.Security.Cryptography;
+using ArcaneLibs.Extensions;
+using BenchmarkDotNet.Attributes;
+
+namespace FilesystemBenchmark;
+
+public class Benchmarks {
+    private string testPath = "/home/Rory/.local/share/ModerationClient/default/syncCache";
+
+    private EnumerationOptions enumOpts = new EnumerationOptions() {
+        MatchType = MatchType.Simple,
+        AttributesToSkip = FileAttributes.None,
+        IgnoreInaccessible = false,
+        RecurseSubdirectories = true
+    };
+
+    [Benchmark]
+    public void GetFilesMatching() {
+        _ = Directory.GetFiles(testPath, "*.*", SearchOption.AllDirectories).Count();
+    }
+    
+    [Benchmark]
+    public void EnumerateFilesMatching() {
+        _ = Directory.EnumerateFiles(testPath, "*.*", SearchOption.AllDirectories).Count();
+    }
+    
+    [Benchmark]
+    public void GetFilesMatchingSingleStar() {
+        _ = Directory.GetFiles(testPath, "*", SearchOption.AllDirectories).Count();
+    }
+    
+    [Benchmark]
+    public void EnumerateFilesMatchingSingleStar() {
+        _ = Directory.EnumerateFiles(testPath, "*", SearchOption.AllDirectories).Count();
+    }
+    
+    [Benchmark]
+    public void GetFilesMatchingSingleStarSimple() {
+        _ = Directory.GetFiles(testPath, "*", new EnumerationOptions() {
+            MatchType = MatchType.Simple,
+            AttributesToSkip = FileAttributes.None,
+            IgnoreInaccessible = false,
+            RecurseSubdirectories = true
+        }).Count();
+    }
+    
+    [Benchmark]
+    public void EnumerateFilesMatchingSingleStarSimple() {
+        _ = Directory.EnumerateFiles(testPath, "*", new EnumerationOptions() {
+            MatchType = MatchType.Simple,
+            AttributesToSkip = FileAttributes.None,
+            IgnoreInaccessible = false,
+            RecurseSubdirectories = true
+        }).Count();
+    }
+    
+    [Benchmark]
+    public void GetFilesMatchingSingleStarSimpleCached() {
+        _ = Directory.GetFiles(testPath, "*", enumOpts).Count();
+    }
+    
+    [Benchmark]
+    public void EnumerateFilesMatchingSingleStarSimpleCached() {
+        _ = Directory.EnumerateFiles(testPath, "*", enumOpts).Count();
+    }
+    
+    // [Benchmark]
+    // public void GetFilesRecursiveFunc() {
+    //     GetFilesRecursive(testPath);
+    // }
+    //
+    // [Benchmark]
+    // public void GetFilesRecursiveParallelFunc() {
+    //     GetFilesRecursiveParallel(testPath);
+    // }
+    //
+    // [Benchmark]
+    // public void GetFilesRecursiveEntriesFunc() {
+    //     GetFilesRecursiveEntries(testPath);
+    // }
+    //
+    // [Benchmark]
+    // public void GetFilesRecursiveAsyncFunc() {
+    //     GetFilesRecursiveAsync(testPath).ToBlockingEnumerable();
+    // }
+    
+
+    private List<string> GetFilesRecursive(string path) {
+        var result = new List<string>();
+        foreach (var dir in Directory.GetDirectories(path)) {
+            result.AddRange(GetFilesRecursive(dir));
+        }
+
+        result.AddRange(Directory.GetFiles(path));
+        return result;
+    }
+
+    private List<string> GetFilesRecursiveEntries(string path) {
+        var result = new List<string>();
+        foreach (var entry in Directory.EnumerateFileSystemEntries(path)) {
+            if (Directory.Exists(entry)) {
+                result.AddRange(GetFilesRecursiveEntries(entry));
+            }
+            else {
+                result.Add(entry);
+            }
+        }
+
+        return result;
+    }
+
+    private List<string> GetFilesRecursiveParallel(string path) {
+        var result = new ConcurrentBag<string>();
+        Directory.GetDirectories(path).AsParallel().ForAll(dir => {
+            GetFilesRecursiveParallel(dir).ForEach(result.Add);
+        });
+
+        Directory.GetFiles(path).AsParallel().ForAll(result.Add);
+        return result.ToList();
+    }
+    
+    private async IAsyncEnumerable<string> GetFilesRecursiveAsync(string path) {
+        foreach (var dir in Directory.GetDirectories(path)) {
+            foreach (var file in GetFilesRecursiveAsync(dir).ToBlockingEnumerable()) {
+                yield return file;
+            }
+        }
+
+        foreach (var file in Directory.GetFiles(path)) {
+            yield return file;
+        }
+    }
+}
\ No newline at end of file
diff --git a/FilesystemBenchmark/FilesystemBenchmark.csproj b/FilesystemBenchmark/FilesystemBenchmark.csproj
new file mode 100644
index 0000000..bb0af83
--- /dev/null
+++ b/FilesystemBenchmark/FilesystemBenchmark.csproj
@@ -0,0 +1,19 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+    <PropertyGroup>
+        <OutputType>Exe</OutputType>
+        <TargetFramework>net8.0</TargetFramework>
+        <LangVersion>preview</LangVersion>
+        <ImplicitUsings>enable</ImplicitUsings>
+        <Nullable>enable</Nullable>
+    </PropertyGroup>
+
+    <ItemGroup>
+      <PackageReference Include="BenchmarkDotNet" Version="0.14.0" />
+    </ItemGroup>
+
+    <ItemGroup>
+      <ProjectReference Include="..\LibMatrix\ArcaneLibs\ArcaneLibs\ArcaneLibs.csproj" />
+    </ItemGroup>
+
+</Project>
diff --git a/FilesystemBenchmark/Program.cs b/FilesystemBenchmark/Program.cs
new file mode 100644
index 0000000..9454263
--- /dev/null
+++ b/FilesystemBenchmark/Program.cs
@@ -0,0 +1,8 @@
+// See https://aka.ms/new-console-template for more information
+
+using BenchmarkDotNet.Attributes;
+using BenchmarkDotNet.Configs;
+using BenchmarkDotNet.Running;
+using FilesystemBenchmark;
+
+BenchmarkRunner.Run<Benchmarks>();
\ No newline at end of file
diff --git a/LibMatrix b/LibMatrix
-Subproject bba7333ee6581a92bbbc7479d72325e704fe7fa
+Subproject a8d20e9d57857296e4600f44807893f4dcad72d
diff --git a/ModerationClient.sln b/ModerationClient.sln
index c23825d..cff05e1 100644
--- a/ModerationClient.sln
+++ b/ModerationClient.sln
@@ -47,6 +47,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ArcaneLibs.UsageTest", "Lib
 EndProject
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ArcaneLib.Tests", "LibMatrix\ArcaneLibs\ArcaneLib.Tests\ArcaneLib.Tests.csproj", "{046D35DF-E1F2-41DA-94D3-80CF960C100A}"
 EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Test", "Test\Test.csproj", "{BC6C910C-79C7-42D3-ABD9-8B9ED9121AE5}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FilesystemBenchmark", "FilesystemBenchmark\FilesystemBenchmark.csproj", "{6F3FA65D-5661-4840-B421-BF64CD4DBBD2}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
@@ -129,6 +133,14 @@ Global
 		{046D35DF-E1F2-41DA-94D3-80CF960C100A}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{046D35DF-E1F2-41DA-94D3-80CF960C100A}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{046D35DF-E1F2-41DA-94D3-80CF960C100A}.Release|Any CPU.Build.0 = Release|Any CPU
+		{BC6C910C-79C7-42D3-ABD9-8B9ED9121AE5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{BC6C910C-79C7-42D3-ABD9-8B9ED9121AE5}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{BC6C910C-79C7-42D3-ABD9-8B9ED9121AE5}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{BC6C910C-79C7-42D3-ABD9-8B9ED9121AE5}.Release|Any CPU.Build.0 = Release|Any CPU
+		{6F3FA65D-5661-4840-B421-BF64CD4DBBD2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{6F3FA65D-5661-4840-B421-BF64CD4DBBD2}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{6F3FA65D-5661-4840-B421-BF64CD4DBBD2}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{6F3FA65D-5661-4840-B421-BF64CD4DBBD2}.Release|Any CPU.Build.0 = Release|Any CPU
 	EndGlobalSection
 	GlobalSection(NestedProjects) = preSolution
 		{6FDD7CD7-4526-410B-858D-35FA8292317C} = {C5771E60-0DA8-42AC-B54B-94C91B5B719B}
@@ -154,7 +166,7 @@ Global
 		{046D35DF-E1F2-41DA-94D3-80CF960C100A} = {D7A5A7D3-4D9E-4B15-9C48-5E75A91483CF}
 	EndGlobalSection
 	GlobalSection(RiderSharedRunConfigurations) = postSolution
-		File = .run\local\ModerationClient (Apothecary) test.run.xml
 		File = .run\local\ModerationClient (Apothecary).run.xml
+		File = .run\local\ModerationClient (matrix.org).run.xml
 	EndGlobalSection
 EndGlobal
diff --git a/ModerationClient/App.axaml.cs b/ModerationClient/App.axaml.cs
index c44b5a2..b15c0fa 100644
--- a/ModerationClient/App.axaml.cs
+++ b/ModerationClient/App.axaml.cs
@@ -1,11 +1,11 @@
 using System;
 using System.IO;
+using System.Threading;
 using Avalonia;
 using Avalonia.Controls.ApplicationLifetimes;
 using Avalonia.Data.Core.Plugins;
 using Avalonia.Markup.Xaml;
 using LibMatrix.Services;
-using MatrixUtils.Abstractions;
 using MatrixUtils.Desktop;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Hosting;
@@ -29,7 +29,6 @@ public partial class App : Application {
     // ReSharper disable once AsyncVoidMethod
     public override async void OnFrameworkInitializationCompleted() {
         var builder = Microsoft.Extensions.Hosting.Host.CreateApplicationBuilder(Environment.GetCommandLineArgs());
-        builder.Services.AddTransient<MainWindowViewModel>();
         ConfigureServices(builder.Services);
 
         Host = builder.Build();
@@ -47,10 +46,11 @@ public partial class App : Application {
     }
 
     private static IServiceProvider ConfigureServices(IServiceCollection services) {
+        var cfg = CommandLineConfiguration.FromProcessArgs();
         services.AddRoryLibMatrixServices(new() {
             AppName = "ModerationClient",
         });
-        services.AddSingleton<CommandLineConfiguration>(CommandLineConfiguration.FromProcessArgs());
+        services.AddSingleton<CommandLineConfiguration>(cfg);
         services.AddSingleton<MatrixAuthenticationService>();
         services.AddSingleton<ModerationClientConfiguration>();
 
@@ -72,9 +72,15 @@ public partial class App : Application {
         services.AddTransient<ClientView>();
         
         // Register ViewModels
+        services.AddTransient<MainWindowViewModel>();
         services.AddTransient<ClientViewModel>();
         services.AddTransient<UserManagementViewModel>();
 
+        if (cfg.TestConfiguration is not null) {
+            services.AddSingleton(cfg.TestConfiguration);
+            services.AddHostedService<TestRunner>();
+        }
+
         return services.BuildServiceProvider();
     }
 }
\ No newline at end of file
diff --git a/ModerationClient/Models/SpaceTreeNodes/RoomNode.cs b/ModerationClient/Models/SpaceTreeNodes/RoomNode.cs
new file mode 100644
index 0000000..76d5aa9
--- /dev/null
+++ b/ModerationClient/Models/SpaceTreeNodes/RoomNode.cs
@@ -0,0 +1,14 @@
+using ArcaneLibs;
+
+namespace ModerationClient.Models.SpaceTreeNodes;
+
+public class RoomNode : NotifyPropertyChanged {
+    private string? _name;
+
+    public string RoomID { get; set; }
+
+    public string? Name {
+        get => _name;
+        set => SetField(ref _name, value);
+    }
+}
\ No newline at end of file
diff --git a/ModerationClient/Models/SpaceTreeNodes/SpaceNode.cs b/ModerationClient/Models/SpaceTreeNodes/SpaceNode.cs
new file mode 100644
index 0000000..b8042ae
--- /dev/null
+++ b/ModerationClient/Models/SpaceTreeNodes/SpaceNode.cs
@@ -0,0 +1,15 @@
+using System.Collections.ObjectModel;
+
+namespace ModerationClient.Models.SpaceTreeNodes;
+
+public class SpaceNode : RoomNode {
+    private bool _isExpanded = false;
+
+    public SpaceNode(bool includeSelf = true) {
+        if(includeSelf)
+            ChildRooms = [this];
+    }
+
+    public ObservableCollection<SpaceNode> ChildSpaces { get; set; } = [];
+    public ObservableCollection<RoomNode> ChildRooms { get; set; } = [];
+}
\ No newline at end of file
diff --git a/ModerationClient/ModerationClient.csproj b/ModerationClient/ModerationClient.csproj
index 9876af9..c64d0c3 100644
--- a/ModerationClient/ModerationClient.csproj
+++ b/ModerationClient/ModerationClient.csproj
@@ -9,7 +9,6 @@
     </PropertyGroup>
 
     <ItemGroup>
-        <Folder Include="Models\"/>
         <AvaloniaResource Include="Assets\**"/>
     </ItemGroup>
 
diff --git a/ModerationClient/Program.cs b/ModerationClient/Program.cs
index 9229194..82e10aa 100644
--- a/ModerationClient/Program.cs
+++ b/ModerationClient/Program.cs
@@ -1,10 +1,12 @@
-using Avalonia;
+using Avalonia;
 using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
 
 namespace ModerationClient;
 
-internal sealed class Program
-{
+internal sealed class Program {
     // Initialization code. Don't use any Avalonia, third-party APIs or any
     // SynchronizationContext-reliant code before AppMain is called: things aren't initialized
     // yet and stuff might break.
@@ -18,4 +20,13 @@ internal sealed class Program
             .UsePlatformDetect()
             .WithInterFont()
             .LogToTrace();
-}
\ No newline at end of file
+
+    // private static FileStream f = new("/dev/input/by-path/platform-pcspkr-event-spkr", FileMode.Open, FileAccess.Write, FileShare.Write, 24);
+    public static void Beep(short freq, short duration) {
+        // f.Write([..new byte[16], 0x12, 0x00, 0x02, 0x00, (byte)(freq & 0xFF), (byte)((freq >> 8) & 0xFF), 0x00, 0x00]);
+        // if (duration > 0) {
+            // Thread.Sleep(duration);
+            // f.Write([..new byte[16], 0x12, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00]);
+        // }
+    }
+}
diff --git a/ModerationClient/Services/ClientContainer.cs b/ModerationClient/Services/ClientContainer.cs
new file mode 100644
index 0000000..fa3abef
--- /dev/null
+++ b/ModerationClient/Services/ClientContainer.cs
@@ -0,0 +1,8 @@
+namespace ModerationClient.Services;
+
+public class ClientContainer {
+    public ClientContainer(MatrixAuthenticationService authService, CommandLineConfiguration cfg)
+    {
+        
+    }
+}
\ No newline at end of file
diff --git a/ModerationClient/Services/CommandLineConfiguration.cs b/ModerationClient/Services/CommandLineConfiguration.cs
index 63c3691..4f7da2d 100644
--- a/ModerationClient/Services/CommandLineConfiguration.cs
+++ b/ModerationClient/Services/CommandLineConfiguration.cs
@@ -2,15 +2,14 @@ using System;
 using System.Collections.Generic;
 using System.Diagnostics.CodeAnalysis;
 using System.IO;
+using System.Text.Json;
 using ArcaneLibs;
-using Microsoft.Extensions.Logging;
+using ArcaneLibs.Extensions;
 
 namespace ModerationClient.Services;
 
 [SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
 public record CommandLineConfiguration {
-    private readonly string? _loginData;
-
     public static CommandLineConfiguration FromProcessArgs() {
         // logger.LogInformation("Command line arguments: " + string.Join(", ", Environment.GetCommandLineArgs()));
         CommandLineConfiguration cfg = FromSerialised(Environment.GetCommandLineArgs());
@@ -27,24 +26,27 @@ public record CommandLineConfiguration {
         if (!string.IsNullOrWhiteSpace(cfg.LoginData)) {
             File.WriteAllText(Path.Combine(cfg.ProfileDirectory, "login.json"), cfg.LoginData);
         }
+        
+        
         return cfg;
     }
-
     public string[] Serialise() {
+        var current = FromProcessArgs();
         List<string> args = new();
-        if (Profile != "default") args.AddRange(["--profile", Profile]);
+        if (Profile != current.Profile) args.AddRange(["--profile", Profile]);
         if (IsTemporary) args.Add("--temporary");
         if (Math.Abs(Scale - 1f) > float.Epsilon) args.AddRange(["--scale", Scale.ToString()]);
-        if (ProfileDirectory != Util.ExpandPath("$HOME/.local/share/ModerationClient/default")) args.AddRange(["--profile-dir", ProfileDirectory]);
-        if (!string.IsNullOrWhiteSpace(_loginData)) args.AddRange(["--login-data", _loginData!]);
+        if (ProfileDirectory != current.ProfileDirectory) args.AddRange(["--profile-dir", ProfileDirectory]);
+        if (!string.IsNullOrWhiteSpace(_loginData) && _loginData != current.LoginData) args.AddRange(["--login-data", _loginData!]);
+        if (TestConfiguration is not null && TestConfiguration != current.TestConfiguration) args.AddRange(["--test-config", TestConfiguration!.ToJson()]);
         return args.ToArray();
     }
-
     public static CommandLineConfiguration FromSerialised(string[] args) {
         CommandLineConfiguration cfg = new();
         for (var i = 0; i < args.Length; i++) {
             switch (args[i]) {
                 case "--profile":
+                case "-p":
                     cfg = cfg with { Profile = args[++i] };
                     break;
                 case "--temporary":
@@ -59,12 +61,16 @@ public record CommandLineConfiguration {
                 case "--login-data":
                     cfg = cfg with { LoginData = args[++i] };
                     break;
+                case "--test-config":
+                    cfg = cfg with { testConfiguration = args[++i] };
+                    break;
             }
         }
 
         return cfg;
     }
 
+    private readonly string? _loginData;
     public string Profile { get; init; } = "default";
     public bool IsTemporary { get; init; }
 
@@ -78,4 +84,15 @@ public record CommandLineConfiguration {
             _loginData = value;
         }
     }
+
+    private string? testConfiguration {
+        get => TestConfiguration?.ToJson();
+        init => TestConfiguration = value is null ? null : JsonSerializer.Deserialize<TestConfig>(value);
+    }
+
+    public TestConfig? TestConfiguration { get; init; }
+
+    public class TestConfig {
+        public List<string> Mxids { get; set; } = new();
+    }
 }
\ No newline at end of file
diff --git a/ModerationClient/Services/FileStorageProvider.cs b/ModerationClient/Services/FileStorageProvider.cs
index 3658369..5b43ce4 100644
--- a/ModerationClient/Services/FileStorageProvider.cs
+++ b/ModerationClient/Services/FileStorageProvider.cs
@@ -1,19 +1,28 @@
 using System;
 using System.Collections.Generic;
+using System.Diagnostics;
 using System.Diagnostics.CodeAnalysis;
 using System.IO;
 using System.Linq;
 using System.Text.Json;
 using System.Threading.Tasks;
 using ArcaneLibs.Extensions;
-using LibMatrix.Extensions;
 using LibMatrix.Interfaces.Services;
-using Microsoft.Extensions.Logging;
 
-namespace MatrixUtils.Abstractions;
+namespace ModerationClient.Services;
 
 public class FileStorageProvider : IStorageProvider {
-    private readonly ILogger<FileStorageProvider> _logger;
+    // private readonly ILogger<FileStorageProvider> _logger;
+    private static readonly JsonSerializerOptions Options = new() {
+        WriteIndented = true
+    };
+    
+    private static readonly EnumerationOptions EnumOpts = new EnumerationOptions() {
+        MatchType = MatchType.Simple,
+        AttributesToSkip = FileAttributes.None,
+        IgnoreInaccessible = false,
+        RecurseSubdirectories = true
+    };
 
     public string TargetPath { get; }
 
@@ -30,25 +39,58 @@ public class FileStorageProvider : IStorageProvider {
         }
     }
 
-    public async Task SaveObjectAsync<T>(string key, T value) => await File.WriteAllTextAsync(Path.Join(TargetPath, key), value?.ToJson());
+    public async Task SaveObjectAsync<T>(string key, T value) {
+        EnsureContainingDirectoryExists(GetFullPath(key));
+        await using var fileStream = File.Create(GetFullPath(key));
+        await JsonSerializer.SerializeAsync(fileStream, value, Options);
+    }
 
     [RequiresUnreferencedCode("This API uses reflection to deserialize JSON")]
-    public async Task<T?> LoadObjectAsync<T>(string key) => JsonSerializer.Deserialize<T>(await File.ReadAllTextAsync(Path.Join(TargetPath, key)));
+    public async Task<T?> LoadObjectAsync<T>(string key) {
+        await using var fileStream = File.OpenRead(GetFullPath(key));
+        return JsonSerializer.Deserialize<T>(fileStream);
+    }
 
-    public Task<bool> ObjectExistsAsync(string key) => Task.FromResult(File.Exists(Path.Join(TargetPath, key)));
+    public Task<bool> ObjectExistsAsync(string key) => Task.FromResult(File.Exists(GetFullPath(key)));
 
-    public Task<List<string>> GetAllKeysAsync() => Task.FromResult(Directory.GetFiles(TargetPath).Select(Path.GetFileName).ToList());
+    public async Task<IEnumerable<string>> GetAllKeysAsync() {
+        var sw = Stopwatch.StartNew();
+        // var result = Directory.EnumerateFiles(TargetPath, "*", SearchOption.AllDirectories)
+        var result = Directory.EnumerateFiles(TargetPath, "*", EnumOpts)
+            .Select(s => s.Replace(TargetPath, "").TrimStart('/'));
+        // Console.WriteLine($"GetAllKeysAsync got {result.Count()} results in {sw.ElapsedMilliseconds}ms");
+        // Environment.Exit(0);
+        return result;
+    }
 
     public Task DeleteObjectAsync(string key) {
-        File.Delete(Path.Join(TargetPath, key));
+        File.Delete(GetFullPath(key));
         return Task.CompletedTask;
     }
 
     public async Task SaveStreamAsync(string key, Stream stream) {
-        Directory.CreateDirectory(Path.GetDirectoryName(Path.Join(TargetPath, key)) ?? throw new InvalidOperationException());
-        await using var fileStream = File.Create(Path.Join(TargetPath, key));
+        EnsureContainingDirectoryExists(GetFullPath(key));
+        await using var fileStream = File.Create(GetFullPath(key));
         await stream.CopyToAsync(fileStream);
     }
 
-    public Task<Stream?> LoadStreamAsync(string key) => Task.FromResult<Stream?>(File.Exists(Path.Join(TargetPath, key)) ? File.OpenRead(Path.Join(TargetPath, key)) : null);
-}
+    public Task<Stream?> LoadStreamAsync(string key) => Task.FromResult<Stream?>(File.Exists(GetFullPath(key)) ? File.OpenRead(GetFullPath(key)) : null);
+
+    public Task CopyObjectAsync(string sourceKey, string destKey) {
+        EnsureContainingDirectoryExists(GetFullPath(destKey));
+        File.Copy(GetFullPath(sourceKey), GetFullPath(destKey));
+        return Task.CompletedTask;
+    }
+
+    public Task MoveObjectAsync(string sourceKey, string destKey) {
+        EnsureContainingDirectoryExists(GetFullPath(destKey));
+        File.Move(GetFullPath(sourceKey), GetFullPath(destKey));
+        return Task.CompletedTask;
+    }
+
+    private string GetFullPath(string key) => Path.Join(TargetPath, key);
+
+    private void EnsureContainingDirectoryExists(string path) {
+        Directory.CreateDirectory(Path.GetDirectoryName(path) ?? throw new InvalidOperationException());
+    }
+}
\ No newline at end of file
diff --git a/ModerationClient/Services/MatrixAuthenticationService.cs b/ModerationClient/Services/MatrixAuthenticationService.cs
index 7e9ce70..46ec067 100644
--- a/ModerationClient/Services/MatrixAuthenticationService.cs
+++ b/ModerationClient/Services/MatrixAuthenticationService.cs
@@ -1,16 +1,12 @@
-using System;
 using System.IO;
 using System.Text.Json;
-using System.Threading;
 using System.Threading.Tasks;
 using ArcaneLibs;
 using ArcaneLibs.Extensions;
-using Avalonia.Controls.Diagnostics;
 using LibMatrix;
 using LibMatrix.Homeservers;
 using LibMatrix.Responses;
 using LibMatrix.Services;
-using MatrixUtils.Desktop;
 using Microsoft.Extensions.Logging;
 
 namespace ModerationClient.Services;
diff --git a/ModerationClient/Services/ModerationClientConfiguration.cs b/ModerationClient/Services/ModerationClientConfiguration.cs
index f770fef..3cc4ffb 100644
--- a/ModerationClient/Services/ModerationClientConfiguration.cs
+++ b/ModerationClient/Services/ModerationClientConfiguration.cs
@@ -1,10 +1,5 @@
-using System;
-using System.Collections;
 using System.Diagnostics.CodeAnalysis;
-using System.IO;
-using System.Linq;
 using ArcaneLibs;
-using ArcaneLibs.Extensions;
 using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.Hosting;
 using Microsoft.Extensions.Logging;
diff --git a/ModerationClient/Services/StatusBarService.cs b/ModerationClient/Services/StatusBarService.cs
new file mode 100644
index 0000000..57aff21
--- /dev/null
+++ b/ModerationClient/Services/StatusBarService.cs
@@ -0,0 +1,19 @@
+using System;
+using ArcaneLibs;
+
+namespace ModerationClient.Services;
+
+public class StatusBarService : NotifyPropertyChanged {
+    private string _statusText = "Ready";
+    private bool _isBusy;
+
+    public string StatusText {
+        get => _statusText + " " + DateTime.Now.ToString("u")[..^1];
+        set => SetField(ref _statusText, value);
+    }
+
+    public bool IsBusy {
+        get => _isBusy;
+        set => SetField(ref _isBusy, value);
+    }
+}
\ No newline at end of file
diff --git a/ModerationClient/Services/TestRunner.cs b/ModerationClient/Services/TestRunner.cs
new file mode 100644
index 0000000..dbacf99
--- /dev/null
+++ b/ModerationClient/Services/TestRunner.cs
@@ -0,0 +1,36 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Hosting;
+
+namespace ModerationClient.Services;
+
+public class TestRunner(CommandLineConfiguration.TestConfig testConfig, MatrixAuthenticationService mas) : IHostedService {
+    public async Task StartAsync(CancellationToken cancellationToken) {
+        Console.WriteLine("TestRunner: Starting test runner");
+        mas.PropertyChanged += (_, args) => {
+            if (args.PropertyName == nameof(MatrixAuthenticationService.IsLoggedIn) && mas.IsLoggedIn) {
+                Console.WriteLine("TestRunner: Logged in, starting test");
+                _ = Run();
+            }
+        };
+    }
+
+    public async Task StopAsync(CancellationToken cancellationToken) {
+        Console.WriteLine("TestRunner: Stopping test runner");
+    }
+
+    private async Task Run() {
+        var hs = mas.Homeserver!;
+        Console.WriteLine("TestRunner: Running test on homeserver " + hs);
+        foreach (var mxid in testConfig.Mxids) {
+            var room = await hs.CreateRoom(new() {
+                Name = mxid,
+                Invite = testConfig.Mxids
+            });
+            
+            await room.SendMessageEventAsync(new("test"));
+
+        }
+    }
+}
\ No newline at end of file
diff --git a/ModerationClient/ViewModels/ClientViewModel.cs b/ModerationClient/ViewModels/ClientViewModel.cs
index 312b46a..fb3681e 100644
--- a/ModerationClient/ViewModels/ClientViewModel.cs
+++ b/ModerationClient/ViewModels/ClientViewModel.cs
@@ -1,17 +1,20 @@
-using System;
+using System;
 using System.Collections.Generic;
 using System.Collections.ObjectModel;
 using System.Collections.Specialized;
 using System.Diagnostics;
 using System.IO;
 using System.Linq;
+using System.Text.Json;
 using System.Threading.Tasks;
+using ArcaneLibs;
 using ArcaneLibs.Collections;
+using LibMatrix;
 using LibMatrix.EventTypes.Spec.State;
 using LibMatrix.Helpers;
 using LibMatrix.Responses;
-using MatrixUtils.Abstractions;
 using Microsoft.Extensions.Logging;
+using ModerationClient.Models.SpaceTreeNodes;
 using ModerationClient.Services;
 
 namespace ModerationClient.ViewModels;
@@ -22,7 +25,13 @@ public partial class ClientViewModel : ViewModelBase {
         _authService = authService;
         _cfg = cfg;
         DisplayedSpaces.Add(_allRoomsNode = new AllRoomsSpaceNode(this));
-        _ = Task.Run(Run);
+        DisplayedSpaces.Add(DirectMessages = new SpaceNode(false) { Name = "Direct messages" });
+        _ = Task.Run(Run).ContinueWith(x => {
+            if (x.IsFaulted) {
+                Status = "Critical error running client view model: " + x.Exception?.Message;
+                _logger.LogError(x.Exception, "Error running client view model.");
+            }
+        });
     }
 
     private readonly ILogger<ClientViewModel> _logger;
@@ -33,6 +42,9 @@ public partial class ClientViewModel : ViewModelBase {
     private string _status = "Loading...";
     public ObservableCollection<SpaceNode> DisplayedSpaces { get; } = [];
     public ObservableDictionary<string, RoomNode> AllRooms { get; } = new();
+    public SpaceNode DirectMessages { get; }
+
+    public bool Paused { get; set; } = false;
 
     public SpaceNode CurrentSpace {
         get => _currentSpace ?? _allRoomsNode;
@@ -45,25 +57,50 @@ public partial class ClientViewModel : ViewModelBase {
     }
 
     public async Task Run() {
-        Status = "Interrupted.";
-        return;
+        Console.WriteLine("Running client view model loop...");
+        ArgumentNullException.ThrowIfNull(_authService.Homeserver, nameof(_authService.Homeserver));
+        // var sh = new SyncStateResolver(_authService.Homeserver, _logger, storageProvider: new FileStorageProvider(Path.Combine(_cfg.ProfileDirectory, "syncCache")));
+        var store = new FileStorageProvider(Path.Combine(_cfg.ProfileDirectory, "syncCache"));
+        Console.WriteLine($"Sync store at {store.TargetPath}");
+
+        var sh = new SyncHelper(_authService.Homeserver, _logger, storageProvider: store) {
+            // MinimumDelay = TimeSpan.FromSeconds(1)
+        };
+        Console.WriteLine("Sync helper created.");
+
+        //optimise - we create a new scope here to make ssr go out of scope
+        // if((await sh.GetUnoptimisedStoreCount()) > 1000)
+        {
+            Console.WriteLine("RUN - Optimising sync store...");
+            Status = "Optimising sync store, please wait...";
+            var ssr = new SyncStateResolver(_authService.Homeserver, _logger, storageProvider: store);
+            Console.WriteLine("Created sync state resolver...");
+            Status = "Optimising sync store, please wait... Creating new snapshot...";
+            await ssr.OptimiseStore();
+            Status = "Optimising sync store, please wait... Deleting old intermediate snapshots...";
+            await ssr.RemoveOldSnapshots();
+        }
+
+        var unoptimised = await sh.GetUnoptimisedStoreCount(); // this is slow, so we cache
         Status = "Doing initial sync...";
-        var sh = new SyncStateResolver(_authService.Homeserver, _logger, storageProvider: new FileStorageProvider(Path.Combine(_cfg.ProfileDirectory, "syncCache")));
-        // var res = await sh.SyncAsync();
-        //await sh.OptimiseStore();
-        while (true) {
-            // Status = "Syncing...";
-            var res = await sh.ContinueAsync();
-            Status = $"Processing sync... {res.next.NextBatch}";
-            await ApplySpaceChanges(res.next);
-            //OnPropertyChanged(nameof(CurrentSpace));
-            //OnPropertyChanged(nameof(CurrentSpace.ChildRooms));
-            // Console.WriteLine($"mow A={AllRooms.Count}|D={DisplayedSpaces.Count}");
-            // for (int i = 0; i < GC.MaxGeneration; i++) {
-            // GC.Collect(i, GCCollectionMode.Forced, blocking: true);
-            // GC.WaitForPendingFinalizers();
-            // }
-            Status = "Syncing...";
+        await foreach (var res in sh.EnumerateSyncAsync()) {
+            Program.Beep((short)250, 0);
+            Status = $"Processing sync... {res.NextBatch}";
+            await ApplySyncChanges(res);
+
+            Program.Beep(0, 0);
+            if (Paused) {
+                Status = "Sync loop interrupted... Press pause/break to resume.";
+                while (Paused) await Task.Delay(1000);
+            }
+            else Status = $"Syncing... {unoptimised++} unoptimised sync responses...";
+        }
+    }
+
+    private async Task ApplySyncChanges(SyncResponse newSync) {
+        await ApplySpaceChanges(newSync);
+        if (newSync.AccountData?.Events?.FirstOrDefault(x => x.Type == "m.direct") is { } evt) {
+            await ApplyDirectMessagesChanges(evt);
         }
     }
 
@@ -71,20 +108,25 @@ public partial class ClientViewModel : ViewModelBase {
         List<Task> tasks = [];
         foreach (var room in newSync.Rooms?.Join ?? []) {
             if (!AllRooms.ContainsKey(room.Key)) {
-                AllRooms.Add(room.Key, new RoomNode { Name = "Loading..." });
+                // AllRooms.Add(room.Key, new RoomNode { Name = "Loading..." });
+                AllRooms.Add(room.Key, new RoomNode { Name = "", RoomID = room.Key });
             }
 
             if (room.Value.State?.Events is not null) {
                 var nameEvent = room.Value.State!.Events!.FirstOrDefault(x => x.Type == "m.room.name" && x.StateKey == "");
-                AllRooms[room.Key].Name = (nameEvent?.TypedContent as RoomNameEventContent)?.Name ?? "";
-                if (string.IsNullOrWhiteSpace(AllRooms[room.Key].Name)) {
-                    AllRooms[room.Key].Name = "Loading...";
-                    tasks.Add(_authService.Homeserver!.GetRoom(room.Key).GetNameOrFallbackAsync().ContinueWith(r => AllRooms[room.Key].Name = r.Result));
-                }
+                if (nameEvent is not null)
+                    AllRooms[room.Key].Name = (nameEvent?.TypedContent as RoomNameEventContent)?.Name ?? "";
+            }
+
+            if (string.IsNullOrWhiteSpace(AllRooms[room.Key].Name)) {
+                AllRooms[room.Key].Name = "Loading...";
+                tasks.Add(_authService.Homeserver!.GetRoom(room.Key).GetNameOrFallbackAsync().ContinueWith(r => AllRooms[room.Key].Name = r.Result));
+                // Status = $"Getting room name for {room.Key}...";
+                // AllRooms[room.Key].Name = await _authService.Homeserver!.GetRoom(room.Key).GetNameOrFallbackAsync();
             }
         }
-        
-        await Task.WhenAll(tasks);
+
+        await AwaitTasks(tasks, "Waiting for {0}/{1} tasks while applying room changes...");
 
         return;
 
@@ -111,20 +153,59 @@ public partial class ClientViewModel : ViewModelBase {
             handledRoomIds.Add(roomId);
         }
     }
-}
 
-public class SpaceNode : RoomNode {
-    public ObservableCollection<SpaceNode> ChildSpaces { get; set; } = [];
-    public ObservableCollection<RoomNode> ChildRooms { get; set; } = [];
-}
+    private async Task ApplyDirectMessagesChanges(StateEventResponse evt) {
+        _logger.LogCritical("Direct messages updated!");
+        var dms = evt.RawContent.Deserialize<Dictionary<string, string[]?>>();
+        List<Task> tasks = [];
+        foreach (var (userId, roomIds) in dms) {
+            if (roomIds is null || roomIds.Length == 0) continue;
+            var space = DirectMessages.ChildSpaces.FirstOrDefault(x => x.RoomID == userId);
+            if (space is null) {
+                space = new SpaceNode { Name = userId, RoomID = userId };
+                tasks.Add(_authService.Homeserver!.GetProfileAsync(userId)
+                    .ContinueWith(r => space.Name = string.IsNullOrWhiteSpace(r.Result?.DisplayName) ? userId : r.Result.DisplayName));
+                DirectMessages.ChildSpaces.Add(space);
+            }
+
+            foreach (var roomId in roomIds) {
+                var room = space.ChildRooms.FirstOrDefault(x => x.RoomID == roomId);
+                if (room is null) {
+                    room = AllRooms.TryGetValue(roomId, out var existing) ? existing : new RoomNode { Name = "Unknown: " + roomId, RoomID = roomId };
+                    space.ChildRooms.Add(room);
+                }
+            }
 
-public class RoomNode {
-    public string Name { get; set; }
+            foreach (var spaceChildRoom in space.ChildRooms.ToList()) {
+                if (!roomIds.Contains(spaceChildRoom.RoomID)) {
+                    space.ChildRooms.Remove(spaceChildRoom);
+                }
+            }
+        }
+
+        await AwaitTasks(tasks, "Waiting for {0}/{1} tasks while applying DM changes...");
+    }
+
+    private async Task AwaitTasks(List<Task> tasks, string message) {
+        if (tasks.Count > 0) {
+            int total = tasks.Count;
+            while (tasks.Any(x => !x.IsCompleted)) {
+                int incomplete = tasks.Count(x => !x.IsCompleted);
+                Program.Beep((short)MathUtil.Map(incomplete, 0, total, 20, 7500), 5);
+                // Program.Beep(0, 0);
+                Status = string.Format(message, incomplete, total);
+                await Task.WhenAny(tasks);
+                tasks.RemoveAll(x => x.IsCompleted);
+            }
+
+            Program.Beep(0, 0);
+        }
+    }
 }
 
 // implementation details
 public class AllRoomsSpaceNode : SpaceNode {
-    public AllRoomsSpaceNode(ClientViewModel vm) {
+    public AllRoomsSpaceNode(ClientViewModel vm) : base(false) {
         Name = "All rooms";
         vm.AllRooms.CollectionChanged += (_, args) => {
             switch (args.Action) {
diff --git a/ModerationClient/ViewModels/MainWindowViewModel.cs b/ModerationClient/ViewModels/MainWindowViewModel.cs
index be64de4..5cd5c45 100644
--- a/ModerationClient/ViewModels/MainWindowViewModel.cs
+++ b/ModerationClient/ViewModels/MainWindowViewModel.cs
@@ -1,7 +1,6 @@
 using System;
 using Avalonia;
 using ModerationClient.Services;
-using ModerationClient.Views;
 
 namespace ModerationClient.ViewModels;
 
@@ -9,10 +8,10 @@ public partial class MainWindowViewModel(MatrixAuthenticationService authService
     // public MainWindow? MainWindow { get; set; }
 
     private float _scale = 1.0f;
-    private ViewModelBase _currentViewModel = new LoginViewModel(authService);
+    private ViewModelBase? _currentViewModel = null;
     private Size _physicalSize = new Size(300, 220);
 
-    public ViewModelBase CurrentViewModel {
+    public ViewModelBase? CurrentViewModel {
         get => _currentViewModel;
         set => SetProperty(ref _currentViewModel, value);
     }
diff --git a/ModerationClient/ViewModels/UserManagement/UserManagementViewModel.cs b/ModerationClient/ViewModels/UserManagement/UserManagementViewModel.cs
index 7a2ad63..90020d6 100644
--- a/ModerationClient/ViewModels/UserManagement/UserManagementViewModel.cs
+++ b/ModerationClient/ViewModels/UserManagement/UserManagementViewModel.cs
@@ -1,20 +1,10 @@
 using System;
-using System.Collections.Generic;
 using System.Collections.ObjectModel;
-using System.Collections.Specialized;
-using System.Diagnostics;
-using System.IO;
-using System.Linq;
 using System.Text.Json;
 using System.Threading.Tasks;
-using ArcaneLibs.Collections;
 using ArcaneLibs.Extensions;
-using LibMatrix.EventTypes.Spec.State;
-using LibMatrix.Helpers;
 using LibMatrix.Homeservers;
 using LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses;
-using LibMatrix.Responses;
-using MatrixUtils.Abstractions;
 using Microsoft.Extensions.Logging;
 using ModerationClient.Services;
 
@@ -51,6 +41,7 @@ public partial class UserManagementViewModel : ViewModelBase {
         }
 
         await foreach (var user in synapse.Admin.SearchUsersAsync(chunkLimit: 100)) {
+            Program.Beep(250, 1);
             Console.WriteLine("USERMANAGER GOT USER: " + user.ToJson(indent:false, ignoreNull: true));
             Users.Add(JsonSerializer.Deserialize<User>(user.ToJson())!);
         }
@@ -58,6 +49,6 @@ public partial class UserManagementViewModel : ViewModelBase {
     }
 }
 
-public class User : AdminUserListResult.AdminUserListResultUser {
+public class User : SynapseAdminUserListResult.SynapseAdminUserListResultUser {
     
 }
\ No newline at end of file
diff --git a/ModerationClient/Views/MainWindow/ClientView.axaml b/ModerationClient/Views/MainWindow/ClientView.axaml
index ba030e4..e0cd4e0 100644
--- a/ModerationClient/Views/MainWindow/ClientView.axaml
+++ b/ModerationClient/Views/MainWindow/ClientView.axaml
@@ -4,36 +4,42 @@
              xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
              xmlns:views="clr-namespace:ModerationClient.Views"
              xmlns:viewModels="clr-namespace:ModerationClient.ViewModels"
+             xmlns:spaceTreeNodes="clr-namespace:ModerationClient.Models.SpaceTreeNodes"
              mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
              x:Class="ModerationClient.Views.ClientView"
              x:DataType="viewModels:ClientViewModel">
     <Grid Width="{Binding $parent.Width}" Height="{Binding $parent.Height}" RowDefinitions="*, Auto">
         <Grid Grid.Row="0">
             <Grid.ColumnDefinitions>
-                <ColumnDefinition Width="128" MinWidth="16" />
+                <ColumnDefinition Width="256" MinWidth="16" />
                 <ColumnDefinition Width="1" />
-                <ColumnDefinition Width="128" MinWidth="16" />
+                <ColumnDefinition Width="256" MinWidth="16" />
                 <ColumnDefinition Width="1" />
                 <ColumnDefinition Width="*" MinWidth="16" />
             </Grid.ColumnDefinitions>
-            <TreeView Grid.Column="0" Background="Red" ItemsSource="{CompiledBinding DisplayedSpaces}" SelectedItem="{CompiledBinding CurrentSpace}">
+            <TreeView Grid.Column="0" Background="#202020" ItemsSource="{CompiledBinding DisplayedSpaces}" SelectedItem="{CompiledBinding CurrentSpace}">
                 <TreeView.ItemTemplate>
-                    <TreeDataTemplate ItemsSource="{Binding ChildSpaces}">
+                    <!-- <TreeView.Styles> -->
+                    <!--     <Style Selector="TreeViewItem"> -->
+                    <!--         <Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}" /> -->
+                    <!--     </Style> -->
+                    <!-- </TreeView.Styles> -->
+                    <TreeDataTemplate ItemsSource="{Binding ChildSpaces}" DataType="spaceTreeNodes:SpaceNode">
                         <TextBlock Text="{Binding Name}" Height="20" />
                     </TreeDataTemplate>
                 </TreeView.ItemTemplate>
             </TreeView>
             <GridSplitter Grid.Column="1" Background="Black" ResizeDirection="Columns" />
             <!-- <Rectangle Grid.Column="2" Fill="Green" /> -->
-            <ListBox Grid.Column="2" Background="Green" ItemsSource="{CompiledBinding CurrentSpace.ChildRooms}">
+            <ListBox Grid.Column="2" Background="#242424" ItemsSource="{CompiledBinding CurrentSpace.ChildRooms}">
                 <ListBox.ItemTemplate>
-                    <DataTemplate DataType="viewModels:RoomNode">
+                    <DataTemplate DataType="spaceTreeNodes:RoomNode">
                         <TextBlock Text="{CompiledBinding Name}" Height="20" />
                     </DataTemplate>
                 </ListBox.ItemTemplate>
             </ListBox>
             <GridSplitter Grid.Column="3" Background="Black" ResizeDirection="Columns" />
-            <Rectangle Grid.Column="4" Fill="Blue" />
+            <Rectangle Grid.Column="4" Fill="#282828" />
         </Grid>
         <Grid Grid.Row="1" ColumnDefinitions="Auto, *, Auto" Background="Black">
             <Label Grid.Column="2" Content="{CompiledBinding Status}" />
diff --git a/ModerationClient/Views/MainWindow/ClientView.axaml.cs b/ModerationClient/Views/MainWindow/ClientView.axaml.cs
index 894e807..5b0f62d 100644
--- a/ModerationClient/Views/MainWindow/ClientView.axaml.cs
+++ b/ModerationClient/Views/MainWindow/ClientView.axaml.cs
@@ -1,5 +1,4 @@
 using Avalonia.Controls;
-using Avalonia.Markup.Xaml;
 
 namespace ModerationClient.Views;
 
diff --git a/ModerationClient/Views/MainWindow/LoginView.axaml.cs b/ModerationClient/Views/MainWindow/LoginView.axaml.cs
index 5e84ace..ea2f59d 100644
--- a/ModerationClient/Views/MainWindow/LoginView.axaml.cs
+++ b/ModerationClient/Views/MainWindow/LoginView.axaml.cs
@@ -1,12 +1,12 @@
 using System;
 using Avalonia.Controls;
 using Avalonia.Interactivity;
-using Avalonia.Markup.Xaml;
 using ModerationClient.ViewModels;
 
 namespace ModerationClient.Views;
 
 public partial class LoginView : UserControl {
+    private LoginViewModel? ViewModel => DataContext as LoginViewModel;
     public LoginView() {
         InitializeComponent();
     }
diff --git a/ModerationClient/Views/MainWindow/MainWindow.axaml b/ModerationClient/Views/MainWindow/MainWindow.axaml
index ef13553..c45b296 100644
--- a/ModerationClient/Views/MainWindow/MainWindow.axaml
+++ b/ModerationClient/Views/MainWindow/MainWindow.axaml
@@ -17,23 +17,28 @@
     <!-- </Design.DataContext> -->
 
     <Grid RowDefinitions="Auto, *">
-        <Grid Grid.Row="0" ColumnDefinitions="Auto, *, Auto" x:Name="TopPanel">
-            <StackPanel Orientation="Horizontal" Grid.Column="0">
-                <Label Content="{CompiledBinding Scale}" />
-                <Label>x</Label>
-                <Rectangle Width="32" />
-                <Label Content="{CompiledBinding ChildTargetWidth}" />
-                <Label>x</Label>
-                <Label Content="{CompiledBinding ChildTargetHeight}" />
-            </StackPanel>
-            <Label Grid.Column="2">Press '?' for keybinds</Label>
-        </Grid>
+
         <Viewbox Grid.Row="1">
-            <ContentControl
-                Width="{CompiledBinding ChildTargetWidth}"
-                Background="#222222"
-                Height="{CompiledBinding ChildTargetHeight}"
-                Content="{CompiledBinding CurrentViewModel}" />
+            <Grid RowDefinitions="Auto, *"
+                  Background="#202020"
+                  Width="{CompiledBinding ChildTargetWidth}"
+                  Height="{CompiledBinding ChildTargetHeight}">
+                <Grid Grid.Row="0" ColumnDefinitions="Auto, *, Auto" x:Name="TopPanel" Background="#000000">
+                    <StackPanel Orientation="Horizontal" Grid.Column="0">
+                        <Label>[F1 -]</Label>
+                        <Label Content="{CompiledBinding Scale}" />
+                        <Label>x</Label>
+                        <Label>[+ F2]</Label>
+                        <Rectangle Width="32" />
+                        <Label>VRes =</Label>
+                        <Label Content="{CompiledBinding ChildTargetWidth}" />
+                        <Label>x</Label>
+                        <Label Content="{CompiledBinding ChildTargetHeight}" />
+                    </StackPanel>
+                    <Label Grid.Column="2">Press '?' for keybinds</Label>
+                </Grid>
+                <ContentControl Grid.Row="1" Content="{CompiledBinding CurrentViewModel}" />
+            </Grid>
         </Viewbox>
     </Grid>
 </Window>
\ No newline at end of file
diff --git a/ModerationClient/Views/MainWindow/MainWindow.axaml.cs b/ModerationClient/Views/MainWindow/MainWindow.axaml.cs
index 01027c1..cc3534d 100644
--- a/ModerationClient/Views/MainWindow/MainWindow.axaml.cs
+++ b/ModerationClient/Views/MainWindow/MainWindow.axaml.cs
@@ -1,23 +1,55 @@
 using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using ArcaneLibs.Extensions;
 using Avalonia;
 using Avalonia.Controls;
-using Avalonia.Diagnostics;
 using Avalonia.Input;
+using LibMatrix.Responses;
+using LibMatrix.Services;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Hosting;
 using ModerationClient.Services;
 using ModerationClient.ViewModels;
 
+#if DEBUG
+using Avalonia.Diagnostics;
+#endif
+
 namespace ModerationClient.Views.MainWindow;
 
 public partial class MainWindow : Window {
+    private readonly CommandLineConfiguration _cfg;
+
     public MainWindow(CommandLineConfiguration cfg, MainWindowViewModel dataContext, IHostApplicationLifetime appLifetime) {
+        _cfg = cfg;
         InitializeComponent();
         DataContext = dataContext;
         _ = dataContext.AuthService.LoadProfileAsync().ContinueWith(x => {
             if (x.IsFaulted) {
                 Console.WriteLine("Failed to load profile: " + x.Exception);
             }
+
+            dataContext.CurrentViewModel = dataContext.AuthService.IsLoggedIn
+                ? App.Current.Host.Services.GetRequiredService<ClientViewModel>()
+                : new LoginViewModel(dataContext.AuthService);
+
+            if (!dataContext.AuthService.IsLoggedIn) {
+                dataContext.AuthService.PropertyChanged += (sender, args) => {
+                    if (args.PropertyName == nameof(MatrixAuthenticationService.IsLoggedIn)) {
+                        if (dataContext.AuthService.IsLoggedIn) {
+                            // dataContext.CurrentViewModel = new ClientViewModel(dataContext.AuthService);
+                            dataContext.CurrentViewModel = App.Current.Host.Services.GetRequiredService<ClientViewModel>();
+                            // var window = App.Current.Host.Services.GetRequiredService<UserManagementWindow>();
+                            // window.Show();
+                        }
+                        else {
+                            dataContext.CurrentViewModel = new LoginViewModel(dataContext.AuthService);
+                        }
+                    }
+                };
+            }
         });
         Console.WriteLine("mainwnd");
 #if DEBUG
@@ -35,36 +67,23 @@ public partial class MainWindow : Window {
                         return;
                     }
 
-                    viewModel.PhysicalSize = new Size(ClientSize.Width, ClientSize.Height - TopPanel.Bounds.Height);
+                    // viewModel.PhysicalSize = new Size(ClientSize.Width, ClientSize.Height - TopPanel.Bounds.Height);
+                    viewModel.PhysicalSize = new Size(ClientSize.Width, ClientSize.Height);
                     break;
                 }
             }
         };
 
-        TopPanel.PropertyChanged += (_, args) => {
-            if (args.Property.Name == nameof(Visual.Bounds)) {
-                if (DataContext is not MainWindowViewModel viewModel) {
-                    Console.WriteLine("WARN: MainWindowViewModel is null, ignoring TopPanel.Bounds change!");
-                    return;
-                }
-
-                viewModel.PhysicalSize = new Size(ClientSize.Width, ClientSize.Height - TopPanel.Bounds.Height);
-            }
-        };
-
-        dataContext.AuthService.PropertyChanged += (sender, args) => {
-            if (args.PropertyName == nameof(MatrixAuthenticationService.IsLoggedIn)) {
-                if (dataContext.AuthService.IsLoggedIn) {
-                    // dataContext.CurrentViewModel = new ClientViewModel(dataContext.AuthService);
-                    dataContext.CurrentViewModel = App.Current.Host.Services.GetRequiredService<ClientViewModel>();
-                    var window = App.Current.Host.Services.GetRequiredService<UserManagementWindow>();
-                    window.Show();
-                }
-                else {
-                    dataContext.CurrentViewModel = new LoginViewModel(dataContext.AuthService);
-                }
-            }
-        };
+        // TopPanel.PropertyChanged += (_, args) => {
+        //     if (args.Property.Name == nameof(Visual.Bounds)) {
+        //         if (DataContext is not MainWindowViewModel viewModel) {
+        //             Console.WriteLine("WARN: MainWindowViewModel is null, ignoring TopPanel.Bounds change!");
+        //             return;
+        //         }
+        //
+        //         viewModel.PhysicalSize = new Size(ClientSize.Width, ClientSize.Height - TopPanel.Bounds.Height);
+        //     }
+        // };
 
         dataContext.Scale = cfg.Scale;
         Width *= cfg.Scale;
@@ -76,9 +95,13 @@ public partial class MainWindow : Window {
         });
     }
 
-    protected override void OnKeyDown(KeyEventArgs e) => OnKeyDown(this, e);
+    protected override void OnKeyDown(KeyEventArgs e) => OnKeyDown(this, e).ContinueWith(t => {
+        if (t.IsFaulted) {
+            Console.WriteLine("OnKeyDown faulted: " + t.Exception);
+        }
+    });
 
-    private void OnKeyDown(object? _, KeyEventArgs e) {
+    private async Task OnKeyDown(object? _, KeyEventArgs e) {
         if (DataContext is not MainWindowViewModel viewModel) {
             Console.WriteLine($"WARN: DataContext is {DataContext?.GetType().Name ?? "null"}, ignoring key press!");
             return;
@@ -100,6 +123,11 @@ public partial class MainWindow : Window {
                 viewModel.Scale = 5.0f;
             }
         }
+        else if (e.Key == Key.Pause) {
+            if (viewModel.CurrentViewModel is ClientViewModel clientViewModel) {
+                clientViewModel.Paused = !clientViewModel.Paused;
+            }
+        }
         else if (e.KeyModifiers == KeyModifiers.Control) {
             if (e.Key == Key.K) {
                 if (viewModel.CurrentViewModel is ClientViewModel clientViewModel) {
@@ -107,7 +135,7 @@ public partial class MainWindow : Window {
                 }
                 else Console.WriteLine("WARN: CurrentViewModel is not ClientViewModel, ignoring Quick Switcher");
             }
-            else if (e.Key == Key.U ) {
+            else if (e.Key == Key.U) {
                 Console.WriteLine("UserManagementWindow invoked");
                 var window = App.Current.Host.Services.GetRequiredService<UserManagementWindow>();
                 window.Show();
@@ -116,8 +144,31 @@ public partial class MainWindow : Window {
                 Console.WriteLine("Launching new process");
                 System.Diagnostics.Process.Start(System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName, Environment.GetCommandLineArgs());
             }
-            else if (e.Key == Key.F9) {
-                
+            else if (e.Key == Key.F9) { }
+            else if (e.Key == Key.D) {
+                List<LoginResponse> mxids = new();
+                var hsps = App.Current.Services.GetRequiredService<HomeserverProviderService>();
+                var rhs = await hsps.GetRemoteHomeserver("matrixunittests.rory.gay", enableServer: false);
+                for (int i = 0; i < 64; i++) {
+                    Console.WriteLine("Debugging invoked");
+                    var main = await rhs.RegisterAsync(Guid.NewGuid().ToString(), "password");
+                    mxids.Add(main);
+                    Console.WriteLine($"Registered: {main.UserId} {main.AccessToken} - {mxids.Count}");
+                }
+
+                foreach (var mxid in mxids) {
+                    Console.WriteLine("Launching new process: ");
+                    var args = (_cfg with {
+                        Profile = "mut-" + mxid.UserId,
+                        ProfileDirectory = null,
+                        LoginData = mxid.ToJson(),
+                        TestConfiguration = new() {
+                            Mxids = mxids.Select(x => x.UserId).ToList()
+                        }
+                    }).Serialise();
+                    Console.WriteLine(string.Join(' ', args));
+                    System.Diagnostics.Process.Start(System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName, args);
+                }
             }
         }
     }
diff --git a/Test/Program.cs b/Test/Program.cs
new file mode 100644
index 0000000..2739294
--- /dev/null
+++ b/Test/Program.cs
@@ -0,0 +1,21 @@
+using System.Text.Json;

+using LibMatrix;

+using LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses;

+

+// var src1 = new SynapseCollectionResult<EventIdResponse>();

+// var src2 = new SynapseCollectionResult<EventIdResponse>(chunkKey: "meow", nextTokenKey: "woof", prevTokenKey: "bark", totalKey: "purr");

+//

+// for (int i = 0; i < 10000000; i++) {

+//     src1.Chunk.Add(new EventIdResponse { EventId = Guid.NewGuid().ToString() });

+//     src2.Chunk.Add(new EventIdResponse { EventId = Guid.NewGuid().ToString() });

+// }

+//

+// File.WriteAllText("src1.json", JsonSerializer.Serialize(src1, new JsonSerializerOptions(){WriteIndented = true}));

+// File.WriteAllText("src2.json", JsonSerializer.Serialize(src2, new JsonSerializerOptions(){WriteIndented = true}));

+

+using var stream1 = File.OpenRead("src1.json");

+var dst1 = new SynapseCollectionResult<EventIdResponse>().FromJson(stream1, (item) => {

+    ArgumentNullException.ThrowIfNull(item.EventId);

+});

+

+var a = new StateEventResponse();
\ No newline at end of file
diff --git a/Test/Test.csproj b/Test/Test.csproj
new file mode 100644
index 0000000..39961c1
--- /dev/null
+++ b/Test/Test.csproj
@@ -0,0 +1,18 @@
+<Project Sdk="Microsoft.NET.Sdk">

+

+  <PropertyGroup>

+    <OutputType>Exe</OutputType>

+    <TargetFramework>net8.0</TargetFramework>

+    <ImplicitUsings>enable</ImplicitUsings>

+    <Nullable>enable</Nullable>

+  </PropertyGroup>

+

+  <ItemGroup>

+    <ProjectReference Include="..\LibMatrix\LibMatrix\LibMatrix.csproj" />

+  </ItemGroup>

+

+  <ItemGroup>

+    <PackageReference Include="BenchmarkDotNet" Version="0.14.0" />

+  </ItemGroup>

+

+</Project>