1361 lines
53 KiB
C#
1361 lines
53 KiB
C#
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();
|
||
}
|
||
}
|