mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
feat: Add ASCII art size selection for image attachments and enhance message context menu
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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]`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 (focusAttr is { } focus)
|
||||||
|
{
|
||||||
|
attr = focus;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
attr = segment.Color ?? normalAttr;
|
||||||
if (attr.Background == Color.None)
|
if (attr.Background == Color.None)
|
||||||
attr = attr with { Background = normalAttr.Background };
|
attr = attr with { Background = normalAttr.Background };
|
||||||
if (mentionBg.HasValue)
|
if (mentionBg.HasValue)
|
||||||
attr = attr with { Background = mentionBg.Value };
|
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(" ");
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,23 +392,36 @@ 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<EmbedDto>? embeds = null;
|
|
||||||
if (embedJsonPlain is not null)
|
|
||||||
{
|
|
||||||
try { embeds = JsonSerializer.Deserialize<List<EmbedDto>>(embedJsonPlain); }
|
|
||||||
catch { /* ignore malformed JSON */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
List<AttachmentDto>? attachments = null;
|
List<AttachmentDto>? attachments = null;
|
||||||
if (attachmentsByMessage.TryGetValue(x.m.Id, out var atts) && atts.Count > 0)
|
var hadAttachments = attachmentsByMessage.TryGetValue(x.m.Id, out var atts) && atts.Count > 0;
|
||||||
|
if (hadAttachments)
|
||||||
{
|
{
|
||||||
attachments = atts.Select(a => new AttachmentDto(
|
// 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.Kind,
|
||||||
a.Url,
|
a.Url,
|
||||||
a.FileName,
|
a.FileName,
|
||||||
@@ -413,9 +429,18 @@ public class ChatService : IChatService
|
|||||||
// Preview re-encrypted for transport; client decrypts (and room-decrypts for E2E)
|
// Preview re-encrypted for transport; client decrypts (and room-decrypts for E2E)
|
||||||
_encryption.EncryptNullable(_encryption.DecryptNullable(a.AsciiPreview)))).ToList();
|
_encryption.EncryptNullable(_encryption.DecryptNullable(a.AsciiPreview)))).ToList();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var embedJsonPlain = _encryption.DecryptNullable(x.m.EmbedJson);
|
||||||
|
List<EmbedDto>? embeds = null;
|
||||||
|
if (embedJsonPlain is not null)
|
||||||
|
{
|
||||||
|
try { embeds = JsonSerializer.Deserialize<List<EmbedDto>>(embedJsonPlain); }
|
||||||
|
catch { /* ignore malformed JSON */ }
|
||||||
|
}
|
||||||
|
|
||||||
// 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);
|
||||||
|
|||||||
Reference in New Issue
Block a user