diff --git a/Blacklight.sln.DotSettings.user b/Blacklight.sln.DotSettings.user index 6afa4c6e38a77283682f0bf60cc04f1f9bb21deb..b1ac598d89d1c8a8a413c7574c876ebb0e2e0d00 100644 --- a/Blacklight.sln.DotSettings.user +++ b/Blacklight.sln.DotSettings.user @@ -8,6 +8,7 @@ <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHtmlControl_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003F58931fa041a846b53462c4c4c5f52620bf9de09eff582aa4b1ed83c83e4e10ea_003FHtmlControl_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <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_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> </wpf:ResourceDictionary> \ No newline at end of file diff --git a/Blacklight/App.axaml b/Blacklight/App.axaml index abc01e617299c8294a6e9532241f3672e393349c..96cf1224b61ddd0bc6bdea995fb15f25bc17e52e 100644 --- a/Blacklight/App.axaml +++ b/Blacklight/App.axaml @@ -24,31 +24,7 @@ <Style Selector="idc|DockControl"> <Setter Property="(ids:DockProperties.ControlRecycling)" Value="{StaticResource ControlRecyclingKey}" /> </Style> - <Style Selector="ToolChromeControl"> - <Setter Property="Template"> - <ControlTemplate> - <Grid RowDefinitions="40,*" x:DataType="controls:IToolDock" x:CompileBindings="True" Margin="0"> - <Border Grid.Row="0" BorderBrush="{DynamicResource ResourceExplorerBorderColor}" BorderThickness="0, 0,0, 1"> - <Grid x:Name="PART_Grip" Background="{DynamicResource ResourceExplorerColor}"> - <StackPanel Orientation="Horizontal" Margin="5, 2, 2, 2"> - <Image VerticalAlignment="Center" Height="32" Width="32" Source="avares://Blacklight/Assets/t5.png" /> - <TextBlock Margin="6,0,0,0" FontSize="14" VerticalAlignment="Center">Emilia</TextBlock> - </StackPanel> - <Button x:Name="PART_MenuButton" IsVisible="False" /> - <Button x:Name="PART_PinButton" IsVisible="False" /> - <Button x:Name="PART_MaximizeRestoreButton" IsVisible="False" /> - <Button x:Name="PART_CloseButton" IsVisible="False" /> - </Grid> - </Border> - <ContentPresenter x:Name="PART_ContentPresenter" - BorderThickness="0, 0, 0, 0" - Content="{TemplateBinding Content}" - Grid.Row="1" /> - </Grid> - </ControlTemplate> - </Setter> - </Style> <Style Selector="Border#PART_Border"> <Setter Property="BorderThickness" Value="0" /> diff --git a/Blacklight/App.axaml.cs b/Blacklight/App.axaml.cs index 0f9ba21a945e33d702532c64d71fbc3533297e9e..06f9727bc02a2cc8da5346066ae7ba54da7711ae 100644 --- a/Blacklight/App.axaml.cs +++ b/Blacklight/App.axaml.cs @@ -8,6 +8,7 @@ using Avalonia.Markup.Xaml; using Blacklight.Util; using Blacklight.ViewModels; using Blacklight.Views; +using Blacklight.Views.Login; using Lightquark.NET; using Microsoft.Extensions.DependencyInjection; @@ -25,37 +26,48 @@ public partial class App : Application public override void OnFrameworkInitializationCompleted() { - var services = new ServiceCollection(); + if (Services == null) + { + var services = new ServiceCollection(); - services.AddSingleton<MainWindowViewModel>(); - services.AddSingleton<ViewNavigationService>(); - services.AddSingleton<Client>(); + services.AddSingleton<MainWindowViewModel>(); + services.AddSingleton<ViewNavigationService>(); + services.AddSingleton<Client>(); - services.AddSingleton<ClientViewModel>(); - services.AddSingleton<LoginViewModel>(); + services.AddSingleton<ClientViewModel>(); + services.AddSingleton<LoginViewModel>(); - var serviceProvider = services.BuildServiceProvider(); - Services = serviceProvider; + var serviceProvider = services.BuildServiceProvider(); + Services = serviceProvider; - serviceProvider.GetRequiredService<ViewNavigationService>(); - var mainWindowViewModel = serviceProvider.GetRequiredService<MainWindowViewModel>(); - var loginViewModel = serviceProvider.GetRequiredService<LoginViewModel>(); - var client = serviceProvider.GetRequiredService<Client>(); - client.OnLogOut = () => - { - mainWindowViewModel.CurrentViewModel = loginViewModel; - var path = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "blacklight", "login.json"); - try - { - Console.WriteLine("Deleting login.json"); - File.Delete(path); - } - catch (Exception ex) + serviceProvider.GetRequiredService<ViewNavigationService>(); + var mainWindowVm = serviceProvider.GetRequiredService<MainWindowViewModel>(); + var loginViewModel = serviceProvider.GetRequiredService<LoginViewModel>(); + var client = serviceProvider.GetRequiredService<Client>(); + client.OnLogOut = () => { - Console.Error.WriteLine(ex); - } - }; - var clientViewModel = serviceProvider.GetRequiredService<ClientViewModel>(); + mainWindowVm.CurrentViewModel = loginViewModel; + loginViewModel.CurrentControl = new MainLoginPrompt(); + var path = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "blacklight", "login.json"); + try + { + Console.WriteLine("Deleting login.json (1)"); + File.Delete(path); + } + catch (Exception ex) + { + Console.Error.WriteLine(ex); + } + }; + } + else + { + Console.Error.WriteLine("Services not null when entering OnFrameworkInitializationCompleted"); + } + + var mainWindowViewModel = Services.GetRequiredService<MainWindowViewModel>(); + var clientViewModel = Services.GetRequiredService<ClientViewModel>(); + switch (ApplicationLifetime) { @@ -78,21 +90,9 @@ public partial class App : Application clientViewModel.CloseLayout(); }; - break; - } - case ISingleViewApplicationLifetime singleViewLifetime: - { - var mainView = new ClientView - { - DataContext = mainWindowViewModel - }; - - singleViewLifetime.MainView = mainView; - break; } } - base.OnFrameworkInitializationCompleted(); } diff --git a/Blacklight/Models/Tools/ResourceExplorer.cs b/Blacklight/Models/Tools/ResourceExplorer.cs index 430562bf7b8b7f4236329e50ad428bbf0c6fc138..c93c7dc29a309e2a55072581f8ed6ce905848162 100644 --- a/Blacklight/Models/Tools/ResourceExplorer.cs +++ b/Blacklight/Models/Tools/ResourceExplorer.cs @@ -1,4 +1,4 @@ -namespace Blacklight.Models.Tools; +namespace Blacklight.Models.Tools; public class ResourceExplorer { diff --git a/Blacklight/Util/ViewNavigationService.cs b/Blacklight/Util/ViewNavigationService.cs index 4ee93d486fc98915b9a6cbf830c4c83f812f60ba..380401c1afaf5838c2a0abb6488645f4f9108bd6 100644 --- a/Blacklight/Util/ViewNavigationService.cs +++ b/Blacklight/Util/ViewNavigationService.cs @@ -21,12 +21,13 @@ public class ViewNavigationService _mainWindowViewModel.CurrentViewModel = _serviceProvider.GetRequiredService<LoginViewModel>(); } - public void ShowView<TViewModel>() where TViewModel : class + public void ShowView<TViewModel>() where TViewModel : ViewModelBase { var viewModel = _serviceProvider.GetService<TViewModel>(); if (viewModel != null) { _mainWindowViewModel!.CurrentViewModel = viewModel; + viewModel.OnEnterView(); } else { diff --git a/Blacklight/ViewModels/ClientViewModel.cs b/Blacklight/ViewModels/ClientViewModel.cs index c2c01f4166c40ac95fffac6230e596d0136cc961..741d1481af3898688beb913456015777ecab2bf4 100644 --- a/Blacklight/ViewModels/ClientViewModel.cs +++ b/Blacklight/ViewModels/ClientViewModel.cs @@ -1,4 +1,6 @@ -using System.Collections.ObjectModel; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Diagnostics; using System.IO; using System.Windows.Input; @@ -11,6 +13,7 @@ using Dock.Model.Controls; using Dock.Model.Core; using Dock.Model.Mvvm.Controls; using Lightquark.NET; +using Lightquark.NET.Objects.Reply; using Newtonsoft.Json; using Newtonsoft.Json.Converters; diff --git a/Blacklight/ViewModels/LoginViewModel.cs b/Blacklight/ViewModels/LoginViewModel.cs index 203c6f3a309c77b7ce4914f1161cfc5861f67ce8..ea9fc3c7aaa806a3b800d7e7beb7472fa7061ce2 100644 --- a/Blacklight/ViewModels/LoginViewModel.cs +++ b/Blacklight/ViewModels/LoginViewModel.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Threading; +using System.Threading.Tasks; using System.Windows.Input; using Avalonia.Media.Imaging; using Avalonia.Platform; @@ -78,11 +79,13 @@ public class LoginViewModel : ViewModelBase _nav = nav; Client = client; Status = "Get network"; + Client.OnRefresh = SavePersist; var path = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "blacklight", "login.json"); if (Path.Exists(path)) { try { + Console.WriteLine("Found login.json, loading existing data"); var json = File.ReadAllText(path); var persistentData = JsonConvert.DeserializeObject<LoginViewModelPersistent>(json); LoadExistingData(persistentData!); @@ -94,6 +97,7 @@ public class LoginViewModel : ViewModelBase } else { + Console.WriteLine("No login.json"); SwitchNetwork(); _currentControl = new MainLoginPrompt(); } @@ -101,20 +105,31 @@ public class LoginViewModel : ViewModelBase public async void LoadExistingData(LoginViewModelPersistent persistentData) { - LoadingText = "Connecting to network"; - var network = await Client.GetNetworkAsync(new Uri(persistentData.NetworkBase)); - if (network.success) + try { - Client.UseToken(null, "temp"); - Client.NetworkInformation = network.networkInformation; - Client.UseToken(persistentData.AccessToken, persistentData.RefreshToken); - Console.WriteLine("Found token, showing ClientView"); - _nav.ShowView<ClientViewModel>(); - _currentControl = new MainLoginPrompt(); + + Console.WriteLine($"Connecting to {persistentData.NetworkBase} from login.json"); + LoadingText = "Connecting to network"; + Network = persistentData.NetworkBase; + var network = await Client.GetNetworkAsync(new Uri(persistentData.NetworkBase)); + Console.WriteLine($"Fetch successful? {network.success} ({network.error})"); + if (network.success) + { + Client.NetworkInformation = network.networkInformation; + Client.UseToken(persistentData.AccessToken, persistentData.RefreshToken); + Console.WriteLine("Found token, showing ClientView"); + EnterClient(); + } + else + { + LoadingText = $"{network.error}"; + LoadingError = true; + } } - else + catch (UriFormatException ex) { - LoadingText = $"{network.error}"; + Console.Error.WriteLine(ex); + LoadingText = $"{ex.Message}"; LoadingError = true; } } @@ -153,7 +168,7 @@ public class LoginViewModel : ViewModelBase var path = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "blacklight", "login.json"); try { - Console.WriteLine("Deleting login.json"); + Console.WriteLine("Deleting login.json (2)"); File.Delete(path); } catch (Exception ex) @@ -177,16 +192,9 @@ public class LoginViewModel : ViewModelBase var res = await Client.AcquireToken(Email, Password); if (res.success) { - var path = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "blacklight", "login.json"); - Directory.CreateDirectory(Path.GetDirectoryName(path)!); - await File.WriteAllTextAsync(path, JsonConvert.SerializeObject(new LoginViewModelPersistent - { - NetworkBase = Network, - AccessToken = res.accessToken!, - RefreshToken = res.refreshToken! - })); - Console.WriteLine("Logged in, showing ClientView"); - _nav.ShowView<ClientViewModel>(); + await SavePersist(res.accessToken!, res.refreshToken!); + Console.WriteLine("Logged in, entering client"); + EnterClient(); } else { @@ -195,6 +203,33 @@ public class LoginViewModel : ViewModelBase EnableButtons = true; } + private async void EnterClient() + { + LoadingText = "Waiting for gateway connection"; + LoadingError = false; + _currentControl = new Loading(); + Client.GatewayConnectionEvent.Wait(); + LoadingText = "Getting user data"; + await Client.GetCurrentUser(); + _nav.ShowView<ClientViewModel>(); + LoadingText = "Loading?"; + _currentControl = new MainLoginPrompt(); + } + + private async Task SavePersist(string accessToken, string refreshToken) + { + Console.WriteLine($"Saving token {accessToken}"); + var path = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "blacklight", "login.json"); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + await File.WriteAllTextAsync(path, JsonConvert.SerializeObject(new LoginViewModelPersistent + { + NetworkBase = Network, + AccessToken = accessToken, + RefreshToken = refreshToken + })); + Console.WriteLine("Token saved!"); + } + private void EnterNetworkSwitcher() { CurrentControl = new NetworkSelectionPrompt(); diff --git a/Blacklight/ViewModels/MainWindowViewModel.cs b/Blacklight/ViewModels/MainWindowViewModel.cs index 653631d633019fe6d38f2d1d70a603d844a98151..f25cbbcc5e2b812525109e75efa460cd3683c8cb 100644 --- a/Blacklight/ViewModels/MainWindowViewModel.cs +++ b/Blacklight/ViewModels/MainWindowViewModel.cs @@ -4,6 +4,7 @@ using Avalonia.Controls.Shapes; using Avalonia.Media.Imaging; using Avalonia.Platform; using Blacklight.Util; +using Blacklight.Views.Login; using Lightquark.NET; using Microsoft.Extensions.DependencyInjection; using Path = System.IO.Path; @@ -52,23 +53,8 @@ public class MainWindowViewModel : ViewModelBase public MainWindowViewModel() { - Directory.CreateDirectory(Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "blacklight")); - var services = new ServiceCollection(); - services.AddSingleton<MainWindowViewModel>(_ => this); - services.AddSingleton<Client>(); - services.AddSingleton<ViewNavigationService>(); - services.AddSingleton<ClientViewModel>(); - services.AddSingleton<LoginViewModel>(); - - var serviceProvider = services.BuildServiceProvider(); - App.Services = serviceProvider; + var serviceProvider = App.Services!; var nav = serviceProvider.GetRequiredService<ViewNavigationService>(); - var loginViewModel = serviceProvider.GetRequiredService<LoginViewModel>(); - var client = serviceProvider.GetRequiredService<Client>(); - client.OnLogOut = () => - { - CurrentViewModel = loginViewModel; - }; nav.SetMainWindowViewModel(this); _currentViewModel = serviceProvider.GetRequiredService<LoginViewModel>(); } diff --git a/Blacklight/ViewModels/ViewModelBase.cs b/Blacklight/ViewModels/ViewModelBase.cs index 8a7abc6a26d4b93c2a28551b4618fe7b167689bc..290a034ba215e66c6d2c573f346e0101aeca27a3 100644 --- a/Blacklight/ViewModels/ViewModelBase.cs +++ b/Blacklight/ViewModels/ViewModelBase.cs @@ -4,4 +4,7 @@ namespace Blacklight.ViewModels; public class ViewModelBase : ObservableObject { + public virtual void OnEnterView() + { + } } \ No newline at end of file diff --git a/Blacklight/Views/ClientView.axaml b/Blacklight/Views/ClientView.axaml index 83eed77992fc3aae62da22978ab737eb39b4d63b..1621953d9cc96d8b2eaf1e57644e4d15de8c0f85 100644 --- a/Blacklight/Views/ClientView.axaml +++ b/Blacklight/Views/ClientView.axaml @@ -7,6 +7,8 @@ xmlns:vm="using:Blacklight.ViewModels" xmlns:tools="clr-namespace:Blacklight.Views.Tools" xmlns:viewModels="clr-namespace:Blacklight.ViewModels" + xmlns:controls="clr-namespace:Dock.Model.Controls;assembly=Dock.Model" + xmlns:views="clr-namespace:Blacklight.Views" mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="550" x:CompileBindings="True" @@ -16,6 +18,34 @@ <vm:ClientViewModel /> </Design.DataContext> + <UserControl.Styles> + <Style Selector="ToolChromeControl"> + <Setter Property="Template"> + <ControlTemplate > + <Grid RowDefinitions="40,*" x:DataType="controls:IToolDock" x:CompileBindings="True" Margin="0"> + <Border Grid.Row="0" BorderBrush="{DynamicResource ResourceExplorerBorderColor}" BorderThickness="0, 0,0, 1"> + <Grid x:Name="PART_Grip" Background="{DynamicResource ResourceExplorerColor}"> + <StackPanel Orientation="Horizontal" Margin="5, 2, 2, 2"> + <Image VerticalAlignment="Center" Height="32" Width="32" Source="{Binding $parent[views:ClientView].((vm:ClientViewModel)DataContext).Client.CurrentUser.Avatar }" /> + <TextBlock Margin="6,0,0,0" FontSize="14" VerticalAlignment="Center" Text="{Binding $parent[views:ClientView].((vm:ClientViewModel)DataContext).Client.CurrentUser.Username }" /> + </StackPanel> + + <Button x:Name="PART_MenuButton" IsVisible="False" /> + <Button x:Name="PART_PinButton" IsVisible="False" /> + <Button x:Name="PART_MaximizeRestoreButton" IsVisible="False" /> + <Button x:Name="PART_CloseButton" IsVisible="False" /> + </Grid> + </Border> + <ContentPresenter x:Name="PART_ContentPresenter" + BorderThickness="0, 0, 0, 0" + Content="{TemplateBinding Content}" + Grid.Row="1" /> + </Grid> + </ControlTemplate> + </Setter> + </Style> + </UserControl.Styles> + <Grid RowDefinitions="Auto,*,20" ColumnDefinitions="Auto,*,40" Background="Transparent"> <Border BorderThickness="0,0,0,1" diff --git a/Lightquark.NET/Client.cs b/Lightquark.NET/Client.cs index 19ddf4fd0a22d5e2c414366e3d4203ae53eb2839..e0d22cfda5d521f9b50e95f2a8a42c3b90e0998b 100644 --- a/Lightquark.NET/Client.cs +++ b/Lightquark.NET/Client.cs @@ -1,20 +1,44 @@ using System.Collections.ObjectModel; using System.ComponentModel; +using AutoMapper; using CommunityToolkit.Mvvm.ComponentModel; using Lightquark.NET.Objects; +using Lightquark.NET.Util; using Lightquark.NET.Util.Converters; +using MongoDB.Bson; using Newtonsoft.Json; using Newtonsoft.Json.Serialization; namespace Lightquark.NET; +public enum GatewayConnectState +{ + NotConnected, + Connecting, + Connected +} + 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; } = []; + + public User? CurrentUser + { + get => _currentUser; + set => SetProperty(ref _currentUser, value); + } + public Action? OnLogOut; + public Func<string, string, Task>? OnRefresh; + public EventBus EventBus = new(); + public GatewayConnectState GatewayStatus { get; set; } = GatewayConnectState.NotConnected; + private string? _accessToken; private string? AccessToken @@ -37,6 +61,8 @@ public partial class Client : ObservableObject private EnrichedNetworkInformation? _networkInformation; private HttpClient _httpClient = new(); + private User? _currentUser; + private IMapper _mapper; public Client() { @@ -44,9 +70,14 @@ public partial class Client : ObservableObject { Formatting = Formatting.Indented, NullValueHandling = NullValueHandling.Ignore, - Converters = new List<JsonConverter> { new AttachmentCreationConverter() }, + Converters = new List<JsonConverter> { new AttachmentCreationConverter(), new StatusCreationConverter() }, ContractResolver = new CamelCasePropertyNamesContractResolver() }; + var mapperConfig = new MapperConfiguration(cfg => + { + cfg.CreateMap<User, User>().ForAllMembers(opts => opts.Condition((src, dest, srcMember) => srcMember != null)); + }); + _mapper = mapperConfig.CreateMapper(); } @@ -76,6 +107,11 @@ public partial class Client : ObservableObject { _refreshTimer = new Timer(AcquireToken, null, timeUntilRefresh, Timeout.InfiniteTimeSpan); } + + if (GatewayStatus == GatewayConnectState.NotConnected) + { + InitialConnectGateway(); + } } } Console.WriteLine($"Detected {e.PropertyName} change"); diff --git a/Lightquark.NET/ClientMethods/Gateway.cs b/Lightquark.NET/ClientMethods/Gateway.cs new file mode 100644 index 0000000000000000000000000000000000000000..e9baf1bfdb1ebd46733777f268a0ae408824e612 --- /dev/null +++ b/Lightquark.NET/ClientMethods/Gateway.cs @@ -0,0 +1,172 @@ +using Lightquark.NET.Objects; +using Lightquark.NET.Objects.Reply; +using Newtonsoft.Json; +using Websocket.Client; + +namespace Lightquark.NET; + +public partial class Client +{ + private WebsocketClient? _gateway; + private Timer? _heartbeatTimer; + private string? _gatekeeperSession; + public ManualResetEventSlim GatewayConnectionEvent = new(false); + + private async void InitialConnectGateway() + { + if (NetworkInformation?.Gateway == null) + { + await Console.Error.WriteLineAsync("Network gateway uri null"); + return; + } + GatewayStatus = GatewayConnectState.Connecting; + _gateway = new WebsocketClient(new Uri(NetworkInformation.Gateway)); + _gateway.ReconnectTimeout = null; + _gateway.DisconnectionHappened.Subscribe(info => + { + GatewayConnectionEvent.Reset(); + GatewayStatus = GatewayConnectState.NotConnected; + Console.Error.WriteLine($"Disconnection from gateway, type {info.Type}"); + }); + _gateway.ReconnectionHappened.Subscribe(info => + { + Console.Error.WriteLine($"Reconnection to gateway, type {info.Type}"); + SendGatewayMessage(new { @event = "authenticate", gatekeeperSession = _gatekeeperSession, token = AccessToken }); + }); + _gateway.MessageReceived.Subscribe(msg => + { + try + { + var message = ParseGateway<BaseGatewayMessage>(msg.Text!); + if (message.Event != "heartbeat") + { + Console.WriteLine(msg.Text); + } + EventBus.Publish(new GatewayBusEvent + { + BaseMessage = message, + RawMessageString = msg.Text! + }); + switch (message.Event) + { + case "authenticate": + var authMessage = ParseGateway<AuthGatewayMessage>(msg.Text!); + switch (authMessage.Code) + { + case 200: + { + GatewayStatus = GatewayConnectState.Connected; + GatewayConnectionEvent.Set(); + Console.WriteLine("Connected to gateway!"); + if (_heartbeatTimer == null) + { + _heartbeatTimer = new Timer(Heartbeat, null, TimeSpan.FromSeconds(15), TimeSpan.FromSeconds(15)); + } + + break; + } + case 401: + LogOut(); + break; + default: + Console.Error.WriteLine($"Auth failure to gateway! Code {authMessage.Code}"); + break; + } + break; + case "rpc": + break; + case "userUpdate": + var userUpdateMessage = ParseGateway<UserUpdateGatewayMessage>(msg.Text!); + if (userUpdateMessage.User.Id == CurrentUser?.Id) + { + CurrentUserFromObject(userUpdateMessage.User); + } + + break; + } + } + catch (Exception ex) + { + Console.Error.WriteLine(ex); + } + }); + + await _gateway.Start(); + } + + private void Heartbeat(object? state) + { + SendGatewayMessage(new {@event = "heartbeat"}); + } + + public async Task<Reply<T>> CallRpc<T>(string method, string route, object? body) where T : BaseReplyResponse + { + if (GatewayStatus != GatewayConnectState.Connected) throw new Exception("RPC Call when Gateway not connected!"); + var sem = new SemaphoreSlim(0, 1); + var state = Guid.NewGuid().ToString(); + RpcGatewayMessage<T>? responseBody = null; + var subscriptionId = EventBus.Subscribe<GatewayBusEvent>(busEvent => + { + if (busEvent.BaseMessage.State != state) return; + responseBody = ParseGateway<RpcGatewayMessage<T>>(busEvent.RawMessageString); + sem.Release(); + }); + SendGatewayMessage(new + { + @event = "rpc", + token = AccessToken, + state, + route, + method, + body + }); + await sem.WaitAsync(); + EventBus.Unsubscribe<GatewayBusEvent>(subscriptionId); + + return responseBody!.Body; + } + + private void SendGatewayMessage(object message) + { + _gateway.Send(JsonConvert.SerializeObject(message)); + } + + private T ParseGateway<T>(string message) + { + return JsonConvert.DeserializeObject<T>(message)!; + } +} + +public record BaseGatewayMessage +{ + [JsonProperty("event")] + public required string Event { get; init; } + + [JsonProperty("state")] + public string? State { get; init; } +} + + +public record AuthGatewayMessage : BaseGatewayMessage +{ + [JsonProperty("code")] + public required int Code { get; init; } +} + +public record UserUpdateGatewayMessage : BaseGatewayMessage +{ + [JsonProperty("user")] + public required User User { get; init; } +} + +public record GatewayBusEvent +{ + public required BaseGatewayMessage BaseMessage { get; init; } + public required string RawMessageString { get; init; } +} + +public record RpcGatewayMessage<T> : BaseGatewayMessage where T : BaseReplyResponse +{ + [JsonProperty("body")] + public required Reply<T> Body { get; init; } +} \ No newline at end of file diff --git a/Lightquark.NET/ClientMethods/Login.cs b/Lightquark.NET/ClientMethods/Login.cs index d0be610c2b5ea2bc23b227fde097a3011a38ca0b..3bab6b27ec1af79ee143c5efc2d5e6215e0fa7c8 100644 --- a/Lightquark.NET/ClientMethods/Login.cs +++ b/Lightquark.NET/ClientMethods/Login.cs @@ -63,26 +63,30 @@ public partial class Client { accessToken = AccessToken, refreshToken = RefreshToken }), new MediaTypeHeaderValue("application/json"))); - var parsedRes = JsonConvert.DeserializeObject<Reply<RefreshReplyResponse>>(await res.Content.ReadAsStringAsync()); + var parsedRes = + JsonConvert.DeserializeObject<Reply<RefreshReplyResponse>>(await res.Content.ReadAsStringAsync()); if (parsedRes == null) { throw new Exception("Parsing failure"); } - + switch (res.StatusCode) { case HttpStatusCode.OK: AccessToken = parsedRes.Response.AccessToken; Console.WriteLine("Token refreshed! Happy days."); + var refreshHandlerTask = OnRefresh?.Invoke(parsedRes.Response.AccessToken!, RefreshToken!); + if (refreshHandlerTask != null) await refreshHandlerTask; break; default: - await Console.Error.WriteLineAsync($"Failed to refresh ({res.StatusCode}) ({parsedRes?.Response?.Message})"); + await Console.Error.WriteLineAsync( + $"Failed to refresh ({res.StatusCode}) ({parsedRes?.Response?.Message})"); AccessToken = null; RefreshToken = null; break; } - + } catch (Exception ex) { @@ -93,8 +97,10 @@ public partial class Client public void LogOut() { + Console.WriteLine("LogOut"); AccessToken = null; RefreshToken = null; + _gateway.Dispose(); } public void RunLogOut() diff --git a/Lightquark.NET/ClientMethods/User.cs b/Lightquark.NET/ClientMethods/User.cs new file mode 100644 index 0000000000000000000000000000000000000000..4c32f1b463b519b0910f1c392f5ec342ffd888cb --- /dev/null +++ b/Lightquark.NET/ClientMethods/User.cs @@ -0,0 +1,31 @@ +using Avalonia.Media.Imaging; +using Lightquark.NET.Objects; +using Lightquark.NET.Objects.Reply; + +namespace Lightquark.NET; + +public partial class Client +{ + public async Task GetCurrentUser() + { + var res = await CallRpc<UserReplyResponse>("GET", "/v4/user/me", null); + if (!res.Request.Success) throw new Exception("Failed to fetch user"); + CurrentUserFromObject(res.Response.User!); + } + + private async void CurrentUserFromObject(User user) + { + try + { + var avatarRes = await _httpClient.GetAsync(user!.AvatarUriGetter); + user.Avatar = new Bitmap(await avatarRes.Content.ReadAsStreamAsync()); + } + catch (Exception ex) + { + Console.Error.WriteLine(ex); + } + CurrentUser = _mapper.Map(user, CurrentUser); + Console.WriteLine(CurrentUser.Status.PrimaryText); + Console.WriteLine(CurrentUser.AvatarUriGetter); + } +} \ No newline at end of file diff --git a/Lightquark.NET/Lightquark.NET.csproj b/Lightquark.NET/Lightquark.NET.csproj index 4463863201054c4605436c925058df1a07d18d84..2cb7d5bd3070f714c6e970c08cef75f5e60b5c38 100644 --- a/Lightquark.NET/Lightquark.NET.csproj +++ b/Lightquark.NET/Lightquark.NET.csproj @@ -7,14 +7,12 @@ </PropertyGroup> <ItemGroup> + <PackageReference Include="AutoMapper" Version="13.0.1" /> <PackageReference Include="Avalonia" Version="11.1.3" /> <PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2" /> <PackageReference Include="Lightquark.Types" Version="2024.9.15.139" /> <PackageReference Include="Markdig" Version="0.37.0" /> - </ItemGroup> - - <ItemGroup> - <Folder Include="Util\" /> + <PackageReference Include="Websocket.Client" Version="5.1.2" /> </ItemGroup> </Project> diff --git a/Lightquark.NET/Objects/Reply/UserReply.cs b/Lightquark.NET/Objects/Reply/UserReply.cs new file mode 100644 index 0000000000000000000000000000000000000000..d2c3cd560610db2ac9e5e9a58f13dba7bf020347 --- /dev/null +++ b/Lightquark.NET/Objects/Reply/UserReply.cs @@ -0,0 +1,9 @@ +using Newtonsoft.Json; + +namespace Lightquark.NET.Objects.Reply; + +public record UserReplyResponse : BaseReplyResponse +{ + [JsonProperty("user")] + public User? User; +} \ No newline at end of file diff --git a/Lightquark.NET/Objects/Status.cs b/Lightquark.NET/Objects/Status.cs index 642938c49e3960aa5b8160b1764ba954a3384b75..90eb5b7369024a88d00e8d408b4b31c886ba1ba4 100644 --- a/Lightquark.NET/Objects/Status.cs +++ b/Lightquark.NET/Objects/Status.cs @@ -10,15 +10,15 @@ public class Status : IStatus { [JsonProperty("_id")] [JsonConverter(typeof(ObjectIdConverter))] - public required ObjectId Id { get; set; } + public ObjectId Id { get; set; } [JsonIgnore] - public required ObjectId UserId { get; set; } + public ObjectId UserId { get; set; } [JsonConverter(typeof(LowerCaseStringEnumConverter))] - public required StatusType Type { get; set; } + public StatusType Type { get; set; } - public required string PrimaryText { get; set; } + public string PrimaryText { get; set; } public string? Text1 { get; set; } diff --git a/Lightquark.NET/Objects/User.cs b/Lightquark.NET/Objects/User.cs index 70ab097bd2a1b0b64f7976aa44eb4a2bcc8151b5..968d7bc34e6c31d78ddd12420cab8335d3279d72 100644 --- a/Lightquark.NET/Objects/User.cs +++ b/Lightquark.NET/Objects/User.cs @@ -1,53 +1,103 @@ -using Lightquark.NET.Util.Converters; +using System.Diagnostics.CodeAnalysis; +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 User : IUser +public class User : ObservableObject, IUser { + private Bitmap _avatar; + private IStatus? _status; + private string? _pronouns; + private ObjectId _id; + private string _email; + private string _username; + private bool _admin; + private bool _isBot; + private bool _secretThirdThing; + private Uri _avatarUriGetter; + [JsonProperty("_id")] [JsonConverter(typeof(ObjectIdConverter))] - public ObjectId Id { get; set; } + public ObjectId Id + { + get => _id; + set => SetProperty(ref _id, value); + } [JsonIgnore] public required byte[] PasswordHash { get; init; } - + [JsonProperty("email")] - public required string Email { get; set; } - + public required string Email + { + get => _email; + [MemberNotNull(nameof(_email))] set => SetProperty(ref _email, value); + } + [JsonIgnore] public required byte[] Salt { get; init; } - + [JsonProperty("username")] - public required string Username { get; set; } - + public required string Username + { + get => _username; + [MemberNotNull(nameof(_username))] set => SetProperty(ref _username, value); + } + [JsonProperty("admin")] - public bool Admin { get; set; } - + public bool Admin + { + get => _admin; + set => SetProperty(ref _admin, value); + } + [JsonProperty("isBot")] - public bool IsBot { get; set; } - + public bool IsBot + { + get => _isBot; + set => SetProperty(ref _isBot, value); + } + [JsonProperty("secretThirdThing")] - public bool SecretThirdThing { get; set; } - - [JsonProperty("pronouns")] - public string? Pronouns { get; set; } + public bool SecretThirdThing + { + get => _secretThirdThing; + set => SetProperty(ref _secretThirdThing, value); + } + [JsonProperty("pronouns")] + public string? Pronouns + { + get => _pronouns; + set => SetProperty(ref _pronouns, value); + } - // Virtual fields - [JsonIgnore] - public Status[]? VirtualStatuses { get; set; } // Store multiple statuses + [JsonProperty("status")] + public IStatus? Status + { + get => _status; + set => SetProperty(ref _status, value); + } - public IStatus? Status => VirtualStatuses?.FirstOrDefault(); + public Bitmap Avatar + { + get => _avatar; + set => SetProperty(ref _avatar, value); + } // Calculated field - [JsonIgnore] - public Uri? AvatarUri { get; set; } + [JsonProperty("avatarUri")] + public Uri AvatarUriGetter + { + get => _avatarUriGetter; + set => SetProperty(ref _avatarUriGetter, value); + } - [JsonProperty("avatarUri")] public Uri AvatarUriGetter => AvatarUri!; - [JsonIgnore] [JsonConverter(typeof(ObjectIdConverter))] public ObjectId? AvatarFileId { get; set; } diff --git a/Lightquark.NET/Util/Converters/StatusCreationConverter.cs b/Lightquark.NET/Util/Converters/StatusCreationConverter.cs new file mode 100644 index 0000000000000000000000000000000000000000..bb02ad89f10cc3edd6ca9dd5380383505dc3ded0 --- /dev/null +++ b/Lightquark.NET/Util/Converters/StatusCreationConverter.cs @@ -0,0 +1,13 @@ +using Lightquark.NET.Objects; +using Lightquark.Types.Mongo; +using Newtonsoft.Json.Converters; + +namespace Lightquark.NET.Util.Converters; + +public class StatusCreationConverter : CustomCreationConverter<IStatus> +{ + public override IStatus Create(Type objectType) + { + return new Status(); + } +} \ No newline at end of file diff --git a/Lightquark.NET/Util/EventBus.cs b/Lightquark.NET/Util/EventBus.cs new file mode 100644 index 0000000000000000000000000000000000000000..5f96b2acd36492f1c21f45c84e9e3971183ac66a --- /dev/null +++ b/Lightquark.NET/Util/EventBus.cs @@ -0,0 +1,52 @@ +namespace Lightquark.NET.Util; + +public class EventBus +{ + private readonly Dictionary<Type, List<EventBusSubscription>> _subscriptions = new(); + + public void Publish<TEvent>(TEvent @event) + { + var eventType = typeof(TEvent); + if (@event == null || !_subscriptions.TryGetValue(eventType, out var handlers)) return; + Task.Run(() => + { + foreach (var subscription in handlers) + { + subscription.Handler(@event); + } + }); + } + + public Guid Subscribe<TEvent>(Action<TEvent> handler) + { + var eventType = typeof(TEvent); + if (!_subscriptions.TryGetValue(eventType, out var value)) + { + value = []; + _subscriptions[eventType] = value; + } + + var subscription = new EventBusSubscription + { + Handler = e => handler((TEvent)e), + SubscriptionId = Guid.NewGuid() + }; + value.Add(subscription); + return subscription.SubscriptionId; + } + + public void Unsubscribe<TEvent>(Guid subscriptionId) + { + var eventType = typeof(TEvent); + if (_subscriptions.TryGetValue(eventType, out var value)) + { + value.RemoveAll(s => s.SubscriptionId == subscriptionId); + } + } +} + +public record EventBusSubscription +{ + public required Action<object> Handler; + public required Guid SubscriptionId; +} \ No newline at end of file