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.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;
}
/// <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)
{
if (!_conn.IsAuthenticated) return;
+22
View File
@@ -7,6 +7,16 @@ using Attribute = Terminal.Gui.Drawing.Attribute;
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>
/// A single line in the chat, composed of colored segments.
/// </summary>
@@ -20,6 +30,14 @@ public partial class ChatLine
public string? AttachmentFileName { get; set; }
public AttachmentKind? AttachmentKind { 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>
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;
}
@@ -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;
}
/// <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>
/// Builds a clickable attachment line carrying the metadata the message list uses to
/// route activation (play audio, download file, save original image).
+36 -2
View File
@@ -176,6 +176,12 @@ public sealed partial class MainWindow : Runnable
/// </summary>
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>
/// Fired when the user presses Delete on the selected message. Parameter is the message id.
/// </summary>
@@ -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:
+4 -11
View File
@@ -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))
{
+1 -1
View File
@@ -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);
}
+21 -63
View File
@@ -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;
/// <summary>
/// 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>
public static List<string> FormatMessage(MessageDto message)
public static List<string> FormatMessage(MessageDto message, string? publicBaseUrl = null)
{
var lines = new List<string>();
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
}
/// <summary>
/// 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.
/// </summary>
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('/')}";
}
/// <summary>
/// Format a link embed as IRC PRIVMSG lines (text-only, no ASCII thumbnail).
@@ -104,35 +91,6 @@ public static partial class IrcMessageFormatter
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>
/// Split a message into chunks of approximately maxBytes (UTF-8), at word boundaries.
/// </summary>
+7
View File
@@ -12,4 +12,11 @@ public sealed class IrcOptions
public string? TlsCertPassword { get; set; }
public string ServerName { get; set; } = "echohub";
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;
}
/// <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}")]
[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);
}
@@ -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);
}
}
+4 -4
View File
@@ -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$"));
}
@@ -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));
}
}
+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}")]);
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]