kotalk/src/PhysOn.Desktop/ViewModels/ConversationWindowViewModel.cs

224 lines
7.2 KiB
C#
Raw Normal View History

2026-04-16 09:24:26 +09:00
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);
}
}