Chat Participant list populates sucessfully, Android build fixes

This commit is contained in:
GalaxyLittlepaws
2025-12-13 22:22:37 -05:00
parent 5b88fa90ac
commit e0200a5891
22 changed files with 4494 additions and 278 deletions
@@ -1,9 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0-android35.0</TargetFramework>
<TargetFramework>net9.0-android36.0</TargetFramework>
<AndroidUseLatestPlatformSdk>true</AndroidUseLatestPlatformSdk>
<AndroidSdkVersion>35</AndroidSdkVersion>
<AndroidSdkVersion>36</AndroidSdkVersion>
<SupportedOSPlatformVersion>24.0</SupportedOSPlatformVersion>
<Nullable>enable</Nullable>
<ApplicationId>com.GalaxyViewer.GalaxyViewer</ApplicationId>
@@ -15,8 +15,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia.Android" Version="11.3.2" />
<PackageReference Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.0.1.16" />
<PackageReference Include="Avalonia.Android" Version="11.3.7" />
<PackageReference Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.0.1.17" />
</ItemGroup>
<ItemGroup>
@@ -13,9 +13,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia.Desktop" Version="11.3.2" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.7" />
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.3.2" />
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.3.7" />
</ItemGroup>
<ItemGroup>
+9 -27
View File
@@ -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)
+37 -31
View File
@@ -1,19 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFrameworks>net9.0;net8.0;net9.0-android36.0</TargetFrameworks>
<LangVersion>latest</LangVersion>
<Version>2025.07.22-test</Version>
<Version>2025.12.13-test</Version>
<AssemblyInformationalVersion>$(Version)</AssemblyInformationalVersion>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
<PropertyGroup>
<ApplicationIcon>Assets\GalaxyViewerLogo.ico</ApplicationIcon>
<ApplicationIcon>Assets/GalaxyViewerLogo.ico</ApplicationIcon>
</PropertyGroup>
<!-- <PropertyGroup>
<PropertyGroup>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup> -->
</PropertyGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\**" />
@@ -29,35 +29,41 @@
<Watch Include="**\*.axaml" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.3.7" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.3.7" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.7" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.7" />
<!-- Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration. -->
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.3.7" />
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.1.0" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageReference Include="Irihi.Ursa" Version="1.13.0" />
<PackageReference Include="Irihi.Ursa.Themes.Semi" Version="1.13.0" />
<PackageReference Include="LibreMetaverse" Version="2.4.13.1131" />
<PackageReference Include="LiteDB" Version="5.0.21" />
<PackageReference Include="Material.Icons.Avalonia" Version="2.4.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="ReactiveUI.Avalonia" Version="14.7.1" />
<PackageReference Include="Semi.Avalonia" Version="11.3.7" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="3.119.1" />
<PackageReference Include="xunit" Version="2.9.3" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.3.2" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.3.2" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.2" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.2" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.3.2" />
<!-- Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration. -->
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.3.2" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<!-- More things will go here -->
<PackageReference Include="Irihi.Ursa" Version="1.12.0" />
<PackageReference Include="Irihi.Ursa.ReactiveUIExtension" Version="1.0.1" />
<PackageReference Include="Irihi.Ursa.Themes.Semi" Version="1.12.0" />
<PackageReference Include="LibreMetaverse" Version="2.4.3.1065" />
<PackageReference Include="LiteDB" Version="5.0.21" />
<PackageReference Include="Live.Avalonia" Version="1.4.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="3.119.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.7" />
<PackageReference Include="Semi.Avalonia" Version="11.2.1.9" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<Folder Include="Helpers\" />
<Folder Include="Libs\" />
<Folder Include="Wrappers\" />
</ItemGroup>
<ItemGroup>
<Folder Include="Libs\" />
<Folder Include="Tests\" />
<Folder Include="Wrappers\" />
<Compile Update="Views\AddressBar.axaml.cs">
<DependentUpon>AddressBar.axaml</DependentUpon>
<SubType>Code</SubType>
</Compile>
</ItemGroup>
</Project>
+2 -1
View File
@@ -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<ChatMessage> Messages { get; set; } = [];
public ObservableCollection<string> TypingUsers { get; set; } = [];
public ObservableCollection<ChatParticipant> Participants { get; set; } = new();
private DateTime _lastActivity = DateTime.Now;
private bool _hasUnreadMessages;
+8 -4
View File
@@ -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"
};
}
}
}
+54
View File
@@ -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));
}
+291 -25
View File
@@ -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<ChatConversation>? ConversationUpdated;
public event EventHandler<ChatConversation?>? ActiveConversationChanged;
private readonly Dictionary<UUID, Queue<(InstantMessageEventArgs Args, DateTime Timestamp)>> _pendingGroupMessages = new();
private readonly Dictionary<UUID, Queue<(InstantMessageEventArgs Args, DateTime Timestamp)>>
_pendingGroupMessages = new();
private readonly ConcurrentDictionary<UUID, string> _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<AvatarPropertiesReplyEventArgs>();
EventHandler<AvatarPropertiesReplyEventArgs>? 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;
+173 -62
View File
@@ -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<GridModel>("grids");
@@ -85,64 +87,73 @@ public class LiteDbService : IDisposable, INotifyPropertyChanged
private void SeedPreferences()
{
var preferencesCollection = _database?.GetCollection<PreferencesModel>("preferences");
if (preferencesCollection != null && preferencesCollection.Count() != 0) return;
var defaultPreferences = PreferencesManager.CreateDefaultPreferences();
preferencesCollection?.Insert(defaultPreferences);
lock (_dbLock)
{
var preferencesCollection = _database?.GetCollection<PreferencesModel>("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<GridModel>("grids");
if (gridsCollection != null && gridsCollection.Count() != 0) return;
var grids = new[]
lock (_dbLock)
{
new GridModel
var gridsCollection = _database?.GetCollection<GridModel>("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<SessionModel>("session");
if (sessionCollection != null && sessionCollection.Count() > 0)
lock (_dbLock)
{
sessionCollection.DeleteAll();
// Log.Debug("Session data cleared");
}
var sessionCollection = _database?.GetCollection<SessionModel>("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<SessionModel>("session");
return collection?.FindOne(Query.All()) ?? new SessionModel();
lock (_dbLock)
{
var collection = _database?.GetCollection<SessionModel>("session");
return collection?.FindOne(Query.All()) ?? new SessionModel();
}
}
public bool HasSessionChanged(SessionModel currentSession)
public async Task<T?> ExecuteDbAsync<T>(Func<LiteDatabase, T> 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<SessionModel> GetSessionAsync(CancellationToken cancellationToken = default)
{
var collection = _database?.GetCollection<SessionModel>("session");
collection?.Upsert(session);
// Log.Debug("Session data saved");
return await ExecuteDbAsync(db =>
{
var collection = db.GetCollection<SessionModel>("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<SessionModel>("session");
collection.Upsert(session);
return true;
}, cancellationToken);
Session = session;
SessionChanged?.Invoke(this, EventArgs.Empty);
}
// TODO: Finish implementing this cache system
public async Task AgentCacheSetAsync<T>(string agentUuid, string key, T value, CancellationToken cancellationToken = default)
{
await ExecuteDbAsync(db =>
{
var collection = db.GetCollection<CacheEntry<T>>(agentUuid);
var entry = new CacheEntry<T> { Key = key, Value = value, Timestamp = DateTime.UtcNow };
collection.Upsert(entry);
return true;
}, cancellationToken);
}
public async Task<T?> AgentCacheGetAsync<T>(string agentUuid, string key, CancellationToken cancellationToken = default)
{
return await ExecuteDbAsync(db =>
{
var collection = db.GetCollection<CacheEntry<T>>(agentUuid);
var entry = collection.FindOne(x => x.Key == key);
return entry != null ? entry.Value : default;
}, cancellationToken);
}
public async Task GridCacheSetAsync<T>(string gridName, string key, T value, CancellationToken cancellationToken = default)
{
await ExecuteDbAsync(db =>
{
var collection = db.GetCollection<CacheEntry<T>>("gridcache_" + gridName);
var entry = new CacheEntry<T> { Key = key, Value = value, Timestamp = DateTime.UtcNow };
collection.Upsert(entry);
return true;
}, cancellationToken);
}
public async Task<T?> GridCacheGetAsync<T>(string gridName, string key, CancellationToken cancellationToken = default)
{
return await ExecuteDbAsync(db =>
{
var collection = db.GetCollection<CacheEntry<T>>("gridcache_" + gridName);
var entry = collection.FindOne(x => x.Key == key);
return entry != null ? entry.Value : default;
}, cancellationToken);
}
public class CacheEntry<T>
{
[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<AvatarNameCacheEntry>("avatar_name_cache");
var entry = col.FindById(id);
return entry?.Name;
}
}
public void SetAvatarName(Guid id, string name)
{
lock (_dbLock)
{
var col = _database!.GetCollection<AvatarNameCacheEntry>("avatar_name_cache");
col.Upsert(new AvatarNameCacheEntry { Id = id, Name = name });
}
}
}
@@ -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;
}
/// <summary>
/// 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
/// </summary>
public async Task<Bitmap?> 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;
}
}
/// <summary>
/// Requests an image asset from the asset server asynchronously.
/// </summary>
private async Task<AssetTexture?> 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;
}
/// <summary>
/// Decodes a texture asset into a ManagedImage, validating dimensions.
/// </summary>
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;
}
/// <summary>
/// Converts a ManagedImage to an Avalonia Bitmap via SkiaSharp.
/// </summary>
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();
}
}
}
}
@@ -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<ChatParticipant> 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<UUID>();
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);
}
}
}
+52 -20
View File
@@ -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<ChatConversation> Conversations => _chatService.Conversations;
public ObservableCollection<ChatConversation> 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<Unit, Unit> SendMessageCommand { get; }
public ReactiveCommand<ChatConversation, Unit> SelectConversationCommand { get; }
public ReactiveCommand<Unit, Unit> 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<ChatConversation>(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;
@@ -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);
+3 -1
View File
@@ -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();
+28 -6
View File
@@ -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));
}
}
}
+110 -56
View File
@@ -1,67 +1,121 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:GalaxyViewer.ViewModels"
xmlns:u="https://irihi.tech/ursa"
xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
xmlns:views="clr-namespace:GalaxyViewer.Views"
x:Class="GalaxyViewer.Views.ChatArea"
x:DataType="vm:ChatViewModel">
<Grid RowDefinitions="Auto,*,Auto">
<!-- Conversation Header -->
<Border Grid.Row="0" Padding="15,10"
Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}"
BorderThickness="0,2,0,0">
<TextBlock Text="{Binding ActiveConversation.Name}" FontSize="16" FontWeight="SemiBold" />
</Border>
<Grid>
<!-- Main chat area -->
<Grid>
<Grid RowDefinitions="Auto,*,Auto">
<!-- Conversation Header -->
<Border Grid.Row="0" Padding="15,10"
Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}"
BorderThickness="0,2,0,0"
HorizontalAlignment="Stretch">
<Grid>
<DockPanel HorizontalAlignment="Stretch" LastChildFill="False">
<Button x:Name="OpenDrawerButton"
DockPanel.Dock="Left"
ToolTip.Tip="{StaticResource ConversationDrawer_OpenTooltip}"
Background="Transparent"
Foreground="{StaticResource SystemAccentColor}"
Margin="0,0,8,0"
VerticalAlignment="Center"
IsVisible="{OnPlatform Default=False, iOS=True, Android=True}">
<Grid>
<materialIcons:MaterialIcon Kind="Menu" Width="24" Height="24"/>
<u:Badge
Header="{Binding TotalUnreadCount}"
IsVisible="{Binding HasUnreadMessages}"
Background="{StaticResource SystemAccentColor}"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Margin="0,0,0,0" />
</Grid>
</Button>
<TextBlock Text="{Binding ActiveConversation.Name}"
FontSize="16"
FontWeight="SemiBold"
VerticalAlignment="Center"
DockPanel.Dock="Left"
Margin="0,0,8,0" />
<Button x:Name="OpenConversationParticipantsDrawerButton"
DockPanel.Dock="Right"
ToolTip.Tip="Show Conversation Participants"
Foreground="{StaticResource SystemAccentColor}"
Margin="0,0,8,0"
VerticalAlignment="Center">
<materialIcons:MaterialIcon Kind="EmojiMan" Width="24" Height="24"/>
</Button>
</DockPanel>
</Grid>
</Border>
<!-- Messages -->
<ScrollViewer Grid.Row="1" Name="MessagesScrollViewer"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<StackPanel x:Name="MessagesPanel" />
</ScrollViewer>
<!-- Messages -->
<ScrollViewer Grid.Row="1" Name="MessagesScrollViewer"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<StackPanel x:Name="MessagesPanel" />
</ScrollViewer>
<!-- Typing indicator -->
<StackPanel Grid.Row="2" MinHeight="40">
<Border Padding="10,4" Background="{DynamicResource SystemControlBackgroundChromeLowBrush}"
IsVisible="{Binding ActiveConversation.IsTyping}">
<StackPanel Orientation="Horizontal" Spacing="6">
<StackPanel Orientation="Horizontal" Spacing="3">
<Ellipse Width="4" Height="4"
Fill="{DynamicResource SystemControlForegroundBaseMediumBrush}" />
<Ellipse Width="4" Height="4"
Fill="{DynamicResource SystemControlForegroundBaseMediumBrush}" />
<Ellipse Width="4" Height="4"
Fill="{DynamicResource SystemControlForegroundBaseMediumBrush}" />
</StackPanel>
<TextBlock Text="{Binding ActiveConversation.LastMessage}" FontStyle="Italic" FontSize="11" />
<!-- Typing indicator -->
<StackPanel Grid.Row="2" MinHeight="40">
<Border Padding="10,4" Background="{DynamicResource SystemControlBackgroundChromeLowBrush}"
IsVisible="{Binding ActiveConversation.IsTyping}">
<StackPanel Orientation="Horizontal" Spacing="6">
<StackPanel Orientation="Horizontal" Spacing="3">
<Ellipse Width="4" Height="4"
Fill="{DynamicResource SystemControlForegroundBaseMediumBrush}" />
<Ellipse Width="4" Height="4"
Fill="{DynamicResource SystemControlForegroundBaseMediumBrush}" />
<Ellipse Width="4" Height="4"
Fill="{DynamicResource SystemControlForegroundBaseMediumBrush}" />
</StackPanel>
<TextBlock Text="{Binding ActiveConversation.LastMessage}" FontStyle="Italic" FontSize="11" />
</StackPanel>
</Border>
<!-- Input -->
<Border Padding="15,10" Background="{DynamicResource SystemControlBackgroundChromeLowBrush}">
<StackPanel>
<DockPanel>
<Button DockPanel.Dock="Right"
Command="{Binding SendMessageCommand}"
Content="{StaticResource Chat_Send}"
Margin="8,0,0,0"
IsEnabled="{Binding CanSendMessage}" />
<TextBox Text="{Binding MessageText}"
Watermark="{StaticResource Chat_TypeMessage}"
AcceptsReturn="False"
AcceptsTab="False"
MinHeight="30"
VerticalAlignment="Center"
IsEnabled="{Binding CanTypeMessage}">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding SendMessageCommand}" />
<KeyBinding Gesture="Return" Command="{Binding SendMessageCommand}" />
</TextBox.KeyBindings>
</TextBox>
</DockPanel>
</StackPanel>
</Border>
</StackPanel>
</Border>
</Grid>
<!-- Input -->
<Border Padding="15,10" Background="{DynamicResource SystemControlBackgroundChromeLowBrush}">
<StackPanel>
<DockPanel>
<Button DockPanel.Dock="Right"
Command="{Binding SendMessageCommand}"
Content="{StaticResource Chat_Send}"
Margin="8,0,0,0"
IsEnabled="{Binding CanSendMessage}" />
<TextBox Text="{Binding MessageText}"
Watermark="{StaticResource Chat_TypeMessage}"
AcceptsReturn="False"
AcceptsTab="False"
MinHeight="30"
VerticalAlignment="Center"
IsEnabled="{Binding CanTypeMessage}">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding SendMessageCommand}" />
<KeyBinding Gesture="Return" Command="{Binding SendMessageCommand}" />
</TextBox.KeyBindings>
</TextBox>
</DockPanel>
</StackPanel>
</Border>
</StackPanel>
<!-- Mobile Drawer -->
<u:OverlayDialogHost Name="ConversationDrawer" HostId="ChatDrawer"
IsVisible="{OnPlatform Default=False, iOS=True, Android=True}"
HorizontalAlignment="Stretch" VerticalAlignment="Stretch" />
<!-- Conversation Participants Drawer (Ursa) -->
<u:OverlayDialogHost Name="ChatParticipantsDrawer" HostId="ChatParticipantsDrawerHost"
HorizontalAlignment="Stretch" VerticalAlignment="Stretch" />
</Grid>
</Grid>
</UserControl>
</UserControl>
+109 -30
View File
@@ -7,7 +7,9 @@ using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.Threading;
using GalaxyViewer.Models;
using GalaxyViewer.Services;
using GalaxyViewer.ViewModels;
using Serilog;
using Ursa.Controls;
using Ursa.Common;
using Ursa.Controls.Options;
@@ -25,10 +27,15 @@ public partial class ChatArea : UserControl
InitializeComponent();
DataContextChanged += OnDataContextChanged;
Loaded += (s, e) =>
Loaded += (_, _) =>
{
_messagesScrollViewer = this.FindControl<ScrollViewer>("MessagesScrollViewer");
};
var openChatParticipantsDrawerButton = this.FindControl<Button>("OpenConversationParticipantsDrawerButton");
if (openChatParticipantsDrawerButton == null) return;
openChatParticipantsDrawerButton.Click -= OpenConversationParticipantsDrawerButton_Click;
openChatParticipantsDrawerButton.Click += OpenConversationParticipantsDrawerButton_Click;
}
private void InitializeComponent()
@@ -37,27 +44,74 @@ public partial class ChatArea : UserControl
_messagesPanel = this.FindControl<StackPanel>("MessagesPanel");
var openDrawerButton = this.FindControl<Button>("OpenDrawerButton");
if (openDrawerButton != null)
openDrawerButton.Click += (s, e) => OpenDrawer();
var openConversationsDrawer = this.FindControl<Button>("OpenDrawerButton");
if (openConversationsDrawer == null) return;
openConversationsDrawer.Click += (_, _) => OpenConversationDrawer();
}
private void OpenDrawer()
private void OpenConversationDrawer()
{
var options = new DrawerOptions
{
Position = Position.Left,
CanLightDismiss = true,
IsCloseButtonVisible = true,
Title = "Conversations",
CanResize = false
};
var hostId = "ChatDrawer";
const string hostId = "ChatDrawer";
if (_chatViewModel == null)
{
Log.Error("ChatViewModel is null in OpenDrawer. Cannot open drawer.");
return;
}
var drawerViewModel = new ConversationDrawerViewModel(_chatViewModel);
Drawer.ShowCustom<ConversationDrawerView, ConversationDrawerViewModel>(drawerViewModel,
hostId, options);
try
{
Drawer.ShowCustom<ConversationDrawerView, ConversationDrawerViewModel>(drawerViewModel,
hostId, options);
}
catch (Exception ex)
{
Log.Error(ex, "Drawer.ShowCustom threw an exception.");
}
}
private void OpenChatParticipantsDrawer()
{
var options = new DrawerOptions
{
Position = Position.Right,
CanLightDismiss = true,
IsCloseButtonVisible = true,
CanResize = false
};
const string hostId = "ChatParticipantsDrawerHost";
if (_chatViewModel == null)
{
Log.Error("ChatViewModel is null in OpenChatParticipantsDrawer. Cannot open drawer.");
return;
}
var activeConversation = _chatViewModel.ActiveConversation;
var profileImageService = new ProfileImageService(_chatViewModel.ChatService.Client);
var drawerViewModel = new ChatParticipantsViewModel(_chatViewModel.ChatService, activeConversation, profileImageService);
drawerViewModel.SubscribeEvents();
try
{
Drawer.ShowCustom<ChatParticipantsView, ChatParticipantsViewModel>(drawerViewModel,
hostId, options);
}
catch (Exception ex)
{
Log.Error(ex, "Drawer.ShowCustom threw an exception for ChatParticipantsDrawer.");
}
}
private void OnDataContextChanged(object? sender, EventArgs e)
@@ -67,11 +121,6 @@ public partial class ChatArea : UserControl
_chatViewModel.ActiveConversation.Messages.CollectionChanged -= OnMessagesChanged;
}
if (_chatViewModel?.Conversations != null)
{
_chatViewModel.Conversations.CollectionChanged -= OnConversationsChanged;
}
if (_chatViewModel != null)
{
_chatViewModel.PropertyChanged -= OnViewModelPropertyChanged;
@@ -80,15 +129,16 @@ public partial class ChatArea : UserControl
_chatViewModel = DataContext as ChatViewModel;
if (_chatViewModel == null) return;
_chatViewModel.Conversations.CollectionChanged += OnConversationsChanged;
_chatViewModel.PropertyChanged += OnViewModelPropertyChanged;
if (_chatViewModel.ActiveConversation?.Messages != null)
{
_chatViewModel.ActiveConversation.Messages.CollectionChanged += OnMessagesChanged;
}
var openDrawerButton = this.FindControl<Button>("OpenDrawerButton");
if (openDrawerButton != null)
openDrawerButton.IsEnabled = _chatViewModel != null;
RefreshMessages();
}
@@ -98,12 +148,9 @@ public partial class ChatArea : UserControl
if (e.PropertyName != nameof(ChatViewModel.ActiveConversation)) return;
if (_chatViewModel?.ActiveConversation?.Messages != null)
{
foreach (var conv in _chatViewModel.Conversations)
foreach (var conv in _chatViewModel.Conversations.ToList().Where(conv => conv != _chatViewModel.ActiveConversation))
{
if (conv != _chatViewModel.ActiveConversation)
{
conv.Messages.CollectionChanged -= OnMessagesChanged;
}
conv.Messages.CollectionChanged -= OnMessagesChanged;
}
}
@@ -120,11 +167,6 @@ public partial class ChatArea : UserControl
RefreshMessages();
}
private void OnConversationsChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
// No specific refresh needed for the message panel here
}
private void RefreshMessages()
{
if (_chatViewModel?.ActiveConversation?.Messages == null)
@@ -189,6 +231,35 @@ public partial class ChatArea : UserControl
{
var header = new DockPanel { Margin = new Thickness(0, 0, 0, 6) };
// Create a horizontal panel for avatar and sender info
var senderPanel = new StackPanel { Orientation = Avalonia.Layout.Orientation.Horizontal };
// Add avatar image if available
if (message.AvatarImage != null)
{
var avatarImage = new Image
{
Source = message.AvatarImage,
Width = 24,
Height = 24,
Margin = new Thickness(0, 0, 8, 0)
};
// Make avatar circular
// TODO: Add a preference for avatar shape
var avatarBorder = new Border
{
Child = avatarImage,
CornerRadius = new CornerRadius(12),
ClipToBounds = true,
Width = 24,
Height = 24,
Margin = new Thickness(0, 0, 8, 0)
};
senderPanel.Children.Add(avatarBorder);
}
if (!string.IsNullOrEmpty(message.SenderName))
{
var senderText = new TextBlock
@@ -196,16 +267,19 @@ public partial class ChatArea : UserControl
Text = message.SenderName,
FontWeight = FontWeight.Bold,
FontSize = 12,
Margin = new Thickness(0, 0, 8, 0)
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
};
header.Children.Add(senderText);
senderPanel.Children.Add(senderText);
}
header.Children.Add(senderPanel);
var timestamp = new TextBlock
{
Text = message.Timestamp.ToString("h:mm:ss tt"),
FontSize = 10,
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right,
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
};
DockPanel.SetDock(timestamp, Dock.Right);
header.Children.Add(timestamp);
@@ -230,4 +304,9 @@ public partial class ChatArea : UserControl
return border;
}
private void OpenConversationParticipantsDrawerButton_Click(object? sender, EventArgs e)
{
OpenChatParticipantsDrawer();
}
}
@@ -0,0 +1,35 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:GalaxyViewer.ViewModels"
xmlns:u="https://irihi.tech/ursa"
xmlns:models="clr-namespace:GalaxyViewer.Models"
x:Class="GalaxyViewer.Views.ChatParticipantsView"
x:DataType="vm:ChatParticipantsViewModel">
<Border Background="{DynamicResource CardBackground}" Padding="16" Width="280">
<StackPanel>
<TextBlock Text="Chat Participants" FontSize="20" FontWeight="Bold" Margin="0,0,0,12" />
<ItemsControl ItemsSource="{Binding Participants}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="models:ChatParticipant">
<Border Margin="0,0,0,8" Padding="8" Background="{DynamicResource SystemControlBackgroundChromeLowBrush}" CornerRadius="8">
<StackPanel Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
<u:Avatar Width="48" Height="48" CornerRadius="24">
<Image Source="{Binding AvatarImage}" Width="48" Height="48"/>
</u:Avatar>
<StackPanel>
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" FontSize="16"/>
<TextBlock Text="{Binding DisplayName}" FontSize="14" Foreground="Gray"/>
<TextBlock Text="{Binding Distance, StringFormat='{}{0:0.0} m'}"
FontSize="12"
Opacity="0.7"
IsVisible="{Binding $parent[UserControl].((vm:ChatParticipantsViewModel)DataContext).ShowDistance}"/>
</StackPanel>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</UserControl>
@@ -0,0 +1,36 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using GalaxyViewer.ViewModels;
namespace GalaxyViewer.Views;
public partial class ChatParticipantsView : UserControl
{
private ChatParticipantsViewModel? _viewModel;
public ChatParticipantsView()
{
InitializeComponent();
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_viewModel = DataContext as ChatParticipantsViewModel;
_viewModel?.SubscribeEvents();
_viewModel?.RefreshParticipants();
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_viewModel?.UnsubscribeEvents();
}
}
+1
View File
@@ -28,6 +28,7 @@
IsVisible="{Binding IsInChatWindow, Converter={StaticResource InverseBoolConverter}}">
<Button Command="{Binding PopOutChatCommand}"
Background="Transparent"
Foreground="{StaticResource SystemAccentColor}"
BorderThickness="0"
Padding="8,6"
CornerRadius="4"
File diff suppressed because it is too large Load Diff
+12 -5
View File
@@ -26,17 +26,19 @@ Features unique to this viewer (compared to the stock viewer) will include:
- [ ] Friends
- [ ] Groups
- [ ] Profiles
- [ ] Search
- [ ] People Search
- [ ] World Interaction
- [x] Teleporting
- [ ] 3D World View
- [ ] Rezzing Objects
- [ ] Camera Controls
- [ ] World Map
- [ ] Mini Map
- [ ] Radar
- [ ] Nearby People
- [ ] Places Search
- [ ] Content Creation and Management
@@ -81,6 +83,8 @@ To be added...
Make sure you have the [.NET Core SDK](https://dotnet.microsoft.com/download) installed. We currently use .NET 9.0.
On Arch Linux you may need to use dotnet-install.sh as described [here](https://wiki.archlinux.org/title/.NET#Install_multiple_versions_manually).
Clone the repository and navigate to the project directory.
### Android Build Requirements
@@ -88,20 +92,23 @@ Clone the repository and navigate to the project directory.
To build the Android version, you need:
- **Android Studio** (for SDK management and device emulation)
- **Android SDK version 34** (required by the project)
- **Android SDK version 36** (required by the project)
- **Java SDK 21**
Note: If you are on Arch Linux, the aur package is missing the workload for Android. You will need to install the dotnet SDK manually as mentioned above.
#### Steps:
1. Download and install [Android Studio](https://developer.android.com/studio).
2. Use the SDK Manager in Android Studio to install **Android SDK Platform 34**.
2. Use the SDK Manager in Android Studio to install **Android SDK Platform 36**.
3. Make sure your `ANDROID_HOME` environment variable is set, or let Android Studio manage it.
4. Run:
```bash
dotnet workload install android
```
to install the .NET Android workload.
to install the .NET Android workload. (May require sudo privileges.)
Install the .NET things for android, wasm, etc. if you want to build for those platforms.
Be sure to install the .NET things for android, wasm, etc. if you want to build for those platforms.
`dotnet workload install android`, `dotnet workload install wasm-tools`, etc.
Run `dotnet build` to build the project.