From cbf918444400a438cb73308234c2dc1750d5bfa8 Mon Sep 17 00:00:00 2001
From: Emilia <emilia@jumpsca.re>
Date: Fri, 18 Oct 2024 22:37:31 +0300
Subject: [PATCH] i do not have the slightest clue what this commit contains +
 quarklist

---
 Blacklight.sln.DotSettings.user               |   3 +
 Blacklight/App.axaml                          |   5 +-
 .../Util/ResourceExplorerTemplateSelector.cs  |  58 ++++
 Blacklight/Util/StaticFallback.cs             |   1 +
 Blacklight/ViewModels/ClientViewModel.cs      |  10 +-
 Blacklight/ViewModels/LoginViewModel.cs       |   2 +
 Blacklight/Views/ClientView.axaml             |   1 +
 Blacklight/Views/MainWindow.axaml             |   2 +
 .../Views/Tools/ResourceExplorerView.axaml    |  36 ++-
 .../Views/Tools/ResourceExplorerView.axaml.cs |   1 +
 Lightquark.NET/Client.cs                      |  19 +-
 Lightquark.NET/ClientMethods/Channel.cs       |  21 ++
 Lightquark.NET/ClientMethods/Gateway.cs       |  19 +-
 Lightquark.NET/ClientMethods/Quark.cs         | 257 ++++++++++++++++++
 Lightquark.NET/ClientMethods/User.cs          |   2 +-
 Lightquark.NET/Objects/Channel.cs             |  11 +-
 Lightquark.NET/Objects/Quark.cs               |  25 +-
 Lightquark.NET/Objects/QuarkListItem.cs       |  37 +++
 .../Objects/Reply/PreferenceReply.cs          |   9 +
 .../Objects/Reply/QuarkListReply.cs           |   9 +
 Lightquark.NET/Objects/Reply/QuarkReply.cs    |   9 +
 .../Converters/ChannelCreationConverter.cs    |  13 +
 22 files changed, 515 insertions(+), 35 deletions(-)
 create mode 100644 Blacklight/Util/ResourceExplorerTemplateSelector.cs
 create mode 100644 Lightquark.NET/ClientMethods/Channel.cs
 create mode 100644 Lightquark.NET/ClientMethods/Quark.cs
 create mode 100644 Lightquark.NET/Objects/QuarkListItem.cs
 create mode 100644 Lightquark.NET/Objects/Reply/PreferenceReply.cs
 create mode 100644 Lightquark.NET/Objects/Reply/QuarkListReply.cs
 create mode 100644 Lightquark.NET/Objects/Reply/QuarkReply.cs
 create mode 100644 Lightquark.NET/Util/Converters/ChannelCreationConverter.cs

diff --git a/Blacklight.sln.DotSettings.user b/Blacklight.sln.DotSettings.user
index b1ac598..f1fd528 100644
--- a/Blacklight.sln.DotSettings.user
+++ b/Blacklight.sln.DotSettings.user
@@ -2,6 +2,7 @@
 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003A020002A9pdb321_002Eil_002Fl_003A_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FDecompilerCache_003FILViewer_003Fda232ecc044f4c3d8e856366253516531d2800_003F71_003F8a186d8b_003F020002A9pdb321_002Eil/@EntryIndexedValue">ForceIncluded</s:String>
 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003A020002ACpdb869_002Eil_002Fl_003A_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FDecompilerCache_003FILViewer_003Fda232ecc044f4c3d8e856366253516531d2800_003F00_003F5a1370d8_003F020002ACpdb869_002Eil/@EntryIndexedValue">ForceIncluded</s:String>
 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAssetLoader_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003F6dcc875f2facef5a505e1840bcae49509f5b796e47105cc3205aa92843f4753e_003FAssetLoader_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
+	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ABinding_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003F72cdf2fe6a3b54998bac4691eedd12e9311b7efeae35e65311b545606e2f_003FBinding_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AContentControl_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fa0b45743773945a3bb67ca6440ae9eadf9c00_003F50_003F9d315227_003FContentControl_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADockFluentTheme_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fc13735b26c914624b4ee4a88a575ae253ae00_003Feb_003F23efc6b0_003FDockFluentTheme_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFactory_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fe8b9e6b427dceb6f436414441b760d4dc52661df17f386ce4caa48aab354989_003FFactory_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
@@ -9,6 +10,8 @@
 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIAssetLoader_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fda232ecc044f4c3d8e856366253516531d2800_003F_005F27262_003FIAssetLoader_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIFactory_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003F52dbc4b2fb874f79fb4fec0b07cf7f6ca6ac95616e1d164ef210a0645a1aa9_003FIFactory_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIToolDock_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003F9c3ecc55c41dc7ed59e07448c63f6e65a746b8d44d381c2de59e8b39f659a968_003FIToolDock_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
+	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ALowerCaseStringEnumConverter_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F1e29f6bc726b4d68bc914cbabf2575377600_003F27_003F3006d2c3_003FLowerCaseStringEnumConverter_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANetworkInformation_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F5b1b048ea0e94b28ad7cbac8ef6bc4c26e00_003Fb4_003Fff405095_003FNetworkInformation_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AProportionalDock_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003F91c655b1795150937d9264ac1431c463ed5e5e8ad0286ad17e16f16175d94_003FProportionalDock_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
+	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ATreeDataTemplate_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fe9a5203b1cebd5ba4f745e6a4b3dd651886fe711fe1ad32dec118143c4fbd0_003FTreeDataTemplate_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
 	</wpf:ResourceDictionary>
\ No newline at end of file
diff --git a/Blacklight/App.axaml b/Blacklight/App.axaml
index 96cf122..7253eff 100644
--- a/Blacklight/App.axaml
+++ b/Blacklight/App.axaml
@@ -57,10 +57,7 @@
 			<Setter Property="HeaderTemplate">
 				<DataTemplate DataType="core:IDockable">
 					<StackPanel Orientation="Horizontal" VerticalAlignment="Center" Margin="6, 2">
-						<TextBlock Margin="0, 0, 2, 0" FontSize="16">
-							<TextElement.Foreground>
-								<SolidColorBrush>#38ae39</SolidColorBrush>
-							</TextElement.Foreground>
+						<TextBlock Margin="0, 0, 2, 0" FontSize="16" Foreground="{DynamicResource ChannelHashtagColor}">
 							#
 						</TextBlock>
 						<TextBlock VerticalAlignment="Center" Text="{Binding Title}" />
diff --git a/Blacklight/Util/ResourceExplorerTemplateSelector.cs b/Blacklight/Util/ResourceExplorerTemplateSelector.cs
new file mode 100644
index 0000000..0bcb2da
--- /dev/null
+++ b/Blacklight/Util/ResourceExplorerTemplateSelector.cs
@@ -0,0 +1,58 @@
+// https://stackoverflow.com/questions/74003533/avalonia-treeview-template-selector
+
+using System;
+using System.Collections.Generic;
+using Avalonia.Controls;
+using Avalonia.Controls.Templates;
+using Avalonia.Data;
+using Avalonia.Markup.Xaml.Templates;
+using Avalonia.Metadata;
+using Lightquark.NET.Objects;
+
+namespace Blacklight.Util;
+
+public class ResourceExplorerTemplateSelector : ITreeDataTemplate
+{
+    [Content]
+    // ReSharper disable once CollectionNeverUpdated.Global
+    public Dictionary<string, IDataTemplate> AvailableTemplates { get; } = new();
+
+    public InstancedBinding? ItemsSelector(object item)
+    {
+        if (item is QuarkListItem listItem)
+        {
+            // decide which template's ItemSelector to call 
+            string key = listItem.Type switch
+            {
+                QuarkListItemType.Folder => "FolderTemplate",
+                QuarkListItemType.Quark => "QuarkTemplate",
+                QuarkListItemType.Channel => "ChannelTemplate",
+                _ => throw new ArgumentOutOfRangeException()
+            };
+            return ((TreeDataTemplate)AvailableTemplates[key]).ItemsSelector(item); 
+        }
+        else
+            return null;
+    }
+
+    // Check if we can accept the provided data
+    public bool Match(object? data)
+    {
+        return data is QuarkListItem;
+    }
+
+    // Build the DataTemplate here
+    Control? ITemplate<object?, Control?>.Build(object? param)
+    {
+        if (param is not QuarkListItem listItem) return null;
+        string key = listItem.Type switch
+        {
+            QuarkListItemType.Folder => "FolderTemplate",
+            QuarkListItemType.Quark => "QuarkTemplate",
+            QuarkListItemType.Channel => "ChannelTemplate",
+            _ => throw new ArgumentOutOfRangeException()
+        };
+
+        return AvailableTemplates[key].Build(param); // finally we look up the provided key and let the System build the DataTemplate for us
+    }
+}
\ No newline at end of file
diff --git a/Blacklight/Util/StaticFallback.cs b/Blacklight/Util/StaticFallback.cs
index 44c0007..26b27b0 100644
--- a/Blacklight/Util/StaticFallback.cs
+++ b/Blacklight/Util/StaticFallback.cs
@@ -7,4 +7,5 @@ namespace Blacklight.Util;
 public class StaticFallback
 {
     public static Bitmap NetworkIcon = new Bitmap(AssetLoader.Open(new Uri("avares://Blacklight/Assets/missing.png")));
+    public static Bitmap QuarkIcon = new Bitmap(AssetLoader.Open(new Uri("avares://Blacklight/Assets/missing.png")));
 }
\ No newline at end of file
diff --git a/Blacklight/ViewModels/ClientViewModel.cs b/Blacklight/ViewModels/ClientViewModel.cs
index 741d148..efbfd81 100644
--- a/Blacklight/ViewModels/ClientViewModel.cs
+++ b/Blacklight/ViewModels/ClientViewModel.cs
@@ -13,9 +13,11 @@ using Dock.Model.Controls;
 using Dock.Model.Core;
 using Dock.Model.Mvvm.Controls;
 using Lightquark.NET;
+using Lightquark.NET.Objects;
 using Lightquark.NET.Objects.Reply;
 using Newtonsoft.Json;
 using Newtonsoft.Json.Converters;
+using Serilog;
 
 namespace Blacklight.ViewModels;
 
@@ -24,7 +26,6 @@ public class ClientViewModel : ViewModelBase
     
     private readonly IFactory? _factory;
     private IRootDock? _layout;
-    
     public IRootDock? Layout
     {
         get => _layout;
@@ -33,6 +34,7 @@ public class ClientViewModel : ViewModelBase
     
     public ICommand NewLayout { get; }
     public ICommand Logout { get; }
+    public ICommand Test { get; }
     public Client Client { get; }
 
 
@@ -54,8 +56,14 @@ public class ClientViewModel : ViewModelBase
 
         NewLayout = new RelayCommand(ResetLayout);
         Logout = new RelayCommand(LogoutNow);
+        Test = new RelayCommand(TestCommand);
     }
 
+    public void TestCommand()
+    {
+        Log.Information("Current items in QuarkList: {ItemCount}", Client.QuarkList.Count);
+    }
+    
     public void LogoutNow()
     {
         Client.LogOut();
diff --git a/Blacklight/ViewModels/LoginViewModel.cs b/Blacklight/ViewModels/LoginViewModel.cs
index 108a2b3..263aac8 100644
--- a/Blacklight/ViewModels/LoginViewModel.cs
+++ b/Blacklight/ViewModels/LoginViewModel.cs
@@ -215,6 +215,8 @@ public class LoginViewModel : ViewModelBase
         await Client.GatewayConnectionEvent.WaitAsync();
         LoadingText = "Getting user data";
         await Client.GetCurrentUser();
+        LoadingText = "Quarking quarks";
+        await Client.GetQuarkList();
         _nav.ShowView<ClientViewModel>();
         LoadingText = "Loading?";
         _currentControl = new MainLoginPrompt();
diff --git a/Blacklight/Views/ClientView.axaml b/Blacklight/Views/ClientView.axaml
index 1621953..369cf54 100644
--- a/Blacklight/Views/ClientView.axaml
+++ b/Blacklight/Views/ClientView.axaml
@@ -66,6 +66,7 @@
         <MenuItem Header="_Blacklight">
           <MenuItem Header="_New Layout" Command="{Binding NewLayout}" />
           <MenuItem Header="_Log out (temp location!)" Command="{Binding Logout}" />
+          <MenuItem Header="_Test" Command="{Binding Test}" />
         </MenuItem>
       </Menu>
       
diff --git a/Blacklight/Views/MainWindow.axaml b/Blacklight/Views/MainWindow.axaml
index 41e4f73..d297dbf 100644
--- a/Blacklight/Views/MainWindow.axaml
+++ b/Blacklight/Views/MainWindow.axaml
@@ -41,6 +41,7 @@
 					<SolidColorBrush x:Key="DockThemeForegroundBrush">#000000</SolidColorBrush>
 					<SolidColorBrush x:Key="DockApplicationAccentForegroundBrush">#000000</SolidColorBrush>
 					<SolidColorBrush x:Key="TabItemSelectedBorderColor">#4e4d4a</SolidColorBrush>
+					<SolidColorBrush x:Key="ChannelHashtagColor">#38ae39</SolidColorBrush>
 				</ResourceDictionary>
 				<ResourceDictionary x:Key="Dark">
 					<SolidColorBrush x:Key="RightBeltColor">#F22b2d30</SolidColorBrush>
@@ -56,6 +57,7 @@
 					<SolidColorBrush x:Key="DockApplicationAccentBrushMed">Transparent</SolidColorBrush>
 					<SolidColorBrush x:Key="DockThemeBorderLowBrush">Transparent</SolidColorBrush>
 					<SolidColorBrush x:Key="TabItemSelectedBorderColor">#b1b2b5</SolidColorBrush>
+					<SolidColorBrush x:Key="ChannelHashtagColor">#38ae39</SolidColorBrush>
 				</ResourceDictionary>
 			</ResourceDictionary.ThemeDictionaries>
 		</ResourceDictionary>
diff --git a/Blacklight/Views/Tools/ResourceExplorerView.axaml b/Blacklight/Views/Tools/ResourceExplorerView.axaml
index 64828e2..12532ac 100644
--- a/Blacklight/Views/Tools/ResourceExplorerView.axaml
+++ b/Blacklight/Views/Tools/ResourceExplorerView.axaml
@@ -2,14 +2,36 @@
              xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
              xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
              xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+             xmlns:viewModels="clr-namespace:Blacklight.ViewModels"
+             xmlns:views="clr-namespace:Blacklight.Views"
+             xmlns:objects="clr-namespace:Lightquark.NET.Objects;assembly=Lightquark.NET"
+             xmlns:util="clr-namespace:Blacklight.Util"
              mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
              x:Class="Blacklight.Views.Tools.ResourceExplorerView">
-    <Grid ColumnDefinitions="*" RowDefinitions="*" Background="{DynamicResource ResourceExplorerColor}">
-        <Panel Grid.Row="0">
-            <TextBlock>Meow</TextBlock>
-            <TextBlock>Meow</TextBlock>
-            <TextBlock>Meow</TextBlock>
-            <TextBlock>Meow</TextBlock>
-        </Panel>
+	<Grid ColumnDefinitions="*" RowDefinitions="*" Background="{DynamicResource ResourceExplorerColor}">
+		<TreeView Grid.Row="0" ItemsSource="{Binding $parent[views:ClientView].((viewModels:ClientViewModel)DataContext).Client.ResourceTree}">
+	    <TreeView.DataTemplates>
+		    <util:ResourceExplorerTemplateSelector>
+			    <TreeDataTemplate x:Key="QuarkTemplate" x:DataType="objects:QuarkListItem" ItemsSource="{Binding Children}">
+				    <StackPanel Orientation="Horizontal">
+					    <Image Height="32" Margin="0,0,4,0"
+						    Source="{Binding Quark.Icon, FallbackValue={x:Static util:StaticFallback.QuarkIcon} }"></Image>
+					    <TextBlock VerticalAlignment="Center" Text="{Binding Quark.Name, FallbackValue='No Quark Information'}"></TextBlock>
+				    </StackPanel>
+			    </TreeDataTemplate>
+			    <TreeDataTemplate x:Key="ChannelTemplate" x:DataType="objects:QuarkListItem">
+				    <StackPanel Orientation="Horizontal">
+					    <TextBlock FontWeight="Bold" Foreground="{DynamicResource ChannelHashtagColor}" Margin="0,0,2,0">#</TextBlock>
+					    <TextBlock Text="{Binding Channel.Name, FallbackValue='No Channel Information'}"></TextBlock>
+				    </StackPanel>
+			    </TreeDataTemplate>
+			    <TreeDataTemplate x:Key="FolderTemplate" x:DataType="objects:QuarkListItem" ItemsSource="{Binding Children}">
+				    <StackPanel>
+					    <TextBlock Text="{Binding FolderName, FallbackValue='Folder'}"></TextBlock>
+				    </StackPanel>
+			    </TreeDataTemplate>
+		    </util:ResourceExplorerTemplateSelector>
+	    </TreeView.DataTemplates>
+     </TreeView>
     </Grid>
 </UserControl>
diff --git a/Blacklight/Views/Tools/ResourceExplorerView.axaml.cs b/Blacklight/Views/Tools/ResourceExplorerView.axaml.cs
index 12393e0..0e17f96 100644
--- a/Blacklight/Views/Tools/ResourceExplorerView.axaml.cs
+++ b/Blacklight/Views/Tools/ResourceExplorerView.axaml.cs
@@ -2,6 +2,7 @@
 using Avalonia.Controls;
 using Avalonia.Input;
 using Avalonia.Markup.Xaml;
+using Blacklight.Util;
 using Dock.Model.Controls;
 using Dock.Model.Core;
 
diff --git a/Lightquark.NET/Client.cs b/Lightquark.NET/Client.cs
index c018410..df35aec 100644
--- a/Lightquark.NET/Client.cs
+++ b/Lightquark.NET/Client.cs
@@ -21,12 +21,13 @@ public enum GatewayConnectState
 
 public partial class Client : ObservableObject
 {
-    private const string Version = "v4";
-    public ObservableCollection<ObjectId> QuarkList { get; set; } = [];
-    public ObservableCollection<Quark> Quarks { get; set; } = [];
-    public ObservableCollection<Channel> Channels { get; set; } = [];
-    public ObservableCollection<Message> Messages { get; set; } = [];
-    public ObservableCollection<User> Users { get; set; } = [];
+    private const string Version = "v4"; 
+    public ObservableCollection<QuarkListItem> QuarkList { get; } = [];
+    public ObservableCollection<QuarkListItem> ResourceTree { get; } = [];
+    public ObservableCollection<Quark> Quarks { get; } = [];
+    public ObservableCollection<Channel> Channels { get; } = [];
+    public ObservableCollection<Message> Messages { get; } = [];
+    public ObservableCollection<User> Users { get; } = [];
 
     public User? CurrentUser
     {
@@ -71,12 +72,14 @@ public partial class Client : ObservableObject
         {
             Formatting = Formatting.Indented,
             NullValueHandling = NullValueHandling.Ignore,
-            Converters = new List<JsonConverter> { new AttachmentCreationConverter(), new StatusCreationConverter() },
+            Converters = new List<JsonConverter> { new AttachmentCreationConverter(), new StatusCreationConverter(), new ChannelCreationConverter() },
             ContractResolver = new CamelCasePropertyNamesContractResolver()
         };
         var mapperConfig = new MapperConfiguration(cfg =>
         {
-            cfg.CreateMap<User, User>().ForAllMembers(opts => opts.Condition((src, dest, srcMember) => srcMember != null));
+            cfg.CreateMap<User, User>().ForAllMembers(opts => opts.Condition((_, _, srcMember) => srcMember != null));
+            cfg.CreateMap<Quark, Quark>().ForAllMembers(opts => opts.Condition((_, _, srcMember) => srcMember != null));
+            cfg.CreateMap<Channel, Channel>().ForAllMembers(opts => opts.Condition((_, _, srcMember) => srcMember != null));
         });
         _mapper = mapperConfig.CreateMapper();
     }
diff --git a/Lightquark.NET/ClientMethods/Channel.cs b/Lightquark.NET/ClientMethods/Channel.cs
new file mode 100644
index 0000000..25ab170
--- /dev/null
+++ b/Lightquark.NET/ClientMethods/Channel.cs
@@ -0,0 +1,21 @@
+using Lightquark.NET.Objects;
+
+namespace Lightquark.NET;
+
+public partial class Client
+{
+    private void AddOrUpdateChannel(Channel channel)
+    {
+        var existingChannel = Channels.FirstOrDefault(q => q.Id == channel.Id);
+        if (existingChannel != null)
+        {
+            // Update
+            var index = Channels.IndexOf(existingChannel);
+            Channels[index] = _mapper.Map(channel, existingChannel);
+        }
+        else
+        {
+            Channels.Add(channel);
+        }
+    }
+}
\ No newline at end of file
diff --git a/Lightquark.NET/ClientMethods/Gateway.cs b/Lightquark.NET/ClientMethods/Gateway.cs
index 5747341..8de712c 100644
--- a/Lightquark.NET/ClientMethods/Gateway.cs
+++ b/Lightquark.NET/ClientMethods/Gateway.cs
@@ -103,7 +103,7 @@ public partial class Client
                         break;
                     case "gatekeeperMeasure":
                         var measureMessage = ParseGateway<MeasureGatewayMessage>(msg.Text!);
-                        GatekeeperMeasure(measureMessage);
+                        // GatekeeperMeasure(measureMessage);
                         break;
                     case "gatekeeperSelection":
                         var selectionMessage = ParseGateway<SelectionGatewayMessage>(msg.Text!);
@@ -127,10 +127,12 @@ public partial class Client
 
     public async Task<Reply<T>> CallRpc<T>(string method, string route, object? body) where T : BaseReplyResponse
     {
+        Log.Information("Starting RPC to {Method} {Route}", method, route);
         await GatewayConnectionEvent.WaitAsync(); // Wait until connected just in case
         if (GatewayStatus != GatewayConnectState.Connected) throw new Exception("RPC Call when Gateway not connected!"); // ???
         var sem = new SemaphoreSlim(0, 1);
         var state = Guid.NewGuid().ToString();
+        Log.Information("RPC to {Method} {Route} has state {State}", method, route, state);
         _inFlightRpcs.Add(state);
         if (_noRpcInFlight.Task.IsCompleted)
         {
@@ -141,9 +143,13 @@ public partial class Client
             RpcGatewayMessage<T>? responseBody = null;
             var subscriptionId = EventBus.Subscribe<GatewayBusEvent>(busEvent =>
             {
+                Log.Information("RPC to {Method} {Route} received event with state {State}", method, route, busEvent.BaseMessage.State);
                 if (busEvent.BaseMessage.State != state) return;
+                Log.Information("RPC to {Method} {Route} accepted event with state {State}", method, route, busEvent.BaseMessage.State);
                 responseBody = ParseGateway<RpcGatewayMessage<T>>(busEvent.RawMessageString);
+                Log.Information("RPC to {Method} {Route} parsed response", method, route);
                 sem.Release();
+                Log.Information("RPC to {Method} {Route} released semaphore", method, route);
             });
             SendGatewayMessage(new
             {
@@ -154,7 +160,12 @@ public partial class Client
                 method,
                 body
             });
-            await sem.WaitAsync();
+            await sem.WaitAsync(10000);
+            Log.Information("RPC to {Method} {Route} has stopped waiting", method, route);
+            if (responseBody == null)
+            {
+                throw new TimeoutException($"RPC call to {method} {route} timed out after 10000ms");
+            }
             EventBus.Unsubscribe<GatewayBusEvent>(subscriptionId);
             _inFlightRpcs.Remove(state);
             if (_inFlightRpcs.Count == 0) _noRpcInFlight.SetResult(true);
@@ -162,9 +173,9 @@ public partial class Client
         }
         catch (Exception ex)
         {
-            _inFlightRpcs.Remove(state);
-            if (_inFlightRpcs.Count == 0) _noRpcInFlight.SetResult(true);
             Log.Error(ex, "Failed RPC call :(");
+            _inFlightRpcs.Remove(state);
+            if (_inFlightRpcs.Count == 0 && !_noRpcInFlight.Task.IsCompleted) _noRpcInFlight.SetResult(true);
             throw;
         }
     }
diff --git a/Lightquark.NET/ClientMethods/Quark.cs b/Lightquark.NET/ClientMethods/Quark.cs
new file mode 100644
index 0000000..db44a8d
--- /dev/null
+++ b/Lightquark.NET/ClientMethods/Quark.cs
@@ -0,0 +1,257 @@
+using System.Collections.ObjectModel;
+using Avalonia.Media.Imaging;
+using Lightquark.NET.Objects;
+using Lightquark.NET.Objects.Reply;
+using MongoDB.Bson;
+using Newtonsoft.Json;
+using Serilog;
+
+namespace Lightquark.NET;
+
+public partial class Client
+{
+    public async Task GetQuarkList()
+    {
+        var res = await CallRpc<PreferenceReplyResponse>("GET", $"/{Version}/user/me/preferences/global", null);
+        if (!res.Request.Success) throw new Exception("Failed to fetch global preferences");
+        var globalPreferences = res.Response.Preferences!; // Success so not null :)
+        if (!globalPreferences.TryGetValue("quark_list", out var quarkListObject) || quarkListObject is not string quarkListJson)
+        {
+            await ResetQuarkList();
+            return;
+        }
+        else
+        {
+            var quarkList = JsonConvert.DeserializeObject<QuarkListItem[]>(quarkListJson);
+            QuarkList.Clear();
+            if (quarkList == null)
+            {
+                await ResetQuarkList();
+                return;
+            }
+            else
+            {
+                foreach (var quarkListItem in quarkList)
+                {
+                    QuarkList.Add(quarkListItem);
+                }   
+            }
+        }
+        
+        // Check for missing or outdated quarks in the list
+        var userQuarkRes = await CallRpc<QuarkListReplyResponse>("GET", $"/{Version}/quark", null);
+        if (!userQuarkRes.Request.Success) throw new Exception("Failed to fetch user quarks");
+        var userQuarks = userQuarkRes.Response.Quarks!;
+        List<ObjectId> matchedIds = [];
+        foreach (var quark in userQuarks)
+        {
+            AddOrUpdateQuark(quark);
+            // Add quark to quark list if missing
+            if (!QuarkList.Any(IdFinder(quark.Id)))
+            {
+                QuarkList.Add(new QuarkListItem
+                {
+                    Type = QuarkListItemType.Quark,
+                    QuarkId = quark.Id,
+                    Quark = quark
+                });
+            }
+            matchedIds.Add(quark.Id);
+        }
+
+        var listedIds = QuarkList.SelectMany(i =>
+        {
+            switch (i.Type)
+            {
+                case QuarkListItemType.Quark:
+                    return [i.QuarkId!.Value];
+                case QuarkListItemType.Folder:
+                    return i.Children!.Select(c => c.QuarkId!.Value);
+                default:
+                    Log.Error("Unknown quark list element type {@El}", i);
+                    return [];
+            }
+        });
+        
+        // Find listed ids that are not matched
+        var oldIds = listedIds.Where(i => !matchedIds.Contains(i)).ToArray();
+        foreach (var oldId in oldIds)
+        {
+            var item = QuarkList.First(IdFinder(oldId));
+            switch (item.Type)
+            {
+                case QuarkListItemType.Quark:
+                    QuarkList.Remove(item);
+                    break;
+                case QuarkListItemType.Folder:
+                {
+                    var index = QuarkList.IndexOf(item);
+                    foreach (var quarkListItem in item.Children!.ToArray())
+                    {
+                        if (quarkListItem.QuarkId == oldId)
+                        {
+                            item.Children!.Remove(quarkListItem);
+                        }
+                    }
+                    QuarkList[index] = item;
+                    break;
+                }
+                default:
+                    Log.Error("Unknown quark list element type {@El}", item);
+                    continue;
+            }
+        }
+        
+        await SaveQuarkList();
+        await UpdateResourceTree();
+    }
+    
+    private Func<QuarkListItem, bool> IdFinder(ObjectId id)
+    {
+        return i =>
+        {
+            return i.Type switch
+            {
+                QuarkListItemType.Quark => i.QuarkId == id,
+                QuarkListItemType.Folder => i.Children?.Any(q => q.QuarkId == id) ?? false,
+                _ => false
+            };
+        };
+    }
+
+    private async Task ResetQuarkList()
+    {
+        
+        var userQuarkRes = await CallRpc<QuarkListReplyResponse>("GET", $"/{Version}/quark", null);
+        if (!userQuarkRes.Request.Success) throw new Exception("Failed to fetch user quarks");
+        var userQuarks = userQuarkRes.Response.Quarks!;
+        foreach (var quark in userQuarks)
+        {
+            AddOrUpdateQuark(quark);
+            QuarkList.Add(new QuarkListItem
+            {
+                Type = QuarkListItemType.Quark,
+                QuarkId = quark.Id,
+                Quark = quark
+            });
+        }
+
+        await SaveQuarkList();
+    }
+
+    private async Task SaveQuarkList()
+    {
+        var quarkListJson = JsonConvert.SerializeObject(QuarkList);
+        await CallRpc<BaseReplyResponse>("POST", $"/{Version}/user/me/preferences/global/quark_list", new
+        {
+            value = quarkListJson
+        });
+    }
+
+    private async Task UpdateResourceTree()
+    {
+        var quarkList = QuarkList.ToList();
+        foreach (var quarkListItem in quarkList.ToList())
+        {
+            var index = quarkList.IndexOf(quarkListItem);
+            if (quarkListItem.Type == QuarkListItemType.Folder)
+            {
+                var quarkTasks = quarkListItem.Children?
+                    .Where(t => t.Type == QuarkListItemType.Quark)
+                    .Select(PopulateQuarkListQuark) ?? [];
+                var quarks = await Task.WhenAll(quarkTasks);
+                var observableQuarks = new ObservableCollection<QuarkListItem>();
+                foreach (var listItem in quarks)
+                {
+                    if (listItem != null) observableQuarks.Add(listItem);
+                }
+                quarkListItem.Children = observableQuarks;
+                quarkList[index] = quarkListItem;
+            }
+            else if (quarkListItem.Type == QuarkListItemType.Quark)
+            {
+                var populatedQuark = await PopulateQuarkListQuark(quarkListItem);
+                if (populatedQuark != null)
+                {
+                    quarkList[index] = populatedQuark;
+                }
+            }
+        }
+        
+        // TODO: hacky and bad! Actually should only update anything that changed but blehhhh
+        ResourceTree.Clear();
+        foreach (var quarkListItem in quarkList)
+        {
+            ResourceTree.Add(quarkListItem);
+        }
+    }
+
+    private async Task<QuarkListItem?> PopulateQuarkListQuark(QuarkListItem quarkListItem)
+    {
+        if (quarkListItem.Type != QuarkListItemType.Quark)
+            throw new Exception("Quark list parse failure? Non-quark passed to PopulateQuarkListQuark");
+        Log.Information("Quark {@Quark}", quarkListItem);
+        if (quarkListItem.QuarkId == null || quarkListItem.QuarkId == ObjectId.Empty)
+            throw new Exception("Quark list parse failure? Empty or null quark id");
+        var quark = Quarks.FirstOrDefault(q => q.Id == quarkListItem.QuarkId);
+        if (quark == null)
+        {
+            Log.Information("Missing Quark in cache, getting it from API");
+            var rpcQuark = await CallRpc<QuarkReplyResponse>("GET", $"/{Version}/quark/{quarkListItem.QuarkId}", null);
+            if (!rpcQuark.Request.Success) return quarkListItem;
+            AddOrUpdateQuark(rpcQuark.Response.Quark!);
+            quark = rpcQuark.Response.Quark!;
+        }
+
+        quarkListItem.Quark = quark;
+        quarkListItem.Children = [];
+        Log.Information("Quark has {Count} channels", quark.Channels?.Length);
+        foreach (var channel in quark.Channels ?? [])
+        {
+            Log.Information("Channel {Ch}", channel);
+            AddOrUpdateChannel((Channel)channel);
+            var cacheChannel = Channels.FirstOrDefault(c => c.Id == channel.Id);
+            quarkListItem.Children.Add(new QuarkListItem
+            {
+                Type = QuarkListItemType.Channel,
+                Channel = cacheChannel,
+                Children = null,
+                FolderName = null,
+            });
+        }
+
+        return quarkListItem;
+    }
+
+    private async void AddOrUpdateQuark(Quark quark)
+    {
+        var existingQuark = Quarks.FirstOrDefault(q => q.Id == quark.Id);
+        if (existingQuark != null)
+        {
+            // Update
+            var index = Quarks.IndexOf(existingQuark);
+            if (existingQuark.Icon == null || existingQuark.FetchedIcon != existingQuark.IconUri) existingQuark = await FetchQuarkIcon(quark);
+            Quarks[index] = _mapper.Map(quark, existingQuark);
+        }
+        else
+        {
+            if (quark.Icon == null || quark.FetchedIcon != quark.IconUri) quark = await FetchQuarkIcon(quark);
+            Quarks.Add(quark);
+        }
+    }
+
+    private async Task<Quark> FetchQuarkIcon(Quark quark)
+    {
+        try
+        {
+            var iconRes = await _httpClient.GetAsync(quark.IconUri);
+            quark.Icon = new Bitmap(await iconRes.Content.ReadAsStreamAsync());
+            quark.FetchedIcon = quark.IconUri;
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "Failed to get quark icon for {@Quark}", quark);
+        }
+        return quark;
+    }
+}
\ No newline at end of file
diff --git a/Lightquark.NET/ClientMethods/User.cs b/Lightquark.NET/ClientMethods/User.cs
index 8c716ab..a9c0ef9 100644
--- a/Lightquark.NET/ClientMethods/User.cs
+++ b/Lightquark.NET/ClientMethods/User.cs
@@ -9,7 +9,7 @@ public partial class Client
 {
     public async Task GetCurrentUser()
     {
-        var res = await CallRpc<UserReplyResponse>("GET", "/v4/user/me", null);
+        var res = await CallRpc<UserReplyResponse>("GET", $"/{Version}/user/me", null);
         if (!res.Request.Success) throw new Exception("Failed to fetch user");
         CurrentUserFromObject(res.Response.User!);
     }
diff --git a/Lightquark.NET/Objects/Channel.cs b/Lightquark.NET/Objects/Channel.cs
index 88a28bb..220f8b1 100644
--- a/Lightquark.NET/Objects/Channel.cs
+++ b/Lightquark.NET/Objects/Channel.cs
@@ -1,4 +1,5 @@
-using Lightquark.NET.Util.Converters;
+using Avalonia.Media.Imaging;
+using Lightquark.NET.Util.Converters;
 using Lightquark.Types.Mongo;
 using MongoDB.Bson;
 using Newtonsoft.Json;
@@ -9,19 +10,19 @@ public class Channel : IChannel
 {
     [JsonProperty("_id")]
     [JsonConverter(typeof(ObjectIdConverter))]
-    public required ObjectId Id { get; set; }
+    public ObjectId Id { get; set; }
 
     [JsonProperty("name")]
-    public required string Name { get; set; }
+    public string Name { get; set; }
     
     [JsonProperty("description")]
-    public required string Description { get; set; }
+    public string Description { get; set; }
     
     [JsonProperty("index")]
     public int? Index { get; set; }
     
     [JsonIgnore]
-    public required ObjectId QuarkId { get; set; }
+    public ObjectId QuarkId { get; set; }
 
     // Virtuals
     
diff --git a/Lightquark.NET/Objects/Quark.cs b/Lightquark.NET/Objects/Quark.cs
index 9e7f146..451574c 100644
--- a/Lightquark.NET/Objects/Quark.cs
+++ b/Lightquark.NET/Objects/Quark.cs
@@ -1,12 +1,28 @@
-using Lightquark.NET.Util.Converters;
+using Avalonia.Media.Imaging;
+using CommunityToolkit.Mvvm.ComponentModel;
+using Lightquark.NET.Util.Converters;
 using Lightquark.Types.Mongo;
 using MongoDB.Bson;
 using Newtonsoft.Json;
 
 namespace Lightquark.NET.Objects;
 
-public class Quark : IQuark
+public class Quark : ObservableObject, IQuark
 {
+    [JsonIgnore]
+    private Bitmap? _icon;
+    
+    [JsonIgnore]
+    public string? FetchedIcon = null;
+
+    [JsonIgnore]
+    public Bitmap? Icon
+    {
+        get => _icon;
+        set => SetProperty(ref _icon, value);
+    }
+
+    
     [JsonProperty("_id")]
     [JsonConverter(typeof(ObjectIdConverter))]
     public required ObjectId Id { get; set; }
@@ -25,7 +41,7 @@ public class Quark : IQuark
     public required ObjectId[] Owners { get; set; }
 
     [JsonProperty("channels")]
-    public IChannel[]? Channels => VirtualChannels;
+    public IChannel[] Channels { get; set; }
     
     [JsonProperty("inviteEnabled")]
     public bool InviteEnabled { get; set; } = true;
@@ -34,6 +50,5 @@ public class Quark : IQuark
     [JsonConverter(typeof(ObjectIdConverter))]
     public ObjectId? SystemMessageChannel { get; set; }
 
-    [JsonIgnore]
-    public Channel[]? VirtualChannels { get; set; }
+    // [JsonIgnore] public Channel[]? VirtualChannels => (Channel[])Channels;
 }
\ No newline at end of file
diff --git a/Lightquark.NET/Objects/QuarkListItem.cs b/Lightquark.NET/Objects/QuarkListItem.cs
new file mode 100644
index 0000000..5608dc4
--- /dev/null
+++ b/Lightquark.NET/Objects/QuarkListItem.cs
@@ -0,0 +1,37 @@
+using System.Collections.ObjectModel;
+using Lightquark.NET.Util.Converters;
+using Lightquark.Types.Mongo;
+using MongoDB.Bson;
+using Newtonsoft.Json;
+
+namespace Lightquark.NET.Objects;
+
+public class QuarkListItem
+{
+    [JsonProperty("type")]
+    [JsonConverter(typeof(LowerCaseStringEnumConverter))]
+    public required QuarkListItemType Type { get; init; }
+    
+    [JsonProperty("quarkId")]
+    [JsonConverter(typeof(ObjectIdConverter))]
+    public ObjectId? QuarkId { get; init; }
+    
+    [JsonIgnore]
+    public Quark? Quark { get; set; }
+    
+    [JsonIgnore]
+    public Channel? Channel { get; set; }
+    
+    [JsonProperty("children")]
+    public ObservableCollection<QuarkListItem>? Children { get; set; }
+
+    [JsonProperty("name")]
+    public string? FolderName { get; set; }
+}
+
+public enum QuarkListItemType
+{
+    Quark,
+    Folder,
+    Channel
+}
\ No newline at end of file
diff --git a/Lightquark.NET/Objects/Reply/PreferenceReply.cs b/Lightquark.NET/Objects/Reply/PreferenceReply.cs
new file mode 100644
index 0000000..d044f8f
--- /dev/null
+++ b/Lightquark.NET/Objects/Reply/PreferenceReply.cs
@@ -0,0 +1,9 @@
+using Newtonsoft.Json;
+
+namespace Lightquark.NET.Objects.Reply;
+
+public record PreferenceReplyResponse : BaseReplyResponse
+{
+    [JsonProperty("preferences")] 
+    public Dictionary<string, object>? Preferences;
+}
\ No newline at end of file
diff --git a/Lightquark.NET/Objects/Reply/QuarkListReply.cs b/Lightquark.NET/Objects/Reply/QuarkListReply.cs
new file mode 100644
index 0000000..dd41ae2
--- /dev/null
+++ b/Lightquark.NET/Objects/Reply/QuarkListReply.cs
@@ -0,0 +1,9 @@
+using Newtonsoft.Json;
+
+namespace Lightquark.NET.Objects.Reply;
+
+public record QuarkListReplyResponse : BaseReplyResponse
+{
+    [JsonProperty("quarks")] 
+    public Quark[]? Quarks;
+}
\ No newline at end of file
diff --git a/Lightquark.NET/Objects/Reply/QuarkReply.cs b/Lightquark.NET/Objects/Reply/QuarkReply.cs
new file mode 100644
index 0000000..f095b2b
--- /dev/null
+++ b/Lightquark.NET/Objects/Reply/QuarkReply.cs
@@ -0,0 +1,9 @@
+using Newtonsoft.Json;
+
+namespace Lightquark.NET.Objects.Reply;
+
+public record QuarkReplyResponse : BaseReplyResponse
+{
+    [JsonProperty("quark")] 
+    public Quark? Quark;
+}
\ No newline at end of file
diff --git a/Lightquark.NET/Util/Converters/ChannelCreationConverter.cs b/Lightquark.NET/Util/Converters/ChannelCreationConverter.cs
new file mode 100644
index 0000000..67c8999
--- /dev/null
+++ b/Lightquark.NET/Util/Converters/ChannelCreationConverter.cs
@@ -0,0 +1,13 @@
+using Lightquark.NET.Objects;
+using Lightquark.Types.Mongo;
+using Newtonsoft.Json.Converters;
+
+namespace Lightquark.NET.Util.Converters;
+
+public class ChannelCreationConverter : CustomCreationConverter<IChannel>
+{
+    public override IChannel Create(Type objectType)
+    {
+        return new Channel();
+    }
+}
\ No newline at end of file
-- 
GitLab