diff --git a/GalaxyViewer.Android/GalaxyViewer.Android.csproj b/GalaxyViewer.Android/GalaxyViewer.Android.csproj index 02867a9..d731ae8 100644 --- a/GalaxyViewer.Android/GalaxyViewer.Android.csproj +++ b/GalaxyViewer.Android/GalaxyViewer.Android.csproj @@ -1,9 +1,9 @@ Exe - net9.0-android35.0 + net9.0-android36.0 true - 35 + 36 24.0 enable com.GalaxyViewer.GalaxyViewer @@ -15,8 +15,8 @@ - - + + diff --git a/GalaxyViewer.Desktop/GalaxyViewer.Desktop.csproj b/GalaxyViewer.Desktop/GalaxyViewer.Desktop.csproj index 98795d8..d0a0e23 100644 --- a/GalaxyViewer.Desktop/GalaxyViewer.Desktop.csproj +++ b/GalaxyViewer.Desktop/GalaxyViewer.Desktop.csproj @@ -13,9 +13,9 @@ - + - + diff --git a/GalaxyViewer/App.axaml.cs b/GalaxyViewer/App.axaml.cs index f023d90..01ee502 100644 --- a/GalaxyViewer/App.axaml.cs +++ b/GalaxyViewer/App.axaml.cs @@ -6,7 +6,6 @@ using System.Threading.Tasks; using Avalonia; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; -using Avalonia.Markup.Xaml.Styling; using Avalonia.Styling; using Avalonia.Platform; using Avalonia.Media; @@ -92,6 +91,10 @@ public class App : Application, IDisposable AvaloniaXamlLoader.Load(this); +#if DEBUG + this.AttachDeveloperTools(); +#endif + try { _platformSettings = PlatformSettings; @@ -152,10 +155,7 @@ public class App : Application, IDisposable if (PreferencesManager != null) { var preferences = await PreferencesManager.LoadPreferencesAsync(); - if (preferences != null) - { - ApplyPreferences(preferences); - } + ApplyPreferences(preferences); } } catch (Exception ex) @@ -265,7 +265,7 @@ public class App : Application, IDisposable } } - internal static IBrush GetSystemAccentBrush() + private static IBrush GetSystemAccentBrush() { try { @@ -299,11 +299,9 @@ public class App : Application, IDisposable Resources["TextColor"] = color; Resources["TextColorBrush"] = brush; - if (Current != null) - { - Current.Resources["TextColor"] = color; - Current.Resources["TextColorBrush"] = brush; - } + if (Current == null) return; + Current.Resources["TextColor"] = color; + Current.Resources["TextColorBrush"] = brush; } private ThemeVariant GetThemeVariant(string themePreference) @@ -375,22 +373,6 @@ public class App : Application, IDisposable } } - // 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) diff --git a/GalaxyViewer/GalaxyViewer.csproj b/GalaxyViewer/GalaxyViewer.csproj index 8a5f596..9a25689 100644 --- a/GalaxyViewer/GalaxyViewer.csproj +++ b/GalaxyViewer/GalaxyViewer.csproj @@ -1,19 +1,19 @@  - net9.0 + net9.0;net8.0;net9.0-android36.0 latest - 2025.07.22-test + 2025.12.13-test $(Version) true - Assets\GalaxyViewerLogo.ico + Assets/GalaxyViewerLogo.ico - + @@ -29,35 +29,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - + + + - - - + + AddressBar.axaml + Code + \ No newline at end of file diff --git a/GalaxyViewer/Models/ChatConversation.cs b/GalaxyViewer/Models/ChatConversation.cs index 835605e..878d33f 100644 --- a/GalaxyViewer/Models/ChatConversation.cs +++ b/GalaxyViewer/Models/ChatConversation.cs @@ -15,10 +15,11 @@ public class ChatConversation : INotifyPropertyChanged public UUID ParticipantId { get; init; } public UUID GroupId { get; set; } public string? GroupName { get; set; } - public UUID? SessionId { get; init; } + public UUID? SessionId { get; set; } public string? AvatarImage { get; set; } public ObservableCollection Messages { get; set; } = []; public ObservableCollection TypingUsers { get; set; } = []; + public ObservableCollection Participants { get; set; } = new(); private DateTime _lastActivity = DateTime.Now; private bool _hasUnreadMessages; diff --git a/GalaxyViewer/Models/ChatMessage.cs b/GalaxyViewer/Models/ChatMessage.cs index d08b44b..fb243a8 100644 --- a/GalaxyViewer/Models/ChatMessage.cs +++ b/GalaxyViewer/Models/ChatMessage.cs @@ -1,4 +1,5 @@ using System; +using Avalonia.Media.Imaging; using OpenMetaverse; namespace GalaxyViewer.Models; @@ -17,15 +18,18 @@ public class ChatMessage public string? GroupName { get; set; } public bool IsFromSelf { get; set; } public InstantMessageDialog? ImDialog { get; set; } - public bool IsSystemMessage { get; set; } + public Bitmap? AvatarImage { get; set; } public string MessageTag { get { - if (MessageType == ChatMessageType.System) - return "system-message"; - return IsFromSelf ? "from-self" : "from-other"; + return MessageType switch + { + ChatMessageType.System => "system-message", + ChatMessageType.Objects => "from-object", + _ => IsFromSelf ? "from-self" : "from-other" + }; } } } diff --git a/GalaxyViewer/Models/ChatParticipant.cs b/GalaxyViewer/Models/ChatParticipant.cs new file mode 100644 index 0000000..1089ebd --- /dev/null +++ b/GalaxyViewer/Models/ChatParticipant.cs @@ -0,0 +1,54 @@ +using System; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using Avalonia.Media.Imaging; +using OpenMetaverse; + +namespace GalaxyViewer.Models; + +public class ChatParticipant : INotifyPropertyChanged +{ + private string _name = string.Empty; + private Bitmap? _avatarImage; + private double _distance; + private UUID _agentId; + private string _displayName = string.Empty; + + public UUID AgentId + { + get => _agentId; + set { _agentId = value; OnPropertyChanged(); } + } + + public string Name + { + get => _name; + set { _name = value; OnPropertyChanged(); } + } + + public Bitmap? AvatarImage + { + get => _avatarImage; + set { _avatarImage = value; OnPropertyChanged(); } + } + + public double Distance + { + get => _distance; + set { _distance = value; OnPropertyChanged(); } + } + + public string DisplayName + { + get => _displayName; + set { _displayName = value; OnPropertyChanged(); } + } + + public bool IsOwner { get; set; } + public bool IsModerator { get; set; } + public bool IsOnline { get; set; } + + public event PropertyChangedEventHandler? PropertyChanged; + protected void OnPropertyChanged([CallerMemberName] string? propertyName = null) + => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); +} diff --git a/GalaxyViewer/Services/ChatService.cs b/GalaxyViewer/Services/ChatService.cs index ac55a86..1520b4f 100644 --- a/GalaxyViewer/Services/ChatService.cs +++ b/GalaxyViewer/Services/ChatService.cs @@ -2,11 +2,13 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Avalonia.Threading; using GalaxyViewer.Models; using OpenMetaverse; using Serilog; +using System.Collections.Concurrent; using ChatType = OpenMetaverse.ChatType; namespace GalaxyViewer.Services; @@ -15,8 +17,11 @@ public sealed class ChatService : IDisposable { private readonly GridClient _client; private readonly LiteDbService _dbService; + private readonly ProfileImageService _profileImageService; private bool _disposed; + public GridClient Client => _client; + private bool _hasShownConnectionMessage; @@ -27,17 +32,27 @@ public sealed class ChatService : IDisposable public event EventHandler? ConversationUpdated; public event EventHandler? ActiveConversationChanged; - private readonly Dictionary> _pendingGroupMessages = new(); + private readonly Dictionary> + _pendingGroupMessages = new(); + + private readonly ConcurrentDictionary _avatarNameCache = new(); public ChatService(GridClient client, LiteDbService dbService) { _client = client; _dbService = dbService; + _profileImageService = new ProfileImageService(client); InitializeLocalChat(); RegisterClientEvents(); _client.Network.EventQueueRunning += OnNetworkConnected; _client.Network.LoginProgress += OnLoginProgress; + + // Register for ChatterBoxSessionStartReply CAPS event to capture group chat session IDs + _client.Network.RegisterEventCallback("ChatterBoxSessionStartReply", + OnChatterBoxSessionStartReplyCaps); + // Register for avatar name replies + _client.Avatars.UUIDNameReply += OnUUIDNameReply; } private void InitializeLocalChat() @@ -93,7 +108,11 @@ public sealed class ChatService : IDisposable IsFromSelf = e.SourceID == _client.Self.AgentID }; - if (LocalChatConversation != null) AddMessageToConversation(LocalChatConversation, message); + if (LocalChatConversation != null) + { + AddMessageToConversation(LocalChatConversation, message); + LoadProfileImageForMessage(message); + } } private static bool ShouldFilterMessage(string message) @@ -116,7 +135,8 @@ public sealed class ChatService : IDisposable 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)) + if (eventArgs.IM.GroupIM || + _client.Groups.GroupName2KeyCache.ContainsKey(eventArgs.IM.IMSessionID)) { HandleGroupIm(eventArgs); return; @@ -166,11 +186,31 @@ public sealed class ChatService : IDisposable private void HandleGroupIm(InstantMessageEventArgs e) { - var groupId = e.IM.GroupIM ? e.IM.ToAgentID : e.IM.IMSessionID; + // For group IMs: + // - If GroupIM flag is true: ToAgentID is the group UUID, IMSessionID is the session ID + // - If GroupIM flag is false but it's in cache: IMSessionID is BOTH the group UUID AND session ID + UUID groupId; + UUID sessionId; + + if (e.IM.GroupIM) + { + // Standard group IM: ToAgentID = group, IMSessionID = session + groupId = e.IM.ToAgentID; + sessionId = e.IM.IMSessionID; + } + else + { + // For groups you're already in: IMSessionID serves as both group and session ID + groupId = e.IM.IMSessionID; + sessionId = e.IM.IMSessionID; // Use the same UUID for session ID + } + + Log.Debug( + "[ChatService] HandleGroupIm: GroupIM={GroupIM}, ToAgentID={ToAgentID}, IMSessionID={IMSessionID}, Derived GroupId={GroupId}, Derived SessionId={SessionId}", + e.IM.GroupIM, e.IM.ToAgentID, e.IM.IMSessionID, groupId, sessionId); if (!_client.Groups.GroupName2KeyCache.TryGetValue(groupId, out var groupName)) { - if (!_pendingGroupMessages.ContainsKey(groupId)) { _pendingGroupMessages[groupId] = new Queue<(InstantMessageEventArgs, DateTime)>(); @@ -197,7 +237,7 @@ public sealed class ChatService : IDisposable return; } - var conversation = GetOrCreateGroupConversation(groupId, groupName); + var conversation = GetOrCreateGroupConversation(groupId, groupName, sessionId); AddMessageToGroupConversation(conversation, e); } @@ -206,7 +246,24 @@ public sealed class ChatService : IDisposable if (!_pendingGroupMessages.TryGetValue(groupId, out var messageQueue)) return; - var conversation = GetOrCreateGroupConversation(groupId, groupName); + // Get sessionId from the first message in the queue + UUID sessionId = UUID.Zero; + if (messageQueue.Count > 0) + { + var firstMessage = messageQueue.Peek().Item1; + if (firstMessage.IM.GroupIM) + { + // Standard group IM: IMSessionID is the session ID + sessionId = firstMessage.IM.IMSessionID; + } + else + { + // For groups you're already in: IMSessionID serves as both group and session ID + sessionId = firstMessage.IM.IMSessionID; + } + } + + var conversation = GetOrCreateGroupConversation(groupId, groupName, sessionId); while (messageQueue.Count > 0) { @@ -217,24 +274,34 @@ public sealed class ChatService : IDisposable _pendingGroupMessages.Remove(groupId); } - private ChatConversation GetOrCreateGroupConversation(UUID groupId, string groupName) + private ChatConversation GetOrCreateGroupConversation(UUID groupId, string groupName, UUID sessionId = default) { var conversation = Conversations.FirstOrDefault(c => c.MessageType == ChatMessageType.GroupChat && c.GroupId == groupId); - if (conversation != null) return conversation; + if (conversation != null) + { + // Update SessionId if it wasn't set before and we have a valid one now + if ((conversation.SessionId == null || conversation.SessionId == UUID.Zero) && + sessionId != UUID.Zero) + { + conversation.SessionId = sessionId; + Log.Information("[ChatService] Updated SessionId={SessionId} for GroupId={GroupId}", + sessionId, groupId); + } + return conversation; + } + conversation = new ChatConversation { MessageType = ChatMessageType.GroupChat, GroupId = groupId, GroupName = groupName, - Name = groupName + Name = groupName, + SessionId = sessionId != UUID.Zero ? sessionId : null }; - Dispatcher.UIThread.Post(() => - { - Conversations.Add(conversation); - }); + Dispatcher.UIThread.Post(() => { Conversations.Add(conversation); }); return conversation; } @@ -253,6 +320,8 @@ public sealed class ChatService : IDisposable IsFromSelf = e.IM.FromAgentID == _client.Self.AgentID }; + LoadProfileImageForMessage(message); + AddMessageToConversation(conversation, message); } @@ -273,6 +342,7 @@ public sealed class ChatService : IDisposable IsFromSelf = false }; + LoadProfileImageForMessage(message); AddMessageToConversation(conversation, message); } @@ -315,7 +385,8 @@ public sealed class ChatService : IDisposable }); } - private void AddMessageToGroupConversation(ChatConversation conversation, InstantMessageEventArgs e) + private void AddMessageToGroupConversation(ChatConversation conversation, + InstantMessageEventArgs e) { var m = new ChatMessage { @@ -332,6 +403,7 @@ public sealed class ChatService : IDisposable Dispatcher.UIThread.Post(() => { + // Add message to conversation conversation.Messages.Add(m); conversation.LastMessage = m.Message; conversation.LastActivity = m.Timestamp; @@ -342,6 +414,7 @@ public sealed class ChatService : IDisposable conversation.UnreadCount++; } + MessageReceived?.Invoke(this, m); }); } @@ -349,7 +422,8 @@ public sealed class ChatService : IDisposable private void HandleTypingIndicator(InstantMessageEventArgs eventArgs, bool isTyping) { var conversation = Conversations.FirstOrDefault(c => - c.MessageType == ChatMessageType.InstantMessage && c.ParticipantId == eventArgs.IM.FromAgentID); + c.MessageType == ChatMessageType.InstantMessage && + c.ParticipantId == eventArgs.IM.FromAgentID); if (conversation == null) return; Dispatcher.UIThread.Post(() => @@ -392,6 +466,7 @@ public sealed class ChatService : IDisposable ImDialog = e.IM.Dialog, IsFromSelf = false }; + LoadProfileImageForMessage(message); AddMessageToConversation(conversation, message); } @@ -407,6 +482,7 @@ public sealed class ChatService : IDisposable ImDialog = e.IM.Dialog, IsFromSelf = false }; + LoadProfileImageForMessage(message); AddMessageToConversation(conversation, message); } @@ -524,15 +600,27 @@ public sealed class ChatService : IDisposable public async Task SendLocalChatAsync(string message, ChatType chatType = ChatType.Normal) { - await Task.Run(() => - { - _client.Self.Chat(message, 0, chatType); - }); + 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); }); + + // Add your own message to the conversation + var conversation = GetOrCreateImConversation(targetId, _client.Self.Name); + var chatMessage = new ChatMessage + { + SenderName = _client.Self.Name, + SenderUuid = _client.Self.AgentID, + Message = message, + MessageType = ChatMessageType.InstantMessage, + ImDialog = InstantMessageDialog.MessageFromAgent, + IsFromSelf = true, + Timestamp = DateTime.Now + }; + LoadProfileImageForMessage(chatMessage); + AddMessageToConversation(conversation, chatMessage); } public async Task SendGroupMessageAsync(UUID sessionId, string message) @@ -553,6 +641,18 @@ public sealed class ChatService : IDisposable }); } + public void RequestGroupMembers(UUID groupId) + { + if (groupId == UUID.Zero) + { + Log.Warning("[ChatService] Cannot request group members with UUID.Zero"); + return; + } + + Log.Debug("[ChatService] Requesting group members for GroupId={GroupId}", groupId); + _client.Groups.RequestGroupMembers(groupId); + } + public void MarkConversationAsRead(ChatConversation conversation) { conversation.HasUnreadMessages = false; @@ -590,6 +690,8 @@ public sealed class ChatService : IDisposable Timestamp = DateTime.Now }; + LoadProfileImageForMessage(message); + if (LocalChatConversation != null) AddMessageToConversation(LocalChatConversation, message); } @@ -611,6 +713,7 @@ public sealed class ChatService : IDisposable Timestamp = DateTime.Now }; + LoadProfileImageForMessage(welcomeMessage); if (LocalChatConversation != null) AddMessageToConversation(LocalChatConversation, welcomeMessage); } @@ -624,20 +727,16 @@ public sealed class ChatService : IDisposable Timestamp = DateTime.Now }; + LoadProfileImageForMessage(loginMessage); 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: @@ -645,6 +744,171 @@ public sealed class ChatService : IDisposable } } + private void OnChatterBoxSessionStartReplyCaps(string capsKey, + OpenMetaverse.Interfaces.IMessage message, Simulator simulator) + { + if (message is not OpenMetaverse.Messages.Linden.ChatterBoxSessionStartReplyMessage + sessionStart) return; + var sessionId = sessionStart.SessionID; + var tempSessionId = sessionStart.TempSessionID; + var sessionName = sessionStart.SessionName; + var type = sessionStart.Type; + var voiceEnabled = sessionStart.VoiceEnabled; + var moderatedVoice = sessionStart.ModeratedVoice; + var success = sessionStart.Success; + + Log.Information( + "[ChatService] ChatterBoxSessionStartReply: SessionId={SessionId}, TempSessionId={TempSessionId}, SessionName={SessionName}, Type={Type}, VoiceEnabled={VoiceEnabled}, ModeratedVoice={ModeratedVoice}, Success={Success}", + sessionId, tempSessionId, sessionName, type, voiceEnabled, moderatedVoice, success); + + var conversation = Conversations.FirstOrDefault(c => + c.MessageType == ChatMessageType.GroupChat && + (c.GroupName == sessionName || c.Name == sessionName)); + + if (conversation == null) + { + // Try to find by GroupId if we can determine it + UUID groupId = UUID.Zero; + if (_client.Groups.GroupName2KeyCache.ContainsValue(sessionName)) + { + groupId = _client.Groups.GroupName2KeyCache.FirstOrDefault(x => x.Value == sessionName).Key; + } + + if (groupId != UUID.Zero) + { + conversation = Conversations.FirstOrDefault(c => + c.MessageType == ChatMessageType.GroupChat && c.GroupId == groupId); + } + + if (conversation == null) + { + conversation = new ChatConversation + { + MessageType = ChatMessageType.GroupChat, + GroupId = groupId, + GroupName = sessionName, + Name = sessionName, + SessionId = sessionId + }; + Dispatcher.UIThread.Post(() => { Conversations.Add(conversation); }); + Log.Information("[ChatService] Created new conversation for session {SessionName} with SessionId={SessionId}, GroupId={GroupId}", + sessionName, sessionId, groupId); + } + } + + // Directly update the existing conversation object instead of replacing it + conversation.SessionId = sessionId; + + // Try to get the groupId from the group name if not already set + if (conversation.GroupId == UUID.Zero && _client.Groups.GroupName2KeyCache.ContainsValue(sessionName)) + { + var groupId = _client.Groups.GroupName2KeyCache.FirstOrDefault(x => x.Value == sessionName).Key; + if (groupId != UUID.Zero) + { + conversation.GroupId = groupId; + } + } + + Log.Information("[ChatService] Updated SessionId={SessionId} for conversation {ConversationName}, GroupId={GroupId}", + sessionId, conversation.Name, conversation.GroupId); + + Dispatcher.UIThread.Post(() => + { + ConversationUpdated?.Invoke(this, conversation); + }); + } + + private void OnUUIDNameReply(object? sender, UUIDNameReplyEventArgs e) + { + foreach (var kvp in e.Names) + { + _avatarNameCache[kvp.Key] = kvp.Value; + // Update all conversations that have this participant + foreach (var conversation in Conversations.ToList()) + { + var participant = + conversation.Participants.FirstOrDefault(p => p.AgentId == kvp.Key); + if (participant != null) + { + Dispatcher.UIThread.Post(() => + { + participant.Name = kvp.Value; + ConversationUpdated?.Invoke(this, conversation); + }); + } + } + } + } + + private void LoadProfileImageForMessage(ChatMessage message) + { + if (message.SenderUuid == UUID.Zero || message.MessageType == ChatMessageType.System) + return; + + // Load profile image asynchronously + Task.Run(async () => + { + try + { + await LoadProfileImageAsync(message); + } + catch (Exception ex) + { + Log.Warning(ex, "[ChatService] Failed to load profile image for {SenderUuid}", message.SenderUuid); + } + }); + } + + private async Task LoadProfileImageAsync(ChatMessage message) + { + var senderUuid = message.SenderUuid; + var tcs = new TaskCompletionSource(); + EventHandler? handler = null; + + handler = (sender, e) => + { + if (e.AvatarID != senderUuid) return; + _client.Avatars.AvatarPropertiesReply -= handler; + tcs.TrySetResult(e); + }; + + _client.Avatars.AvatarPropertiesReply += handler; + _client.Avatars.RequestAvatarProperties(senderUuid); + + try + { + // Wait for the avatar properties with a timeout + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var avatarProps = await tcs.Task.WaitAsync(cts.Token); + + if (avatarProps.Properties.ProfileImage != UUID.Zero) + { + var bitmap = await _profileImageService.GetProfileImageAsync(avatarProps.Properties.ProfileImage, cts.Token); + if (bitmap != null) + { + await Dispatcher.UIThread.InvokeAsync(() => + { + message.AvatarImage = bitmap; + // Trigger UI refresh by finding the conversation and notifying update + var conversation = Conversations.FirstOrDefault(c => c.Messages.Contains(message)); + if (conversation != null) + { + ConversationUpdated?.Invoke(this, conversation); + } + }); + } + } + } + catch (OperationCanceledException) + { + Log.Warning("[ChatService] Profile image request timed out for {SenderUuid}", senderUuid); + } + finally + { + _client.Avatars.AvatarPropertiesReply -= handler; + } + } + #region Disposable Support public void Dispose() @@ -661,6 +925,8 @@ public sealed class ChatService : IDisposable UnregisterClientEvents(); _pendingGroupMessages.Clear(); _hasShownConnectionMessage = false; + // Unsubscribe from avatar name reply + _client.Avatars.UUIDNameReply -= OnUUIDNameReply; } _disposed = true; diff --git a/GalaxyViewer/Services/LiteDbService.cs b/GalaxyViewer/Services/LiteDbService.cs index 3f05b7d..19878b9 100644 --- a/GalaxyViewer/Services/LiteDbService.cs +++ b/GalaxyViewer/Services/LiteDbService.cs @@ -1,15 +1,13 @@ 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; +using System.Threading; +using System.Threading.Tasks; namespace GalaxyViewer.Services; @@ -17,7 +15,7 @@ public class LiteDbService : IDisposable, INotifyPropertyChanged { private LiteDatabase? _database; private readonly string _databasePath; - public LiteDatabase? Database => _database; + public LiteDatabase? Database { get { lock (_dbLock) { return _database; } } } private readonly GridClient _client; public LiteDbService(GridClient client) @@ -33,7 +31,7 @@ public class LiteDbService : IDisposable, INotifyPropertyChanged public SessionModel Session { get => _session; - private set + set { _session = value; OnPropertyChanged(); @@ -50,14 +48,18 @@ public class LiteDbService : IDisposable, INotifyPropertyChanged return Path.Combine(appDataPath, "data.db"); } + private readonly object _dbLock = new(); + private void InitializeDatabase() { try { Directory.CreateDirectory(Path.GetDirectoryName(_databasePath) ?? throw new InvalidOperationException()); - - _database = new LiteDatabase(_databasePath); + lock (_dbLock) + { + _database = new LiteDatabase(_databasePath); + } Log.Information("LiteDbService initialized with database path: {DbPath}", _databasePath); var gridsCollection = _database.GetCollection("grids"); @@ -85,64 +87,73 @@ public class LiteDbService : IDisposable, INotifyPropertyChanged private void SeedPreferences() { - var preferencesCollection = _database?.GetCollection("preferences"); - if (preferencesCollection != null && preferencesCollection.Count() != 0) return; - var defaultPreferences = PreferencesManager.CreateDefaultPreferences(); - preferencesCollection?.Insert(defaultPreferences); + lock (_dbLock) + { + 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 != null && gridsCollection.Count() != 0) return; - var grids = new[] + lock (_dbLock) { - new GridModel + var gridsCollection = _database?.GetCollection("grids"); + if (gridsCollection != null && gridsCollection.Count() != 0) return; + var grids = new[] { - 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); + 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.Debug("Database seeded with default grids"); } private void ClearSessionData() { - var sessionCollection = _database?.GetCollection("session"); - if (sessionCollection != null && sessionCollection.Count() > 0) + lock (_dbLock) { - sessionCollection.DeleteAll(); - // Log.Debug("Session data cleared"); - } + var sessionCollection = _database?.GetCollection("session"); + if (sessionCollection != null && sessionCollection.Count() > 0) + { + sessionCollection.DeleteAll(); + // Log.Debug("Session data cleared"); + } - if (sessionCollection != null && sessionCollection.Count() != 0) return; - sessionCollection?.Insert(new SessionModel()); - // Log.Debug("Session data created"); + if (sessionCollection != null && sessionCollection.Count() != 0) return; + sessionCollection?.Insert(new SessionModel()); + // Log.Debug("Session data created"); + } } private void OnPropertyChanged([CallerMemberName] string? propertyName = null) @@ -154,28 +165,128 @@ public class LiteDbService : IDisposable, INotifyPropertyChanged public SessionModel GetSession() { - var collection = _database?.GetCollection("session"); - return collection?.FindOne(Query.All()) ?? new SessionModel(); + lock (_dbLock) + { + var collection = _database?.GetCollection("session"); + return collection?.FindOne(Query.All()) ?? new SessionModel(); + } } - public bool HasSessionChanged(SessionModel currentSession) + public async Task ExecuteDbAsync(Func func, CancellationToken cancellationToken = default) { - var storedSession = GetSession(); - return !storedSession.Equals(currentSession); + return await Task.Run(() => + { + lock (_dbLock) + { + cancellationToken.ThrowIfCancellationRequested(); + return _database != null ? func(_database) : default; + } + }, cancellationToken); } - public void SaveSession(SessionModel session) + public async Task GetSessionAsync(CancellationToken cancellationToken = default) { - var collection = _database?.GetCollection("session"); - collection?.Upsert(session); - // Log.Debug("Session data saved"); + return await ExecuteDbAsync(db => + { + var collection = db.GetCollection("session"); + return collection.FindOne(Query.All()) ?? new SessionModel(); + }, cancellationToken) ?? new SessionModel(); + } + public async Task SaveSessionAsync(SessionModel session, CancellationToken cancellationToken = default) + { + await ExecuteDbAsync(db => + { + var collection = db.GetCollection("session"); + collection.Upsert(session); + return true; + }, cancellationToken); Session = session; SessionChanged?.Invoke(this, EventArgs.Empty); } + // TODO: Finish implementing this cache system + public async Task AgentCacheSetAsync(string agentUuid, string key, T value, CancellationToken cancellationToken = default) + { + await ExecuteDbAsync(db => + { + var collection = db.GetCollection>(agentUuid); + var entry = new CacheEntry { Key = key, Value = value, Timestamp = DateTime.UtcNow }; + collection.Upsert(entry); + return true; + }, cancellationToken); + } + + public async Task AgentCacheGetAsync(string agentUuid, string key, CancellationToken cancellationToken = default) + { + return await ExecuteDbAsync(db => + { + var collection = db.GetCollection>(agentUuid); + var entry = collection.FindOne(x => x.Key == key); + return entry != null ? entry.Value : default; + }, cancellationToken); + } + + public async Task GridCacheSetAsync(string gridName, string key, T value, CancellationToken cancellationToken = default) + { + await ExecuteDbAsync(db => + { + var collection = db.GetCollection>("gridcache_" + gridName); + var entry = new CacheEntry { Key = key, Value = value, Timestamp = DateTime.UtcNow }; + collection.Upsert(entry); + return true; + }, cancellationToken); + } + + public async Task GridCacheGetAsync(string gridName, string key, CancellationToken cancellationToken = default) + { + return await ExecuteDbAsync(db => + { + var collection = db.GetCollection>("gridcache_" + gridName); + var entry = collection.FindOne(x => x.Key == key); + return entry != null ? entry.Value : default; + }, cancellationToken); + } + + public class CacheEntry + { + [BsonId] + public string Key { get; set; } = string.Empty; + public T? Value { get; set; } + public DateTime Timestamp { get; set; } + } + + public class AvatarNameCacheEntry + { + [BsonId] + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + } + void IDisposable.Dispose() { - _database?.Dispose(); + lock (_dbLock) + { + _database?.Dispose(); + } + } + + public string? GetAvatarName(Guid id) + { + lock (_dbLock) + { + var col = _database!.GetCollection("avatar_name_cache"); + var entry = col.FindById(id); + return entry?.Name; + } + } + + public void SetAvatarName(Guid id, string name) + { + lock (_dbLock) + { + var col = _database!.GetCollection("avatar_name_cache"); + col.Upsert(new AvatarNameCacheEntry { Id = id, Name = name }); + } } } \ No newline at end of file diff --git a/GalaxyViewer/Services/ProfileImageService.cs b/GalaxyViewer/Services/ProfileImageService.cs new file mode 100644 index 0000000..b49cd45 --- /dev/null +++ b/GalaxyViewer/Services/ProfileImageService.cs @@ -0,0 +1,152 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Media.Imaging; +using OpenMetaverse; +using OpenMetaverse.Assets; +using Serilog; +using SkiaSharp; + +namespace GalaxyViewer.Services +{ + public class ProfileImageService + { + private readonly GridClient _client; + private readonly ILogger _log; + private const int RequestTimeoutMs = 10000; + + public ProfileImageService(GridClient client, ILogger? log = null) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + _log = log ?? Log.Logger; + } + + /// + /// Requests a profile image asset and returns an Avalonia Bitmap or null on failure. + /// TODO: Future improvements: + /// - Implement image caching to avoid redundant asset server requests, and cache invalidation + /// - Add image scaling/resizing for efficient UI display + /// + public async Task GetProfileImageAsync(UUID imageId, CancellationToken ct = default) + { + if (imageId == UUID.Zero) + return null; + + try + { + var asset = await RequestImageAssetAsync(imageId, ct).ConfigureAwait(false); + if (asset == null) + return null; + + var managedImage = DecodeTextureAsset(asset, imageId); + return managedImage == null ? null : ConvertManagedImageToAvaloniaBitmap(managedImage, imageId); + } + catch (OperationCanceledException) + { + _log.Warning("[ProfileImageService] Request timed out for image {ImageId}", imageId); + return null; + } + catch (Exception ex) + { + _log.Warning(ex, "[ProfileImageService] Failed to load image {ImageId}", imageId); + return null; + } + } + + /// + /// Requests an image asset from the asset server asynchronously. + /// + private async Task RequestImageAssetAsync(UUID imageId, CancellationToken ct) + { + var tcs = new TaskCompletionSource<(TextureRequestState state, Asset? asset)>( + TaskCreationOptions.RunContinuationsAsynchronously); + + _client.Assets.RequestImage(imageId, (state, asset) => + { + tcs.TrySetResult((state, asset)); + }); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(RequestTimeoutMs); + + var (state, asset) = await tcs.Task.WaitAsync(cts.Token).ConfigureAwait(false); + + if (state != TextureRequestState.Finished || asset is not AssetTexture texAsset) + { + _log.Debug("[ProfileImageService] Asset request failed: state={State}", state); + return null; + } + + return texAsset; + } + + /// + /// Decodes a texture asset into a ManagedImage, validating dimensions. + /// + private OpenMetaverse.Imaging.ManagedImage? DecodeTextureAsset(AssetTexture asset, UUID imageId) + { + if (!asset.Decode()) + { + _log.Debug("[ProfileImageService] Failed to decode texture {ImageId}", imageId); + return null; + } + + var image = asset.Image; + if (image == null) + { + _log.Debug("[ProfileImageService] Asset decode succeeded but image is null {ImageId}", imageId); + return null; + } + + if (image.Width <= 0 || image.Height <= 0) + { + _log.Debug("[ProfileImageService] Invalid image dimensions: {Width}x{Height} for {ImageId}", + image.Width, image.Height, imageId); + return null; + } + + return image; + } + + /// + /// Converts a ManagedImage to an Avalonia Bitmap via SkiaSharp. + /// + private Bitmap? ConvertManagedImageToAvaloniaBitmap( + OpenMetaverse.Imaging.ManagedImage managedImage, UUID imageId) + { + SKBitmap? skBitmap = null; + try + { + skBitmap = managedImage.ExportBitmap(); + if (skBitmap == null) + { + _log.Debug("[ProfileImageService] ExportBitmap returned null for {ImageId}", imageId); + return null; + } + + using var skImage = SKImage.FromBitmap(skBitmap); + if (skImage == null) + { + _log.Debug("[ProfileImageService] SKImage creation failed for {ImageId}", imageId); + return null; + } + + using var encodedData = skImage.Encode(SKEncodedImageFormat.Png, 90); + if (encodedData == null) + { + _log.Debug("[ProfileImageService] Image encoding failed for {ImageId}", imageId); + return null; + } + + var imageBytes = encodedData.ToArray(); + using var memoryStream = new MemoryStream(imageBytes); + return new Bitmap(memoryStream); + } + finally + { + skBitmap?.Dispose(); + } + } + } +} diff --git a/GalaxyViewer/ViewModels/ChatParticipantsViewModel.cs b/GalaxyViewer/ViewModels/ChatParticipantsViewModel.cs new file mode 100644 index 0000000..8db71f6 --- /dev/null +++ b/GalaxyViewer/ViewModels/ChatParticipantsViewModel.cs @@ -0,0 +1,502 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using GalaxyViewer.Models; +using GalaxyViewer.Services; +using OpenMetaverse; +using OpenMetaverse.Assets; +using Serilog; +using SkiaSharp; +using Timer = System.Timers.Timer; + +namespace GalaxyViewer.ViewModels; + +public class ChatParticipantsViewModel : ViewModelBase +{ + private readonly GridClient _client; + private readonly ChatService _chatService; + private readonly ProfileImageService _profileImageService; + public ObservableCollection Participants { get; } = []; + private readonly object _lock = new(); + private Timer? _refreshTimer; + private const double RefreshIntervalMs = 5000; // 5 seconds + private readonly ChatConversation _conversation; + private bool _isDisposed; + private bool _eventsSubscribed; + + // Property to control distance visibility - only show for local chat + public bool ShowDistance => _conversation.MessageType == ChatMessageType.LocalChat; + + public ChatParticipantsViewModel(ChatService chatService, ChatConversation conversation, ProfileImageService profileImageService) + { + Log.Debug("[ChatParticipants] Constructor called"); + _chatService = chatService; + _client = chatService.Client; + _conversation = conversation; + _profileImageService = profileImageService; + _client.Objects.AvatarUpdate += OnAvatarUpdate; + _client.Objects.KillObject += OnKillObject; + + // For group chats, ensure we're joined to the session (like Radegast does) + if (conversation.MessageType == ChatMessageType.GroupChat && + conversation.SessionId != null && + conversation.SessionId != UUID.Zero) + { + if (!_client.Self.GroupChatSessions.ContainsKey(conversation.SessionId.Value)) + { + Log.Information("[ChatParticipants] Joining group chat session {SessionId}", conversation.SessionId); + _client.Self.RequestJoinGroupChat(conversation.SessionId.Value); + } + else + { + // Immediately populate from existing session data + UpdateParticipantListFromSession(); + } + } + + RefreshParticipants(); + } + + public void RefreshParticipants() + { + // Check if disposed first + if (_isDisposed) + { + Log.Debug("[ChatParticipants] RefreshParticipants called after disposal, ignoring"); + return; + } + + Log.Debug( + "[ChatParticipants] RefreshParticipants called. Conversation type: {ConversationMessageType}", + _conversation.MessageType); + + switch (_conversation.MessageType) + { + case ChatMessageType.GroupChat: + { + if (_conversation.GroupId == UUID.Zero) + { + Log.Warning( + "[ChatParticipants] GroupChat: GroupId is UUID.Zero, cannot show participants."); + return; + } + + Log.Information( + "[ChatParticipants] Group chat participants will be populated via ChatterBoxSessionAgentListUpdates CAPS messages. GroupId: {ConversationGroupId}, SessionId: {SessionId}", + _conversation.GroupId, _conversation.SessionId); + break; + } + case ChatMessageType.InstantMessage: + { + lock (_lock) + { + var needsUpdate = Participants.Count != 2 || + Participants.All(p => + p.AgentId != _client.Self.AgentID) || + Participants.All(p => + p.AgentId != _conversation.ParticipantId); + + if (!needsUpdate) + break; + + Log.Information( + "[ChatParticipants] IM participants: self={SelfAgentId}, other={ConversationParticipantId}", + _client.Self.AgentID, _conversation.ParticipantId); + + Participants.Clear(); + + // Add self + Participants.Add(new ChatParticipant + { + AgentId = _client.Self.AgentID, + Name = _client.Self.Name, + DisplayName = _client.Self.Name, + Distance = 0.0 // Distance not applicable for IM + }); + + // Add the other participant if valid and not self + if (_conversation.ParticipantId != UUID.Zero && + _conversation.ParticipantId != _client.Self.AgentID) + { + Participants.Add(new ChatParticipant + { + AgentId = _conversation.ParticipantId, + Name = "...", + DisplayName = "...", + Distance = 0.0 // Distance not applicable for IM + }); + + _client.Avatars.RequestAvatarName(_conversation.ParticipantId); + + if (_client.Avatars.DisplayNamesAvailable()) + { + _client.Avatars.GetDisplayNames([_conversation.ParticipantId], + (success, names, _) => + { + if (!success || names == null) return; + lock (_lock) + { + foreach (var agent in names) + { + var person = + Participants.FirstOrDefault(p => + p.AgentId == agent.ID); + if (person != null) + person.DisplayName = agent.DisplayName; + } + } + }); + } + } + } + + break; + } + case ChatMessageType.LocalChat: + // Local chat: show all nearby avatars except self + var sim = _client.Network.CurrentSim; + if (sim == null) return; + var selfId = _client.Self.AgentID; + var avatars = sim.ObjectsAvatars.Values.Where(a => a.ID != selfId).ToList(); + var avatarIds = avatars.Select(a => a.ID).ToList(); + lock (_lock) + { + // Remove avatars no longer present + for (var i = Participants.Count - 1; i >= 0; i--) + { + if (avatarIds.All(id => id != Participants[i].AgentId)) + Participants.RemoveAt(i); + } + + // Add or update avatars + foreach (var avatar in avatars) + { + var person = Participants.FirstOrDefault(p => avatar.ID == p.AgentId); + if (person == null) + { + person = new ChatParticipant + { + AgentId = avatar.ID, + Distance = (avatar.Position - sim.Client.Self.SimPosition) + .Length() + }; + Participants.Add(person); + } + else + { + person.Distance = + (avatar.Position - sim.Client.Self.SimPosition).Length(); + } + } + } + + // Request legacy names + _client.Avatars.RequestAvatarNames(avatarIds); + // Request display names + if (_client.Avatars.DisplayNamesAvailable()) + { + _client.Avatars.GetDisplayNames(avatarIds, (success, names, _) => + { + if (!success || names == null) return; + lock (_lock) + { + foreach (var agent in names) + { + var person = + Participants.FirstOrDefault(p => p.AgentId == agent.ID); + if (person != null) person.DisplayName = agent.DisplayName; + } + } + }); + } + + break; + case ChatMessageType.System: + case ChatMessageType.Objects: + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + public void SubscribeEvents() + { + if (_eventsSubscribed) + { + Log.Warning("[ChatParticipants] SubscribeEvents called but already subscribed, ignoring"); + return; + } + + Log.Information("[ChatParticipants] SubscribeEvents called"); + _client.Avatars.UUIDNameReply += Avatars_UUIDNameReply; + _client.Network.SimChanged += Network_SimChanged; + _client.Objects.ObjectUpdate += Objects_ObjectUpdate; + + // Use Radegast's approach: subscribe to GroupChat events instead of CAPS + _client.Self.GroupChatJoined += Self_GroupChatJoined; + _client.Self.ChatSessionMemberAdded += Self_ChatSessionMemberAdded; + _client.Self.ChatSessionMemberLeft += Self_ChatSessionMemberLeft; + + // Subscribe to avatar properties for profile images + _client.Avatars.AvatarPropertiesReply += Avatars_AvatarPropertiesReply; + + // Start periodic refresh timer + _refreshTimer = new Timer(RefreshIntervalMs); + _refreshTimer.Elapsed += (_, _) => RefreshParticipants(); + _refreshTimer.AutoReset = true; + _refreshTimer.Start(); + + _eventsSubscribed = true; + } + + public void UnsubscribeEvents() + { + _isDisposed = true; + + _client.Avatars.UUIDNameReply -= Avatars_UUIDNameReply; + _client.Network.SimChanged -= Network_SimChanged; + _client.Objects.ObjectUpdate -= Objects_ObjectUpdate; + + // Unsubscribe from GroupChat events + _client.Self.GroupChatJoined -= Self_GroupChatJoined; + _client.Self.ChatSessionMemberAdded -= Self_ChatSessionMemberAdded; + _client.Self.ChatSessionMemberLeft -= Self_ChatSessionMemberLeft; + + // Unsubscribe from avatar properties + _client.Avatars.AvatarPropertiesReply -= Avatars_AvatarPropertiesReply; + // Stop and dispose timer + if (_refreshTimer == null) return; + _refreshTimer.Stop(); + _refreshTimer.Dispose(); + _refreshTimer = null; + } + + private void Network_SimChanged(object? sender, SimChangedEventArgs e) + { + RefreshParticipants(); + } + + private void Objects_ObjectUpdate(object? sender, PrimEventArgs e) + { + if (e.Prim is Avatar) + RefreshParticipants(); + } + + private void Avatars_UUIDNameReply(object? sender, UUIDNameReplyEventArgs e) + { + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + lock (_lock) + { + foreach (var kvp in e.Names) + { + var person = Participants.FirstOrDefault(p => kvp.Key == p.AgentId); + if (person != null) + person.Name = kvp.Value; + } + } + }); + } + + private void OnAvatarUpdate(object? sender, AvatarUpdateEventArgs e) + { + // Only track distance for local chat + if (_conversation.MessageType != ChatMessageType.LocalChat) + return; + + if (e.Avatar.ID == _client.Self.AgentID) return; // skip self + var sim = _client.Network.CurrentSim; + if (sim == null) return; + var avatar = e.Avatar; + var distance = (avatar.Position - sim.Client.Self.SimPosition).Length(); + + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + lock (_lock) + { + var person = Participants.FirstOrDefault(p => p.AgentId == avatar.ID); + if (person == null) + { + person = new ChatParticipant { AgentId = avatar.ID, Distance = distance }; + Participants.Add(person); + } + else + { + person.Distance = distance; + } + } + }); + } + + private void OnKillObject(object? sender, KillObjectEventArgs e) + { + var sim = _client.Network.CurrentSim; + if (sim == null) return; + // Try to get the avatar by local ID + if (!sim.ObjectsAvatars.TryGetValue(e.ObjectLocalID, out var avatar)) return; + var avatarId = avatar.ID; + + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + lock (_lock) + { + for (var i = Participants.Count - 1; i >= 0; i--) + { + if (Participants[i].AgentId == avatarId) + { + Participants.RemoveAt(i); + } + } + } + }); + } + + + // Radegast's approach: Use GroupChatSessions instead of CAPS messages + private void Self_GroupChatJoined(object sender, GroupChatJoinedEventArgs e) + { + if (_conversation.MessageType != ChatMessageType.GroupChat || + _conversation.SessionId != e.SessionID) return; + Log.Information("[ChatParticipants] Successfully joined group chat session {SessionId}", e.SessionID); + UpdateParticipantListFromSession(); + } + + private void Self_ChatSessionMemberAdded(object sender, ChatSessionMemberAddedEventArgs e) + { + if (_conversation.MessageType != ChatMessageType.GroupChat || + _conversation.SessionId != e.SessionID) return; + Log.Information("[ChatParticipants] Member {AgentId} added to session {SessionId}", e.AgentID, e.SessionID); + UpdateParticipantListFromSession(); + } + + private void Self_ChatSessionMemberLeft(object sender, ChatSessionMemberLeftEventArgs e) + { + if (_conversation.MessageType != ChatMessageType.GroupChat || + _conversation.SessionId != e.SessionID) return; + Log.Information("[ChatParticipants] Member {AgentId} left session {SessionId}", e.AgentID, e.SessionID); + UpdateParticipantListFromSession(); + } + + private void Avatars_AvatarPropertiesReply(object sender, AvatarPropertiesReplyEventArgs e) + { + if (e.Properties.ProfileImage != UUID.Zero) + { + LoadProfileImage(e.AvatarID, e.Properties.ProfileImage); + } + } + + private void LoadProfileImage(UUID avatarId, UUID imageId) + { + // TODO: Profile image loading is an incomplete placeholder. Need to: + // - Fix the code so it actually works + // - Implement caching to avoid re-requesting the same image, along with cache expiration + // - Add image scaling/resizing for display efficiency + + // Run off the UI thread and send the result back to the UI + _ = Task.Run(async () => + { + try + { + var bitmap = await _profileImageService.GetProfileImageAsync(imageId).ConfigureAwait(false); + if (bitmap == null) + { + Log.Debug("[ChatParticipants] No profile image available for {AgentId}", avatarId); + return; + } + + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + lock (_lock) + { + var participant = Participants.FirstOrDefault(p => p.AgentId == avatarId); + if (participant == null) return; + participant.AvatarImage = bitmap; + Log.Debug("[ChatParticipants] Loaded profile image for {AgentId}", avatarId); + } + }); + } + catch (Exception ex) + { + Log.Warning(ex, "[ChatParticipants] Failed to load profile image for {AgentId}", avatarId); + } + }); + } + + private void UpdateParticipantListFromSession() + { + if (_conversation.SessionId == null || _conversation.SessionId == UUID.Zero) + { + Log.Warning("[ChatParticipants] Cannot update participant list - SessionId is null or zero"); + return; + } + + if (_client.Self.GroupChatSessions.TryGetValue(_conversation.SessionId.Value, out var participants)) + { + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + lock (_lock) + { + Participants.Clear(); + var agentIds = new List(); + + foreach (var participant in participants) + { + var chatParticipant = new ChatParticipant + { + AgentId = participant.AvatarKey, + Name = string.Empty, // Will be populated by name requests + DisplayName = string.Empty, + IsModerator = participant.IsModerator, + Distance = 0.0 // Distance not applicable for group chat, ignored + }; + + Participants.Add(chatParticipant); + agentIds.Add(participant.AvatarKey); + } + + Log.Information("[ChatParticipants] Updated participant list with {Count} members from GroupChatSessions", Participants.Count); + + // Request names and profile images for all participants + if (agentIds.Count <= 0) return; + _client.Avatars.RequestAvatarNames(agentIds); + + foreach (var agentId in agentIds) + { + _client.Avatars.RequestAvatarProperties(agentId); + } + + if (!_client.Avatars.DisplayNamesAvailable()) return; + const int batchSize = 50; + for (var i = 0; i < agentIds.Count; i += batchSize) + { + var batch = agentIds.Skip(i).Take(batchSize).ToList(); + _client.Avatars.GetDisplayNames(batch, (success, names, _) => + { + if (!success || names == null) return; + + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + lock (_lock) + { + foreach (var agent in names) + { + var person = Participants.FirstOrDefault(p => p.AgentId == agent.ID); + if (person != null) + person.DisplayName = agent.DisplayName; + } + } + }); + }); + } + } + }); + } + else + { + Log.Warning("[ChatParticipants] SessionId {SessionId} not found in GroupChatSessions", _conversation.SessionId); + } + } +} \ No newline at end of file diff --git a/GalaxyViewer/ViewModels/ChatViewModel.cs b/GalaxyViewer/ViewModels/ChatViewModel.cs index b1ce3ee..e429039 100644 --- a/GalaxyViewer/ViewModels/ChatViewModel.cs +++ b/GalaxyViewer/ViewModels/ChatViewModel.cs @@ -1,5 +1,6 @@ using System; using System.Collections.ObjectModel; +using System.Linq; using System.Reactive; using System.Threading.Tasks; using System.Windows.Input; @@ -14,9 +15,9 @@ using ReactiveUI; namespace GalaxyViewer.ViewModels; -public class ChatViewModel : ViewModelBase, IDisposable +public sealed class ChatViewModel : ViewModelBase, IDisposable { - private readonly ChatService _chatService; + internal readonly ChatService ChatService; private readonly ICommand? _backToDashboardCommand; private ChatConversation? _activeConversation; private string _messageText = string.Empty; @@ -29,7 +30,7 @@ public class ChatViewModel : ViewModelBase, IDisposable public bool IsInChatWindow { get; set; } - public ObservableCollection Conversations => _chatService.Conversations; + public ObservableCollection Conversations => ChatService.Conversations; public ChatConversation? ActiveConversation { @@ -38,7 +39,7 @@ public class ChatViewModel : ViewModelBase, IDisposable { if (_activeConversation == value) return; _activeConversation = value; - _chatService.SetActiveConversation(value); + ChatService.SetActiveConversation(value); OnPropertyChanged(nameof(ActiveConversation)); OnPropertyChanged(nameof(ActiveMessages)); OnPropertyChanged(nameof(CanSendMessage)); @@ -78,34 +79,65 @@ public class ChatViewModel : ViewModelBase, IDisposable && !IsLoading && ActiveConversation.MessageType != ChatMessageType.Objects; - public bool ShowObjectImWarning => ActiveConversation?.MessageType == ChatMessageType.Objects; + private bool ShowObjectImWarning => ActiveConversation?.MessageType == ChatMessageType.Objects; public string ObjectImWarning => ShowObjectImWarning ? Application.Current?.FindResource("Chat_ObjectImWarning") as string ?? string.Empty : string.Empty; + public int TotalUnreadCount => Conversations.Where(c => c != ActiveConversation).Sum(c => c.UnreadCount); + public bool HasUnreadMessages => TotalUnreadCount > 0; + public ReactiveCommand SendMessageCommand { get; } public ReactiveCommand SelectConversationCommand { get; } public ReactiveCommand PopOutChatCommand { get; } public ChatViewModel(ChatService chatService, ICommand? backToDashboardCommand = null) { - _chatService = chatService; + ChatService = chatService; _backToDashboardCommand = backToDashboardCommand; - _chatService.ActiveConversationChanged += OnActiveConversationChanged; - _chatService.MessageReceived += OnMessageReceived; - _chatService.ConversationUpdated += OnConversationUpdated; + ChatService.ActiveConversationChanged += OnActiveConversationChanged; + ChatService.MessageReceived += OnMessageReceived; + ChatService.ConversationUpdated += OnConversationUpdated; + + Conversations.CollectionChanged += Conversations_CollectionChanged; + + foreach (var conv in Conversations.ToList()) + conv.PropertyChanged += Conversation_PropertyChanged; SendMessageCommand = ReactiveCommand.CreateFromTask(SendMessageAsync); SelectConversationCommand = ReactiveCommand.Create(SelectConversation); PopOutChatCommand = ReactiveCommand.Create(PopOutChat); - ActiveConversation = _chatService.LocalChatConversation; + ActiveConversation = ChatService.LocalChatConversation; InitializeTypingTimer(); } + private void Conversations_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) + { + if (e.NewItems != null) + foreach (ChatConversation conv in e.NewItems) + conv.PropertyChanged += Conversation_PropertyChanged; + if (e.OldItems != null) + foreach (ChatConversation conv in e.OldItems) + conv.PropertyChanged -= Conversation_PropertyChanged; + RaiseUnreadProperties(); + } + + private void Conversation_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(ChatConversation.UnreadCount)) + RaiseUnreadProperties(); + } + + private void RaiseUnreadProperties() + { + OnPropertyChanged(nameof(TotalUnreadCount)); + OnPropertyChanged(nameof(HasUnreadMessages)); + } + private void InitializeTypingTimer() { _typingTimer = new System.Timers.Timer(3000); // 3 seconds @@ -128,14 +160,14 @@ public class ChatViewModel : ViewModelBase, IDisposable switch (ActiveConversation.MessageType) { case ChatMessageType.LocalChat: - await _chatService.SendLocalChatAsync(message); + await ChatService.SendLocalChatAsync(message); break; case ChatMessageType.InstantMessage when !false: - await _chatService.SendInstantMessageAsync(ActiveConversation.ParticipantId, + await ChatService.SendInstantMessageAsync(ActiveConversation.ParticipantId, message); break; case ChatMessageType.GroupChat when !false: - await _chatService.SendGroupMessageAsync(ActiveConversation.GroupId, message); + await ChatService.SendGroupMessageAsync(ActiveConversation.GroupId, message); break; case ChatMessageType.System: break; @@ -158,7 +190,7 @@ public class ChatViewModel : ViewModelBase, IDisposable private void SelectConversation(ChatConversation conversation) { ActiveConversation = conversation; - _chatService.MarkConversationAsRead(conversation); + ChatService.MarkConversationAsRead(conversation); } private void OnMessageReceived(object? sender, ChatMessage message) @@ -224,7 +256,7 @@ public class ChatViewModel : ViewModelBase, IDisposable if (!_isCurrentlyTyping) { _isCurrentlyTyping = true; - _chatService.StartLocalChatTyping(); + ChatService.StartLocalChatTyping(); } _typingTimer?.Stop(); @@ -255,7 +287,7 @@ public class ChatViewModel : ViewModelBase, IDisposable { if (!_isCurrentlyTyping) return; _isCurrentlyTyping = false; - _chatService.StopLocalChatTyping(); + ChatService.StopLocalChatTyping(); _typingTimer?.Stop(); } @@ -265,14 +297,14 @@ public class ChatViewModel : ViewModelBase, IDisposable GC.SuppressFinalize(this); } - protected virtual void Dispose(bool disposing) + private void Dispose(bool disposing) { if (_disposed) return; if (disposing) { - _chatService.ActiveConversationChanged -= OnActiveConversationChanged; - _chatService.MessageReceived -= OnMessageReceived; - _chatService.ConversationUpdated -= OnConversationUpdated; + ChatService.ActiveConversationChanged -= OnActiveConversationChanged; + ChatService.MessageReceived -= OnMessageReceived; + ChatService.ConversationUpdated -= OnConversationUpdated; } _disposed = true; diff --git a/GalaxyViewer/ViewModels/DashboardViewModel.cs b/GalaxyViewer/ViewModels/DashboardViewModel.cs index 96f0c63..01efdd0 100644 --- a/GalaxyViewer/ViewModels/DashboardViewModel.cs +++ b/GalaxyViewer/ViewModels/DashboardViewModel.cs @@ -112,25 +112,25 @@ public sealed class DashboardViewModel : ViewModelBase, INotifyPropertyChanged // Main Chat/Local Chat tab (non-closeable for now) var chatViewModel = new ChatViewModel(_chatService); - var mainChatTab = new TabItem("main_chat", "Local Chat", + var mainChatTab = new TabItem("main_chat", "local_chat_resource_key", "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", + var worldTab = new TabItem("world", "world_resource_key", "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", + var inventoryTab = new TabItem("inventory", "inventory_resource_key", "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", + var peopleTab = new TabItem("people", "people_resource_key", "People", new TextBlock { Text = "People/Friends view - Coming Soon" }, false); Tabs.Add(peopleTab); diff --git a/GalaxyViewer/ViewModels/LoginViewModel.cs b/GalaxyViewer/ViewModels/LoginViewModel.cs index f6fa3d6..1e45869 100644 --- a/GalaxyViewer/ViewModels/LoginViewModel.cs +++ b/GalaxyViewer/ViewModels/LoginViewModel.cs @@ -270,6 +270,8 @@ public class LoginViewModel : ReactiveObject, IRoutableViewModel return; } + LoginStatusMessage = "Logging in with MFA..."; + loginParams.Token = mfaCode; #if DEBUG @@ -370,7 +372,7 @@ public class LoginViewModel : ReactiveObject, IRoutableViewModel LoginWelcomeMessage = _client.Network.LoginMessage }; - _liteDbService.SaveSession(session); + await _liteDbService.SaveSessionAsync(session); CurrentSession = session; UpdateViewBindings(); diff --git a/GalaxyViewer/ViewModels/TabItem.cs b/GalaxyViewer/ViewModels/TabItem.cs index 59ecf9d..c2b54d1 100644 --- a/GalaxyViewer/ViewModels/TabItem.cs +++ b/GalaxyViewer/ViewModels/TabItem.cs @@ -10,19 +10,22 @@ public class TabItem : INotifyPropertyChanged private bool _isActive; private bool _hasNotification; private int _notificationCount; - private string _title; + private string _titleResourceKey; private object _content; + private int _chatTabUnreadCount; + private bool _showChatTabBadge; + private string _title; public event PropertyChangedEventHandler? PropertyChanged; public string Id { get; set; } - public string Title + public string TitleResourceKey { - get => _title; + get => _titleResourceKey; set { - _title = value; + _titleResourceKey = value; OnPropertyChanged(); } } @@ -79,9 +82,28 @@ public class TabItem : INotifyPropertyChanged public IBrush? IconBrush { get; set; } - public TabItem(string id, string title, object content, bool isCloseable = true) + public int ChatTabUnreadCount + { + get => _chatTabUnreadCount; + set { _chatTabUnreadCount = value; OnPropertyChanged(); } + } + + public bool ShowChatTabBadge + { + get => _showChatTabBadge; + set { _showChatTabBadge = value; OnPropertyChanged(); } + } + + public string Title + { + get => _title; + set { _title = value; OnPropertyChanged(); } + } + + public TabItem(string id, string titleResourceKey, string title, object content, bool isCloseable = true) { Id = id; + TitleResourceKey = titleResourceKey; Title = title; Content = content; IsCloseable = isCloseable; @@ -91,4 +113,4 @@ public class TabItem : INotifyPropertyChanged { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } -} \ No newline at end of file +} diff --git a/GalaxyViewer/Views/ChatArea.axaml b/GalaxyViewer/Views/ChatArea.axaml index 894316b..41c0c59 100644 --- a/GalaxyViewer/Views/ChatArea.axaml +++ b/GalaxyViewer/Views/ChatArea.axaml @@ -1,67 +1,121 @@ - - - - - + + + + + + + + + + + + + + - - - - + + + + - - - - - - - - - - + + + + + + + + + + + + + + + + + +