LANCommander/LANCommander.Launcher/ViewModels/ChatWindowViewModel.cs
Pat Hartl b183e4b18c Remove old launcher, rename Avalonia launcher
Also adds ARM builds for Linux + Windows launcher
2026-06-06 05:49:24 -05:00

432 lines
13 KiB
C#

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LANCommander.Launcher.Services;
using LANCommander.Launcher.Services;
using LANCommander.SDK.Models;
using LANCommander.SDK.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace LANCommander.Launcher.ViewModels;
public partial class ChatWindowViewModel : ViewModelBase
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<ChatWindowViewModel> _logger;
private IChatClient? _chatClient;
private AuthenticationService? _authenticationService;
private NotificationService? _notificationService;
private bool _initialized;
// ── Thread list ──────────────────────────────────────────────────────────
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasThreads))]
private ObservableCollection<ChatThreadViewModel> _threads = new();
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasSelectedThread))]
private ChatThreadViewModel? _selectedThread;
[ObservableProperty]
private string _messageInput = string.Empty;
[ObservableProperty]
private int _totalUnreadCount;
[ObservableProperty]
private bool _isLoading;
public bool HasSelectedThread => SelectedThread != null;
public bool HasThreads => Threads.Count > 0;
// ── New thread creation ──────────────────────────────────────────────────
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasSelectedUsers))]
private ObservableCollection<UserSelectionViewModel> _availableUsers = new();
[ObservableProperty]
private ObservableCollection<UserSelectionViewModel> _filteredUsers = new();
[ObservableProperty]
private string _userSearchText = string.Empty;
[ObservableProperty]
private bool _isCreatingThread;
[ObservableProperty]
private bool _isStartingThread;
public bool HasSelectedUsers => AvailableUsers.Any(u => u.IsSelected);
// ── Window state ─────────────────────────────────────────────────────────
/// <summary>Set by the window code-behind when it gains/loses activation.</summary>
public bool IsWindowActive { get; set; }
/// <summary>Fired when a message arrives while the chat window is inactive.</summary>
public event EventHandler<(string threadTitle, string senderName)>? MessageReceivedWhileInactive;
public ChatWindowViewModel(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
_logger = serviceProvider.GetRequiredService<ILogger<ChatWindowViewModel>>();
}
public async Task InitializeAsync()
{
try
{
_chatClient = _serviceProvider.GetRequiredService<IChatClient>();
_authenticationService = _serviceProvider.GetRequiredService<AuthenticationService>();
_notificationService = _serviceProvider.GetService<NotificationService>();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to resolve ChatClient");
}
}
public async Task LoadThreadsAsync(bool force = false)
{
if (_chatClient == null || (IsLoading && !force))
return;
if (_initialized && !force)
return;
IsLoading = true;
try
{
var threads = force
? await _chatClient.LoadThreadsAsync()
: await _chatClient.GetThreadsAsync();
await Dispatcher.UIThread.InvokeAsync(() =>
{
Threads.Clear();
foreach (var thread in threads.OrderByDescending(t => t.LastActivityOn))
Threads.Add(WrapThread(thread));
OnPropertyChanged(nameof(HasThreads));
if (SelectedThread == null && Threads.Count > 0)
SelectedThread = Threads[0];
});
_initialized = true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load chat threads");
}
finally
{
IsLoading = false;
}
}
private Guid GetCurrentUserId() => _authenticationService?.GetUserId() ?? Guid.Empty;
private ChatThreadViewModel WrapThread(ChatThread thread)
{
var vm = new ChatThreadViewModel(thread, GetCurrentUserId());
thread.DispatcherInvoke = async action => await Dispatcher.UIThread.InvokeAsync(action);
thread.OnMessageReceivedAsync = async message =>
{
var isActiveThread = SelectedThread?.Thread.Id == thread.Id;
await Dispatcher.UIThread.InvokeAsync(() =>
{
if (!IsWindowActive || !isActiveThread)
{
vm.UnreadCount++;
UpdateTotalUnreadCount();
}
MoveThreadToTop(vm);
});
if (!IsWindowActive)
{
MessageReceivedWhileInactive?.Invoke(this, (thread.Title, message.UserName));
_notificationService?.NotifyChatMessage(
thread.Title,
message.UserName,
message.Content);
}
};
return vm;
}
partial void OnSelectedThreadChanged(ChatThreadViewModel? oldValue, ChatThreadViewModel? newValue)
{
SendMessageCommand.NotifyCanExecuteChanged();
if (newValue != null)
{
_ = MarkThreadReadAsync(newValue);
_ = LoadThreadMessagesAsync(newValue);
}
}
private async Task LoadThreadMessagesAsync(ChatThreadViewModel threadVm)
{
if (_chatClient == null)
return;
// Skip if the thread already has messages loaded (from this session)
if (threadVm.Thread.MessageGroups.Count > 0)
return;
try
{
var response = await _chatClient.GetMessagesAsync(threadVm.Thread.Id, null, 50);
if (response.Items != null && response.Items.Any())
await threadVm.Thread.MessagesReceivedAsync(response.Items);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load messages for thread {ThreadId}", threadVm.Thread.Id);
}
}
private async Task MarkThreadReadAsync(ChatThreadViewModel threadVm)
{
if (_chatClient == null)
return;
try
{
threadVm.UnreadCount = 0;
UpdateTotalUnreadCount();
await _chatClient.UpdatedReadStatus(threadVm.Thread.Id);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to update read status for thread {ThreadId}", threadVm.Thread.Id);
}
}
private void UpdateTotalUnreadCount() =>
TotalUnreadCount = Threads.Sum(t => t.UnreadCount);
private void MoveThreadToTop(ChatThreadViewModel threadVm)
{
var index = Threads.IndexOf(threadVm);
if (index > 0)
Threads.Move(index, 0);
}
// ── Send message ─────────────────────────────────────────────────────────
[RelayCommand(CanExecute = nameof(CanSendMessage))]
private async Task SendMessageAsync()
{
if (_chatClient == null || SelectedThread == null || string.IsNullOrWhiteSpace(MessageInput))
return;
var text = MessageInput.Trim();
MessageInput = string.Empty;
try
{
await _chatClient.SendMessageAsync(SelectedThread.Thread.Id, text);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send chat message");
MessageInput = text;
}
}
private bool CanSendMessage() =>
SelectedThread != null && !string.IsNullOrWhiteSpace(MessageInput);
partial void OnMessageInputChanged(string value) =>
SendMessageCommand.NotifyCanExecuteChanged();
// ── New thread ───────────────────────────────────────────────────────────
[RelayCommand]
private async Task OpenNewThreadAsync()
{
if (_chatClient == null)
return;
IsCreatingThread = true;
UserSearchText = string.Empty;
try
{
var users = await _chatClient.GetUsersAsync();
var currentUserId = _authenticationService?.GetUserId() ?? Guid.Empty;
await Dispatcher.UIThread.InvokeAsync(() =>
{
AvailableUsers.Clear();
foreach (var user in users.Where(u => u.Id != currentUserId))
{
var vm = new UserSelectionViewModel(user);
vm.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(UserSelectionViewModel.IsSelected))
OnPropertyChanged(nameof(HasSelectedUsers));
};
AvailableUsers.Add(vm);
}
ApplyUserFilter();
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load users for new thread");
IsCreatingThread = false;
}
}
[RelayCommand]
private void CancelNewThread()
{
IsCreatingThread = false;
UserSearchText = string.Empty;
foreach (var u in AvailableUsers)
u.IsSelected = false;
}
[RelayCommand]
private async Task StartNewThreadAsync()
{
if (_chatClient == null)
return;
var selected = AvailableUsers.Where(u => u.IsSelected).ToList();
if (selected.Count == 0)
return;
IsStartingThread = true;
try
{
var currentUserId = GetCurrentUserId();
var userIds = selected.Select(u => u.User.Id.ToString()).ToList();
if (currentUserId != Guid.Empty && !userIds.Contains(currentUserId.ToString()))
userIds.Add(currentUserId.ToString());
var threadId = await _chatClient.StartThreadAsync(userIds);
if (threadId != Guid.Empty)
{
// Reload threads so the new one appears
await LoadThreadsAsync(force: true);
await Dispatcher.UIThread.InvokeAsync(() =>
{
// Select the newly created thread
var newThread = Threads.FirstOrDefault(t => t.Thread.Id == threadId);
if (newThread != null)
SelectedThread = newThread;
IsCreatingThread = false;
UserSearchText = string.Empty;
});
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to start new chat thread");
}
finally
{
IsStartingThread = false;
}
}
partial void OnUserSearchTextChanged(string value) => ApplyUserFilter();
private void ApplyUserFilter()
{
FilteredUsers.Clear();
var query = UserSearchText?.Trim() ?? string.Empty;
foreach (var user in AvailableUsers)
{
if (string.IsNullOrEmpty(query) || user.Name.Contains(query, StringComparison.OrdinalIgnoreCase))
FilteredUsers.Add(user);
}
}
}
// ── ChatThreadViewModel ───────────────────────────────────────────────────────
public partial class ChatThreadViewModel : ViewModelBase
{
public ChatThread Thread { get; }
public string Title => Thread.Title;
public ObservableCollection<ChatMessageGroup> MessageGroups => Thread.MessageGroups;
public List<User> OtherParticipants { get; }
public string OtherParticipantNames => string.Join(", ", OtherParticipants.Select(p => p.Name));
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasUnread))]
private int _unreadCount;
public bool HasUnread => UnreadCount > 0;
public ChatThreadViewModel(ChatThread thread, Guid currentUserId)
{
Thread = thread;
OtherParticipants = thread.Participants
.Where(p => p.Id != currentUserId)
.ToList();
}
}
// ── UserSelectionViewModel ────────────────────────────────────────────────────
public partial class UserSelectionViewModel : ViewModelBase
{
public User User { get; }
public string Name => User.Name;
[ObservableProperty]
private bool _isSelected;
public UserSelectionViewModel(User user)
{
User = user;
}
}