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

1164 lines
46 KiB
C#
Raw Normal View History

2026-04-16 09:24:26 +09:00
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 readonly PhysOnApiClient _apiClient = new();
private readonly SessionStore _sessionStore = new();
private readonly PhysOnRealtimeClient _realtimeClient = new();
private readonly WorkspaceLayoutStore _workspaceLayoutStore;
private readonly IConversationWindowManager _conversationWindowManager;
private DesktopSession? _session;
private string? _currentUserId;
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");
2026-04-16 11:14:22 +09:00
ApplyAckDraftCommand = new RelayCommand(() => ApplyQuickDraft("확인했습니다."));
ApplyShareDraftCommand = new RelayCommand(() => ApplyQuickDraft("공유드립니다.\n- "));
ApplyTaskDraftCommand = new RelayCommand(() => ApplyQuickDraft("할 일\n- "));
2026-04-16 09:24:26 +09:00
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; }
2026-04-16 11:14:22 +09:00
public IRelayCommand ApplyAckDraftCommand { get; }
public IRelayCommand ApplyShareDraftCommand { get; }
public IRelayCommand ApplyTaskDraftCommand { get; }
2026-04-16 09:24:26 +09:00
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";
2026-04-16 11:14:22 +09:00
[ObservableProperty] private string selectedConversationSubtitle = "준비";
2026-04-16 09:24:26 +09:00
[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;
2026-04-16 11:14:22 +09:00
[ObservableProperty] private double conversationPaneWidthValue = 348;
2026-04-16 09:24:26 +09:00
[ObservableProperty] private int detachedWindowCount;
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;
2026-04-16 11:14:22 +09:00
public double ConversationPaneWidth => IsConversationPaneCollapsed ? 0 : ConversationPaneWidthValue;
2026-04-16 09:24:26 +09:00
public double InspectorPaneWidth => IsInspectorVisible ? (IsCompactDensity ? 92 : 108) : 0;
2026-04-16 11:14:22 +09:00
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;
2026-04-16 09:24:26 +09:00
public double ComposerMinHeight => IsCompactDensity ? 48 : 58;
public string ComposerCounterText => $"{ComposerText.Trim().Length}";
2026-04-16 11:14:22 +09:00
public string SearchWatermark => "검색";
2026-04-16 09:24:26 +09:00
public string InspectorStatusText => HasDetachedWindows
? $"{RealtimeStatusGlyph} {DetachedWindowBadgeText}"
: RealtimeStatusGlyph;
public string WorkspaceModeText => HasDetachedWindows ? $"분리 창 {DetachedWindowBadgeText}" : "단일 창";
public string StatusSummaryText => string.IsNullOrWhiteSpace(StatusLine) ? RealtimeStatusText : StatusLine;
2026-04-16 11:14:22 +09:00
public string ComposerPlaceholderText => HasSelectedConversation ? "메시지" : "대화 선택";
2026-04-16 09:24:26 +09:00
public string ComposerActionText => Messages.Count == 0 ? "시작" : "보내기";
public bool ShowMessageEmptyState => Messages.Count == 0;
2026-04-16 11:14:22 +09:00
public string MessageEmptyStateTitle => HasSelectedConversation ? "첫 메시지" : "대화 선택";
2026-04-16 09:24:26 +09:00
public string MessageEmptyStateText => HasSelectedConversation
2026-04-16 11:14:22 +09:00
? "짧게 남기세요."
: "목록에서 선택";
2026-04-16 09:24:26 +09:00
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);
}
}
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) => 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();
}
2026-04-16 11:14:22 +09:00
partial void OnConversationPaneWidthValueChanged(double value)
{
OnPropertyChanged(nameof(ConversationPaneWidth));
_ = PersistWorkspaceLayoutAsync();
}
2026-04-16 09:24:26 +09:00
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 OnSelectedConversationChanged(ConversationRowViewModel? value)
{
UpdateSelectedConversationState(value?.ConversationId);
SelectedConversationTitle = value?.Title ?? "KoTalk";
2026-04-16 11:14:22 +09:00
SelectedConversationSubtitle = value?.Subtitle ?? "대화";
2026-04-16 09:24:26 +09:00
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(),
new DeviceRegistrationDto(
$"desktop-{Environment.MachineName.ToLowerInvariant()}",
"windows",
Environment.MachineName,
2026-04-16 11:14:22 +09:00
"0.1.0-alpha.4"));
2026-04-16 09:24:26 +09:00
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();
IsAuthenticated = false;
CurrentUserDisplayName = "KO";
StatusLine = string.Empty;
RealtimeState = RealtimeConnectionState.Idle;
RealtimeStatusText = "준비";
ErrorText = null;
ApiBaseUrl = DefaultApiBaseUrl;
ConversationSearchText = string.Empty;
SelectedListFilter = "all";
SelectedConversation = null;
SelectedConversationTitle = "KoTalk";
2026-04-16 11:14:22 +09:00
SelectedConversationSubtitle = "준비";
2026-04-16 09:24:26 +09:00
NotifyConversationMetricsChanged();
}
private async Task HandleSelectedConversationChangedAsync(ConversationRowViewModel? value)
{
if (!IsAuthenticated || value is null || _session is null)
{
return;
}
2026-04-16 11:14:22 +09:00
Messages.Clear();
NotifyMessageStateChanged();
2026-04-16 09:24:26 +09:00
await RunBusyAsync(async () =>
{
var items = await _apiClient.GetMessagesAsync(_session.ApiBaseUrl, _session.AccessToken, value.ConversationId, CancellationToken.None);
2026-04-16 11:14:22 +09:00
if (!string.Equals(SelectedConversation?.ConversationId, value.ConversationId, StringComparison.Ordinal))
{
return;
}
2026-04-16 09:24:26 +09:00
Messages.Clear();
foreach (var item in items.Items)
{
Messages.Add(MapMessage(item));
}
if (Messages.Count > 0)
{
var lastSequence = Messages[^1].ServerSequence;
value.LastReadSequence = lastSequence;
value.UnreadCount = 0;
await _apiClient.UpdateReadCursorAsync(
_session.ApiBaseUrl,
_session.AccessToken,
value.ConversationId,
new UpdateReadCursorRequest(lastSequence),
CancellationToken.None);
}
NotifyConversationMetricsChanged();
NotifyMessageStateChanged();
RefreshConversationFilter(value.ConversationId);
if (_session is not null)
{
_session = _session with { LastConversationId = value.ConversationId };
if (RememberSession)
{
await _sessionStore.SaveAsync(_session);
}
}
}, clearMessages: 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)
{
_currentUserId = bootstrap.Me.UserId;
CurrentUserDisplayName = displayName;
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) &&
!string.IsNullOrWhiteSpace(InviteCode);
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 filtered = Conversations
.Where(PassesSelectedFilter)
.Where(item => string.IsNullOrWhiteSpace(search) || MatchesConversationSearch(item, search))
.ToList();
FilteredConversations.Clear();
foreach (var item in filtered)
{
FilteredConversations.Add(item);
}
HasFilteredConversations = filtered.Count > 0;
ConversationEmptyStateText = string.IsNullOrWhiteSpace(search)
? (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 = FilteredConversations.FirstOrDefault();
}
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 static bool MatchesConversationSearch(ConversationRowViewModel item, string search)
{
return item.Title.Contains(search, StringComparison.CurrentCultureIgnoreCase) ||
item.LastMessageText.Contains(search, StringComparison.CurrentCultureIgnoreCase) ||
item.Subtitle.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));
}
2026-04-16 11:14:22 +09:00
public void UpdateConversationPaneWidth(double width)
{
if (IsConversationPaneCollapsed)
{
return;
}
var clamped = Math.Clamp(Math.Round(width), 280, 480);
if (Math.Abs(clamped - ConversationPaneWidthValue) > 1)
{
ConversationPaneWidthValue = clamped;
}
}
private void ApplyQuickDraft(string template)
{
ComposerText = string.IsNullOrWhiteSpace(ComposerText)
? template
: ComposerText.TrimEnd() + Environment.NewLine + template;
}
2026-04-16 09:24:26 +09:00
private void LoadSampleWorkspace()
{
Conversations.Clear();
FilteredConversations.Clear();
Messages.Clear();
CurrentUserDisplayName = "이안";
DisplayName = "이안";
InviteCode = string.Empty;
2026-04-16 11:14:22 +09:00
_currentUserId = "sample-user";
_session = new DesktopSession(
DefaultApiBaseUrl,
"sample-access",
"sample-refresh",
CurrentUserDisplayName,
"sample-ops");
2026-04-16 09:24:26 +09:00
RealtimeState = RealtimeConnectionState.Connected;
RealtimeStatusText = "연결됨";
StatusLine = "준비";
IsAuthenticated = true;
IsCompactDensity = true;
IsInspectorVisible = false;
IsConversationPaneCollapsed = false;
2026-04-16 11:14:22 +09:00
ConversationPaneWidthValue = 348;
2026-04-16 09:24:26 +09:00
DetachedWindowCount = 1;
ErrorText = null;
var now = DateTimeOffset.Now;
Conversations.Add(new ConversationRowViewModel
{
ConversationId = "sample-ops",
Title = "제품 운영",
2026-04-16 11:14:22 +09:00
Subtitle = "레이아웃 검수 메모를 확인해 주세요.",
LastMessageText = "레이아웃 검수 메모를 확인해 주세요.",
2026-04-16 09:24:26 +09:00
MetaText = FormatConversationMeta(now.AddMinutes(-5), 2),
UnreadCount = 2,
IsPinned = true,
LastReadSequence = 12,
SortKey = now.AddMinutes(-5)
});
Conversations.Add(new ConversationRowViewModel
{
ConversationId = "sample-review",
Title = "디자인 리뷰",
2026-04-16 11:14:22 +09:00
Subtitle = "오후 2시에 포인트만 다시 볼게요.",
LastMessageText = "오후 2시에 포인트만 다시 볼게요.",
2026-04-16 09:24:26 +09:00
MetaText = FormatConversationMeta(now.AddMinutes(-22), 0),
UnreadCount = 0,
IsPinned = false,
LastReadSequence = 5,
SortKey = now.AddMinutes(-22)
});
Conversations.Add(new ConversationRowViewModel
{
ConversationId = "sample-friends",
Title = "주말 약속",
2026-04-16 11:14:22 +09:00
Subtitle = "브런치 장소만 정하면 끝.",
LastMessageText = "브런치 장소만 정하면 끝.",
2026-04-16 09:24:26 +09:00
MetaText = FormatConversationMeta(now.AddMinutes(-54), 0),
UnreadCount = 0,
IsPinned = false,
LastReadSequence = 3,
SortKey = now.AddMinutes(-54)
});
2026-04-16 11:14:22 +09:00
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)
});
2026-04-16 09:24:26 +09:00
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 = "민지",
2026-04-16 11:14:22 +09:00
Text = "회의 전에 레이아웃 이슈만 짧게 정리해 주세요.",
2026-04-16 09:24:26 +09:00
MetaText = "08:54",
IsMine = false,
ServerSequence = 13
},
new MessageRowViewModel
{
MessageId = "sample-msg-2",
SenderName = "이안",
2026-04-16 11:14:22 +09:00
Text = "리스트 폭을 다시 줄이고 우측 빈 패널도 없앴어요.",
2026-04-16 09:24:26 +09:00
MetaText = "08:56",
IsMine = true,
ServerSequence = 14
},
new MessageRowViewModel
{
MessageId = "sample-msg-3",
SenderName = "민지",
2026-04-16 11:14:22 +09:00
Text = "좋아요. 지금 화면이면 검수하기 좋겠네요.",
2026-04-16 09:24:26 +09:00
MetaText = "08:58",
IsMine = false,
ServerSequence = 15
},
new MessageRowViewModel
{
MessageId = "sample-msg-4",
SenderName = "이안",
2026-04-16 11:14:22 +09:00
Text = "스크린샷 기준으로 밀도도 같이 맞췄습니다.",
2026-04-16 09:24:26 +09:00
MetaText = "09:05",
IsMine = true,
ServerSequence = 16
},
new MessageRowViewModel
{
MessageId = "sample-msg-5",
SenderName = "민지",
2026-04-16 11:14:22 +09:00
Text = "좋아요. 확인 흐름이 더 짧아졌어요.",
2026-04-16 09:24:26 +09:00
MetaText = "09:06",
IsMine = false,
ServerSequence = 17
},
new MessageRowViewModel
{
MessageId = "sample-msg-6",
SenderName = "이안",
2026-04-16 11:14:22 +09:00
Text = "분리 창은 상단 액션으로 남겨 두었습니다.",
2026-04-16 09:24:26 +09:00
MetaText = "09:07",
IsMine = true,
ServerSequence = 18
},
new MessageRowViewModel
{
MessageId = "sample-msg-7",
SenderName = "민지",
Text = "이 정도면 데스크톱 검수용 화면으로 충분하겠네요.",
MetaText = "09:08",
IsMine = false,
ServerSequence = 19
2026-04-16 11:14:22 +09:00
},
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
2026-04-16 09:24:26 +09:00
}
})
{
Messages.Add(item);
}
OnPropertyChanged(nameof(CurrentUserMonogram));
NotifyMessageStateChanged();
}
private void HandleMessagesCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
NotifyMessageStateChanged();
}
private void NotifyMessageStateChanged()
{
OnPropertyChanged(nameof(ShowMessageEmptyState));
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);
}
}
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;
2026-04-16 11:14:22 +09:00
ConversationPaneWidthValue = 348;
2026-04-16 09:24:26 +09:00
StatusLine = "초기화";
}
private void ApplyWorkspaceLayout(DesktopWorkspaceLayout layout)
{
IsCompactDensity = layout.IsCompactDensity;
IsInspectorVisible = layout.IsInspectorVisible;
IsConversationPaneCollapsed = layout.IsConversationPaneCollapsed;
2026-04-16 11:14:22 +09:00
ConversationPaneWidthValue = Math.Clamp(layout.ConversationPaneWidth, 280, 480);
2026-04-16 09:24:26 +09:00
}
private Task PersistWorkspaceLayoutAsync()
{
return _workspaceLayoutStore.SaveAsync(new DesktopWorkspaceLayout(
IsCompactDensity,
IsInspectorVisible,
2026-04-16 11:14:22 +09:00
IsConversationPaneCollapsed,
ConversationPaneWidthValue));
2026-04-16 09:24:26 +09:00
}
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();
}
}