diff --git a/.gitignore b/.gitignore index 8afdcb6..0ec3d0d 100644 --- a/.gitignore +++ b/.gitignore @@ -452,3 +452,21 @@ $RECYCLE.BIN/ !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json + +## +## GalaxyViewer specific build things +## + +# AppImage files +*.AppImage + +# Build output directory (already covered by publish/ above) +/publish/ + +# Desktop files generated during build +*.desktop + +# LinuxDeploy build artifacts +linuxdeploy-output/ +linuxdeploy-*.AppImage +appimagetool-*.AppImage diff --git a/Directory.Build.props b/Directory.Build.props index 37fd070..ee78009 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,10 +1,8 @@ - 0.1.0 GalaxyViewer GalaxyViewer is a cross-platform viewer for Second Life and NGC OpenSimulator. Galaxy Littlepaws enable - 11.1.0 \ No newline at end of file diff --git a/GalaxyViewer.Android/GalaxyViewer.Android.csproj b/GalaxyViewer.Android/GalaxyViewer.Android.csproj index 59e4709..02867a9 100644 --- a/GalaxyViewer.Android/GalaxyViewer.Android.csproj +++ b/GalaxyViewer.Android/GalaxyViewer.Android.csproj @@ -15,8 +15,8 @@ - - + + diff --git a/GalaxyViewer.Android/MainActivity.cs b/GalaxyViewer.Android/MainActivity.cs index 01e23ec..870f731 100644 --- a/GalaxyViewer.Android/MainActivity.cs +++ b/GalaxyViewer.Android/MainActivity.cs @@ -1,14 +1,9 @@ -using Android; using Android.App; using Android.Content.PM; -using Android.OS; -using Android.Widget; -using AndroidX.Core.App; -using AndroidX.Core.Content; +using Android.Views; using Avalonia; using Avalonia.Android; using Avalonia.ReactiveUI; -using GalaxyViewer.Services; namespace GalaxyViewer.Android { @@ -17,60 +12,11 @@ namespace GalaxyViewer.Android Theme = "@style/MyTheme.NoActionBar", Icon = "@drawable/icon", MainLauncher = true, - ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)] + WindowSoftInputMode = SoftInput.AdjustResize, + ConfigurationChanges = + ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)] public class MainActivity : AvaloniaMainActivity { - const int RequestStorageId = 0; - - protected override void OnCreate(Bundle savedInstanceState) - { - base.OnCreate(savedInstanceState); - - if (ContextCompat.CheckSelfPermission(this, Manifest.Permission.WriteExternalStorage) != Permission.Granted) - { - ActivityCompat.RequestPermissions(this, new string[] { Manifest.Permission.WriteExternalStorage }, RequestStorageId); - } - else - { - LoadResources(); - // TODO: Fix this so that it doesn't instantly crash and asks for permission before loading resources - } - } - - public override void OnRequestPermissionsResult(int requestCode, string[] permissions, Permission[] grantResults) - { - base.OnRequestPermissionsResult(requestCode, permissions, grantResults); - - if (requestCode == RequestStorageId) - { - if (grantResults.Length > 0 && grantResults[0] == Permission.Granted) - { - // Permission granted, proceed with loading resources - LoadResources(); - } - else - { - // Permission denied, show a message to the user - var message = "Permission to access storage denied. The application will not be able to load resources."; - var toast = Toast.MakeText(this, message, ToastLength.Long); - toast.Show(); - } - } - } - - private void LoadResources() - { - // Load your resources here - InitializeLiteDbService(); - } - - private void InitializeLiteDbService() - { - // Initialize LiteDbService here - var liteDbService = new LiteDbService(); - // Use liteDbService as needed - } - protected override AppBuilder CustomizeAppBuilder(AppBuilder builder) { return base.CustomizeAppBuilder(builder) diff --git a/GalaxyViewer.Android/Properties/AndroidManifest.xml b/GalaxyViewer.Android/Properties/AndroidManifest.xml index bc9a332..c265097 100644 --- a/GalaxyViewer.Android/Properties/AndroidManifest.xml +++ b/GalaxyViewer.Android/Properties/AndroidManifest.xml @@ -1,14 +1,38 @@ - + + - - - + + + + + + - + + + + + + + + + + + + + + - + \ No newline at end of file diff --git a/GalaxyViewer.Desktop/GalaxyViewer.Desktop.csproj b/GalaxyViewer.Desktop/GalaxyViewer.Desktop.csproj index 194d330..98795d8 100644 --- a/GalaxyViewer.Desktop/GalaxyViewer.Desktop.csproj +++ b/GalaxyViewer.Desktop/GalaxyViewer.Desktop.csproj @@ -13,9 +13,9 @@ - + - + diff --git a/GalaxyViewer.Desktop/Program.cs b/GalaxyViewer.Desktop/Program.cs index 49004d9..b5f5b51 100644 --- a/GalaxyViewer.Desktop/Program.cs +++ b/GalaxyViewer.Desktop/Program.cs @@ -1,4 +1,4 @@ -using System; +using System; using Avalonia; using Avalonia.ReactiveUI; @@ -20,4 +20,4 @@ sealed class Program .WithInterFont() .LogToTrace() .UseReactiveUI(); -} +} \ No newline at end of file diff --git a/GalaxyViewer/App.axaml b/GalaxyViewer/App.axaml index fb4985b..866e191 100644 --- a/GalaxyViewer/App.axaml +++ b/GalaxyViewer/App.axaml @@ -10,6 +10,7 @@ + @@ -18,8 +19,15 @@ + - + + + + + + + \ No newline at end of file diff --git a/GalaxyViewer/App.axaml.cs b/GalaxyViewer/App.axaml.cs index 55b86d7..f023d90 100644 --- a/GalaxyViewer/App.axaml.cs +++ b/GalaxyViewer/App.axaml.cs @@ -1,19 +1,21 @@ using System; using System.ComponentModel; -using System.Diagnostics; using System.IO; using System.Runtime.CompilerServices; using System.Threading.Tasks; using Avalonia; -using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; +using Avalonia.Markup.Xaml.Styling; using Avalonia.Styling; +using Avalonia.Platform; +using Avalonia.Media; using GalaxyViewer.Models; using GalaxyViewer.Services; using GalaxyViewer.ViewModels; using GalaxyViewer.Views; using Microsoft.Extensions.DependencyInjection; +using OpenMetaverse; using Serilog; namespace GalaxyViewer; @@ -22,8 +24,24 @@ public class App : Application, IDisposable { private static IServiceProvider? _serviceProvider; public static PreferencesManager? PreferencesManager { get; private set; } - private static LiteDbService _liteDbService; - private static SessionModel _session; + private static LiteDbService? _liteDbService; + private static GridClient? _gridClient; + + private static bool _isLoggedIn; + private IPlatformSettings? _platformSettings; + + public static event PropertyChangedEventHandler? StaticPropertyChanged; + + public static bool IsLoggedIn + { + get => _isLoggedIn; + set + { + if (_isLoggedIn == value) return; + _isLoggedIn = value; + OnStaticPropertyChanged(); + } + } public App() { @@ -37,15 +55,27 @@ public class App : Application, IDisposable "GalaxyViewer", "logs", "error.log"); Log.Logger = new LoggerConfiguration() - .WriteTo.Console() - .WriteTo.File(logFilePath, rollingInterval: RollingInterval.Day) + .MinimumLevel.Information() + .WriteTo.Console( + outputTemplate: + "[{Timestamp:HH:mm:ss} {Level:u3}] GALAXYVIEWER: {Message:lj}{NewLine}{Exception}") + .WriteTo.File(logFilePath, + rollingInterval: RollingInterval.Day, + outputTemplate: + "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] {Message:lj}{NewLine}{Exception}") .CreateLogger(); } private static void ConfigureServices(IServiceCollection services) { - services.AddSingleton(); - // Register other services here + _gridClient = new GridClient(); + services.AddSingleton(_gridClient); + services.AddSingleton(_ => new LiteDbService(_gridClient)); + services.AddSingleton(provider => + new SessionService( + provider.GetRequiredService(), + provider.GetRequiredService() + )); } public override void Initialize() @@ -54,74 +84,124 @@ public class App : Application, IDisposable ConfigureServices(serviceCollection); _serviceProvider = serviceCollection.BuildServiceProvider(); - _liteDbService = _serviceProvider.GetService(); - if (_liteDbService == null) - { - throw new InvalidOperationException("LiteDbService is not registered."); - } + _liteDbService = _serviceProvider.GetRequiredService(); PreferencesManager = new PreferencesManager(_liteDbService); + PreferencesManager.PreferencesChanged += OnPreferencesChanged; - _session = _liteDbService.GetSession(); - AvaloniaXamlLoader.Load(this); - base.Initialize(); - } - public static bool IsLoggedIn - { - get => _session.IsLoggedIn; - set + try { - if (_session.IsLoggedIn != value) + _platformSettings = PlatformSettings; + if (_platformSettings != null) { - _session.IsLoggedIn = value; - _liteDbService.SaveSession(_session); - OnStaticPropertyChanged(); + _platformSettings.ColorValuesChanged += OnSystemThemeChanged; } } + catch (Exception ex) + { + Log.Warning(ex, "Failed to subscribe to system theme changes"); + } + + base.Initialize(); } public override void OnFrameworkInitializationCompleted() { try { + if (_serviceProvider == null) + { + Log.Error("Service provider is null during framework initialization"); + throw new InvalidOperationException( + "Service provider was not properly initialized"); + } + + var liteDbService = _serviceProvider.GetRequiredService(); + var gridClient = _serviceProvider.GetRequiredService(); + var sessionService = _serviceProvider.GetRequiredService(); + + Log.Information("Services initialized successfully"); + + // Create UI first to avoid blocking switch (ApplicationLifetime) { case IClassicDesktopStyleApplicationLifetime desktop: - Log.Information("Initializing MainWindow for desktop application."); desktop.MainWindow = new MainWindow { - DataContext = new MainViewModel(_liteDbService) + DataContext = new MainViewModel(liteDbService, gridClient, sessionService) }; desktop.MainWindow.Show(); + Log.Information("Desktop main window created and shown"); break; case ISingleViewApplicationLifetime singleViewPlatform: - Log.Information("Initializing MainView for single view application."); - singleViewPlatform.MainView = new MainView - { - DataContext = new MainViewModel(_liteDbService) - }; + Log.Information("Creating MainView for single view platform (Android)"); + break; + default: + Log.Warning("Unknown application lifetime type: {Type}", + ApplicationLifetime?.GetType().Name); break; } + + Task.Run(async () => + { + try + { + if (PreferencesManager != null) + { + var preferences = await PreferencesManager.LoadPreferencesAsync(); + if (preferences != null) + { + ApplyPreferences(preferences); + } + } + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to load initial preferences"); + } + }); } catch (Exception ex) { Log.Error(ex, "An error occurred while initializing the main window."); - throw; // Optionally rethrow the exception if you want to halt the application + throw; } base.OnFrameworkInitializationCompleted(); } - public static event PropertyChangedEventHandler? StaticPropertyChanged; - private static void OnStaticPropertyChanged([CallerMemberName] string propertyName = null) + private static void OnStaticPropertyChanged([CallerMemberName] string? propertyName = null) { StaticPropertyChanged?.Invoke(null, new PropertyChangedEventArgs(propertyName)); } + private void OnSystemThemeChanged(object? sender, PlatformColorValues e) + { + Task.Run(async () => + { + try + { + if (PreferencesManager != null) + { + var preferences = await PreferencesManager.LoadPreferencesAsync(); + if (preferences?.Theme == "System") + { + ApplyPreferences(preferences); + RefreshThemeForAllWindows(); + } + } + } + catch (Exception ex) + { + Log.Warning(ex, "Error handling system theme change"); + } + }); + } + private void OnPreferencesChanged(object? sender, PreferencesModel preferences) { ApplyPreferences(preferences); @@ -132,36 +212,192 @@ public class App : Application, IDisposable { Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => { - RequestedThemeVariant = preferences.Theme switch - { - "Light" => ThemeVariant.Light, - "Dark" => ThemeVariant.Dark, - _ => ThemeVariant.Default - }; + RequestedThemeVariant = GetThemeVariant(preferences.Theme); - // TODO: Apply other preferences + var isDarkTheme = RequestedThemeVariant == ThemeVariant.Dark || + (RequestedThemeVariant == ThemeVariant.Default && + DetectSystemTheme() == ThemeVariant.Dark); + + UpdateAccentColorResource(preferences.AccentColor); + UpdateTextColorResource(isDarkTheme); }); } + private void UpdateAccentColorResource(string accentColorPreference) + { + try + { + IBrush accentBrush; + + if (accentColorPreference == "System Default") + { + accentBrush = GetSystemAccentBrush(); + } + else + { + var isDarkTheme = RequestedThemeVariant == ThemeVariant.Dark || + (RequestedThemeVariant == ThemeVariant.Default && + DetectSystemTheme() == ThemeVariant.Dark); + + var colorValue = + PreferencesOptions.GetAccentColorForTheme(accentColorPreference, isDarkTheme); + + try + { + accentBrush = new SolidColorBrush(Color.Parse(colorValue)); + } + catch + { + accentBrush = GetSystemAccentBrush(); + } + } + + Resources["SystemAccentColorBrush"] = accentBrush; + Resources["SystemAccentColor"] = ((SolidColorBrush)accentBrush).Color; + // Ensure these resources are available for DynamicResource lookups + if (Current == null) return; + Current.Resources["SystemAccentColorBrush"] = accentBrush; + Current.Resources["SystemAccentColor"] = ((SolidColorBrush)accentBrush).Color; + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to update accent color resource"); + } + } + + internal static IBrush GetSystemAccentBrush() + { + try + { + if (Current?.TryGetResource("SystemAccentColorBrush", + Current.ActualThemeVariant, out var resource) == true) + { + if (resource is IBrush brush) + return brush; + } + + if (Current?.TryGetResource("SystemAccentColor", + Current.ActualThemeVariant, out var colorResource) == true) + { + if (colorResource is Color color) + return new SolidColorBrush(color); + } + } + catch + { + // Continue to fallback + } + + // Final fallback to a reasonable blue + return new SolidColorBrush(Color.Parse("#0078D4")); + } + + private void UpdateTextColorResource(bool isDarkTheme) + { + var color = isDarkTheme ? Color.Parse("#F0F0F0") : Color.Parse("#222222"); + var brush = new SolidColorBrush(color); + + Resources["TextColor"] = color; + Resources["TextColorBrush"] = brush; + if (Current != null) + { + Current.Resources["TextColor"] = color; + Current.Resources["TextColorBrush"] = brush; + } + } + + private ThemeVariant GetThemeVariant(string themePreference) + { + return themePreference switch + { + "Light" => ThemeVariant.Light, + "Dark" => ThemeVariant.Dark, + "System" => DetectSystemTheme(), + _ => ThemeVariant.Default + }; + } + + private ThemeVariant DetectSystemTheme() + { + try + { + if (_platformSettings != null) + { + var colorValues = _platformSettings.GetColorValues(); + return colorValues.ThemeVariant == PlatformThemeVariant.Dark + ? ThemeVariant.Dark + : ThemeVariant.Light; + } + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to detect system theme, falling back to default"); + } + + return ThemeVariant.Default; + } + private async void RefreshThemeForAllWindows() { - if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktopLifetime) - return; - - await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(async () => + try { - foreach (var window in desktopLifetime.Windows) + if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktopLifetime) + return; + + await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(async () => { - if (window is not BaseWindow baseWindow) continue; - var resultTheme = (await PreferencesManager?.LoadPreferencesAsync())?.Theme; - if (resultTheme != null) - baseWindow.ApplyTheme(resultTheme); - } - }); + try + { + if (PreferencesManager != null) + { + var preferences = await PreferencesManager.LoadPreferencesAsync(); + if (preferences?.Theme != null) + { + foreach (var window in desktopLifetime.Windows) + { + if (window is BaseWindow baseWindow) + { + baseWindow.ApplyTheme(preferences.Theme); + } + } + } + } + } + catch (Exception ex) + { + Log.Warning(ex, "Error refreshing themes for windows"); + } + }); + } + catch (Exception e) + { + Log.Warning(e, "Failed to refresh theme for all windows"); + } + } + + // TODO: Implement a method to set language resources based on user preferences + // Currently we only have US English resources + private static void SetLanguageResources() + { + var language = PreferencesManager?.CurrentPreferences?.Language ?? "en-US"; + var resources = Current?.Resources; + if (resources == null) return; + resources.MergedDictionaries.Clear(); + + if (language == "en-US") + { + resources.MergedDictionaries.Add( + new ResourceInclude(new Uri("avares://GalaxyViewer/Resources/Strings.axaml"))); + } } public void Dispose() { + if (_platformSettings != null) + { + _platformSettings.ColorValuesChanged -= OnSystemThemeChanged; + } + //PreferencesManager?.Dispose(); (_serviceProvider as IDisposable)?.Dispose(); } diff --git a/GalaxyViewer/Assets/Fonts/NotoColorEmoji.ttf b/GalaxyViewer/Assets/Fonts/NotoColorEmoji.ttf index cf7a47e..5fb1151 100644 Binary files a/GalaxyViewer/Assets/Fonts/NotoColorEmoji.ttf and b/GalaxyViewer/Assets/Fonts/NotoColorEmoji.ttf differ diff --git a/GalaxyViewer/Assets/GalaxyViewerLogo-0.png b/GalaxyViewer/Assets/GalaxyViewerLogo-0.png new file mode 100644 index 0000000..0e4dc29 Binary files /dev/null and b/GalaxyViewer/Assets/GalaxyViewerLogo-0.png differ diff --git a/GalaxyViewer/Assets/GalaxyViewerLogo-1.png b/GalaxyViewer/Assets/GalaxyViewerLogo-1.png new file mode 100644 index 0000000..95e706b Binary files /dev/null and b/GalaxyViewer/Assets/GalaxyViewerLogo-1.png differ diff --git a/GalaxyViewer/Assets/GalaxyViewerLogo-2.png b/GalaxyViewer/Assets/GalaxyViewerLogo-2.png new file mode 100644 index 0000000..d7a209a Binary files /dev/null and b/GalaxyViewer/Assets/GalaxyViewerLogo-2.png differ diff --git a/GalaxyViewer/Assets/Localization/LocalizationManager.cs b/GalaxyViewer/Assets/Localization/LocalizationManager.cs deleted file mode 100644 index e38578e..0000000 --- a/GalaxyViewer/Assets/Localization/LocalizationManager.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.ComponentModel; -using System.Globalization; -using System.Resources; - -namespace GalaxyViewer.Assets.Localization; - -public class LocalizationManager : INotifyPropertyChanged -{ - private readonly ResourceManager _resourceManager = new(typeof(Strings)); - private CultureInfo _currentCulture = CultureInfo.CurrentCulture; - - public event PropertyChangedEventHandler? PropertyChanged; - - public string GetString(string name) - { - return _resourceManager.GetString(name, _currentCulture) ?? $"[{name}]"; - } - - public void SetCulture(string cultureCode) - { - var newCulture = new CultureInfo(cultureCode); - if (_currentCulture.Name != newCulture.Name) - { - _currentCulture = newCulture; - CultureInfo.CurrentCulture = newCulture; - CultureInfo.CurrentUICulture = newCulture; - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(null)); - } - } -} \ No newline at end of file diff --git a/GalaxyViewer/Assets/Localization/Strings.Designer.cs b/GalaxyViewer/Assets/Localization/Strings.Designer.cs deleted file mode 100644 index 8fc67ae..0000000 --- a/GalaxyViewer/Assets/Localization/Strings.Designer.cs +++ /dev/null @@ -1,62 +0,0 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by a tool. -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - -namespace GalaxyViewer.Assets.Localization { - using System; - - - /// - /// A strongly-typed resource class, for looking up localized strings, etc. - /// - // This class was auto-generated by the StronglyTypedResourceBuilder - // class via a tool like ResGen or Visual Studio. - // To add or remove a member, edit your .ResX file then rerun ResGen - // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] - [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] - [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - internal class Strings { - - private static global::System.Resources.ResourceManager resourceMan; - - private static global::System.Globalization.CultureInfo resourceCulture; - - [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] - internal Strings() { - } - - /// - /// Returns the cached ResourceManager instance used by this class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - internal static global::System.Resources.ResourceManager ResourceManager { - get { - if (object.ReferenceEquals(resourceMan, null)) { - global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("GalaxyViewer.Assets.Localization.Strings", typeof(Strings).Assembly); - resourceMan = temp; - } - return resourceMan; - } - } - - /// - /// Overrides the current thread's CurrentUICulture property for all - /// resource lookups using this strongly typed resource class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - internal static global::System.Globalization.CultureInfo Culture { - get { - return resourceCulture; - } - set { - resourceCulture = value; - } - } - } -} diff --git a/GalaxyViewer/Assets/Localization/Strings.en-US.Designer.cs b/GalaxyViewer/Assets/Localization/Strings.en-US.Designer.cs deleted file mode 100644 index c6df0a0..0000000 --- a/GalaxyViewer/Assets/Localization/Strings.en-US.Designer.cs +++ /dev/null @@ -1,107 +0,0 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by a tool. -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - -namespace GalaxyViewer.Assets.Localization { - using System; - - - /// - /// A strongly-typed resource class, for looking up localized strings, etc. - /// - // This class was auto-generated by the StronglyTypedResourceBuilder - // class via a tool like ResGen or Visual Studio. - // To add or remove a member, edit your .ResX file then rerun ResGen - // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] - [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] - [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - internal class Strings_en_US { - - private static global::System.Resources.ResourceManager resourceMan; - - private static global::System.Globalization.CultureInfo resourceCulture; - - [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] - internal Strings_en_US() { - } - - /// - /// Returns the cached ResourceManager instance used by this class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - internal static global::System.Resources.ResourceManager ResourceManager { - get { - if (object.ReferenceEquals(resourceMan, null)) { - global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("GalaxyViewer.Assets.Localization.Strings.en-US", typeof(Strings_en_US).Assembly); - resourceMan = temp; - } - return resourceMan; - } - } - - /// - /// Overrides the current thread's CurrentUICulture property for all - /// resource lookups using this strongly typed resource class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - internal static global::System.Globalization.CultureInfo Culture { - get { - return resourceCulture; - } - set { - resourceCulture = value; - } - } - - /// - /// Looks up a localized string similar to Login. - /// - internal static string LoginScreenLoginButton { - get { - return ResourceManager.GetString("LoginScreenLoginButton", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Password. - /// - internal static string LoginScreenPassword { - get { - return ResourceManager.GetString("LoginScreenPassword", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Register. - /// - internal static string LoginScreenRegisterButton { - get { - return ResourceManager.GetString("LoginScreenRegisterButton", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Login. - /// - internal static string LoginScreenTitle { - get { - return ResourceManager.GetString("LoginScreenTitle", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Username. - /// - internal static string LoginScreenUsername { - get { - return ResourceManager.GetString("LoginScreenUsername", resourceCulture); - } - } - } -} diff --git a/GalaxyViewer/Assets/Localization/Strings.en-US.resx b/GalaxyViewer/Assets/Localization/Strings.en-US.resx deleted file mode 100644 index 3d14c34..0000000 --- a/GalaxyViewer/Assets/Localization/Strings.en-US.resx +++ /dev/null @@ -1,84 +0,0 @@ - - - - - - - - - - text/microsoft-resx - - - 1.3 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, - PublicKeyToken=b77a5c561934e089 - - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, - PublicKeyToken=b77a5c561934e089 - - - - - - Welcome to GalaxyViewer 🌌 - - - Error - - - Ok - - - Cancel - - - - Login - - - Username - - - Password - - - Login Location - - - Grid - - - Login - - - Login successful - - - Wrong username or password - - - - Preferences - - - Language - - - Login Location - - - Theme - - - Font - - - Save Preferences - - \ No newline at end of file diff --git a/GalaxyViewer/Assets/Localization/Strings.resx b/GalaxyViewer/Assets/Localization/Strings.resx deleted file mode 100644 index 54388f7..0000000 --- a/GalaxyViewer/Assets/Localization/Strings.resx +++ /dev/null @@ -1,82 +0,0 @@ - - - - - - - - - - - - text/microsoft-resx - - - 1.3 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - - Welcome to GalaxyViewer 🌌 - - - Error - - - Ok - - - Cancel - - - - Login - - - Username - - - Password - - - Login Location - - - Grid - - - Login - - - Login successful - - - Wrong username or password - - - - Preferences - - - Language - - - Login Location - - - Theme - - - Font - - - Save Preferences - - \ No newline at end of file diff --git a/GalaxyViewer/Commands/RelayCommand.cs b/GalaxyViewer/Commands/RelayCommand.cs index 01e1fdc..f1d6a98 100644 --- a/GalaxyViewer/Commands/RelayCommand.cs +++ b/GalaxyViewer/Commands/RelayCommand.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading.Tasks; using System.Windows.Input; diff --git a/GalaxyViewer/Controls/AddressBar.axaml b/GalaxyViewer/Controls/AddressBar.axaml new file mode 100644 index 0000000..0bb7ef9 --- /dev/null +++ b/GalaxyViewer/Controls/AddressBar.axaml @@ -0,0 +1,216 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/GalaxyViewer/Controls/AddressBar.axaml.cs b/GalaxyViewer/Controls/AddressBar.axaml.cs new file mode 100644 index 0000000..9b98102 --- /dev/null +++ b/GalaxyViewer/Controls/AddressBar.axaml.cs @@ -0,0 +1,43 @@ +using Avalonia.Controls; +using Avalonia.Input; +using System.Linq; +using Avalonia.VisualTree; +using GalaxyViewer.ViewModels; +using System.Reactive; +using Avalonia.Markup.Xaml; + +namespace GalaxyViewer.Controls +{ + public partial class AddressBar : UserControl + { + public AddressBar() + { + InitializeComponent(); + AddHandler(PointerPressedEvent, OnRootPointerPressed, handledEventsToo: true); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + + private void OnRootPointerPressed(object? sender, PointerPressedEventArgs e) + { + if (DataContext is not AddressBarViewModel vm) + return; + if (!vm.IsEditing) + return; + + var textBox = this.GetVisualDescendants().OfType().FirstOrDefault(tb => tb.IsVisible); + if (textBox == null) + return; + + var pointerPos = e.GetPosition(textBox); + var bounds = new Avalonia.Rect(0, 0, textBox.Bounds.Width, textBox.Bounds.Height); + if (!bounds.Contains(pointerPos)) + { + vm.CancelEditCommand.Execute(Unit.Default); + } + } + } +} \ No newline at end of file diff --git a/GalaxyViewer/Controls/EmojiAwareTextBlock.cs b/GalaxyViewer/Controls/EmojiAwareTextBlock.cs new file mode 100644 index 0000000..026f850 --- /dev/null +++ b/GalaxyViewer/Controls/EmojiAwareTextBlock.cs @@ -0,0 +1,73 @@ +using System.Collections.Generic; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Documents; +using Avalonia.Media; + +namespace GalaxyViewer.Controls; + +public class EmojiAwareTextBlock : TextBlock +{ + public static readonly StyledProperty EmojiFontFamilyProperty = + AvaloniaProperty.Register( + nameof(EmojiFontFamily)); + + public FontFamily EmojiFontFamily + { + get => GetValue(EmojiFontFamilyProperty); + set => SetValue(EmojiFontFamilyProperty, value); + } + + public EmojiAwareTextBlock() + { + PropertyChanged += (_, e) => + { + if (e.Property == TextProperty || e.Property == EmojiFontFamilyProperty) + { + ApplyFonts(); + } + }; + } + + private void ApplyFonts() + { + if (string.IsNullOrEmpty(Text)) + return; + + var inlines = new List(); + for (var i = 0; i < Text.Length; i++) + { + string charOrEmoji; + bool isEmoji; + + if (char.IsSurrogatePair(Text, i)) + { + charOrEmoji = Text.Substring(i, 2); + isEmoji = true; + i++; + } + else + { + charOrEmoji = Text[i].ToString(); + // This really could use some improvement, but it's as good as I can get for now + isEmoji = char.IsSurrogate(Text[i]) || + (Text[i] >= '\u2600' && Text[i] <= '\u27BF') || + (Text[i] >= '\u2B50' && Text[i] <= '\u2B55'); + } + + var run = new Run(charOrEmoji) + { + FontFamily = isEmoji ? EmojiFontFamily : FontFamily + }; + inlines.Add(run); + } + + if (Inlines == null) + { + Inlines = new InlineCollection(); + } + + Inlines.Clear(); + Inlines.AddRange(inlines); + } +} \ No newline at end of file diff --git a/GalaxyViewer/Controls/EmojiTextBlock.cs b/GalaxyViewer/Controls/EmojiTextBlock.cs index e6820c8..5067c3f 100644 --- a/GalaxyViewer/Controls/EmojiTextBlock.cs +++ b/GalaxyViewer/Controls/EmojiTextBlock.cs @@ -1,28 +1,26 @@ -using System.Linq; -using System.Text.RegularExpressions; +using System.Collections.Generic; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Documents; using Avalonia.Media; +using Serilog; namespace GalaxyViewer.Controls; -public abstract partial class EmojiTextBlock : TextBlock +public partial class EmojiTextBlock : TextBlock { - private static readonly Regex EmojiRegex = MyRegex(); - public static readonly StyledProperty EmojiFontFamilyProperty = AvaloniaProperty.Register(nameof(EmojiFontFamily)); + public static readonly StyledProperty DefaultFontFamilyProperty = + AvaloniaProperty.Register(nameof(DefaultFontFamily)); + public FontFamily EmojiFontFamily { get => GetValue(EmojiFontFamilyProperty); set => SetValue(EmojiFontFamilyProperty, value); } - public static readonly StyledProperty DefaultFontFamilyProperty = - AvaloniaProperty.Register(nameof(DefaultFontFamily)); - public FontFamily DefaultFontFamily { get => GetValue(DefaultFontFamilyProperty); @@ -31,9 +29,11 @@ public abstract partial class EmojiTextBlock : TextBlock protected EmojiTextBlock() { - this.PropertyChanged += (_, e) => + PropertyChanged += (_, e) => { - if (e.Property == TextProperty) + if (e.Property == TextProperty || + e.Property == EmojiFontFamilyProperty || + e.Property == DefaultFontFamilyProperty) { ApplyFonts(); } @@ -45,12 +45,43 @@ public abstract partial class EmojiTextBlock : TextBlock if (string.IsNullOrEmpty(Text)) return; - var inlines = Text.Select(ch => new Run(ch.ToString()) { FontFamily = EmojiRegex.IsMatch(ch.ToString()) ? EmojiFontFamily : DefaultFontFamily }).Cast().ToList(); + var inlines = new List(); + for (int i = 0; i < Text.Length; i++) + { + string charOrEmoji; + bool isEmoji; - Inlines?.Clear(); - Inlines?.AddRange(inlines); + if (char.IsSurrogatePair(Text, i)) + { + charOrEmoji = Text.Substring(i, 2); + isEmoji = true; + i++; + } + else + { + charOrEmoji = Text[i].ToString(); + isEmoji = char.IsSurrogate(Text[i]) || + (Text[i] >= '\u2600' && Text[i] <= '\u27BF') || + (Text[i] >= '\u2B50' && Text[i] <= '\u2B55'); + } + + var fontFamily = isEmoji ? EmojiFontFamily : DefaultFontFamily; + + var run = new Run(charOrEmoji) + { + FontFamily = fontFamily + }; + inlines.Add(run); + } + + if (Inlines == null) + { + Inlines = new InlineCollection(); + } + + Inlines.Clear(); + Inlines.AddRange(inlines); + + Log.Information("Font application complete. Total inlines: {Count}", inlines.Count); } - - [GeneratedRegex(@"[\u203C-\u3299\u1F000-\u1F644\u1F680-\u1F6FF\u1F700-\u1F77F\u1F780-\u1F7FF\u1F800-\u1F8FF\u1F900-\u1F9FF\u1FA00-\u1FA6F\u1FA70-\u1FAFF\u1FB00-\u1FBFF]", RegexOptions.Compiled)] - private static partial Regex MyRegex(); } \ No newline at end of file diff --git a/GalaxyViewer/Converters/AccentColorConverter.cs b/GalaxyViewer/Converters/AccentColorConverter.cs new file mode 100644 index 0000000..07ae376 --- /dev/null +++ b/GalaxyViewer/Converters/AccentColorConverter.cs @@ -0,0 +1,81 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Media; +using Avalonia.Styling; +using GalaxyViewer.Models; + +namespace GalaxyViewer.Converters; + +public class AccentColorConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is not string accentColorPreference || accentColorPreference == "System Default") + return GetSystemAccentBrush(); + + var isDarkTheme = IsCurrentThemeDark(); + + var colorValue = PreferencesOptions.GetAccentColorForTheme(accentColorPreference, isDarkTheme); + + try + { + return new SolidColorBrush(Color.Parse(colorValue)); + } + catch + { + return GetSystemAccentBrush(); + } + } + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + + private static bool IsCurrentThemeDark() + { + try + { + var app = Avalonia.Application.Current; + if (app?.ActualThemeVariant != null) + { + return app.ActualThemeVariant == ThemeVariant.Dark; + } + } + catch + { + // Continue to fallback + } + + // Fallback: assume light theme + return false; + } + + private static IBrush GetSystemAccentBrush() + { + try + { + if (Avalonia.Application.Current?.TryGetResource("SystemAccentColorBrush", + Avalonia.Application.Current.ActualThemeVariant, out var resource) == true) + { + if (resource is IBrush brush) + return brush; + } + + if (Avalonia.Application.Current?.TryGetResource("SystemAccentColor", + Avalonia.Application.Current.ActualThemeVariant, out var colorResource) == true) + { + if (colorResource is Color color) + return new SolidColorBrush(color); + } + } + catch + { + // Continue to fallback + } + + // Final fallback to a reasonable blue + return new SolidColorBrush(Color.Parse("#0078D4")); + } +} \ No newline at end of file diff --git a/GalaxyViewer/Converters/BoolToDoubleConverter.cs b/GalaxyViewer/Converters/BoolToDoubleConverter.cs new file mode 100644 index 0000000..54dbb28 --- /dev/null +++ b/GalaxyViewer/Converters/BoolToDoubleConverter.cs @@ -0,0 +1,28 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; + +namespace GalaxyViewer.Converters; + +public class BoolToDoubleConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is not bool boolValue || parameter is not string doubleParams) + return 1.0; + var values = doubleParams.Split(' ', ','); + if (values.Length < 2) return 1.0; + if (double.TryParse(values[0].Trim(), out var trueValue) && + double.TryParse(values[1].Trim(), out var falseValue)) + { + return boolValue ? trueValue : falseValue; + } + + return 1.0; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/GalaxyViewer/Converters/InversionBoolConverter.cs b/GalaxyViewer/Converters/InversionBoolConverter.cs new file mode 100644 index 0000000..f6a5b81 --- /dev/null +++ b/GalaxyViewer/Converters/InversionBoolConverter.cs @@ -0,0 +1,14 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; + +namespace GalaxyViewer.Converters; + +public class InverseBoolConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + => value is bool b ? !b : value; + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => value is bool b ? !b : value; +} \ No newline at end of file diff --git a/GalaxyViewer/Converters/LocalizedStringConverter.cs b/GalaxyViewer/Converters/LocalizedStringConverter.cs deleted file mode 100644 index 6a6d96f..0000000 --- a/GalaxyViewer/Converters/LocalizedStringConverter.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System; -using System.Globalization; -using Avalonia.Data.Converters; -using GalaxyViewer.Assets.Localization; - -namespace GalaxyViewer.Converters; - -public class LocalizedStringConverter : IValueConverter -{ - public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) - { - if (parameter is string key) return new LocalizationManager().GetString(key); - return "Key not found for " + value; - } - - public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) - { - // Change to en-US if the language is not found - return value; - } -} \ No newline at end of file diff --git a/GalaxyViewer/Converters/TabConverters.cs b/GalaxyViewer/Converters/TabConverters.cs new file mode 100644 index 0000000..39269fb --- /dev/null +++ b/GalaxyViewer/Converters/TabConverters.cs @@ -0,0 +1,91 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace GalaxyViewer.Converters; + +public class BoolToBackgroundConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is bool isActive && isActive) + { + return new SolidColorBrush(Colors.Transparent); + } + return new SolidColorBrush(Colors.LightGray); + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} + +public class BoolToFontWeightConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is bool isActive && isActive) + { + return FontWeight.SemiBold; + } + return FontWeight.Normal; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} + +public class BoolToForegroundConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is bool isActive && isActive) + { + return Brushes.Black; + } + return Brushes.Gray; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} + +public class BoolToStatusColorConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is bool isLoggedIn && isLoggedIn) + { + return new SolidColorBrush(Colors.Green); + } + return new SolidColorBrush(Colors.Red); + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} + +public class BoolToStatusTextConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is bool isLoggedIn && isLoggedIn) + { + return "Connected"; + } + return "Disconnected"; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/GalaxyViewer/Converters/TextDirectionConverter.cs b/GalaxyViewer/Converters/TextDirectionConverter.cs new file mode 100644 index 0000000..f85404e --- /dev/null +++ b/GalaxyViewer/Converters/TextDirectionConverter.cs @@ -0,0 +1,70 @@ +using System; +using System.Globalization; +using System.Linq; +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace GalaxyViewer.Converters; + +public class TextDirectionConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is not string text || string.IsNullOrEmpty(text)) + return FlowDirection.LeftToRight; + + // Check if the first character in the text is RTL + var firstChar = text.FirstOrDefault(char.IsLetter); + if (firstChar == default) + return FlowDirection.LeftToRight; + + var unicodeCategory = char.GetUnicodeCategory(firstChar); + + // Check if it's an RTL character + if (IsRtlUnicodeCategory(unicodeCategory)) + return FlowDirection.RightToLeft; + + // Additional check for RTL scripts by Unicode ranges + if (IsRtlCharacter(firstChar)) + return FlowDirection.RightToLeft; + + return FlowDirection.LeftToRight; + } + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + + private static bool IsRtlUnicodeCategory(UnicodeCategory category) + { + return category == UnicodeCategory.OtherLetter && + char.GetUnicodeCategory('\u0627') == category; // Arabic letter Alef + } + + private static bool IsRtlCharacter(char character) + { + var code = (int)character; + + // Hebrew: U+0590 to U+05FF + if (code >= 0x0590 && code <= 0x05FF) return true; + + // Arabic: U+0600 to U+06FF + if (code >= 0x0600 && code <= 0x06FF) return true; + + // Arabic Supplement: U+0750 to U+077F + if (code >= 0x0750 && code <= 0x077F) return true; + + // Arabic Extended-A: U+08A0 to U+08FF + if (code >= 0x08A0 && code <= 0x08FF) return true; + + // Persian/Farsi and Urdu use Arabic script + // Thaana (Maldivian): U+0780 to U+07BF + if (code >= 0x0780 && code <= 0x07BF) return true; + + // Syriac: U+0700 to U+074F + if (code >= 0x0700 && code <= 0x074F) return true; + + return false; + } +} \ No newline at end of file diff --git a/GalaxyViewer/GalaxyViewer.csproj b/GalaxyViewer/GalaxyViewer.csproj index ac14984..8a5f596 100644 --- a/GalaxyViewer/GalaxyViewer.csproj +++ b/GalaxyViewer/GalaxyViewer.csproj @@ -2,26 +2,27 @@ net9.0 latest - 0.1.0 + 2025.07.22-test $(Version) true - true + Assets\GalaxyViewerLogo.ico + + + + PreserveNewest - - PreserveNewest - - - @@ -29,55 +30,34 @@ - - - - + + + + + - + + - - + - - + + - + - + - - + + - - - - - - ResXFileCodeGenerator - Strings.Designer.cs - - - ResXFileCodeGenerator - Strings.en.Designer.cs - - - - - - True - True - Strings.resx - - - True - True - Strings.en-US.resx - + + + - + \ No newline at end of file diff --git a/GalaxyViewer/Models/ChatConversation.cs b/GalaxyViewer/Models/ChatConversation.cs new file mode 100644 index 0000000..835605e --- /dev/null +++ b/GalaxyViewer/Models/ChatConversation.cs @@ -0,0 +1,102 @@ +using System; +using System.Collections.ObjectModel; +using System.ComponentModel; +using OpenMetaverse; + +namespace GalaxyViewer.Models; + +public class ChatConversation : INotifyPropertyChanged +{ + private bool _isTyping; + + public UUID AvatarUuid { get; set; } + public string Name { get; set; } = string.Empty; + public ChatMessageType MessageType { get; init; } + public UUID ParticipantId { get; init; } + public UUID GroupId { get; set; } + public string? GroupName { get; set; } + public UUID? SessionId { get; init; } + public string? AvatarImage { get; set; } + public ObservableCollection Messages { get; set; } = []; + public ObservableCollection TypingUsers { get; set; } = []; + + private DateTime _lastActivity = DateTime.Now; + private bool _hasUnreadMessages; + private int _unreadCount; + private string _lastMessage = string.Empty; + private bool _isActive; + + public DateTime LastActivity + { + get => _lastActivity; + set + { + if (_lastActivity == value) return; + _lastActivity = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(LastActivity))); + } + } + + public bool HasUnreadMessages + { + get => _hasUnreadMessages; + set + { + if (_hasUnreadMessages == value) return; + _hasUnreadMessages = value; + PropertyChanged?.Invoke(this, + new PropertyChangedEventArgs(nameof(HasUnreadMessages))); + } + } + + public int UnreadCount + { + get => _unreadCount; + set + { + if (_unreadCount == value) return; + _unreadCount = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(UnreadCount))); + } + } + + public string LastMessage + { + get => _lastMessage; + set + { + if (_lastMessage == value) return; + _lastMessage = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(LastMessage))); + } + } + + public bool IsActive + { + get => _isActive; + set + { + if (_isActive == value) return; + _isActive = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsActive))); + } + } + + public bool IsTyping + { + get => _isTyping; + set + { + if (_isTyping == value) return; + _isTyping = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsTyping))); + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + + public override string ToString() + { + return Name; + } +} \ No newline at end of file diff --git a/GalaxyViewer/Models/ChatMessage.cs b/GalaxyViewer/Models/ChatMessage.cs new file mode 100644 index 0000000..d08b44b --- /dev/null +++ b/GalaxyViewer/Models/ChatMessage.cs @@ -0,0 +1,40 @@ +using System; +using OpenMetaverse; + +namespace GalaxyViewer.Models; + +public class ChatMessage +{ + public string SenderName { get; set; } = string.Empty; + public UUID SenderUuid { get; set; } + public string Message { get; set; } = string.Empty; + public DateTime Timestamp { get; set; } = DateTime.Now; + public ChatMessageType MessageType { get; set; } + public ChatType ChatType { get; set; } = ChatType.Normal; + public ChatSourceType SourceType { get; set; } + public ChatAudibleLevel AudibleLevel { get; set; } + public UUID GroupId { get; set; } + public string? GroupName { get; set; } + public bool IsFromSelf { get; set; } + public InstantMessageDialog? ImDialog { get; set; } + public bool IsSystemMessage { get; set; } + + public string MessageTag + { + get + { + if (MessageType == ChatMessageType.System) + return "system-message"; + return IsFromSelf ? "from-self" : "from-other"; + } + } +} + +public enum ChatMessageType +{ + LocalChat, + InstantMessage, + GroupChat, + System, + Objects +} \ No newline at end of file diff --git a/GalaxyViewer/Models/DebugViewModel.cs b/GalaxyViewer/Models/DebugViewModel.cs deleted file mode 100644 index f793495..0000000 --- a/GalaxyViewer/Models/DebugViewModel.cs +++ /dev/null @@ -1,10 +0,0 @@ -using ReactiveUI; - -namespace GalaxyViewer.Models -{ - public class DebugViewModel(IScreen screen) : ReactiveObject, IRoutableViewModel - { - public string UrlPathSegment => "debug"; - public IScreen HostScreen { get; } = screen; - } -} \ No newline at end of file diff --git a/GalaxyViewer/Models/GridModel.cs b/GalaxyViewer/Models/GridModel.cs index 814d83d..1b8545c 100644 --- a/GalaxyViewer/Models/GridModel.cs +++ b/GalaxyViewer/Models/GridModel.cs @@ -16,4 +16,15 @@ public class GridModel public string Register { get; set; } public string Password { get; set; } public string Version { get; set; } + public object IsDefault { get; set; } + + public override bool Equals(object? obj) => + obj is GridModel other && GridName == other.GridName; + + public override int GetHashCode() => GridName?.GetHashCode() ?? 0; + + public override string ToString() + { + return GridName; + } } \ No newline at end of file diff --git a/GalaxyViewer/Models/PreferencesModel.cs b/GalaxyViewer/Models/PreferencesModel.cs index 1bd7968..991eb63 100644 --- a/GalaxyViewer/Models/PreferencesModel.cs +++ b/GalaxyViewer/Models/PreferencesModel.cs @@ -11,5 +11,7 @@ public class PreferencesModel public string Font { get; set; } public string Language { get; set; } public string SelectedGridNick { get; set; } + public string AccentColor { get; set; } = "System Default"; public long LastSavedEpoch { get; set; } + public string Version { get; set; } } \ No newline at end of file diff --git a/GalaxyViewer/Models/PreferencesOptions.cs b/GalaxyViewer/Models/PreferencesOptions.cs index f938e9f..da7c772 100644 --- a/GalaxyViewer/Models/PreferencesOptions.cs +++ b/GalaxyViewer/Models/PreferencesOptions.cs @@ -4,8 +4,44 @@ namespace GalaxyViewer.Models; public static class PreferencesOptions { - public static readonly List ThemeOptions = ["Light", "Dark", "Default"]; + public static readonly List ThemeOptions = ["Light", "Dark", "System"]; public static readonly List LoginLocationOptions = ["Home", "Last Location"]; public static readonly List FontOptions = ["Inter", "Atkinson Hyperlegible"]; public static readonly List LanguageOptions = ["en-US"]; + + public static readonly List AccentColorOptions = [ + "System Default", // Uses OS accent color + "Blue", // Classic blue + "Purple", // Purple accent + "Teal", // Teal accent + "Green", // Green accent + "Orange", // Orange accent + "Red", // Red accent + "Pink", // Pink accent + "Indigo" // Indigo accent + ]; + + private static readonly Dictionary AccentColors = new() + { + { "System Default", ("SystemAccent", "SystemAccent") }, // Special key for system accent + { "Blue", ("#0078D4", "#60CDFF") }, // Light blue -> Bright blue for dark mode + { "Purple", ("#8B5A9F", "#B19CD9") }, // Purple -> Light purple for dark mode + { "Teal", ("#0F7173", "#4CC2C4") }, // Teal -> Light teal for dark mode + { "Green", ("#107C10", "#6BCF7F") }, // Green -> Light green for dark mode + { "Orange", ("#D83B01", "#FF8C00") }, // Orange -> Light orange for dark mode + { "Red", ("#D13438", "#FF6B6B") }, // Red -> Light red for dark mode + { "Pink", ("#E3008C", "#FF69B4") }, // Pink -> Light pink for dark mode + { "Indigo", ("#5C2D91", "#9A7DC4") } // Indigo -> Light indigo for dark mode + }; + + public static string GetAccentColorForTheme(string accentColorName, bool isDarkTheme) + { + if (!AccentColors.TryGetValue(accentColorName, out var colorPair)) + { + // Fallback to Blue if accent color not found + colorPair = AccentColors["Blue"]; + } + + return isDarkTheme ? colorPair.Dark : colorPair.Light; + } } \ No newline at end of file diff --git a/GalaxyViewer/Models/SessionModel.cs b/GalaxyViewer/Models/SessionModel.cs index 5eec6dd..e098abd 100644 --- a/GalaxyViewer/Models/SessionModel.cs +++ b/GalaxyViewer/Models/SessionModel.cs @@ -1,15 +1,74 @@ using System; +using System.ComponentModel; using OpenMetaverse; namespace GalaxyViewer.Models; -public class SessionModel +public class SessionModel : INotifyPropertyChanged { - public int Id { get; set; } - public bool IsLoggedIn { get; set; } - public string AvatarName { get; set; } - public UUID AvatarKey { get; set; } - public int Balance { get; set; } - public string CurrentLocation { get; set; } - public string LoginWelcomeMessage { get; set; } + private int _id; + private string _avatarName; + private UUID _avatarKey; + private string _currentLocation; + private string _loginWelcomeMessage; + + public int Id + { + get => _id; + set + { + if (_id == value) return; + _id = value; + OnPropertyChanged(nameof(Id)); + } + } + + public string AvatarName + { + get => _avatarName; + set + { + if (_avatarName == value) return; + _avatarName = value; + OnPropertyChanged(nameof(AvatarName)); + } + } + + public UUID AvatarKey + { + get => _avatarKey; + set + { + if (_avatarKey == value) return; + _avatarKey = value; + OnPropertyChanged(nameof(AvatarKey)); + } + } + + public string CurrentLocation + { + get => _currentLocation; + set + { + if (_currentLocation == value) return; + _currentLocation = value; + OnPropertyChanged(nameof(CurrentLocation)); + } + } + + public string LoginWelcomeMessage + { + get => _loginWelcomeMessage; + set + { + if (_loginWelcomeMessage == value) return; + _loginWelcomeMessage = value; + OnPropertyChanged(nameof(LoginWelcomeMessage)); + } + } + + public event PropertyChangedEventHandler PropertyChanged; + + protected void OnPropertyChanged(string propertyName) => + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } \ No newline at end of file diff --git a/GalaxyViewer/Resources/Strings.axaml b/GalaxyViewer/Resources/Strings.axaml new file mode 100644 index 0000000..d07967a --- /dev/null +++ b/GalaxyViewer/Resources/Strings.axaml @@ -0,0 +1,129 @@ + + + Go back + Go Back + Cancel editing + Location editor + Enter location: Region Name, Region/X/Y/Z, secondlife://URL, or maps.secondlife.com URL + Go forward + Go Forward + Search or go to location + Search/Go + Go to home location + Go Home + Open address bar settings + Settings + GalaxyViewer + Welcome to GalaxyViewer + Chat messages will appear here... + You can't send instant messages to objects. + Pop Out + Pop out chat window + Open chat in separate window + Send + Chat + Type a message... + GalaxyViewer - Chat + Cancel + Error + Ok + Conversations + Click to refresh balance + About Land + About Region + Chat + Classifieds + Communicate + Community + Create new Landmark Here + Dev + Developer Tools + Events + Exit + Favorites + File + Friends + Friends List + Groups + Import Object + Landmarks + Login + Logout + Marketplace + Mini-Map + Nearby Media + Nearby Objects + Nearby People + New Window + Objects Nearby + People Nearby + Preferences + Relog + Script Editor + Set Home to Here + Teleport History + Teleport Home + Upload Blinn-Phong Texture + Upload Mesh + Upload PBR Material + Voice + World + World Map + Login + Wrong username or password + Grid + Login Location + Password + Login successful + Login + Username + Enter your username + Username input field + Username + Enter your password + Password input field + Password + Log in to GalaxyViewer + Login button + Chat + Developer Tools + Friends + Groups + Landmarks + Login + Logout + Preferences + Teleport Home + World Map + Enter MFA Code: + 6-digit code + Enter your multi-factor authentication code + MFA code input field + Submit + Submit MFA code + Submit button for MFA code + Accent Color + Appearance + Back + Font + Language + Localization + Login Location + Save Preferences + Theme + Preferences + Select your preferred theme + Theme selection + Choose an accent color + Accent color selection + Select your preferred font + Font selection + Choose your language + Language selection + Save your preferences + Save preferences button + Go back to the previous screen + Back button + + \ No newline at end of file diff --git a/GalaxyViewer/Services/ChatService.cs b/GalaxyViewer/Services/ChatService.cs new file mode 100644 index 0000000..ac55a86 --- /dev/null +++ b/GalaxyViewer/Services/ChatService.cs @@ -0,0 +1,675 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using Avalonia.Threading; +using GalaxyViewer.Models; +using OpenMetaverse; +using Serilog; +using ChatType = OpenMetaverse.ChatType; + +namespace GalaxyViewer.Services; + +public sealed class ChatService : IDisposable +{ + private readonly GridClient _client; + private readonly LiteDbService _dbService; + private bool _disposed; + + private bool + _hasShownConnectionMessage; + + public ObservableCollection Conversations { get; } = []; + public ChatConversation? LocalChatConversation { get; private set; } + + public event EventHandler? MessageReceived; + public event EventHandler? ConversationUpdated; + public event EventHandler? ActiveConversationChanged; + + private readonly Dictionary> _pendingGroupMessages = new(); + + public ChatService(GridClient client, LiteDbService dbService) + { + _client = client; + _dbService = dbService; + InitializeLocalChat(); + RegisterClientEvents(); + + _client.Network.EventQueueRunning += OnNetworkConnected; + _client.Network.LoginProgress += OnLoginProgress; + } + + private void InitializeLocalChat() + { + LocalChatConversation = new ChatConversation + { + Name = "Local Chat", + MessageType = ChatMessageType.LocalChat, + IsActive = true + }; + + Dispatcher.UIThread.Post(() => { Conversations.Add(LocalChatConversation); }); + } + + private void RegisterClientEvents() + { + UnregisterClientEvents(); + + _client.Self.ChatFromSimulator += OnChatFromSimulator; + _client.Self.IM += OnInstantMessage; + } + + private void UnregisterClientEvents() + { + _client.Self.ChatFromSimulator -= OnChatFromSimulator; + _client.Self.IM -= OnInstantMessage; + _client.Network.EventQueueRunning -= OnNetworkConnected; + _client.Network.LoginProgress -= OnLoginProgress; + } + + private void OnChatFromSimulator(object? sender, ChatEventArgs e) + { + if (e.Type is ChatType.StartTyping or ChatType.StopTyping) + { + OnLocalChatTyping(e); + return; + } + + if (ShouldFilterMessage(e.Message)) + { + return; + } + + var message = new ChatMessage + { + SenderName = e.FromName, + SenderUuid = e.SourceID, + Message = e.Message, + MessageType = ChatMessageType.LocalChat, + ChatType = e.Type, + SourceType = e.SourceType, + AudibleLevel = e.AudibleLevel, + IsFromSelf = e.SourceID == _client.Self.AgentID + }; + + if (LocalChatConversation != null) AddMessageToConversation(LocalChatConversation, message); + } + + private static bool ShouldFilterMessage(string message) + { + var lowerMessage = message.ToLowerInvariant(); + + var systemFilters = Array.Empty(); + // TODO: Populate with actual system filter terms + + // Check if message contains system filter terms + if (systemFilters.Any(filter => lowerMessage.Contains((string)filter))) + { + return true; + } + + // Filter empty messages + return string.IsNullOrWhiteSpace(message) || message.Trim().Length < 1; + } + + private void OnInstantMessage(object? sender, InstantMessageEventArgs eventArgs) + { + // Group chats want to be special so we figure that out here, before any others + if (eventArgs.IM.GroupIM || _client.Groups.GroupName2KeyCache.ContainsKey(eventArgs.IM.IMSessionID)) + { + HandleGroupIm(eventArgs); + return; + } + + switch (eventArgs.IM.Dialog) + { + case InstantMessageDialog.MessageFromAgent: + HandlePersonalIm(eventArgs); + break; + + case InstantMessageDialog.MessageFromObject: + HandleObjectIm(eventArgs); + break; + + case InstantMessageDialog.StartTyping: + HandleTypingIndicator(eventArgs, true); + break; + + case InstantMessageDialog.StopTyping: + HandleTypingIndicator(eventArgs, false); + break; + + case InstantMessageDialog.FriendshipOffered: + HandleFriendshipOffer(eventArgs); + break; + + case InstantMessageDialog.FriendshipAccepted: + HandleFriendshipAccepted(eventArgs); + break; + + case InstantMessageDialog.FriendshipDeclined: + HandleFriendshipDeclined(eventArgs); + break; + + case InstantMessageDialog.InventoryOffered: + HandleInventoryOffer(eventArgs); + break; + + default: + Log.Information( + "Unhandled InstantMessage Dialog: {ImDialog} from {ImFromAgentName}: {ImMessage}", + eventArgs.IM.Dialog, eventArgs.IM.FromAgentName, eventArgs.IM.Message); + break; + } + } + + private void HandleGroupIm(InstantMessageEventArgs e) + { + var groupId = e.IM.GroupIM ? e.IM.ToAgentID : e.IM.IMSessionID; + + if (!_client.Groups.GroupName2KeyCache.TryGetValue(groupId, out var groupName)) + { + + if (!_pendingGroupMessages.ContainsKey(groupId)) + { + _pendingGroupMessages[groupId] = new Queue<(InstantMessageEventArgs, DateTime)>(); + + EventHandler? handler = null; + handler = (_, args) => + { + if (args.GroupNames.TryGetValue(groupId, out var name)) + { + Dispatcher.UIThread.Post(() => + { + ProcessQueuedGroupMessages(groupId, name); + }); + } + + _client.Groups.GroupNamesReply -= handler; + }; + + _client.Groups.GroupNamesReply += handler; + _client.Groups.RequestGroupName(groupId); + } + + _pendingGroupMessages[groupId].Enqueue((e, DateTime.Now)); + return; + } + + var conversation = GetOrCreateGroupConversation(groupId, groupName); + AddMessageToGroupConversation(conversation, e); + } + + private void ProcessQueuedGroupMessages(UUID groupId, string groupName) + { + if (!_pendingGroupMessages.TryGetValue(groupId, out var messageQueue)) + return; + + var conversation = GetOrCreateGroupConversation(groupId, groupName); + + while (messageQueue.Count > 0) + { + var (e, _) = messageQueue.Dequeue(); + AddMessageToGroupConversation(conversation, e); + } + + _pendingGroupMessages.Remove(groupId); + } + + private ChatConversation GetOrCreateGroupConversation(UUID groupId, string groupName) + { + var conversation = Conversations.FirstOrDefault(c => + c.MessageType == ChatMessageType.GroupChat && c.GroupId == groupId); + + if (conversation != null) return conversation; + conversation = new ChatConversation + { + MessageType = ChatMessageType.GroupChat, + GroupId = groupId, + GroupName = groupName, + Name = groupName + }; + + Dispatcher.UIThread.Post(() => + { + Conversations.Add(conversation); + }); + + return conversation; + } + + private void HandlePersonalIm(InstantMessageEventArgs e) + { + var conversation = GetOrCreateImConversation(e.IM.FromAgentID, e.IM.FromAgentName); + + var message = new ChatMessage + { + SenderName = e.IM.FromAgentName, + SenderUuid = e.IM.FromAgentID, + Message = e.IM.Message, + MessageType = ChatMessageType.InstantMessage, + ImDialog = e.IM.Dialog, + IsFromSelf = e.IM.FromAgentID == _client.Self.AgentID + }; + + AddMessageToConversation(conversation, message); + } + + private void HandleObjectIm(InstantMessageEventArgs e) + { + // Object messages might go to a separate "Objects" conversation for now, + // but we'd like the ability for it to be filtered or grouped into local chat too + // TODO: Implement filtering and preferences + var conversation = GetOrCreateObjectConversation(); + + var message = new ChatMessage + { + SenderName = e.IM.FromAgentName, + SenderUuid = e.IM.FromAgentID, + Message = e.IM.Message, + MessageType = ChatMessageType.Objects, + ImDialog = e.IM.Dialog, + IsFromSelf = false + }; + + AddMessageToConversation(conversation, message); + } + + private void AddMessageToConversation(ChatConversation conversation, ChatMessage message) + { + Dispatcher.UIThread.Post(() => + { + try + { + var isDuplicate = conversation.Messages.Any(m => + m.SenderUuid == message.SenderUuid && + m.Message.Equals(message.Message, StringComparison.Ordinal) && + m.MessageType == message.MessageType && + m.Timestamp == message.Timestamp); + + if (isDuplicate) + { + Log.Debug("Skipping duplicate message from {SenderName}: {Message}", + message.SenderName, message.Message); + return; + } + + conversation.Messages.Add(message); + conversation.LastMessage = message.Message; + conversation.LastActivity = message.Timestamp; + + if (!message.IsFromSelf && !conversation.IsActive) + { + conversation.HasUnreadMessages = true; + conversation.UnreadCount++; + } + + MessageReceived?.Invoke(this, message); + ConversationUpdated?.Invoke(this, conversation); + } + catch (Exception ex) + { + Log.Error(ex, "Error adding message to conversation"); + } + }); + } + + private void AddMessageToGroupConversation(ChatConversation conversation, InstantMessageEventArgs e) + { + var m = new ChatMessage + { + SenderName = e.IM.FromAgentName, + SenderUuid = e.IM.FromAgentID, + Message = e.IM.Message, + Timestamp = DateTime.Now, + MessageType = ChatMessageType.GroupChat, + GroupId = e.IM.ToAgentID, + GroupName = conversation.GroupName, + IsFromSelf = e.IM.FromAgentID == _client.Self.AgentID, + ImDialog = e.IM.Dialog + }; + + Dispatcher.UIThread.Post(() => + { + conversation.Messages.Add(m); + conversation.LastMessage = m.Message; + conversation.LastActivity = m.Timestamp; + + if (!m.IsFromSelf && !conversation.IsActive) + { + conversation.HasUnreadMessages = true; + conversation.UnreadCount++; + } + + MessageReceived?.Invoke(this, m); + }); + } + + private void HandleTypingIndicator(InstantMessageEventArgs eventArgs, bool isTyping) + { + var conversation = Conversations.FirstOrDefault(c => + c.MessageType == ChatMessageType.InstantMessage && c.ParticipantId == eventArgs.IM.FromAgentID); + + if (conversation == null) return; + Dispatcher.UIThread.Post(() => + { + conversation.IsTyping = isTyping; + if (isTyping) + { + conversation.LastMessage = $"{eventArgs.IM.FromAgentName} is typing..."; + } + else + { + var lastActualMessage = conversation.Messages.LastOrDefault(); + conversation.LastMessage = lastActualMessage?.Message ?? ""; + } + }); + + ConversationUpdated?.Invoke(this, conversation); + } + + private void HandleFriendshipOffer(InstantMessageEventArgs e) + { + // This should trigger a notification/dialog, not create a chat + // For now, we'll log it + Log.Information("Friendship offer from {ImFromAgentName}: {ImMessage}", e.IM.FromAgentName, + e.IM.Message); + + // TODO: Show friendship offer dialog + // TODO: Allow user to accept/decline + } + + private void HandleFriendshipAccepted(InstantMessageEventArgs e) + { + var conversation = GetOrCreateImConversation(e.IM.FromAgentID, e.IM.FromAgentName); + var message = new ChatMessage + { + SenderName = "System", + SenderUuid = e.IM.FromAgentID, + Message = $"{e.IM.FromAgentName} has accepted your friendship request.", + MessageType = ChatMessageType.InstantMessage, + ImDialog = e.IM.Dialog, + IsFromSelf = false + }; + AddMessageToConversation(conversation, message); + } + + private void HandleFriendshipDeclined(InstantMessageEventArgs e) + { + var conversation = GetOrCreateImConversation(e.IM.FromAgentID, e.IM.FromAgentName); + var message = new ChatMessage + { + SenderName = "System", + SenderUuid = e.IM.FromAgentID, + Message = $"{e.IM.FromAgentName} has declined your friendship request.", + MessageType = ChatMessageType.InstantMessage, + ImDialog = e.IM.Dialog, + IsFromSelf = false + }; + AddMessageToConversation(conversation, message); + } + + private void HandleInventoryOffer(InstantMessageEventArgs e) + { + // This should trigger an inventory offer dialog later, for now we'll log it + Log.Information("Inventory offer from {ImFromAgentName}: {ImMessage}", e.IM.FromAgentName, + e.IM.Message); + + // TODO: Show inventory offer dialog + // TODO: Allow user to accept/decline + } + + public void StartLocalChatTyping() + { + if (!_client.Network.Connected) return; + _client.Self.AnimationStart(Animations.TYPE, true); + _client.Self.Chat("", 0, ChatType.StartTyping); + } + + public void StopLocalChatTyping() + { + if (!_client.Network.Connected) return; + _client.Self.AnimationStop(Animations.TYPE, true); + _client.Self.Chat("", 0, ChatType.StopTyping); + } + + private void OnLocalChatTyping(ChatEventArgs e) + { + if (LocalChatConversation == null) return; + + var isTyping = e.Type == ChatType.StartTyping; + var typingUserName = e.FromName; + + if (e.SourceID == _client.Self.AgentID) return; + + Dispatcher.UIThread.Post(() => + { + if (isTyping) + { + if (!LocalChatConversation.TypingUsers.Contains(typingUserName)) + { + LocalChatConversation.TypingUsers.Add(typingUserName); + } + } + else + { + LocalChatConversation.TypingUsers.Remove(typingUserName); + } + + LocalChatConversation.IsTyping = LocalChatConversation.TypingUsers.Count > 0; + + if (LocalChatConversation.IsTyping) + { + var typingMessage = LocalChatConversation.TypingUsers.Count switch + { + 1 => $"{LocalChatConversation.TypingUsers.First()} is typing...", + 2 => + $"{LocalChatConversation.TypingUsers.First()} and {LocalChatConversation.TypingUsers.Last()} are typing...", + _ => + $"{LocalChatConversation.TypingUsers.First()} and {LocalChatConversation.TypingUsers.Count - 1} others are typing..." + }; + + LocalChatConversation.LastMessage = typingMessage; + } + else + { + var lastActualMessage = LocalChatConversation.Messages.LastOrDefault(); + LocalChatConversation.LastMessage = lastActualMessage?.Message ?? ""; + } + }); + + ConversationUpdated?.Invoke(this, LocalChatConversation); + } + + private ChatConversation GetOrCreateImConversation(UUID participantId, string participantName) + { + var existing = Conversations.FirstOrDefault(c => + c.MessageType == ChatMessageType.InstantMessage && c.ParticipantId == participantId); + + if (existing != null) + return existing; + + var conversation = new ChatConversation + { + Name = participantName, + MessageType = ChatMessageType.InstantMessage, + ParticipantId = participantId + }; + + Dispatcher.UIThread.Post(() => { Conversations.Add(conversation); }); + + return conversation; + } + + private ChatConversation GetOrCreateObjectConversation() + { + var existing = Conversations.FirstOrDefault(c => + c is { MessageType: ChatMessageType.InstantMessage, Name: "Objects" }); + + if (existing != null) + return existing; + + var conversation = new ChatConversation + { + Name = "Objects", + MessageType = ChatMessageType.InstantMessage, + ParticipantId = UUID.Zero + }; + + Dispatcher.UIThread.Post(() => { Conversations.Add(conversation); }); + + return conversation; + } + + public async Task SendLocalChatAsync(string message, ChatType chatType = ChatType.Normal) + { + await Task.Run(() => + { + _client.Self.Chat(message, 0, chatType); + }); + } + + public async Task SendInstantMessageAsync(UUID targetId, string message) + { + await Task.Run(() => { _client.Self.InstantMessage(targetId, message); }); + } + + public async Task SendGroupMessageAsync(UUID sessionId, string message) + { + await Task.Run(() => + { + _client.Self.InstantMessage( + _client.Self.Name, + sessionId, + message, + sessionId, + InstantMessageDialog.SessionSend, + InstantMessageOnline.Offline, + Vector3.Zero, + UUID.Zero, + [] + ); + }); + } + + public void MarkConversationAsRead(ChatConversation conversation) + { + conversation.HasUnreadMessages = false; + conversation.UnreadCount = 0; + ConversationUpdated?.Invoke(this, conversation); + } + + public void SetActiveConversation(ChatConversation? conversation) + { + foreach (var conv in Conversations) + { + conv.IsActive = conv == conversation; + } + + if (conversation != null) + { + MarkConversationAsRead(conversation); + } + + // Notify that the active conversation has changed + ActiveConversationChanged?.Invoke(this, conversation); + } + + private void OnNetworkConnected(object? sender, EventQueueRunningEventArgs e) + { + if (_hasShownConnectionMessage) return; + _hasShownConnectionMessage = true; + + var message = new ChatMessage + { + SenderName = "System", + Message = "Connected to grid successfully.", + MessageType = ChatMessageType.System, + IsFromSelf = false, + Timestamp = DateTime.Now + }; + + if (LocalChatConversation != null) AddMessageToConversation(LocalChatConversation, message); + } + + private void OnLoginProgress(object? sender, LoginProgressEventArgs e) + { + switch (e.Status) + { + case LoginStatus.Success: + { + var session = _dbService.GetSession(); + if (!string.IsNullOrEmpty(session.LoginWelcomeMessage)) + { + var welcomeMessage = new ChatMessage + { + SenderName = "Grid", + Message = session.LoginWelcomeMessage, + MessageType = ChatMessageType.System, + IsFromSelf = false, + Timestamp = DateTime.Now + }; + + if (LocalChatConversation != null) + AddMessageToConversation(LocalChatConversation, welcomeMessage); + } + + var loginMessage = new ChatMessage + { + SenderName = "System", + Message = $"Successfully logged in as {_client.Self.Name}", + MessageType = ChatMessageType.System, + IsFromSelf = false, + Timestamp = DateTime.Now + }; + + if (LocalChatConversation != null) + AddMessageToConversation(LocalChatConversation, loginMessage); + break; + } + case LoginStatus.Failed: + break; + case LoginStatus.None: + break; + case LoginStatus.ConnectingToLogin: + break; + case LoginStatus.ReadingResponse: + break; + case LoginStatus.ConnectingToSim: + break; + case LoginStatus.Redirecting: + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + #region Disposable Support + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + private void Dispose(bool disposing) + { + if (_disposed) return; + if (disposing) + { + UnregisterClientEvents(); + _pendingGroupMessages.Clear(); + _hasShownConnectionMessage = false; + } + + _disposed = true; + } + + ~ChatService() + { + Dispose(false); + } + + #endregion +} \ No newline at end of file diff --git a/GalaxyViewer/Services/LiteDbService.cs b/GalaxyViewer/Services/LiteDbService.cs index 71b0a09..3f05b7d 100644 --- a/GalaxyViewer/Services/LiteDbService.cs +++ b/GalaxyViewer/Services/LiteDbService.cs @@ -1,10 +1,14 @@ using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; using System.ComponentModel; using System.IO; +using System.Linq; using System.Runtime.CompilerServices; using Avalonia.Markup.Xaml.MarkupExtensions; using LiteDB; using GalaxyViewer.Models; +using OpenMetaverse; using Serilog; namespace GalaxyViewer.Services; @@ -14,8 +18,18 @@ public class LiteDbService : IDisposable, INotifyPropertyChanged private LiteDatabase? _database; private readonly string _databasePath; public LiteDatabase? Database => _database; + private readonly GridClient _client; + + public LiteDbService(GridClient client) + { + _client = client; + _databasePath = GetDatabasePath(); + InitializeDatabase(); + Session = GetSession(); + } private SessionModel _session; + public SessionModel Session { get => _session; @@ -26,14 +40,7 @@ public class LiteDbService : IDisposable, INotifyPropertyChanged } } - public event PropertyChangedEventHandler PropertyChanged; - - public LiteDbService() - { - _databasePath = GetDatabasePath(); - InitializeDatabase(); - Session = GetSession(); - } + public event PropertyChangedEventHandler? PropertyChanged; private static string GetDatabasePath() { @@ -49,17 +56,19 @@ public class LiteDbService : IDisposable, INotifyPropertyChanged { Directory.CreateDirectory(Path.GetDirectoryName(_databasePath) ?? throw new InvalidOperationException()); + _database = new LiteDatabase(_databasePath); - Log.Information("LiteDbService initialized with database path: {DbPath}", - _databasePath); + Log.Information("LiteDbService initialized with database path: {DbPath}", _databasePath); + + var gridsCollection = _database.GetCollection("grids"); + if (gridsCollection.Count() == 0) + { + SeedGrids(); + } + ClearSessionData(); SeedDatabase(); } - catch (LiteException ex) - { - Log.Error(ex, "LiteDB exception occurred. Attempting to recreate the database."); - HandleDatabaseCorruption(); - } catch (Exception ex) { Log.Error(ex, "Failed to initialize LiteDbService"); @@ -67,116 +76,86 @@ public class LiteDbService : IDisposable, INotifyPropertyChanged } } - private void HandleDatabaseCorruption() - { - try - { - if (File.Exists(_databasePath)) - { - File.Move(_databasePath, _databasePath + ".bak"); - } - - _database = new LiteDatabase(_databasePath); - SeedDatabase(); - Log.Information("Database recreated successfully."); - } - catch (Exception ex) - { - Log.Error(ex, "Failed to recreate the database."); - throw; - } - } private void SeedDatabase() { + ClearSessionData(); SeedPreferences(); - SeedGrids(); } private void SeedPreferences() { - var preferencesCollection = _database.GetCollection("preferences"); - if (preferencesCollection.Count() == 0) - { - var defaultPreferences = new PreferencesModel - { - Theme = "Default", - LoginLocation = "Home", - Font = "Atkinson Hyperlegible", - Language = "en-US", - SelectedGridNick = "agni", - LastSavedEpoch = DateTimeOffset.UtcNow.ToUnixTimeSeconds() - }; - preferencesCollection.Insert(defaultPreferences); - Log.Information("Database seeded with default preferences"); - } + var preferencesCollection = _database?.GetCollection("preferences"); + if (preferencesCollection != null && preferencesCollection.Count() != 0) return; + var defaultPreferences = PreferencesManager.CreateDefaultPreferences(); + preferencesCollection?.Insert(defaultPreferences); + // Log.Debug("Database seeded with default preferences"); } private void SeedGrids() { - var gridsCollection = _database.GetCollection("grids"); - if (gridsCollection.Count() == 0) + var gridsCollection = _database?.GetCollection("grids"); + if (gridsCollection != null && gridsCollection.Count() != 0) return; + var grids = new[] { - var grids = new[] + new GridModel { - new GridModel - { - GridNick = "agni", - GridName = "Second Life (agni)", - Platform = "SecondLife", - LoginUri = "https://login.agni.lindenlab.com/cgi-bin/login.cgi", - LoginPage = "http://secondlife.com/app/login/?channel=Second+Life+Release", - HelperUri = "https://secondlife.com/helpers/", - Website = "http://secondlife.com/", - Support = "http://secondlife.com/support/", - Register = "http://secondlife.com/registration/", - Password = "http://secondlife.com/account/request.php", - Version = "0" - }, - new GridModel - { - GridNick = "aditi", - GridName = "Second Life Beta (aditi)", - Platform = "SecondLife", - LoginUri = "https://login.aditi.lindenlab.com/cgi-bin/login.cgi", - LoginPage = "http://secondlife.com/app/login/?channel=Second+Life+Beta", - HelperUri = "http://aditi-secondlife.webdev.lindenlab.com/helpers/", - Website = "http://secondlife.com/", - Support = "http://secondlife.com/support/", - Register = "http://secondlife.com/registration/", - Password = "http://secondlife.com/account/request.php", - Version = "1" - } - }; - gridsCollection.InsertBulk(grids); - Log.Information("Database seeded with default grids"); - } + GridNick = "agni", + GridName = "Second Life (agni)", + Platform = "SecondLife", + LoginUri = "https://login.agni.lindenlab.com/cgi-bin/login.cgi", + LoginPage = "http://secondlife.com/app/login/?channel=Second+Life+Release", + HelperUri = "https://secondlife.com/helpers/", + Website = "http://secondlife.com/", + Support = "http://secondlife.com/support/", + Register = "http://secondlife.com/registration/", + Password = "http://secondlife.com/account/request.php", + Version = "0" + }, + new GridModel + { + GridNick = "aditi", + GridName = "Second Life Beta (aditi)", + Platform = "SecondLife", + LoginUri = "https://login.aditi.lindenlab.com/cgi-bin/login.cgi", + LoginPage = "http://secondlife.com/app/login/?channel=Second+Life+Beta", + HelperUri = "http://aditi-secondlife.webdev.lindenlab.com/helpers/", + Website = "http://secondlife.com/", + Support = "http://secondlife.com/support/", + Register = "http://secondlife.com/registration/", + Password = "http://secondlife.com/account/request.php", + Version = "1" + } + }; + gridsCollection?.InsertBulk(grids); + // Log.Debug("Database seeded with default grids"); } private void ClearSessionData() { - ILiteCollection? sessionCollection; - if (_database.CollectionExists("session")) + var sessionCollection = _database?.GetCollection("session"); + if (sessionCollection != null && sessionCollection.Count() > 0) { - sessionCollection = _database.GetCollection("session"); sessionCollection.DeleteAll(); - Log.Information("Session data cleared on startup"); + // Log.Debug("Session data cleared"); } - sessionCollection = _database.GetCollection("session"); - sessionCollection.Insert(new SessionModel()); - Log.Information("Session data created on startup"); + if (sessionCollection != null && sessionCollection.Count() != 0) return; + sessionCollection?.Insert(new SessionModel()); + // Log.Debug("Session data created"); } - public void OnPropertyChanged([CallerMemberName] string propertyName = null) + private void OnPropertyChanged([CallerMemberName] string? propertyName = null) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } + public event EventHandler? SessionChanged; + public SessionModel GetSession() { - var collection = _database.GetCollection("session"); - return collection.FindOne(Query.All()) ?? new SessionModel(); + var collection = _database?.GetCollection("session"); + return collection?.FindOne(Query.All()) ?? new SessionModel(); } public bool HasSessionChanged(SessionModel currentSession) @@ -187,16 +166,16 @@ public class LiteDbService : IDisposable, INotifyPropertyChanged public void SaveSession(SessionModel session) { - var collection = _database.GetCollection("session"); - collection.Upsert(session); - Log.Information("Session data saved"); + var collection = _database?.GetCollection("session"); + collection?.Upsert(session); + // Log.Debug("Session data saved"); Session = session; + SessionChanged?.Invoke(this, EventArgs.Empty); } - public void Dispose() + void IDisposable.Dispose() { _database?.Dispose(); - Log.Information("LiteDbService disposed"); } } \ No newline at end of file diff --git a/GalaxyViewer/Services/NavigationService.cs b/GalaxyViewer/Services/NavigationService.cs deleted file mode 100644 index 9cace2b..0000000 --- a/GalaxyViewer/Services/NavigationService.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; -using System.Collections.Generic; -using Avalonia.Controls; - -namespace GalaxyViewer.Services; - -public class NavigationService -{ - private readonly Dictionary _routes = new(); - private readonly ContentControl _contentControl; - - public NavigationService(ContentControl contentControl) - { - _contentControl = contentControl; - } - - public void RegisterRoute(string uri, Type viewType) - { - _routes[uri] = viewType; - } - - public void NavigateTo(string uri) - { - if (_routes.TryGetValue(uri, out var viewType)) - { - var view = (Control)Activator.CreateInstance(viewType)!; - _contentControl.Content = view; - } - else - { - throw new InvalidOperationException($"No view registered for URI: {uri}"); - } - } -} \ No newline at end of file diff --git a/GalaxyViewer/Services/PreferencesManager.cs b/GalaxyViewer/Services/PreferencesManager.cs index 5d1c1a0..aa2a1e1 100644 --- a/GalaxyViewer/Services/PreferencesManager.cs +++ b/GalaxyViewer/Services/PreferencesManager.cs @@ -1,71 +1,81 @@ -using System; +using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using LiteDB; using GalaxyViewer.Models; using System.Threading.Tasks; +using Serilog; namespace GalaxyViewer.Services; public sealed class PreferencesManager { + private readonly LiteDbService _liteDbService; private readonly ILiteCollection _preferencesCollection; private readonly ILiteCollection? _gridsCollection; + private PreferencesModel _currentPreferences; public event EventHandler? PreferencesChanged; public PreferencesManager(LiteDbService? liteDbService) { - Debug.Assert(liteDbService != null, nameof(liteDbService) + " != null"); - var database = liteDbService?.Database; - Debug.Assert(database != null, nameof(database) + " != null"); + _liteDbService = liteDbService ?? throw new ArgumentNullException(nameof(liteDbService)); + var database = _liteDbService.Database ?? throw new InvalidOperationException("Database not initialized"); _preferencesCollection = database.GetCollection("preferences"); _gridsCollection = database.GetCollection("grids"); + _currentPreferences = LoadRawPreferences(); + if (!string.IsNullOrEmpty(_currentPreferences.Version) && + _currentPreferences.Version.Contains('.')) return; + _currentPreferences.Version = VersionHelper.GetInformationalVersion(); + _currentPreferences.LastSavedEpoch = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + _preferencesCollection.Upsert(_currentPreferences); } - public PreferencesModel CurrentPreferences + public PreferencesModel CurrentPreferences => _currentPreferences; + + public Task LoadPreferencesAsync() => Task.FromResult(_currentPreferences); + + private PreferencesModel LoadRawPreferences() { - get + var rawColumn = _liteDbService.Database?.GetCollection("preferences"); + var column = rawColumn?.FindOne(Query.All()); + if (column == null) { - var preferences = _preferencesCollection.FindOne(Query.All()); - return preferences ?? new PreferencesModel - { - Id = ObjectId.NewObjectId(), - Theme = "Default", - LoginLocation = "Home", - Font = "Atkinson Hyperlegible", - Language = "en-US", - LastSavedEpoch = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), - SelectedGridNick = string.Empty - }; + return CreateDefaultPreferences(); } - } - - public async Task LoadPreferencesAsync() - { - return await Task.Run(() => + var preferences = new PreferencesModel { - var preferences = _preferencesCollection.FindOne(Query.All()); - return preferences ?? new PreferencesModel - { - Id = ObjectId.NewObjectId(), - Theme = "Default", - LoginLocation = "Home", - Font = "Atkinson Hyperlegible", - Language = "en-US", - SelectedGridNick = "agni", - LastSavedEpoch = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), - }; - }); + Id = column["_id"].AsObjectId, + Theme = column.TryGetValue("Theme", out var value) ? value.AsString : "System", + LoginLocation = column.TryGetValue("LoginLocation", out var value1) ? value1.AsString : "Home", + Font = column.TryGetValue("Font", out var value2) ? value2.AsString : "Atkinson Hyperlegible", + Language = column.TryGetValue("Language", out var value3) ? value3.AsString : "en-US", + SelectedGridNick = column.TryGetValue("SelectedGridNick", out var value4) ? value4.AsString : "", + AccentColor = column.TryGetValue("AccentColor", out var value5) ? value5.AsString : "System Default", + LastSavedEpoch = column.TryGetValue("LastSavedEpoch", out var value6) ? value6.AsInt64 : 0, + }; + if (column.TryGetValue("Version", out var v)) + { + preferences.Version = v.IsString ? v.AsString : v.RawValue?.ToString() ?? string.Empty; + } + else + { + preferences.Version = string.Empty; + } + return preferences; } public async Task SavePreferencesAsync(PreferencesModel preferences) { await Task.Run(() => { - _preferencesCollection.Upsert(preferences); - OnPreferencesChanged(preferences); + preferences.LastSavedEpoch = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + preferences.Version = VersionHelper.GetInformationalVersion(); + _preferencesCollection.DeleteAll(); + _preferencesCollection.Insert(preferences); + _liteDbService.Database?.Checkpoint(); + _currentPreferences = preferences; + PreferencesChanged?.Invoke(this, preferences); }); } @@ -76,18 +86,29 @@ public sealed class PreferencesManager { "ThemeOptions", PreferencesOptions.ThemeOptions }, { "LoginLocationOptions", PreferencesOptions.LoginLocationOptions }, { "FontOptions", PreferencesOptions.FontOptions }, - { "LanguageOptions", PreferencesOptions.LanguageOptions } + { "LanguageOptions", PreferencesOptions.LanguageOptions }, + { "AccentColorOptions", PreferencesOptions.AccentColorOptions } }; } public List GetGridOptions() { - return _gridsCollection?.FindAll().Select(grid => grid.GridNick).ToList() ?? - []; + return _gridsCollection?.FindAll().Select(grid => grid.GridNick).ToList() ?? new List(); } - private void OnPreferencesChanged(PreferencesModel preferences) + public static PreferencesModel CreateDefaultPreferences() { - PreferencesChanged?.Invoke(this, preferences); + return new PreferencesModel + { + Id = ObjectId.NewObjectId(), + Theme = "System", + LoginLocation = "Home", + Font = "Atkinson Hyperlegible", + Language = "en-US", + AccentColor = "System Default", + SelectedGridNick = "agni", + LastSavedEpoch = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + Version = VersionHelper.GetInformationalVersion() + }; } } \ No newline at end of file diff --git a/GalaxyViewer/Services/SessionService.cs b/GalaxyViewer/Services/SessionService.cs new file mode 100644 index 0000000..a62c31e --- /dev/null +++ b/GalaxyViewer/Services/SessionService.cs @@ -0,0 +1,43 @@ +using System; +using GalaxyViewer.Models; +using OpenMetaverse; + +namespace GalaxyViewer.Services; + +public class SessionService +{ + private readonly LiteDbService _dbService; + + public SessionService(LiteDbService dbService, GridClient client) + { + _dbService = dbService; + SetClient(client); + } + + private void SetClient(GridClient client) + { + client.Self.MoneyBalance += BalanceReceivedHandler; + } + + public event EventHandler? BalanceChanged; + private int _balance; + + public int Balance + { + get => _balance; + private set + { + if (_balance == value) return; + _balance = value; + BalanceChanged?.Invoke(this, _balance); + } + } + + private void BalanceReceivedHandler(object? sender, BalanceEventArgs e) + { + Balance = e.Balance; + } + + public bool HasSessionChanged(SessionModel currentSession) + => !_dbService.GetSession().Equals(currentSession); +} \ No newline at end of file diff --git a/GalaxyViewer/Services/VersionHelper.cs b/GalaxyViewer/Services/VersionHelper.cs new file mode 100644 index 0000000..aee7d77 --- /dev/null +++ b/GalaxyViewer/Services/VersionHelper.cs @@ -0,0 +1,35 @@ +using System; +using System.Reflection; + +namespace GalaxyViewer.Services; + +public static class VersionHelper +{ + private static string GetApplicationVersion() + { + try + { + var assembly = Assembly.GetExecutingAssembly(); + var version = assembly.GetName().Version; + return version?.ToString() ?? "Unknown"; + } + catch + { + return "Unknown"; + } + } + + public static string GetInformationalVersion() + { + try + { + var assembly = Assembly.GetExecutingAssembly(); + var versionAttribute = assembly.GetCustomAttribute(); + return versionAttribute?.InformationalVersion ?? GetApplicationVersion(); + } + catch + { + return GetApplicationVersion(); + } + } +} \ No newline at end of file diff --git a/GalaxyViewer/Styles.axaml b/GalaxyViewer/Styles.axaml index d53d82c..7ca2a3d 100644 --- a/GalaxyViewer/Styles.axaml +++ b/GalaxyViewer/Styles.axaml @@ -1,6 +1,58 @@ + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:controls="clr-namespace:GalaxyViewer.Controls"> + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/GalaxyViewer/Styles/Atkinson Hyperlegible.axaml b/GalaxyViewer/Styles/Atkinson Hyperlegible.axaml index edfd80c..a9f6372 100644 --- a/GalaxyViewer/Styles/Atkinson Hyperlegible.axaml +++ b/GalaxyViewer/Styles/Atkinson Hyperlegible.axaml @@ -4,7 +4,7 @@ - + \ No newline at end of file diff --git a/GalaxyViewer/ViewModels/AddressBarViewModel.cs b/GalaxyViewer/ViewModels/AddressBarViewModel.cs new file mode 100644 index 0000000..14ab232 --- /dev/null +++ b/GalaxyViewer/ViewModels/AddressBarViewModel.cs @@ -0,0 +1,672 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Reactive; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using ReactiveUI; +using OpenMetaverse; +using GalaxyViewer.Services; +using GalaxyViewer.Models; +using Serilog; +using Avalonia.Media; +using System.Windows.Input; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Threading; + +namespace GalaxyViewer.ViewModels +{ + public partial class AddressBarViewModel : ViewModelBase, IDisposable + { + private readonly LiteDbService _liteDbService; + private readonly GridClient _client; + private SessionModel _session; + private readonly ICommand? _openPreferencesCommand; + + private DateTime _lastLocationUpdate = DateTime.MinValue; + private const int LocationUpdateThrottleMs = 100; + + private readonly List _locationHistory = []; + private int _currentHistoryIndex = -1; + + private Vector3 _lastKnownPosition = Vector3.Zero; + private string _lastKnownRegion = string.Empty; + + // Regex for parsing SLURL coordinates like "Region Name/128/128/23" + [GeneratedRegex(@"^(.+?)\/(\d+)\/(\d+)\/(\d+)$")] + private static partial Regex SlurlRegex(); + + public AddressBarViewModel(LiteDbService liteDbService, + GridClient client, + ICommand? openPreferencesCommand = null) + { + _liteDbService = liteDbService; + _client = client; + _session = _liteDbService.GetSession(); + _openPreferencesCommand = openPreferencesCommand; + + HomeCommand = ReactiveCommand.Create(GoHome); + BackCommand = ReactiveCommand.Create(GoBack, this.WhenAnyValue(x => x.CanGoBack)); + ForwardCommand = + ReactiveCommand.Create(GoForward, this.WhenAnyValue(x => x.CanGoForward)); + SettingsCommand = ReactiveCommand.Create(OpenSettings); + + StartEditCommand = ReactiveCommand.Create(StartEdit); + CommitEditCommand = ReactiveCommand.Create(CommitEdit); + CancelEditCommand = ReactiveCommand.Create(CancelEdit); + + _liteDbService.PropertyChanged += OnLiteDbServicePropertyChanged; + _client.Self.TeleportProgress += OnTeleportProgress; + _client.Objects.AvatarUpdate += OnAvatarUpdate; + _client.Network.SimConnected += OnSimConnected; + _client.Network.SimDisconnected += OnSimDisconnected; + + if (_client.Network.Connected) + { + UpdateLocationDisplay(); + } + } + + public ReactiveCommand HomeCommand { get; } + public ReactiveCommand BackCommand { get; } + public ReactiveCommand ForwardCommand { get; } + public ReactiveCommand SettingsCommand { get; } + public ReactiveCommand StartEditCommand { get; } + public ReactiveCommand CommitEditCommand { get; } + public ReactiveCommand CancelEditCommand { get; } + + private string _currentLocationDisplay = string.Empty; + + public string CurrentLocationDisplay + { + get => _currentLocationDisplay; + set + { + if (_currentLocationDisplay == value) return; + _currentLocationDisplay = value; + OnPropertyChanged(nameof(CurrentLocationDisplay)); + } + } + + private string _currentCoordinatesDisplay = string.Empty; + + public string CurrentCoordinatesDisplay + { + get => _currentCoordinatesDisplay; + set + { + if (_currentCoordinatesDisplay == value) return; + _currentCoordinatesDisplay = value; + OnPropertyChanged(nameof(CurrentCoordinatesDisplay)); + } + } + + private bool _isEditing; + + public bool IsEditing + { + get => _isEditing; + set + { + if (_isEditing == value) return; + _isEditing = value; + OnPropertyChanged(nameof(IsEditing)); + OnPropertyChanged(nameof(IsDisplaying)); + } + } + + public bool IsDisplaying => !IsEditing; + + private string _editableLocation = string.Empty; + + public string EditableLocation + { + get => _editableLocation; + set + { + if (_editableLocation == value) return; + _editableLocation = value; + OnPropertyChanged(nameof(EditableLocation)); + } + } + + private string _maturityRating = "G"; + + public string MaturityRating + { + get => _maturityRating; + set + { + if (_maturityRating == value) return; + _maturityRating = value; + OnPropertyChanged(nameof(MaturityRating)); + OnPropertyChanged(nameof(MaturityRatingTooltip)); // Don't forget this! + } + } + + public string MaturityRatingTooltip => + MaturityRating switch + { + "G" => Application.Current?.FindResource("AddressBar_Maturity_G") as string ?? + "General: Suitable for all ages - family-friendly content", + "M" => Application.Current?.FindResource("AddressBar_Maturity_M") as string ?? + "Moderate: Suitable for ages 17 and above, may include moderate use of violence and 'sexy' content", + "A" => Application.Current?.FindResource("AddressBar_Maturity_A") as string ?? + "Adult: Suitable for ages 18 and above, may include heavy use of violence, drugs, or sexual content", + _ => Application.Current?.FindResource("AddressBar_Maturity_Default") as string ?? + "Content rating information" + }; + + public string MaturityRatingColorHex => + MaturityRating switch + { + "G" => "#4CAF50", // Green + "M" => "#FF9800", // Orange + "A" => "#F44336", // Red + _ => "#4CAF50" + }; + + public SolidColorBrush MaturityRatingBrush => + new(Color.Parse(MaturityRatingColorHex)); + + public bool CanGoBack => _currentHistoryIndex > 0; + public bool CanGoForward => _currentHistoryIndex < _locationHistory.Count - 1; + + private void GoHome() + { + try + { + if (!_client.Network.Connected) return; + _client.Self.GoHome(); + Log.Information("Teleporting home"); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to teleport home"); + } + } + + private void GoBack() + { + if (!CanGoBack) return; + + _currentHistoryIndex--; + var location = _locationHistory[_currentHistoryIndex]; + TeleportToLocation(location); + + OnPropertyChanged(nameof(CanGoBack)); + OnPropertyChanged(nameof(CanGoForward)); + } + + private void GoForward() + { + if (!CanGoForward) return; + + _currentHistoryIndex++; + var location = _locationHistory[_currentHistoryIndex]; + TeleportToLocation(location); + + OnPropertyChanged(nameof(CanGoBack)); + OnPropertyChanged(nameof(CanGoForward)); + } + + private string GetCurrentLocationForEditing() + { + if (_client.Network?.Connected != true || _client.Network.CurrentSim == null) + return _session.CurrentLocation; + // Return in the format expected by the dialog: "Region Name/X/Y/Z" + var regionName = _client.Network.CurrentSim.Name; + var x = (int)_client.Self.SimPosition.X; + var y = (int)_client.Self.SimPosition.Y; + var z = (int)_client.Self.SimPosition.Z; + return $"{regionName}/{x}/{y}/{z}"; + } + + public bool ShowMaturityRating => !string.IsNullOrEmpty(MaturityRating); + + public string MaturityRatingColor => MaturityRatingColorHex; + + private void StartEdit() + { + Log.Information("Starting address edit mode"); + var currentLocation = GetCurrentLocationForEditing(); + EditableLocation = currentLocation; + IsEditing = true; + } + + private void CommitEdit() + { + Log.Information($"CommitEdit calling SearchCommand with: '{EditableLocation}'"); + if (!string.IsNullOrWhiteSpace(EditableLocation)) + { + Search(); + } + else + { + Log.Warning("EditableLocation is empty or null - no location to navigate to"); + } + + IsEditing = false; + } + + private void CancelEdit() + { + Log.Information("Cancelling address edit"); + EditableLocation = GetCurrentLocationForEditing(); + IsEditing = false; + } + + private void Search() + { + var location = EditableLocation.Trim(); + Log.Information("Search/Go requested with location: '{Location}'", location); + + if (string.IsNullOrEmpty(location)) + { + Log.Warning("EditableLocation is empty or null - no location to navigate to"); + return; + } + + TeleportToLocation(location); + IsEditing = false; + } + + private void TeleportToLocation(string location) + { + try + { + if (!_client.Network.Connected) + { + Log.Warning("Not connected to grid - cannot teleport"); + return; + } + + if (ParseLocationString(location, out var regionName, out var x, out var y, + out var z)) + { + Log.Information("Teleporting to {RegionName} ({F},{F1},{F2})", regionName, x, y, + z); + + IsTeleporting = true; + TeleportStatusMessage = $"Teleporting to {regionName}..."; + + Dispatcher.UIThread.InvokeAsync(async () => + { + try + { + _client.Self.Teleport(regionName, new Vector3(x, y, z)); + AddToHistory(location); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to initiate teleport to {Location}", location); + IsTeleporting = false; + TeleportStatusMessage = "Teleport failed"; + + await Task.Delay(3000); + if (TeleportStatusMessage == "Teleport failed") + { + TeleportStatusMessage = ""; + } + } + }, DispatcherPriority.Background); + } + else + { + Log.Error("Failed to parse location: {Location}", location); + } + } + catch (Exception ex) + { + Log.Error(ex, $"Failed to teleport to location: {location}"); + IsTeleporting = false; + TeleportStatusMessage = ""; + } + } + + private bool ParseLocationString(string location, out string regionName, out float x, + out float y, out float z) + { + regionName = ""; + x = y = z = 0; + + if (string.IsNullOrWhiteSpace(location)) + return false; + + // Handle secondlife:// URLs + if (location.StartsWith("secondlife://")) + { + return ParseSecondLifeUrl(location, out regionName, out x, out y, out z); + } + + // Handle maps.secondlife.com URLs + if (location.Contains("maps.secondlife.com")) + { + return ParseMapsUrl(location, out regionName, out x, out y, out z); + } + + // Handle "Region Name/X/Y/Z" format + var match = SlurlRegex().Match(location); + if (match.Success) + { + regionName = Uri.UnescapeDataString(match.Groups[1].Value); + if (float.TryParse(match.Groups[2].Value, out x) && + float.TryParse(match.Groups[3].Value, out y) && + float.TryParse(match.Groups[4].Value, out z)) + { + return true; + } + } + + // Handle just region name + regionName = location; + x = y = 128; // Default to center + z = 0; + return true; + } + + private bool ParseSecondLifeUrl(string url, out string regionName, out float x, out float y, + out float z) + { + regionName = ""; + x = y = z = 0; + + try + { + var path = url.Substring("secondlife://".Length); + var parts = path.Split('/'); + + if (parts.Length >= 1) + { + regionName = Uri.UnescapeDataString(parts[0]); + + if (parts.Length >= 3) + { + float.TryParse(parts[1], out x); + float.TryParse(parts[2], out y); + if (parts.Length >= 4) + float.TryParse(parts[3], out z); + } + else + { + x = y = 128; // Default center + } + + return true; + } + } + catch (Exception ex) + { + Log.Error(ex, "Failed to parse secondlife URL: {Url}", url); + } + + return false; + } + + private static bool ParseMapsUrl(string url, out string regionName, out float x, + out float y, out float z) + { + regionName = ""; + x = y = z = 0; + + try + { + var secondlifeIndex = url.IndexOf("secondlife/", StringComparison.Ordinal); + if (secondlifeIndex >= 0) + { + var path = url.Substring(secondlifeIndex + "secondlife/".Length); + var parts = path.Split('/'); + + if (parts.Length >= 1) + { + regionName = Uri.UnescapeDataString(parts[0]); + + if (parts.Length >= 3) + { + float.TryParse(parts[1], out x); + float.TryParse(parts[2], out y); + if (parts.Length >= 4) + float.TryParse(parts[3], out z); + } + else + { + x = y = 128; // Default center + } + + return true; + } + } + } + catch (Exception ex) + { + Log.Error(ex, "Failed to parse maps URL: {Url}", url); + } + + return false; + } + + private void AddToHistory(string location) + { + // TODO: Implement proper history management + if (_currentHistoryIndex < _locationHistory.Count - 1) + { + _locationHistory.RemoveRange(_currentHistoryIndex + 1, + _locationHistory.Count - _currentHistoryIndex - 1); + } + + _locationHistory.Add(location); + _currentHistoryIndex = _locationHistory.Count - 1; + + OnPropertyChanged(nameof(CanGoBack)); + OnPropertyChanged(nameof(CanGoForward)); + } + + private void OpenSettings() + { + Log.Information("Opening preferences window"); + if (_openPreferencesCommand?.CanExecute(null) == true) + { + _openPreferencesCommand.Execute(null); + } + else + { + Log.Warning("No preferences navigation command available"); + } + } + + private void UpdateLocationDisplay() + { + if (_client.Network?.Connected != true || _client.Network.CurrentSim == null) + { + CurrentLocationDisplay = _session.CurrentLocation; + CurrentCoordinatesDisplay = ""; + MaturityRating = "G"; + return; + } + + var regionName = _client.Network.CurrentSim.Name; + var x = (int)_client.Self.SimPosition.X; + var y = (int)_client.Self.SimPosition.Y; + var z = (int)_client.Self.SimPosition.Z; + + CurrentLocationDisplay = $"{regionName} ({x},{y},{z})"; + CurrentCoordinatesDisplay = $"{x}/{y}/{z}"; + + // Update maturity rating based on region access level + MaturityRating = _client.Network.CurrentSim.Access switch + { + SimAccess.Mature => "M", + SimAccess.Adult => "A", + _ => "G" + }; + + OnPropertyChanged(nameof(MaturityRatingColorHex)); + OnPropertyChanged(nameof(MaturityRatingColor)); + OnPropertyChanged(nameof(MaturityRatingBrush)); + OnPropertyChanged(nameof(ShowMaturityRating)); + } + + private void OnLiteDbServicePropertyChanged(object? sender, PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(LiteDbService.Session): + _session = _liteDbService.GetSession(); + UpdateLocationDisplay(); + break; + } + } + + private void OnAvatarUpdate(object? sender, AvatarUpdateEventArgs e) + { + if (e.Avatar.ID != _client.Self.AgentID) + return; + + // Check if we need to throttle updates to prevent performance issues + var now = DateTime.UtcNow; + if (now - _lastLocationUpdate < TimeSpan.FromMilliseconds(LocationUpdateThrottleMs)) + return; + + // Check if position or region has actually changed to avoid unnecessary updates + var currentPosition = _client.Self.SimPosition; + var currentRegion = _client.Network.CurrentSim?.Name ?? ""; + + if (!HasLocationChanged(currentPosition, currentRegion)) return; + _lastLocationUpdate = now; + _lastKnownPosition = currentPosition; + _lastKnownRegion = currentRegion; + + Dispatcher.UIThread.InvokeAsync(UpdateLocationDisplay, DispatcherPriority.Background); + } + + private void OnSimConnected(object? sender, EventArgs e) + { + Log.Information("Connected to simulator: {Simulator}", + _client.Network.CurrentSim?.Name); + UpdateLocationDisplay(); + } + + private void OnSimDisconnected(object? sender, EventArgs e) + { + Log.Information("Disconnected from simulator"); + CurrentLocationDisplay = "Disconnected"; + CurrentCoordinatesDisplay = ""; + } + + private void OnTeleportProgress(object? sender, TeleportEventArgs e) + { + Dispatcher.UIThread.InvokeAsync(() => + { + switch (e.Status) + { + case TeleportStatus.Start: + IsTeleporting = true; + TeleportStatusMessage = "Initializing teleport..."; + break; + + case TeleportStatus.Progress: + TeleportStatusMessage = e.Message ?? "Teleporting..."; + break; + + case TeleportStatus.Failed: + IsTeleporting = false; + TeleportStatusMessage = $"Teleport failed: {e.Message}"; + Log.Warning("Teleport failed: {Message}", e.Message); + + Task.Delay(5000).ContinueWith(_ => + { + if (TeleportStatusMessage.StartsWith("Teleport failed")) + { + TeleportStatusMessage = ""; + } + }, TaskScheduler.FromCurrentSynchronizationContext()); + break; + + case TeleportStatus.Finished: + IsTeleporting = false; + TeleportStatusMessage = "Teleport complete"; + Log.Information("Teleport completed successfully"); + + UpdateLocationDisplay(); + + Task.Delay(2000).ContinueWith(_ => + { + if (TeleportStatusMessage == "Teleport complete") + { + TeleportStatusMessage = ""; + } + }, TaskScheduler.FromCurrentSynchronizationContext()); + break; + + case TeleportStatus.Cancelled: + IsTeleporting = false; + TeleportStatusMessage = "Teleport cancelled"; + + Task.Delay(3000).ContinueWith(_ => + { + if (TeleportStatusMessage == "Teleport cancelled") + { + TeleportStatusMessage = ""; + } + }, TaskScheduler.FromCurrentSynchronizationContext()); + break; + } + + OnPropertyChanged(nameof(ShowTeleportStatus)); + }, DispatcherPriority.Normal); + } + + private bool _isTeleporting; + + public bool IsTeleporting + { + get => _isTeleporting; + set + { + if (_isTeleporting == value) return; + _isTeleporting = value; + OnPropertyChanged(nameof(IsTeleporting)); + OnPropertyChanged(nameof(ShowTeleportStatus)); + } + } + + private string _teleportStatusMessage = ""; + + public string TeleportStatusMessage + { + get => _teleportStatusMessage; + set + { + if (_teleportStatusMessage == value) return; + _teleportStatusMessage = value; + OnPropertyChanged(nameof(TeleportStatusMessage)); + OnPropertyChanged(nameof(ShowTeleportStatus)); + } + } + + private bool HasLocationChanged(Vector3 currentPosition, string currentRegion) + { + if (currentRegion != _lastKnownRegion) + return true; + + const float + minMovementThreshold = + 0.5f; // Minimum distance to consider as movement is 0.5 meters + var distance = Vector3.Distance(currentPosition, _lastKnownPosition); + return distance >= minMovementThreshold; + } + + public bool ShowTeleportStatus => + IsTeleporting && !string.IsNullOrEmpty(TeleportStatusMessage); + + public void Dispose() + { + _liteDbService.PropertyChanged -= OnLiteDbServicePropertyChanged; + _client.Self.TeleportProgress -= OnTeleportProgress; + _client.Objects.AvatarUpdate -= OnAvatarUpdate; + _client.Network.SimConnected -= OnSimConnected; + _client.Network.SimDisconnected -= OnSimDisconnected; + } + } +} \ No newline at end of file diff --git a/GalaxyViewer/ViewModels/ChatViewModel.cs b/GalaxyViewer/ViewModels/ChatViewModel.cs new file mode 100644 index 0000000..b1ce3ee --- /dev/null +++ b/GalaxyViewer/ViewModels/ChatViewModel.cs @@ -0,0 +1,285 @@ +using System; +using System.Collections.ObjectModel; +using System.Reactive; +using System.Threading.Tasks; +using System.Windows.Input; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Threading; +using GalaxyViewer.Models; +using GalaxyViewer.Services; +using GalaxyViewer.Views; +using ReactiveUI; + +namespace GalaxyViewer.ViewModels; + +public class ChatViewModel : ViewModelBase, IDisposable +{ + private readonly ChatService _chatService; + private readonly ICommand? _backToDashboardCommand; + private ChatConversation? _activeConversation; + private string _messageText = string.Empty; + private bool _isLoading; + private bool _disposed; + + private System.Timers.Timer? _typingTimer; + private bool _isCurrentlyTyping; + private DateTime _lastTypingTime; + + public bool IsInChatWindow { get; set; } + + public ObservableCollection Conversations => _chatService.Conversations; + + public ChatConversation? ActiveConversation + { + get => _activeConversation; + set + { + if (_activeConversation == value) return; + _activeConversation = value; + _chatService.SetActiveConversation(value); + OnPropertyChanged(nameof(ActiveConversation)); + OnPropertyChanged(nameof(ActiveMessages)); + OnPropertyChanged(nameof(CanSendMessage)); + } + } + + public ObservableCollection? ActiveMessages => _activeConversation?.Messages; + + public string MessageText + { + get => _messageText; + set + { + _messageText = value; + OnPropertyChanged(nameof(MessageText)); + OnPropertyChanged(nameof(CanSendMessage)); + + HandleTypingDetection(); + } + } + + public bool IsLoading + { + get => _isLoading; + set + { + _isLoading = value; + OnPropertyChanged(nameof(IsLoading)); + } + } + + public bool CanSendMessage => !string.IsNullOrWhiteSpace(MessageText) && + ActiveConversation != null && !IsLoading; + + public bool CanTypeMessage => + ActiveConversation != null + && !IsLoading + && ActiveConversation.MessageType != ChatMessageType.Objects; + + public bool ShowObjectImWarning => ActiveConversation?.MessageType == ChatMessageType.Objects; + + public string ObjectImWarning => ShowObjectImWarning + ? Application.Current?.FindResource("Chat_ObjectImWarning") as string ?? string.Empty + : string.Empty; + + public ReactiveCommand SendMessageCommand { get; } + public ReactiveCommand SelectConversationCommand { get; } + public ReactiveCommand PopOutChatCommand { get; } + + public ChatViewModel(ChatService chatService, ICommand? backToDashboardCommand = null) + { + _chatService = chatService; + _backToDashboardCommand = backToDashboardCommand; + + _chatService.ActiveConversationChanged += OnActiveConversationChanged; + _chatService.MessageReceived += OnMessageReceived; + _chatService.ConversationUpdated += OnConversationUpdated; + + SendMessageCommand = ReactiveCommand.CreateFromTask(SendMessageAsync); + SelectConversationCommand = ReactiveCommand.Create(SelectConversation); + PopOutChatCommand = ReactiveCommand.Create(PopOutChat); + + ActiveConversation = _chatService.LocalChatConversation; + + InitializeTypingTimer(); + } + + private void InitializeTypingTimer() + { + _typingTimer = new System.Timers.Timer(3000); // 3 seconds + _typingTimer.Elapsed += OnTypingTimerElapsed; + _typingTimer.AutoReset = false; + } + + private async Task SendMessageAsync() + { + if (!CanSendMessage || ActiveConversation == null) return; + + StopTyping(); + + IsLoading = true; + var message = MessageText; + MessageText = string.Empty; + + try + { + switch (ActiveConversation.MessageType) + { + case ChatMessageType.LocalChat: + await _chatService.SendLocalChatAsync(message); + break; + case ChatMessageType.InstantMessage when !false: + await _chatService.SendInstantMessageAsync(ActiveConversation.ParticipantId, + message); + break; + case ChatMessageType.GroupChat when !false: + await _chatService.SendGroupMessageAsync(ActiveConversation.GroupId, message); + break; + case ChatMessageType.System: + break; + case ChatMessageType.Objects: + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + catch (Exception) + { + MessageText = message; + } + finally + { + IsLoading = false; + } + } + + private void SelectConversation(ChatConversation conversation) + { + ActiveConversation = conversation; + _chatService.MarkConversationAsRead(conversation); + } + + private void OnMessageReceived(object? sender, ChatMessage message) + { + Dispatcher.UIThread.Post(() => + { + OnPropertyChanged(nameof(ActiveMessages)); + + MessageReceived?.Invoke(this, message); + }); + } + + public event EventHandler? MessageReceived; + + private void OnConversationUpdated(object? sender, ChatConversation conversation) + { + OnPropertyChanged(nameof(Conversations)); + } + + private void OnActiveConversationChanged(object? sender, ChatConversation? conversation) + { + Dispatcher.UIThread.Post(() => { ActiveConversation = conversation; }); + } + + private void PopOutChat() + { + if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime + desktop) + return; + + IsInChatWindow = true; + var chatWindow = new ChatWindow(this) + { + DataContext = this, + Title = "GalaxyViewer - Chat", + Width = 800, + Height = 600, + WindowStartupLocation = WindowStartupLocation.CenterOwner + }; + + _backToDashboardCommand?.Execute(null); + + if (desktop.MainWindow != null) + { + chatWindow.ShowDialog(desktop.MainWindow); + } + else + { + chatWindow.Show(); + } + } + + private void HandleTypingDetection() + { + if (ActiveConversation?.MessageType != ChatMessageType.LocalChat) + return; + + var now = DateTime.Now; + _lastTypingTime = now; + + if (!string.IsNullOrWhiteSpace(MessageText)) + { + if (!_isCurrentlyTyping) + { + _isCurrentlyTyping = true; + _chatService.StartLocalChatTyping(); + } + + _typingTimer?.Stop(); + _typingTimer?.Start(); + } + else + { + if (_isCurrentlyTyping) + { + StopTyping(); + } + } + } + + private void OnTypingTimerElapsed(object? sender, System.Timers.ElapsedEventArgs e) + { + Dispatcher.UIThread.Post(() => + { + var timeSinceLastTyping = DateTime.Now - _lastTypingTime; + if (timeSinceLastTyping.TotalSeconds >= 3) + { + StopTyping(); + } + }); + } + + private void StopTyping() + { + if (!_isCurrentlyTyping) return; + _isCurrentlyTyping = false; + _chatService.StopLocalChatTyping(); + _typingTimer?.Stop(); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (_disposed) return; + if (disposing) + { + _chatService.ActiveConversationChanged -= OnActiveConversationChanged; + _chatService.MessageReceived -= OnMessageReceived; + _chatService.ConversationUpdated -= OnConversationUpdated; + } + + _disposed = true; + } + + ~ChatViewModel() + { + Dispose(false); + } +} \ No newline at end of file diff --git a/GalaxyViewer/ViewModels/ConversationDrawerViewModel.cs b/GalaxyViewer/ViewModels/ConversationDrawerViewModel.cs new file mode 100644 index 0000000..934c4a6 --- /dev/null +++ b/GalaxyViewer/ViewModels/ConversationDrawerViewModel.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using GalaxyViewer.Models; +using System.Windows.Input; + +namespace GalaxyViewer.ViewModels; + +public class ConversationDrawerViewModel : ObservableObject +{ + private readonly ChatViewModel? _parentChatViewModel; + + public ObservableCollection Conversations { get; } + + public ICommand SelectConversationCommand { get; } + + public ConversationDrawerViewModel(ChatViewModel? parentChatViewModel) + { + _parentChatViewModel = parentChatViewModel; + Conversations = _parentChatViewModel?.Conversations ?? []; + + SelectConversationCommand = new RelayCommand(SelectConversation); + } + + private void SelectConversation(ChatConversation? conversation) + { + if (conversation != null && _parentChatViewModel != null) + { + _parentChatViewModel.SelectConversationCommand.Execute(conversation).Subscribe(); + } + } +} diff --git a/GalaxyViewer/ViewModels/DashboardViewModel.cs b/GalaxyViewer/ViewModels/DashboardViewModel.cs new file mode 100644 index 0000000..96f0c63 --- /dev/null +++ b/GalaxyViewer/ViewModels/DashboardViewModel.cs @@ -0,0 +1,211 @@ +using System; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Reactive; +using System.Threading.Tasks; +using System.Windows.Input; +using System.Linq; +using ReactiveUI; +using Avalonia.Controls; +using OpenMetaverse; +using GalaxyViewer.Services; +using GalaxyViewer.Models; +using GalaxyViewer.Views; + +namespace GalaxyViewer.ViewModels; + +public sealed class DashboardViewModel : ViewModelBase, INotifyPropertyChanged +{ + + private readonly LiteDbService _liteDbService; + private readonly SessionService _sessionService; + private readonly GridClient _client; + private readonly ChatService? _chatService; + private SessionModel _session; + private TabItem? _activeTab; + private int _totalUnreadMessages; + + public ObservableCollection Tabs { get; } + + private TabItem? ActiveTab + { + get => _activeTab; + set + { + if (_activeTab != null) + _activeTab.IsActive = false; + + this.RaiseAndSetIfChanged(ref _activeTab, value); + + if (_activeTab != null) + _activeTab.IsActive = true; + } + } + + public object? ActiveTabContent => ActiveTab?.Content; + + public int CurrentBalance { get; private set; } + public string FormattedBalance => $"{CurrencySymbol}{CurrentBalance:N0}"; + private string CurrencySymbol => "L$"; + + public AddressBarViewModel AddressBarViewModel { get; } + + private int TotalUnreadMessages + { + get => _totalUnreadMessages; + set + { + if (_totalUnreadMessages == value) return; + + _totalUnreadMessages = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(HasUnreadMessages)); + OnPropertyChanged(nameof(UnreadMessagesText)); + } + } + + public bool HasUnreadMessages => TotalUnreadMessages > 0; + public string UnreadMessagesText => TotalUnreadMessages > 99 ? "99+" : TotalUnreadMessages.ToString(); + + public ReactiveCommand RefreshBalanceCommand { get; } + + public ICommand ActivateTabCommand { get; } + public ICommand CloseTabCommand { get; } + + public DashboardViewModel( + LiteDbService liteDbService, + GridClient client, + SessionService sessionService, + ICommand? openPreferencesCommand = null, + ChatService? chatService = null) + { + _liteDbService = liteDbService ?? throw new ArgumentNullException(nameof(liteDbService)); + _client = client ?? throw new ArgumentNullException(nameof(client)); + _sessionService = sessionService ?? throw new ArgumentNullException(nameof(sessionService)); + _chatService = chatService; + _session = _liteDbService.GetSession(); + + Tabs = []; + + AddressBarViewModel = new AddressBarViewModel(_liteDbService, _client, openPreferencesCommand); + + RefreshBalanceCommand = ReactiveCommand.Create(RequestBalance); + ActivateTabCommand = ReactiveCommand.Create(ActivateTab); + CloseTabCommand = ReactiveCommand.Create(CloseTab); + + InitializeTabs(); + SubscribeToEvents(); + + CurrentBalance = _sessionService.Balance; + OnPropertyChanged(nameof(CurrentBalance)); + + if (_chatService != null) + { + UpdateUnreadMessageCount(); + } + } + + + private void InitializeTabs() + { + if (_chatService == null) return; + + // Main Chat/Local Chat tab (non-closeable for now) + var chatViewModel = new ChatViewModel(_chatService); + var mainChatTab = new TabItem("main_chat", "Local Chat", + new ChatView { DataContext = chatViewModel }, false); + Tabs.Add(mainChatTab); + + // World/Map tab (placeholder) + // TODO: Implement actual world/map functionality + var worldTab = new TabItem("world", "World", + new TextBlock { Text = "World/Map view - Coming Soon" }, false); + Tabs.Add(worldTab); + + // Inventory tab (placeholder) + // TODO: Implement actual inventory functionality + var inventoryTab = new TabItem("inventory", "Inventory", + new TextBlock { Text = "Inventory view - Coming Soon" }, false); + Tabs.Add(inventoryTab); + + // People/Friends tab (placeholder) + // TODO: Implement actual people/friends functionality + var peopleTab = new TabItem("people", "People", + new TextBlock { Text = "People/Friends view - Coming Soon" }, false); + Tabs.Add(peopleTab); + + if (Tabs.Count > 0) + ActiveTab = Tabs[0]; + } + + private void ActivateTab(TabItem tab) + { + ActiveTab = tab; + tab.NotificationCount = 0; + this.RaisePropertyChanged(nameof(ActiveTabContent)); + UpdateUnreadMessageCount(); + } + + private void CloseTab(TabItem tab) + { + if (!tab.IsCloseable) return; + + var index = Tabs.IndexOf(tab); + Tabs.Remove(tab); + + if (tab == ActiveTab && Tabs.Count > 0) + { + var newActiveIndex = Math.Min(index, Tabs.Count - 1); + ActiveTab = Tabs[newActiveIndex]; + } + + UpdateUnreadMessageCount(); + } + + private void SubscribeToEvents() + { + _client.Network.LoginProgress += OnLoginProgress; + _client.Network.Disconnected += OnDisconnected; + _sessionService.BalanceChanged += OnBalanceChanged; + } + + private void UpdateUnreadMessageCount() + { + var totalUnread = Tabs.Sum(tab => tab.NotificationCount); + TotalUnreadMessages = totalUnread; + } + + private void OnLoginProgress(object? sender, LoginProgressEventArgs e) + { + if (e.Status != LoginStatus.Success) return; + CurrentBalance = _sessionService.Balance; + OnPropertyChanged(nameof(CurrentBalance)); + OnPropertyChanged(nameof(FormattedBalance)); + } + + private void OnDisconnected(object? sender, DisconnectedEventArgs e) + { + CurrentBalance = 0; + OnPropertyChanged(nameof(CurrentBalance)); + OnPropertyChanged(nameof(FormattedBalance)); + } + + private void OnBalanceChanged(object? sender, int newBalance) + { + CurrentBalance = newBalance; + OnPropertyChanged(nameof(CurrentBalance)); + OnPropertyChanged(nameof(FormattedBalance)); + } + + private void RequestBalance() + { + Task.Run(() => _client.Self.RequestBalance()); + } + + public new event PropertyChangedEventHandler? PropertyChanged; + + private new void OnPropertyChanged([System.Runtime.CompilerServices.CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} diff --git a/GalaxyViewer/ViewModels/LoggedInViewModel.cs b/GalaxyViewer/ViewModels/LoggedInViewModel.cs deleted file mode 100644 index 4f0ab0b..0000000 --- a/GalaxyViewer/ViewModels/LoggedInViewModel.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System.ComponentModel; -using ReactiveUI; -using GalaxyViewer.Services; -using GalaxyViewer.Models; - -namespace GalaxyViewer.ViewModels; - -public class LoggedInViewModel : ViewModelBase, INotifyPropertyChanged -{ - private readonly LiteDbService _liteDbService; - private SessionModel _session; - - public LoggedInViewModel(LiteDbService liteDbService) - { - _liteDbService = liteDbService; - _session = _liteDbService.GetSession(); - _liteDbService.PropertyChanged += OnLiteDbServicePropertyChanged; - } - - public string CurrentLocation - { - get => _session.CurrentLocation; - set - { - if (_session.CurrentLocation != value) - { - _session.CurrentLocation = value; - _liteDbService.SaveSession(_session); - OnPropertyChanged(nameof(CurrentLocation)); - } - } - } - - public string LoginWelcomeMessage - { - get => _session.LoginWelcomeMessage; - set - { - if (_session.LoginWelcomeMessage != value) - { - _session.LoginWelcomeMessage = value; - _liteDbService.SaveSession(_session); - OnPropertyChanged(nameof(LoginWelcomeMessage)); - } - } - } - - public int Balance - { - get => _session.Balance; - set - { - if (_session.Balance != value) - { - _session.Balance = value; - _liteDbService.SaveSession(_session); - OnPropertyChanged(nameof(Balance)); - } - } - } - - private void OnLiteDbServicePropertyChanged(object sender, PropertyChangedEventArgs e) - { - if (e.PropertyName == nameof(LiteDbService.Session)) - { - _session = _liteDbService.GetSession(); - OnPropertyChanged(nameof(CurrentLocation)); - OnPropertyChanged(nameof(LoginWelcomeMessage)); - OnPropertyChanged(nameof(Balance)); - } - } - - public event PropertyChangedEventHandler PropertyChanged; - - protected virtual void OnPropertyChanged(string propertyName) - { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); - } -} \ No newline at end of file diff --git a/GalaxyViewer/ViewModels/LoginViewModel.cs b/GalaxyViewer/ViewModels/LoginViewModel.cs index c23fed7..f6fa3d6 100644 --- a/GalaxyViewer/ViewModels/LoginViewModel.cs +++ b/GalaxyViewer/ViewModels/LoginViewModel.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics; @@ -13,10 +13,10 @@ using GalaxyViewer.Models; using GalaxyViewer.Services; using GalaxyViewer.Views; using Newtonsoft.Json; +using OpenMetaverse; using ReactiveUI; using Ursa.Controls; using Serilog; -using OpenMetaverse; namespace GalaxyViewer.ViewModels; @@ -33,14 +33,6 @@ public class LoginViewModel : ReactiveObject, IRoutableViewModel } } - private bool _isLoggedIn; - - public bool IsLoggedIn - { - get => App.IsLoggedIn; - set => App.IsLoggedIn = value; - } - private string _loginStatusMessage; public string LoginStatusMessage @@ -49,38 +41,60 @@ public class LoginViewModel : ReactiveObject, IRoutableViewModel set => this.RaiseAndSetIfChanged(ref _loginStatusMessage, value); } + /* + public static string SplashScreenUrl + { + get + { + var version = VersionHelper.GetInformationalVersion(); + var platform = GetFullPlatformString().ToLowerInvariant(); + return $"https://galaxyviewer-splash.pages.dev?version={version}&platform={platform}"; + } + } + +#if DEBUG + public bool ShowSplashScreen => false; + // Hide in debug builds because you need a bunch of dependencies for Linux and why would I do all that for a tiny thing? + // The built one has all the dependencies included so leave it in there +#else + public bool ShowSplashScreen => true; // Show in release builds +#endif +*/ + private readonly LiteDbService _liteDbService; private readonly PreferencesViewModel _preferencesViewModel; - private readonly Timer _sessionCheckTimer; - private SessionModel _currentSession; private string _username; private string _password; - private readonly GridClient _client = new(); + private readonly GridClient _client; private IRoutableViewModel? _routableViewModelImplementation; private readonly GridService _gridService; - private ObservableCollection _grids; + private ObservableCollection _grids; private GridModel _selectedGrid; - private const string DefaultGridUri = - "https://login.agni.lindenlab.com/cgi-bin/login.cgi"; - public WindowToastManager? ToastManager { get; set; } - public LoginViewModel(LiteDbService liteDbService) + private SessionModel _currentSession; + + public SessionModel CurrentSession + { + get => _currentSession; + set => this.RaiseAndSetIfChanged(ref _currentSession, value); + } + + public LoginViewModel(LiteDbService liteDbService, GridClient client) { _liteDbService = liteDbService ?? throw new ArgumentNullException(nameof(liteDbService)); + _client = client ?? throw new ArgumentNullException(nameof(client)); _preferencesViewModel = new PreferencesViewModel(); _currentSession = _liteDbService.GetSession() ?? throw new InvalidOperationException("Session could not be retrieved."); - _sessionCheckTimer = - new Timer(CheckSessionChanges, null, TimeSpan.Zero, TimeSpan.FromMinutes(1)); _username = string.Empty; _password = string.Empty; LoginLocations = _preferencesViewModel.LoginLocationOptions; SelectedLoginLocation = _preferencesViewModel.SelectedLoginLocation; TryLoginCommand = ReactiveCommand.CreateFromTask(TryLoginAsync); _gridService = new GridService(); - _grids = new ObservableCollection(); + _grids = []; LoadGrids(); @@ -89,30 +103,16 @@ public class LoginViewModel : ReactiveObject, IRoutableViewModel { Log.Error("An error occurred during login: {Error}", ex.Message); // Handle the error (e.g., show a dialog to the user) - _ = ShowLoginErrorAsync("An error occurred during login. Please try again."); + _ = ShowLoginErrorAsync(); }); // Subscribe to the Network.LoginProgress event _client.Network.LoginProgress += OnLoginProgress; } - private void CheckSessionChanges(object? state) - { - if (_currentSession == null) - { - Log.Error("LiteDbService or current session is null."); - return; - } - - if (!_liteDbService.HasSessionChanged(_currentSession)) return; - _currentSession = _liteDbService.GetSession(); - UpdateViewBindings(); - } - private void UpdateViewBindings() { - // Update the properties bound to the view - this.RaisePropertyChanged(nameof(IsLoggedIn)); + // Update the properties bound to the view\ this.RaisePropertyChanged(nameof(LoginStatusMessage)); } @@ -140,48 +140,59 @@ public class LoginViewModel : ReactiveObject, IRoutableViewModel } } - private UserControl _mfaPromptContainer; + private UserControl? _mfaPromptContainer; - public UserControl MfaPromptContainer + public UserControl? MfaPromptContainer { get => _mfaPromptContainer; set => this.RaiseAndSetIfChanged(ref _mfaPromptContainer, value); } - public ObservableCollection Grids { get; set; } - - public GridModel? SelectedGrid + public ObservableCollection Grids { - get - { - var selectedGrid = _grids.FirstOrDefault(g => - g.GridNick == _preferencesViewModel.SelectedGridNick) ?? new GridModel - { - GridNick = "Second Life", - LoginUri = DefaultGridUri - }; - - return selectedGrid; - } - set - { - if (value == null) return; - _preferencesViewModel.SelectedGridNick = value.GridNick; - this.RaiseAndSetIfChanged(ref _selectedGrid, value); - } + get => _grids; + set => this.RaiseAndSetIfChanged(ref _grids, value); } private void LoadGrids() { var grids = _gridService.GetAllGrids(); - _grids = new ObservableCollection(grids); - SelectedGrid = _grids.FirstOrDefault(g => - g.GridNick == _preferencesViewModel.SelectedGridNick); + Grids = new ObservableCollection(grids); + + // If no selection, default to the Second Life grid + if (Grids.Count > 0) + { + if (string.IsNullOrEmpty(_preferencesViewModel.SelectedGridNick)) + _preferencesViewModel.SelectedGridNick = Grids[0].GridName; + + var selected = + Grids.FirstOrDefault(g => g.GridName == _preferencesViewModel.SelectedGridNick) + ?? Grids[0]; + + if (SelectedGrid != selected) + SelectedGrid = selected; + } + else + { + SelectedGrid = null; + } + } + + public GridModel? SelectedGrid + { + get => _selectedGrid; + set + { + if (_selectedGrid == value) return; + _selectedGrid = value; + if (value != null) _preferencesViewModel.SelectedGridNick = value.GridName; + this.RaisePropertyChanged(); + } } public ReactiveCommand TryLoginCommand { get; } - private async Task ShowLoginErrorAsync(string errorMessage) + private async Task ShowLoginErrorAsync() { // ToastManager?.Show( // new Toast( @@ -198,7 +209,7 @@ public class LoginViewModel : ReactiveObject, IRoutableViewModel if (string.IsNullOrWhiteSpace(Username) || string.IsNullOrWhiteSpace(Password)) { Log.Warning("Username or password is empty"); - await ShowLoginErrorAsync("Username or password is empty"); + await ShowLoginErrorAsync(); return; } @@ -210,7 +221,7 @@ public class LoginViewModel : ReactiveObject, IRoutableViewModel .GetCustomAttribute()? .InformationalVersion ?? "Version not found"; - var loginParams = _client?.Network?.DefaultLoginParams( + var loginParams = _client.Network?.DefaultLoginParams( Username.Split(' ')[0], // firstName Username.Contains(' ') ? Username.Split(' ')[1] : "Resident", // lastName Password, @@ -221,7 +232,7 @@ public class LoginViewModel : ReactiveObject, IRoutableViewModel if (loginParams == null) { Log.Error("Failed to create login parameters"); - await ShowLoginErrorAsync("Failed to create login parameters"); + await ShowLoginErrorAsync(); return; } @@ -229,7 +240,7 @@ public class LoginViewModel : ReactiveObject, IRoutableViewModel loginParams.MfaEnabled = true; loginParams.Platform = ourPlatform; loginParams.PlatformVersion = Environment.OSVersion.VersionString; - loginParams.LoginLocation = _preferencesViewModel?.SelectedLoginLocation switch + loginParams.Start = _preferencesViewModel.SelectedLoginLocation switch { "Home" => "home", "Last Location" => "last", @@ -237,25 +248,25 @@ public class LoginViewModel : ReactiveObject, IRoutableViewModel }; loginParams.UserAgent = "LibreMetaverse"; -#if DEBUG - Log.Information("Login parameters: {LoginParams}", - JsonConvert.SerializeObject(loginParams, Formatting.Indented)); -#endif - - var loginSuccess = await Task.Run(() => _client?.Network?.Login(loginParams) ?? false); + var loginSuccess = await Task.Run(() => _client.Network?.Login(loginParams) ?? false); if (loginSuccess) { +#if DEBUG + Log.Information("Login parameters: {LoginParams}", + JsonConvert.SerializeObject(loginParams, Formatting.Indented)); +#endif + await HandleSuccessfulLogin(); } - else if (_client?.Network?.LoginMessage.Contains("multifactor") == true) + else if (_client.Network?.LoginMessage.Contains("multifactor") == true) { Log.Information("MFA required for login"); var mfaCode = await ShowMfaPromptDialogAsync(); if (string.IsNullOrWhiteSpace(mfaCode)) { Log.Warning("MFA code is empty"); - await ShowLoginErrorAsync("MFA code is required"); + await ShowLoginErrorAsync(); return; } @@ -266,7 +277,7 @@ public class LoginViewModel : ReactiveObject, IRoutableViewModel JsonConvert.SerializeObject(loginParams, Formatting.Indented)); #endif - loginSuccess = await Task.Run(() => _client?.Network?.Login(loginParams) ?? false); + loginSuccess = await Task.Run(() => _client.Network?.Login(loginParams) ?? false); if (loginSuccess) { @@ -274,19 +285,24 @@ public class LoginViewModel : ReactiveObject, IRoutableViewModel } else { - Log.Error("MFA login failed: {Error}", _client?.Network?.LoginMessage); - IsLoggedIn = false; - await ShowLoginErrorAsync($"MFA login failed: {_client?.Network?.LoginMessage}"); + Log.Error("MFA login failed: {Error}", _client.Network?.LoginMessage); + await ShowLoginErrorAsync(); } } else { - Log.Error("Login failed: {Error}", _client?.Network?.LoginMessage); - IsLoggedIn = false; - await ShowLoginErrorAsync($"Login failed: {_client?.Network?.LoginMessage}"); + Log.Error("Login failed: {Error}", _client.Network?.LoginMessage); + await ShowLoginErrorAsync(); } } + private bool _isMfaPromptVisible; + public bool IsMfaPromptVisible + { + get => _isMfaPromptVisible; + set => this.RaiseAndSetIfChanged(ref _isMfaPromptVisible, value); + } + private async Task ShowMfaPromptDialogAsync() { var tcs = new TaskCompletionSource(); @@ -297,11 +313,12 @@ public class LoginViewModel : ReactiveObject, IRoutableViewModel }; MfaPromptContainer = mfaPromptDialog; + IsMfaPromptVisible = true; var mfaCode = await tcs.Task; - // Clear the MFA prompt container after getting the code MfaPromptContainer = null; + IsMfaPromptVisible = false; return mfaCode; } @@ -322,34 +339,62 @@ public class LoginViewModel : ReactiveObject, IRoutableViewModel "Unk"; } + private static string GetFullPlatformString() + { + var platformMap = new Dictionary + { + { OSPlatform.Windows, "Windows" }, + { OSPlatform.Linux, "Linux" }, + { OSPlatform.OSX, "MacOS" }, + { OSPlatform.Create("BROWSER"), "Browser" }, + { OSPlatform.Create("ANDROID"), "Android" }, + { OSPlatform.Create("IOS"), "iOS" } + }; + + return platformMap.FirstOrDefault(kv => RuntimeInformation.IsOSPlatform(kv.Key)).Value ?? + "Unknown"; + } + + private async Task HandleSuccessfulLogin() { Log.Information("Login successful as {Name}", _client.Self.Name); - IsLoggedIn = true; + App.IsLoggedIn = true; var session = new SessionModel { - Id = 1, // Assuming a single session record - IsLoggedIn = true, + Id = 1, AvatarName = _client.Self.Name, AvatarKey = _client.Self.AgentID, - Balance = _client.Self.Balance, CurrentLocation = _client.Network.CurrentSim.Name, LoginWelcomeMessage = _client.Network.LoginMessage }; _liteDbService.SaveSession(session); + CurrentSession = session; + UpdateViewBindings(); + Log.Information("Session updated on successful login"); + try + { + _client.Self.RequestBalance(); + Log.Information("Balance request sent after successful login"); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to request balance after login"); + } + await ProcessCapabilitiesAsync(); } private async Task ProcessCapabilitiesAsync() { - var loginMessage = await Task.Run(() => _client?.Network?.LoginMessage); + var loginMessage = await Task.Run(() => _client.Network?.LoginMessage); Log.Information("Login message: {Message}", loginMessage); - var currentSim = await Task.Run(() => _client?.Network?.CurrentSim); + var currentSim = await Task.Run(() => _client.Network?.CurrentSim); Log.Information("Current location: {Sim}", currentSim); await Task.CompletedTask; @@ -375,7 +420,6 @@ public class LoginViewModel : ReactiveObject, IRoutableViewModel case LoginStatus.Success: LoginStatusMessage = $"Logged in as {_client.Self.Name}, welcome to {_client.Network.CurrentSim?.Name}"; - IsLoggedIn = true; break; case LoginStatus.Failed: LoginStatusMessage = $"Login failed: {e.Message}"; diff --git a/GalaxyViewer/ViewModels/MainViewModel.cs b/GalaxyViewer/ViewModels/MainViewModel.cs index 3071a8d..93bb292 100644 --- a/GalaxyViewer/ViewModels/MainViewModel.cs +++ b/GalaxyViewer/ViewModels/MainViewModel.cs @@ -1,10 +1,11 @@ -using System; +using System; using System.ComponentModel; using System.Runtime.CompilerServices; using System.Windows.Input; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Threading; using GalaxyViewer.Views; using GalaxyViewer.Services; using OpenMetaverse; @@ -12,13 +13,17 @@ using ReactiveUI; namespace GalaxyViewer.ViewModels; -public class MainViewModel : ViewModelBase, INotifyPropertyChanged +public class MainViewModel : ViewModelBase, INotifyPropertyChanged, IDisposable { private UserControl _currentView; - private readonly GridClient _client = new(); - private readonly LoginViewModel _loginViewModel; - private readonly LoggedInViewModel _loggedInViewModel; + private readonly GridClient _client; private readonly LiteDbService _liteDbService; + private readonly SessionService _sessionService; + private readonly ChatService _chatService; + private readonly DashboardView _dashboardView; + private bool _disposed; + + private PreferencesWindow? _preferencesWindow; public new event PropertyChangedEventHandler? PropertyChanged; @@ -33,7 +38,7 @@ public class MainViewModel : ViewModelBase, INotifyPropertyChanged } } - private new void OnPropertyChanged([CallerMemberName] string propertyName = null) + private new void OnPropertyChanged([CallerMemberName] string? propertyName = null) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } @@ -45,29 +50,40 @@ public class MainViewModel : ViewModelBase, INotifyPropertyChanged public ICommand NavToLoginViewCommand { get; } public ICommand NavToPreferencesViewCommand { get; } public ICommand NavToDevViewCommand { get; } + public ICommand BackToDashboardViewCommand { get; } - public MainViewModel(LiteDbService liteDbService) + + public MainViewModel(LiteDbService liteDbService, GridClient client, + SessionService sessionService) { _liteDbService = liteDbService; + _client = client; + _sessionService = sessionService; + _chatService = new ChatService(_client, _liteDbService); - App.StaticPropertyChanged += (sender, args) => - { - if (args.PropertyName != nameof(App.IsLoggedIn)) return; - OnPropertyChanged(nameof(IsLoggedIn)); - if (IsLoggedIn) - { - NavigateToLoggedInView(); - } - }; - - _loginViewModel = new LoginViewModel(_liteDbService); - _loggedInViewModel = new LoggedInViewModel(_liteDbService); - _currentView = new LoginView(_liteDbService); ExitCommand = ReactiveCommand.Create(LogoutAndExit); LogoutCommand = ReactiveCommand.Create(Logout); NavToLoginViewCommand = ReactiveCommand.Create(NavigateToLoginView); NavToPreferencesViewCommand = ReactiveCommand.Create(NavigateToPreferencesView); NavToDevViewCommand = ReactiveCommand.Create(NavigateToDevView); + BackToDashboardViewCommand = ReactiveCommand.Create(NavigateBackToDashboardView); + + var dashboardViewModel = new DashboardViewModel(_liteDbService, _client, _sessionService, + NavToPreferencesViewCommand, _chatService); + _dashboardView = new DashboardView { DataContext = dashboardViewModel }; + + App.StaticPropertyChanged += (_, args) => + { + if (args.PropertyName != nameof(App.IsLoggedIn)) return; + OnPropertyChanged(nameof(IsLoggedIn)); + if (IsLoggedIn) + { + Dispatcher.UIThread.Post(NavigateToDashboardView); + } + }; + + var loginViewModel = new LoginViewModel(_liteDbService, _client); + _currentView = new LoginView { DataContext = loginViewModel }; } private void LogoutAndExit() @@ -82,40 +98,100 @@ public class MainViewModel : ViewModelBase, INotifyPropertyChanged private void Logout() { - if (App.IsLoggedIn) - { - _client.Network.Logout(); - _loginViewModel.IsLoggedIn = false; - } - + _chatService.Dispose(); + _client.Network.Logout(); + App.IsLoggedIn = false; NavigateToLoginView(); } private void NavigateToLoginView() { - CurrentView = new LoginView(_liteDbService); + Dispatcher.UIThread.Post(() => + { + var loginViewModel = new LoginViewModel(_liteDbService, _client); + var loginView = new LoginView { DataContext = loginViewModel }; + CurrentView = loginView; + + if (OperatingSystem.IsAndroid()) + { + Dispatcher.UIThread.Post(() => + { + loginView.Focus(); + }, DispatcherPriority.Loaded); + } + }); } - private void NavigateToLoggedInView() + private void NavigateToDashboardView() { - CurrentView = new LoggedInView(_liteDbService); + CurrentView = _dashboardView; } private void NavigateToPreferencesView() { -#if ANDROID - CurrentView = new PreferencesView { DataContext = new PreferencesViewModel() }; -#else - var preferencesWindow = new PreferencesWindow + if (OperatingSystem.IsAndroid() || OperatingSystem.IsIOS()) { - DataContext = new PreferencesViewModel() - }; - preferencesWindow.Show(); -#endif + CurrentView = new PreferencesView { DataContext = new PreferencesViewModel(BackToDashboardViewCommand) }; + } + else + { + if (_preferencesWindow is not { IsVisible: true }) + { + _preferencesWindow?.Close(); + _preferencesWindow = new PreferencesWindow + { + DataContext = new PreferencesViewModel() + }; + _preferencesWindow.Closed += (_, _) => _preferencesWindow = null; + _preferencesWindow.Show(); + } + else + { + _preferencesWindow?.Activate(); + } + } + } + + private void NavigateBackToDashboardView() + { + Dispatcher.UIThread.Post(() => + { + if (App.IsLoggedIn) + { + NavigateToDashboardView(); + } + else + { + NavigateToLoginView(); + } + }); } private void NavigateToDevView() { CurrentView = new DevView { DataContext = new DevViewModel() }; } + + + private readonly object _disposeLock = new(); + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (_disposed) return; + if (disposing) + { + _chatService.Dispose(); + } + _disposed = true; + } + + ~MainViewModel() + { + Dispose(false); + } } \ No newline at end of file diff --git a/GalaxyViewer/ViewModels/PreferencesViewModel.cs b/GalaxyViewer/ViewModels/PreferencesViewModel.cs index 131879c..3ea18d4 100644 --- a/GalaxyViewer/ViewModels/PreferencesViewModel.cs +++ b/GalaxyViewer/ViewModels/PreferencesViewModel.cs @@ -1,10 +1,13 @@ +using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Linq; using System.Threading.Tasks; using System.Windows.Input; using GalaxyViewer.Commands; using GalaxyViewer.Models; using GalaxyViewer.Services; +using Serilog; namespace GalaxyViewer.ViewModels; @@ -14,45 +17,120 @@ public class PreferencesViewModel : ViewModelBase private PreferencesModel _preferences; private bool _isLoadingPreferences; - public PreferencesViewModel() + public ICommand? BackCommand { get; } + + public PreferencesViewModel(ICommand? backCommand = null) { + BackCommand = backCommand; + if (App.PreferencesManager != null) _preferencesManager = App.PreferencesManager; - _preferences = App.PreferencesManager?.CurrentPreferences ?? new PreferencesModel(); + + _preferences = new PreferencesModel(); var preferencesOptions = _preferencesManager?.GetCurrentPreferencesOptions(); + ThemeOptions = new ObservableCollection(preferencesOptions?["ThemeOptions"] ?? - []); + PreferencesOptions.ThemeOptions); LoginLocationOptions = new ObservableCollection( preferencesOptions?["LoginLocationOptions"] ?? - []); + PreferencesOptions.LoginLocationOptions); LanguageOptions = new ObservableCollection( preferencesOptions?["LanguageOptions"] ?? - []); - FontOptions = - new ObservableCollection(preferencesOptions?["FontOptions"] ?? []); - _selectedTheme = _preferences.Theme; - _selectedLoginLocation = _preferences.LoginLocation; - _selectedLanguage = _preferences.Language; - _selectedFont = _preferences.Font; - _selectedGridNick = _preferences.SelectedGridNick; + PreferencesOptions.LanguageOptions); + FontOptions = new ObservableCollection(preferencesOptions?["FontOptions"] ?? + PreferencesOptions.FontOptions); + AccentColorOptions = new ObservableCollection(preferencesOptions?["AccentColorOptions"] ?? + PreferencesOptions.AccentColorOptions); + + // Initialize with default values - these will be overridden by LoadPreferences() + _selectedTheme = ThemeOptions.First(); + _selectedLoginLocation = LoginLocationOptions.First(); + _selectedLanguage = LanguageOptions.First(); + _selectedFont = FontOptions.First(); + _selectedGridNick = ""; + _selectedAccentColor = AccentColorOptions.First(); + SaveCommand = new RelayCommand(async () => await SavePreferencesAsync()); - LoadPreferences(); + + _ = LoadPreferencesAsync(); } - private async void LoadPreferences() + private async Task LoadPreferencesAsync() { - _isLoadingPreferences = true; - if (_preferencesManager != null) - _preferences = await _preferencesManager.LoadPreferencesAsync(); + try + { + _isLoadingPreferences = true; + + await Task.Run(async () => + { + if (_preferencesManager != null) + { + var preferences = await _preferencesManager.LoadPreferencesAsync(); + + await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => + { + _preferences = preferences; + UpdateUIFromPreferences(); + }); + } + }); + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to load preferences in PreferencesViewModel"); + } + finally + { + _isLoadingPreferences = false; + } + } + + private void UpdateUIFromPreferences() + { + var loadedTheme = _preferences.Theme; + + if (loadedTheme == "Default") + { + loadedTheme = "System"; + } + + _selectedTheme = !string.IsNullOrEmpty(loadedTheme) && + ThemeOptions.Contains(loadedTheme) + ? loadedTheme + : "System"; + + _selectedLoginLocation = !string.IsNullOrEmpty(_preferences.LoginLocation) && + LoginLocationOptions.Contains(_preferences.LoginLocation) + ? _preferences.LoginLocation + : LoginLocationOptions.First(); + + _selectedLanguage = !string.IsNullOrEmpty(_preferences.Language) && + LanguageOptions.Contains(_preferences.Language) + ? _preferences.Language + : LanguageOptions.First(); + + _selectedFont = !string.IsNullOrEmpty(_preferences.Font) && + FontOptions.Contains(_preferences.Font) + ? _preferences.Font + : FontOptions.First(); + + _selectedGridNick = _preferences.SelectedGridNick ?? ""; + + _selectedAccentColor = !string.IsNullOrEmpty(_preferences.AccentColor) && + AccentColorOptions.Contains(_preferences.AccentColor) + ? _preferences.AccentColor + : AccentColorOptions.First(); // "System Default" - not really working yet - SelectedTheme = _preferences.Theme; - SelectedLoginLocation = _preferences.LoginLocation; - SelectedLanguage = _preferences.Language; - SelectedFont = _preferences.Font; - SelectedGridNick = _preferences.SelectedGridNick; var gridOptions = _preferencesManager?.GetGridOptions(); GridOptions = new ObservableCollection(gridOptions ?? []); _isLoadingPreferences = false; + + OnPropertyChanged(nameof(SelectedTheme)); + OnPropertyChanged(nameof(SelectedLoginLocation)); + OnPropertyChanged(nameof(SelectedLanguage)); + OnPropertyChanged(nameof(SelectedFont)); + OnPropertyChanged(nameof(SelectedGridNick)); + OnPropertyChanged(nameof(SelectedAccentColor)); } public ObservableCollection ThemeOptions { get; private set; } @@ -60,12 +138,14 @@ public class PreferencesViewModel : ViewModelBase public ObservableCollection LanguageOptions { get; private set; } public ObservableCollection FontOptions { get; private set; } public ObservableCollection GridOptions { get; private set; } + public ObservableCollection AccentColorOptions { get; private set; } private string _selectedLoginLocation; private string _selectedLanguage; private string _selectedFont; private string _selectedTheme; private string _selectedGridNick; + private string _selectedAccentColor; public string SelectedTheme { @@ -122,6 +202,17 @@ public class PreferencesViewModel : ViewModelBase } } + public string SelectedAccentColor + { + get => _selectedAccentColor; + set + { + if (_selectedAccentColor == value || _isLoadingPreferences) return; + _selectedAccentColor = value; + OnPropertyChanged(nameof(SelectedAccentColor)); + } + } + private async Task SavePreferencesAsync() { _preferences.Theme = SelectedTheme; @@ -129,9 +220,17 @@ public class PreferencesViewModel : ViewModelBase _preferences.Language = SelectedLanguage; _preferences.Font = SelectedFont; _preferences.SelectedGridNick = SelectedGridNick; + _preferences.AccentColor = SelectedAccentColor; if (_preferencesManager != null) + { await _preferencesManager.SavePreferencesAsync(_preferences); + Log.Information("Preferences saved successfully"); + } + else + { + Log.Warning("PreferencesManager is null - preferences not saved"); + } } public ICommand SaveCommand { get; } diff --git a/GalaxyViewer/ViewModels/SetProperty.cs b/GalaxyViewer/ViewModels/SetProperty.cs index 8053084..d33c0a6 100644 --- a/GalaxyViewer/ViewModels/SetProperty.cs +++ b/GalaxyViewer/ViewModels/SetProperty.cs @@ -1,4 +1,4 @@ -using System.ComponentModel; +using System.ComponentModel; namespace GalaxyViewer.ViewModels; @@ -6,7 +6,7 @@ public abstract partial class ViewModelBase : INotifyPropertyChanged { public new event PropertyChangedEventHandler? PropertyChanged; - protected void OnPropertyChanged(string propertyName) + protected void OnPropertyChanged(string? propertyName) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } diff --git a/GalaxyViewer/ViewModels/TabItem.cs b/GalaxyViewer/ViewModels/TabItem.cs new file mode 100644 index 0000000..59ecf9d --- /dev/null +++ b/GalaxyViewer/ViewModels/TabItem.cs @@ -0,0 +1,94 @@ +using System; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using Avalonia.Media; + +namespace GalaxyViewer.ViewModels; + +public class TabItem : INotifyPropertyChanged +{ + private bool _isActive; + private bool _hasNotification; + private int _notificationCount; + private string _title; + private object _content; + + public event PropertyChangedEventHandler? PropertyChanged; + + public string Id { get; set; } + + public string Title + { + get => _title; + set + { + _title = value; + OnPropertyChanged(); + } + } + + public object Content + { + get => _content; + set + { + _content = value; + OnPropertyChanged(); + } + } + + public bool IsActive + { + get => _isActive; + set + { + _isActive = value; + OnPropertyChanged(); + if (value) + { + HasNotification = false; + NotificationCount = 0; + } + } + } + + public bool IsCloseable { get; set; } = true; + + public bool HasNotification + { + get => _hasNotification; + set + { + _hasNotification = value; + OnPropertyChanged(); + } + } + + public int NotificationCount + { + get => _notificationCount; + set + { + _notificationCount = value; + OnPropertyChanged(); + HasNotification = value > 0; + } + } + + public bool HasIcon => IconBrush != null; + + public IBrush? IconBrush { get; set; } + + public TabItem(string id, string title, object content, bool isCloseable = true) + { + Id = id; + Title = title; + Content = content; + IsCloseable = isCloseable; + } + + protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} \ No newline at end of file diff --git a/GalaxyViewer/ViewModels/ViewModelBase.cs b/GalaxyViewer/ViewModels/ViewModelBase.cs index 0ffd8a6..99b845c 100644 --- a/GalaxyViewer/ViewModels/ViewModelBase.cs +++ b/GalaxyViewer/ViewModels/ViewModelBase.cs @@ -1,7 +1,7 @@ -using ReactiveUI; +using ReactiveUI; namespace GalaxyViewer.ViewModels; public abstract partial class ViewModelBase : ReactiveObject { -} +} \ No newline at end of file diff --git a/GalaxyViewer/Views/BaseWindow.cs b/GalaxyViewer/Views/BaseWindow.cs index 6370d0e..c2c5691 100644 --- a/GalaxyViewer/Views/BaseWindow.cs +++ b/GalaxyViewer/Views/BaseWindow.cs @@ -1,30 +1,36 @@ -using System; -using System.Diagnostics; +using System; using System.Threading.Tasks; +using Avalonia; using Avalonia.Controls; using Avalonia.Media; using Avalonia.Styling; +using Avalonia.Platform; +using Avalonia.Input; using GalaxyViewer.Models; -using Ursa.ReactiveUIExtension; +using Serilog; +using GalaxyViewer.ViewModels; namespace GalaxyViewer.Views; -public class BaseWindow : ReactiveUrsaWindow, IStyleable +public class BaseWindow : Window { - Type IStyleable.StyleKey => typeof(Window); - protected BaseWindow() { Title = "GalaxyViewer"; Icon = new WindowIcon("Assets/GalaxyViewerLogo.ico"); CanResize = true; + + // Use only native window decorations - no custom chrome + SystemDecorations = SystemDecorations.Full; + ExtendClientAreaToDecorationsHint = false; + if (App.PreferencesManager != null) App.PreferencesManager.PreferencesChanged += OnPreferencesChanged; - // Load preferences asynchronously without blocking the UI thread _ = LoadPreferencesAsync(); } + private async Task LoadPreferencesAsync() { if (App.PreferencesManager == null) return; @@ -49,12 +55,39 @@ public class BaseWindow : ReactiveUrsaWindow, IStyleable { Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => { - RequestedThemeVariant = theme switch - { - "Light" => ThemeVariant.Light, - "Dark" => ThemeVariant.Dark, - _ => ThemeVariant.Default - }; + RequestedThemeVariant = GetThemeVariant(theme); }); } + + private ThemeVariant GetThemeVariant(string themePreference) + { + return themePreference switch + { + "Light" => ThemeVariant.Light, + "Dark" => ThemeVariant.Dark, + "System" => DetectSystemTheme(), + _ => ThemeVariant.Default + }; + } + + private ThemeVariant DetectSystemTheme() + { + try + { + var platformSettings = PlatformSettings; + if (platformSettings != null) + { + var colorValues = platformSettings.GetColorValues(); + return colorValues.ThemeVariant == PlatformThemeVariant.Dark + ? ThemeVariant.Dark + : ThemeVariant.Light; + } + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to detect system theme in BaseWindow, falling back to default"); + } + + return ThemeVariant.Default; + } } \ No newline at end of file diff --git a/GalaxyViewer/Views/ChatArea.axaml b/GalaxyViewer/Views/ChatArea.axaml new file mode 100644 index 0000000..894316b --- /dev/null +++ b/GalaxyViewer/Views/ChatArea.axaml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/GalaxyViewer/Views/ChatView.axaml.cs b/GalaxyViewer/Views/ChatView.axaml.cs new file mode 100644 index 0000000..3f17bf7 --- /dev/null +++ b/GalaxyViewer/Views/ChatView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace GalaxyViewer.Views; + +public partial class ChatView : UserControl +{ + public ChatView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/GalaxyViewer/Views/ChatWindow.axaml b/GalaxyViewer/Views/ChatWindow.axaml new file mode 100644 index 0000000..446c190 --- /dev/null +++ b/GalaxyViewer/Views/ChatWindow.axaml @@ -0,0 +1,14 @@ + + + \ No newline at end of file diff --git a/GalaxyViewer/Views/LoggedInView.axaml.cs b/GalaxyViewer/Views/ChatWindow.axaml.cs similarity index 51% rename from GalaxyViewer/Views/LoggedInView.axaml.cs rename to GalaxyViewer/Views/ChatWindow.axaml.cs index 6fe4ba8..461c6e5 100644 --- a/GalaxyViewer/Views/LoggedInView.axaml.cs +++ b/GalaxyViewer/Views/ChatWindow.axaml.cs @@ -1,16 +1,15 @@ -using Avalonia.Controls; using Avalonia.Markup.Xaml; -using GalaxyViewer.Services; using GalaxyViewer.ViewModels; namespace GalaxyViewer.Views; -public partial class LoggedInView : UserControl +public partial class ChatWindow : BaseWindow { - public LoggedInView(LiteDbService liteDbService) + public ChatWindow(ChatViewModel viewModel) { - DataContext = new LoggedInViewModel(liteDbService); InitializeComponent(); + DataContext = viewModel; + Closed += (_, __) => viewModel.IsInChatWindow = false; } private void InitializeComponent() diff --git a/GalaxyViewer/Views/ConversationDrawerView.axaml b/GalaxyViewer/Views/ConversationDrawerView.axaml new file mode 100644 index 0000000..13a273e --- /dev/null +++ b/GalaxyViewer/Views/ConversationDrawerView.axaml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/GalaxyViewer/Views/ConversationDrawerView.axaml.cs b/GalaxyViewer/Views/ConversationDrawerView.axaml.cs new file mode 100644 index 0000000..2f15305 --- /dev/null +++ b/GalaxyViewer/Views/ConversationDrawerView.axaml.cs @@ -0,0 +1,17 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace GalaxyViewer.Views; + +public partial class ConversationDrawerView : UserControl +{ + public ConversationDrawerView() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } +} diff --git a/GalaxyViewer/Views/DashboardView.axaml b/GalaxyViewer/Views/DashboardView.axaml new file mode 100644 index 0000000..75b380b --- /dev/null +++ b/GalaxyViewer/Views/DashboardView.axaml @@ -0,0 +1,185 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/GalaxyViewer/Views/DashboardView.axaml.cs b/GalaxyViewer/Views/DashboardView.axaml.cs new file mode 100644 index 0000000..813d0a2 --- /dev/null +++ b/GalaxyViewer/Views/DashboardView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace GalaxyViewer.Views; + +public partial class DashboardView : UserControl +{ + public DashboardView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/GalaxyViewer/Views/DevView.axaml.cs b/GalaxyViewer/Views/DevView.axaml.cs index 51fc36d..13cfe9c 100644 --- a/GalaxyViewer/Views/DevView.axaml.cs +++ b/GalaxyViewer/Views/DevView.axaml.cs @@ -1,8 +1,5 @@ -using Avalonia.Controls; +using Avalonia.Controls; using Avalonia.Markup.Xaml; -using Avalonia.VisualTree; -using GalaxyViewer.ViewModels; -using Ursa.Controls; namespace GalaxyViewer.Views; diff --git a/GalaxyViewer/Views/LoggedInView.axaml b/GalaxyViewer/Views/LoggedInView.axaml deleted file mode 100644 index bb0d512..0000000 --- a/GalaxyViewer/Views/LoggedInView.axaml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/GalaxyViewer/Views/LoginView.axaml b/GalaxyViewer/Views/LoginView.axaml index f33214e..b6af494 100644 --- a/GalaxyViewer/Views/LoginView.axaml +++ b/GalaxyViewer/Views/LoginView.axaml @@ -4,51 +4,98 @@ x:Class="GalaxyViewer.Views.LoginView" xmlns:viewModels="clr-namespace:GalaxyViewer.ViewModels" x:DataType="viewModels:LoginViewModel"> + + + + + + + + + + + + + - - - - - - - - - - - - - + + + + + + + + \ No newline at end of file diff --git a/GalaxyViewer/Views/MenuDesktopView.axaml b/GalaxyViewer/Views/MenuDesktopView.axaml index cacd6bc..680bb8a 100644 --- a/GalaxyViewer/Views/MenuDesktopView.axaml +++ b/GalaxyViewer/Views/MenuDesktopView.axaml @@ -3,56 +3,92 @@ xmlns:vm="clr-namespace:GalaxyViewer.ViewModels" x:Class="GalaxyViewer.Views.MenuDesktopView" x:DataType="vm:MainViewModel"> - - - + + + + + + + - - - - - + + + + + - - - - + + + - - - - - - - - - + - - - - - - + - - - - - - - + + + + + + + + + + + + + + + + - - - - - - - + + + + + + + + + - - + + + + + + + + + + + + + + - \ No newline at end of file + diff --git a/GalaxyViewer/Views/MenuDesktopView.axaml.cs b/GalaxyViewer/Views/MenuDesktopView.axaml.cs index 6a13b71..a41821a 100644 --- a/GalaxyViewer/Views/MenuDesktopView.axaml.cs +++ b/GalaxyViewer/Views/MenuDesktopView.axaml.cs @@ -1,6 +1,5 @@ using Avalonia.Controls; using Avalonia.Markup.Xaml; -using GalaxyViewer.ViewModels; namespace GalaxyViewer.Views; diff --git a/GalaxyViewer/Views/MfaPromptDialog.axaml b/GalaxyViewer/Views/MfaPromptDialog.axaml index a7f7e8f..e081b54 100644 --- a/GalaxyViewer/Views/MfaPromptDialog.axaml +++ b/GalaxyViewer/Views/MfaPromptDialog.axaml @@ -5,9 +5,19 @@ x:DataType="viewModels:MfaPromptDialogViewModel"> - - - + + +