diff --git a/docs/changelog/v0.2.3.md b/docs/changelog/v0.2.3.md
index 0b78e8b..9170622 100644
--- a/docs/changelog/v0.2.3.md
+++ b/docs/changelog/v0.2.3.md
@@ -8,6 +8,8 @@
- `ModerationController` with full REST API for role assignment, kicks, bans, mutes, message deletion, and channel nuking
- Mutes support optional duration (auto-expire) and blocked users cannot log in
- Role claim included in JWT tokens; role badges shown in the online users panel
+- Kicked and banned users are forcibly disconnected in real time — server cleans up presence, broadcasts departures, and signals client disconnect
+- Works for both SignalR and IRC connections; client shows an error dialog with the reason
### Private Channels
- Channels can be created as public or private via a checkbox in the Create Channel dialog
@@ -18,13 +20,13 @@
### OpenGraph Link Embeds
- Messages containing URLs now show a rich preview below the message text
-- Server-side fetching: detects the first URL in a message, fetches the page, and parses OpenGraph meta tags (`og:title`, `og:description`, `og:image`, `og:site_name`)
-- OG images are converted to a small 24x12 colored ASCII thumbnail using the existing half-block renderer
-- Embeds are persisted in the database and included in channel history
-- TUI client renders embeds with a `▏` left border bar — site name and border in blue, title in white, description in gray, followed by the ASCII thumbnail
-- IRC gateway receives a text-only embed preview (site name, title, description) — no ASCII thumbnail to keep IRC output clean
+- Multiple URLs per message supported (up to 3) — each gets its own embed
+- Server-side fetching: detects URLs in a message, fetches each page, and parses OpenGraph meta tags (`og:title`, `og:description`, `og:site_name`)
+- Embeds are persisted in the database as a JSON array and included in channel history
+- TUI client renders embeds with a `▏` left border bar — site name and border in blue, title in white, description in gray; text word-wraps at actual viewport width
+- IRC gateway receives a text-only embed preview (site name, title, description)
- Falls back to `
` tag when no OG tags are present; gracefully skips if no useful metadata is found
-- 3-second fetch timeout ensures message delivery is never significantly delayed
+- 5-second fetch timeout ensures message delivery is never significantly delayed
- SSRF protection rejects private/loopback IP addresses before fetching
### Notification Sounds
@@ -67,13 +69,16 @@
- Emoji-to-text shortcode conversion for consistent cross-platform rendering
- Fixed `/send` and `/avatar` commands not handling file paths with spaces correctly, even when quoted
- Server-side newline spam protection — consecutive blank/whitespace-only lines collapsed to 1 and total lines capped at 30
+- Fixed OG tag regex truncating descriptions containing apostrophes (e.g. `"HueByte's portfolio"` was cut to `"HueByte"`) — switched to backreference-based quote pairing
## Infrastructure
-- New `LinkEmbedService` on the server — URL detection, HTML fetching (first 64KB), OG tag parsing via regex, image thumbnail generation
-- `EmbedDto` record added to shared Core DTOs; `MessageDto` extended with optional `Embed` field (backward-compatible)
-- `EmbedJson` nullable column on the `Message` table stores serialized embed data (max 8KB)
+- New `LinkEmbedService` on the server — URL detection, HTML fetching (first 64KB), OG tag parsing via compiled regex
+- `EmbedDto` record added to shared Core DTOs; `MessageDto.Embeds` list for multiple embeds per message
+- `EmbedJson` nullable column on the `Message` table stores serialized embed data as JSON array (max 8KB); `DataMigrationService` auto-migrates old single-object format
- Dedicated `"OgFetch"` named HttpClient with bot User-Agent header and 5-second timeout
+- `PresenceTracker.ForceRemoveUser()` for atomic user cleanup on kick/ban
+- `IChatBroadcaster.ForceDisconnectUserAsync()` and `IEchoHubClient.ForceDisconnect` for force-disconnect signaling
- `NotificationSoundService` for cross-platform audio playback of embedded notification sounds
- Startup `DataMigrationService` automatically converts old ANSI-format messages to the new color tag format on server boot, logging the count of migrated records
- `EmojiHelper` utility for emoji-to-shortcode conversion
diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs
index 64b226f..1c9f65f 100644
--- a/src/EchoHub.Client/AppOrchestrator.cs
+++ b/src/EchoHub.Client/AppOrchestrator.cs
@@ -347,6 +347,11 @@ public sealed class AppOrchestrator : IDisposable
await _apiClient!.NukeChannelAsync(channel);
};
+ _commandHandler.OnTestSound += async () =>
+ {
+ await _notificationSound.PlayTestAsync();
+ };
+
_commandHandler.OnQuit += () =>
{
InvokeUI(() => _app.RequestStop());
@@ -788,7 +793,6 @@ public sealed class AppOrchestrator : IDisposable
InvokeUI(() => _mainWindow.AddMessage(message));
if (!string.IsNullOrEmpty(_currentUsername)
- && !message.SenderUsername.Equals(_currentUsername, StringComparison.OrdinalIgnoreCase)
&& message.Content.Contains($"@{_currentUsername}", StringComparison.OrdinalIgnoreCase))
{
_ = _notificationSound.PlayAsync();
diff --git a/src/EchoHub.Client/Commands/CommandHandler.cs b/src/EchoHub.Client/Commands/CommandHandler.cs
index 19df340..b2304dd 100644
--- a/src/EchoHub.Client/Commands/CommandHandler.cs
+++ b/src/EchoHub.Client/Commands/CommandHandler.cs
@@ -25,6 +25,7 @@ public class CommandHandler
public event Func? OnUnmuteUser;
public event Func? OnAssignRole;
public event Func? OnNukeChannel;
+ public event Func? OnTestSound;
public event Func? OnQuit;
public event Func? OnHelp;
@@ -60,6 +61,7 @@ public class CommandHandler
"unmute" => await HandleUnmute(args),
"role" => await HandleRole(args),
"nuke" => await HandleNuke(),
+ "test-sound" => await HandleTestSound(),
"quit" or "exit" => await HandleQuit(),
"help" or "?" => await HandleHelp(),
_ => new CommandResult(true, $"Unknown command: /{command}. Type /help for available commands.", IsError: true),
@@ -319,6 +321,13 @@ public class CommandHandler
return new CommandResult(true, "Nuking channel history...");
}
+ private async Task HandleTestSound()
+ {
+ if (OnTestSound is not null)
+ await OnTestSound();
+ return new CommandResult(true, "Playing notification sound...");
+ }
+
private async Task HandleHelp()
{
if (OnHelp is not null)
@@ -346,6 +355,7 @@ public class CommandHandler
/unmute - Unmute a user (Mod+)
/role - Assign role (Admin+)
/nuke - Clear channel history (Mod+)
+ /test-sound - Play notification sound
/quit - Exit the app
""");
}
diff --git a/src/EchoHub.Client/Config/ClientConfig.cs b/src/EchoHub.Client/Config/ClientConfig.cs
index 25cdb45..80052a7 100644
--- a/src/EchoHub.Client/Config/ClientConfig.cs
+++ b/src/EchoHub.Client/Config/ClientConfig.cs
@@ -10,7 +10,7 @@ public class ClientConfig
public class NotificationConfig
{
- public bool Enabled { get; set; } = false;
+ public bool Enabled { get; set; } = true;
public byte Volume { get; set; } = 30;
public string? SoundFile { get; set; }
}
diff --git a/src/EchoHub.Client/Services/NotificationSoundService.cs b/src/EchoHub.Client/Services/NotificationSoundService.cs
index d9573c0..2118d40 100644
--- a/src/EchoHub.Client/Services/NotificationSoundService.cs
+++ b/src/EchoHub.Client/Services/NotificationSoundService.cs
@@ -25,13 +25,29 @@ public class NotificationSoundService
if (!_config.Enabled || _resolvedSoundPath is null)
return;
+ await PlayInternal();
+ }
+
+ ///
+ /// Plays the notification sound regardless of the Enabled setting (for /test-sound).
+ ///
+ public async Task PlayTestAsync()
+ {
+ if (_resolvedSoundPath is null)
+ return;
+
+ await PlayInternal();
+ }
+
+ private async Task PlayInternal()
+ {
try
{
if (_player.Playing)
await _player.Stop();
await _player.SetVolume(_config.Volume);
- await _player.Play(_resolvedSoundPath);
+ await _player.Play(_resolvedSoundPath!);
}
catch (Exception ex)
{
diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs
index bf5f22b..804ef21 100644
--- a/src/EchoHub.Client/UI/MainWindow.cs
+++ b/src/EchoHub.Client/UI/MainWindow.cs
@@ -51,7 +51,7 @@ public sealed class MainWindow : Runnable
"/status", "/nick", "/color", "/theme", "/send",
"/avatar", "/profile", "/servers", "/join", "/leave",
"/topic", "/users", "/kick", "/ban", "/unban",
- "/mute", "/unmute", "/role", "/nuke", "/quit", "/help"
+ "/mute", "/unmute", "/role", "/nuke", "/test-sound", "/quit", "/help"
];
private readonly List _channelNames = [];
@@ -923,8 +923,9 @@ public sealed class MainWindow : Runnable
// Render link embeds if present
if (message.Embeds is { Count: > 0 })
{
+ var chatWidth = _lastChatWidth > 0 ? _lastChatWidth : 80;
foreach (var embed in message.Embeds)
- lines.AddRange(FormatEmbed(embed, indent));
+ lines.AddRange(FormatEmbed(embed, indent, chatWidth));
}
break;
}
@@ -977,115 +978,45 @@ public sealed class MainWindow : Runnable
///
/// Format a link embed as indented chat lines with a left border bar,
- /// optional description wrapping, and a small icon on the right (Discord-style).
- /// Layout: ▏ {text column} {icon column}
+ /// text at full width, and optional icon below (preview image).
+ /// Each line is pre-wrapped to fit chatWidth so ChatLine.Wrap won't break layout.
///
- private static List FormatEmbed(EmbedDto embed, string indent)
+ private static List FormatEmbed(EmbedDto embed, string indent, int chatWidth)
{
var lines = new List();
const string border = "\u258f "; // ▏ + space
- const int borderCols = 2; // ▏ = 1 col + space = 1 col
- const int iconGap = 1; // space between text and icon
+ const int borderCols = 2;
+ int indentCols = indent.GetColumns();
+ int textWidth = chatWidth - indentCols - borderCols;
+ if (textWidth < 20) textWidth = 20;
- // Parse icon lines if present
- var iconLines = new List();
- int iconWidth = 0;
- if (!string.IsNullOrWhiteSpace(embed.ImageAscii))
+ // Helper: create a bordered text line
+ void AddTextLine(string text, Attribute? color)
{
- foreach (var artLine in embed.ImageAscii.Split('\n'))
- {
- var trimmed = artLine.TrimEnd('\r');
- if (!string.IsNullOrEmpty(trimmed))
- iconLines.Add(trimmed);
- }
- if (iconLines.Count > 0)
- {
- // Measure icon width from the first line (strip color tags for measurement)
- var stripped = ChatLine.StripColorTags(iconLines[0]);
- iconWidth = stripped.GetColumns();
- }
+ lines.Add(new ChatLine(
+ [
+ new ChatSegment(indent, null),
+ new ChatSegment(border, ChatColors.EmbedBorderAttr),
+ new ChatSegment(text, color)
+ ]));
}
- bool hasIcon = iconLines.Count > 0 && iconWidth > 0;
- int indentCols = indent.GetColumns();
-
- // We don't know the terminal width at format time, so use a reasonable default
- // for text wrapping. The ChatListSource.Render will handle final clipping.
- const int estimatedWidth = 80;
- int availableForText = estimatedWidth - indentCols - borderCols;
- int textColWidth = hasIcon
- ? availableForText - iconWidth - iconGap
- : availableForText;
- if (textColWidth < 20) textColWidth = 20;
-
- // Collect all text rows (site name, title, wrapped description, URL)
- var textRows = new List<(string Text, Attribute? Color)>();
-
+ // Site name
if (!string.IsNullOrWhiteSpace(embed.SiteName))
- textRows.Add((embed.SiteName, ChatColors.EmbedBorderAttr));
+ AddTextLine(embed.SiteName, ChatColors.EmbedBorderAttr);
+ // Title
if (!string.IsNullOrWhiteSpace(embed.Title))
- textRows.Add((embed.Title, ChatColors.EmbedTitleAttr));
+ {
+ foreach (var wrapped in WordWrap(embed.Title, textWidth))
+ AddTextLine(wrapped, ChatColors.EmbedTitleAttr);
+ }
- // Word-wrap description
+ // Description (word-wrapped at full available width)
if (!string.IsNullOrWhiteSpace(embed.Description))
{
- foreach (var wrappedLine in WordWrap(embed.Description, textColWidth))
- textRows.Add((wrappedLine, ChatColors.EmbedDescAttr));
- }
-
- // Dim URL at the bottom
- textRows.Add((embed.Url, ChatColors.EmbedUrlAttr));
-
- // Merge text rows with icon rows side-by-side
- int totalLines = Math.Max(textRows.Count, iconLines.Count);
- for (int i = 0; i < totalLines; i++)
- {
- var segments = new List();
- segments.Add(new ChatSegment(indent, null));
- segments.Add(new ChatSegment(border, ChatColors.EmbedBorderAttr));
-
- if (i < textRows.Count)
- {
- var (text, color) = textRows[i];
- segments.Add(new ChatSegment(text, color));
-
- // Pad to align icon column
- if (hasIcon && i < iconLines.Count)
- {
- int textCols = text.GetColumns();
- int padding = textColWidth - textCols + iconGap;
- if (padding > 0)
- segments.Add(new ChatSegment(new string(' ', padding), null));
- }
- }
- else if (hasIcon && i < iconLines.Count)
- {
- // No text row, pad the full text column + gap
- segments.Add(new ChatSegment(new string(' ', textColWidth + iconGap), null));
- }
-
- // Append icon line
- if (hasIcon && i < iconLines.Count)
- {
- var iconLine = iconLines[i];
- if (ChatLine.HasColorTags(iconLine))
- {
- // Build a composite: plain segments + colored icon
- var plainPart = new ChatLine(segments);
- var iconPart = ChatLine.FromColoredText(iconLine);
- var merged = new List(plainPart.Segments);
- merged.AddRange(iconPart.Segments);
- lines.Add(new ChatLine(merged));
- continue;
- }
- else
- {
- segments.Add(new ChatSegment(iconLine, null));
- }
- }
-
- lines.Add(new ChatLine(segments));
+ foreach (var wrapped in WordWrap(embed.Description, textWidth))
+ AddTextLine(wrapped, ChatColors.EmbedDescAttr);
}
return lines;
diff --git a/src/EchoHub.Core/Constants/HubConstants.cs b/src/EchoHub.Core/Constants/HubConstants.cs
index 7db4fff..024d956 100644
--- a/src/EchoHub.Core/Constants/HubConstants.cs
+++ b/src/EchoHub.Core/Constants/HubConstants.cs
@@ -15,10 +15,8 @@ public static class HubConstants
public const int AsciiArtHeightHalfBlock = 80;
// Link embed constants
- public const int EmbedIconWidth = 12;
- public const int EmbedIconHeight = 6;
public const int EmbedMaxDescriptionLength = 500;
public const int EmbedMaxHtmlBytes = 64 * 1024; // 64 KB
- public const int EmbedFetchTimeoutSeconds = 3;
+ public const int EmbedFetchTimeoutSeconds = 5;
public const int EmbedMaxUrlsPerMessage = 3;
}
diff --git a/src/EchoHub.Server/Services/LinkEmbedService.cs b/src/EchoHub.Server/Services/LinkEmbedService.cs
index 8d032cc..e424730 100644
--- a/src/EchoHub.Server/Services/LinkEmbedService.cs
+++ b/src/EchoHub.Server/Services/LinkEmbedService.cs
@@ -10,16 +10,13 @@ namespace EchoHub.Server.Services;
public partial class LinkEmbedService
{
private readonly IHttpClientFactory _httpClientFactory;
- private readonly ImageToAsciiService _asciiService;
private readonly ILogger _logger;
public LinkEmbedService(
IHttpClientFactory httpClientFactory,
- ImageToAsciiService asciiService,
ILogger logger)
{
_httpClientFactory = httpClientFactory;
- _asciiService = asciiService;
_logger = logger;
}
@@ -111,15 +108,7 @@ public partial class LinkEmbedService
siteName = siteName is not null ? WebUtility.HtmlDecode(siteName) : null;
description = description is not null ? WebUtility.HtmlDecode(description) : null;
- // Attempt to fetch OG image as small icon
- string? imageAscii = null;
- var imageUrl = ogTags.GetValueOrDefault("image");
- if (!string.IsNullOrWhiteSpace(imageUrl))
- {
- imageAscii = await FetchImageThumbnailAsync(imageUrl, uri, cts.Token);
- }
-
- return new EmbedDto(siteName, title, description, imageAscii, url);
+ return new EmbedDto(siteName, title, description, null, url);
}
private static List ExtractUrls(string content)
@@ -169,78 +158,26 @@ public partial class LinkEmbedService
var tags = new Dictionary(StringComparer.OrdinalIgnoreCase);
// Match:
+ // Groups: 1=prop quote, 2=key, 3=content quote, 4=value
foreach (Match match in OgTagRegex().Matches(html))
{
- var key = match.Groups[1].Value;
- var value = match.Groups[2].Value;
+ var key = match.Groups[2].Value;
+ var value = match.Groups[4].Value;
tags.TryAdd(key, value);
}
// Match reversed order:
+ // Groups: 1=content quote, 2=value, 3=prop quote, 4=key
foreach (Match match in OgTagReversedRegex().Matches(html))
{
- var value = match.Groups[1].Value;
- var key = match.Groups[2].Value;
+ var value = match.Groups[2].Value;
+ var key = match.Groups[4].Value;
tags.TryAdd(key, value);
}
return tags;
}
- private async Task FetchImageThumbnailAsync(string imageUrl, Uri pageUri, CancellationToken ct)
- {
- try
- {
- // Resolve relative image URLs against the page URI
- if (!Uri.TryCreate(imageUrl, UriKind.Absolute, out var imageUri))
- {
- if (!Uri.TryCreate(pageUri, imageUrl, out imageUri))
- return null;
- }
-
- if (imageUri.Scheme is not ("http" or "https"))
- return null;
-
- if (IsPrivateHost(imageUri))
- return null;
-
- var client = _httpClientFactory.CreateClient("OgFetch");
- using var response = await client.GetAsync(imageUri, ct);
-
- if (!response.IsSuccessStatusCode)
- return null;
-
- var contentType = response.Content.Headers.ContentType?.MediaType;
- if (contentType is null || !contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
- return null;
-
- await using var stream = await response.Content.ReadAsStreamAsync(ct);
-
- // Buffer into a MemoryStream for validation + conversion
- using var memoryStream = new MemoryStream();
- await stream.CopyToAsync(memoryStream, ct);
-
- if (memoryStream.Length == 0 || memoryStream.Length > HubConstants.MaxFileSizeBytes)
- return null;
-
- memoryStream.Position = 0;
-
- if (!FileValidationHelper.IsValidImage(memoryStream))
- return null;
-
- memoryStream.Position = 0;
-
- return _asciiService.ConvertToAscii(memoryStream,
- HubConstants.EmbedIconWidth,
- HubConstants.EmbedIconHeight);
- }
- catch (Exception ex)
- {
- _logger.LogDebug(ex, "Failed to fetch OG image thumbnail from {Url}", imageUrl);
- return null;
- }
- }
-
private static async Task ReadLimitedAsync(HttpResponseMessage response, int maxBytes, CancellationToken ct)
{
await using var stream = await response.Content.ReadAsStreamAsync(ct);
@@ -263,17 +200,17 @@ public partial class LinkEmbedService
return encoding.GetString(buffer, 0, totalRead);
}
- [GeneratedRegex(@"https?://[^\s<>""')\]]+", RegexOptions.IgnoreCase)]
+ [GeneratedRegex(@"https?://[^\s<>""')\]]+", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
private static partial Regex UrlRegex();
- [GeneratedRegex(@"]*?property\s*=\s*[""']og:(\w+)[""'][^>]*?content\s*=\s*[""']([^""']*)[""'][^>]*/?>",
- RegexOptions.IgnoreCase | RegexOptions.Singleline)]
+ [GeneratedRegex(@"]*?property\s*=\s*([""'])og:(\w+)\1[^>]*?content\s*=\s*([""'])(.*?)\3[^>]*/?>",
+ RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled)]
private static partial Regex OgTagRegex();
- [GeneratedRegex(@"]*?content\s*=\s*[""']([^""']*)[""'][^>]*?property\s*=\s*[""']og:(\w+)[""'][^>]*/?>",
- RegexOptions.IgnoreCase | RegexOptions.Singleline)]
+ [GeneratedRegex(@"]*?content\s*=\s*([""'])(.*?)\1[^>]*?property\s*=\s*([""'])og:(\w+)\3[^>]*/?>",
+ RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled)]
private static partial Regex OgTagReversedRegex();
- [GeneratedRegex(@"]*>([^<]+)", RegexOptions.IgnoreCase)]
+ [GeneratedRegex(@"]*>([^<]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
private static partial Regex TitleTagRegex();
}