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<string, Task>? OnUnmuteUser; public event Func<string, string, Task>? OnAssignRole; public event Func<Task>? OnNukeChannel; + public event Func<Task>? OnTestSound; public event Func<Task>? OnQuit; public event Func<Task>? 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<CommandResult> HandleTestSound() + { + if (OnTestSound is not null) + await OnTestSound(); + return new CommandResult(true, "Playing notification sound..."); + } + private async Task<CommandResult> HandleHelp() { if (OnHelp is not null) @@ -346,6 +355,7 @@ public class CommandHandler /unmute <user> - Unmute a user (Mod+) /role <user> <admin|mod|member> - 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(); + } + + /// <summary> + /// Plays the notification sound regardless of the Enabled setting (for /test-sound). + /// </summary> + 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<string> _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 /// <summary> /// 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. /// </summary> - private static List<ChatLine> FormatEmbed(EmbedDto embed, string indent) + private static List<ChatLine> FormatEmbed(EmbedDto embed, string indent, int chatWidth) { var lines = new List<ChatLine>(); 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<string>(); - 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<ChatSegment>(); - 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<ChatSegment>(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<LinkEmbedService> _logger; public LinkEmbedService( IHttpClientFactory httpClientFactory, - ImageToAsciiService asciiService, ILogger<LinkEmbedService> 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<string> ExtractUrls(string content) @@ -169,78 +158,26 @@ public partial class LinkEmbedService var tags = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); // Match: <meta property="og:key" content="value" /> + // 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: <meta content="value" property="og:key" /> + // 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<string?> 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<string> 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(@"<meta\s+[^>]*?property\s*=\s*[""']og:(\w+)[""'][^>]*?content\s*=\s*[""']([^""']*)[""'][^>]*/?>", - RegexOptions.IgnoreCase | RegexOptions.Singleline)] + [GeneratedRegex(@"<meta\s+[^>]*?property\s*=\s*([""'])og:(\w+)\1[^>]*?content\s*=\s*([""'])(.*?)\3[^>]*/?>", + RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled)] private static partial Regex OgTagRegex(); - [GeneratedRegex(@"<meta\s+[^>]*?content\s*=\s*[""']([^""']*)[""'][^>]*?property\s*=\s*[""']og:(\w+)[""'][^>]*/?>", - RegexOptions.IgnoreCase | RegexOptions.Singleline)] + [GeneratedRegex(@"<meta\s+[^>]*?content\s*=\s*([""'])(.*?)\1[^>]*?property\s*=\s*([""'])og:(\w+)\3[^>]*/?>", + RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled)] private static partial Regex OgTagReversedRegex(); - [GeneratedRegex(@"<title[^>]*>([^<]+)", RegexOptions.IgnoreCase)] + [GeneratedRegex(@"]*>([^<]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled)] private static partial Regex TitleTagRegex(); }