1052 lines
42 KiB
C#
1052 lines
42 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 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");
|
|||
|
|
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 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 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;
|
|||
|
|
public double ConversationPaneWidth => IsConversationPaneCollapsed ? 0 : (IsCompactDensity ? 296 : 340);
|
|||
|
|
public double InspectorPaneWidth => IsInspectorVisible ? (IsCompactDensity ? 92 : 108) : 0;
|
|||
|
|
public Thickness ConversationRowPadding => IsCompactDensity ? new Thickness(6, 6) : new Thickness(8, 7);
|
|||
|
|
public Thickness MessageBubblePadding => IsCompactDensity ? new Thickness(10, 8) : new Thickness(12, 10);
|
|||
|
|
public double ConversationAvatarSize => IsCompactDensity ? 26 : 30;
|
|||
|
|
public double ComposerMinHeight => IsCompactDensity ? 48 : 58;
|
|||
|
|
public string ComposerCounterText => $"{ComposerText.Trim().Length}";
|
|||
|
|
public string SearchWatermark => "대화 검색";
|
|||
|
|
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;
|
|||
|
|
public string MessageEmptyStateTitle => HasSelectedConversation ? "첫 메시지부터 시작" : "대화를 먼저 고르세요";
|
|||
|
|
public string MessageEmptyStateText => HasSelectedConversation
|
|||
|
|
? "짧게 한 줄만 남겨도 바로 이어집니다."
|
|||
|
|
: "받은함에서 대화를 고르거나 창으로 분리해 집중할 수 있습니다.";
|
|||
|
|
|
|||
|
|
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();
|
|||
|
|
}
|
|||
|
|
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";
|
|||
|
|
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(),
|
|||
|
|
new DeviceRegistrationDto(
|
|||
|
|
$"desktop-{Environment.MachineName.ToLowerInvariant()}",
|
|||
|
|
"windows",
|
|||
|
|
Environment.MachineName,
|
|||
|
|
"0.1.0"));
|
|||
|
|
|
|||
|
|
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";
|
|||
|
|
SelectedConversationSubtitle = "대화를 열면 바로 이어집니다.";
|
|||
|
|
NotifyConversationMetricsChanged();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private async Task HandleSelectedConversationChangedAsync(ConversationRowViewModel? value)
|
|||
|
|
{
|
|||
|
|
if (!IsAuthenticated || value is null || _session is null)
|
|||
|
|
{
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await RunBusyAsync(async () =>
|
|||
|
|
{
|
|||
|
|
var items = await _apiClient.GetMessagesAsync(_session.ApiBaseUrl, _session.AccessToken, value.ConversationId, CancellationToken.None);
|
|||
|
|
|
|||
|
|
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));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void LoadSampleWorkspace()
|
|||
|
|
{
|
|||
|
|
Conversations.Clear();
|
|||
|
|
FilteredConversations.Clear();
|
|||
|
|
Messages.Clear();
|
|||
|
|
|
|||
|
|
CurrentUserDisplayName = "이안";
|
|||
|
|
DisplayName = "이안";
|
|||
|
|
InviteCode = string.Empty;
|
|||
|
|
RealtimeState = RealtimeConnectionState.Connected;
|
|||
|
|
RealtimeStatusText = "연결됨";
|
|||
|
|
StatusLine = "준비";
|
|||
|
|
IsAuthenticated = true;
|
|||
|
|
IsCompactDensity = true;
|
|||
|
|
IsInspectorVisible = false;
|
|||
|
|
IsConversationPaneCollapsed = false;
|
|||
|
|
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)
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
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
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
{
|
|||
|
|
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;
|
|||
|
|
StatusLine = "초기화";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void ApplyWorkspaceLayout(DesktopWorkspaceLayout layout)
|
|||
|
|
{
|
|||
|
|
IsCompactDensity = layout.IsCompactDensity;
|
|||
|
|
IsInspectorVisible = layout.IsInspectorVisible;
|
|||
|
|
IsConversationPaneCollapsed = layout.IsConversationPaneCollapsed;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private Task PersistWorkspaceLayoutAsync()
|
|||
|
|
{
|
|||
|
|
return _workspaceLayoutStore.SaveAsync(new DesktopWorkspaceLayout(
|
|||
|
|
IsCompactDensity,
|
|||
|
|
IsInspectorVisible,
|
|||
|
|
IsConversationPaneCollapsed));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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();
|
|||
|
|
}
|
|||
|
|
}
|