mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
feat: Add support for message attachments
- Introduced Attachment model to handle file attachments associated with messages. - Updated ModerationController to manage message deletions and attachment cleanup. - Enhanced ChatService to include attachments in message retrieval. - Implemented migration for legacy single-attachment messages to the new Attachments model. - Added unit tests for attachment handling in message formatting and parsing. - Updated database context and migrations to support new Attachments table.
This commit is contained in:
@@ -18,7 +18,7 @@ public partial class ChatLine
|
||||
public bool IsMention { get; set; }
|
||||
public string? AttachmentUrl { get; set; }
|
||||
public string? AttachmentFileName { get; set; }
|
||||
public MessageType? Type { get; set; }
|
||||
public AttachmentKind? AttachmentKind { get; set; }
|
||||
public string? SenderUsername { get; set; }
|
||||
/// <summary>Number of spaces to prepend on continuation lines when this line is word-wrapped.</summary>
|
||||
public int ContinuationIndent { get; set; }
|
||||
@@ -124,7 +124,7 @@ public partial class ChatLine
|
||||
{
|
||||
wrapped.AttachmentUrl = AttachmentUrl;
|
||||
wrapped.AttachmentFileName = AttachmentFileName;
|
||||
wrapped.Type = Type;
|
||||
wrapped.AttachmentKind = AttachmentKind;
|
||||
wrapped.MessageId = MessageId;
|
||||
wrapped.SenderUsername = SenderUsername;
|
||||
}
|
||||
|
||||
@@ -238,86 +238,77 @@ public sealed class ChatMessageManager
|
||||
var senderName = message.SenderUsername + ":";
|
||||
var senderColor = HexColorHelper.ParseHexColor(message.SenderNicknameColor);
|
||||
|
||||
var indent = new string(' ', $"[{time}] {senderName} ".Length);
|
||||
var pad = new string(' ', 7);
|
||||
|
||||
var lines = new List<ChatLine>();
|
||||
var hasContent = !string.IsNullOrWhiteSpace(message.Content);
|
||||
var attachments = message.Attachments ?? [];
|
||||
|
||||
switch (message.Type)
|
||||
// Header line: caption text, or a summary when the message is attachments-only
|
||||
if (hasContent)
|
||||
{
|
||||
case MessageType.Image:
|
||||
lines.Add(BuildChatLine(time, senderName, senderColor, " [Image]"));
|
||||
if (!string.IsNullOrWhiteSpace(message.Content))
|
||||
{
|
||||
foreach (var artLine in message.Content.Split('\n'))
|
||||
var displayContent = EmojiHelper.ReplaceEmoji(message.Content);
|
||||
var contentLines = displayContent.Split('\n');
|
||||
lines.Add(BuildChatLineWithMentions(time, senderName, senderColor, $" {contentLines[0].TrimEnd('\r')}"));
|
||||
for (int i = 1; i < contentLines.Length; i++)
|
||||
lines.Add(new ChatLine(ChatColors.SplitMentions($"{indent}{contentLines[i].TrimEnd('\r')}")));
|
||||
}
|
||||
else
|
||||
{
|
||||
var summary = attachments.Count switch
|
||||
{
|
||||
0 => " ",
|
||||
1 => $" [{attachments[0].Kind.ToString().ToLowerInvariant()}]",
|
||||
_ => $" [{attachments.Count} attachments]",
|
||||
};
|
||||
lines.Add(BuildChatLine(time, senderName, senderColor, summary));
|
||||
}
|
||||
|
||||
foreach (var l in lines)
|
||||
l.ContinuationIndent = indent.Length;
|
||||
|
||||
// One block per attachment
|
||||
foreach (var attachment in attachments)
|
||||
{
|
||||
switch (attachment.Kind)
|
||||
{
|
||||
case Core.Models.AttachmentKind.Image:
|
||||
if (!string.IsNullOrWhiteSpace(attachment.AsciiPreview))
|
||||
{
|
||||
var trimmed = artLine.TrimEnd('\r');
|
||||
if (ChatLine.HasColorTags(trimmed))
|
||||
lines.Add(ChatLine.FromColoredText(" " + trimmed));
|
||||
else
|
||||
lines.Add(new ChatLine($" {trimmed}"));
|
||||
foreach (var artLine in attachment.AsciiPreview.Split('\n'))
|
||||
{
|
||||
var trimmed = artLine.TrimEnd('\r');
|
||||
lines.Add(ChatLine.HasColorTags(trimmed)
|
||||
? ChatLine.FromColoredText(pad + trimmed)
|
||||
: new ChatLine($"{pad}{trimmed}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.Add(AttachmentActionLine(pad,
|
||||
$"[↓ save original] {attachment.FileName} [{FormatFileSize(attachment.FileSize)}]",
|
||||
ChatColors.FileAttr, attachment));
|
||||
break;
|
||||
|
||||
// Clickable action to download the original image below the ASCII art
|
||||
if (message.AttachmentUrl is not null)
|
||||
{
|
||||
var imageName = message.AttachmentFileName ?? "image";
|
||||
var imageSize = FormatFileSize(message.AttachmentFileSize);
|
||||
var saveLine = new ChatLine(new List<ChatSegment>
|
||||
{
|
||||
new(" ", null),
|
||||
new($"[↓ save original] {imageName} [{imageSize}]", ChatColors.FileAttr),
|
||||
});
|
||||
saveLine.AttachmentUrl = message.AttachmentUrl;
|
||||
saveLine.AttachmentFileName = imageName;
|
||||
saveLine.Type = MessageType.Image;
|
||||
lines.Add(saveLine);
|
||||
}
|
||||
break;
|
||||
case Core.Models.AttachmentKind.Audio:
|
||||
lines.Add(AttachmentActionLine(pad,
|
||||
$"♪ [Audio: {attachment.FileName}] [{FormatFileSize(attachment.FileSize)}]",
|
||||
ChatColors.AudioAttr, attachment));
|
||||
break;
|
||||
|
||||
case MessageType.Audio:
|
||||
var audioName = message.AttachmentFileName ?? "unknown";
|
||||
var audioSize = FormatFileSize(message.AttachmentFileSize);
|
||||
var audioLine = BuildChatLineColored(time, senderName, senderColor,
|
||||
$" \u266a [Audio: {audioName}] [{audioSize}]", ChatColors.AudioAttr);
|
||||
audioLine.AttachmentUrl = message.AttachmentUrl;
|
||||
audioLine.AttachmentFileName = audioName;
|
||||
audioLine.Type = MessageType.Audio;
|
||||
lines.Add(audioLine);
|
||||
break;
|
||||
default:
|
||||
lines.Add(AttachmentActionLine(pad,
|
||||
$"[File: {attachment.FileName}] [{FormatFileSize(attachment.FileSize)}]",
|
||||
ChatColors.FileAttr, attachment));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
case MessageType.File:
|
||||
var fileName = message.AttachmentFileName ?? "unknown";
|
||||
var fileSize = FormatFileSize(message.AttachmentFileSize);
|
||||
var fileLine = BuildChatLineColored(time, senderName, senderColor,
|
||||
$" [File: {fileName}] [{fileSize}]", ChatColors.FileAttr);
|
||||
fileLine.AttachmentUrl = message.AttachmentUrl;
|
||||
fileLine.AttachmentFileName = fileName;
|
||||
fileLine.Type = MessageType.File;
|
||||
lines.Add(fileLine);
|
||||
break;
|
||||
|
||||
case MessageType.Text:
|
||||
default:
|
||||
var displayContent = EmojiHelper.ReplaceEmoji(message.Content);
|
||||
var contentLines = displayContent.Split('\n');
|
||||
var firstLine = contentLines[0].TrimEnd('\r');
|
||||
lines.Add(BuildChatLineWithMentions(time, senderName, senderColor, $" {firstLine}"));
|
||||
var indent = new string(' ', $"[{time}] {senderName} ".Length);
|
||||
for (int i = 1; i < contentLines.Length; i++)
|
||||
{
|
||||
var contText = $"{indent}{contentLines[i].TrimEnd('\r')}";
|
||||
lines.Add(new ChatLine(ChatColors.SplitMentions(contText)));
|
||||
}
|
||||
|
||||
foreach (var l in lines)
|
||||
l.ContinuationIndent = indent.Length;
|
||||
|
||||
if (message.Embeds is { Count: > 0 })
|
||||
{
|
||||
var chatWidth = _chatWidth > 0 ? _chatWidth : 80;
|
||||
foreach (var embed in message.Embeds)
|
||||
lines.AddRange(FormatEmbed(embed, indent, chatWidth));
|
||||
}
|
||||
break;
|
||||
// Link embeds (from caption URLs)
|
||||
if (message.Embeds is { Count: > 0 })
|
||||
{
|
||||
var chatWidth = _chatWidth > 0 ? _chatWidth : 80;
|
||||
foreach (var embed in message.Embeds)
|
||||
lines.AddRange(FormatEmbed(embed, indent, chatWidth));
|
||||
}
|
||||
|
||||
foreach (var line in lines)
|
||||
@@ -326,7 +317,7 @@ public sealed class ChatMessageManager
|
||||
line.SenderUsername = message.SenderUsername;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(_currentUser) && message.Type == MessageType.Text)
|
||||
if (hasContent && !string.IsNullOrEmpty(_currentUser))
|
||||
{
|
||||
var pattern = $@"@{Regex.Escape(_currentUser)}\b";
|
||||
if (Regex.IsMatch(message.Content, pattern, RegexOptions.IgnoreCase))
|
||||
@@ -339,6 +330,23 @@ public sealed class ChatMessageManager
|
||||
return lines;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a clickable attachment line carrying the metadata the message list uses to
|
||||
/// route activation (play audio, download file, save original image).
|
||||
/// </summary>
|
||||
private static ChatLine AttachmentActionLine(string pad, string text, Attribute color, AttachmentDto attachment)
|
||||
{
|
||||
var line = new ChatLine(new List<ChatSegment>
|
||||
{
|
||||
new(pad, null),
|
||||
new(text, color),
|
||||
});
|
||||
line.AttachmentUrl = attachment.Url;
|
||||
line.AttachmentFileName = attachment.FileName;
|
||||
line.AttachmentKind = attachment.Kind;
|
||||
return line;
|
||||
}
|
||||
|
||||
private static ChatLine BuildChatLine(string time, string senderName, Attribute? senderColor, string suffix)
|
||||
{
|
||||
var segments = new List<ChatSegment>
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
using System.Text;
|
||||
|
||||
namespace EchoHub.Client.UI.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Recognizes a dragged-and-dropped file (or files) that a terminal delivers into the input as an
|
||||
/// absolute path. Terminals differ: some paste the whole path at once, others send it character by
|
||||
/// character; either way this checks whether the current input text resolves to existing file(s).
|
||||
/// </summary>
|
||||
public static class DroppedFileParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Cheap pre-check so callers only stat the filesystem when the input plausibly holds a path:
|
||||
/// a quoted path, a Windows drive path (<c>X:\</c>/<c>X:/</c>), a UNC path (<c>\\</c>), or a
|
||||
/// POSIX absolute path (<c>/</c>). Normal chat text never starts this way.
|
||||
/// </summary>
|
||||
public static bool LooksLikePath(string text)
|
||||
{
|
||||
var t = text.TrimStart();
|
||||
if (t.Length < 3)
|
||||
return false;
|
||||
if (t[0] is '"' or '/')
|
||||
return true;
|
||||
if (t.StartsWith(@"\\", StringComparison.Ordinal))
|
||||
return true;
|
||||
return char.IsLetter(t[0]) && t[1] == ':' && (t[2] == '\\' || t[2] == '/');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when <paramref name="text"/> resolves to one or more existing files.
|
||||
/// Handles a single path (quoted or not, possibly containing spaces) and multiple
|
||||
/// space-separated (optionally quoted) paths. <paramref name="fileExists"/> is injectable
|
||||
/// for testing; production passes <see cref="File.Exists"/>.
|
||||
/// </summary>
|
||||
public static bool TryGetFiles(string text, out List<string> files, Func<string, bool>? fileExists = null)
|
||||
{
|
||||
fileExists ??= File.Exists;
|
||||
files = [];
|
||||
|
||||
var trimmed = text.Trim();
|
||||
if (trimmed.Length < 3 || trimmed.Length > 4096 || trimmed.Contains('\n'))
|
||||
return false;
|
||||
|
||||
// Single path, possibly quoted and/or containing spaces.
|
||||
var unquoted = StripQuotes(trimmed);
|
||||
if (Path.IsPathFullyQualified(unquoted) && fileExists(unquoted))
|
||||
{
|
||||
files.Add(unquoted);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Multiple files: space-separated tokens, each optionally quoted.
|
||||
foreach (var token in TokenizeQuoted(trimmed))
|
||||
{
|
||||
if (!Path.IsPathFullyQualified(token) || !fileExists(token))
|
||||
{
|
||||
files.Clear();
|
||||
return false;
|
||||
}
|
||||
files.Add(token);
|
||||
}
|
||||
|
||||
return files.Count > 0;
|
||||
}
|
||||
|
||||
private static string StripQuotes(string s) =>
|
||||
s.Length >= 2 && ((s[0] == '"' && s[^1] == '"') || (s[0] == '\'' && s[^1] == '\''))
|
||||
? s[1..^1]
|
||||
: s;
|
||||
|
||||
private static IEnumerable<string> TokenizeQuoted(string input)
|
||||
{
|
||||
var current = new StringBuilder();
|
||||
var quote = '\0';
|
||||
|
||||
foreach (var c in input)
|
||||
{
|
||||
if (quote != '\0')
|
||||
{
|
||||
if (c == quote) quote = '\0';
|
||||
else current.Append(c);
|
||||
}
|
||||
else if (c is '"' or '\'')
|
||||
{
|
||||
quote = c;
|
||||
}
|
||||
else if (c == ' ')
|
||||
{
|
||||
if (current.Length > 0)
|
||||
{
|
||||
yield return current.ToString();
|
||||
current.Clear();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
current.Append(c);
|
||||
}
|
||||
}
|
||||
|
||||
if (current.Length > 0)
|
||||
yield return current.ToString();
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,9 @@ 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 static readonly Key F2Key = Key.F2;
|
||||
private bool _hasStagedAttachments;
|
||||
|
||||
internal static readonly string AppVersion =
|
||||
typeof(MainWindow).Assembly.GetName().Version?.ToString(3) ?? "?";
|
||||
@@ -60,7 +62,7 @@ public sealed partial class MainWindow : Runnable
|
||||
private static readonly string[] SlashCommands =
|
||||
[
|
||||
"/status", "/nick", "/color", "/theme", "/send",
|
||||
"/avatar", "/profile", "/servers", "/join", "/passwd", "/leave",
|
||||
"/avatar", "/profile", "/servers", "/join", "/passwd", "/leave", "/clear", "/downloadpath",
|
||||
"/topic", "/users", "/kick", "/ban", "/unban",
|
||||
"/mute", "/unmute", "/role", "/nuke", "/test-sound", "/quit", "/help"
|
||||
];
|
||||
@@ -159,6 +161,11 @@ public sealed partial class MainWindow : Runnable
|
||||
/// </summary>
|
||||
public event Action<string, string>? OnImageSaveRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when the user presses Delete on the selected message. Parameter is the message id.
|
||||
/// </summary>
|
||||
public event Action<Guid>? OnDeleteMessageRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when the user activates a username (in userlist or message). Parameter is the username.
|
||||
/// </summary>
|
||||
@@ -240,6 +247,7 @@ public sealed partial class MainWindow : Runnable
|
||||
};
|
||||
_messageList.Source = new ChatListSource();
|
||||
_messageList.Accepting += OnMessageListAccepting;
|
||||
_messageList.KeyDown += OnMessageListKeyDown;
|
||||
_messageList.VerticalScrollBar.Scrolled += OnMessageListVerticalScrollBarScrolled;
|
||||
_messageList.VerticalScrollBar.Visible = true;
|
||||
|
||||
@@ -249,7 +257,7 @@ public sealed partial class MainWindow : Runnable
|
||||
// Bottom input area
|
||||
_inputFrame = new FrameView
|
||||
{
|
||||
Title = "Message \u2502 Enter=send \u2502 Ctrl+N=newline \u2502 Tab=complete \u2502 Ctrl+K=search",
|
||||
Title = DefaultInputTitle,
|
||||
X = 22,
|
||||
Y = Pos.Bottom(_chatFrame),
|
||||
Width = Dim.Fill(UsersPanelWidth),
|
||||
@@ -321,6 +329,27 @@ public sealed partial class MainWindow : Runnable
|
||||
KeyDown += OnWindowKeyDown;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the attachment staging indicator shown on the input frame's title.
|
||||
/// Passing an empty list restores the default hint.
|
||||
/// </summary>
|
||||
public void SetStagedAttachments(IReadOnlyList<string> fileNames)
|
||||
{
|
||||
_hasStagedAttachments = fileNames.Count > 0;
|
||||
if (fileNames.Count == 0)
|
||||
{
|
||||
_inputFrame.Title = DefaultInputTitle;
|
||||
}
|
||||
else
|
||||
{
|
||||
var names = string.Join(", ", fileNames);
|
||||
if (names.Length > 60)
|
||||
names = names[..57] + "...";
|
||||
_inputFrame.Title = $"📎 {fileNames.Count} staged: {names} │ Enter=send │ /clear to drop";
|
||||
}
|
||||
_inputFrame.SetNeedsDraw();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the currently registered color schemes to all views.
|
||||
/// Call after theme changes to refresh colors.
|
||||
@@ -458,21 +487,21 @@ public sealed partial class MainWindow : Runnable
|
||||
// Audio/file attachments take priority
|
||||
if (line.AttachmentUrl is not null && line.AttachmentFileName is not null)
|
||||
{
|
||||
if (line.Type == MessageType.Audio)
|
||||
if (line.AttachmentKind == AttachmentKind.Audio)
|
||||
{
|
||||
OnAudioPlayRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.Type == MessageType.File)
|
||||
if (line.AttachmentKind == AttachmentKind.File)
|
||||
{
|
||||
OnFileDownloadRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.Type == MessageType.Image)
|
||||
if (line.AttachmentKind == AttachmentKind.Image)
|
||||
{
|
||||
OnImageSaveRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
|
||||
e.Handled = true;
|
||||
@@ -510,6 +539,32 @@ public sealed partial class MainWindow : Runnable
|
||||
}
|
||||
}
|
||||
|
||||
private void OnMessageListKeyDown(object? sender, Key e)
|
||||
{
|
||||
if (e.KeyCode != Key.Delete.KeyCode && e.KeyCode != Key.Backspace.KeyCode)
|
||||
return;
|
||||
|
||||
if (_messageList.Source is not ChatListSource source)
|
||||
return;
|
||||
|
||||
var index = _messageList.SelectedItem;
|
||||
if (!index.HasValue || index.Value < 0 || index.Value >= source.Count)
|
||||
return;
|
||||
|
||||
var line = source.GetLine(index.Value);
|
||||
if (line?.MessageId is not { } messageId)
|
||||
return;
|
||||
|
||||
// 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");
|
||||
if (confirm == 0)
|
||||
OnDeleteMessageRequested?.Invoke(messageId);
|
||||
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnMessageListVerticalScrollBarScrolled(object? sender, EventArgs<int> e)
|
||||
{
|
||||
if (_messageList.VerticalScrollBar.Value == 0)
|
||||
@@ -545,7 +600,9 @@ public sealed partial class MainWindow : Runnable
|
||||
else if (e.KeyCode == EnterKey.KeyCode)
|
||||
{
|
||||
var text = _inputField.Text?.Trim() ?? string.Empty;
|
||||
if (!string.IsNullOrEmpty(text) && !string.IsNullOrEmpty(_messageManager.CurrentChannel))
|
||||
// Send when there's text, or when only attachments are staged (empty caption).
|
||||
if ((!string.IsNullOrEmpty(text) || _hasStagedAttachments)
|
||||
&& !string.IsNullOrEmpty(_messageManager.CurrentChannel))
|
||||
{
|
||||
OnMessageSubmitted?.Invoke(_messageManager.CurrentChannel, text);
|
||||
_inputField.Text = string.Empty;
|
||||
@@ -564,9 +621,13 @@ public sealed partial class MainWindow : Runnable
|
||||
}
|
||||
else if (e.KeyCode == CtrlVKey.KeyCode || e.KeyCode == CtrlYKey.KeyCode)
|
||||
{
|
||||
// Explicit paste support: terminals that don't intercept Ctrl+V themselves
|
||||
// otherwise leave users with only the right-click context menu.
|
||||
GuardedClipboardAction(() => _inputField.Paste(), "paste");
|
||||
// If a file was copied in the OS file manager, the clipboard holds a file list
|
||||
// (not text) — attach it. Otherwise paste text. This is the reliable path on
|
||||
// Windows Terminal, which never pastes copied files as text.
|
||||
if (ClipboardFiles.TryGetFiles(out var pastedFiles))
|
||||
StageFiles(pastedFiles);
|
||||
else
|
||||
GuardedClipboardAction(() => _inputField.Paste(), "paste");
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.KeyCode == CtrlXKey.KeyCode)
|
||||
@@ -599,42 +660,35 @@ public sealed partial class MainWindow : Runnable
|
||||
}
|
||||
|
||||
private bool _suppressEmojiReplace;
|
||||
private int _lastInputLength;
|
||||
|
||||
private void OnInputContentsChanged(object? sender, ContentsChangedEventArgs e)
|
||||
{
|
||||
var text = _inputField.Text;
|
||||
var previousLength = _lastInputLength;
|
||||
_lastInputLength = text?.Length ?? 0;
|
||||
|
||||
if (_suppressEmojiReplace)
|
||||
return;
|
||||
|
||||
var text = _inputField.Text;
|
||||
if (string.IsNullOrEmpty(text))
|
||||
return;
|
||||
|
||||
// A file dropped onto the terminal arrives as a pasted absolute path.
|
||||
// Detect multi-char bursts that resolve to existing files and route them
|
||||
// through /send instead of leaving a raw path in the input.
|
||||
if (text.Length - previousLength > 3 && TryGetDroppedFiles(text, out var droppedFiles))
|
||||
// A file dropped onto the terminal is delivered as its absolute path inserted into the
|
||||
// input — often character by character (this Terminal.Gui build has no bracketed-paste
|
||||
// coalescing). As soon as the input resolves to existing file path(s), route them
|
||||
// through /send (which stages them) instead of leaving a raw path to be sent as a message.
|
||||
if (DroppedFileParser.LooksLikePath(text) && DroppedFileParser.TryGetFiles(text, out var droppedFiles)
|
||||
&& !string.IsNullOrEmpty(_messageManager.CurrentChannel))
|
||||
{
|
||||
var channel = _messageManager.CurrentChannel;
|
||||
if (!string.IsNullOrEmpty(channel))
|
||||
_suppressEmojiReplace = true;
|
||||
try
|
||||
{
|
||||
_suppressEmojiReplace = true;
|
||||
try
|
||||
{
|
||||
_inputField.Text = string.Empty;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_suppressEmojiReplace = false;
|
||||
}
|
||||
|
||||
foreach (var file in droppedFiles)
|
||||
OnMessageSubmitted?.Invoke(channel, $"/send \"{file}\"");
|
||||
return;
|
||||
_inputField.Text = string.Empty;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_suppressEmojiReplace = false;
|
||||
}
|
||||
|
||||
StageFiles(droppedFiles);
|
||||
return;
|
||||
}
|
||||
|
||||
var replaced = EmojiHelper.ReplaceEmoji(text);
|
||||
@@ -695,77 +749,17 @@ public sealed partial class MainWindow : Runnable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interprets pasted text as one or more dropped files. Terminals deliver a file drop
|
||||
/// as the absolute path (quoted when it contains spaces; multiple files space-separated).
|
||||
/// Returns true only when the entire input resolves to existing files.
|
||||
/// Routes files (from a drop or a file-clipboard paste) through the /send pipeline, which
|
||||
/// stages them; the next Enter sends them with any typed caption.
|
||||
/// </summary>
|
||||
private static bool TryGetDroppedFiles(string text, out List<string> files)
|
||||
private void StageFiles(IEnumerable<string> files)
|
||||
{
|
||||
files = [];
|
||||
var channel = _messageManager.CurrentChannel;
|
||||
if (string.IsNullOrEmpty(channel))
|
||||
return;
|
||||
|
||||
var trimmed = text.Trim();
|
||||
if (trimmed.Length < 3 || trimmed.Length > 4096 || trimmed.Contains('\n'))
|
||||
return false;
|
||||
|
||||
// Single unquoted path, possibly with spaces (e.g. WSL or plain conhost drops)
|
||||
var unquoted = StripQuotes(trimmed);
|
||||
if (Path.IsPathFullyQualified(unquoted) && File.Exists(unquoted))
|
||||
{
|
||||
files.Add(unquoted);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Multiple files: space-separated tokens, each optionally quoted
|
||||
foreach (var token in TokenizeQuoted(trimmed))
|
||||
{
|
||||
if (!Path.IsPathFullyQualified(token) || !File.Exists(token))
|
||||
{
|
||||
files.Clear();
|
||||
return false;
|
||||
}
|
||||
files.Add(token);
|
||||
}
|
||||
|
||||
return files.Count > 0;
|
||||
}
|
||||
|
||||
private static string StripQuotes(string s) =>
|
||||
s.Length >= 2 && ((s[0] == '"' && s[^1] == '"') || (s[0] == '\'' && s[^1] == '\''))
|
||||
? s[1..^1]
|
||||
: s;
|
||||
|
||||
private static IEnumerable<string> TokenizeQuoted(string input)
|
||||
{
|
||||
var current = new System.Text.StringBuilder();
|
||||
var quote = '\0';
|
||||
|
||||
foreach (var c in input)
|
||||
{
|
||||
if (quote != '\0')
|
||||
{
|
||||
if (c == quote) quote = '\0';
|
||||
else current.Append(c);
|
||||
}
|
||||
else if (c is '"' or '\'')
|
||||
{
|
||||
quote = c;
|
||||
}
|
||||
else if (c == ' ')
|
||||
{
|
||||
if (current.Length > 0)
|
||||
{
|
||||
yield return current.ToString();
|
||||
current.Clear();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
current.Append(c);
|
||||
}
|
||||
}
|
||||
|
||||
if (current.Length > 0)
|
||||
yield return current.ToString();
|
||||
foreach (var file in files)
|
||||
OnMessageSubmitted?.Invoke(channel, $"/send \"{file}\"");
|
||||
}
|
||||
|
||||
private void OnChatViewportChanged()
|
||||
|
||||
Reference in New Issue
Block a user