kotalk/src/PhysOn.Desktop/ViewModels/ConversationWindowViewModel.cs
2026-04-16 09:24:26 +09:00

224 lines
7.2 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Collections.ObjectModel;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PhysOn.Contracts.Conversations;
using PhysOn.Contracts.Realtime;
using PhysOn.Desktop.Services;
namespace PhysOn.Desktop.ViewModels;
public partial class ConversationWindowViewModel : ViewModelBase, IAsyncDisposable
{
private readonly PhysOnApiClient _apiClient = new();
private readonly PhysOnRealtimeClient _realtimeClient = new();
private readonly ConversationWindowLaunch _launchContext;
public ConversationWindowViewModel(ConversationWindowLaunch launchContext)
{
_launchContext = launchContext;
ConversationTitle = launchContext.ConversationTitle;
ConversationSubtitle = launchContext.ConversationSubtitle;
SendMessageCommand = new AsyncRelayCommand(SendMessageAsync, CanSendMessage);
ReloadCommand = new AsyncRelayCommand(LoadMessagesAsync, () => !IsBusy);
_realtimeClient.ConnectionStateChanged += HandleRealtimeConnectionStateChanged;
_realtimeClient.MessageCreated += HandleMessageCreated;
}
public ObservableCollection<MessageRowViewModel> Messages { get; } = [];
public IAsyncRelayCommand SendMessageCommand { get; }
public IAsyncRelayCommand ReloadCommand { get; }
[ObservableProperty] private string conversationTitle = string.Empty;
[ObservableProperty] private string conversationSubtitle = string.Empty;
[ObservableProperty] private string composerText = string.Empty;
[ObservableProperty] private string statusText = "·";
[ObservableProperty] private bool isBusy;
[ObservableProperty] private string? errorText;
public string ConversationGlyph =>
string.IsNullOrWhiteSpace(ConversationTitle) ? "PO" : ConversationTitle.Trim()[..Math.Min(2, ConversationTitle.Trim().Length)];
public bool HasErrorText => !string.IsNullOrWhiteSpace(ErrorText);
public async Task InitializeAsync()
{
await LoadMessagesAsync();
try
{
var bootstrap = await _apiClient.GetBootstrapAsync(
_launchContext.ApiBaseUrl,
_launchContext.AccessToken,
CancellationToken.None);
await _realtimeClient.ConnectAsync(bootstrap.Ws.Url, _launchContext.AccessToken, CancellationToken.None);
}
catch (Exception exception)
{
ErrorText = exception.Message;
}
}
public async Task SendMessageFromShortcutAsync()
{
if (SendMessageCommand.CanExecute(null))
{
await SendMessageCommand.ExecuteAsync(null);
}
}
public async ValueTask DisposeAsync()
{
await _realtimeClient.DisposeAsync();
}
partial void OnComposerTextChanged(string value) => SendMessageCommand.NotifyCanExecuteChanged();
partial void OnErrorTextChanged(string? value) => OnPropertyChanged(nameof(HasErrorText));
partial void OnConversationTitleChanged(string value) => OnPropertyChanged(nameof(ConversationGlyph));
private async Task LoadMessagesAsync()
{
if (IsBusy)
{
return;
}
try
{
IsBusy = true;
ErrorText = null;
StatusText = "◌";
var items = await _apiClient.GetMessagesAsync(
_launchContext.ApiBaseUrl,
_launchContext.AccessToken,
_launchContext.ConversationId,
CancellationToken.None);
Messages.Clear();
foreach (var item in items.Items.OrderBy(message => message.ServerSequence))
{
Messages.Add(MapMessage(item));
}
StatusText = "●";
}
catch (Exception exception)
{
ErrorText = exception.Message;
StatusText = "×";
}
finally
{
IsBusy = false;
ReloadCommand.NotifyCanExecuteChanged();
SendMessageCommand.NotifyCanExecuteChanged();
}
}
private async Task SendMessageAsync()
{
if (!CanSendMessage())
{
return;
}
var draft = ComposerText.Trim();
var clientMessageId = Guid.NewGuid();
ComposerText = string.Empty;
var pendingMessage = new MessageRowViewModel
{
MessageId = $"pending-{Guid.NewGuid():N}",
ClientMessageId = clientMessageId,
Text = draft,
SenderName = _launchContext.DisplayName,
MetaText = "보내는 중",
IsMine = true,
IsPending = true,
ServerSequence = Messages.Count == 0 ? 1 : Messages[^1].ServerSequence + 1
};
Messages.Add(pendingMessage);
try
{
var committed = await _apiClient.SendTextMessageAsync(
_launchContext.ApiBaseUrl,
_launchContext.AccessToken,
_launchContext.ConversationId,
new PostTextMessageRequest(clientMessageId, draft),
CancellationToken.None);
Messages.Remove(pendingMessage);
UpsertMessage(MapMessage(committed));
StatusText = "●";
}
catch (Exception exception)
{
pendingMessage.IsPending = false;
pendingMessage.IsFailed = true;
pendingMessage.MetaText = "전송 실패";
ErrorText = exception.Message;
}
}
private bool CanSendMessage() => !IsBusy && !string.IsNullOrWhiteSpace(ComposerText);
private void HandleRealtimeConnectionStateChanged(RealtimeConnectionState state)
{
Dispatcher.UIThread.Post(() =>
{
StatusText = state switch
{
RealtimeConnectionState.Connected => "●",
RealtimeConnectionState.Reconnecting => "◔",
RealtimeConnectionState.Disconnected => "○",
RealtimeConnectionState.Connecting => "◌",
_ => StatusText
};
});
}
private void HandleMessageCreated(MessageItemDto payload)
{
if (!string.Equals(payload.ConversationId, _launchContext.ConversationId, StringComparison.Ordinal))
{
return;
}
Dispatcher.UIThread.Post(() => UpsertMessage(MapMessage(payload)));
}
private static MessageRowViewModel MapMessage(MessageItemDto item)
{
return new MessageRowViewModel
{
MessageId = item.MessageId,
ClientMessageId = item.ClientMessageId,
Text = item.Text,
SenderName = item.Sender.DisplayName,
MetaText = item.CreatedAt.LocalDateTime.ToString("HH:mm"),
IsMine = item.IsMine,
ServerSequence = item.ServerSequence
};
}
private void UpsertMessage(MessageRowViewModel next)
{
var existing = Messages.FirstOrDefault(item =>
string.Equals(item.MessageId, next.MessageId, StringComparison.Ordinal) ||
(next.ClientMessageId != Guid.Empty && item.ClientMessageId == next.ClientMessageId));
if (existing is not null)
{
var index = Messages.IndexOf(existing);
Messages[index] = next;
return;
}
Messages.Add(next);
}
}