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]