about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.gitignore21
-rw-r--r--.idea/.idea.ModerationClient/.idea/avalonia.xml2
-rw-r--r--.run/local/.gitkeep0
m---------LibMatrix0
-rw-r--r--ModerationClient.sln6
-rw-r--r--ModerationClient/App.axaml.cs33
-rw-r--r--ModerationClient/ModerationClient.csproj14
-rw-r--r--ModerationClient/Services/CommandLineConfiguration.cs83
-rw-r--r--ModerationClient/ViewModels/ClientViewModel.cs14
-rw-r--r--ModerationClient/ViewModels/MainWindowViewModel.cs28
-rw-r--r--ModerationClient/ViewModels/UserManagement/UserManagementViewModel.cs63
-rw-r--r--ModerationClient/Views/MainWindow/ClientView.axaml (renamed from ModerationClient/Views/ClientView.axaml)10
-rw-r--r--ModerationClient/Views/MainWindow/ClientView.axaml.cs (renamed from ModerationClient/Views/ClientView.axaml.cs)0
-rw-r--r--ModerationClient/Views/MainWindow/LoginView.axaml (renamed from ModerationClient/Views/LoginView.axaml)2
-rw-r--r--ModerationClient/Views/MainWindow/LoginView.axaml.cs (renamed from ModerationClient/Views/LoginView.axaml.cs)0
-rw-r--r--ModerationClient/Views/MainWindow/MainWindow.axaml (renamed from ModerationClient/Views/MainWindow.axaml)25
-rw-r--r--ModerationClient/Views/MainWindow/MainWindow.axaml.cs (renamed from ModerationClient/Views/MainWindow.axaml.cs)54
-rw-r--r--ModerationClient/Views/UserManagementWindow.axaml59
-rw-r--r--ModerationClient/Views/UserManagementWindow.axaml.cs136
19 files changed, 441 insertions, 109 deletions
diff --git a/.gitignore b/.gitignore
index 984ec54..a6807bd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,19 +1,12 @@
+# Dotnet build outputs
 **/bin/
 **/obj/
-MatrixRoomUtils/
-MatrixRoomUtils.Web/wwwroot/MRU.tar.xz
-/src/
-*.tar.xz
-matrix-sync.json
-/patches/
-MatrixRoomUtils.Bot/bot_data/
-appsettings.Local*.json
-nixpkgs/
+
+# User files
 *.DotSettings.user
 *.patch
+/patches/
 
-test.tsv
-test-proxy.tsv
-homeservers.txt
-LoginPayload.txt
-LoginPayload.txt.old
+# Local files:
+appsettings.Local*.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 203dd5a..6bc9c45 100644
--- a/.idea/.idea.ModerationClient/.idea/avalonia.xml
+++ b/.idea/.idea.ModerationClient/.idea/avalonia.xml
@@ -8,6 +8,8 @@
         <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/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" />
       </map>
     </option>
diff --git a/.run/local/.gitkeep b/.run/local/.gitkeep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/.run/local/.gitkeep
diff --git a/LibMatrix b/LibMatrix
-Subproject 3b488242050bbc0521d846bd31cb6ea59b8d4e3
+Subproject bba7333ee6581a92bbbc7479d72325e704fe7fa
diff --git a/ModerationClient.sln b/ModerationClient.sln
index c04d45e..c23825d 100644
--- a/ModerationClient.sln
+++ b/ModerationClient.sln
@@ -1,6 +1,6 @@
 
 Microsoft Visual Studio Solution File, Format Version 12.00
-# 
+#
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ModerationClient", "ModerationClient\ModerationClient.csproj", "{18FFCA62-0CA3-4B24-8708-8EE5C5BBDE41}"
 EndProject
 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "LibMatrix", "LibMatrix", "{C5771E60-0DA8-42AC-B54B-94C91B5B719B}"
@@ -153,4 +153,8 @@ Global
 		{F9BA709C-DA10-4416-A07D-C89293FB0D24} = {D7A5A7D3-4D9E-4B15-9C48-5E75A91483CF}
 		{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
+	EndGlobalSection
 EndGlobal
diff --git a/ModerationClient/App.axaml.cs b/ModerationClient/App.axaml.cs
index db584de..c44b5a2 100644
--- a/ModerationClient/App.axaml.cs
+++ b/ModerationClient/App.axaml.cs
@@ -2,7 +2,6 @@ using System;
 using System.IO;
 using Avalonia;
 using Avalonia.Controls.ApplicationLifetimes;
-using Avalonia.Data.Core;
 using Avalonia.Data.Core.Plugins;
 using Avalonia.Markup.Xaml;
 using LibMatrix.Services;
@@ -13,18 +12,12 @@ using Microsoft.Extensions.Hosting;
 using ModerationClient.Services;
 using ModerationClient.ViewModels;
 using ModerationClient.Views;
+using ModerationClient.Views.MainWindow;
 
 namespace ModerationClient;
 
 public partial class App : Application {
-    /// <summary>
-    /// Gets the current <see cref="App"/> instance in use
-    /// </summary>
-    public new static App Current => (App)Application.Current;
-
-    /// <summary>
-    /// Gets the <see cref="IServiceProvider"/> instance to resolve application services.
-    /// </summary>
+    public new static App Current => Application.Current as App ?? throw new InvalidOperationException("Application.Current is null");
     public IServiceProvider Services => Host.Services;
 
     public IHost Host { get; private set; }
@@ -38,16 +31,10 @@ public partial class App : Application {
         var builder = Microsoft.Extensions.Hosting.Host.CreateApplicationBuilder(Environment.GetCommandLineArgs());
         builder.Services.AddTransient<MainWindowViewModel>();
         ConfigureServices(builder.Services);
-        // builder.Services.AddHostedService<HostedBackgroundService>();
 
         Host = builder.Build();
         if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) {
-            // Line below is needed to remove Avalonia data validation.
-            // Without this line you will get duplicate validations from both Avalonia and CT
             BindingPlugins.DataValidators.RemoveAt(0);
-            // desktop.MainWindow = new MainWindow {
-                // DataContext = Host.Services.GetRequiredService<MainWindowViewModel>()
-            // };
             desktop.MainWindow = Host.Services.GetRequiredService<MainWindow>();
             desktop.Exit += (sender, args) => {
                 Host.StopAsync(TimeSpan.FromSeconds(5)).GetAwaiter().GetResult();
@@ -59,19 +46,16 @@ public partial class App : Application {
         await Host.StartAsync();
     }
 
-    /// <summary>
-    /// Configures the services for the application.
-    /// </summary>
     private static IServiceProvider ConfigureServices(IServiceCollection services) {
         services.AddRoryLibMatrixServices(new() {
             AppName = "ModerationClient",
         });
-        services.AddSingleton<CommandLineConfiguration>();
+        services.AddSingleton<CommandLineConfiguration>(CommandLineConfiguration.FromProcessArgs());
         services.AddSingleton<MatrixAuthenticationService>();
         services.AddSingleton<ModerationClientConfiguration>();
 
-        services.AddSingleton<TieredStorageService>(x => {
-                var cmdLine = x.GetRequiredService<CommandLineConfiguration>();
+        services.AddSingleton<TieredStorageService>(s => {
+                var cmdLine = s.GetRequiredService<CommandLineConfiguration>();
                 return new TieredStorageService(
                     cacheStorageProvider: new FileStorageProvider(Directory.CreateTempSubdirectory($"modcli-{cmdLine.Profile}").FullName),
                     dataStorageProvider: new FileStorageProvider(Directory.CreateTempSubdirectory($"modcli-{cmdLine.Profile}").FullName)
@@ -79,12 +63,17 @@ public partial class App : Application {
             }
         );
 
-        // Register views
+        // Register windows
         services.AddSingleton<MainWindow>();
+        services.AddTransient<UserManagementWindow>();
+        
+        // Register views
         services.AddTransient<LoginView>();
         services.AddTransient<ClientView>();
+        
         // Register ViewModels
         services.AddTransient<ClientViewModel>();
+        services.AddTransient<UserManagementViewModel>();
 
         return services.BuildServiceProvider();
     }
diff --git a/ModerationClient/ModerationClient.csproj b/ModerationClient/ModerationClient.csproj
index 84adbc3..9876af9 100644
--- a/ModerationClient/ModerationClient.csproj
+++ b/ModerationClient/ModerationClient.csproj
@@ -37,14 +37,22 @@
     </ItemGroup>
 
     <ItemGroup>
-      <Compile Update="Views\LoginView.axaml.cs">
-        <DependentUpon>LoginWindow.axaml</DependentUpon>
+      <Compile Update="Views\UserManagementWindow.axaml.cs">
+        <DependentUpon>UserManagementWindow.axaml</DependentUpon>
         <SubType>Code</SubType>
       </Compile>
-      <Compile Update="Views\ClientView.axaml.cs">
+      <Compile Update="Views\MainWindow\MainWindow.axaml.cs">
+        <DependentUpon>MainWindow.axaml</DependentUpon>
+        <SubType>Code</SubType>
+      </Compile>
+      <Compile Update="Views\MainWindow\ClientView.axaml.cs">
         <DependentUpon>ClientView.axaml</DependentUpon>
         <SubType>Code</SubType>
       </Compile>
+      <Compile Update="Views\MainWindow\LoginView.axaml.cs">
+        <DependentUpon>LoginView.axaml</DependentUpon>
+        <SubType>Code</SubType>
+      </Compile>
     </ItemGroup>
 
     <ItemGroup>
diff --git a/ModerationClient/Services/CommandLineConfiguration.cs b/ModerationClient/Services/CommandLineConfiguration.cs
index 4debd5c..63c3691 100644
--- a/ModerationClient/Services/CommandLineConfiguration.cs
+++ b/ModerationClient/Services/CommandLineConfiguration.cs
@@ -1,4 +1,5 @@
 using System;
+using System.Collections.Generic;
 using System.Diagnostics.CodeAnalysis;
 using System.IO;
 using ArcaneLibs;
@@ -6,47 +7,75 @@ using Microsoft.Extensions.Logging;
 
 namespace ModerationClient.Services;
 
-public class CommandLineConfiguration {
-    public CommandLineConfiguration(ILogger<CommandLineConfiguration> logger) {
-        var args = Environment.GetCommandLineArgs();
-        logger.LogInformation("Command line arguments: " + string.Join(", ", args));
+[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());
+
+        if (string.IsNullOrWhiteSpace(cfg.ProfileDirectory))
+            cfg = cfg with {
+                ProfileDirectory = cfg.IsTemporary
+                    ? Directory.CreateTempSubdirectory("ModerationClient-tmp").FullName
+                    : Util.ExpandPath($"$HOME/.local/share/ModerationClient/{cfg.Profile}")
+            };
+
+        // logger.LogInformation("Profile directory: " + cfg.ProfileDirectory);
+        Directory.CreateDirectory(cfg.ProfileDirectory);
+        if (!string.IsNullOrWhiteSpace(cfg.LoginData)) {
+            File.WriteAllText(Path.Combine(cfg.ProfileDirectory, "login.json"), cfg.LoginData);
+        }
+        return cfg;
+    }
+
+    public string[] Serialise() {
+        List<string> args = new();
+        if (Profile != "default") 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!]);
+        return args.ToArray();
+    }
+
+    public static CommandLineConfiguration FromSerialised(string[] args) {
+        CommandLineConfiguration cfg = new();
         for (var i = 0; i < args.Length; i++) {
-            logger.LogInformation("Processing argument: " + args[i]);
             switch (args[i]) {
                 case "--profile":
-                case "-p":
-                    if (args.Length <= i + 1 || args[i + 1].StartsWith("-")) {
-                        throw new ArgumentException("No profile specified");
-                    }
-
-                    Profile = args[++i];
-                    logger.LogInformation("Set profile to: " + Profile);
+                    cfg = cfg with { Profile = args[++i] };
                     break;
                 case "--temporary":
-                    IsTemporary = true;
-                    logger.LogInformation("Using temporary profile");
+                    cfg = cfg with { IsTemporary = true };
                     break;
                 case "--profile-dir":
-                    ProfileDirectory = args[++i];
+                    cfg = cfg with { ProfileDirectory = args[++i] };
                     break;
                 case "--scale":
-                    Scale = float.Parse(args[++i]);
+                    cfg = cfg with { Scale = float.Parse(args[++i]) };
+                    break;
+                case "--login-data":
+                    cfg = cfg with { LoginData = args[++i] };
                     break;
             }
         }
 
-        if (string.IsNullOrWhiteSpace(ProfileDirectory))
-            ProfileDirectory = IsTemporary
-                ? Directory.CreateTempSubdirectory("ModerationClient-tmp").FullName
-                : Util.ExpandPath($"$HOME/.local/share/ModerationClient/{Profile}");
-
-        logger.LogInformation("Profile directory: " + ProfileDirectory);
-        Directory.CreateDirectory(ProfileDirectory);
+        return cfg;
     }
 
-    public string Profile { get; private set; } = "default";
-    public bool IsTemporary { get; private set; }
+    public string Profile { get; init; } = "default";
+    public bool IsTemporary { get; init; }
 
-    public string ProfileDirectory { get; private set; }
-    public float Scale { get; private set; } = 1f;
+    public string ProfileDirectory { get; init; }
+    public float Scale { get; init; } = 1f;
+
+    public string? LoginData {
+        get => _loginData;
+        init {
+            Console.WriteLine("Setting login data: " + value);
+            _loginData = value;
+        }
+    }
 }
\ No newline at end of file
diff --git a/ModerationClient/ViewModels/ClientViewModel.cs b/ModerationClient/ViewModels/ClientViewModel.cs
index 1e287ec..312b46a 100644
--- a/ModerationClient/ViewModels/ClientViewModel.cs
+++ b/ModerationClient/ViewModels/ClientViewModel.cs
@@ -30,6 +30,7 @@ public partial class ClientViewModel : ViewModelBase {
     private readonly CommandLineConfiguration _cfg;
     private SpaceNode? _currentSpace;
     private readonly SpaceNode _allRoomsNode;
+    private string _status = "Loading...";
     public ObservableCollection<SpaceNode> DisplayedSpaces { get; } = [];
     public ObservableDictionary<string, RoomNode> AllRooms { get; } = new();
 
@@ -38,12 +39,22 @@ public partial class ClientViewModel : ViewModelBase {
         set => SetProperty(ref _currentSpace, value);
     }
 
+    public string Status {
+        get => _status + " " + DateTime.Now;
+        set => SetProperty(ref _status, value);
+    }
+
     public async Task Run() {
+        Status = "Interrupted.";
+        return;
+        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));
@@ -52,6 +63,7 @@ public partial class ClientViewModel : ViewModelBase {
             // GC.Collect(i, GCCollectionMode.Forced, blocking: true);
             // GC.WaitForPendingFinalizers();
             // }
+            Status = "Syncing...";
         }
     }
 
@@ -72,7 +84,7 @@ public partial class ClientViewModel : ViewModelBase {
             }
         }
         
-        // await Task.WhenAll(tasks);
+        await Task.WhenAll(tasks);
 
         return;
 
diff --git a/ModerationClient/ViewModels/MainWindowViewModel.cs b/ModerationClient/ViewModels/MainWindowViewModel.cs
index 01ec6d6..be64de4 100644
--- a/ModerationClient/ViewModels/MainWindowViewModel.cs
+++ b/ModerationClient/ViewModels/MainWindowViewModel.cs
@@ -1,14 +1,16 @@
 using System;
+using Avalonia;
 using ModerationClient.Services;
 using ModerationClient.Views;
 
 namespace ModerationClient.ViewModels;
 
 public partial class MainWindowViewModel(MatrixAuthenticationService authService, CommandLineConfiguration cfg) : ViewModelBase {
-    public MainWindow? MainWindow { get; set; }
-    
+    // public MainWindow? MainWindow { get; set; }
+
     private float _scale = 1.0f;
     private ViewModelBase _currentViewModel = new LoginViewModel(authService);
+    private Size _physicalSize = new Size(300, 220);
 
     public ViewModelBase CurrentViewModel {
         get => _currentViewModel;
@@ -21,13 +23,23 @@ public partial class MainWindowViewModel(MatrixAuthenticationService authService
     public float Scale {
         get => _scale;
         set {
-            SetProperty(ref _scale, (float)Math.Round(value, 2));
-            OnPropertyChanged(nameof(ChildTargetWidth));
-            OnPropertyChanged(nameof(ChildTargetHeight));
+            if (SetProperty(ref _scale, (float)Math.Round(value, 2))) {
+                OnPropertyChanged(nameof(ChildTargetWidth));
+                OnPropertyChanged(nameof(ChildTargetHeight));
+            }
         }
     }
-    public int ChildTargetWidth => (int)(MainWindow?.Width / Scale ?? 1);
-    public int ChildTargetHeight => (int)(MainWindow?.Height / Scale ?? 1);
 
-    
+    public int ChildTargetWidth => (int)(PhysicalSize.Width / Scale);
+    public int ChildTargetHeight => (int)(PhysicalSize.Height / Scale);
+
+    public Size PhysicalSize {
+        get => _physicalSize;
+        set {
+            if (SetProperty(ref _physicalSize, value)) {
+                OnPropertyChanged(nameof(ChildTargetWidth));
+                OnPropertyChanged(nameof(ChildTargetHeight));
+            }
+        }
+    }
 }
\ No newline at end of file
diff --git a/ModerationClient/ViewModels/UserManagement/UserManagementViewModel.cs b/ModerationClient/ViewModels/UserManagement/UserManagementViewModel.cs
new file mode 100644
index 0000000..7a2ad63
--- /dev/null
+++ b/ModerationClient/ViewModels/UserManagement/UserManagementViewModel.cs
@@ -0,0 +1,63 @@
+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;
+
+namespace ModerationClient.ViewModels;
+
+public partial class UserManagementViewModel : ViewModelBase {
+    public UserManagementViewModel(ILogger<UserManagementViewModel> logger, MatrixAuthenticationService authService, CommandLineConfiguration cfg) {
+        _logger = logger;
+        _authService = authService;
+        _cfg = cfg;
+        _ = Task.Run(Run).ContinueWith(x=>x.Exception?.Handle(y=> {
+            Console.WriteLine(y);
+            return true;
+        }));
+    }
+
+    private readonly ILogger<UserManagementViewModel> _logger;
+    private readonly MatrixAuthenticationService _authService;
+    private readonly CommandLineConfiguration _cfg;
+    private string _status = "Loading...";
+    public ObservableCollection<User> Users { get; set; } = [];
+
+    public string Status {
+        get => _status + " " + DateTime.Now;
+        set => SetProperty(ref _status, value);
+    }
+
+    public async Task Run() {
+        Users.Clear();
+        Status = "Doing initial sync...";
+        if (_authService.Homeserver is not AuthenticatedHomeserverSynapse synapse) {
+            Console.WriteLine("This client only supports Synapse homeservers.");
+            return;
+        }
+
+        await foreach (var user in synapse.Admin.SearchUsersAsync(chunkLimit: 100)) {
+            Console.WriteLine("USERMANAGER GOT USER: " + user.ToJson(indent:false, ignoreNull: true));
+            Users.Add(JsonSerializer.Deserialize<User>(user.ToJson())!);
+        }
+        Console.WriteLine("Done.");
+    }
+}
+
+public class User : AdminUserListResult.AdminUserListResultUser {
+    
+}
\ No newline at end of file
diff --git a/ModerationClient/Views/ClientView.axaml b/ModerationClient/Views/MainWindow/ClientView.axaml
index 0ed8021..ba030e4 100644
--- a/ModerationClient/Views/ClientView.axaml
+++ b/ModerationClient/Views/MainWindow/ClientView.axaml
@@ -7,7 +7,7 @@
              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="*,20">
+    <Grid Width="{Binding $parent.Width}" Height="{Binding $parent.Height}" RowDefinitions="*, Auto">
         <Grid Grid.Row="0">
             <Grid.ColumnDefinitions>
                 <ColumnDefinition Width="128" MinWidth="16" />
@@ -19,7 +19,7 @@
             <TreeView Grid.Column="0" Background="Red" ItemsSource="{CompiledBinding DisplayedSpaces}" SelectedItem="{CompiledBinding CurrentSpace}">
                 <TreeView.ItemTemplate>
                     <TreeDataTemplate ItemsSource="{Binding ChildSpaces}">
-                        <TextBlock Text="{Binding Name}" />
+                        <TextBlock Text="{Binding Name}" Height="20" />
                     </TreeDataTemplate>
                 </TreeView.ItemTemplate>
             </TreeView>
@@ -28,15 +28,15 @@
             <ListBox Grid.Column="2" Background="Green" ItemsSource="{CompiledBinding CurrentSpace.ChildRooms}">
                 <ListBox.ItemTemplate>
                     <DataTemplate DataType="viewModels:RoomNode">
-                        <Label Content="{CompiledBinding Name}" />
+                        <TextBlock Text="{CompiledBinding Name}" Height="20" />
                     </DataTemplate>
                 </ListBox.ItemTemplate>
             </ListBox>
             <GridSplitter Grid.Column="3" Background="Black" ResizeDirection="Columns" />
             <Rectangle Grid.Column="4" Fill="Blue" />
         </Grid>
-        <Grid Grid.Row="1" ColumnDefinitions="Auto, *, Auto">
-            <Label Grid.Column="2">Text here</Label>
+        <Grid Grid.Row="1" ColumnDefinitions="Auto, *, Auto" Background="Black">
+            <Label Grid.Column="2" Content="{CompiledBinding Status}" />
         </Grid>
     </Grid>
 </UserControl>
\ No newline at end of file
diff --git a/ModerationClient/Views/ClientView.axaml.cs b/ModerationClient/Views/MainWindow/ClientView.axaml.cs
index 894e807..894e807 100644
--- a/ModerationClient/Views/ClientView.axaml.cs
+++ b/ModerationClient/Views/MainWindow/ClientView.axaml.cs
diff --git a/ModerationClient/Views/LoginView.axaml b/ModerationClient/Views/MainWindow/LoginView.axaml
index 10e97c6..5dc6533 100644
--- a/ModerationClient/Views/LoginView.axaml
+++ b/ModerationClient/Views/MainWindow/LoginView.axaml
@@ -7,8 +7,6 @@
              mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
              x:Class="ModerationClient.Views.LoginView"
              x:DataType="viewModels:LoginViewModel">
-             <!-- DataContext="{Binding $self}"> -->
-             <!-- DataContext="{Binding $self}"> -->
     <StackPanel>
         <Label>Log in</Label>
         <StackPanel Orientation="Horizontal">
diff --git a/ModerationClient/Views/LoginView.axaml.cs b/ModerationClient/Views/MainWindow/LoginView.axaml.cs
index 5e84ace..5e84ace 100644
--- a/ModerationClient/Views/LoginView.axaml.cs
+++ b/ModerationClient/Views/MainWindow/LoginView.axaml.cs
diff --git a/ModerationClient/Views/MainWindow.axaml b/ModerationClient/Views/MainWindow/MainWindow.axaml
index 1c2b396..ef13553 100644
--- a/ModerationClient/Views/MainWindow.axaml
+++ b/ModerationClient/Views/MainWindow/MainWindow.axaml
@@ -5,20 +5,19 @@
         xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
         xmlns:views="clr-namespace:ModerationClient.Views"
         mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
-        x:Class="ModerationClient.Views.MainWindow"
+        x:Class="ModerationClient.Views.MainWindow.MainWindow"
         x:DataType="vm:MainWindowViewModel"
         Icon="/Assets/avalonia-logo.ico"
         Title="ModerationClient"
-        Width="640" Height="480">
-
-    <Design.DataContext>
-        <!-- This only sets the DataContext for the previewer in an IDE,
+        Width="1280" Height="720">
+    <!-- <Design.DataContext> -->
+    <!-- This only sets the DataContext for the previewer in an IDE,
              to set the actual DataContext for runtime, set the DataContext property in code (look at App.axaml.cs) -->
-        <vm:MainWindowViewModel />
-    </Design.DataContext>
+    <!-- <vm:MainWindowViewModel /> -->
+    <!-- </Design.DataContext> -->
 
-    <StackPanel>
-        <Grid ColumnDefinitions="Auto, *, Auto">
+    <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>
@@ -29,14 +28,12 @@
             </StackPanel>
             <Label Grid.Column="2">Press '?' for keybinds</Label>
         </Grid>
-        <Viewbox>
+        <Viewbox Grid.Row="1">
             <ContentControl
                 Width="{CompiledBinding ChildTargetWidth}"
-                Height="{CompiledBinding ChildTargetHeight}"
                 Background="#222222"
+                Height="{CompiledBinding ChildTargetHeight}"
                 Content="{CompiledBinding CurrentViewModel}" />
         </Viewbox>
-
-    </StackPanel>
-
+    </Grid>
 </Window>
\ No newline at end of file
diff --git a/ModerationClient/Views/MainWindow.axaml.cs b/ModerationClient/Views/MainWindow/MainWindow.axaml.cs
index 884e90c..01027c1 100644
--- a/ModerationClient/Views/MainWindow.axaml.cs
+++ b/ModerationClient/Views/MainWindow/MainWindow.axaml.cs
@@ -8,13 +8,17 @@ using Microsoft.Extensions.Hosting;
 using ModerationClient.Services;
 using ModerationClient.ViewModels;
 
-namespace ModerationClient.Views;
+namespace ModerationClient.Views.MainWindow;
 
 public partial class MainWindow : Window {
     public MainWindow(CommandLineConfiguration cfg, MainWindowViewModel dataContext, IHostApplicationLifetime appLifetime) {
         InitializeComponent();
         DataContext = dataContext;
-        _ = dataContext.AuthService.LoadProfileAsync();
+        _ = dataContext.AuthService.LoadProfileAsync().ContinueWith(x => {
+            if (x.IsFaulted) {
+                Console.WriteLine("Failed to load profile: " + x.Exception);
+            }
+        });
         Console.WriteLine("mainwnd");
 #if DEBUG
         this.AttachDevTools(new DevToolsOptions() {
@@ -25,31 +29,43 @@ public partial class MainWindow : Window {
         PropertyChanged += (sender, args) => {
             // Console.WriteLine($"MainWindow PropertyChanged: {args.Property.Name} ({args.OldValue} -> {args.NewValue})");
             switch (args.Property.Name) {
-                case nameof(Height):
-                case nameof(Width): {
+                case nameof(ClientSize): {
                     if (DataContext is not MainWindowViewModel viewModel) {
-                        Console.WriteLine("WARN: MainWindowViewModel is null, ignoring height/width change!");
+                        Console.WriteLine("WARN: MainWindowViewModel is null, ignoring ClientSize change!");
                         return;
                     }
 
-                    // Console.WriteLine("height/width changed");
-                    viewModel.Scale = viewModel.Scale;
+                    viewModel.PhysicalSize = new Size(ClientSize.Width, ClientSize.Height - TopPanel.Bounds.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);
                 }
             }
         };
-        dataContext.MainWindow = this;
+
         dataContext.Scale = cfg.Scale;
         Width *= cfg.Scale;
         Height *= cfg.Scale;
@@ -84,11 +100,25 @@ public partial class MainWindow : Window {
                 viewModel.Scale = 5.0f;
             }
         }
-        else if (e.Key == Key.K && e.KeyModifiers == KeyModifiers.Control) {
-            if(viewModel.CurrentViewModel is ClientViewModel clientViewModel) {
-                Console.WriteLine("QuickSwitcher invoked");
+        else if (e.KeyModifiers == KeyModifiers.Control) {
+            if (e.Key == Key.K) {
+                if (viewModel.CurrentViewModel is ClientViewModel clientViewModel) {
+                    Console.WriteLine("QuickSwitcher invoked");
+                }
+                else Console.WriteLine("WARN: CurrentViewModel is not ClientViewModel, ignoring Quick Switcher");
+            }
+            else if (e.Key == Key.U ) {
+                Console.WriteLine("UserManagementWindow invoked");
+                var window = App.Current.Host.Services.GetRequiredService<UserManagementWindow>();
+                window.Show();
+            }
+            else if (e.Key == Key.F5) {
+                Console.WriteLine("Launching new process");
+                System.Diagnostics.Process.Start(System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName, Environment.GetCommandLineArgs());
+            }
+            else if (e.Key == Key.F9) {
+                
             }
-            else Console.WriteLine("WARN: CurrentViewModel is not ClientViewModel, ignoring Quick Switcher");
         }
     }
 }
\ No newline at end of file
diff --git a/ModerationClient/Views/UserManagementWindow.axaml b/ModerationClient/Views/UserManagementWindow.axaml
new file mode 100644
index 0000000..ef93517
--- /dev/null
+++ b/ModerationClient/Views/UserManagementWindow.axaml
@@ -0,0 +1,59 @@
+<Window xmlns="https://github.com/avaloniaui"
+        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+        xmlns:vm="using:ModerationClient.ViewModels"
+        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+        xmlns:views="clr-namespace:ModerationClient.Views"
+        xmlns:responses="clr-namespace:LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses;assembly=LibMatrix"
+        mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
+        x:Class="ModerationClient.Views.UserManagementWindow"
+        x:DataType="vm:MainWindowViewModel"
+        Icon="/Assets/avalonia-logo.ico"
+        Title="ModerationClient"
+        Width="640" Height="480">
+    <!-- <Design.DataContext> -->
+    <!-- This only sets the DataContext for the previewer in an IDE,
+             to set the actual DataContext for runtime, set the DataContext property in code (look at App.axaml.cs) -->
+    <!-- <vm:MainWindowViewModel /> -->
+    <!-- </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">
+            <ScrollViewer
+                Width="{CompiledBinding ChildTargetWidth}"
+                Background="#222222"
+                Height="{CompiledBinding ChildTargetHeight}">
+                <ContentControl DataContext="{CompiledBinding Path=CurrentViewModel}">
+                    <ItemsControl ItemsSource="{ReflectionBinding Users}">
+                        <ItemsControl.ItemTemplate>
+                            <DataTemplate DataType="vm:User">
+                                <StackPanel Orientation="Vertical">
+                                    <StackPanel Orientation="Horizontal">
+                                        <TextBlock Text="{CompiledBinding Name}" />
+                                        <TextBlock Text="{CompiledBinding DisplayName}" />
+                                    </StackPanel>
+                                    <StackPanel Orientation="Horizontal">
+                                        <Button Tag="{CompiledBinding .}" Click="PuppetButtonClicked">Puppet</Button>
+                                        <!-- <Button>Terminate</Button> -->
+                                    </StackPanel>
+                                </StackPanel>
+                            </DataTemplate>
+                        </ItemsControl.ItemTemplate>
+                    </ItemsControl>
+                </ContentControl>
+            </ScrollViewer>
+        </Viewbox>
+    </Grid>
+
+</Window>
\ No newline at end of file
diff --git a/ModerationClient/Views/UserManagementWindow.axaml.cs b/ModerationClient/Views/UserManagementWindow.axaml.cs
new file mode 100644
index 0000000..2d2dfb4
--- /dev/null
+++ b/ModerationClient/Views/UserManagementWindow.axaml.cs
@@ -0,0 +1,136 @@
+using System;
+using ArcaneLibs.Extensions;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Diagnostics;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using LibMatrix.Homeservers;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using ModerationClient.Services;
+using ModerationClient.ViewModels;
+
+namespace ModerationClient.Views;
+
+public partial class UserManagementWindow : Window {
+    private readonly CommandLineConfiguration _cfg;
+    private readonly MatrixAuthenticationService _auth;
+
+    public UserManagementWindow(CommandLineConfiguration cfg, MainWindowViewModel dataContext, IHostApplicationLifetime appLifetime,
+        UserManagementViewModel userManagementViewModel, MatrixAuthenticationService auth) {
+        _cfg = cfg;
+        _auth = auth;
+        InitializeComponent();
+        DataContext = dataContext;
+        dataContext.CurrentViewModel = userManagementViewModel;
+        Console.WriteLine("mainwnd");
+#if DEBUG
+        this.AttachDevTools(new DevToolsOptions() {
+            ShowAsChildWindow = true,
+            LaunchView = DevToolsViewKind.LogicalTree,
+        });
+#endif
+        PropertyChanged += (sender, args) => {
+            // Console.WriteLine($"MainWindow PropertyChanged: {args.Property.Name} ({args.OldValue} -> {args.NewValue})");
+            switch (args.Property.Name) {
+                case nameof(ClientSize): {
+                    if (DataContext is not MainWindowViewModel viewModel) {
+                        Console.WriteLine("WARN: MainWindowViewModel is null, ignoring ClientSize change!");
+                        return;
+                    }
+
+                    viewModel.PhysicalSize = new Size(ClientSize.Width, ClientSize.Height - TopPanel.Bounds.Height);
+                    break;
+                }
+            }
+        };
+
+        TopPanel.PropertyChanged += (_, args) => {
+            if (args.Property.Name == nameof(TopPanel.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>();
+                }
+                else {
+                    dataContext.CurrentViewModel = new LoginViewModel(dataContext.AuthService);
+                }
+            }
+        };
+
+        dataContext.Scale = cfg.Scale;
+        Width *= cfg.Scale;
+        Height *= cfg.Scale;
+
+        appLifetime.ApplicationStopping.Register(() => {
+            Console.WriteLine("ApplicationStopping triggered");
+            Close();
+        });
+    }
+
+    protected override void OnKeyDown(KeyEventArgs e) => OnKeyDown(this, e);
+
+    private void OnKeyDown(object? _, KeyEventArgs e) {
+        if (DataContext is not MainWindowViewModel viewModel) {
+            Console.WriteLine($"WARN: DataContext is {DataContext?.GetType().Name ?? "null"}, ignoring key press!");
+            return;
+        }
+
+        // Console.WriteLine("MainWindow KeyDown: " + e.Key);
+        if (e.Key == Key.Escape) {
+            viewModel.Scale = 1.0f;
+        }
+        else if (e.Key == Key.F1) {
+            viewModel.Scale -= 0.1f;
+            if (viewModel.Scale < 0.1f) {
+                viewModel.Scale = 0.1f;
+            }
+        }
+        else if (e.Key == Key.F2) {
+            viewModel.Scale += 0.1f;
+            if (viewModel.Scale > 5.0f) {
+                viewModel.Scale = 5.0f;
+            }
+        }
+        else if (e.Key == Key.K && e.KeyModifiers == KeyModifiers.Control) {
+            if (viewModel.CurrentViewModel is ClientViewModel clientViewModel) {
+                Console.WriteLine("QuickSwitcher invoked");
+            }
+            else Console.WriteLine("WARN: CurrentViewModel is not ClientViewModel, ignoring Quick Switcher");
+        }
+    }
+
+    // ReSharper disable once AsyncVoidMethod
+    private async void PuppetButtonClicked(object? sender, RoutedEventArgs e) {
+        if (e.Source is not Button button) {
+            Console.WriteLine("WARN: Source is not Button, ignoring PuppetButtonClicked!");
+            return;
+        }
+
+        if (button.Tag is not User user) {
+            Console.WriteLine("WARN: Tag is not User, ignoring PuppetButtonClicked!");
+            return;
+        }
+
+        if (_auth.Homeserver is not AuthenticatedHomeserverSynapse synapse) {
+            Console.WriteLine("WARN: Homeserver is not Synapse, ignoring PuppetButtonClicked!");
+            return;
+        }
+
+        var puppet = await synapse.Admin.LoginUserAsync(user.Name, TimeSpan.FromMinutes(5));
+
+        System.Diagnostics.Process.Start(System.Diagnostics.Process.GetCurrentProcess().MainModule!.FileName,
+            (_cfg with { IsTemporary = true, LoginData = puppet.ToJson() }).Serialise());
+    }
+}
\ No newline at end of file