diff --git a/README.md b/README.md index 0b3f8be..b36459d 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,7 @@ For direct TLS without a reverse proxy, the IRC gateway can terminate TLS itself | ------- | ----------- | | `/join [password]` | Join a channel (passphrase for encrypted channels) | | `/passwd ` | Change the current encrypted channel's passphrase (creator only) | +| `/size [s\|m\|l]` | ASCII-art size for attached images (no arg = picker) | | `/downloadpath [path]` | Set the download folder (no path = native folder picker) | | `/leave` | Leave current channel | | `/topic ` | Set channel topic (creator only) | @@ -242,6 +243,8 @@ For direct TLS without a reverse proxy, the IRC gateway can terminate TLS itself | `/help` | Show help | | `/quit` | Exit | +**Message actions:** **right-click a message** for a context menu — delete, save/download/play its attachment, mention the sender, view their profile, or copy the text. (Keyboard alternative: press F6 to focus the message list, select with the arrow keys, and press Delete; F6 again returns to the input.) You can always delete your own messages; moderators and above can delete others' messages, but only from users below their own role. + ## Themes `/theme ` to switch: diff --git a/docs/changelog/v0.2.12.md b/docs/changelog/v0.2.12.md index 8b5a20c..8cceccf 100644 --- a/docs/changelog/v0.2.12.md +++ b/docs/changelog/v0.2.12.md @@ -18,6 +18,7 @@ Private channels are now genuinely private: password-protected channels are end- - Each image attachment renders its own ASCII preview with its own "save original" action; audio/file attachments each get their own play/download line. - In encrypted channels every attachment is encrypted individually (blob + ASCII preview), and the caption is room-encrypted — the server still stores only ciphertext and can report count/size but not contents. - Up to 10 attachments per message. +- **Right-click message menu** — right-click any message for a context menu: save/download/play its attachment, mention the sender, view their profile, copy the text, or delete the message. (Keyboard: F6 focuses the message list for arrow-key selection + Delete.) The selected message is now highlighted while the list is focused. - **Message deletion** — press Delete on a selected message to remove it. You can always delete your own messages; moderators and above can delete others' messages, but only from users **below their own role** (a mod can't delete an admin's or owner's message). Deleting a message also removes its attachment blobs from server storage. - **Customizable download folder** — `/downloadpath` opens your OS-native folder picker (Windows Explorer / macOS Finder / Linux GTK or KDE) to choose where downloaded attachments and saved images go; `/downloadpath ` sets it directly (the fallback when no native picker is available). Downloaded files now land in that folder (with automatic `(n)` de-duplication) instead of a temp directory. - `/join [password]` — join protected channels inline, or let the client prompt: joining a protected channel without a password opens a masked prompt that re-prompts on a wrong password @@ -25,11 +26,13 @@ Private channels are now genuinely private: password-protected channels are end- - IRC `MODE` implemented — `MODE #chan` reports `+k`/`+`, `MODE #chan +k ` sets and `-k` clears the room password (channel creator or admin only), ban-list probes get a clean empty reply, and `CHANMODES` is advertised in ISUPPORT - IRC `TOPIC` set support — the channel creator can change the topic from IRC; the change broadcasts to connected TUI clients (previously topic changes were rejected with a stub error) - Attach a file by drag & drop or by pasting — drop a file onto the terminal, or **copy a file in your file manager and press Ctrl+V**, to stage it as an attachment (the next Enter sends it with your caption). Multiple files at once are supported. Ctrl+V still pastes text when the clipboard holds text; Ctrl+Y is a paste alias. On Windows the copied-file paste reads the clipboard's file list directly (Windows Terminal never pastes copied files as text), with `xclip`/`wl-paste` used on Linux +- Pick ASCII-art size for attached images — `/size` opens a Small/Medium/Large picker (40×40 / 80×80 / 120×120) with descriptions, `/size ` sets it directly, and `/send -l` sets it for that message. The choice is a saved preference and applies to copy-paste/drag-drop images (which have no per-file flag); the current size is shown in the staging tray - New `TransparentLight` theme — dark characters on a transparent background, for light terminal color schemes (`/theme transparentlight`) - Timestamps in messages are now aware of the current culture and display the short time pattern for today's messages and the short date+time pattern for older messages. ## Bug Fixes +- Attachments whose files have been pruned (retention cleanup deletes blobs older than `Storage:RetentionDays` but left the message rows) no longer render a dead download/preview. When channel history loads, the server checks which attachment blobs still exist: missing ones are dropped from the message, and an attachment-only message whose files are all gone is removed from the database. - Fixed intermittent crash on Ctrl+W — Terminal.Gui binds Ctrl+W to clipboard-cut, and Windows clipboard contention (another app holding the clipboard) threw an unhandled `Win32Exception` that took the app down. Ctrl+W now deletes the previous word (readline behavior, no clipboard), and all clipboard shortcuts (Ctrl+X/C/V/Y) are guarded so transient clipboard failures log a warning instead of crashing - Fixed emoji shortcode replacement permanently disabling itself if a cursor update threw mid-replacement - IRC `LIST` no longer leaks private channels; protected channels are marked `[+k]` diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs index 428b5fc..77b4c0d 100644 --- a/src/EchoHub.Client/AppOrchestrator.cs +++ b/src/EchoHub.Client/AppOrchestrator.cs @@ -116,6 +116,7 @@ public sealed class AppOrchestrator : IDisposable _commandHandler.OnJoinChannel += HandleCmdJoinChannel; _commandHandler.OnChangeRoomPassword += HandleCmdChangeRoomPassword; _commandHandler.OnClearAttachments += HandleCmdClearAttachments; + _commandHandler.OnSetAsciiSize += HandleCmdSetAsciiSize; _commandHandler.OnSetDownloadPath += HandleCmdSetDownloadPath; _commandHandler.OnLeaveChannel += HandleCmdLeaveChannel; _commandHandler.OnSetTopic += HandleCmdSetTopic; @@ -195,18 +196,84 @@ public sealed class AppOrchestrator : IDisposable 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(() => _mainWindow.SetStagedAttachments(_stagedAttachments.Select(Path.GetFileName).OfType().ToList())); + InvokeUI(RefreshStagingTray); return Task.CompletedTask; } private Task HandleCmdClearAttachments() { _stagedAttachments.Clear(); - InvokeUI(() => _mainWindow.SetStagedAttachments([])); + InvokeUI(RefreshStagingTray); return Task.CompletedTask; } + /// + /// 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 + @@ -216,30 +283,32 @@ public sealed class AppOrchestrator : IDisposable { var staged = _stagedAttachments.ToList(); _stagedAttachments.Clear(); - InvokeUI(() => _mainWindow.SetStagedAttachments([])); + InvokeUI(RefreshStagingTray); var hasRoomKey = _conn.RoomKeys.TryGetKey(channel, out var roomKey); + var size = _config.DefaultAsciiSize; RunAsync(async () => { var outgoing = new List(); foreach (var path in staged) - outgoing.Add(await BuildOutgoingAttachmentAsync(path, hasRoomKey ? roomKey : null)); + 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); + await _conn.Api!.SendMessageWithAttachmentsAsync(channel, wireContent, outgoing, size); }, "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 and room-encrypted — so the server never sees the file or image contents. + /// locally (at ) and room-encrypted — so the server never sees the + /// file or image contents. /// - private static async Task BuildOutgoingAttachmentAsync(string path, byte[]? roomKey) + private static async Task BuildOutgoingAttachmentAsync(string path, byte[]? roomKey, string size) { var fileName = Path.GetFileName(path); @@ -255,7 +324,7 @@ public sealed class AppOrchestrator : IDisposable if (FileValidationHelper.IsValidImage(ms)) { declaredKind = "image"; - var (w, h) = ImageToAsciiService.GetDimensions(null); + var (w, h) = ImageToAsciiService.GetDimensions(size); ms.Position = 0; preview = RoomCrypto.EncryptText(new ImageToAsciiService().ConvertToAscii(ms, w, h), roomKey); } diff --git a/src/EchoHub.Client/Commands/CommandHandler.cs b/src/EchoHub.Client/Commands/CommandHandler.cs index 59c7c06..3f7fda8 100644 --- a/src/EchoHub.Client/Commands/CommandHandler.cs +++ b/src/EchoHub.Client/Commands/CommandHandler.cs @@ -17,6 +17,7 @@ public class CommandHandler public event Func? OnChangeRoomPassword; public event Func? OnClearAttachments; public event Func? OnSetDownloadPath; + public event Func? OnSetAsciiSize; public event Func? OnLeaveChannel; public event Func? OnSetTopic; public event Func? OnListUsers; @@ -51,6 +52,7 @@ public class CommandHandler "theme" => await HandleTheme(args), "send" => await HandleSend(args), "clear" => await HandleClear(), + "size" or "asciisize" => await HandleAsciiSize(args), "downloadpath" or "downloads" => await HandleDownloadPath(args), "profile" => await HandleProfile(args), "avatar" => await HandleAvatar(args), @@ -176,6 +178,14 @@ public class CommandHandler return new CommandResult(true, "Cleared staged attachments."); } + private async Task HandleAsciiSize(string args) + { + // No argument → open the size picker; an argument (s/m/l or small/medium/large) sets it. + if (OnSetAsciiSize is not null) + await OnSetAsciiSize(args.Trim()); + return new CommandResult(true); + } + private async Task HandleDownloadPath(string args) { // No argument → open the native folder picker; an argument sets the path directly. @@ -380,7 +390,10 @@ public class CommandHandler /send [-s|-m|-l] - Stage a file to attach (Enter sends with your text) /send [-s|-m|-l] - Send an image URL immediately /clear - Drop all staged attachments + /size [s|m|l] - ASCII art size for attached images (no arg = picker) (Tip: copy a file and press Ctrl+V, or drag a file onto the window, to attach it.) + (Tip: right-click a message for actions — delete, save/download/play attachment, + mention, view profile, copy. Or press F6 to pick a message, then Delete.) /downloadpath [path] - Set download folder (no path = native folder picker) /avatar - Set your avatar /profile [username] - View a profile diff --git a/src/EchoHub.Client/Config/ClientConfig.cs b/src/EchoHub.Client/Config/ClientConfig.cs index ee87a69..6ebedd6 100644 --- a/src/EchoHub.Client/Config/ClientConfig.cs +++ b/src/EchoHub.Client/Config/ClientConfig.cs @@ -12,6 +12,12 @@ public class ClientConfig /// OS Downloads folder is used. Set via the native folder picker or /downloadpath. /// public string? DownloadPath { get; set; } + + /// + /// ASCII-art rendering size for images you attach: "s" (40×40), "m" (80×80), or "l" (120×120). + /// Applies to copy-paste/drag-drop attachments, which have no per-file size flag. + /// + public string DefaultAsciiSize { get; set; } = "m"; } public class NotificationConfig diff --git a/src/EchoHub.Client/UI/Chat/ChatListSource.cs b/src/EchoHub.Client/UI/Chat/ChatListSource.cs index d999d72..6e221db 100644 --- a/src/EchoHub.Client/UI/Chat/ChatListSource.cs +++ b/src/EchoHub.Client/UI/Chat/ChatListSource.cs @@ -65,6 +65,13 @@ public class ChatListSource : IListDataSource var chatLine = _lines[item]; var normalAttr = listView.GetAttributeForRole(VisualRole.Normal); + + // Highlight the selected row (whole width) while the list has focus — used by the + // F6 selection flow and right-click. The custom source must draw this itself. + var focusAttr = selected && listView.HasFocus + ? listView.GetAttributeForRole(VisualRole.Focus) + : (Attribute?)null; + var mentionBg = chatLine.IsMention ? ChatColors.MentionHighlightAttr.Background : (Color?)null; int charPos = 0; @@ -72,11 +79,19 @@ public class ChatListSource : IListDataSource foreach (var segment in chatLine.Segments) { - var attr = segment.Color ?? normalAttr; - if (attr.Background == Color.None) - attr = attr with { Background = normalAttr.Background }; - if (mentionBg.HasValue) - attr = attr with { Background = mentionBg.Value }; + Attribute attr; + if (focusAttr is { } focus) + { + attr = focus; + } + else + { + attr = segment.Color ?? normalAttr; + if (attr.Background == Color.None) + attr = attr with { Background = normalAttr.Background }; + if (mentionBg.HasValue) + attr = attr with { Background = mentionBg.Value }; + } listView.SetAttribute(attr); foreach (var grapheme in GraphemeHelper.GetGraphemes(segment.Text)) @@ -91,7 +106,8 @@ public class ChatListSource : IListDataSource } } - var fillAttr = mentionBg.HasValue ? new Attribute(normalAttr.Foreground, mentionBg.Value) : normalAttr; + var fillAttr = focusAttr + ?? (mentionBg.HasValue ? new Attribute(normalAttr.Foreground, mentionBg.Value) : normalAttr); listView.SetAttribute(fillAttr); for (int i = drawnChars; i < width; i++) listView.AddStr(" "); diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index 1be8d70..13c63f4 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -40,7 +40,7 @@ public sealed partial class MainWindow : Runnable private readonly UserListSource _usersListSource; private bool _usersPanelVisible = true; private const int UsersPanelWidth = 22; - private const string DefaultInputTitle = "Message │ Enter=send │ Ctrl+N=newline │ Tab=complete │ Ctrl+K=search"; + private const string DefaultInputTitle = "Message │ Enter=send │ Tab=complete │ Ctrl+K=search │ F6=pick message"; private static readonly Key F2Key = Key.F2; private bool _hasStagedAttachments; @@ -57,12 +57,13 @@ public sealed partial class MainWindow : Runnable private static readonly Key CtrlXKey = Key.X.WithCtrl; private static readonly Key CtrlCKey = Key.C.WithCtrl; private static readonly Key CtrlYKey = Key.Y.WithCtrl; + private static readonly Key F6Key = Key.F6; // Available slash commands for Tab autocomplete private static readonly string[] SlashCommands = [ "/status", "/nick", "/color", "/theme", "/send", - "/avatar", "/profile", "/servers", "/join", "/passwd", "/leave", "/clear", "/downloadpath", + "/avatar", "/profile", "/servers", "/join", "/passwd", "/leave", "/clear", "/size", "/downloadpath", "/topic", "/users", "/kick", "/ban", "/unban", "/mute", "/unmute", "/role", "/nuke", "/test-sound", "/quit", "/help" ]; @@ -248,6 +249,7 @@ public sealed partial class MainWindow : Runnable _messageList.Source = new ChatListSource(); _messageList.Accepting += OnMessageListAccepting; _messageList.KeyDown += OnMessageListKeyDown; + _messageList.MouseEvent += OnMessageListMouseEvent; _messageList.VerticalScrollBar.Scrolled += OnMessageListVerticalScrollBarScrolled; _messageList.VerticalScrollBar.Visible = true; @@ -330,10 +332,10 @@ public sealed partial class MainWindow : Runnable } /// - /// Updates the attachment staging indicator shown on the input frame's title. - /// Passing an empty list restores the default hint. + /// Updates the attachment staging indicator shown on the input frame's title, including the + /// current ASCII-art size for images. Passing an empty list restores the default hint. /// - public void SetStagedAttachments(IReadOnlyList fileNames) + public void SetStagedAttachments(IReadOnlyList fileNames, string asciiSizeLabel) { _hasStagedAttachments = fileNames.Count > 0; if (fileNames.Count == 0) @@ -343,9 +345,9 @@ public sealed partial class MainWindow : Runnable else { var names = string.Join(", ", fileNames); - if (names.Length > 60) - names = names[..57] + "..."; - _inputFrame.Title = $"📎 {fileNames.Count} staged: {names} │ Enter=send │ /clear to drop"; + if (names.Length > 45) + names = names[..42] + "..."; + _inputFrame.Title = $"📎 {fileNames.Count}: {names} │ art: {asciiSizeLabel} (/size) │ Enter=send │ /clear"; } _inputFrame.SetNeedsDraw(); } @@ -541,6 +543,14 @@ public sealed partial class MainWindow : Runnable private void OnMessageListKeyDown(object? sender, Key e) { + // F6 returns focus to the input box. + if (e.KeyCode == F6Key.KeyCode) + { + _inputField.SetFocus(); + e.Handled = true; + return; + } + if (e.KeyCode != Key.Delete.KeyCode && e.KeyCode != Key.Backspace.KeyCode) return; @@ -557,12 +567,104 @@ public sealed partial class MainWindow : Runnable // Server enforces the real permission (own message, or Mod+ over a lower role); // the client just confirms intent and lets the server reject if disallowed. - var confirm = MessageBox.Query(_app, "Delete Message", - "Delete this message?", "Delete", "Cancel"); + ConfirmDeleteMessage(messageId); + e.Handled = true; + } + + private void OnMessageListMouseEvent(object? sender, Mouse e) + { + if (!e.Flags.HasFlag(MouseFlags.RightButtonClicked)) + return; + + if (_messageList.Source is not ChatListSource source || source.Count == 0 || e.Position is not { } pos) + return; + + var index = _messageList.TopItem + pos.Y; + if (index < 0 || index >= source.Count) + return; + + // Select the right-clicked row (so the menu acts on it and it highlights), then show the menu. + _messageList.SelectedItem = index; + _messageList.SetFocus(); + + var line = source.GetLine(index); + if (line is null) + return; + + ShowMessageContextMenu(line, e.ScreenPosition); + e.Handled = true; + } + + /// + /// Builds and shows a right-click context menu for a message line: attachment actions, + /// mention/profile for the sender, copy, and delete (permission enforced server-side). + /// + private void ShowMessageContextMenu(ChatLine line, System.Drawing.Point screenPosition) + { + var items = new List(); + var sender = line.SenderUsername; + + if (line.AttachmentKind is { } kind && line.AttachmentUrl is { } url && line.AttachmentFileName is { } name) + { + switch (kind) + { + case AttachmentKind.Image: + items.Add(new MenuItem("Save original image", "", () => OnImageSaveRequested?.Invoke(url, name), Key.Empty)); + break; + case AttachmentKind.Audio: + items.Add(new MenuItem("Play audio", "", () => OnAudioPlayRequested?.Invoke(url, name), Key.Empty)); + break; + default: + items.Add(new MenuItem("Download file", "", () => OnFileDownloadRequested?.Invoke(url, name), Key.Empty)); + break; + } + } + + if (sender is not null) + { + items.Add(new MenuItem($"Mention @{sender}", "", () => MentionUser(sender), Key.Empty)); + items.Add(new MenuItem($"View {sender}'s profile", "", () => OnUserProfileRequested?.Invoke(sender), Key.Empty)); + } + + items.Add(new MenuItem("Copy text", "", () => CopyToClipboard(line.ToString()), Key.Empty)); + + if (line.MessageId is { } messageId) + { + items.Add(new Line()); + items.Add(new MenuItem("Delete message", "", () => ConfirmDeleteMessage(messageId), Key.Empty)); + } + + if (items.Count == 0) + return; + + var menu = new PopoverMenu(items); + _app.Popovers?.Register(menu); + menu.MakeVisible(screenPosition); + } + + private void MentionUser(string username) + { + _inputField.InsertText($"@{username} "); + _inputField.SetFocus(); + } + + private void CopyToClipboard(string text) + { + try + { + _app.Clipboard?.TrySetClipboardData(text); + } + catch (Exception ex) + { + Log.Warning(ex, "Copy to clipboard failed"); + } + } + + private void ConfirmDeleteMessage(Guid messageId) + { + var confirm = MessageBox.Query(_app, "Delete Message", "Delete this message?", "Delete", "Cancel"); if (confirm == 0) OnDeleteMessageRequested?.Invoke(messageId); - - e.Handled = true; } private void OnMessageListVerticalScrollBarScrolled(object? sender, EventArgs e) @@ -619,6 +721,13 @@ public sealed partial class MainWindow : Runnable ShowSearchDialog(); e.Handled = true; } + else if (e.KeyCode == F6Key.KeyCode) + { + // Move focus into the message list so you can select a message (arrows) and + // delete it (Delete). F6 again returns focus here. (Esc is the app quit key.) + FocusMessageList(); + e.Handled = true; + } else if (e.KeyCode == CtrlVKey.KeyCode || e.KeyCode == CtrlYKey.KeyCode) { // If a file was copied in the OS file manager, the clipboard holds a file list @@ -1049,6 +1158,26 @@ public sealed partial class MainWindow : Runnable _inputField.SetFocus(); } + /// + /// Moves focus into the message list for selection (arrows) and deletion (Delete). Selects the + /// most recent message when nothing is selected. No-op when the channel has no messages. + /// + private void FocusMessageList() + { + if (_messageList.Source is not ChatListSource source || source.Count == 0) + return; + + if (!_messageList.SelectedItem.HasValue + || _messageList.SelectedItem < 0 + || _messageList.SelectedItem >= source.Count) + { + _messageList.SelectedItem = source.Count - 1; + } + + _messageList.SetFocus(); + _messageList.SetNeedsDraw(); + } + private void RefreshMessages() { var messages = _messageManager.GetMessages(_messageManager.CurrentChannel); diff --git a/src/EchoHub.Server/Services/ChatService.cs b/src/EchoHub.Server/Services/ChatService.cs index 4a5badf..f5509ad 100644 --- a/src/EchoHub.Server/Services/ChatService.cs +++ b/src/EchoHub.Server/Services/ChatService.cs @@ -18,6 +18,7 @@ public class ChatService : IChatService private readonly LinkEmbedService _embedService; private readonly IMessageEncryptionService _encryption; private readonly IChannelService _channelService; + private readonly FileStorageService _fileStorage; private readonly ILogger _logger; public ChatService( @@ -27,6 +28,7 @@ public class ChatService : IChatService LinkEmbedService embedService, IMessageEncryptionService encryption, IChannelService channelService, + FileStorageService fileStorage, ILogger logger) { _scopeFactory = scopeFactory; @@ -35,6 +37,7 @@ public class ChatService : IChatService _embedService = embedService; _encryption = encryption; _channelService = channelService; + _fileStorage = fileStorage; _logger = logger; } @@ -389,12 +392,46 @@ public class ChatService : IChatService .GroupBy(a => a.MessageId) .ToDictionary(g => g.Key, g => g.ToList()); - return raw.Select(x => + // Attachment blobs can be pruned by retention while the message rows remain. Check what's + // actually on disk (one scan) so we never render a dead download, and so we can drop + // attachment-only messages whose files are all gone. + var storedFileIds = attachmentsByMessage.Count > 0 ? _fileStorage.GetStoredFileIds() : []; + + var result = new List(raw.Count); + var deadMessageIds = new List(); + + foreach (var x in raw) { // Decrypt DB content (handles both encrypted and plaintext via prefix detection) var plaintext = _encryption.Decrypt(x.m.Content); - var embedJsonPlain = _encryption.DecryptNullable(x.m.EmbedJson); + List? attachments = null; + var hadAttachments = attachmentsByMessage.TryGetValue(x.m.Id, out var atts) && atts.Count > 0; + if (hadAttachments) + { + // Keep only attachments whose underlying file still exists on disk. + var live = atts!.Where(a => storedFileIds.Contains(FileIdFromUrl(a.Url))).ToList(); + + // Attachment-only message whose files are all gone → prune it entirely. + if (live.Count == 0 && string.IsNullOrEmpty(plaintext)) + { + deadMessageIds.Add(x.m.Id); + continue; + } + + if (live.Count > 0) + { + attachments = live.Select(a => new AttachmentDto( + a.Kind, + a.Url, + a.FileName, + a.FileSize, + // Preview re-encrypted for transport; client decrypts (and room-decrypts for E2E) + _encryption.EncryptNullable(_encryption.DecryptNullable(a.AsciiPreview)))).ToList(); + } + } + + var embedJsonPlain = _encryption.DecryptNullable(x.m.EmbedJson); List? embeds = null; if (embedJsonPlain is not null) { @@ -402,20 +439,8 @@ public class ChatService : IChatService catch { /* ignore malformed JSON */ } } - List? attachments = null; - if (attachmentsByMessage.TryGetValue(x.m.Id, out var atts) && atts.Count > 0) - { - attachments = atts.Select(a => new AttachmentDto( - a.Kind, - a.Url, - a.FileName, - a.FileSize, - // Preview re-encrypted for transport; client decrypts (and room-decrypts for E2E) - _encryption.EncryptNullable(_encryption.DecryptNullable(a.AsciiPreview)))).ToList(); - } - // Encrypt for transport — client decrypts - return new MessageDto( + result.Add(new MessageDto( x.m.Id, _encryption.Encrypt(plaintext), x.m.SenderUsername, @@ -423,7 +448,28 @@ public class ChatService : IChatService channelName, x.m.SentAt, attachments, - embeds); - }).ToList(); + embeds)); + } + + // Lazily delete the pruned messages (+ their attachment rows) as they're encountered. + if (deadMessageIds.Count > 0) + { + try + { + await db.Attachments.Where(a => deadMessageIds.Contains(a.MessageId)).ExecuteDeleteAsync(); + await db.Messages.Where(m => deadMessageIds.Contains(m.Id)).ExecuteDeleteAsync(); + _logger.LogInformation("Pruned {Count} attachment-only messages with missing files in '{Channel}'", + deadMessageIds.Count, channelName); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to prune messages with missing attachments in '{Channel}'", channelName); + } + } + + return result; } + + /// Extracts the storage file id from an attachment URL (e.g. "/api/files/{id}"). + private static string FileIdFromUrl(string url) => url.Split('/')[^1]; } diff --git a/src/EchoHub.Server/Services/FileStorageService.cs b/src/EchoHub.Server/Services/FileStorageService.cs index a34d3cd..a013e06 100644 --- a/src/EchoHub.Server/Services/FileStorageService.cs +++ b/src/EchoHub.Server/Services/FileStorageService.cs @@ -35,6 +35,22 @@ public class FileStorageService return files.Length > 0 ? files[0] : null; } + /// + /// Returns the set of stored file ids (filenames without extension) currently on disk. + /// One directory scan, so callers can bulk-check many attachments without a glob per file. + /// + public HashSet GetStoredFileIds() + { + var ids = new HashSet(StringComparer.OrdinalIgnoreCase); + if (!Directory.Exists(_storagePath)) + return ids; + + foreach (var file in Directory.EnumerateFiles(_storagePath)) + ids.Add(Path.GetFileNameWithoutExtension(file)); + + return ids; + } + public void DeleteFile(string fileId) { var filePath = GetFilePath(fileId);