feat: add image handling actions and improve IRC message formatting

This commit is contained in:
HueByte
2026-07-17 16:52:15 +02:00
parent b9d099dd73
commit 953e081123
13 changed files with 249 additions and 209 deletions
+57
View File
@@ -110,6 +110,7 @@ public sealed class AppOrchestrator : IDisposable
_mainWindow.OnAudioPlayRequested += HandleAudioPlayRequested; _mainWindow.OnAudioPlayRequested += HandleAudioPlayRequested;
_mainWindow.OnFileDownloadRequested += HandleFileDownloadRequested; _mainWindow.OnFileDownloadRequested += HandleFileDownloadRequested;
_mainWindow.OnImageSaveRequested += HandleImageSaveRequested; _mainWindow.OnImageSaveRequested += HandleImageSaveRequested;
_mainWindow.OnImageOpenRequested += HandleImageOpenRequested;
_mainWindow.OnDeleteMessageRequested += HandleDeleteMessageRequested; _mainWindow.OnDeleteMessageRequested += HandleDeleteMessageRequested;
_mainWindow.OnCheckForUpdatesRequested += HandleCheckForUpdatesRequested; _mainWindow.OnCheckForUpdatesRequested += HandleCheckForUpdatesRequested;
_mainWindow.OnRollbackRequested += HandleRollbackRequested; _mainWindow.OnRollbackRequested += HandleRollbackRequested;
@@ -1704,6 +1705,62 @@ public sealed class AppOrchestrator : IDisposable
return tempPath; return tempPath;
} }
/// <summary>File extensions the "[open]" action will hand to the OS image viewer for E2E rooms.</summary>
private static readonly HashSet<string> ImageOpenExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp",
};
/// <summary>
/// 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.
/// </summary>
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) private void HandleImageSaveRequested(string attachmentUrl, string fileName)
{ {
if (!_conn.IsAuthenticated) return; if (!_conn.IsAuthenticated) return;
+22
View File
@@ -7,6 +7,16 @@ using Attribute = Terminal.Gui.Drawing.Attribute;
namespace EchoHub.Client.UI.Chat; namespace EchoHub.Client.UI.Chat;
/// <summary>An action a click on an attachment line can trigger.</summary>
public enum AttachmentAction
{
OpenImage,
SaveImage,
}
/// <summary>Inclusive column range on a chat line that triggers an attachment action when clicked.</summary>
public readonly record struct AttachmentActionSpan(int StartCol, int EndCol, AttachmentAction Action);
/// <summary> /// <summary>
/// A single line in the chat, composed of colored segments. /// A single line in the chat, composed of colored segments.
/// </summary> /// </summary>
@@ -20,6 +30,14 @@ public partial class ChatLine
public string? AttachmentFileName { get; set; } public string? AttachmentFileName { get; set; }
public AttachmentKind? AttachmentKind { get; set; } public AttachmentKind? AttachmentKind { get; set; }
public string? SenderUsername { get; set; } public string? SenderUsername { get; set; }
/// <summary>
/// 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.
/// </summary>
public List<AttachmentActionSpan>? ActionSpans { get; set; }
/// <summary>Number of spaces to prepend on continuation lines when this line is word-wrapped.</summary> /// <summary>Number of spaces to prepend on continuation lines when this line is word-wrapped.</summary>
public int ContinuationIndent { get; set; } public int ContinuationIndent { get; set; }
@@ -160,6 +178,10 @@ public partial class ChatLine
wrapped.IsMention = IsMention; 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; return results;
} }
@@ -443,9 +443,7 @@ public sealed class ChatMessageManager
lines.Add(new ChatLine(segments)); lines.Add(new ChatLine(segments));
} }
} }
lines.Add(AttachmentActionLine( lines.Add(ImageActionLine(attachment));
$"[↓ save original] {attachment.FileName} [{FormatFileSize(attachment.FileSize)}]",
ChatColors.FileAttr, attachment));
break; break;
case Core.Models.AttachmentKind.Audio: case Core.Models.AttachmentKind.Audio:
@@ -489,6 +487,41 @@ public sealed class ChatMessageManager
return lines; return lines;
} }
/// <summary>
/// Builds the action line below an image preview: "[open] [↓ save original] name [size]".
/// Each bracket is an <see cref="AttachmentActionSpan"/> so a mouse click can target it;
/// keyboard activation (Enter) uses the default action, open.
/// </summary>
private static ChatLine ImageActionLine(AttachmentDto attachment)
{
var segments = RailPrefix();
var col = segments.Sum(s => s.Text.GetColumns());
var spans = new List<AttachmentActionSpan>();
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(),
};
}
/// <summary> /// <summary>
/// Builds a clickable attachment line carrying the metadata the message list uses to /// Builds a clickable attachment line carrying the metadata the message list uses to
/// route activation (play audio, download file, save original image). /// route activation (play audio, download file, save original image).
+36 -2
View File
@@ -176,6 +176,12 @@ public sealed partial class MainWindow : Runnable
/// </summary> /// </summary>
public event Action<string, string>? OnImageSaveRequested; public event Action<string, string>? OnImageSaveRequested;
/// <summary>
/// Fired when the user activates an image's "[open]" action to view it without saving.
/// Parameters: attachmentUrl, fileName.
/// </summary>
public event Action<string, string>? OnImageOpenRequested;
/// <summary> /// <summary>
/// Fired when the user presses Delete on the selected message. Parameter is the message id. /// Fired when the user presses Delete on the selected message. Parameter is the message id.
/// </summary> /// </summary>
@@ -532,7 +538,9 @@ public sealed partial class MainWindow : Runnable
if (line.AttachmentKind == AttachmentKind.Image) 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; e.Handled = true;
return; return;
} }
@@ -600,7 +608,8 @@ public sealed partial class MainWindow : Runnable
private void OnMessageListMouseEvent(object? sender, Mouse e) 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; return;
if (_messageList.Source is not ChatListSource source || source.Count == 0 || e.Position is not { } pos) 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) if (index < 0 || index >= source.Count)
return; 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. // Select the right-clicked row (so the menu acts on it and it highlights), then show the menu.
_messageList.SelectedItem = index; _messageList.SelectedItem = index;
_messageList.SetFocus(); _messageList.SetFocus();
@@ -636,6 +669,7 @@ public sealed partial class MainWindow : Runnable
switch (kind) switch (kind)
{ {
case AttachmentKind.Image: 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)); items.Add(new MenuItem("Save original image", "", () => OnImageSaveRequested?.Invoke(url, name), Key.Empty));
break; break;
case AttachmentKind.Audio: case AttachmentKind.Audio:
+4 -11
View File
@@ -16,17 +16,10 @@ public class IrcBroadcaster : IChatBroadcaster
public async Task SendMessageToChannelAsync(string channelName, MessageDto message) public async Task SendMessageToChannelAsync(string channelName, MessageDto message)
{ {
// Decrypt content and attachment previews for IRC clients (they can't handle // Decrypt transport-encrypted content for IRC clients (they can't handle
// app-layer encryption). E2E room ciphertext ($RC1$) passes through untouched; // 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) };
var decryptedMessage = message with var lines = IrcMessageFormatter.FormatMessage(decryptedMessage, _gateway.Options.PublicBaseUrl);
{
Content = _encryption.Decrypt(message.Content),
Attachments = message.Attachments?
.Select(a => a with { AsciiPreview = _encryption.DecryptNullable(a.AsciiPreview) })
.ToList(),
};
var lines = IrcMessageFormatter.FormatMessage(decryptedMessage);
foreach (var conn in _gateway.GetConnectionsInChannel(channelName)) foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
{ {
+1 -1
View File
@@ -433,7 +433,7 @@ public sealed class IrcCommandHandler
foreach (var m in history) foreach (var m in history)
{ {
var decrypted = m with { Content = _encryption.Decrypt(m.Content) }; 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) foreach (var line in lines)
await _conn.SendAsync(line); await _conn.SendAsync(line);
} }
+21 -63
View File
@@ -1,20 +1,20 @@
using System.Text; using System.Text;
using System.Text.RegularExpressions;
using EchoHub.Core.Contracts;
using EchoHub.Core.DTOs; using EchoHub.Core.DTOs;
using EchoHub.Core.Models; using EchoHub.Core.Models;
using EchoHub.Core.Security;
namespace EchoHub.Server.Irc; namespace EchoHub.Server.Irc;
public static partial class IrcMessageFormatter public static class IrcMessageFormatter
{ {
private const int MaxIrcLineContentBytes = 400; private const int MaxIrcLineContentBytes = 400;
/// <summary> /// <summary>
/// Format a MessageDto as one or more IRC PRIVMSG lines. /// 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.
/// <paramref name="publicBaseUrl"/> makes the links absolute so any IRC client can open them.
/// </summary> /// </summary>
public static List<string> FormatMessage(MessageDto message) public static List<string> FormatMessage(MessageDto message, string? publicBaseUrl = null)
{ {
var lines = new List<string>(); var lines = new List<string>();
var ircChannel = $"#{message.ChannelName}"; var ircChannel = $"#{message.ChannelName}";
@@ -27,34 +27,19 @@ public static partial class IrcMessageFormatter
lines.Add($"{prefix} PRIVMSG {ircChannel} :{chunk}"); lines.Add($"{prefix} PRIVMSG {ircChannel} :{chunk}");
} }
// One block per attachment // One link line per attachment
if (message.Attachments is { Count: > 0 }) if (message.Attachments is { Count: > 0 })
{ {
foreach (var attachment in message.Attachments) foreach (var attachment in message.Attachments)
{ {
switch (attachment.Kind) var url = ToAbsoluteUrl(attachment.Url, publicBaseUrl);
var tag = attachment.Kind switch
{ {
case AttachmentKind.Image: AttachmentKind.Image => $"[Image: {attachment.FileName}]",
lines.Add($"{prefix} PRIVMSG {ircChannel} :[Image: {attachment.FileName}] {attachment.Url}"); AttachmentKind.Audio => $"\u266a [Audio: {attachment.FileName}]",
if (attachment.AsciiPreview is not null && !IsCiphertext(attachment.AsciiPreview)) _ => $"[File: {attachment.FileName}]",
{ };
foreach (var line in attachment.AsciiPreview.Split('\n')) lines.Add($"{prefix} PRIVMSG {ircChannel} :{tag} {url}");
{
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;
}
} }
} }
@@ -69,13 +54,15 @@ public static partial class IrcMessageFormatter
} }
/// <summary> /// <summary>
/// True when a preview is still encrypted — transport ($ENC$v1$) if a broadcast path /// Joins a relative attachment path onto the configured public base URL.
/// forgot to decrypt it, or E2E room ciphertext ($RC1$) the server cannot decrypt. /// Already-absolute URLs and unset base URLs pass through unchanged.
/// Emitting it would flood IRC clients with a multi-KB base64 blob.
/// </summary> /// </summary>
private static bool IsCiphertext(string text) => public static string ToAbsoluteUrl(string url, string? publicBaseUrl)
text.StartsWith(IMessageEncryptionService.CiphertextPrefix, StringComparison.Ordinal) {
|| text.StartsWith(RoomCrypto.CiphertextPrefix, StringComparison.Ordinal); if (string.IsNullOrWhiteSpace(publicBaseUrl) || Uri.IsWellFormedUriString(url, UriKind.Absolute))
return url;
return $"{publicBaseUrl.TrimEnd('/')}/{url.TrimStart('/')}";
}
/// <summary> /// <summary>
/// Format a link embed as IRC PRIVMSG lines (text-only, no ASCII thumbnail). /// Format a link embed as IRC PRIVMSG lines (text-only, no ASCII thumbnail).
@@ -104,35 +91,6 @@ public static partial class IrcMessageFormatter
return lines; return lines;
} }
/// <summary>
/// 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.
/// </summary>
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();
/// <summary> /// <summary>
/// Split a message into chunks of approximately maxBytes (UTF-8), at word boundaries. /// Split a message into chunks of approximately maxBytes (UTF-8), at word boundaries.
/// </summary> /// </summary>
+7
View File
@@ -12,4 +12,11 @@ public sealed class IrcOptions
public string? TlsCertPassword { get; set; } public string? TlsCertPassword { get; set; }
public string ServerName { get; set; } = "echohub"; public string ServerName { get; set; } = "echohub";
public string? Motd { get; set; } public string? Motd { get; set; }
/// <summary>
/// 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.
/// </summary>
public string? PublicBaseUrl { get; set; }
} }
@@ -19,7 +19,14 @@ public class FilesController : ControllerBase
_fileStorage = fileStorage; _fileStorage = fileStorage;
} }
/// <summary>
/// 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.
/// </summary>
[HttpGet("{fileId}")] [HttpGet("{fileId}")]
[AllowAnonymous]
public IActionResult GetFile(string fileId) public IActionResult GetFile(string fileId)
{ {
if (!Guid.TryParse(fileId, out _)) if (!Guid.TryParse(fileId, out _))
@@ -48,6 +55,11 @@ public class FilesController : ControllerBase
_ => "application/octet-stream" _ => "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); var fileName = Path.GetFileName(filePath);
return PhysicalFile(filePath, contentType, fileName); return PhysicalFile(filePath, contentType, fileName);
} }
@@ -62,13 +62,11 @@ public class DataMigrationServiceTests
} }
[Fact] [Fact]
public void AnsiToColorTags_RoundTrip_WithColorTagsToAnsi() public void AnsiToColorTags_ForegroundBackgroundAndReset_AllConverted()
{ {
// AnsiToColorTags and IrcMessageFormatter.ColorTagsToAnsi should be inverses var ansi = "\x1b[38;2;255;0;0mred\x1b[48;2;0;255;0mgreen\x1b[0m";
var original = "{F:FF0000}red{B:00FF00}green{X}";
var ansi = EchoHub.Server.Irc.IrcMessageFormatter.ColorTagsToAnsi(original);
var backToTags = DataMigrationService.AnsiToColorTags(ansi); var backToTags = DataMigrationService.AnsiToColorTags(ansi);
Assert.Equal(original, backToTags); Assert.Equal("{F:FF0000}red{B:00FF00}green{X}", backToTags);
} }
} }
+4 -4
View File
@@ -90,7 +90,7 @@ public class IrcBroadcasterTests
} }
[Fact] [Fact]
public async Task SendMessage_DecryptsAttachmentAsciiPreview() public async Task SendMessage_ImageAttachment_SendsLinkLineOnly()
{ {
var (_, stream) = AddConnectionWithCapture("bob", "general"); var (_, stream) = AddConnectionWithCapture("bob", "general");
@@ -104,9 +104,9 @@ public class IrcBroadcasterTests
await _broadcaster.SendMessageToChannelAsync("general", message); await _broadcaster.SendMessageToChannelAsync("general", message);
var output = stream.GetOutputLines(); var output = stream.GetOutputLines();
Assert.Contains(output, l => l.Contains("[Image: photo.png]")); Assert.Contains(output, l => l.Contains("[Image: photo.png]") && l.Contains("/api/files/abc"));
Assert.Contains(output, l => l.Contains("line1")); // ASCII preview art is never sent to IRC clients — images are links only
Assert.Contains(output, l => l.Contains("line2")); Assert.DoesNotContain(output, l => l.Contains("line1"));
Assert.DoesNotContain(output, l => l.Contains("$ENC$")); Assert.DoesNotContain(output, l => l.Contains("$ENC$"));
} }
@@ -118,37 +118,50 @@ public class IrcMessageFormatterTests
} }
[Fact] [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); var lines = IrcMessageFormatter.FormatMessage(msg);
Assert.Contains(lines, l => l.Contains("line1")); Assert.Single(lines);
Assert.Contains(lines, l => l.Contains("line2")); Assert.Contains("[Image: image.png]", lines[0]);
Assert.DoesNotContain(lines, l => l.Contains("line2"));
} }
[Fact] [Fact]
public void FormatMessage_ImageMessage_SkipsEmptyAsciiLines() public void FormatMessage_RelativeUrl_JoinedWithPublicBaseUrl()
{ {
var msg = CreateImageMessage("line1\n\nline2"); var msg = CreateImageMessage("art", "photo.jpg", "/api/files/abc");
var lines = IrcMessageFormatter.FormatMessage(msg); var lines = IrcMessageFormatter.FormatMessage(msg, "https://chat.example.com");
// Empty lines should be skipped Assert.Single(lines);
var asciiLines = lines.Where(l => !l.Contains("[Image:") && !l.Contains("Download:")).ToList(); Assert.Contains("https://chat.example.com/api/files/abc", lines[0]);
Assert.Equal(2, asciiLines.Count);
} }
[Theory] [Fact]
[InlineData("$ENC$v1$abc123$def456")] // transport ciphertext a broadcast path forgot to decrypt public void FormatMessage_AbsoluteUrl_NotRewrittenByPublicBaseUrl()
[InlineData("$RC1$abc123def456")] // E2E room ciphertext the server cannot decrypt
public void FormatMessage_ImageMessage_SkipsCiphertextPreview(string ciphertextPreview)
{ {
var msg = CreateImageMessage(ciphertextPreview, "photo.jpg", "https://example.com/photo.jpg"); var msg = CreateImageMessage("art", "photo.jpg", "https://cdn.example.com/photo.jpg");
var lines = IrcMessageFormatter.FormatMessage(msg); var lines = IrcMessageFormatter.FormatMessage(msg, "https://chat.example.com");
// Only the [Image: ...] header line — never the ciphertext blob Assert.Contains("https://cdn.example.com/photo.jpg", lines[0]);
Assert.Single(lines); Assert.DoesNotContain("https://chat.example.com", lines[0]);
Assert.Contains("[Image: photo.jpg]", 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] [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));
}
} }
+14 -43
View File
@@ -59,11 +59,24 @@ public class IrcMessageFormatterTests
attachments: [new AttachmentDto(AttachmentKind.Image, "/api/files/abc", "photo.png", 0, "{F:FF0000}█{X}")]); attachments: [new AttachmentDto(AttachmentKind.Image, "/api/files/abc", "photo.png", 0, "{F:FF0000}█{X}")]);
var lines = IrcMessageFormatter.FormatMessage(msg); 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("[Image: photo.png]", lines[0]);
Assert.Contains("/api/files/abc", 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] [Fact]
public void FormatMessage_FileAttachment_IncludesFileTag() public void FormatMessage_FileAttachment_IncludesFileTag()
{ {
@@ -121,48 +134,6 @@ public class IrcMessageFormatterTests
Assert.Contains(lines, l => l.Contains("[File: c.pdf]")); 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 ────────────────────────────────────────────────── // ── SplitMessage ──────────────────────────────────────────────────
[Fact] [Fact]