kotalk/src/PhysOn.Desktop/ViewModels/MainWindowViewModel.cs
ian b54eca25f8
Some checks are pending
ci / server (push) Waiting to run
ci / web (push) Waiting to run
ci / desktop-windows (push) Waiting to run
공개: alpha.11 검색과 전환 개선 반영
2026-04-16 15:37:41 +09:00

1361 lines
53 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 System.Collections.Specialized;
using Avalonia;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PhysOn.Contracts.Auth;
using PhysOn.Contracts.Conversations;
using PhysOn.Contracts.Realtime;
using PhysOn.Desktop.Models;
using PhysOn.Desktop.Services;
namespace PhysOn.Desktop.ViewModels;
public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
{
private const string DefaultApiBaseUrl = "https://vstalk.phy.kr";
private const string DefaultPublicAlphaKey = "ALPHA-OPEN-2026";
private const double DefaultWorkspaceWindowWidth = 1180;
private const double DefaultWorkspaceWindowHeight = 760;
private const double DefaultOnboardingWindowWidth = 420;
private const double DefaultOnboardingWindowHeight = 508;
private const double MinWorkspaceWindowWidth = 840;
private const double MinWorkspaceWindowHeight = 560;
private const double MinOnboardingWindowWidth = 404;
private const double MinOnboardingWindowHeight = 468;
private const double DefaultConversationPaneWidth = 304;
private const double MinConversationPaneWidth = 248;
private const double MaxConversationPaneWidth = 360;
private readonly PhysOnApiClient _apiClient = new();
private readonly SessionStore _sessionStore = new();
private readonly PhysOnRealtimeClient _realtimeClient = new();
private readonly WorkspaceLayoutStore _workspaceLayoutStore;
private readonly IConversationWindowManager _conversationWindowManager;
private readonly Dictionary<string, List<MessageRowViewModel>> _messageCache = new(StringComparer.Ordinal);
private DesktopSession? _session;
private string? _currentUserId;
private bool _isSampleWorkspace;
public MainWindowViewModel()
: this(new ConversationWindowManager(), new WorkspaceLayoutStore())
{
}
public MainWindowViewModel(
IConversationWindowManager conversationWindowManager,
WorkspaceLayoutStore workspaceLayoutStore)
{
_conversationWindowManager = conversationWindowManager;
_workspaceLayoutStore = workspaceLayoutStore;
Messages.CollectionChanged += HandleMessagesCollectionChanged;
SignInCommand = new AsyncRelayCommand(SignInAsync, CanSignIn);
SendMessageCommand = new AsyncRelayCommand(SendMessageAsync, CanSendMessage);
SignOutCommand = new AsyncRelayCommand(SignOutAsync);
ReloadCommand = new AsyncRelayCommand(ReloadAsync, () => IsAuthenticated && !IsBusy);
ToggleAdvancedSettingsCommand = new RelayCommand(() => ShowAdvancedSettings = !ShowAdvancedSettings);
ShowAllConversationsCommand = new RelayCommand(() => SelectedListFilter = "all");
ShowUnreadConversationsCommand = new RelayCommand(() => SelectedListFilter = "unread");
ShowPinnedConversationsCommand = new RelayCommand(() => SelectedListFilter = "pinned");
ApplyAckDraftCommand = new RelayCommand(() => ApplyQuickDraft("확인했습니다."));
ApplyShareDraftCommand = new RelayCommand(() => ApplyQuickDraft("공유드립니다.\n- "));
ApplyTaskDraftCommand = new RelayCommand(() => ApplyQuickDraft("할 일\n- "));
ToggleCompactModeCommand = new RelayCommand(() => IsCompactDensity = !IsCompactDensity);
ToggleInspectorCommand = new RelayCommand(() => IsInspectorVisible = !IsInspectorVisible);
ToggleConversationPaneCommand = new RelayCommand(() => IsConversationPaneCollapsed = !IsConversationPaneCollapsed);
ResetWorkspaceCommand = new RelayCommand(ResetWorkspaceLayout);
DetachConversationCommand = new AsyncRelayCommand(DetachConversationAsync, CanDetachConversation);
DetachConversationRowCommand = new AsyncRelayCommand<ConversationRowViewModel?>(DetachConversationRowAsync, CanDetachConversationRow);
SelectConversationCommand = new RelayCommand<ConversationRowViewModel?>(conversation =>
{
if (conversation is not null)
{
SelectedConversation = conversation;
}
});
_realtimeClient.ConnectionStateChanged += HandleRealtimeConnectionStateChanged;
_realtimeClient.SessionConnected += HandleSessionConnected;
_realtimeClient.MessageCreated += HandleMessageCreated;
_realtimeClient.ReadCursorUpdated += HandleReadCursorUpdated;
_conversationWindowManager.WindowCountChanged += HandleDetachedWindowCountChanged;
}
public ObservableCollection<ConversationRowViewModel> Conversations { get; } = [];
public ObservableCollection<ConversationRowViewModel> FilteredConversations { get; } = [];
public ObservableCollection<MessageRowViewModel> Messages { get; } = [];
public IAsyncRelayCommand SignInCommand { get; }
public IAsyncRelayCommand SendMessageCommand { get; }
public IAsyncRelayCommand SignOutCommand { get; }
public IAsyncRelayCommand ReloadCommand { get; }
public IAsyncRelayCommand DetachConversationCommand { get; }
public IAsyncRelayCommand<ConversationRowViewModel?> DetachConversationRowCommand { get; }
public IRelayCommand ToggleAdvancedSettingsCommand { get; }
public IRelayCommand ShowAllConversationsCommand { get; }
public IRelayCommand ShowUnreadConversationsCommand { get; }
public IRelayCommand ShowPinnedConversationsCommand { get; }
public IRelayCommand ApplyAckDraftCommand { get; }
public IRelayCommand ApplyShareDraftCommand { get; }
public IRelayCommand ApplyTaskDraftCommand { get; }
public IRelayCommand ToggleCompactModeCommand { get; }
public IRelayCommand ToggleInspectorCommand { get; }
public IRelayCommand ToggleConversationPaneCommand { get; }
public IRelayCommand ResetWorkspaceCommand { get; }
public IRelayCommand<ConversationRowViewModel?> SelectConversationCommand { get; }
[ObservableProperty] private string apiBaseUrl = DefaultApiBaseUrl;
[ObservableProperty] private string displayName = string.Empty;
[ObservableProperty] private string inviteCode = string.Empty;
[ObservableProperty] private bool rememberSession = true;
[ObservableProperty] private bool showAdvancedSettings;
[ObservableProperty] private bool isAuthenticated;
[ObservableProperty] private bool isBusy;
[ObservableProperty] private string currentUserDisplayName = "KO";
[ObservableProperty] private string statusLine = string.Empty;
[ObservableProperty] private RealtimeConnectionState realtimeState = RealtimeConnectionState.Idle;
[ObservableProperty] private string realtimeStatusText = "준비";
[ObservableProperty] private string? errorText;
[ObservableProperty] private string conversationSearchText = string.Empty;
[ObservableProperty] private string selectedListFilter = "all";
[ObservableProperty] private string composerText = string.Empty;
[ObservableProperty] private string selectedConversationTitle = "KoTalk";
[ObservableProperty] private string selectedConversationSubtitle = "준비";
[ObservableProperty] private ConversationRowViewModel? selectedConversation;
[ObservableProperty] private bool hasErrorText;
[ObservableProperty] private bool hasFilteredConversations;
[ObservableProperty] private string conversationEmptyStateText = "지금 표시할 대화가 없습니다.";
[ObservableProperty] private bool isCompactDensity = true;
[ObservableProperty] private bool isInspectorVisible;
[ObservableProperty] private bool isConversationPaneCollapsed;
[ObservableProperty] private bool isConversationLoading;
[ObservableProperty] private double conversationPaneWidthValue = DefaultConversationPaneWidth;
[ObservableProperty] private int detachedWindowCount;
[ObservableProperty] private double? windowWidth;
[ObservableProperty] private double? windowHeight;
[ObservableProperty] private bool isWindowMaximized;
public bool ShowOnboarding => !IsAuthenticated;
public bool ShowShell => IsAuthenticated;
public bool IsAllFilterSelected => string.Equals(SelectedListFilter, "all", StringComparison.Ordinal);
public bool IsUnreadFilterSelected => string.Equals(SelectedListFilter, "unread", StringComparison.Ordinal);
public bool IsPinnedFilterSelected => string.Equals(SelectedListFilter, "pinned", StringComparison.Ordinal);
public int TotalConversationCount => Conversations.Count;
public int UnreadConversationCount => Conversations.Count(item => item.UnreadCount > 0);
public int PinnedConversationCount => Conversations.Count(item => item.IsPinned);
public bool ShowConversationEmptyState => !HasFilteredConversations;
public string AdvancedSettingsButtonText => ShowAdvancedSettings ? "옵션 닫기" : "옵션";
public string CurrentUserMonogram =>
string.IsNullOrWhiteSpace(CurrentUserDisplayName) ? "KO" : CurrentUserDisplayName.Trim()[..Math.Min(2, CurrentUserDisplayName.Trim().Length)];
public string AllFilterButtonText => "◎";
public string UnreadFilterButtonText => "●";
public string PinnedFilterButtonText => "★";
public string RealtimeStatusGlyph => RealtimeState switch
{
RealtimeConnectionState.Connected => "●",
RealtimeConnectionState.Connecting => "◌",
RealtimeConnectionState.Reconnecting => "◔",
RealtimeConnectionState.Disconnected => "○",
_ => "·"
};
public string CompactModeGlyph => IsCompactDensity ? "◫" : "◻";
public string DensityGlyph => IsCompactDensity ? "▥" : "▤";
public string InspectorGlyph => IsInspectorVisible ? "▣" : "□";
public string InspectorActionGlyph => IsInspectorVisible ? "▣" : "□";
public string ConversationPaneGlyph => IsConversationPaneCollapsed ? "" : "";
public string PaneActionGlyph => IsConversationPaneCollapsed ? "" : "";
public string SelectedConversationGlyph =>
SelectedConversation is null ? "KO" : SelectedConversation.AvatarText;
public bool HasSelectedConversation => SelectedConversation is not null;
public bool HasSelectedConversationUnread => (SelectedConversation?.UnreadCount ?? 0) > 0;
public string SelectedConversationUnreadBadgeText => (SelectedConversation?.UnreadCount ?? 0) > 99
? "99+"
: (SelectedConversation?.UnreadCount ?? 0).ToString();
public bool SelectedConversationIsPinned => SelectedConversation?.IsPinned ?? false;
public string DetachedWindowBadgeText => DetachedWindowCount > 9 ? "9+" : DetachedWindowCount.ToString();
public string DetachedWindowActionGlyph => HasDetachedWindows ? DetachedWindowBadgeText : "↗";
public bool HasDetachedWindows => DetachedWindowCount > 0;
public bool IsConversationPaneExpanded => !IsConversationPaneCollapsed;
public double ConversationPaneWidth => IsConversationPaneCollapsed ? 0 : ConversationPaneWidthValue;
public double InspectorPaneWidth => IsInspectorVisible ? (IsCompactDensity ? 92 : 108) : 0;
public bool HasPersistedWindowBounds => WindowWidth is > 0 && WindowHeight is > 0;
public Thickness ConversationRowPadding => IsCompactDensity ? new Thickness(6, 5) : new Thickness(8, 6);
public Thickness MessageBubblePadding => IsCompactDensity ? new Thickness(10, 7) : new Thickness(12, 9);
public double ConversationAvatarSize => IsCompactDensity ? 28 : 32;
public double ComposerMinHeight => IsCompactDensity ? 48 : 58;
public string ComposerCounterText => $"{ComposerText.Trim().Length}";
public string SearchWatermark => "검색";
public bool HasConversationSearchText => !string.IsNullOrWhiteSpace(ConversationSearchText);
public string InspectorStatusText => HasDetachedWindows
? $"{RealtimeStatusGlyph} {DetachedWindowBadgeText}"
: RealtimeStatusGlyph;
public string WorkspaceModeText => HasDetachedWindows ? $"분리 창 {DetachedWindowBadgeText}" : "단일 창";
public string StatusSummaryText => string.IsNullOrWhiteSpace(StatusLine) ? RealtimeStatusText : StatusLine;
public string ComposerPlaceholderText => HasSelectedConversation ? "메시지" : "대화 선택";
public string ComposerActionText => Messages.Count == 0 ? "시작" : "보내기";
public bool ShowMessageEmptyState => Messages.Count == 0 && !IsConversationLoading;
public bool ShowConversationLoadingState => HasSelectedConversation && IsConversationLoading;
public string MessageEmptyStateTitle => HasSelectedConversation ? "첫 메시지" : "대화 선택";
public string MessageEmptyStateText => HasSelectedConversation
? "짧게 남기세요."
: "목록에서 선택";
public (double Width, double Height, bool IsMaximized) GetSuggestedWindowLayout()
{
if (HasPersistedWindowBounds)
{
return (WindowWidth!.Value, WindowHeight!.Value, IsWindowMaximized);
}
return ShowOnboarding
? (DefaultOnboardingWindowWidth, DefaultOnboardingWindowHeight, false)
: (DefaultWorkspaceWindowWidth, DefaultWorkspaceWindowHeight, false);
}
public (double MinWidth, double MinHeight) GetSuggestedWindowConstraints()
{
return ShowOnboarding
? (MinOnboardingWindowWidth, MinOnboardingWindowHeight)
: (MinWorkspaceWindowWidth, MinWorkspaceWindowHeight);
}
public async Task InitializeAsync()
{
if (string.Equals(Environment.GetEnvironmentVariable("KOTALK_DESKTOP_SAMPLE_MODE"), "1", StringComparison.Ordinal))
{
LoadSampleWorkspace();
return;
}
var workspaceLayout = await _workspaceLayoutStore.LoadAsync();
if (workspaceLayout is not null)
{
ApplyWorkspaceLayout(workspaceLayout);
}
var storedSession = await _sessionStore.LoadAsync();
if (storedSession is null)
{
return;
}
ApiBaseUrl = storedSession.ApiBaseUrl;
_session = storedSession;
await RestoreSessionAsync(storedSession);
}
public async Task SendMessageFromShortcutAsync()
{
if (SendMessageCommand.CanExecute(null))
{
await SendMessageCommand.ExecuteAsync(null);
}
}
public async Task OpenDetachedConversationFromShortcutAsync()
{
if (DetachConversationCommand.CanExecute(null))
{
await DetachConversationCommand.ExecuteAsync(null);
}
}
public void ClearSearch()
{
if (!string.IsNullOrWhiteSpace(ConversationSearchText))
{
ConversationSearchText = string.Empty;
}
}
public async Task ActivateSearchResultAsync(bool detach)
{
if (!HasConversationSearchText)
{
return;
}
var target = FilteredConversations.FirstOrDefault();
if (target is null)
{
return;
}
if (detach)
{
await DetachConversationRowAsync(target);
return;
}
SelectedConversation = target;
ClearSearch();
}
partial void OnIsAuthenticatedChanged(bool value)
{
OnPropertyChanged(nameof(ShowOnboarding));
OnPropertyChanged(nameof(ShowShell));
ReloadCommand.NotifyCanExecuteChanged();
SendMessageCommand.NotifyCanExecuteChanged();
DetachConversationCommand.NotifyCanExecuteChanged();
DetachConversationRowCommand.NotifyCanExecuteChanged();
}
partial void OnApiBaseUrlChanged(string value) => SignInCommand.NotifyCanExecuteChanged();
partial void OnDisplayNameChanged(string value) => SignInCommand.NotifyCanExecuteChanged();
partial void OnInviteCodeChanged(string value) => SignInCommand.NotifyCanExecuteChanged();
partial void OnShowAdvancedSettingsChanged(bool value) => OnPropertyChanged(nameof(AdvancedSettingsButtonText));
partial void OnConversationSearchTextChanged(string value)
{
OnPropertyChanged(nameof(HasConversationSearchText));
RefreshConversationFilter();
}
partial void OnSelectedListFilterChanged(string value)
{
OnPropertyChanged(nameof(IsAllFilterSelected));
OnPropertyChanged(nameof(IsUnreadFilterSelected));
OnPropertyChanged(nameof(IsPinnedFilterSelected));
RefreshConversationFilter();
}
partial void OnErrorTextChanged(string? value) => HasErrorText = !string.IsNullOrWhiteSpace(value);
partial void OnHasFilteredConversationsChanged(bool value) => OnPropertyChanged(nameof(ShowConversationEmptyState));
partial void OnStatusLineChanged(string value) => OnPropertyChanged(nameof(StatusSummaryText));
partial void OnComposerTextChanged(string value)
{
SendMessageCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(ComposerCounterText));
}
partial void OnRealtimeStatusTextChanged(string value) => OnPropertyChanged(nameof(StatusSummaryText));
partial void OnRealtimeStateChanged(RealtimeConnectionState value)
{
OnPropertyChanged(nameof(RealtimeStatusGlyph));
OnPropertyChanged(nameof(InspectorStatusText));
}
partial void OnIsCompactDensityChanged(bool value)
{
OnPropertyChanged(nameof(CompactModeGlyph));
OnPropertyChanged(nameof(DensityGlyph));
OnPropertyChanged(nameof(ConversationPaneWidth));
OnPropertyChanged(nameof(InspectorPaneWidth));
OnPropertyChanged(nameof(ConversationRowPadding));
OnPropertyChanged(nameof(MessageBubblePadding));
OnPropertyChanged(nameof(ConversationAvatarSize));
OnPropertyChanged(nameof(ComposerMinHeight));
_ = PersistWorkspaceLayoutAsync();
}
partial void OnIsInspectorVisibleChanged(bool value)
{
OnPropertyChanged(nameof(InspectorGlyph));
OnPropertyChanged(nameof(InspectorActionGlyph));
OnPropertyChanged(nameof(InspectorPaneWidth));
_ = PersistWorkspaceLayoutAsync();
}
partial void OnConversationPaneWidthValueChanged(double value)
{
OnPropertyChanged(nameof(ConversationPaneWidth));
_ = PersistWorkspaceLayoutAsync();
}
partial void OnIsConversationPaneCollapsedChanged(bool value)
{
OnPropertyChanged(nameof(ConversationPaneGlyph));
OnPropertyChanged(nameof(PaneActionGlyph));
OnPropertyChanged(nameof(IsConversationPaneExpanded));
OnPropertyChanged(nameof(ConversationPaneWidth));
OnPropertyChanged(nameof(SearchWatermark));
_ = PersistWorkspaceLayoutAsync();
}
partial void OnDetachedWindowCountChanged(int value)
{
OnPropertyChanged(nameof(DetachedWindowBadgeText));
OnPropertyChanged(nameof(DetachedWindowActionGlyph));
OnPropertyChanged(nameof(HasDetachedWindows));
OnPropertyChanged(nameof(InspectorStatusText));
OnPropertyChanged(nameof(WorkspaceModeText));
}
partial void OnIsConversationLoadingChanged(bool value)
{
OnPropertyChanged(nameof(ShowConversationLoadingState));
NotifyMessageStateChanged();
}
partial void OnSelectedConversationChanged(ConversationRowViewModel? value)
{
UpdateSelectedConversationState(value?.ConversationId);
SelectedConversationTitle = value?.Title ?? "KoTalk";
SelectedConversationSubtitle = value?.Subtitle ?? "대화";
OnPropertyChanged(nameof(SelectedConversationGlyph));
OnPropertyChanged(nameof(HasSelectedConversation));
OnPropertyChanged(nameof(HasSelectedConversationUnread));
OnPropertyChanged(nameof(SelectedConversationUnreadBadgeText));
OnPropertyChanged(nameof(SelectedConversationIsPinned));
OnPropertyChanged(nameof(ComposerPlaceholderText));
NotifyMessageStateChanged();
SendMessageCommand.NotifyCanExecuteChanged();
DetachConversationCommand.NotifyCanExecuteChanged();
_ = HandleSelectedConversationChangedAsync(value);
}
private async Task SignInAsync()
{
await RunBusyAsync(async () =>
{
var apiBaseUrl = ResolveApiBaseUrl();
var request = new RegisterAlphaQuickRequest(
DisplayName.Trim(),
InviteCode.Trim() is { Length: > 0 } inviteCode ? inviteCode : DefaultPublicAlphaKey,
new DeviceRegistrationDto(
$"desktop-{Environment.MachineName.ToLowerInvariant()}",
"windows",
Environment.MachineName,
"0.1.0-alpha.11"));
var response = await _apiClient.RegisterAlphaQuickAsync(apiBaseUrl, request, CancellationToken.None);
ApiBaseUrl = apiBaseUrl;
_session = new DesktopSession(
apiBaseUrl,
response.Tokens.AccessToken,
response.Tokens.RefreshToken,
response.Account.DisplayName,
response.Bootstrap.Conversations.Items.FirstOrDefault()?.ConversationId);
if (RememberSession)
{
await _sessionStore.SaveAsync(_session);
}
ApplyBootstrap(response.Bootstrap, response.Account.DisplayName, _session.LastConversationId);
await StartRealtimeAsync(response.Bootstrap.Ws.Url, _session.AccessToken);
StatusLine = "준비";
NotifyMessageStateChanged();
});
}
private async Task ReloadAsync()
{
if (_session is null)
{
return;
}
await RestoreSessionAsync(_session);
}
private async Task SignOutAsync()
{
await _realtimeClient.DisconnectAsync();
_session = null;
_currentUserId = null;
await _sessionStore.ClearAsync();
Conversations.Clear();
FilteredConversations.Clear();
Messages.Clear();
_messageCache.Clear();
IsAuthenticated = false;
IsConversationLoading = false;
_isSampleWorkspace = false;
CurrentUserDisplayName = "KO";
StatusLine = string.Empty;
RealtimeState = RealtimeConnectionState.Idle;
RealtimeStatusText = "준비";
ErrorText = null;
ApiBaseUrl = DefaultApiBaseUrl;
ConversationSearchText = string.Empty;
SelectedListFilter = "all";
SelectedConversation = null;
SelectedConversationTitle = "KoTalk";
SelectedConversationSubtitle = "준비";
NotifyConversationMetricsChanged();
}
private async Task HandleSelectedConversationChangedAsync(ConversationRowViewModel? value)
{
if (!IsAuthenticated || value is null || _session is null)
{
IsConversationLoading = false;
return;
}
var conversationId = value.ConversationId;
if (_isSampleWorkspace)
{
if (_messageCache.TryGetValue(conversationId, out var sampleItems))
{
ReplaceMessages(sampleItems);
}
IsConversationLoading = false;
return;
}
if (_messageCache.TryGetValue(conversationId, out var cachedItems))
{
ReplaceMessages(cachedItems);
}
else
{
Messages.Clear();
NotifyMessageStateChanged();
}
IsConversationLoading = true;
try
{
await RunBusyAsync(async () =>
{
var items = await _apiClient.GetMessagesAsync(_session.ApiBaseUrl, _session.AccessToken, conversationId, CancellationToken.None);
var mappedItems = items.Items.Select(MapMessage).ToList();
if (!string.Equals(SelectedConversation?.ConversationId, conversationId, StringComparison.Ordinal))
{
return;
}
ReplaceMessages(mappedItems);
_messageCache[conversationId] = CloneMessages(mappedItems);
if (Messages.Count > 0)
{
var lastSequence = Messages[^1].ServerSequence;
value.LastReadSequence = lastSequence;
value.UnreadCount = 0;
await _apiClient.UpdateReadCursorAsync(
_session.ApiBaseUrl,
_session.AccessToken,
conversationId,
new UpdateReadCursorRequest(lastSequence),
CancellationToken.None);
}
NotifyConversationMetricsChanged();
NotifyMessageStateChanged();
RefreshConversationFilter(conversationId);
if (_session is not null)
{
_session = _session with { LastConversationId = conversationId };
if (RememberSession)
{
await _sessionStore.SaveAsync(_session);
}
}
}, clearMessages: false);
}
finally
{
if (string.Equals(SelectedConversation?.ConversationId, conversationId, StringComparison.Ordinal))
{
IsConversationLoading = false;
}
}
}
private async Task SendMessageAsync()
{
if (_session is null || SelectedConversation is null || string.IsNullOrWhiteSpace(ComposerText))
{
return;
}
var draft = ComposerText.Trim();
var clientMessageId = Guid.NewGuid();
ComposerText = string.Empty;
var pending = new MessageRowViewModel
{
MessageId = $"pending-{Guid.NewGuid():N}",
ClientMessageId = clientMessageId,
Text = draft,
SenderName = CurrentUserDisplayName,
MetaText = "전송 중",
IsMine = true,
IsPending = true,
ServerSequence = Messages.Count == 0 ? 1 : Messages[^1].ServerSequence + 1
};
Messages.Add(pending);
NotifyMessageStateChanged();
try
{
var sent = await _apiClient.SendTextMessageAsync(
_session.ApiBaseUrl,
_session.AccessToken,
SelectedConversation.ConversationId,
new PostTextMessageRequest(clientMessageId, draft),
CancellationToken.None);
Messages.Remove(pending);
var committed = MapMessage(sent);
UpsertMessage(committed);
UpdateConversationAfterMessage(sent);
StatusLine = "전송";
NotifyMessageStateChanged();
}
catch (Exception)
{
pending.IsPending = false;
pending.IsFailed = true;
pending.MetaText = "실패";
ErrorText = "메시지를 보내지 못했습니다.";
NotifyMessageStateChanged();
}
}
private async Task RestoreSessionAsync(DesktopSession session)
{
await RunBusyAsync(async () =>
{
var bootstrap = await _apiClient.GetBootstrapAsync(session.ApiBaseUrl, session.AccessToken, CancellationToken.None);
_session = session;
ApplyBootstrap(bootstrap, session.DisplayName, session.LastConversationId);
await StartRealtimeAsync(bootstrap.Ws.Url, session.AccessToken);
StatusLine = "복원";
NotifyMessageStateChanged();
});
}
private async Task DetachConversationAsync()
{
if (_session is null || SelectedConversation is null)
{
return;
}
await ShowDetachedConversationAsync(SelectedConversation);
StatusLine = "분리";
}
private async Task DetachConversationRowAsync(ConversationRowViewModel? conversation)
{
if (conversation is null || !CanDetachConversationRow(conversation))
{
return;
}
SelectedConversation = conversation;
await ShowDetachedConversationAsync(conversation);
StatusLine = "분리";
}
private void ApplyBootstrap(BootstrapResponse bootstrap, string displayName, string? preferredConversationId)
{
_isSampleWorkspace = false;
_currentUserId = bootstrap.Me.UserId;
CurrentUserDisplayName = displayName;
_messageCache.Clear();
Messages.Clear();
IsConversationLoading = false;
Conversations.Clear();
foreach (var item in bootstrap.Conversations.Items)
{
Conversations.Add(new ConversationRowViewModel
{
ConversationId = item.ConversationId,
Title = item.Title,
Subtitle = item.Subtitle,
LastMessageText = item.LastMessage?.Text ?? string.Empty,
MetaText = FormatConversationMeta(item),
UnreadCount = item.UnreadCount,
IsPinned = item.IsPinned,
LastReadSequence = item.LastReadSequence,
SortKey = item.SortKey
});
}
IsAuthenticated = true;
ErrorText = null;
OnPropertyChanged(nameof(CurrentUserMonogram));
NotifyConversationMetricsChanged();
NotifyMessageStateChanged();
RefreshConversationFilter(preferredConversationId);
var target = FilteredConversations.FirstOrDefault(x => x.ConversationId == preferredConversationId)
?? FilteredConversations.FirstOrDefault()
?? Conversations.FirstOrDefault();
if (target is not null)
{
SelectedConversation = target;
}
}
private bool CanSignIn() =>
!IsBusy &&
!string.IsNullOrWhiteSpace(DisplayName);
private bool CanSendMessage() =>
!IsBusy &&
IsAuthenticated &&
SelectedConversation is not null &&
!string.IsNullOrWhiteSpace(ComposerText);
private bool CanDetachConversation() =>
!IsBusy &&
IsAuthenticated &&
SelectedConversation is not null;
private bool CanDetachConversationRow(ConversationRowViewModel? conversation) =>
!IsBusy &&
IsAuthenticated &&
conversation is not null;
private string ResolveApiBaseUrl()
{
var trimmed = ApiBaseUrl.Trim();
return string.IsNullOrWhiteSpace(trimmed) ? DefaultApiBaseUrl : trimmed;
}
private async Task ShowDetachedConversationAsync(ConversationRowViewModel conversation)
{
if (_session is null)
{
return;
}
await _conversationWindowManager.ShowOrFocusAsync(new ConversationWindowLaunch(
_session.ApiBaseUrl,
_session.AccessToken,
CurrentUserDisplayName,
conversation.ConversationId,
conversation.Title,
conversation.Subtitle));
}
private void RefreshConversationFilter(string? preferredConversationId = null)
{
var search = ConversationSearchText.Trim();
var hasSearch = !string.IsNullOrWhiteSpace(search);
var filtered = Conversations
.Where(PassesSelectedFilter)
.Where(item => !hasSearch || MatchesConversationSearch(item, search))
.ToList();
FilteredConversations.Clear();
foreach (var item in filtered)
{
FilteredConversations.Add(item);
}
HasFilteredConversations = filtered.Count > 0;
ConversationEmptyStateText = !hasSearch
? (SelectedListFilter switch
{
"unread" => "안읽음 대화가 없습니다.",
"pinned" => "고정한 대화가 없습니다.",
_ => "받은함이 비어 있습니다."
})
: "검색 결과가 없습니다.";
var targetId = preferredConversationId ?? SelectedConversation?.ConversationId;
var target = !string.IsNullOrWhiteSpace(targetId)
? FilteredConversations.FirstOrDefault(item => item.ConversationId == targetId)
: null;
if (target is null)
{
target = hasSearch
? null
: FilteredConversations.FirstOrDefault();
}
if (hasSearch && preferredConversationId is null && target is null)
{
UpdateSelectedConversationState(SelectedConversation?.ConversationId);
NotifyMessageStateChanged();
return;
}
if (!ReferenceEquals(SelectedConversation, target))
{
SelectedConversation = target;
}
else
{
UpdateSelectedConversationState(target?.ConversationId);
NotifyMessageStateChanged();
}
}
private bool PassesSelectedFilter(ConversationRowViewModel item)
{
return SelectedListFilter switch
{
"unread" => item.UnreadCount > 0,
"pinned" => item.IsPinned,
_ => true
};
}
private bool MatchesConversationSearch(ConversationRowViewModel item, string search)
{
if (item.Title.Contains(search, StringComparison.CurrentCultureIgnoreCase) ||
item.LastMessageText.Contains(search, StringComparison.CurrentCultureIgnoreCase) ||
item.Subtitle.Contains(search, StringComparison.CurrentCultureIgnoreCase))
{
return true;
}
return _messageCache.TryGetValue(item.ConversationId, out var cachedItems) &&
cachedItems.Any(message =>
message.Text.Contains(search, StringComparison.CurrentCultureIgnoreCase) ||
message.SenderName.Contains(search, StringComparison.CurrentCultureIgnoreCase));
}
private void UpdateSelectedConversationState(string? conversationId)
{
foreach (var item in Conversations)
{
item.IsSelected = !string.IsNullOrWhiteSpace(conversationId) &&
string.Equals(item.ConversationId, conversationId, StringComparison.Ordinal);
}
}
private void NotifyConversationMetricsChanged()
{
OnPropertyChanged(nameof(TotalConversationCount));
OnPropertyChanged(nameof(UnreadConversationCount));
OnPropertyChanged(nameof(PinnedConversationCount));
}
public void UpdateConversationPaneWidth(double width)
{
if (IsConversationPaneCollapsed)
{
return;
}
var clamped = Math.Clamp(Math.Round(width), MinConversationPaneWidth, MaxConversationPaneWidth);
if (Math.Abs(clamped - ConversationPaneWidthValue) > 1)
{
ConversationPaneWidthValue = clamped;
}
}
public void CaptureWindowLayout(double width, double height, bool maximized)
{
if (width > 0)
{
WindowWidth = Math.Round(width);
}
if (height > 0)
{
WindowHeight = Math.Round(height);
}
IsWindowMaximized = maximized;
_ = PersistWorkspaceLayoutAsync();
}
private void ApplyQuickDraft(string template)
{
ComposerText = string.IsNullOrWhiteSpace(ComposerText)
? template
: ComposerText.TrimEnd() + Environment.NewLine + template;
}
private void LoadSampleWorkspace()
{
_isSampleWorkspace = true;
_messageCache.Clear();
Conversations.Clear();
FilteredConversations.Clear();
Messages.Clear();
CurrentUserDisplayName = "이안";
DisplayName = "이안";
InviteCode = string.Empty;
_currentUserId = "sample-user";
_session = new DesktopSession(
DefaultApiBaseUrl,
"sample-access",
"sample-refresh",
CurrentUserDisplayName,
"sample-ops");
RealtimeState = RealtimeConnectionState.Connected;
RealtimeStatusText = "연결됨";
StatusLine = "준비";
IsAuthenticated = true;
IsCompactDensity = true;
IsInspectorVisible = false;
IsConversationPaneCollapsed = false;
ConversationPaneWidthValue = 348;
DetachedWindowCount = 1;
ErrorText = null;
var now = DateTimeOffset.Now;
Conversations.Add(new ConversationRowViewModel
{
ConversationId = "sample-ops",
Title = "제품 운영",
Subtitle = "레이아웃 검수 메모를 확인해 주세요.",
LastMessageText = "레이아웃 검수 메모를 확인해 주세요.",
MetaText = FormatConversationMeta(now.AddMinutes(-5), 2),
UnreadCount = 2,
IsPinned = true,
LastReadSequence = 12,
SortKey = now.AddMinutes(-5)
});
Conversations.Add(new ConversationRowViewModel
{
ConversationId = "sample-review",
Title = "디자인 리뷰",
Subtitle = "오후 2시에 포인트만 다시 볼게요.",
LastMessageText = "오후 2시에 포인트만 다시 볼게요.",
MetaText = FormatConversationMeta(now.AddMinutes(-22), 0),
UnreadCount = 0,
IsPinned = false,
LastReadSequence = 5,
SortKey = now.AddMinutes(-22)
});
Conversations.Add(new ConversationRowViewModel
{
ConversationId = "sample-friends",
Title = "주말 약속",
Subtitle = "브런치 장소만 정하면 끝.",
LastMessageText = "브런치 장소만 정하면 끝.",
MetaText = FormatConversationMeta(now.AddMinutes(-54), 0),
UnreadCount = 0,
IsPinned = false,
LastReadSequence = 3,
SortKey = now.AddMinutes(-54)
});
Conversations.Add(new ConversationRowViewModel
{
ConversationId = "sample-team",
Title = "운영 팀",
Subtitle = "오후 공유본만 마지막으로 확인해 주세요.",
LastMessageText = "오후 공유본만 마지막으로 확인해 주세요.",
MetaText = FormatConversationMeta(now.AddHours(-2), 1),
UnreadCount = 1,
IsPinned = false,
LastReadSequence = 7,
SortKey = now.AddHours(-2)
});
Conversations.Add(new ConversationRowViewModel
{
ConversationId = "sample-files",
Title = "자료 모음",
Subtitle = "최신 캡처와 빌드 경로를 정리해 두었습니다.",
LastMessageText = "최신 캡처와 빌드 경로를 정리해 두었습니다.",
MetaText = FormatConversationMeta(now.AddHours(-5), 0),
UnreadCount = 0,
IsPinned = true,
LastReadSequence = 9,
SortKey = now.AddHours(-5)
});
NotifyConversationMetricsChanged();
RefreshConversationFilter("sample-ops");
SelectedConversation = Conversations.FirstOrDefault(item => item.ConversationId == "sample-ops");
Messages.Clear();
foreach (var item in new[]
{
new MessageRowViewModel
{
MessageId = "sample-msg-1",
SenderName = "민지",
Text = "회의 전에 레이아웃 이슈만 짧게 정리해 주세요.",
MetaText = "08:54",
IsMine = false,
ServerSequence = 13
},
new MessageRowViewModel
{
MessageId = "sample-msg-2",
SenderName = "이안",
Text = "리스트 폭을 다시 줄이고 우측 빈 패널도 없앴어요.",
MetaText = "08:56",
IsMine = true,
ServerSequence = 14
},
new MessageRowViewModel
{
MessageId = "sample-msg-3",
SenderName = "민지",
Text = "좋아요. 지금 화면이면 검수하기 좋겠네요.",
MetaText = "08:58",
IsMine = false,
ServerSequence = 15
},
new MessageRowViewModel
{
MessageId = "sample-msg-4",
SenderName = "이안",
Text = "스크린샷 기준으로 밀도도 같이 맞췄습니다.",
MetaText = "09:05",
IsMine = true,
ServerSequence = 16
},
new MessageRowViewModel
{
MessageId = "sample-msg-5",
SenderName = "민지",
Text = "좋아요. 확인 흐름이 더 짧아졌어요.",
MetaText = "09:06",
IsMine = false,
ServerSequence = 17
},
new MessageRowViewModel
{
MessageId = "sample-msg-6",
SenderName = "이안",
Text = "분리 창은 상단 액션으로 남겨 두었습니다.",
MetaText = "09:07",
IsMine = true,
ServerSequence = 18
},
new MessageRowViewModel
{
MessageId = "sample-msg-7",
SenderName = "민지",
Text = "이 정도면 데스크톱 검수용 화면으로 충분하겠네요.",
MetaText = "09:08",
IsMine = false,
ServerSequence = 19
},
new MessageRowViewModel
{
MessageId = "sample-msg-8",
SenderName = "이안",
Text = "검색과 필터는 한 줄 안에서 끝나도록 다시 정리할게요.",
MetaText = "09:10",
IsMine = true,
ServerSequence = 20
},
new MessageRowViewModel
{
MessageId = "sample-msg-9",
SenderName = "민지",
Text = "좋아요. 설명보다 눌리는 구조가 더 중요해요.",
MetaText = "09:11",
IsMine = false,
ServerSequence = 21
},
new MessageRowViewModel
{
MessageId = "sample-msg-10",
SenderName = "이안",
Text = "작성창도 짧은 액션만 남기고 텍스트는 줄였습니다.",
MetaText = "09:12",
IsMine = true,
ServerSequence = 22
},
new MessageRowViewModel
{
MessageId = "sample-msg-11",
SenderName = "민지",
Text = "이제 목록과 대화가 한 화면에서 훨씬 빠르게 읽히네요.",
MetaText = "09:13",
IsMine = false,
ServerSequence = 23
}
})
{
Messages.Add(item);
}
_messageCache["sample-ops"] = CloneMessages(Messages);
OnPropertyChanged(nameof(CurrentUserMonogram));
NotifyMessageStateChanged();
}
private void HandleMessagesCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
NotifyMessageStateChanged();
}
private void NotifyMessageStateChanged()
{
OnPropertyChanged(nameof(ShowMessageEmptyState));
OnPropertyChanged(nameof(ShowConversationLoadingState));
OnPropertyChanged(nameof(MessageEmptyStateTitle));
OnPropertyChanged(nameof(MessageEmptyStateText));
OnPropertyChanged(nameof(ComposerPlaceholderText));
OnPropertyChanged(nameof(ComposerActionText));
}
private async Task StartRealtimeAsync(string wsUrl, string accessToken)
{
await _realtimeClient.ConnectAsync(wsUrl, accessToken, CancellationToken.None);
}
private async Task RunBusyAsync(Func<Task> action, bool clearMessages = true)
{
if (IsBusy)
{
return;
}
try
{
IsBusy = true;
ErrorText = null;
if (clearMessages)
{
StatusLine = "동기화";
}
await action();
}
catch (Exception exception)
{
ErrorText = exception.Message;
if (clearMessages)
{
Messages.Clear();
}
}
finally
{
IsBusy = false;
SignInCommand.NotifyCanExecuteChanged();
SendMessageCommand.NotifyCanExecuteChanged();
ReloadCommand.NotifyCanExecuteChanged();
DetachConversationCommand.NotifyCanExecuteChanged();
DetachConversationRowCommand.NotifyCanExecuteChanged();
}
}
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:HH:mm}",
IsMine = item.IsMine,
ServerSequence = item.ServerSequence
};
}
private static string FormatConversationMeta(ConversationSummaryDto item)
{
return FormatConversationMeta(item.LastMessage?.CreatedAt ?? item.SortKey, item.UnreadCount);
}
private static string FormatConversationMeta(DateTimeOffset timestamp, int unreadCount)
{
var timeText = timestamp.LocalDateTime.ToString("HH:mm");
return unreadCount > 0 ? $"{timeText} · {unreadCount}" : timeText;
}
private void HandleRealtimeConnectionStateChanged(RealtimeConnectionState state)
{
Dispatcher.UIThread.Post(() =>
{
RealtimeState = state;
RealtimeStatusText = state switch
{
RealtimeConnectionState.Connecting => "동기화",
RealtimeConnectionState.Connected => "연결됨",
RealtimeConnectionState.Reconnecting => "다시 연결",
RealtimeConnectionState.Disconnected => "오프라인",
_ => "준비"
};
});
}
private void HandleSessionConnected(SessionConnectedDto payload)
{
Dispatcher.UIThread.Post(() =>
{
RealtimeState = RealtimeConnectionState.Connected;
RealtimeStatusText = "연결됨";
});
}
private void HandleMessageCreated(MessageItemDto payload)
{
Dispatcher.UIThread.Post(() =>
{
UpdateConversationAfterMessage(payload);
if (SelectedConversation?.ConversationId == payload.ConversationId)
{
UpsertMessage(MapMessage(payload));
}
});
}
private void HandleReadCursorUpdated(ReadCursorUpdatedDto payload)
{
Dispatcher.UIThread.Post(() =>
{
if (_currentUserId is null || !string.Equals(payload.AccountId, _currentUserId, StringComparison.OrdinalIgnoreCase))
{
return;
}
var conversation = Conversations.FirstOrDefault(item => item.ConversationId == payload.ConversationId);
if (conversation is null)
{
return;
}
conversation.LastReadSequence = payload.LastReadSequence;
conversation.UnreadCount = 0;
conversation.MetaText = FormatConversationMeta(conversation.SortKey, 0);
NotifyConversationMetricsChanged();
RefreshConversationFilter(conversation.ConversationId);
});
}
private void HandleDetachedWindowCountChanged(int count)
{
Dispatcher.UIThread.Post(() => DetachedWindowCount = count);
}
private void UpdateConversationAfterMessage(MessageItemDto payload)
{
var conversation = Conversations.FirstOrDefault(item => item.ConversationId == payload.ConversationId);
if (conversation is null)
{
return;
}
conversation.LastMessageText = payload.Text;
conversation.SortKey = payload.CreatedAt;
conversation.LastReadSequence = payload.IsMine
? Math.Max(conversation.LastReadSequence, payload.ServerSequence)
: conversation.LastReadSequence;
conversation.UnreadCount = payload.IsMine
? 0
: (SelectedConversation?.ConversationId == payload.ConversationId
? conversation.UnreadCount
: Math.Max(conversation.UnreadCount + 1, 1));
conversation.MetaText = FormatConversationMeta(conversation.SortKey, conversation.UnreadCount);
NotifyConversationMetricsChanged();
ReorderConversations(conversation.ConversationId);
}
private void UpsertMessage(MessageRowViewModel next)
{
var items = Messages.ToList();
var existingIndex = items.FindIndex(item =>
string.Equals(item.MessageId, next.MessageId, StringComparison.Ordinal) ||
(next.ClientMessageId != Guid.Empty && item.ClientMessageId == next.ClientMessageId));
if (existingIndex >= 0)
{
items[existingIndex] = next;
}
else
{
items.Add(next);
}
var ordered = items
.OrderBy(item => item.ServerSequence)
.ThenBy(item => item.IsPending ? 1 : 0)
.ToList();
Messages.Clear();
foreach (var item in ordered)
{
Messages.Add(item);
}
if (SelectedConversation is not null)
{
_messageCache[SelectedConversation.ConversationId] = CloneMessages(ordered);
}
}
private void ReplaceMessages(IEnumerable<MessageRowViewModel> items)
{
Messages.Clear();
foreach (var item in items)
{
Messages.Add(CloneMessage(item));
}
}
private static List<MessageRowViewModel> CloneMessages(IEnumerable<MessageRowViewModel> items) =>
items.Select(CloneMessage).ToList();
private static MessageRowViewModel CloneMessage(MessageRowViewModel item)
{
return new MessageRowViewModel
{
MessageId = item.MessageId,
ClientMessageId = item.ClientMessageId,
Text = item.Text,
SenderName = item.SenderName,
MetaText = item.MetaText,
IsMine = item.IsMine,
IsPending = item.IsPending,
IsFailed = item.IsFailed,
ServerSequence = item.ServerSequence
};
}
private void ReorderConversations(string? selectedConversationId)
{
var ordered = Conversations
.OrderByDescending(item => item.IsPinned)
.ThenByDescending(item => item.SortKey)
.ToList();
Conversations.Clear();
foreach (var item in ordered)
{
Conversations.Add(item);
}
RefreshConversationFilter(selectedConversationId);
}
private void ResetWorkspaceLayout()
{
IsCompactDensity = true;
IsInspectorVisible = false;
IsConversationPaneCollapsed = false;
ConversationPaneWidthValue = DefaultConversationPaneWidth;
WindowWidth = null;
WindowHeight = null;
IsWindowMaximized = false;
StatusLine = "초기화";
}
private void ApplyWorkspaceLayout(DesktopWorkspaceLayout layout)
{
IsCompactDensity = layout.IsCompactDensity;
IsInspectorVisible = layout.IsInspectorVisible;
IsConversationPaneCollapsed = layout.IsConversationPaneCollapsed;
ConversationPaneWidthValue = Math.Clamp(layout.ConversationPaneWidth, MinConversationPaneWidth, MaxConversationPaneWidth);
WindowWidth = layout.WindowWidth;
WindowHeight = layout.WindowHeight;
IsWindowMaximized = layout.IsWindowMaximized;
}
private Task PersistWorkspaceLayoutAsync()
{
return _workspaceLayoutStore.SaveAsync(new DesktopWorkspaceLayout(
IsCompactDensity,
IsInspectorVisible,
IsConversationPaneCollapsed,
ConversationPaneWidthValue,
WindowWidth,
WindowHeight,
IsWindowMaximized));
}
public async ValueTask DisposeAsync()
{
Messages.CollectionChanged -= HandleMessagesCollectionChanged;
_conversationWindowManager.WindowCountChanged -= HandleDetachedWindowCountChanged;
_realtimeClient.ConnectionStateChanged -= HandleRealtimeConnectionStateChanged;
_realtimeClient.SessionConnected -= HandleSessionConnected;
_realtimeClient.MessageCreated -= HandleMessageCreated;
_realtimeClient.ReadCursorUpdated -= HandleReadCursorUpdated;
await _realtimeClient.DisposeAsync();
}
}