feat: Add ASCII art size selection for image attachments and enhance message context menu

This commit is contained in:
HueByte
2026-07-16 05:35:11 +02:00
parent 2d33773c24
commit 6292e82cec
9 changed files with 344 additions and 43 deletions
+3
View File
@@ -227,6 +227,7 @@ For direct TLS without a reverse proxy, the IRC gateway can terminate TLS itself
| ------- | ----------- | | ------- | ----------- |
| `/join <channel> [password]` | Join a channel (passphrase for encrypted channels) | | `/join <channel> [password]` | Join a channel (passphrase for encrypted channels) |
| `/passwd <old> <new>` | Change the current encrypted channel's passphrase (creator only) | | `/passwd <old> <new>` | 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) | | `/downloadpath [path]` | Set the download folder (no path = native folder picker) |
| `/leave` | Leave current channel | | `/leave` | Leave current channel |
| `/topic <text>` | Set channel topic (creator only) | | `/topic <text>` | 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 | | `/help` | Show help |
| `/quit` | Exit | | `/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 <kbd>F6</kbd> to focus the message list, select with the arrow keys, and press <kbd>Delete</kbd>; <kbd>F6</kbd> 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 ## Themes
`/theme <name>` to switch: `/theme <name>` to switch:
+3
View File
@@ -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. - 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. - 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. - 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. - **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 <path>` 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. - **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 <path>` 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 <channel> [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 - `/join <channel> [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 <key>` 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 `MODE` implemented — `MODE #chan` reports `+k`/`+`, `MODE #chan +k <key>` 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) - 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 - 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 <s|m|l>` sets it directly, and `/send <file> -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`) - 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. - 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 ## 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 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 - 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]` - IRC `LIST` no longer leaks private channels; protected channels are marked `[+k]`
+77 -8
View File
@@ -116,6 +116,7 @@ public sealed class AppOrchestrator : IDisposable
_commandHandler.OnJoinChannel += HandleCmdJoinChannel; _commandHandler.OnJoinChannel += HandleCmdJoinChannel;
_commandHandler.OnChangeRoomPassword += HandleCmdChangeRoomPassword; _commandHandler.OnChangeRoomPassword += HandleCmdChangeRoomPassword;
_commandHandler.OnClearAttachments += HandleCmdClearAttachments; _commandHandler.OnClearAttachments += HandleCmdClearAttachments;
_commandHandler.OnSetAsciiSize += HandleCmdSetAsciiSize;
_commandHandler.OnSetDownloadPath += HandleCmdSetDownloadPath; _commandHandler.OnSetDownloadPath += HandleCmdSetDownloadPath;
_commandHandler.OnLeaveChannel += HandleCmdLeaveChannel; _commandHandler.OnLeaveChannel += HandleCmdLeaveChannel;
_commandHandler.OnSetTopic += HandleCmdSetTopic; _commandHandler.OnSetTopic += HandleCmdSetTopic;
@@ -195,18 +196,84 @@ public sealed class AppOrchestrator : IDisposable
return Task.CompletedTask; 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); _stagedAttachments.Add(target);
InvokeUI(() => _mainWindow.SetStagedAttachments(_stagedAttachments.Select(Path.GetFileName).OfType<string>().ToList())); InvokeUI(RefreshStagingTray);
return Task.CompletedTask; return Task.CompletedTask;
} }
private Task HandleCmdClearAttachments() private Task HandleCmdClearAttachments()
{ {
_stagedAttachments.Clear(); _stagedAttachments.Clear();
InvokeUI(() => _mainWindow.SetStagedAttachments([])); InvokeUI(RefreshStagingTray);
return Task.CompletedTask; return Task.CompletedTask;
} }
/// <summary>
/// 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.
/// </summary>
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)}.");
}
/// <summary>Refreshes the staging tray with the current staged files and ASCII size.</summary>
private void RefreshStagingTray()
{
var names = _stagedAttachments.Select(Path.GetFileName).OfType<string>().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)",
};
/// <summary> /// <summary>
/// Sends one message with the given caption plus all staged files as attachments, then /// 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 + /// 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(); var staged = _stagedAttachments.ToList();
_stagedAttachments.Clear(); _stagedAttachments.Clear();
InvokeUI(() => _mainWindow.SetStagedAttachments([])); InvokeUI(RefreshStagingTray);
var hasRoomKey = _conn.RoomKeys.TryGetKey(channel, out var roomKey); var hasRoomKey = _conn.RoomKeys.TryGetKey(channel, out var roomKey);
var size = _config.DefaultAsciiSize;
RunAsync(async () => RunAsync(async () =>
{ {
var outgoing = new List<OutgoingAttachment>(); var outgoing = new List<OutgoingAttachment>();
foreach (var path in staged) 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) var wireContent = hasRoomKey && !string.IsNullOrEmpty(content)
? RoomCrypto.EncryptText(content, roomKey) ? RoomCrypto.EncryptText(content, roomKey)
: content; : content;
await _conn.Api!.SendMessageWithAttachmentsAsync(channel, wireContent, outgoing); await _conn.Api!.SendMessageWithAttachmentsAsync(channel, wireContent, outgoing, size);
}, "Send failed"); }, "Send failed");
} }
/// <summary> /// <summary>
/// Reads a staged file into an <see cref="OutgoingAttachment"/>. For encrypted channels the /// Reads a staged file into an <see cref="OutgoingAttachment"/>. For encrypted channels the
/// blob is AES-GCM encrypted, its kind is declared, and the image ASCII preview is rendered /// 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 <paramref name="size"/>) and room-encrypted — so the server never sees the
/// file or image contents.
/// </summary> /// </summary>
private static async Task<OutgoingAttachment> BuildOutgoingAttachmentAsync(string path, byte[]? roomKey) private static async Task<OutgoingAttachment> BuildOutgoingAttachmentAsync(string path, byte[]? roomKey, string size)
{ {
var fileName = Path.GetFileName(path); var fileName = Path.GetFileName(path);
@@ -255,7 +324,7 @@ public sealed class AppOrchestrator : IDisposable
if (FileValidationHelper.IsValidImage(ms)) if (FileValidationHelper.IsValidImage(ms))
{ {
declaredKind = "image"; declaredKind = "image";
var (w, h) = ImageToAsciiService.GetDimensions(null); var (w, h) = ImageToAsciiService.GetDimensions(size);
ms.Position = 0; ms.Position = 0;
preview = RoomCrypto.EncryptText(new ImageToAsciiService().ConvertToAscii(ms, w, h), roomKey); preview = RoomCrypto.EncryptText(new ImageToAsciiService().ConvertToAscii(ms, w, h), roomKey);
} }
@@ -17,6 +17,7 @@ public class CommandHandler
public event Func<string, string, Task>? OnChangeRoomPassword; public event Func<string, string, Task>? OnChangeRoomPassword;
public event Func<Task>? OnClearAttachments; public event Func<Task>? OnClearAttachments;
public event Func<string, Task>? OnSetDownloadPath; public event Func<string, Task>? OnSetDownloadPath;
public event Func<string, Task>? OnSetAsciiSize;
public event Func<Task>? OnLeaveChannel; public event Func<Task>? OnLeaveChannel;
public event Func<string, Task>? OnSetTopic; public event Func<string, Task>? OnSetTopic;
public event Func<Task>? OnListUsers; public event Func<Task>? OnListUsers;
@@ -51,6 +52,7 @@ public class CommandHandler
"theme" => await HandleTheme(args), "theme" => await HandleTheme(args),
"send" => await HandleSend(args), "send" => await HandleSend(args),
"clear" => await HandleClear(), "clear" => await HandleClear(),
"size" or "asciisize" => await HandleAsciiSize(args),
"downloadpath" or "downloads" => await HandleDownloadPath(args), "downloadpath" or "downloads" => await HandleDownloadPath(args),
"profile" => await HandleProfile(args), "profile" => await HandleProfile(args),
"avatar" => await HandleAvatar(args), "avatar" => await HandleAvatar(args),
@@ -176,6 +178,14 @@ public class CommandHandler
return new CommandResult(true, "Cleared staged attachments."); return new CommandResult(true, "Cleared staged attachments.");
} }
private async Task<CommandResult> 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<CommandResult> HandleDownloadPath(string args) private async Task<CommandResult> HandleDownloadPath(string args)
{ {
// No argument → open the native folder picker; an argument sets the path directly. // No argument → open the native folder picker; an argument sets the path directly.
@@ -380,7 +390,10 @@ public class CommandHandler
/send <filepath> [-s|-m|-l] - Stage a file to attach (Enter sends with your text) /send <filepath> [-s|-m|-l] - Stage a file to attach (Enter sends with your text)
/send <URL> [-s|-m|-l] - Send an image URL immediately /send <URL> [-s|-m|-l] - Send an image URL immediately
/clear - Drop all staged attachments /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: 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) /downloadpath [path] - Set download folder (no path = native folder picker)
/avatar <URL or filepath> - Set your avatar /avatar <URL or filepath> - Set your avatar
/profile [username] - View a profile /profile [username] - View a profile
@@ -12,6 +12,12 @@ public class ClientConfig
/// OS Downloads folder is used. Set via the native folder picker or <c>/downloadpath</c>. /// OS Downloads folder is used. Set via the native folder picker or <c>/downloadpath</c>.
/// </summary> /// </summary>
public string? DownloadPath { get; set; } public string? DownloadPath { get; set; }
/// <summary>
/// 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.
/// </summary>
public string DefaultAsciiSize { get; set; } = "m";
} }
public class NotificationConfig public class NotificationConfig
+22 -6
View File
@@ -65,6 +65,13 @@ public class ChatListSource : IListDataSource
var chatLine = _lines[item]; var chatLine = _lines[item];
var normalAttr = listView.GetAttributeForRole(VisualRole.Normal); 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; var mentionBg = chatLine.IsMention ? ChatColors.MentionHighlightAttr.Background : (Color?)null;
int charPos = 0; int charPos = 0;
@@ -72,11 +79,19 @@ public class ChatListSource : IListDataSource
foreach (var segment in chatLine.Segments) foreach (var segment in chatLine.Segments)
{ {
var attr = segment.Color ?? normalAttr; Attribute attr;
if (attr.Background == Color.None) if (focusAttr is { } focus)
attr = attr with { Background = normalAttr.Background }; {
if (mentionBg.HasValue) attr = focus;
attr = attr with { Background = mentionBg.Value }; }
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); listView.SetAttribute(attr);
foreach (var grapheme in GraphemeHelper.GetGraphemes(segment.Text)) 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); listView.SetAttribute(fillAttr);
for (int i = drawnChars; i < width; i++) for (int i = drawnChars; i < width; i++)
listView.AddStr(" "); listView.AddStr(" ");
+141 -12
View File
@@ -40,7 +40,7 @@ public sealed partial class MainWindow : Runnable
private readonly UserListSource _usersListSource; private readonly UserListSource _usersListSource;
private bool _usersPanelVisible = true; private bool _usersPanelVisible = true;
private const int UsersPanelWidth = 22; 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 static readonly Key F2Key = Key.F2;
private bool _hasStagedAttachments; 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 CtrlXKey = Key.X.WithCtrl;
private static readonly Key CtrlCKey = Key.C.WithCtrl; private static readonly Key CtrlCKey = Key.C.WithCtrl;
private static readonly Key CtrlYKey = Key.Y.WithCtrl; private static readonly Key CtrlYKey = Key.Y.WithCtrl;
private static readonly Key F6Key = Key.F6;
// Available slash commands for Tab autocomplete // Available slash commands for Tab autocomplete
private static readonly string[] SlashCommands = private static readonly string[] SlashCommands =
[ [
"/status", "/nick", "/color", "/theme", "/send", "/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", "/topic", "/users", "/kick", "/ban", "/unban",
"/mute", "/unmute", "/role", "/nuke", "/test-sound", "/quit", "/help" "/mute", "/unmute", "/role", "/nuke", "/test-sound", "/quit", "/help"
]; ];
@@ -248,6 +249,7 @@ public sealed partial class MainWindow : Runnable
_messageList.Source = new ChatListSource(); _messageList.Source = new ChatListSource();
_messageList.Accepting += OnMessageListAccepting; _messageList.Accepting += OnMessageListAccepting;
_messageList.KeyDown += OnMessageListKeyDown; _messageList.KeyDown += OnMessageListKeyDown;
_messageList.MouseEvent += OnMessageListMouseEvent;
_messageList.VerticalScrollBar.Scrolled += OnMessageListVerticalScrollBarScrolled; _messageList.VerticalScrollBar.Scrolled += OnMessageListVerticalScrollBarScrolled;
_messageList.VerticalScrollBar.Visible = true; _messageList.VerticalScrollBar.Visible = true;
@@ -330,10 +332,10 @@ public sealed partial class MainWindow : Runnable
} }
/// <summary> /// <summary>
/// Updates the attachment staging indicator shown on the input frame's title. /// Updates the attachment staging indicator shown on the input frame's title, including the
/// Passing an empty list restores the default hint. /// current ASCII-art size for images. Passing an empty list restores the default hint.
/// </summary> /// </summary>
public void SetStagedAttachments(IReadOnlyList<string> fileNames) public void SetStagedAttachments(IReadOnlyList<string> fileNames, string asciiSizeLabel)
{ {
_hasStagedAttachments = fileNames.Count > 0; _hasStagedAttachments = fileNames.Count > 0;
if (fileNames.Count == 0) if (fileNames.Count == 0)
@@ -343,9 +345,9 @@ public sealed partial class MainWindow : Runnable
else else
{ {
var names = string.Join(", ", fileNames); var names = string.Join(", ", fileNames);
if (names.Length > 60) if (names.Length > 45)
names = names[..57] + "..."; names = names[..42] + "...";
_inputFrame.Title = $"📎 {fileNames.Count} staged: {names} │ Enter=send │ /clear to drop"; _inputFrame.Title = $"📎 {fileNames.Count}: {names} │ art: {asciiSizeLabel} (/size) │ Enter=send │ /clear";
} }
_inputFrame.SetNeedsDraw(); _inputFrame.SetNeedsDraw();
} }
@@ -541,6 +543,14 @@ public sealed partial class MainWindow : Runnable
private void OnMessageListKeyDown(object? sender, Key e) 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) if (e.KeyCode != Key.Delete.KeyCode && e.KeyCode != Key.Backspace.KeyCode)
return; return;
@@ -557,12 +567,104 @@ public sealed partial class MainWindow : Runnable
// Server enforces the real permission (own message, or Mod+ over a lower role); // 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. // the client just confirms intent and lets the server reject if disallowed.
var confirm = MessageBox.Query(_app, "Delete Message", ConfirmDeleteMessage(messageId);
"Delete this message?", "Delete", "Cancel"); 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;
}
/// <summary>
/// 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).
/// </summary>
private void ShowMessageContextMenu(ChatLine line, System.Drawing.Point screenPosition)
{
var items = new List<View>();
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) if (confirm == 0)
OnDeleteMessageRequested?.Invoke(messageId); OnDeleteMessageRequested?.Invoke(messageId);
e.Handled = true;
} }
private void OnMessageListVerticalScrollBarScrolled(object? sender, EventArgs<int> e) private void OnMessageListVerticalScrollBarScrolled(object? sender, EventArgs<int> e)
@@ -619,6 +721,13 @@ public sealed partial class MainWindow : Runnable
ShowSearchDialog(); ShowSearchDialog();
e.Handled = true; 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) 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 // 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(); _inputField.SetFocus();
} }
/// <summary>
/// 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.
/// </summary>
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() private void RefreshMessages()
{ {
var messages = _messageManager.GetMessages(_messageManager.CurrentChannel); var messages = _messageManager.GetMessages(_messageManager.CurrentChannel);
+63 -17
View File
@@ -18,6 +18,7 @@ public class ChatService : IChatService
private readonly LinkEmbedService _embedService; private readonly LinkEmbedService _embedService;
private readonly IMessageEncryptionService _encryption; private readonly IMessageEncryptionService _encryption;
private readonly IChannelService _channelService; private readonly IChannelService _channelService;
private readonly FileStorageService _fileStorage;
private readonly ILogger<ChatService> _logger; private readonly ILogger<ChatService> _logger;
public ChatService( public ChatService(
@@ -27,6 +28,7 @@ public class ChatService : IChatService
LinkEmbedService embedService, LinkEmbedService embedService,
IMessageEncryptionService encryption, IMessageEncryptionService encryption,
IChannelService channelService, IChannelService channelService,
FileStorageService fileStorage,
ILogger<ChatService> logger) ILogger<ChatService> logger)
{ {
_scopeFactory = scopeFactory; _scopeFactory = scopeFactory;
@@ -35,6 +37,7 @@ public class ChatService : IChatService
_embedService = embedService; _embedService = embedService;
_encryption = encryption; _encryption = encryption;
_channelService = channelService; _channelService = channelService;
_fileStorage = fileStorage;
_logger = logger; _logger = logger;
} }
@@ -389,12 +392,46 @@ public class ChatService : IChatService
.GroupBy(a => a.MessageId) .GroupBy(a => a.MessageId)
.ToDictionary(g => g.Key, g => g.ToList()); .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<MessageDto>(raw.Count);
var deadMessageIds = new List<Guid>();
foreach (var x in raw)
{ {
// Decrypt DB content (handles both encrypted and plaintext via prefix detection) // Decrypt DB content (handles both encrypted and plaintext via prefix detection)
var plaintext = _encryption.Decrypt(x.m.Content); var plaintext = _encryption.Decrypt(x.m.Content);
var embedJsonPlain = _encryption.DecryptNullable(x.m.EmbedJson);
List<AttachmentDto>? 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<EmbedDto>? embeds = null; List<EmbedDto>? embeds = null;
if (embedJsonPlain is not null) if (embedJsonPlain is not null)
{ {
@@ -402,20 +439,8 @@ public class ChatService : IChatService
catch { /* ignore malformed JSON */ } catch { /* ignore malformed JSON */ }
} }
List<AttachmentDto>? 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 // Encrypt for transport — client decrypts
return new MessageDto( result.Add(new MessageDto(
x.m.Id, x.m.Id,
_encryption.Encrypt(plaintext), _encryption.Encrypt(plaintext),
x.m.SenderUsername, x.m.SenderUsername,
@@ -423,7 +448,28 @@ public class ChatService : IChatService
channelName, channelName,
x.m.SentAt, x.m.SentAt,
attachments, attachments,
embeds); embeds));
}).ToList(); }
// 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;
} }
/// <summary>Extracts the storage file id from an attachment URL (e.g. "/api/files/{id}").</summary>
private static string FileIdFromUrl(string url) => url.Split('/')[^1];
} }
@@ -35,6 +35,22 @@ public class FileStorageService
return files.Length > 0 ? files[0] : null; return files.Length > 0 ? files[0] : null;
} }
/// <summary>
/// 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.
/// </summary>
public HashSet<string> GetStoredFileIds()
{
var ids = new HashSet<string>(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) public void DeleteFile(string fileId)
{ {
var filePath = GetFilePath(fileId); var filePath = GetFilePath(fileId);