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
+77 -8
View File
@@ -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<string>().ToList()));
InvokeUI(RefreshStagingTray);
return Task.CompletedTask;
}
private Task HandleCmdClearAttachments()
{
_stagedAttachments.Clear();
InvokeUI(() => _mainWindow.SetStagedAttachments([]));
InvokeUI(RefreshStagingTray);
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>
/// 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<OutgoingAttachment>();
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");
}
/// <summary>
/// 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
/// 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>
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);
@@ -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);
}
@@ -17,6 +17,7 @@ public class CommandHandler
public event Func<string, string, Task>? OnChangeRoomPassword;
public event Func<Task>? OnClearAttachments;
public event Func<string, Task>? OnSetDownloadPath;
public event Func<string, Task>? OnSetAsciiSize;
public event Func<Task>? OnLeaveChannel;
public event Func<string, Task>? OnSetTopic;
public event Func<Task>? 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<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)
{
// 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 <URL> [-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 <URL or filepath> - Set your avatar
/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>.
/// </summary>
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
+22 -6
View File
@@ -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(" ");
+141 -12
View File
@@ -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
}
/// <summary>
/// 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.
/// </summary>
public void SetStagedAttachments(IReadOnlyList<string> fileNames)
public void SetStagedAttachments(IReadOnlyList<string> 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;
}
/// <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)
OnDeleteMessageRequested?.Invoke(messageId);
e.Handled = true;
}
private void OnMessageListVerticalScrollBarScrolled(object? sender, EventArgs<int> 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();
}
/// <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()
{
var messages = _messageManager.GetMessages(_messageManager.CurrentChannel);
+63 -17
View File
@@ -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<ChatService> _logger;
public ChatService(
@@ -27,6 +28,7 @@ public class ChatService : IChatService
LinkEmbedService embedService,
IMessageEncryptionService encryption,
IChannelService channelService,
FileStorageService fileStorage,
ILogger<ChatService> 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<MessageDto>(raw.Count);
var deadMessageIds = new List<Guid>();
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<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;
if (embedJsonPlain is not null)
{
@@ -402,20 +439,8 @@ public class ChatService : IChatService
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
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;
}
/// <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;
}
/// <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)
{
var filePath = GetFilePath(fileId);