using EchoHub.Client.Commands; using EchoHub.Client.Config; using EchoHub.Client.Services; using EchoHub.Client.Themes; using EchoHub.Client.UI; using EchoHub.Client.UI.Chat; using EchoHub.Client.UI.Dialogs; using EchoHub.Core.Constants; using EchoHub.Core.DTOs; using EchoHub.Core.Models; using EchoHub.Core.Security; using EchoHub.Core.Services; using Serilog; using Terminal.Gui.App; using Terminal.Gui.Views; namespace EchoHub.Client; /// /// Central orchestrator for the EchoHub TUI client. /// Wires UI events to service calls and connection events to UI updates. /// public sealed class AppOrchestrator : IDisposable { private readonly IApplication _app; private readonly MainWindow _mainWindow; private readonly ChatMessageManager _messageManager; private readonly CommandHandler _commandHandler; private readonly NotificationSoundService _notificationSound; private readonly OsNotificationService _osNotification; private readonly AudioPlaybackService _audioPlayback = new(); private readonly UpdateChecker _updateService; private readonly ConnectionManager _conn = new(); private readonly Dictionary> _channelUsers = new(StringComparer.OrdinalIgnoreCase); private readonly Lock _channelUsersLock = new(); private readonly HashSet _channelsLoadingMore = new(StringComparer.OrdinalIgnoreCase); private readonly List _stagedAttachments = []; // Temp PNGs created for clipboard-image pastes; deleted once their message is sent // (or the staging tray is cleared) so pasted screenshots don't pile up in %TEMP%. private readonly HashSet _tempPastedFiles = []; // E2E channels whose unlock prompt the user cancelled — don't nag on every reselect. // Cleared on connect/reconnect; an explicit /join or a send attempt re-offers the prompt. private readonly HashSet _declinedUnlocks = new(StringComparer.OrdinalIgnoreCase); // Pending reply target — the next text message in that channel is sent as a reply to it. private (string Channel, Guid MessageId)? _pendingReply; private ClientConfig _config; private readonly UserSession _session = new(); public MainWindow MainWindow => _mainWindow; /// /// Set when the user confirms an update. The host must run this after the Terminal.Gui main /// loop exits (console restored), so the updater's in-place restart doesn't fight the TUI. /// public Func? PendingUpdate => _updateService.PendingUpdate; public AppOrchestrator(IApplication app, ClientConfig config) { _app = app; _config = config; _messageManager = new ChatMessageManager(); _mainWindow = new MainWindow(app, _messageManager); _commandHandler = new CommandHandler(); _notificationSound = new NotificationSoundService(config.Notifications); _osNotification = new OsNotificationService(); _updateService = new UpdateChecker(app); WireMainWindowEvents(); WireCommandHandlerEvents(); WireConnectionManagerEvents(); _updateService.Start(); _mainWindow.UpdateStatusBar("Disconnected"); } public void Dispose() { // Quit while still connected — capture read positions before tearing down PersistLastReads(); _conn.DisposeAsync().AsTask().GetAwaiter().GetResult(); _updateService.Dispose(); } // ── Convenience Helpers ──────────────────────────────────────────────── private void RunAsync(Func work, string errorPrefix, string? logContext = null) { AsyncRunner.Run(_app, work, _mainWindow.ShowError, errorPrefix, logContext); } private void InvokeUI(Action action) => _app.Invoke(action); // ── MainWindow Event Wiring ──────────────────────────────────────────── private void WireMainWindowEvents() { _mainWindow.OnConnectRequested += HandleConnect; _mainWindow.OnDisconnectRequested += HandleDisconnect; _mainWindow.OnLogoutRequested += HandleLogout; _mainWindow.OnMessageSubmitted += HandleMessageSubmitted; _mainWindow.OnFilesStaged += HandleFilesStaged; _mainWindow.OnImagePasted += HandleImagePasted; _mainWindow.OnChannelSelected += HandleChannelSelected; _mainWindow.OnProfileRequested += HandleProfileRequested; _mainWindow.OnStatusRequested += HandleStatusRequested; _mainWindow.OnThemeSelected += HandleThemeSelected; _mainWindow.OnSavedServersRequested += HandleSavedServersRequested; _mainWindow.OnCreateChannelRequested += HandleCreateChannelRequested; _mainWindow.OnDeleteChannelRequested += HandleDeleteChannelRequested; _mainWindow.OnAudioPlayRequested += HandleAudioPlayRequested; _mainWindow.OnFileDownloadRequested += HandleFileDownloadRequested; _mainWindow.OnImageSaveRequested += HandleImageSaveRequested; _mainWindow.OnImageOpenRequested += HandleImageOpenRequested; _mainWindow.OnDeleteMessageRequested += HandleDeleteMessageRequested; _mainWindow.OnCheckForUpdatesRequested += HandleCheckForUpdatesRequested; _mainWindow.OnRollbackRequested += HandleRollbackRequested; _mainWindow.OnUserProfileRequested += HandleViewProfile; _mainWindow.OnChannelJoinRequested += HandleChannelJoinFromMessage; _mainWindow.OnSearchRequested += HandleSearchRequested; _mainWindow.OnLoadMoreRequested += HandleLoadMoreRequested; _mainWindow.OnReplyRequested += HandleReplyRequested; _mainWindow.OnReplyCancelRequested += HandleReplyCancelRequested; } // ── Command Handler Wiring ───────────────────────────────────────────── private void WireCommandHandlerEvents() { _commandHandler.OnSetStatus += HandleCmdSetStatus; _commandHandler.OnSetNick += HandleCmdSetNick; _commandHandler.OnSetColor += HandleCmdSetColor; _commandHandler.OnSetTheme += HandleCmdSetTheme; _commandHandler.OnSendFile += HandleCmdSendFile; _commandHandler.OnSetAvatar += HandleCmdSetAvatar; _commandHandler.OnOpenProfile += HandleCmdOpenProfile; _commandHandler.OnOpenServers += HandleCmdOpenServers; _commandHandler.OnJoinChannel += HandleCmdJoinChannel; _commandHandler.OnChangeRoomPassword += HandleCmdChangeRoomPassword; _commandHandler.OnClearAttachments += HandleCmdClearAttachments; _commandHandler.OnSetAsciiSize += HandleCmdSetAsciiSize; _commandHandler.OnSetDownloadPath += HandleCmdSetDownloadPath; _commandHandler.OnLeaveChannel += HandleCmdLeaveChannel; _commandHandler.OnSetTopic += HandleCmdSetTopic; _commandHandler.OnListUsers += HandleCmdListUsers; _commandHandler.OnRoomInfo += HandleCmdMeta; _commandHandler.OnKickUser += HandleCmdKickUser; _commandHandler.OnBanUser += HandleCmdBanUser; _commandHandler.OnUnbanUser += HandleCmdUnbanUser; _commandHandler.OnMuteUser += HandleCmdMuteUser; _commandHandler.OnUnmuteUser += HandleCmdUnmuteUser; _commandHandler.OnAssignRole += HandleCmdAssignRole; _commandHandler.OnNukeChannel += HandleCmdNukeChannel; _commandHandler.OnTestSound += HandleCmdTestSound; _commandHandler.OnQuit += HandleCmdQuit; _commandHandler.OnSendAction += HandleCmdSendAction; _commandHandler.OnSendBanner += HandleCmdSendBanner; _commandHandler.OnCreateInvite += HandleCmdCreateInvite; _commandHandler.OnListInvites += HandleCmdListInvites; _commandHandler.OnRevokeInvite += HandleCmdRevokeInvite; _commandHandler.OnExportData += HandleCmdExportData; _commandHandler.OnDeleteAccount += HandleCmdDeleteAccount; } // ── Command Handlers ────────────────────────────────────────────────── private async Task HandleCmdSetStatus(UserStatus? status, string? message) { if (!_conn.IsConnected) return; // null status keeps the current one; null message keeps it, empty clears it — // so "/status away" no longer wipes your message and "/status msg brb" keeps Away var newStatus = status ?? _session.Status; var newMessage = message is null ? _session.StatusMessage : (message.Length == 0 ? null : message); await _conn.UpdateStatusAsync(newStatus, newMessage); _session.Status = newStatus; _session.StatusMessage = newMessage; } private Task HandleCmdSendAction(string text) { if (!_conn.IsConnected) return Task.CompletedTask; var channel = _mainWindow.CurrentChannel; if (string.IsNullOrEmpty(channel)) return Task.CompletedTask; // CTCP ACTION content flows through the normal send path — room encryption included RunAsync(async () => { if (!await EnsureRoomUnlockedForSendAsync(channel)) return; await _conn.SendMessageAsync(channel, MessageConventions.FormatAction(text)); }, "Send failed"); return Task.CompletedTask; } private Task HandleCmdSendBanner(string text) { if (!_conn.IsConnected) return Task.CompletedTask; var channel = _mainWindow.CurrentChannel; if (string.IsNullOrEmpty(channel)) return Task.CompletedTask; var banner = AsciiBannerService.Render(text); if (banner is null) { InvokeUI(() => _mainWindow.ShowError( $"Nothing to render — /banner supports letters, digits, and basic punctuation (max {AsciiBannerService.MaxInputLength} chars).")); return Task.CompletedTask; } RunAsync(async () => { if (!await EnsureRoomUnlockedForSendAsync(channel)) return; await _conn.SendMessageAsync(channel, banner); }, "Send failed"); return Task.CompletedTask; } private Task HandleCmdCreateInvite(int? maxUses, int? expiresHours) { if (!_conn.IsAuthenticated) return Task.CompletedTask; var channel = _mainWindow.CurrentChannel; if (string.IsNullOrEmpty(channel)) return Task.CompletedTask; RunAsync(async () => { var invite = await _conn.Api!.CreateInviteAsync(maxUses, expiresHours); if (invite is null) return; var expiry = invite.ExpiresAt is { } exp ? $", expires {exp.ToLocalTime():yyyy-MM-dd HH:mm}" : ""; InvokeUI(() => _messageManager.AddSystemMessage(channel, $"Invite code: {invite.Code} (uses: {invite.MaxUses}{expiry})\n" + $"Share it out-of-band. Revoke with: /invite revoke {invite.Code}")); }, "Failed to create invite"); return Task.CompletedTask; } private Task HandleCmdListInvites() { if (!_conn.IsAuthenticated) return Task.CompletedTask; var channel = _mainWindow.CurrentChannel; if (string.IsNullOrEmpty(channel)) return Task.CompletedTask; RunAsync(async () => { var invites = await _conn.Api!.GetInvitesAsync(); var text = invites.Count == 0 ? "No invite codes. Create one with /invite [uses] [hours]." : string.Join('\n', invites.Select(i => { var state = i.UseCount >= i.MaxUses ? "used up" : i.ExpiresAt is { } exp && exp <= DateTimeOffset.UtcNow ? "expired" : i.ExpiresAt is { } e2 ? $"expires {e2.ToLocalTime():yyyy-MM-dd HH:mm}" : "active"; return $"{i.Code} {i.UseCount}/{i.MaxUses} used ({state}, by {i.CreatedByUsername})"; })); InvokeUI(() => _messageManager.AddSystemMessage(channel, text)); }, "Failed to list invites"); return Task.CompletedTask; } private Task HandleCmdRevokeInvite(string code) { if (!_conn.IsAuthenticated) return Task.CompletedTask; var channel = _mainWindow.CurrentChannel; if (string.IsNullOrEmpty(channel)) return Task.CompletedTask; RunAsync(async () => { await _conn.Api!.RevokeInviteAsync(code); InvokeUI(() => _messageManager.AddSystemMessage(channel, $"Invite {code.ToUpperInvariant()} revoked.")); }, "Failed to revoke invite"); return Task.CompletedTask; } private Task HandleCmdExportData() { if (!_conn.IsAuthenticated) return Task.CompletedTask; var channel = _mainWindow.CurrentChannel; RunAsync(async () => { var json = await _conn.Api!.ExportMyDataAsync(); var fileName = $"echohub-export-{_session.Username}-{DateTime.Now:yyyyMMdd-HHmmss}.json"; var destination = DedupPath(GetDownloadDir(), fileName); await File.WriteAllTextAsync(destination, json); InvokeUI(() => { if (!string.IsNullOrEmpty(channel)) _messageManager.AddSystemMessage(channel, $"Data export saved to: {destination}\n(Encrypted-room content in it is ciphertext — the server never had the plaintext.)"); }); }, "Export failed"); return Task.CompletedTask; } private Task HandleCmdDeleteAccount() { if (!_conn.IsAuthenticated) return Task.CompletedTask; InvokeUI(() => { var confirm = MessageBox.ErrorQuery(_app, "Delete Account", $"This permanently deletes '{_session.Username}' on this server:\n" + "profile, sessions, and every file you uploaded.\n" + "Your messages remain, attributed to 'deleted-user'.\n\n" + "This cannot be undone.", "Cancel", "Delete my account"); if (confirm != 1) return; var password = PromptPassword("Enter your password to confirm deletion:"); if (password is null) return; PersistLastReads(); RunAsync(async () => { var baseUrl = _conn.Api!.BaseUrl; await _conn.Api!.DeleteMyAccountAsync(password); ClearSavedToken(baseUrl); await _conn.CleanupAsync(); InvokeUI(() => { _mainWindow.ClearAll(); _mainWindow.UpdateStatusBar("Disconnected"); MessageBox.Query(_app, "Account Deleted", "Your account and uploaded files have been deleted from this server.", "OK"); }); }, "Account deletion failed", "DeleteAccount"); }); return Task.CompletedTask; } /// Modal password prompt; returns null when cancelled or empty. private string? PromptPassword(string prompt) { string? result = null; var dialog = new Dialog { Title = "Confirm Password", Width = 56, Height = 8 }; var label = new Label { Text = prompt, X = 1, Y = 1 }; var field = new TextField { X = 1, Y = 2, Width = Terminal.Gui.ViewBase.Dim.Fill(2), Secret = true }; var ok = new Button { Text = "Confirm", IsDefault = true, X = Terminal.Gui.ViewBase.Pos.Center() - 12, Y = 4 }; var cancel = new Button { Text = "Cancel", X = Terminal.Gui.ViewBase.Pos.Center() + 2, Y = 4 }; ok.Accepting += (_, e) => { result = field.Text; e.Handled = true; _app.RequestStop(); }; cancel.Accepting += (_, e) => { result = null; e.Handled = true; _app.RequestStop(); }; dialog.Add(label, field, ok, cancel); field.SetFocus(); _app.Run(dialog); return string.IsNullOrEmpty(result) ? null : result; } private async Task HandleCmdSetNick(string displayName) { if (!_conn.IsAuthenticated) return; await _conn.Api!.UpdateProfileAsync(new UpdateProfileRequest(DisplayName: displayName)); InvokeUI(() => { _mainWindow.SetCurrentUser(displayName); _mainWindow.UpdateStatusBar("Connected"); }); } private async Task HandleCmdSetColor(string color) { if (!_conn.IsAuthenticated) return; await _conn.Api!.UpdateProfileAsync(new UpdateProfileRequest(NicknameColor: color)); } private Task HandleCmdSetTheme(string name) { InvokeUI(() => HandleThemeSelected(name)); return Task.CompletedTask; } private Task HandleCmdSendFile(string target, string? size) { if (!_conn.IsAuthenticated || !_conn.IsConnected) return Task.CompletedTask; var channel = _mainWindow.CurrentChannel; if (string.IsNullOrEmpty(channel)) return Task.CompletedTask; // A URL image is sent immediately as its own message (it can't be staged/encrypted). if (Uri.TryCreate(target, UriKind.Absolute, out var uri) && (uri.Scheme == "http" || uri.Scheme == "https")) { // Also blocks locked E2E channels (no cached key) — a URL send would be plaintext if (_conn.RoomKeys.HasKey(channel) || _conn.RoomKeys.IsChannelEncrypted(channel)) { InvokeUI(() => _mainWindow.ShowError( "Sending by URL isn't available in encrypted channels — download the file and /send it instead.")); return Task.CompletedTask; } RunAsync(async () => await _conn.Api!.SendUrlAsync(channel, target, size), "Send failed"); return Task.CompletedTask; } // Local files are staged; the next Enter sends them with the typed caption as one message. if (_stagedAttachments.Count >= HubConstants.MaxAttachmentsPerMessage) { InvokeUI(() => _mainWindow.ShowError($"You can attach at most {HubConstants.MaxAttachmentsPerMessage} files per message.")); return Task.CompletedTask; } // An explicit "-s/-m/-l" on /send also sets the message's ASCII size. if (NormalizeAsciiSize(size) is { } flag) _config.DefaultAsciiSize = flag; _stagedAttachments.Add(target); InvokeUI(RefreshStagingTray); return Task.CompletedTask; } private Task HandleCmdClearAttachments() { var temps = _stagedAttachments.Where(_tempPastedFiles.Contains).ToList(); _tempPastedFiles.ExceptWith(temps); CleanupPastedTempFiles(temps); _stagedAttachments.Clear(); InvokeUI(RefreshStagingTray); return Task.CompletedTask; } /// /// Stages a batch of local files (multi-file paste or drag-and-drop) as attachments of the /// next message. Runs synchronously on the UI thread — unlike routing each file through a /// fire-and-forget /send command, a 10-file paste can't race the staging list. /// private void HandleFilesStaged(string channel, IReadOnlyList files) { if (!_conn.IsAuthenticated || !_conn.IsConnected) return; var slotsLeft = HubConstants.MaxAttachmentsPerMessage - _stagedAttachments.Count; _stagedAttachments.AddRange(files.Take(Math.Max(0, slotsLeft))); if (files.Count > slotsLeft) _mainWindow.ShowError($"You can attach at most {HubConstants.MaxAttachmentsPerMessage} files per message."); RefreshStagingTray(); } /// /// Stages an image pasted as raw clipboard data (copied from a browser, a screenshot tool, /// or an image editor). The PNG is written to a per-paste temp folder so it flows through /// the same path-based staging/encryption pipeline as regular files, and the temp file is /// deleted once the message is sent. /// private void HandleImagePasted(string channel, byte[] png) { if (!_conn.IsAuthenticated || !_conn.IsConnected) return; if (_stagedAttachments.Count >= HubConstants.MaxAttachmentsPerMessage) { _mainWindow.ShowError($"You can attach at most {HubConstants.MaxAttachmentsPerMessage} files per message."); return; } try { // A unique folder per paste keeps the Discord-style "image.png" display name // while letting several pasted images coexist in one message. var dir = Path.Combine(Path.GetTempPath(), "EchoHub", "pasted", Guid.NewGuid().ToString("N")[..8]); Directory.CreateDirectory(dir); var path = Path.Combine(dir, "image.png"); File.WriteAllBytes(path, png); _tempPastedFiles.Add(path); _stagedAttachments.Add(path); RefreshStagingTray(); } catch (Exception ex) { Log.Error(ex, "Staging a pasted clipboard image failed"); _mainWindow.ShowError($"Pasting image failed: {ex.Message}"); } } /// Best-effort removal of pasted-image temp files and their per-paste folders. private static void CleanupPastedTempFiles(IReadOnlyList files) { foreach (var file in files) { try { File.Delete(file); if (Path.GetDirectoryName(file) is { } dir) Directory.Delete(dir); } catch (Exception ex) { Log.Debug(ex, "Could not delete pasted-image temp file {File}", file); } } } /// /// Opens the ASCII-art size picker (no argument) or sets it directly from "s"/"m"/"l" (or /// small/medium/large). The choice is a persistent preference applied to attached images. /// private Task HandleCmdSetAsciiSize(string args) { var flag = NormalizeAsciiSize(args); if (flag is not null) { InvokeUI(() => ApplyAsciiSize(flag)); return Task.CompletedTask; } InvokeUI(() => { var choice = MessageBox.Query(_app, "ASCII Art Size", "Size of the ASCII rendering for images you attach:\n\n" + " Small 40 x 40 (compact)\n" + " Medium 80 x 80 (default)\n" + " Large 120 x 120 (detailed)", "Small", "Medium", "Large", "Cancel"); var picked = choice switch { 0 => "s", 1 => "m", 2 => "l", _ => null }; if (picked is not null) ApplyAsciiSize(picked); }); return Task.CompletedTask; } private void ApplyAsciiSize(string flag) { _config.DefaultAsciiSize = flag; ConfigManager.Save(_config); RefreshStagingTray(); var channel = _mainWindow.CurrentChannel; if (!string.IsNullOrEmpty(channel)) _messageManager.AddSystemMessage(channel, $"Image ASCII size set to {AsciiSizeLabel(flag)}."); } /// Refreshes the staging tray with the current staged files and ASCII size. private void RefreshStagingTray() { var names = _stagedAttachments.Select(Path.GetFileName).OfType().ToList(); _mainWindow.SetStagedAttachments(names, AsciiSizeLabel(_config.DefaultAsciiSize)); } private static string? NormalizeAsciiSize(string? size) => size?.Trim().ToLowerInvariant() switch { "s" or "small" => "s", "m" or "medium" => "m", "l" or "large" => "l", _ => null, }; private static string AsciiSizeLabel(string flag) => flag switch { "s" => "Small (40x40)", "l" => "Large (120x120)", _ => "Medium (80x80)", }; /// /// Sends one message with the given caption plus all staged files as attachments, then /// clears the staging tray. In encrypted channels each file is room-encrypted (blob + /// ASCII preview) client-side before upload; the caption is room-encrypted too. /// private void SendStagedMessage(string channel, string content) { var size = _config.DefaultAsciiSize; RunAsync(async () => { // Locked E2E channel: block the send and keep the files staged for after the unlock if (!await EnsureRoomUnlockedForSendAsync(channel)) return; var staged = _stagedAttachments.ToList(); _stagedAttachments.Clear(); InvokeUI(RefreshStagingTray); // Pasted clipboard images live in temp files; once this send owns them they are // deleted whether the upload succeeds or fails (the tray is already cleared). var tempFiles = staged.Where(_tempPastedFiles.Contains).ToList(); _tempPastedFiles.ExceptWith(tempFiles); try { var hasRoomKey = _conn.RoomKeys.TryGetKey(channel, out var roomKey); var outgoing = new List(); foreach (var path in staged) outgoing.Add(await BuildOutgoingAttachmentAsync(path, hasRoomKey ? roomKey : null, size)); var wireContent = hasRoomKey && !string.IsNullOrEmpty(content) ? RoomCrypto.EncryptText(content, roomKey) : content; await _conn.Api!.SendMessageWithAttachmentsAsync(channel, wireContent, outgoing, size); } finally { CleanupPastedTempFiles(tempFiles); } }, "Send failed"); } /// /// Reads a staged file into an . For encrypted channels the /// blob is AES-GCM encrypted, its kind is declared, and the image ASCII preview is rendered /// locally (at ) and room-encrypted — so the server never sees the /// file or image contents. /// private static async Task BuildOutgoingAttachmentAsync(string path, byte[]? roomKey, string size) { var fileName = Path.GetFileName(path); if (roomKey is null) return new OutgoingAttachment(File.OpenRead(path), fileName); var bytes = await File.ReadAllBytesAsync(path); string declaredKind; string? preview = null; using (var ms = new MemoryStream(bytes)) { if (FileValidationHelper.IsValidImage(ms)) { declaredKind = "image"; var (w, h) = ImageToAsciiService.GetDimensions(size); ms.Position = 0; preview = RoomCrypto.EncryptText(new ImageToAsciiService().ConvertToAscii(ms, w, h), roomKey); } else { declaredKind = FileValidationHelper.IsAudioFile(fileName) ? "audio" : "file"; } } var encryptedBlob = RoomCrypto.EncryptBytes(bytes, roomKey); return new OutgoingAttachment(new MemoryStream(encryptedBlob), fileName, declaredKind, preview); } private async Task HandleCmdSetAvatar(string target) { if (!_conn.IsAuthenticated) return; try { await AvatarHelper.UploadAsync(_conn.Api!, target); var channel = _mainWindow.CurrentChannel; if (!string.IsNullOrEmpty(channel)) InvokeUI(() => _messageManager.AddSystemMessage(channel, "Avatar updated.")); } catch (Exception ex) { Log.Error(ex, "Avatar upload failed for {Target}", target); InvokeUI(() => _mainWindow.ShowError($"Avatar upload failed: {ex.Message}")); } } private Task HandleCmdOpenProfile(string? username) { InvokeUI(() => HandleViewProfile(username)); return Task.CompletedTask; } private Task HandleCmdOpenServers() { InvokeUI(HandleSavedServersRequested); return Task.CompletedTask; } private async Task HandleCmdJoinChannel(string channelName, string? password) { if (!_conn.IsConnected) return; try { var history = await JoinChannelWithPasswordPromptAsync(channelName, password); if (history is null) return; // user cancelled the password prompt // A deliberate join cancels any earlier /leave exclusion UpdateServerConfig(server => server.LeftChannels.RemoveAll(c => c.Equals(channelName, StringComparison.OrdinalIgnoreCase))); InvokeUI(() => { _mainWindow.EnsureChannelInList(channelName); _mainWindow.SwitchToChannel(channelName); if (history.Count > 0) _messageManager.LoadHistory(channelName, history); }); } catch (Exception ex) { InvokeUI(() => _mainWindow.ShowError($"Failed to join channel: {ex.Message}")); } } /// /// Joins a channel, prompting for a password when the server requires one and /// re-prompting on a wrong password. For end-to-end encrypted channels the typed /// passphrase never goes to the server — a PBKDF2-derived auth key is sent instead, /// and the room content key is unwrapped locally. Returns the channel history, /// or null if the user cancelled the prompt. /// private async Task?> JoinChannelWithPasswordPromptAsync(string channelName, string? password) { ChannelCryptoDto? crypto = null; try { crypto = await _conn.Api!.GetChannelCryptoAsync(channelName); if (crypto is not null) _conn.RoomKeys.MarkChannelEncrypted(channelName, crypto.IsEncrypted); } catch (Exception ex) { Log.Debug(ex, "Crypto metadata unavailable for {Channel}", channelName); } while (true) { byte[]? kek = null; var wirePassword = password; if (password is not null && crypto is { IsEncrypted: true, EncryptionSalt: not null }) { var derived = RoomCrypto.DeriveKeys(password, Convert.FromBase64String(crypto.EncryptionSalt)); wirePassword = derived.AuthKeyHex; kek = derived.KeyEncryptionKey; } try { var outcome = await _conn.JoinChannelAsync(channelName, wirePassword); if (outcome.WrappedRoomKey is not null) { // A typed passphrase always wins over the cache: unwrap the fresh envelope // and overwrite any stale key (e.g. the channel was deleted and recreated // under the same name — the old key would encrypt for nobody). if (kek is not null && _conn.RoomKeys.TryStoreFromEnvelope(channelName, outcome.WrappedRoomKey, kek)) { lock (_declinedUnlocks) _declinedUnlocks.Remove(channelName); // Re-fetch so history decrypts with the now-available room key return await _conn.GetHistoryAsync(channelName); } if (!_conn.RoomKeys.HasKey(channelName)) return await UnlockRoomKeyAsync(channelName, outcome); } return outcome.History; } catch (ChannelPasswordRequiredException ex) { var prompt = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var message = password is not null ? ex.Message : null; InvokeUI(() => prompt.SetResult(ChannelPasswordDialog.Show(_app, channelName, message))); password = await prompt.Task; if (password is null) return null; } } } /// /// Member of an encrypted channel without a cached room key (e.g. a new device): /// prompt for the passphrase until the room key unwraps or the user gives up. /// private async Task?> UnlockRoomKeyAsync(string channelName, JoinOutcome outcome) { if (outcome.EncryptionSalt is null || outcome.WrappedRoomKey is null) return outcome.History; var salt = Convert.FromBase64String(outcome.EncryptionSalt); var message = "Enter the passphrase to unlock messages."; while (true) { var prompt = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var promptMessage = message; InvokeUI(() => prompt.SetResult(ChannelPasswordDialog.Show(_app, channelName, promptMessage))); var passphrase = await prompt.Task; if (passphrase is null) { // Stays locked; placeholders render instead of content. Remember the decline // so reselecting the channel doesn't nag every time. lock (_declinedUnlocks) _declinedUnlocks.Add(channelName); return outcome.History; } var derived = RoomCrypto.DeriveKeys(passphrase, salt); if (_conn.RoomKeys.TryStoreFromEnvelope(channelName, outcome.WrappedRoomKey, derived.KeyEncryptionKey)) { lock (_declinedUnlocks) _declinedUnlocks.Remove(channelName); return await _conn.GetHistoryAsync(channelName); } message = "Wrong passphrase — try again."; } } /// /// True when a channel is end-to-end encrypted, its room key isn't cached, and the /// user hasn't already declined the unlock prompt this session. /// private bool NeedsUnlockPrompt(string channelName) { if (!_conn.RoomKeys.IsChannelEncrypted(channelName) || _conn.RoomKeys.HasKey(channelName)) return false; lock (_declinedUnlocks) return !_declinedUnlocks.Contains(channelName); } /// /// Unlock flow for a channel that is already hub-joined (auto-join/reconnect discard /// the key envelope): rejoin — members pass the gate without a password and the join /// result carries the envelope — then run the passphrase prompt. On success the /// decrypted history replaces the locked placeholders. Returns true when unlocked. /// private async Task UnlockTrackedChannelAsync(string channelName) { try { var outcome = await _conn.JoinChannelAsync(channelName, null); if (outcome.WrappedRoomKey is null) return false; // not an E2E channel after all var history = await UnlockRoomKeyAsync(channelName, outcome); if (!_conn.RoomKeys.HasKey(channelName)) return false; // cancelled or never unwrapped if (history is not null) InvokeUI(() => _messageManager.LoadHistory(channelName, history)); return true; } catch (Exception ex) { Log.Warning(ex, "Unlock flow failed for #{Channel}", channelName); return false; } } /// /// Send guard for end-to-end encrypted channels: without the cached room key nothing /// may leave the client (it would be plaintext in a room others read as encrypted). /// Offers the unlock prompt right away — even after an earlier decline, since the user /// is actively trying to talk here. Returns true when sending is safe. /// private async Task EnsureRoomUnlockedForSendAsync(string channelName) { if (!_conn.RoomKeys.IsChannelEncrypted(channelName) || _conn.RoomKeys.HasKey(channelName)) return true; if (await UnlockTrackedChannelAsync(channelName)) return true; InvokeUI(() => _mainWindow.ShowError( $"#{channelName} is end-to-end encrypted and locked — nothing was sent. Enter its passphrase to unlock it first.")); return false; } /// /// Changes the current encrypted channel's passphrase: re-derives the join credential /// and re-wraps the cached room content key under the new passphrase. History is /// never re-encrypted — the room key itself doesn't change. /// private async Task HandleCmdChangeRoomPassword(string oldPassphrase, string newPassphrase) { if (!_conn.IsAuthenticated || !_conn.IsConnected) return; var channel = _mainWindow.CurrentChannel; if (string.IsNullOrEmpty(channel)) return; try { var crypto = await _conn.Api!.GetChannelCryptoAsync(channel); if (crypto is not { IsEncrypted: true } || crypto.EncryptionSalt is null) { InvokeUI(() => _mainWindow.ShowError($"#{channel} is not an end-to-end encrypted channel.")); return; } if (!_conn.RoomKeys.TryGetKey(channel, out var roomKey)) { InvokeUI(() => _mainWindow.ShowError("Unlock this channel first (rejoin it with its passphrase), then retry.")); return; } var oldDerived = RoomCrypto.DeriveKeys(oldPassphrase, Convert.FromBase64String(crypto.EncryptionSalt)); var newSalt = RoomCrypto.GenerateSalt(); var newDerived = RoomCrypto.DeriveKeys(newPassphrase, newSalt); await _conn.Api!.RekeyChannelAsync(channel, new RekeyChannelRequest( oldDerived.AuthKeyHex, newDerived.AuthKeyHex, Convert.ToBase64String(newSalt), RoomCrypto.WrapRoomKey(roomKey, newDerived.KeyEncryptionKey))); InvokeUI(() => _messageManager.AddSystemMessage(channel, "Passphrase changed. History stays readable; new members and new devices need the new passphrase.")); } catch (Exception ex) { InvokeUI(() => _mainWindow.ShowError($"Passphrase change failed: {ex.Message}")); } } private async Task HandleCmdLeaveChannel() { if (!_conn.IsConnected) return; var channel = _mainWindow.CurrentChannel; if (string.IsNullOrEmpty(channel)) return; if (channel == HubConstants.DefaultChannel) { InvokeUI(() => _mainWindow.ShowError($"You cannot leave the #{HubConstants.DefaultChannel} channel.")); return; } try { await _conn.LeaveChannelAsync(channel); // Remember the leave so the connect-time auto-join doesn't pull us back in UpdateServerConfig(server => { if (!server.LeftChannels.Contains(channel, StringComparer.OrdinalIgnoreCase)) server.LeftChannels.Add(channel); }); InvokeUI(() => _messageManager.AddSystemMessage(channel, $"You left #{channel}")); } catch (Exception ex) { InvokeUI(() => _mainWindow.ShowError($"Failed to leave channel: {ex.Message}")); } } private async Task HandleCmdSetTopic(string topic) { if (!_conn.IsAuthenticated) return; var channel = _mainWindow.CurrentChannel; if (string.IsNullOrEmpty(channel)) return; try { await _conn.Api!.UpdateChannelTopicAsync(channel, topic); InvokeUI(() => { _mainWindow.SetChannelTopic(channel, topic); _messageManager.AddSystemMessage(channel, $"Topic set to: {topic}"); }); } catch (Exception ex) { InvokeUI(() => _mainWindow.ShowError($"Failed to set topic: {ex.Message}")); } } private async Task HandleCmdListUsers() { if (!_conn.IsConnected) return; var channel = _mainWindow.CurrentChannel; if (string.IsNullOrEmpty(channel)) return; try { var users = await _conn.GetOnlineUsersAsync(channel); InvokeUI(() => { _messageManager.AddSystemMessage(channel, $"Online users in #{channel}:"); foreach (var user in users) { var displayName = user.DisplayName ?? user.Username; var statusText = user.Status.ToString(); if (!string.IsNullOrWhiteSpace(user.StatusMessage)) statusText += $" - {user.StatusMessage}"; _messageManager.AddSystemMessage(channel, $" {displayName} ({statusText})"); } }); } catch (Exception ex) { InvokeUI(() => _mainWindow.ShowError($"Failed to list users: {ex.Message}")); } } private async Task HandleCmdMeta() { if (!_conn.IsConnected || _conn.Api is null) return; var channel = _mainWindow.CurrentChannel; if (string.IsNullOrEmpty(channel)) return; try { var meta = await _conn.Api.GetChannelMetaAsync(channel); if (meta is null) { InvokeUI(() => _mainWindow.ShowError($"Channel #{channel} not found.")); return; } var size = meta.EstimatedSizeBytes <= 0 ? "0 B" : ChatMessageManager.FormatFileSize(meta.EstimatedSizeBytes); var protection = meta.IsEncrypted ? "end-to-end encrypted" : meta.IsProtected ? "password-protected" : "open"; InvokeUI(() => { _messageManager.AddSystemMessage(channel, $"Room info for #{meta.Name}:"); if (!string.IsNullOrWhiteSpace(meta.Topic)) _messageManager.AddSystemMessage(channel, $" Topic {meta.Topic}"); _messageManager.AddSystemMessage(channel, $" Room ID {meta.Id}"); _messageManager.AddSystemMessage(channel, $" Created {meta.CreatedAt.ToLocalTime():g}"); _messageManager.AddSystemMessage(channel, $" Messages {meta.MessageCount}"); _messageManager.AddSystemMessage(channel, $" Unique users {meta.UniqueUserCount}"); _messageManager.AddSystemMessage(channel, $" Est. size {size}"); _messageManager.AddSystemMessage(channel, $" Protection {protection}"); }); } catch (Exception ex) { InvokeUI(() => _mainWindow.ShowError($"Failed to fetch room info: {ex.Message}")); } } private async Task HandleCmdKickUser(string username, string? reason) { if (!_conn.IsAuthenticated) return; await _conn.Api!.KickUserAsync(username, reason); } private async Task HandleCmdBanUser(string username, string? reason) { if (!_conn.IsAuthenticated) return; await _conn.Api!.BanUserAsync(username, reason); } private async Task HandleCmdUnbanUser(string username) { if (!_conn.IsAuthenticated) return; await _conn.Api!.UnbanUserAsync(username); } private async Task HandleCmdMuteUser(string username, int? duration) { if (!_conn.IsAuthenticated) return; await _conn.Api!.MuteUserAsync(username, duration); } private async Task HandleCmdUnmuteUser(string username) { if (!_conn.IsAuthenticated) return; await _conn.Api!.UnmuteUserAsync(username); } private async Task HandleCmdAssignRole(string username, string roleStr) { if (!_conn.IsAuthenticated) return; var role = roleStr switch { "admin" => ServerRole.Admin, "mod" => ServerRole.Mod, _ => ServerRole.Member, }; await _conn.Api!.AssignRoleAsync(username, role); } private async Task HandleCmdNukeChannel() { if (!_conn.IsAuthenticated) return; var channel = _mainWindow.CurrentChannel; if (string.IsNullOrEmpty(channel)) return; await _conn.Api!.NukeChannelAsync(channel); } private async Task HandleCmdTestSound() { await _notificationSound.PlayTestAsync(); } private Task HandleCmdQuit() { InvokeUI(() => _app.RequestStop()); return Task.CompletedTask; } // ── ConnectionManager Event Wiring ───────────────────────────────────── private void WireConnectionManagerEvents() { _conn.MessageReceived += message => { InvokeUI(() => _messageManager.AddMessage(message)); if (!string.IsNullOrEmpty(_session.Username) && message.Content.Contains($"@{_session.Username}", StringComparison.OrdinalIgnoreCase)) { _ = _notificationSound.PlayAsync(); string title = $"Mentioned by {message.SenderDisplayName ?? message.SenderUsername} in #{message.ChannelName}"; string body = message.Content.Length > 200 ? message.Content[..200] + "..." : message.Content; _osNotification.Show(title, body); } }; _conn.UserJoined += (channelName, username, presence) => { InvokeUI(() => _messageManager.AddSystemMessage(channelName, $"{username} joined the channel")); List? snapshot = null; lock (_channelUsersLock) { if (presence is not null && _channelUsers.TryGetValue(channelName, out var users)) { if (!users.Any(u => u.Username.Equals(presence.Username, StringComparison.OrdinalIgnoreCase))) users.Add(presence); if (channelName.Equals(_mainWindow.CurrentChannel, StringComparison.OrdinalIgnoreCase)) snapshot = [.. users]; } } if (snapshot is not null) InvokeUI(() => _mainWindow.UpdateOnlineUsers(snapshot)); else if (channelName.Equals(_mainWindow.CurrentChannel, StringComparison.OrdinalIgnoreCase)) FetchAndUpdateOnlineUsers(); }; _conn.UserLeft += (channelName, username) => { InvokeUI(() => _messageManager.AddSystemMessage(channelName, $"{username} left the channel")); List? snapshot = null; lock (_channelUsersLock) { if (_channelUsers.TryGetValue(channelName, out var users)) { users.RemoveAll(u => u.Username.Equals(username, StringComparison.OrdinalIgnoreCase)); if (channelName.Equals(_mainWindow.CurrentChannel, StringComparison.OrdinalIgnoreCase)) snapshot = [.. users]; } } if (snapshot is not null) InvokeUI(() => _mainWindow.UpdateOnlineUsers(snapshot)); else if (channelName.Equals(_mainWindow.CurrentChannel, StringComparison.OrdinalIgnoreCase)) FetchAndUpdateOnlineUsers(); }; _conn.UserStatusChanged += presence => { InvokeUI(() => { var displayName = presence.DisplayName ?? presence.Username; var statusText = presence.Status.ToString(); if (!string.IsNullOrWhiteSpace(presence.StatusMessage)) statusText += $" - {presence.StatusMessage}"; foreach (var channelName in _mainWindow.GetChannelNames()) _messageManager.AddStatusMessage(channelName, displayName, statusText); }); // Update presence in all cached channel lists List? snapshot = null; lock (_channelUsersLock) { foreach (var (channel, users) in _channelUsers) { var idx = users.FindIndex(u => u.Username.Equals(presence.Username, StringComparison.OrdinalIgnoreCase)); if (idx >= 0) { if (presence.Status == UserStatus.Invisible) users.RemoveAt(idx); else users[idx] = presence; } else if (presence.Status != UserStatus.Invisible) { // User came back from invisible — re-add them users.Add(presence); } } var currentChannel = _mainWindow.CurrentChannel; if (!string.IsNullOrEmpty(currentChannel) && _channelUsers.TryGetValue(currentChannel, out var currentUsers)) snapshot = [.. currentUsers]; } if (snapshot is not null) InvokeUI(() => _mainWindow.UpdateOnlineUsers(snapshot)); }; _conn.UserKicked += (channelName, username, reason) => { var reasonText = reason is not null ? $" ({reason})" : ""; InvokeUI(() => _messageManager.AddSystemMessage(channelName, $"{username} was kicked{reasonText}")); List? snapshot = null; lock (_channelUsersLock) { if (_channelUsers.TryGetValue(channelName, out var users)) { users.RemoveAll(u => u.Username.Equals(username, StringComparison.OrdinalIgnoreCase)); if (channelName.Equals(_mainWindow.CurrentChannel, StringComparison.OrdinalIgnoreCase)) snapshot = [.. users]; } } if (snapshot is not null) InvokeUI(() => _mainWindow.UpdateOnlineUsers(snapshot)); }; _conn.UserBanned += (username, reason) => { var reasonText = reason is not null ? $" ({reason})" : ""; List? snapshot = null; lock (_channelUsersLock) { // Remove banned user from all cached channel lists foreach (var (channel, users) in _channelUsers) { users.RemoveAll(u => u.Username.Equals(username, StringComparison.OrdinalIgnoreCase)); } var currentChannel = _mainWindow.CurrentChannel; if (!string.IsNullOrEmpty(currentChannel) && _channelUsers.TryGetValue(currentChannel, out var currentUsers)) snapshot = [.. currentUsers]; } InvokeUI(() => { if (!username.Equals(_session.Username, StringComparison.OrdinalIgnoreCase)) { var channel = _mainWindow.CurrentChannel; if (!string.IsNullOrEmpty(channel)) _messageManager.AddSystemMessage(channel, $"{username} was banned{reasonText}"); if (snapshot is not null) _mainWindow.UpdateOnlineUsers(snapshot); } }); }; _conn.ForceDisconnected += reason => { InvokeUI(() => { _mainWindow.ShowError(reason); HandleDisconnect(); }); }; _conn.MessageDeleted += (channelName, messageId) => InvokeUI(() => _messageManager.RemoveMessage(channelName, messageId)); _conn.ChannelDeleted += channelName => { InvokeUI(() => { _conn.UntrackChannel(channelName); _mainWindow.RemoveChannel(channelName); }); }; _conn.ChannelNuked += channelName => { InvokeUI(() => { _messageManager.ClearChannelMessages(channelName); _messageManager.AddSystemMessage(channelName, "Channel history has been cleared by a moderator."); }); }; _conn.ChannelUpdated += channel => { InvokeUI(() => { if (channel.IsPublic) _mainWindow.EnsureChannelInList(channel.Name, channel.IsPublic, channel.IsProtected); _mainWindow.SetChannelTopic(channel.Name, channel.Topic); }); }; _conn.Error += errorMessage => InvokeUI(() => _mainWindow.ShowError(errorMessage)); _conn.ConnectionStatusChanged += status => InvokeUI(() => _mainWindow.UpdateStatusBar(status)); _conn.Reconnected += () => { lock (_channelUsersLock) _channelUsers.Clear(); lock (_declinedUnlocks) _declinedUnlocks.Clear(); RunAsync( async () => await _conn.RejoinChannelsAsync(), "Failed to rejoin channels after reconnect"); }; } // ── MainWindow Event Handlers ────────────────────────────────────────── private void HandleConnect() { if (_conn.IsConnected) { var confirm = MessageBox.Query(_app, "Already Connected", "You are already connected to a server.\nDisconnect and connect to a new one?", "Yes", "Cancel"); if (confirm != 0) return; HandleDisconnect(); } var dialogResult = ConnectDialog.Show(_app, _config.SavedServers); if (dialogResult is null) return; Log.Information("Connecting to {Url} as {User} (register={IsRegister})", dialogResult.ServerUrl, dialogResult.Username, dialogResult.IsRegister); RunAsync(async () => { ConnectResult result; try { result = await _conn.ConnectAsync(dialogResult, status => InvokeUI(() => _mainWindow.UpdateStatusBar(status))); } catch (Exception ex) when (dialogResult.SavedRefreshToken is not null) { Log.Warning(ex, "Saved session expired or revoked"); ClearSavedToken(dialogResult.ServerUrl); InvokeUI(() => { _mainWindow.UpdateStatusBar("Disconnected"); MessageBox.ErrorQuery(_app, "Session Expired", "Your saved session has expired or was revoked.\nPlease log in with your password.", "OK"); }); return; } _session.Username = result.Login.Username; lock (_declinedUnlocks) _declinedUnlocks.Clear(); // Persisted last-read markers for this server — used to seed unread counts, // mention highlights, and "new messages" markers from the fetched histories. var lastReads = ConfigManager.Load().SavedServers .FirstOrDefault(s => string.Equals(s.Url, dialogResult.ServerUrl, StringComparison.OrdinalIgnoreCase)) ?.LastReadMessages ?? []; InvokeUI(() => { _mainWindow.SetCurrentUser(result.Login.DisplayName ?? result.Login.Username); _mainWindow.SetChannels(result.Channels); _mainWindow.SwitchToChannel(HubConstants.DefaultChannel); foreach (var (channel, history) in result.Histories) { if (history.Count == 0) continue; Guid? lastRead = lastReads.TryGetValue(channel, out var idText) && Guid.TryParse(idText, out var id) ? id : null; _messageManager.LoadHistory(channel, history, lastRead); } _mainWindow.FocusInput(); FetchAndUpdateOnlineUsers(); }); if (result.ServerInfo is not null && !string.IsNullOrEmpty(result.ServerInfo.Version) && result.ServerInfo.Version != UpdateChecker.CurrentVersion) { InvokeUI(() => { var choice = MessageBox.Query(_app, "Version Mismatch", $"Server version {result.ServerInfo.Version} does not match client version {UpdateChecker.CurrentVersion}.\n\n" + "Some features may not work correctly. Consider updating both to the same version.", "Continue", "Disconnect"); if (choice == 1) HandleDisconnect(); }); } SaveServerToConfig(dialogResult); }, "Connection failed", "Connect"); } private void HandleDisconnect() { Log.Information("Disconnecting from server"); lock (_channelUsersLock) _channelUsers.Clear(); _pendingReply = null; InvokeUI(() => _mainWindow.SetReplyingTo(null)); PersistLastReads(); RunAsync(async () => { await _conn.CleanupAsync(); InvokeUI(() => { _mainWindow.ClearAll(); _mainWindow.UpdateStatusBar("Disconnected"); }); }, "Disconnect error", "Disconnect"); } private void HandleLogout() { Log.Information("Logging out from server"); PersistLastReads(); RunAsync(async () => { var baseUrl = _conn.Api?.BaseUrl; await _conn.LogoutAsync(); if (baseUrl is not null) ClearSavedToken(baseUrl); await _conn.CleanupAsync(); InvokeUI(() => { _mainWindow.ClearAll(); _mainWindow.UpdateStatusBar("Disconnected"); }); }, "Logout error", "Logout"); } private void HandleMessageSubmitted(string channelName, string content) { if (!_conn.IsConnected) { _mainWindow.ShowError("Not connected to a server."); return; } if (_commandHandler.IsCommand(content)) { RunAsync(async () => { var result = await _commandHandler.HandleAsync(content); if (result.Message is not null) { InvokeUI(() => { if (result.IsError) _mainWindow.ShowError(result.Message); else _messageManager.AddSystemMessage(channelName, result.Message); }); } }, "Command failed"); return; } // Staged files → one message with the typed caption plus those attachments. if (_stagedAttachments.Count > 0) { SendStagedMessage(channelName, content); return; } // A pending reply only applies to a plain text message in its own channel Guid? replyTo = _pendingReply is { } pending && pending.Channel.Equals(channelName, StringComparison.OrdinalIgnoreCase) ? pending.MessageId : null; RunAsync(async () => { if (!await EnsureRoomUnlockedForSendAsync(channelName)) return; await _conn.SendMessageAsync(channelName, content, replyTo); if (replyTo is not null) InvokeUI(ClearPendingReply); }, "Send failed"); } private void HandleReplyRequested(Guid messageId, string sender, string snippet) { var channel = _mainWindow.CurrentChannel; if (string.IsNullOrEmpty(channel)) return; _pendingReply = (channel, messageId); if (snippet.Length > 40) snippet = snippet[..40] + "…"; _mainWindow.SetReplyingTo($"{sender}: {snippet}"); } private void HandleReplyCancelRequested() => ClearPendingReply(); private void ClearPendingReply() { _pendingReply = null; _mainWindow.SetReplyingTo(null); } private void HandleDeleteMessageRequested(Guid messageId) { if (!_conn.IsAuthenticated) return; // The server enforces the hierarchy rule (own message, or Mod+ over a strictly // lower role) and broadcasts the deletion; the local list updates on that event. RunAsync(async () => await _conn.Api!.DeleteMessageAsync(messageId), "Failed to delete message"); } private void HandleChannelSelected(string channelName) { if (!_conn.IsConnected) return; // A reply pending in the previous channel doesn't carry over if (_pendingReply is { } pending && !pending.Channel.Equals(channelName, StringComparison.OrdinalIgnoreCase)) InvokeUI(ClearPendingReply); // Checkpoint read positions — the previous channel was just marked read PersistLastReads(); RunAsync(async () => { if (_conn.TrackChannel(channelName)) { var joined = await JoinChannelWithPasswordPromptAsync(channelName, null); if (joined is null) { // User cancelled the password prompt — back to the default channel _conn.UntrackChannel(channelName); InvokeUI(() => _mainWindow.SwitchToChannel(HubConstants.DefaultChannel)); return; } // A deliberate join cancels any earlier /leave exclusion UpdateServerConfig(server => server.LeftChannels.RemoveAll(c => c.Equals(channelName, StringComparison.OrdinalIgnoreCase))); } else if (NeedsUnlockPrompt(channelName)) { // Auto-join/reconnect already hub-joined this E2E channel but discarded the // key envelope — selecting it is the user's cue to unlock it. await UnlockTrackedChannelAsync(channelName); } try { var history = await _conn.GetHistoryAsync(channelName); InvokeUI(() => _messageManager.LoadHistory(channelName, history)); } catch { // History might not be available } FetchAndUpdateOnlineUsers(); }, "Failed to join channel"); } private void HandleLoadMoreRequested() { if (!_conn.IsConnected) return; var channel = _mainWindow.CurrentChannel; if (string.IsNullOrEmpty(channel)) return; if (!_channelsLoadingMore.Add(channel)) return; var offset = _messageManager.GetMessages(channel)?.Count ?? 0; RunAsync(async () => { try { var history = await _conn.GetHistoryAsync(channel, HubConstants.DefaultHistoryCount, offset); InvokeUI(() => _messageManager.PrependHistory(channel, history)); } finally { _channelsLoadingMore.Remove(channel); } }, "Failed to load more messages"); } private void HandleChannelJoinFromMessage(string channelName) { if (!_conn.IsConnected) return; InvokeUI(() => { _mainWindow.EnsureChannelInList(channelName); _mainWindow.SwitchToChannel(channelName); }); HandleChannelSelected(channelName); } private void HandleSearchRequested() { var result = SearchDialog.Show(_app, _mainWindow.GetChannelNames()); if (result is null) return; switch (result.Type) { case SearchResultType.Channel: _mainWindow.SwitchToChannel(result.Key); HandleChannelSelected(result.Key); break; case SearchResultType.Action: switch (result.Key) { case "connect": HandleConnect(); break; case "disconnect": HandleDisconnect(); break; case "logout": HandleLogout(); break; case "profile": HandleProfileRequested(); break; case "status": HandleStatusRequested(); break; case "create-channel": HandleCreateChannelRequested(); break; case "delete-channel": HandleDeleteChannelRequested(); break; case "servers": HandleSavedServersRequested(); break; case "toggle-users": _mainWindow.ToggleUsersPanel(); break; case "updates": HandleCheckForUpdatesRequested(); break; case "quit": _app.RequestStop(); break; } break; } } private void HandleProfileRequested() { HandleViewProfile(null); } private void HandleViewProfile(string? username) { var isOwnProfile = string.IsNullOrWhiteSpace(username) || username.Equals(_session.Username, StringComparison.OrdinalIgnoreCase); Task.Run(async () => { UserProfileDto? profile = null; try { if (_conn.IsAuthenticated) { var target = isOwnProfile ? _session.Username : username!; if (!string.IsNullOrEmpty(target)) profile = await _conn.Api!.GetUserProfileAsync(target); } } catch (Exception ex) { InvokeUI(() => _mainWindow.ShowError($"Failed to load profile: {ex.Message}")); return; } InvokeUI(() => { if (isOwnProfile) { var action = ProfileViewDialog.ShowOwn(_app, profile, _session.Status, _session.StatusMessage); switch (action) { case ProfileAction.EditProfile: HandleEditProfile(profile); break; case ProfileAction.SetStatus: HandleStatusRequested(); break; } } else { ProfileViewDialog.Show(_app, profile); } }); }); } private void HandleEditProfile(UserProfileDto? currentProfile) { var editResult = ProfileEditDialog.Show(_app, currentProfile?.DisplayName, currentProfile?.Bio, currentProfile?.NicknameColor, _config.Notifications.Enabled, _config.Notifications.Volume); if (editResult is null) return; RunAsync(async () => { if (!_conn.IsAuthenticated) return; await _conn.Api!.UpdateProfileAsync(new UpdateProfileRequest( editResult.DisplayName, editResult.Bio, editResult.NicknameColor)); if (editResult.DisplayName is not null) { InvokeUI(() => { _mainWindow.SetCurrentUser(editResult.DisplayName); _mainWindow.UpdateStatusBar("Connected"); }); } // Upload avatar if specified if (editResult.AvatarPath is not null) { try { await AvatarHelper.UploadAsync(_conn.Api!, editResult.AvatarPath); var channel = _mainWindow.CurrentChannel; if (!string.IsNullOrEmpty(channel)) InvokeUI(() => _messageManager.AddSystemMessage(channel, "Avatar updated.")); } catch (Exception ex) { Log.Error(ex, "Avatar upload failed for {Target}", editResult.AvatarPath); InvokeUI(() => _mainWindow.ShowError($"Avatar upload failed: {ex.Message}")); } } if (editResult.NotificationSoundEnabled.HasValue) { _config.Notifications.Enabled = editResult.NotificationSoundEnabled.Value; _notificationSound.SetEnabled(editResult.NotificationSoundEnabled.Value); } if (editResult.NotificationVolume.HasValue) { _config.Notifications.Volume = editResult.NotificationVolume.Value; _notificationSound.SetVolume(editResult.NotificationVolume.Value); } _config.DefaultPreset = new AccountPreset { DisplayName = editResult.DisplayName, Bio = editResult.Bio, NicknameColor = editResult.NicknameColor }; ConfigManager.Save(_config); }, "Profile update failed"); } private void HandleStatusRequested() { var result = StatusDialog.Show(_app, _session.Status, _session.StatusMessage); if (result is null) return; _session.Status = result.Status; _session.StatusMessage = result.StatusMessage; if (_conn.IsConnected) { RunAsync( async () => await _conn.UpdateStatusAsync(result.Status, result.StatusMessage), "Status update failed"); } } private void HandleThemeSelected(string themeName) { Log.Information("Theme selected: {Theme}", themeName); var theme = ThemeManager.GetTheme(themeName); ThemeManager.ApplyTheme(theme); _config.ActiveTheme = themeName; ConfigManager.Save(_config); InvokeUI(() => { _mainWindow.ApplyColorSchemes(); _mainWindow.SetNeedsDraw(); Log.Debug("Theme applied and UI refreshed"); }); } private void HandleSavedServersRequested() { if (_config.SavedServers.Count == 0) { MessageBox.Query(_app, "Saved Servers", "No saved servers yet.\nConnect to a server to save it automatically.", "OK"); return; } var serverLines = _config.SavedServers .Select(s => { var session = !string.IsNullOrEmpty(s.RefreshToken) ? " [session saved]" : ""; return $"{s.Name} ({s.Url}) - {s.Username ?? "?"} - {s.LastConnected:yyyy-MM-dd}{session}"; }) .ToList(); MessageBox.Query(_app, "Saved Servers", string.Join("\n", serverLines), "OK"); } private void HandleCreateChannelRequested() { if (!_conn.IsAuthenticated || !_conn.IsConnected) { _mainWindow.ShowError("Not connected to a server."); return; } var result = CreateChannelDialog.Show(_app); if (result is null) return; RunAsync(async () => { // Password rooms are end-to-end encrypted: derive the join credential and // wrap a fresh room content key locally — the passphrase never leaves here. string? wirePassword = null, saltB64 = null, wrappedKey = null; byte[]? roomKey = null; if (result.Password is not null) { if (result.Password.Length < ValidationConstants.MinChannelPasswordLength) { InvokeUI(() => _mainWindow.ShowError( $"Channel password must be at least {ValidationConstants.MinChannelPasswordLength} characters.")); return; } var salt = RoomCrypto.GenerateSalt(); var derived = RoomCrypto.DeriveKeys(result.Password, salt); roomKey = RoomCrypto.GenerateRoomKey(); wirePassword = derived.AuthKeyHex; saltB64 = Convert.ToBase64String(salt); wrappedKey = RoomCrypto.WrapRoomKey(roomKey, derived.KeyEncryptionKey); } var channel = await _conn.Api!.CreateChannelAsync( result.Name, result.Topic, result.IsPublic, wirePassword, saltB64, wrappedKey); if (channel is null) return; if (roomKey is not null) _conn.RoomKeys.StoreKey(channel.Name, roomKey); var history = (await _conn.JoinChannelAsync(channel.Name)).History; InvokeUI(() => { _mainWindow.EnsureChannelInList(channel.Name, channel.IsPublic, channel.IsProtected); _mainWindow.SetChannelTopic(channel.Name, channel.Topic); _mainWindow.SwitchToChannel(channel.Name); if (history.Count > 0) _messageManager.LoadHistory(channel.Name, history); }); FetchAndUpdateOnlineUsers(); }, "Failed to create channel"); } private void HandleDeleteChannelRequested() { if (!_conn.IsAuthenticated || !_conn.IsConnected) { _mainWindow.ShowError("Not connected to a server."); return; } var channel = _mainWindow.CurrentChannel; if (string.IsNullOrEmpty(channel)) { _mainWindow.ShowError("No channel selected."); return; } if (channel == HubConstants.DefaultChannel) { _mainWindow.ShowError($"The #{HubConstants.DefaultChannel} channel cannot be deleted."); return; } var confirm = MessageBox.Query(_app, "Delete Channel", $"Are you sure you want to delete #{channel}?\nThis will remove all messages permanently.", "Delete", "Cancel"); if (confirm != 0) return; RunAsync(async () => { await _conn.Api!.DeleteChannelAsync(channel); _conn.UntrackChannel(channel); InvokeUI(() => { _mainWindow.RemoveChannel(channel); _mainWindow.SwitchToChannel(HubConstants.DefaultChannel); _messageManager.AddSystemMessage(HubConstants.DefaultChannel, $"Channel #{channel} has been deleted."); }); }, "Failed to delete channel"); } private void HandleAudioPlayRequested(string attachmentUrl, string fileName) { if (!_conn.IsAuthenticated) return; RunAsync(async () => { InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}...")); var tempPath = await DownloadAttachmentAsync(attachmentUrl, fileName); InvokeUI(() => AudioPlayerDialog.Show(_app, _audioPlayback, tempPath, fileName)); }, "Failed to play audio"); } /// /// Downloads an attachment to a temp file, decrypting it locally when the current /// channel is end-to-end encrypted (the server stores those blobs as ciphertext). /// private async Task DownloadAttachmentAsync(string attachmentUrl, string fileName) { var tempPath = await _conn.Api!.DownloadFileToTempAsync(attachmentUrl, fileName); var channel = _mainWindow.CurrentChannel; if (!string.IsNullOrEmpty(channel) && _conn.RoomKeys.TryGetKey(channel, out var roomKey)) { try { var blob = await File.ReadAllBytesAsync(tempPath); await File.WriteAllBytesAsync(tempPath, RoomCrypto.DecryptBytes(blob, roomKey)); } catch (Exception ex) { Log.Warning(ex, "Attachment {File} did not decrypt with the room key — keeping raw bytes", fileName); } } return tempPath; } /// File extensions the "[open]" action will hand to the OS image viewer for E2E rooms. private static readonly HashSet ImageOpenExtensions = new(StringComparer.OrdinalIgnoreCase) { ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", }; /// /// Views an image without saving it to the user's downloads. Plain channels open the /// file's web URL in the default browser (the server serves files by capability URL, so /// no auth token is needed). E2E-encrypted channels would render as ciphertext in a /// browser, so the blob is downloaded, decrypted locally, and opened from a temp file. /// private void HandleImageOpenRequested(string attachmentUrl, string fileName) { if (!_conn.IsAuthenticated) return; var channel = _mainWindow.CurrentChannel; var isEncryptedRoom = !string.IsNullOrEmpty(channel) && _conn.RoomKeys.TryGetKey(channel, out _); if (!isEncryptedRoom) { var webUrl = attachmentUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || attachmentUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ? attachmentUrl : $"{_conn.Api!.BaseUrl}/{attachmentUrl.TrimStart('/')}"; try { System.Diagnostics.Process.Start( new System.Diagnostics.ProcessStartInfo(webUrl) { UseShellExecute = true }); } catch (Exception ex) { Log.Warning(ex, "Failed to open image URL in browser: {Url}", webUrl); InvokeUI(() => _messageManager.AddSystemMessage(channel, $"Couldn't open a browser — image URL: {webUrl}")); } return; } // In E2E rooms the attachment kind is sender-declared, so only hand real image // extensions to the OS viewer; anything else goes through the save path instead. if (!ImageOpenExtensions.Contains(Path.GetExtension(fileName))) { HandleImageSaveRequested(attachmentUrl, fileName); return; } RunAsync(async () => { InvokeUI(() => _messageManager.AddSystemMessage(channel, $"Decrypting {fileName}...")); var tempPath = await DownloadAttachmentAsync(attachmentUrl, fileName); var psi = new System.Diagnostics.ProcessStartInfo(tempPath) { UseShellExecute = true }; System.Diagnostics.Process.Start(psi); }, "Failed to open image"); } private void HandleImageSaveRequested(string attachmentUrl, string fileName) { if (!_conn.IsAuthenticated) return; RunAsync(async () => { InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}...")); var tempPath = await DownloadAttachmentAsync(attachmentUrl, fileName); var destination = DedupPath(GetDownloadDir(), fileName); File.Move(tempPath, destination); InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Image saved to: {destination}")); }, "Failed to save image"); } /// /// Resolves the folder downloads are written to: the user's configured /// if set, otherwise the OS Downloads folder. /// Falls back to the temp folder if neither can be created. /// private string GetDownloadDir() { var dir = _config.DownloadPath; if (string.IsNullOrWhiteSpace(dir)) dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads"); try { Directory.CreateDirectory(dir); return dir; } catch (Exception ex) { Log.Warning(ex, "Download folder {Dir} is not usable; falling back to temp", dir); return Path.GetTempPath(); } } /// Appends " (n)" before the extension until the path doesn't collide with an existing file. private static string DedupPath(string dir, string fileName) { var stem = Path.GetFileNameWithoutExtension(fileName); var ext = Path.GetExtension(fileName); var dest = Path.Combine(dir, fileName); for (var i = 1; File.Exists(dest); i++) dest = Path.Combine(dir, $"{stem} ({i}){ext}"); return dest; } /// /// File extensions considered safe to open with the system default application. /// Everything else is downloaded only — never auto-opened via UseShellExecute. /// private static readonly HashSet SafeOpenExtensions = new(StringComparer.OrdinalIgnoreCase) { ".mp4", ".webm", ".mkv", ".avi", ".mov", // video ".pdf", ".txt", ".csv", ".json", ".xml", // documents }; private void HandleFileDownloadRequested(string attachmentUrl, string fileName) { if (!_conn.IsAuthenticated) return; RunAsync(async () => { InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}...")); var tempPath = await DownloadAttachmentAsync(attachmentUrl, fileName); var destination = DedupPath(GetDownloadDir(), fileName); File.Move(tempPath, destination); InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Saved to: {destination}")); if (SafeOpenExtensions.Contains(Path.GetExtension(fileName))) { try { var psi = new System.Diagnostics.ProcessStartInfo(destination) { UseShellExecute = true }; System.Diagnostics.Process.Start(psi); } catch (Exception ex) { Log.Warning(ex, "Failed to open file with default app: {Path}", destination); } } }, "Failed to download file"); } /// /// Sets the download folder. With no argument, opens the OS-native folder picker; if that /// isn't available (headless, missing tool), tells the user to pass a path instead. With an /// argument, sets that path directly (the fallback for machines with no native picker). /// private Task HandleCmdSetDownloadPath(string args) { var current = _config.DownloadPath ?? GetDownloadDir(); if (!string.IsNullOrWhiteSpace(args)) { SetDownloadPath(args.Trim()); return Task.CompletedTask; } RunAsync(async () => { var result = await NativeFolderPicker.PickFolderAsync(current); InvokeUI(() => { switch (result.Outcome) { case PickerOutcome.Chosen when result.Path is not null: SetDownloadPath(result.Path); break; case PickerOutcome.Cancelled: _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, "Download folder unchanged."); break; case PickerOutcome.Unavailable: _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"No native folder picker here. Current download folder: {current}\nSet one with: /downloadpath "); break; } }); }, "Failed to open folder picker"); return Task.CompletedTask; } private void SetDownloadPath(string path) { try { Directory.CreateDirectory(path); } catch (Exception ex) { InvokeUI(() => _mainWindow.ShowError($"Can't use that folder: {ex.Message}")); return; } _config.DownloadPath = path; ConfigManager.Save(_config); InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Download folder set to: {path}")); } private void HandleCheckForUpdatesRequested() { RunAsync(_updateService.CheckNowAsync, "Failed to check for updates"); } private void HandleRollbackRequested() { if (!UpdateBackupService.BackupExists()) { MessageBox.ErrorQuery(_app, "No Backup", "No backup is available to restore.", "OK"); return; } var info = UpdateBackupService.GetBackupInfo(); var confirm = MessageBox.Query(_app, "Rollback Update", $"Restore to version {info?.Version ?? "unknown"}?\n\nThe app will restart.", "Restore", "Cancel"); if (confirm != 0) return; try { UpdateBackupService.RestoreBackup(); // RestoreBackup calls Environment.Exit(0) } catch (Exception ex) { Log.Error(ex, "Rollback failed"); MessageBox.ErrorQuery(_app, "Rollback Failed", $"Could not restore: {ex.Message}", "OK"); } } // ── Private Helpers ──────────────────────────────────────────────────── private void FetchAndUpdateOnlineUsers() { var channel = _mainWindow.CurrentChannel; if (string.IsNullOrEmpty(channel) || !_conn.IsConnected) return; Task.Run(async () => { try { var users = await _conn.GetOnlineUsersAsync(channel); lock (_channelUsersLock) _channelUsers[channel] = users; InvokeUI(() => _mainWindow.UpdateOnlineUsers(users)); } catch (Exception ex) { Log.Debug(ex, "Failed to fetch online users for {Channel}", channel); } }); } private void SaveServerToConfig(ConnectDialogResult result) { // Update the existing entry in place (never replace it) — the per-server entry also // carries cached room keys, left channels, and last-read markers that must survive. var config = ConfigManager.Load(); var server = config.SavedServers.FirstOrDefault(s => string.Equals(s.Url, result.ServerUrl, StringComparison.OrdinalIgnoreCase)); if (server is null) { server = new SavedServer { Name = new Uri(result.ServerUrl).Host, Url = result.ServerUrl }; config.SavedServers.Add(server); } server.Username = result.Username; server.RefreshToken = result.RememberMe ? _conn.Api!.RefreshToken : null; server.RememberMe = result.RememberMe; server.LastConnected = DateTimeOffset.Now; ConfigManager.Save(config); _config = config; Log.Information("Connected successfully to {Url}", result.ServerUrl); } /// /// Mutates the current server's config entry and persists it. No-op when not /// authenticated or the server isn't saved. /// private void UpdateServerConfig(Action mutate) { var url = _conn.Api?.BaseUrl; if (url is null) return; var config = ConfigManager.Load(); var server = config.SavedServers.FirstOrDefault(s => string.Equals(s.Url, url, StringComparison.OrdinalIgnoreCase)); if (server is null) return; mutate(server); ConfigManager.Save(config); _config = config; } /// /// Persists the in-memory last-read message ids to the current server's config entry, /// so unread/mention state can be reconstructed on the next connect. /// private void PersistLastReads() { var lastReads = _messageManager.LastReadIds; if (lastReads.Count == 0) return; UpdateServerConfig(server => { foreach (var (channel, id) in lastReads) server.LastReadMessages[channel] = id.ToString(); }); } private void ClearSavedToken(string serverUrl) { var config = ConfigManager.Load(); var server = config.SavedServers.FirstOrDefault(s => string.Equals(s.Url, serverUrl, StringComparison.OrdinalIgnoreCase)); if (server is not null) { server.RefreshToken = null; ConfigManager.Save(config); _config = config; } } }