From 953e0811238cf29a2a09392bc8fdd28971df8b20 Mon Sep 17 00:00:00 2001 From: HueByte Date: Fri, 17 Jul 2026 16:52:15 +0200 Subject: [PATCH] feat: add image handling actions and improve IRC message formatting --- src/EchoHub.Client/AppOrchestrator.cs | 57 +++++++++ src/EchoHub.Client/UI/Chat/ChatLine.cs | 22 ++++ .../UI/Chat/ChatMessageManager.cs | 39 ++++++- src/EchoHub.Client/UI/MainWindow.cs | 38 +++++- src/EchoHub.Server.Irc/IrcBroadcaster.cs | 15 +-- src/EchoHub.Server.Irc/IrcCommandHandler.cs | 2 +- src/EchoHub.Server.Irc/IrcMessageFormatter.cs | 84 ++++---------- src/EchoHub.Server.Irc/IrcOptions.cs | 7 ++ .../Controllers/FilesController.cs | 12 ++ .../DataMigrationServiceTests.cs | 8 +- src/EchoHub.Tests/Irc/IrcBroadcasterTests.cs | 8 +- .../Irc/IrcMessageFormatterTests.cs | 109 +++++------------- src/EchoHub.Tests/IrcMessageFormatterTests.cs | 57 +++------ 13 files changed, 249 insertions(+), 209 deletions(-) diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs index 460a975..d00d1f7 100644 --- a/src/EchoHub.Client/AppOrchestrator.cs +++ b/src/EchoHub.Client/AppOrchestrator.cs @@ -110,6 +110,7 @@ public sealed class AppOrchestrator : IDisposable _mainWindow.OnAudioPlayRequested += HandleAudioPlayRequested; _mainWindow.OnFileDownloadRequested += HandleFileDownloadRequested; _mainWindow.OnImageSaveRequested += HandleImageSaveRequested; + _mainWindow.OnImageOpenRequested += HandleImageOpenRequested; _mainWindow.OnDeleteMessageRequested += HandleDeleteMessageRequested; _mainWindow.OnCheckForUpdatesRequested += HandleCheckForUpdatesRequested; _mainWindow.OnRollbackRequested += HandleRollbackRequested; @@ -1704,6 +1705,62 @@ public sealed class AppOrchestrator : IDisposable return tempPath; } + /// File extensions the "[open]" action will hand to the OS image viewer for E2E rooms. + private static readonly HashSet ImageOpenExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", + }; + + /// + /// Views an image without saving it to the user's downloads. Plain channels open the + /// file's web URL in the default browser (the server serves files by capability URL, so + /// no auth token is needed). E2E-encrypted channels would render as ciphertext in a + /// browser, so the blob is downloaded, decrypted locally, and opened from a temp file. + /// + private void HandleImageOpenRequested(string attachmentUrl, string fileName) + { + if (!_conn.IsAuthenticated) return; + + var channel = _mainWindow.CurrentChannel; + var isEncryptedRoom = !string.IsNullOrEmpty(channel) && _conn.RoomKeys.TryGetKey(channel, out _); + + if (!isEncryptedRoom) + { + var webUrl = attachmentUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + || attachmentUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase) + ? attachmentUrl + : $"{_conn.Api!.BaseUrl}/{attachmentUrl.TrimStart('/')}"; + + try + { + System.Diagnostics.Process.Start( + new System.Diagnostics.ProcessStartInfo(webUrl) { UseShellExecute = true }); + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to open image URL in browser: {Url}", webUrl); + InvokeUI(() => _messageManager.AddSystemMessage(channel, $"Couldn't open a browser — image URL: {webUrl}")); + } + return; + } + + // In E2E rooms the attachment kind is sender-declared, so only hand real image + // extensions to the OS viewer; anything else goes through the save path instead. + if (!ImageOpenExtensions.Contains(Path.GetExtension(fileName))) + { + HandleImageSaveRequested(attachmentUrl, fileName); + return; + } + + RunAsync(async () => + { + InvokeUI(() => _messageManager.AddSystemMessage(channel, $"Decrypting {fileName}...")); + var tempPath = await DownloadAttachmentAsync(attachmentUrl, fileName); + var psi = new System.Diagnostics.ProcessStartInfo(tempPath) { UseShellExecute = true }; + System.Diagnostics.Process.Start(psi); + }, "Failed to open image"); + } + private void HandleImageSaveRequested(string attachmentUrl, string fileName) { if (!_conn.IsAuthenticated) return; diff --git a/src/EchoHub.Client/UI/Chat/ChatLine.cs b/src/EchoHub.Client/UI/Chat/ChatLine.cs index f7eef86..6dfb7fa 100644 --- a/src/EchoHub.Client/UI/Chat/ChatLine.cs +++ b/src/EchoHub.Client/UI/Chat/ChatLine.cs @@ -7,6 +7,16 @@ using Attribute = Terminal.Gui.Drawing.Attribute; namespace EchoHub.Client.UI.Chat; +/// An action a click on an attachment line can trigger. +public enum AttachmentAction +{ + OpenImage, + SaveImage, +} + +/// Inclusive column range on a chat line that triggers an attachment action when clicked. +public readonly record struct AttachmentActionSpan(int StartCol, int EndCol, AttachmentAction Action); + /// /// A single line in the chat, composed of colored segments. /// @@ -20,6 +30,14 @@ public partial class ChatLine public string? AttachmentFileName { get; set; } public AttachmentKind? AttachmentKind { get; set; } public string? SenderUsername { get; set; } + + /// + /// Clickable sub-line targets (e.g. the "[open]" and "[save original]" brackets under an + /// image). Columns are relative to the unwrapped line, so only the first wrapped line + /// keeps them. Null means the whole line uses the kind's default action. + /// + public List? ActionSpans { get; set; } + /// Number of spaces to prepend on continuation lines when this line is word-wrapped. public int ContinuationIndent { get; set; } @@ -160,6 +178,10 @@ public partial class ChatLine wrapped.IsMention = IsMention; } + // Span columns only line up with the first wrapped line; later lines fall + // back to the kind's default action. + results[0].ActionSpans = ActionSpans; + return results; } diff --git a/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs b/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs index 44ac51e..5e316a3 100644 --- a/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs +++ b/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs @@ -443,9 +443,7 @@ public sealed class ChatMessageManager lines.Add(new ChatLine(segments)); } } - lines.Add(AttachmentActionLine( - $"[↓ save original] {attachment.FileName} [{FormatFileSize(attachment.FileSize)}]", - ChatColors.FileAttr, attachment)); + lines.Add(ImageActionLine(attachment)); break; case Core.Models.AttachmentKind.Audio: @@ -489,6 +487,41 @@ public sealed class ChatMessageManager return lines; } + /// + /// Builds the action line below an image preview: "[open] [↓ save original] name [size]". + /// Each bracket is an so a mouse click can target it; + /// keyboard activation (Enter) uses the default action, open. + /// + private static ChatLine ImageActionLine(AttachmentDto attachment) + { + var segments = RailPrefix(); + var col = segments.Sum(s => s.Text.GetColumns()); + var spans = new List(); + + void AddAction(string text, AttachmentAction action) + { + var width = text.GetColumns(); + spans.Add(new AttachmentActionSpan(col, col + width - 1, action)); + segments.Add(new(text, ChatColors.FileAttr)); + col += width; + } + + AddAction("[open]", AttachmentAction.OpenImage); + segments.Add(new(" ", null)); + col += 1; + AddAction("[↓ save original]", AttachmentAction.SaveImage); + segments.Add(new($" {attachment.FileName} [{FormatFileSize(attachment.FileSize)}]", ChatColors.FileAttr)); + + return new ChatLine(segments) + { + AttachmentUrl = attachment.Url, + AttachmentFileName = attachment.FileName, + AttachmentKind = attachment.Kind, + ActionSpans = spans, + ContinuationPrefixSegments = RailPrefix(), + }; + } + /// /// Builds a clickable attachment line carrying the metadata the message list uses to /// route activation (play audio, download file, save original image). diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index e518cc7..e9439e8 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -176,6 +176,12 @@ public sealed partial class MainWindow : Runnable /// public event Action? OnImageSaveRequested; + /// + /// Fired when the user activates an image's "[open]" action to view it without saving. + /// Parameters: attachmentUrl, fileName. + /// + public event Action? OnImageOpenRequested; + /// /// Fired when the user presses Delete on the selected message. Parameter is the message id. /// @@ -532,7 +538,9 @@ public sealed partial class MainWindow : Runnable if (line.AttachmentKind == AttachmentKind.Image) { - OnImageSaveRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName); + // Keyboard/default activation opens the image for viewing; + // saving is the mouse span or the context menu. + OnImageOpenRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName); e.Handled = true; return; } @@ -600,7 +608,8 @@ public sealed partial class MainWindow : Runnable private void OnMessageListMouseEvent(object? sender, Mouse e) { - if (!e.Flags.HasFlag(MouseFlags.RightButtonClicked)) + var leftClick = e.Flags.HasFlag(MouseFlags.LeftButtonClicked); + if (!leftClick && !e.Flags.HasFlag(MouseFlags.RightButtonClicked)) return; if (_messageList.Source is not ChatListSource source || source.Count == 0 || e.Position is not { } pos) @@ -610,6 +619,30 @@ public sealed partial class MainWindow : Runnable if (index < 0 || index >= source.Count) return; + // Left-click only activates the "[open]" / "[save original]" brackets on an + // attachment action line; anywhere else it falls through to normal selection. + if (leftClick) + { + var clicked = source.GetLine(index); + if (clicked?.ActionSpans is { } spans + && clicked.AttachmentUrl is { } url && clicked.AttachmentFileName is { } name) + { + foreach (var span in spans) + { + if (pos.X < span.StartCol || pos.X > span.EndCol) + continue; + + if (span.Action == AttachmentAction.OpenImage) + OnImageOpenRequested?.Invoke(url, name); + else + OnImageSaveRequested?.Invoke(url, name); + e.Handled = true; + return; + } + } + return; + } + // Select the right-clicked row (so the menu acts on it and it highlights), then show the menu. _messageList.SelectedItem = index; _messageList.SetFocus(); @@ -636,6 +669,7 @@ public sealed partial class MainWindow : Runnable switch (kind) { case AttachmentKind.Image: + items.Add(new MenuItem("Open image", "", () => OnImageOpenRequested?.Invoke(url, name), Key.Empty)); items.Add(new MenuItem("Save original image", "", () => OnImageSaveRequested?.Invoke(url, name), Key.Empty)); break; case AttachmentKind.Audio: diff --git a/src/EchoHub.Server.Irc/IrcBroadcaster.cs b/src/EchoHub.Server.Irc/IrcBroadcaster.cs index 074e8ba..a5c2317 100644 --- a/src/EchoHub.Server.Irc/IrcBroadcaster.cs +++ b/src/EchoHub.Server.Irc/IrcBroadcaster.cs @@ -16,17 +16,10 @@ public class IrcBroadcaster : IChatBroadcaster public async Task SendMessageToChannelAsync(string channelName, MessageDto message) { - // Decrypt content and attachment previews for IRC clients (they can't handle - // app-layer encryption). E2E room ciphertext ($RC1$) passes through untouched; - // the formatter drops previews that are still ciphertext. - var decryptedMessage = message with - { - Content = _encryption.Decrypt(message.Content), - Attachments = message.Attachments? - .Select(a => a with { AsciiPreview = _encryption.DecryptNullable(a.AsciiPreview) }) - .ToList(), - }; - var lines = IrcMessageFormatter.FormatMessage(decryptedMessage); + // Decrypt transport-encrypted content for IRC clients (they can't handle + // app-layer encryption). E2E room ciphertext ($RC1$) passes through untouched. + var decryptedMessage = message with { Content = _encryption.Decrypt(message.Content) }; + var lines = IrcMessageFormatter.FormatMessage(decryptedMessage, _gateway.Options.PublicBaseUrl); foreach (var conn in _gateway.GetConnectionsInChannel(channelName)) { diff --git a/src/EchoHub.Server.Irc/IrcCommandHandler.cs b/src/EchoHub.Server.Irc/IrcCommandHandler.cs index 0e27201..4745c90 100644 --- a/src/EchoHub.Server.Irc/IrcCommandHandler.cs +++ b/src/EchoHub.Server.Irc/IrcCommandHandler.cs @@ -433,7 +433,7 @@ public sealed class IrcCommandHandler foreach (var m in history) { var decrypted = m with { Content = _encryption.Decrypt(m.Content) }; - var lines = IrcMessageFormatter.FormatMessage(decrypted); + var lines = IrcMessageFormatter.FormatMessage(decrypted, _options.PublicBaseUrl); foreach (var line in lines) await _conn.SendAsync(line); } diff --git a/src/EchoHub.Server.Irc/IrcMessageFormatter.cs b/src/EchoHub.Server.Irc/IrcMessageFormatter.cs index 01b4ab5..269ba64 100644 --- a/src/EchoHub.Server.Irc/IrcMessageFormatter.cs +++ b/src/EchoHub.Server.Irc/IrcMessageFormatter.cs @@ -1,20 +1,20 @@ using System.Text; -using System.Text.RegularExpressions; -using EchoHub.Core.Contracts; using EchoHub.Core.DTOs; using EchoHub.Core.Models; -using EchoHub.Core.Security; namespace EchoHub.Server.Irc; -public static partial class IrcMessageFormatter +public static class IrcMessageFormatter { private const int MaxIrcLineContentBytes = 400; /// /// Format a MessageDto as one or more IRC PRIVMSG lines. + /// Attachments are rendered as single link lines \u2014 the widely-supported IRC convention + /// (clients auto-preview or open plain http(s) URLs) \u2014 never as terminal color art. + /// makes the links absolute so any IRC client can open them. /// - public static List FormatMessage(MessageDto message) + public static List FormatMessage(MessageDto message, string? publicBaseUrl = null) { var lines = new List(); var ircChannel = $"#{message.ChannelName}"; @@ -27,34 +27,19 @@ public static partial class IrcMessageFormatter lines.Add($"{prefix} PRIVMSG {ircChannel} :{chunk}"); } - // One block per attachment + // One link line per attachment if (message.Attachments is { Count: > 0 }) { foreach (var attachment in message.Attachments) { - switch (attachment.Kind) + var url = ToAbsoluteUrl(attachment.Url, publicBaseUrl); + var tag = attachment.Kind switch { - case AttachmentKind.Image: - lines.Add($"{prefix} PRIVMSG {ircChannel} :[Image: {attachment.FileName}] {attachment.Url}"); - if (attachment.AsciiPreview is not null && !IsCiphertext(attachment.AsciiPreview)) - { - foreach (var line in attachment.AsciiPreview.Split('\n')) - { - var trimmed = line.TrimEnd('\r'); - if (trimmed.Length > 0) - lines.Add($"{prefix} PRIVMSG {ircChannel} :{ColorTagsToAnsi(trimmed)}"); - } - } - break; - - case AttachmentKind.Audio: - lines.Add($"{prefix} PRIVMSG {ircChannel} :\u266a [Audio: {attachment.FileName}] {attachment.Url}"); - break; - - default: - lines.Add($"{prefix} PRIVMSG {ircChannel} :[File: {attachment.FileName}] {attachment.Url}"); - break; - } + AttachmentKind.Image => $"[Image: {attachment.FileName}]", + AttachmentKind.Audio => $"\u266a [Audio: {attachment.FileName}]", + _ => $"[File: {attachment.FileName}]", + }; + lines.Add($"{prefix} PRIVMSG {ircChannel} :{tag} {url}"); } } @@ -69,13 +54,15 @@ public static partial class IrcMessageFormatter } /// - /// True when a preview is still encrypted — transport ($ENC$v1$) if a broadcast path - /// forgot to decrypt it, or E2E room ciphertext ($RC1$) the server cannot decrypt. - /// Emitting it would flood IRC clients with a multi-KB base64 blob. + /// Joins a relative attachment path onto the configured public base URL. + /// Already-absolute URLs and unset base URLs pass through unchanged. /// - private static bool IsCiphertext(string text) => - text.StartsWith(IMessageEncryptionService.CiphertextPrefix, StringComparison.Ordinal) - || text.StartsWith(RoomCrypto.CiphertextPrefix, StringComparison.Ordinal); + public static string ToAbsoluteUrl(string url, string? publicBaseUrl) + { + if (string.IsNullOrWhiteSpace(publicBaseUrl) || Uri.IsWellFormedUriString(url, UriKind.Absolute)) + return url; + return $"{publicBaseUrl.TrimEnd('/')}/{url.TrimStart('/')}"; + } /// /// Format a link embed as IRC PRIVMSG lines (text-only, no ASCII thumbnail). @@ -104,35 +91,6 @@ public static partial class IrcMessageFormatter return lines; } - /// - /// Convert printable color tags ({F:RRGGBB}, {B:RRGGBB}, {X}) to ANSI escape codes for IRC clients. - /// Also passes through content that already uses ANSI codes unchanged. - /// - public static string ColorTagsToAnsi(string text) - { - if (!text.Contains('{')) - return text; - - return ColorTagRegex().Replace(text, match => - { - if (match.Groups[1].Success) // {X} reset - return "\x1b[0m"; - if (match.Groups[2].Success) // {F:RRGGBB} or {B:RRGGBB} - { - var hex = match.Groups[3].Value; - var r = Convert.ToInt32(hex[..2], 16); - var g = Convert.ToInt32(hex[2..4], 16); - var b = Convert.ToInt32(hex[4..6], 16); - var code = match.Groups[2].Value == "F" ? "38" : "48"; - return $"\x1b[{code};2;{r};{g};{b}m"; - } - return match.Value; - }); - } - - [GeneratedRegex(@"\{(?:(X)|(?:(F|B):([0-9A-Fa-f]{6})))\}")] - private static partial Regex ColorTagRegex(); - /// /// Split a message into chunks of approximately maxBytes (UTF-8), at word boundaries. /// diff --git a/src/EchoHub.Server.Irc/IrcOptions.cs b/src/EchoHub.Server.Irc/IrcOptions.cs index 2bfa9bd..29b112a 100644 --- a/src/EchoHub.Server.Irc/IrcOptions.cs +++ b/src/EchoHub.Server.Irc/IrcOptions.cs @@ -12,4 +12,11 @@ public sealed class IrcOptions public string? TlsCertPassword { get; set; } public string ServerName { get; set; } = "echohub"; public string? Motd { get; set; } + + /// + /// Public HTTP(S) base of this EchoHub server (e.g. "https://chat.example.com"), + /// used to turn relative attachment URLs into absolute links IRC clients can open. + /// When unset, attachment lines fall back to the relative path. + /// + public string? PublicBaseUrl { get; set; } } diff --git a/src/EchoHub.Server/Controllers/FilesController.cs b/src/EchoHub.Server/Controllers/FilesController.cs index 42db03f..914631d 100644 --- a/src/EchoHub.Server/Controllers/FilesController.cs +++ b/src/EchoHub.Server/Controllers/FilesController.cs @@ -19,7 +19,14 @@ public class FilesController : ControllerBase _fileStorage = fileStorage; } + /// + /// Serves an uploaded file. Anonymous by design: the unguessable GUID in the URL is the + /// access token (Discord-CDN-style capability URL), so attachment links can be opened + /// directly in a browser and shared to IRC clients. E2E-encrypted room blobs are + /// ciphertext at rest, so anonymous access reveals nothing for those channels. + /// [HttpGet("{fileId}")] + [AllowAnonymous] public IActionResult GetFile(string fileId) { if (!Guid.TryParse(fileId, out _)) @@ -48,6 +55,11 @@ public class FilesController : ControllerBase _ => "application/octet-stream" }; + // Images and audio render inline so a browser displays them instead of + // downloading; everything else keeps the attachment disposition. + if (contentType.StartsWith("image/") || contentType.StartsWith("audio/")) + return PhysicalFile(filePath, contentType); + var fileName = Path.GetFileName(filePath); return PhysicalFile(filePath, contentType, fileName); } diff --git a/src/EchoHub.Tests/DataMigrationServiceTests.cs b/src/EchoHub.Tests/DataMigrationServiceTests.cs index 31c2198..6f40725 100644 --- a/src/EchoHub.Tests/DataMigrationServiceTests.cs +++ b/src/EchoHub.Tests/DataMigrationServiceTests.cs @@ -62,13 +62,11 @@ public class DataMigrationServiceTests } [Fact] - public void AnsiToColorTags_RoundTrip_WithColorTagsToAnsi() + public void AnsiToColorTags_ForegroundBackgroundAndReset_AllConverted() { - // AnsiToColorTags and IrcMessageFormatter.ColorTagsToAnsi should be inverses - var original = "{F:FF0000}red{B:00FF00}green{X}"; - var ansi = EchoHub.Server.Irc.IrcMessageFormatter.ColorTagsToAnsi(original); + var ansi = "\x1b[38;2;255;0;0mred\x1b[48;2;0;255;0mgreen\x1b[0m"; var backToTags = DataMigrationService.AnsiToColorTags(ansi); - Assert.Equal(original, backToTags); + Assert.Equal("{F:FF0000}red{B:00FF00}green{X}", backToTags); } } diff --git a/src/EchoHub.Tests/Irc/IrcBroadcasterTests.cs b/src/EchoHub.Tests/Irc/IrcBroadcasterTests.cs index 0e08d82..c148dfc 100644 --- a/src/EchoHub.Tests/Irc/IrcBroadcasterTests.cs +++ b/src/EchoHub.Tests/Irc/IrcBroadcasterTests.cs @@ -90,7 +90,7 @@ public class IrcBroadcasterTests } [Fact] - public async Task SendMessage_DecryptsAttachmentAsciiPreview() + public async Task SendMessage_ImageAttachment_SendsLinkLineOnly() { var (_, stream) = AddConnectionWithCapture("bob", "general"); @@ -104,9 +104,9 @@ public class IrcBroadcasterTests await _broadcaster.SendMessageToChannelAsync("general", message); var output = stream.GetOutputLines(); - Assert.Contains(output, l => l.Contains("[Image: photo.png]")); - Assert.Contains(output, l => l.Contains("line1")); - Assert.Contains(output, l => l.Contains("line2")); + Assert.Contains(output, l => l.Contains("[Image: photo.png]") && l.Contains("/api/files/abc")); + // ASCII preview art is never sent to IRC clients — images are links only + Assert.DoesNotContain(output, l => l.Contains("line1")); Assert.DoesNotContain(output, l => l.Contains("$ENC$")); } diff --git a/src/EchoHub.Tests/Irc/IrcMessageFormatterTests.cs b/src/EchoHub.Tests/Irc/IrcMessageFormatterTests.cs index 29736bc..c393397 100644 --- a/src/EchoHub.Tests/Irc/IrcMessageFormatterTests.cs +++ b/src/EchoHub.Tests/Irc/IrcMessageFormatterTests.cs @@ -118,37 +118,50 @@ public class IrcMessageFormatterTests } [Fact] - public void FormatMessage_ImageMessage_IncludesAsciiArt() + public void FormatMessage_ImageMessage_NeverEmitsAsciiArt() { - var msg = CreateImageMessage("line1\nline2"); + // Images are shared as plain links (the common IRC practice) — never color art, + // regardless of what the preview contains. + var msg = CreateImageMessage("{F:FF0000}█{X}\nline2"); var lines = IrcMessageFormatter.FormatMessage(msg); - Assert.Contains(lines, l => l.Contains("line1")); - Assert.Contains(lines, l => l.Contains("line2")); + Assert.Single(lines); + Assert.Contains("[Image: image.png]", lines[0]); + Assert.DoesNotContain(lines, l => l.Contains("line2")); } [Fact] - public void FormatMessage_ImageMessage_SkipsEmptyAsciiLines() + public void FormatMessage_RelativeUrl_JoinedWithPublicBaseUrl() { - var msg = CreateImageMessage("line1\n\nline2"); - var lines = IrcMessageFormatter.FormatMessage(msg); + var msg = CreateImageMessage("art", "photo.jpg", "/api/files/abc"); + var lines = IrcMessageFormatter.FormatMessage(msg, "https://chat.example.com"); - // Empty lines should be skipped - var asciiLines = lines.Where(l => !l.Contains("[Image:") && !l.Contains("Download:")).ToList(); - Assert.Equal(2, asciiLines.Count); + Assert.Single(lines); + Assert.Contains("https://chat.example.com/api/files/abc", lines[0]); } - [Theory] - [InlineData("$ENC$v1$abc123$def456")] // transport ciphertext a broadcast path forgot to decrypt - [InlineData("$RC1$abc123def456")] // E2E room ciphertext the server cannot decrypt - public void FormatMessage_ImageMessage_SkipsCiphertextPreview(string ciphertextPreview) + [Fact] + public void FormatMessage_AbsoluteUrl_NotRewrittenByPublicBaseUrl() { - var msg = CreateImageMessage(ciphertextPreview, "photo.jpg", "https://example.com/photo.jpg"); - var lines = IrcMessageFormatter.FormatMessage(msg); + var msg = CreateImageMessage("art", "photo.jpg", "https://cdn.example.com/photo.jpg"); + var lines = IrcMessageFormatter.FormatMessage(msg, "https://chat.example.com"); - // Only the [Image: ...] header line — never the ciphertext blob - Assert.Single(lines); - Assert.Contains("[Image: photo.jpg]", lines[0]); + Assert.Contains("https://cdn.example.com/photo.jpg", lines[0]); + Assert.DoesNotContain("https://chat.example.com", lines[0]); + } + + [Fact] + public void ToAbsoluteUrl_NoBaseUrl_ReturnsRelativeUnchanged() + { + Assert.Equal("/api/files/abc", IrcMessageFormatter.ToAbsoluteUrl("/api/files/abc", null)); + Assert.Equal("/api/files/abc", IrcMessageFormatter.ToAbsoluteUrl("/api/files/abc", " ")); + } + + [Fact] + public void ToAbsoluteUrl_TrailingSlashBase_JoinsWithoutDoubleSlash() + { + Assert.Equal("https://x.example/api/files/1", + IrcMessageFormatter.ToAbsoluteUrl("/api/files/1", "https://x.example/")); } [Fact] @@ -244,62 +257,4 @@ public class IrcMessageFormatterTests } } - // ── ColorTagsToAnsi ────────────────────────────────────────────────── - - [Fact] - public void ColorTagsToAnsi_NoTags_ReturnsUnchanged() - { - Assert.Equal("Hello world", IrcMessageFormatter.ColorTagsToAnsi("Hello world")); - } - - [Fact] - public void ColorTagsToAnsi_ForegroundTag_ConvertsToAnsi() - { - var result = IrcMessageFormatter.ColorTagsToAnsi("{F:FF0000}Red text"); - Assert.Equal("\x1b[38;2;255;0;0mRed text", result); - } - - [Fact] - public void ColorTagsToAnsi_BackgroundTag_ConvertsToAnsi() - { - var result = IrcMessageFormatter.ColorTagsToAnsi("{B:00FF00}Green bg"); - Assert.Equal("\x1b[48;2;0;255;0mGreen bg", result); - } - - [Fact] - public void ColorTagsToAnsi_ResetTag_ConvertsToReset() - { - var result = IrcMessageFormatter.ColorTagsToAnsi("{F:FF0000}Red{X} Normal"); - Assert.Equal("\x1b[38;2;255;0;0mRed\x1b[0m Normal", result); - } - - [Fact] - public void ColorTagsToAnsi_MultipleTags_ConvertsAll() - { - var result = IrcMessageFormatter.ColorTagsToAnsi("{F:FF0000}Red {F:0000FF}Blue{X}"); - Assert.Contains("\x1b[38;2;255;0;0m", result); - Assert.Contains("\x1b[38;2;0;0;255m", result); - Assert.Contains("\x1b[0m", result); - } - - [Fact] - public void ColorTagsToAnsi_LowercaseHex_ConvertsCorrectly() - { - var result = IrcMessageFormatter.ColorTagsToAnsi("{F:ff8800}text"); - Assert.Equal("\x1b[38;2;255;136;0mtext", result); - } - - [Fact] - public void ColorTagsToAnsi_NoBraces_SkipsProcessing() - { - var text = "plain text without braces"; - Assert.Equal(text, IrcMessageFormatter.ColorTagsToAnsi(text)); - } - - [Fact] - public void ColorTagsToAnsi_ExistingAnsiCodes_PreservesUnchanged() - { - var text = "\x1b[31mAlready colored\x1b[0m"; - Assert.Equal(text, IrcMessageFormatter.ColorTagsToAnsi(text)); - } } diff --git a/src/EchoHub.Tests/IrcMessageFormatterTests.cs b/src/EchoHub.Tests/IrcMessageFormatterTests.cs index a2521c3..38007c1 100644 --- a/src/EchoHub.Tests/IrcMessageFormatterTests.cs +++ b/src/EchoHub.Tests/IrcMessageFormatterTests.cs @@ -59,11 +59,24 @@ public class IrcMessageFormatterTests attachments: [new AttachmentDto(AttachmentKind.Image, "/api/files/abc", "photo.png", 0, "{F:FF0000}█{X}")]); var lines = IrcMessageFormatter.FormatMessage(msg); - Assert.True(lines.Count >= 2); + // Images are a single link line — the ASCII preview is never sent to IRC clients + Assert.Single(lines); Assert.Contains("[Image: photo.png]", lines[0]); Assert.Contains("/api/files/abc", lines[0]); } + [Fact] + public void FormatMessage_WithPublicBaseUrl_EmitsAbsoluteAttachmentLinks() + { + var msg = CreateMessage( + content: "", + attachments: [new AttachmentDto(AttachmentKind.Image, "/api/files/abc", "photo.png", 0, null)]); + var lines = IrcMessageFormatter.FormatMessage(msg, "https://chat.example.com/"); + + Assert.Single(lines); + Assert.Contains("https://chat.example.com/api/files/abc", lines[0]); + } + [Fact] public void FormatMessage_FileAttachment_IncludesFileTag() { @@ -121,48 +134,6 @@ public class IrcMessageFormatterTests Assert.Contains(lines, l => l.Contains("[File: c.pdf]")); } - // ── ColorTagsToAnsi ─────────────────────────────────────────────── - - [Fact] - public void ColorTagsToAnsi_ForegroundTag_ConvertsToAnsiEscape() - { - var result = IrcMessageFormatter.ColorTagsToAnsi("{F:FF0000}text"); - Assert.Contains("\x1b[38;2;255;0;0m", result); - Assert.Contains("text", result); - } - - [Fact] - public void ColorTagsToAnsi_BackgroundTag_ConvertsToAnsiEscape() - { - var result = IrcMessageFormatter.ColorTagsToAnsi("{B:00FF00}text"); - Assert.Contains("\x1b[48;2;0;255;0m", result); - } - - [Fact] - public void ColorTagsToAnsi_ResetTag_ConvertsToAnsiReset() - { - var result = IrcMessageFormatter.ColorTagsToAnsi("{X}"); - Assert.Equal("\x1b[0m", result); - } - - [Fact] - public void ColorTagsToAnsi_NoTags_ReturnsUnchanged() - { - var result = IrcMessageFormatter.ColorTagsToAnsi("plain text"); - Assert.Equal("plain text", result); - } - - [Fact] - public void ColorTagsToAnsi_MultipleTags_ConvertsAll() - { - var result = IrcMessageFormatter.ColorTagsToAnsi("{F:FF0000}red{F:0000FF}blue{X}"); - Assert.Contains("\x1b[38;2;255;0;0m", result); - Assert.Contains("\x1b[38;2;0;0;255m", result); - Assert.Contains("\x1b[0m", result); - Assert.Contains("red", result); - Assert.Contains("blue", result); - } - // ── SplitMessage ────────────────────────────────────────────────── [Fact]