diff --git a/Blacklight.sln.DotSettings.user b/Blacklight.sln.DotSettings.user index b1ac598d89d1c8a8a413c7574c876ebb0e2e0d00..f1fd528f5d53e5cbb2a12ef757282ae170f664bb 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 96cf1224b61ddd0bc6bdea995fb15f25bc17e52e..7253eff06d9441cb40e2cfc342d862a6d5b314d7 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 0000000000000000000000000000000000000000..0bcb2da65d7233f093da92211ef8a5f1bc9f6831 --- /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 44c00074ac4234392fd5e5761fdbd83f233bf2d2..26b27b011bf5bc42fbcd42bd74e76e211adaa349 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 741d1481af3898688beb913456015777ecab2bf4..efbfd81a33860dd50ba0cca0b8e97c88673eacbf 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 108a2b3bf93ad52d73a7c340a709544e58ff54eb..263aac8c76fd1886bef808d48c9be14a3164dc11 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 1621953d9cc96d8b2eaf1e57644e4d15de8c0f85..369cf54523e9aea8d143479dff4d6eecd3f7932f 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 41e4f7383dbc6247444eb1577738fcaff543ee76..d297dbf3cafd23f650f77c5ce77335128188e353 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 64828e21e2781c7aea4f47e1b3a6674e8729a624..12532ac0cc605fb7077e77b376f055370b4f6f35 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 12393e044496cb470aa8eaf2b2ac5b7b17014fed..0e17f96a38f11c05604a2ab721755ae8256f181c 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 c018410b3aa49094e3e509d16f05ac568d7412f1..df35aecae41ff78f3554cf6c91e27777bc165683 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 0000000000000000000000000000000000000000..25ab170a3023b22905e9aab76da68b40f7be28f5 --- /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 574734109538a9c729047597af5782e4791f14fc..8de712c0b6b516a30d08269c6413cdbccb30f7fe 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 0000000000000000000000000000000000000000..db44a8dd489ce237785b872699bad47c568ebdd6 --- /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 8c716ab00fc6d98b8e4f7a37419e9bf179149436..a9c0ef9de8f5eee030eb9aeba41492ef24ea1d80 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 88a28bb56d1964ad29cdcef52b504c3dbb202713..220f8b12f8106c4d360f78cade6023d21e46d877 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 9e7f146c18ec5c975336af0d6608290c5c517bd1..451574cc0c2021f0e8a225612934c2fc3ffd7695 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 0000000000000000000000000000000000000000..5608dc436bf265d7b970661d66b8ecbbde93529b --- /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 0000000000000000000000000000000000000000..d044f8f2252a0ef0455d24633e5a0b0fa0887a2f --- /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 0000000000000000000000000000000000000000..dd41ae2009b6405a8fa6762bd401ac26514c75aa --- /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 0000000000000000000000000000000000000000..f095b2bba6c916c2eba830da013c8b246c3c6435 --- /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 0000000000000000000000000000000000000000..67c89994a7931e0a18440accfa4ee84a7fc31e31 --- /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