mirror of
https://github.com/GalaxyViewer/GalaxyViewer.git
synced 2026-08-14 00:57:53 +00:00
✨ Chat Participant list populates sucessfully, Android build fixes
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user