diff --git a/docs/changelog/v0.2.4.md b/docs/changelog/v0.2.4.md new file mode 100644 index 0000000..3b8c67a --- /dev/null +++ b/docs/changelog/v0.2.4.md @@ -0,0 +1,22 @@ +# v0.2.4 - Link Embeds + +## Features + +### OpenGraph Link Embeds +- Messages containing URLs now show a rich preview below the message text, similar to Discord +- 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 +- 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 +- SSRF protection rejects private/loopback IP addresses before fetching + +## 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) +- Dedicated `"OgFetch"` named HttpClient with bot User-Agent header and 5-second timeout +- New EF Core migration: `AddMessageEmbed` diff --git a/src/EchoHub.Client/Assets/Notification.mp3 b/src/EchoHub.Client/Assets/Notification.mp3 new file mode 100644 index 0000000..dcf0292 Binary files /dev/null and b/src/EchoHub.Client/Assets/Notification.mp3 differ diff --git a/src/EchoHub.Client/Config/ClientConfig.cs b/src/EchoHub.Client/Config/ClientConfig.cs index 8fcfe07..7536419 100644 --- a/src/EchoHub.Client/Config/ClientConfig.cs +++ b/src/EchoHub.Client/Config/ClientConfig.cs @@ -5,6 +5,13 @@ public class ClientConfig public List<SavedServer> SavedServers { get; set; } = []; public AccountPreset DefaultPreset { get; set; } = new(); public string ActiveTheme { get; set; } = "Default"; + public NotificationConfig Notifications { get; set; } = new(); +} + +public class NotificationConfig +{ + public bool Enabled { get; set; } = true; + public string? SoundFile { get; set; } } public class SavedServer diff --git a/src/EchoHub.Client/EchoHub.Client.csproj b/src/EchoHub.Client/EchoHub.Client.csproj index 2878f87..5e838b3 100644 --- a/src/EchoHub.Client/EchoHub.Client.csproj +++ b/src/EchoHub.Client/EchoHub.Client.csproj @@ -7,6 +7,7 @@ <ItemGroup> <PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.3" /> <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.3" /> + <PackageReference Include="NetCoreAudio" Version="2.0.1" /> <PackageReference Include="Serilog" Version="4.3.1" /> <PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" /> <PackageReference Include="Serilog.Sinks.File" Version="7.0.0" /> @@ -17,6 +18,9 @@ <Content Include="appsettings.json" Condition="Exists('appsettings.json')"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </Content> + <Content Include="Assets\**"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> <EmbeddedResource Include="appsettings.example.json"> <LogicalName>EchoHub.Client.appsettings.example.json</LogicalName> </EmbeddedResource> diff --git a/src/EchoHub.Client/Services/NotificationSoundService.cs b/src/EchoHub.Client/Services/NotificationSoundService.cs new file mode 100644 index 0000000..cd6f416 --- /dev/null +++ b/src/EchoHub.Client/Services/NotificationSoundService.cs @@ -0,0 +1,64 @@ +using EchoHub.Client.Config; +using NetCoreAudio; +using Serilog; + +namespace EchoHub.Client.Services; + +public class NotificationSoundService +{ + private readonly Player _player = new(); + private readonly NotificationConfig _config; + private string? _resolvedSoundPath; + + public NotificationSoundService(NotificationConfig config) + { + _config = config; + ResolveSoundPath(); + } + + public async Task PlayAsync() + { + if (!_config.Enabled || _resolvedSoundPath is null) + return; + + try + { + if (_player.Playing) + await _player.Stop(); + + await _player.Play(_resolvedSoundPath); + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to play notification sound"); + } + } + + private void ResolveSoundPath() + { + // 1. Explicit path from config (~/.echohub/config.json) + if (!string.IsNullOrWhiteSpace(_config.SoundFile)) + { + if (File.Exists(_config.SoundFile)) + { + _resolvedSoundPath = Path.GetFullPath(_config.SoundFile); + Log.Debug("Notification sound: {Path} (from config)", _resolvedSoundPath); + return; + } + + Log.Warning("Configured sound file not found: {Path}", _config.SoundFile); + } + + // 2. Default: Notification.mp3 bundled next to the executable + var defaultPath = Path.Combine(AppContext.BaseDirectory, "Assets", "Notification.mp3"); + + if (File.Exists(defaultPath)) + { + _resolvedSoundPath = defaultPath; + Log.Debug("Notification sound: {Path} (default)", _resolvedSoundPath); + return; + } + + Log.Information("No notification sound file found — notifications will be silent"); + } +} diff --git a/src/EchoHub.Client/UI/ChatRenderer.cs b/src/EchoHub.Client/UI/ChatRenderer.cs index b8b91a4..d4727fc 100644 --- a/src/EchoHub.Client/UI/ChatRenderer.cs +++ b/src/EchoHub.Client/UI/ChatRenderer.cs @@ -101,6 +101,12 @@ public partial class ChatLine public static bool HasColorTags(string text) => text.Contains("{F:") || text.Contains("{B:") || text.Contains("{X}"); + /// <summary> + /// Remove all color tags from text, returning only the visible characters. + /// </summary> + public static string StripColorTags(string text) => + ColorTagRegex().Replace(text, ""); + /// <summary> /// Parse a string containing printable color tags into colored segments. /// Format: {F:RRGGBB} (foreground), {B:RRGGBB} (background), {X} (reset). @@ -458,6 +464,7 @@ public static partial class ChatColors public static readonly Attribute EmbedBorderAttr = new(new Color(91, 155, 213), Color.Black); public static readonly Attribute EmbedTitleAttr = new(Color.White, Color.Black); public static readonly Attribute EmbedDescAttr = new(new Color(160, 160, 160), Color.Black); + public static readonly Attribute EmbedUrlAttr = new(new Color(100, 100, 100), Color.Black); /// <summary> /// Split text around @mentions, giving each @word the MentionTextAttr accent color. diff --git a/src/EchoHub.Client/UI/EmojiHelper.cs b/src/EchoHub.Client/UI/EmojiHelper.cs new file mode 100644 index 0000000..c1d794a --- /dev/null +++ b/src/EchoHub.Client/UI/EmojiHelper.cs @@ -0,0 +1,333 @@ +using System.Globalization; +using System.Text; + +namespace EchoHub.Client.UI; + +/// <summary> +/// Converts emoji grapheme clusters to text shortcodes for safe TUI rendering. +/// Terminal width calculations for emoji are unreliable across different terminals, +/// so we replace them with fixed-width ASCII shortcodes for display only. +/// </summary> +public static class EmojiHelper +{ + /// <summary> + /// Replace emoji graphemes in the text with :shortcode: equivalents. + /// Non-emoji text passes through unchanged. + /// </summary> + public static string ReplaceEmoji(string text) + { + if (string.IsNullOrEmpty(text)) + return text; + + // Quick check: if no characters above BMP or supplementary emoji ranges, skip processing + bool hasEmoji = false; + foreach (var rune in text.EnumerateRunes()) + { + if (IsEmojiRune(rune)) + { + hasEmoji = true; + break; + } + } + + if (!hasEmoji) + return text; + + var sb = new StringBuilder(text.Length); + var enumerator = StringInfo.GetTextElementEnumerator(text); + + while (enumerator.MoveNext()) + { + var grapheme = enumerator.GetTextElement(); + + // Check if this grapheme contains emoji runes + bool graphemeHasEmoji = false; + foreach (var rune in grapheme.EnumerateRunes()) + { + if (IsEmojiRune(rune)) + { + graphemeHasEmoji = true; + break; + } + } + + if (graphemeHasEmoji) + { + // Try to find a shortcode for the whole grapheme first + if (EmojiShortcodes.TryGetValue(grapheme, out var shortcode)) + { + sb.Append(shortcode); + } + else + { + // Try the base emoji (first rune only, stripping modifiers/ZWJ) + var baseRune = GetBaseEmoji(grapheme); + if (baseRune is not null && EmojiShortcodes.TryGetValue(baseRune, out shortcode)) + { + sb.Append(shortcode); + } + else + { + // Unknown emoji — use generic placeholder + sb.Append("[emoji]"); + } + } + } + else + { + sb.Append(grapheme); + } + } + + return sb.ToString(); + } + + private static bool IsEmojiRune(Rune rune) + { + var value = rune.Value; + + // Common emoji ranges + if (value >= 0x1F600 && value <= 0x1F64F) return true; // Emoticons + if (value >= 0x1F300 && value <= 0x1F5FF) return true; // Misc Symbols & Pictographs + if (value >= 0x1F680 && value <= 0x1F6FF) return true; // Transport & Map + if (value >= 0x1F900 && value <= 0x1F9FF) return true; // Supplemental Symbols + if (value >= 0x1FA00 && value <= 0x1FA6F) return true; // Chess Symbols + if (value >= 0x1FA70 && value <= 0x1FAFF) return true; // Symbols Extended-A + if (value >= 0x2600 && value <= 0x26FF) return true; // Misc Symbols + if (value >= 0x2700 && value <= 0x27BF) return true; // Dingbats + if (value >= 0xFE00 && value <= 0xFE0F) return true; // Variation Selectors + if (value >= 0x200D && value <= 0x200D) return true; // ZWJ + if (value >= 0x1F1E0 && value <= 0x1F1FF) return true; // Regional Indicators (flags) + if (value >= 0x231A && value <= 0x23F3) return true; // Misc Technical (watch, hourglass) + if (value >= 0x2934 && value <= 0x2935) return true; // Arrows + if (value >= 0x25AA && value <= 0x25FE) return true; // Geometric Shapes + if (value >= 0x2B05 && value <= 0x2B55) return true; // Misc Symbols & Arrows + if (value >= 0x3030 && value <= 0x303D) return true; // CJK Symbols + if (value == 0x00A9 || value == 0x00AE) return true; // © ® + if (value == 0x2122) return true; // ™ + if (value >= 0x1F000 && value <= 0x1F02F) return true; // Mahjong & Dominos + + return false; + } + + /// <summary> + /// Extract the base emoji string (first non-modifier, non-ZWJ rune) for lookup. + /// </summary> + private static string? GetBaseEmoji(string grapheme) + { + foreach (var rune in grapheme.EnumerateRunes()) + { + // Skip ZWJ, variation selectors, skin tone modifiers + if (rune.Value == 0x200D) continue; + if (rune.Value >= 0xFE00 && rune.Value <= 0xFE0F) continue; + if (rune.Value >= 0x1F3FB && rune.Value <= 0x1F3FF) continue; + + if (IsEmojiRune(rune)) + return rune.ToString(); + } + + return null; + } + + // Common emoji → shortcode mapping (display-only, covers most frequently used emoji) + private static readonly Dictionary<string, string> EmojiShortcodes = new() + { + // Smileys & Emotion + ["\U0001F600"] = ":grinning:", + ["\U0001F601"] = ":grin:", + ["\U0001F602"] = ":joy:", + ["\U0001F603"] = ":smiley:", + ["\U0001F604"] = ":smile:", + ["\U0001F605"] = ":sweat_smile:", + ["\U0001F606"] = ":laughing:", + ["\U0001F607"] = ":innocent:", + ["\U0001F608"] = ":smiling_imp:", + ["\U0001F609"] = ":wink:", + ["\U0001F60A"] = ":blush:", + ["\U0001F60B"] = ":yum:", + ["\U0001F60C"] = ":relieved:", + ["\U0001F60D"] = ":heart_eyes:", + ["\U0001F60E"] = ":sunglasses:", + ["\U0001F60F"] = ":smirk:", + ["\U0001F610"] = ":neutral_face:", + ["\U0001F611"] = ":expressionless:", + ["\U0001F612"] = ":unamused:", + ["\U0001F613"] = ":sweat:", + ["\U0001F614"] = ":pensive:", + ["\U0001F615"] = ":confused:", + ["\U0001F616"] = ":confounded:", + ["\U0001F617"] = ":kissing:", + ["\U0001F618"] = ":kissing_heart:", + ["\U0001F619"] = ":kissing_smiling_eyes:", + ["\U0001F61A"] = ":kissing_closed_eyes:", + ["\U0001F61B"] = ":stuck_out_tongue:", + ["\U0001F61C"] = ":stuck_out_tongue_winking_eye:", + ["\U0001F61D"] = ":stuck_out_tongue_closed_eyes:", + ["\U0001F61E"] = ":disappointed:", + ["\U0001F61F"] = ":worried:", + ["\U0001F620"] = ":angry:", + ["\U0001F621"] = ":rage:", + ["\U0001F622"] = ":cry:", + ["\U0001F623"] = ":persevere:", + ["\U0001F624"] = ":triumph:", + ["\U0001F625"] = ":disappointed_relieved:", + ["\U0001F626"] = ":frowning:", + ["\U0001F627"] = ":anguished:", + ["\U0001F628"] = ":fearful:", + ["\U0001F629"] = ":weary:", + ["\U0001F62A"] = ":sleepy:", + ["\U0001F62B"] = ":tired_face:", + ["\U0001F62C"] = ":grimacing:", + ["\U0001F62D"] = ":sob:", + ["\U0001F62E"] = ":open_mouth:", + ["\U0001F62F"] = ":hushed:", + ["\U0001F630"] = ":cold_sweat:", + ["\U0001F631"] = ":scream:", + ["\U0001F632"] = ":astonished:", + ["\U0001F633"] = ":flushed:", + ["\U0001F634"] = ":sleeping:", + ["\U0001F635"] = ":dizzy_face:", + ["\U0001F636"] = ":no_mouth:", + ["\U0001F637"] = ":mask:", + ["\U0001F641"] = ":slightly_frowning_face:", + ["\U0001F642"] = ":slightly_smiling_face:", + ["\U0001F643"] = ":upside_down_face:", + ["\U0001F644"] = ":roll_eyes:", + ["\U0001F910"] = ":zipper_mouth:", + ["\U0001F911"] = ":money_mouth:", + ["\U0001F912"] = ":thermometer_face:", + ["\U0001F913"] = ":nerd:", + ["\U0001F914"] = ":thinking:", + ["\U0001F915"] = ":head_bandage:", + ["\U0001F920"] = ":cowboy:", + ["\U0001F921"] = ":clown:", + ["\U0001F922"] = ":nauseated:", + ["\U0001F923"] = ":rofl:", + ["\U0001F924"] = ":drooling:", + ["\U0001F925"] = ":lying:", + ["\U0001F929"] = ":star_struck:", + ["\U0001F92A"] = ":zany:", + ["\U0001F92B"] = ":shushing:", + ["\U0001F92C"] = ":cursing:", + ["\U0001F92D"] = ":hand_over_mouth:", + ["\U0001F92E"] = ":vomiting:", + ["\U0001F92F"] = ":exploding_head:", + ["\U0001F970"] = ":smiling_face_with_hearts:", + ["\U0001F971"] = ":yawning:", + ["\U0001F972"] = ":smiling_with_tear:", + ["\U0001F973"] = ":partying:", + ["\U0001F974"] = ":woozy:", + ["\U0001F975"] = ":hot_face:", + ["\U0001F976"] = ":cold_face:", + ["\U0001F979"] = ":holding_back_tears:", + ["\U0001F97A"] = ":pleading:", + ["\U0001FAE0"] = ":melting:", + ["\U0001FAE1"] = ":saluting:", + ["\U0001FAE2"] = ":face_with_open_eyes_hand_over_mouth:", + ["\U0001FAE3"] = ":face_with_peeking_eye:", + ["\U0001FAE4"] = ":face_with_diagonal_mouth:", + + // Gestures + ["\U0001F44D"] = ":+1:", + ["\U0001F44E"] = ":-1:", + ["\U0001F44B"] = ":wave:", + ["\U0001F44C"] = ":ok_hand:", + ["\U0001F44F"] = ":clap:", + ["\U0001F44A"] = ":fist:", + ["\U0001F91D"] = ":handshake:", + ["\U0001F91E"] = ":crossed_fingers:", + ["\U0001F91F"] = ":love_you:", + ["\U0001F918"] = ":metal:", + ["\U0001F919"] = ":call_me:", + ["\U0001F590"] = ":raised_hand:", + ["\U0001F4AA"] = ":muscle:", + ["\U0001F926"] = ":facepalm:", + ["\U0001F937"] = ":shrug:", + ["\U0001F64F"] = ":pray:", + ["\U0001F64C"] = ":raised_hands:", + ["\U0001F64B"] = ":raising_hand:", + + // Hearts & Symbols + ["\u2764"] = "<3", + ["\U0001F494"] = "</3", + ["\U0001F495"] = ":two_hearts:", + ["\U0001F496"] = ":sparkling_heart:", + ["\U0001F497"] = ":heartpulse:", + ["\U0001F498"] = ":cupid:", + ["\U0001F499"] = ":blue_heart:", + ["\U0001F49A"] = ":green_heart:", + ["\U0001F49B"] = ":yellow_heart:", + ["\U0001F49C"] = ":purple_heart:", + ["\U0001F49D"] = ":gift_heart:", + ["\U0001F49E"] = ":revolving_hearts:", + ["\U0001F49F"] = ":heart_decoration:", + ["\U0001F90D"] = ":white_heart:", + ["\U0001F90E"] = ":brown_heart:", + ["\U0001F5A4"] = ":black_heart:", + ["\U0001F9E1"] = ":orange_heart:", + + // Objects & Nature + ["\U0001F525"] = ":fire:", + ["\U0001F4A9"] = ":poop:", + ["\U0001F480"] = ":skull:", + ["\U0001F47B"] = ":ghost:", + ["\U0001F47D"] = ":alien:", + ["\U0001F916"] = ":robot:", + ["\U0001F4AF"] = ":100:", + ["\U0001F4A5"] = ":boom:", + ["\U0001F4A4"] = ":zzz:", + ["\U0001F4A2"] = ":anger:", + ["\U0001F4AC"] = ":speech_balloon:", + ["\U0001F440"] = ":eyes:", + ["\U0001F3B5"] = ":musical_note:", + ["\U0001F3B6"] = ":notes:", + ["\U0001F389"] = ":tada:", + ["\U0001F38A"] = ":confetti:", + ["\U0001F381"] = ":gift:", + ["\U0001F3C6"] = ":trophy:", + ["\U0001F4B0"] = ":money_bag:", + ["\U0001F4BB"] = ":computer:", + ["\U0001F4F1"] = ":phone:", + ["\U0001F4E7"] = ":email:", + ["\U0001F511"] = ":key:", + ["\U0001F512"] = ":lock:", + ["\U0001F513"] = ":unlock:", + ["\U0001F6A8"] = ":rotating_light:", + ["\U0001F6AB"] = ":no_entry:", + + // Animals + ["\U0001F436"] = ":dog:", + ["\U0001F431"] = ":cat:", + ["\U0001F42D"] = ":mouse:", + ["\U0001F430"] = ":rabbit:", + ["\U0001F43B"] = ":bear:", + ["\U0001F427"] = ":penguin:", + ["\U0001F41D"] = ":bee:", + ["\U0001F40D"] = ":snake:", + ["\U0001F422"] = ":turtle:", + + // Food & Drink + ["\U0001F355"] = ":pizza:", + ["\U0001F354"] = ":hamburger:", + ["\U0001F37A"] = ":beer:", + ["\U0001F377"] = ":wine:", + ["\U0001F370"] = ":cake:", + ["\u2615"] = ":coffee:", + ["\U0001F382"] = ":birthday:", + + // Misc symbols (BMP) + ["\u2705"] = ":white_check_mark:", + ["\u274C"] = ":x:", + ["\u274E"] = ":negative_squared_cross_mark:", + ["\u2714"] = ":heavy_check_mark:", + ["\u2716"] = ":heavy_multiplication_x:", + ["\u26A0"] = ":warning:", + ["\u2B50"] = ":star:", + ["\u2728"] = ":sparkles:", + ["\u267B"] = ":recycle:", + ["\u2611"] = ":ballot_box_with_check:", + ["\u23F0"] = ":alarm_clock:", + ["\u231A"] = ":watch:", + ["\u231B"] = ":hourglass:", + }; +} diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index ffc2a87..1032d5c 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -4,7 +4,9 @@ using EchoHub.Core.DTOs; using EchoHub.Core.Models; using Terminal.Gui.App; using Terminal.Gui.Configuration; +using Terminal.Gui.Drawing; using Terminal.Gui.Input; +using Terminal.Gui.Text; using Terminal.Gui.ViewBase; using Terminal.Gui.Views; using Attribute = Terminal.Gui.Drawing.Attribute; @@ -880,7 +882,8 @@ public sealed class MainWindow : Runnable case MessageType.Text: default: - var contentLines = message.Content.Split('\n'); + var displayContent = EmojiHelper.ReplaceEmoji(message.Content); + var contentLines = displayContent.Split('\n'); var firstLine = contentLines[0].TrimEnd('\r'); lines.Add(BuildChatLineWithMentions(time, senderName, senderColor, $" {firstLine}")); // Continuation lines indented to align with first line's content @@ -891,9 +894,12 @@ public sealed class MainWindow : Runnable lines.Add(new ChatLine(ChatColors.SplitMentions(contText))); } - // Render link embed if present - if (message.Embed is not null) - lines.AddRange(FormatEmbed(message.Embed, indent)); + // Render link embeds if present + if (message.Embeds is { Count: > 0 }) + { + foreach (var embed in message.Embeds) + lines.AddRange(FormatEmbed(embed, indent)); + } break; } @@ -944,65 +950,152 @@ public sealed class MainWindow : Runnable } /// <summary> - /// Format a link embed as indented chat lines with a left border bar. + /// 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} /// </summary> private static List<ChatLine> FormatEmbed(EmbedDto embed, string indent) { 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 - // Site name - if (!string.IsNullOrWhiteSpace(embed.SiteName)) - { - lines.Add(new ChatLine(new List<ChatSegment> - { - new(indent, null), - new(border, ChatColors.EmbedBorderAttr), - new(embed.SiteName, ChatColors.EmbedBorderAttr) - })); - } - - // Title - if (!string.IsNullOrWhiteSpace(embed.Title)) - { - lines.Add(new ChatLine(new List<ChatSegment> - { - new(indent, null), - new(border, ChatColors.EmbedBorderAttr), - new(embed.Title, ChatColors.EmbedTitleAttr) - })); - } - - // Description (truncated) - if (!string.IsNullOrWhiteSpace(embed.Description)) - { - var desc = embed.Description.Length > 120 - ? embed.Description[..117] + "..." - : embed.Description; - - lines.Add(new ChatLine(new List<ChatSegment> - { - new(indent, null), - new(border, ChatColors.EmbedBorderAttr), - new(desc, ChatColors.EmbedDescAttr) - })); - } - - // ASCII image thumbnail + // Parse icon lines if present + var iconLines = new List<string>(); + int iconWidth = 0; if (!string.IsNullOrWhiteSpace(embed.ImageAscii)) { foreach (var artLine in embed.ImageAscii.Split('\n')) { var trimmed = artLine.TrimEnd('\r'); - if (string.IsNullOrEmpty(trimmed)) continue; - - if (ChatLine.HasColorTags(trimmed)) - lines.Add(ChatLine.FromColoredText(indent + border + trimmed)); - else - lines.Add(new ChatLine(indent + border + trimmed)); + 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(); + } + } + + 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)>(); + + if (!string.IsNullOrWhiteSpace(embed.SiteName)) + textRows.Add((embed.SiteName, ChatColors.EmbedBorderAttr)); + + if (!string.IsNullOrWhiteSpace(embed.Title)) + textRows.Add((embed.Title, ChatColors.EmbedTitleAttr)); + + // Word-wrap description + 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)); } return lines; } + + /// <summary> + /// Simple word-wrap: splits text into lines that fit within maxCols display columns. + /// </summary> + private static List<string> WordWrap(string text, int maxCols) + { + if (maxCols <= 0) + return [text]; + + var result = new List<string>(); + var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var currentLine = ""; + + foreach (var word in words) + { + var candidate = currentLine.Length == 0 ? word : currentLine + " " + word; + if (candidate.GetColumns() <= maxCols) + { + currentLine = candidate; + } + else + { + if (currentLine.Length > 0) + result.Add(currentLine); + // If a single word exceeds maxCols, just add it as-is + currentLine = word; + } + } + + if (currentLine.Length > 0) + result.Add(currentLine); + + return result; + } } diff --git a/src/EchoHub.Core/Constants/HubConstants.cs b/src/EchoHub.Core/Constants/HubConstants.cs index 5b2120d..7db4fff 100644 --- a/src/EchoHub.Core/Constants/HubConstants.cs +++ b/src/EchoHub.Core/Constants/HubConstants.cs @@ -15,9 +15,10 @@ public static class HubConstants public const int AsciiArtHeightHalfBlock = 80; // Link embed constants - public const int EmbedThumbnailWidth = 24; - public const int EmbedThumbnailHeight = 12; - public const int EmbedMaxDescriptionLength = 200; + 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 EmbedMaxUrlsPerMessage = 3; } diff --git a/src/EchoHub.Core/DTOs/ChatDtos.cs b/src/EchoHub.Core/DTOs/ChatDtos.cs index 5c35564..be772b2 100644 --- a/src/EchoHub.Core/DTOs/ChatDtos.cs +++ b/src/EchoHub.Core/DTOs/ChatDtos.cs @@ -12,7 +12,7 @@ public record MessageDto( string? AttachmentUrl, string? AttachmentFileName, DateTimeOffset SentAt, - EmbedDto? Embed = null); + List<EmbedDto>? Embeds = null); public record ChannelDto( Guid Id, diff --git a/src/EchoHub.Server.Irc/IrcMessageFormatter.cs b/src/EchoHub.Server.Irc/IrcMessageFormatter.cs index 774b081..fb017c5 100644 --- a/src/EchoHub.Server.Irc/IrcMessageFormatter.cs +++ b/src/EchoHub.Server.Irc/IrcMessageFormatter.cs @@ -24,9 +24,12 @@ public static partial class IrcMessageFormatter foreach (var chunk in SplitMessage(message.Content, MaxIrcLineContentBytes)) lines.Add($"{prefix} PRIVMSG {ircChannel} :{chunk}"); - // Append embed preview if present - if (message.Embed is not null) - lines.AddRange(FormatEmbed(prefix, ircChannel, message.Embed)); + // Append embed previews if present + if (message.Embeds is { Count: > 0 }) + { + foreach (var embed in message.Embeds) + lines.AddRange(FormatEmbed(prefix, ircChannel, embed)); + } break; case MessageType.Image: diff --git a/src/EchoHub.Server/Services/ChatService.cs b/src/EchoHub.Server/Services/ChatService.cs index 8dd3844..d590a65 100644 --- a/src/EchoHub.Server/Services/ChatService.cs +++ b/src/EchoHub.Server/Services/ChatService.cs @@ -175,15 +175,15 @@ public class ChatService : IChatService } } - // Attempt to fetch link embed for URLs in the message - EmbedDto? embed = null; + // Attempt to fetch link embeds for URLs in the message + List<EmbedDto>? embeds = null; try { - embed = await _embedService.TryGetEmbedAsync(content); + embeds = await _embedService.TryGetEmbedsAsync(content); } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to fetch link embed for message in '{Channel}'", channelName); + _logger.LogWarning(ex, "Failed to fetch link embeds for message in '{Channel}'", channelName); } var message = new Message @@ -195,7 +195,7 @@ public class ChatService : IChatService ChannelId = channel.Id, SenderUserId = userId, SenderUsername = username, - EmbedJson = embed is not null ? JsonSerializer.Serialize(embed) : null, + EmbedJson = embeds is not null ? JsonSerializer.Serialize(embeds) : null, }; db.Messages.Add(message); @@ -211,7 +211,7 @@ public class ChatService : IChatService null, null, message.SentAt, - embed); + embeds); await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto)); @@ -418,10 +418,10 @@ public class ChatService : IChatService return raw.Select(x => { - EmbedDto? embed = null; + List<EmbedDto>? embeds = null; if (x.m.EmbedJson is not null) { - try { embed = JsonSerializer.Deserialize<EmbedDto>(x.m.EmbedJson); } + try { embeds = JsonSerializer.Deserialize<List<EmbedDto>>(x.m.EmbedJson); } catch { /* ignore malformed JSON */ } } @@ -435,7 +435,7 @@ public class ChatService : IChatService x.m.AttachmentUrl, x.m.AttachmentFileName, x.m.SentAt, - embed); + embeds); }).ToList(); } } diff --git a/src/EchoHub.Server/Services/LinkEmbedService.cs b/src/EchoHub.Server/Services/LinkEmbedService.cs index a854b8c..8d032cc 100644 --- a/src/EchoHub.Server/Services/LinkEmbedService.cs +++ b/src/EchoHub.Server/Services/LinkEmbedService.cs @@ -24,108 +24,119 @@ public partial class LinkEmbedService } /// <summary> - /// Detect the first URL in message content and attempt to fetch OG embed data. - /// Returns null if no URL found, fetch fails, or no useful OG data. + /// Detect all URLs in message content and attempt to fetch OG embed data for each. + /// Returns null if no URLs found or all fetches fail. /// Never throws — all errors are caught internally. /// </summary> - public async Task<EmbedDto?> TryGetEmbedAsync(string content) + public async Task<List<EmbedDto>?> TryGetEmbedsAsync(string content) { - try - { - var url = ExtractFirstUrl(content); - if (url is null) - return null; - - if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) - return null; - - if (uri.Scheme is not ("http" or "https")) - return null; - - if (IsPrivateHost(uri)) - return null; - - using var cts = new CancellationTokenSource( - TimeSpan.FromSeconds(HubConstants.EmbedFetchTimeoutSeconds)); - - var client = _httpClientFactory.CreateClient("OgFetch"); - - using var request = new HttpRequestMessage(HttpMethod.Get, uri); - using var response = await client.SendAsync(request, - HttpCompletionOption.ResponseHeadersRead, cts.Token); - - if (!response.IsSuccessStatusCode) - return null; - - var contentType = response.Content.Headers.ContentType?.MediaType; - if (contentType is null || !contentType.StartsWith("text/html", StringComparison.OrdinalIgnoreCase)) - return null; - - var html = await ReadLimitedAsync(response, HubConstants.EmbedMaxHtmlBytes, cts.Token); - if (string.IsNullOrWhiteSpace(html)) - return null; - - var ogTags = ParseOgTags(html); - - // Try og:title, fallback to <title> tag - var title = ogTags.GetValueOrDefault("title"); - if (string.IsNullOrWhiteSpace(title)) - { - var titleMatch = TitleTagRegex().Match(html); - if (titleMatch.Success) - title = WebUtility.HtmlDecode(titleMatch.Groups[1].Value.Trim()); - } - - // If no title at all, nothing useful to show - if (string.IsNullOrWhiteSpace(title)) - return null; - - var siteName = ogTags.GetValueOrDefault("site_name"); - var description = ogTags.GetValueOrDefault("description"); - - // Truncate description - if (description is not null && description.Length > HubConstants.EmbedMaxDescriptionLength) - description = description[..(HubConstants.EmbedMaxDescriptionLength - 3)] + "..."; - - // HTML decode text fields - title = WebUtility.HtmlDecode(title); - siteName = siteName is not null ? WebUtility.HtmlDecode(siteName) : null; - description = description is not null ? WebUtility.HtmlDecode(description) : null; - - // Attempt to fetch OG image thumbnail - 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); - } - catch (OperationCanceledException) - { - _logger.LogDebug("Embed fetch timed out for message content"); + var urls = ExtractUrls(content); + if (urls.Count == 0) return null; - } - catch (Exception ex) + + var embeds = new List<EmbedDto>(); + + foreach (var url in urls) { - _logger.LogDebug(ex, "Failed to fetch embed"); - return null; + try + { + var embed = await FetchEmbedForUrlAsync(url); + if (embed is not null) + embeds.Add(embed); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to fetch embed for {Url}", url); + } } + + return embeds.Count > 0 ? embeds : null; } - private static string? ExtractFirstUrl(string content) + private async Task<EmbedDto?> FetchEmbedForUrlAsync(string url) { - var match = UrlRegex().Match(content); - if (!match.Success) + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return null; - var url = match.Value; + if (uri.Scheme is not ("http" or "https")) + return null; - // Strip trailing punctuation that's likely not part of the URL - url = url.TrimEnd('.', ',', '!', '?', ')', ']', ';', ':'); + if (IsPrivateHost(uri)) + return null; - return url; + using var cts = new CancellationTokenSource( + TimeSpan.FromSeconds(HubConstants.EmbedFetchTimeoutSeconds)); + + var client = _httpClientFactory.CreateClient("OgFetch"); + + using var request = new HttpRequestMessage(HttpMethod.Get, uri); + using var response = await client.SendAsync(request, + HttpCompletionOption.ResponseHeadersRead, cts.Token); + + if (!response.IsSuccessStatusCode) + return null; + + var contentType = response.Content.Headers.ContentType?.MediaType; + if (contentType is null || !contentType.StartsWith("text/html", StringComparison.OrdinalIgnoreCase)) + return null; + + var html = await ReadLimitedAsync(response, HubConstants.EmbedMaxHtmlBytes, cts.Token); + if (string.IsNullOrWhiteSpace(html)) + return null; + + var ogTags = ParseOgTags(html); + + // Try og:title, fallback to <title> tag + var title = ogTags.GetValueOrDefault("title"); + if (string.IsNullOrWhiteSpace(title)) + { + var titleMatch = TitleTagRegex().Match(html); + if (titleMatch.Success) + title = WebUtility.HtmlDecode(titleMatch.Groups[1].Value.Trim()); + } + + // If no title at all, nothing useful to show + if (string.IsNullOrWhiteSpace(title)) + return null; + + var siteName = ogTags.GetValueOrDefault("site_name"); + var description = ogTags.GetValueOrDefault("description"); + + // Truncate very long descriptions but keep a generous limit + if (description is not null && description.Length > HubConstants.EmbedMaxDescriptionLength) + description = description[..(HubConstants.EmbedMaxDescriptionLength - 3)] + "..."; + + // HTML decode text fields + title = WebUtility.HtmlDecode(title); + 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); + } + + private static List<string> ExtractUrls(string content) + { + var urls = new List<string>(); + + foreach (Match match in UrlRegex().Matches(content)) + { + var url = match.Value.TrimEnd('.', ',', '!', '?', ')', ']', ';', ':'); + if (!urls.Contains(url)) + urls.Add(url); + + if (urls.Count >= HubConstants.EmbedMaxUrlsPerMessage) + break; + } + + return urls; } private static bool IsPrivateHost(Uri uri) @@ -220,8 +231,8 @@ public partial class LinkEmbedService memoryStream.Position = 0; return _asciiService.ConvertToAscii(memoryStream, - HubConstants.EmbedThumbnailWidth, - HubConstants.EmbedThumbnailHeight); + HubConstants.EmbedIconWidth, + HubConstants.EmbedIconHeight); } catch (Exception ex) { diff --git a/src/EchoHub.Server/Setup/DataMigrationService.cs b/src/EchoHub.Server/Setup/DataMigrationService.cs index 8a85280..34028c8 100644 --- a/src/EchoHub.Server/Setup/DataMigrationService.cs +++ b/src/EchoHub.Server/Setup/DataMigrationService.cs @@ -1,5 +1,6 @@ using System.Text.RegularExpressions; using EchoHub.Core.Constants; +using EchoHub.Core.DTOs; using EchoHub.Server.Data; using Microsoft.EntityFrameworkCore; @@ -16,6 +17,7 @@ public static partial class DataMigrationService await EnsureDefaultChannelsPublicAsync(db, logger); await MigrateAnsiMessagesAsync(db, logger); + await MigrateEmbedJsonToArrayAsync(db, logger); } /// <summary> @@ -91,4 +93,47 @@ public static partial class DataMigrationService [GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2|48;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")] private static partial Regex AnsiColorRegex(); + + /// <summary> + /// Migrate old single-object EmbedJson ("{...}") to array format ("[{...}]"). + /// </summary> + private static async Task MigrateEmbedJsonToArrayAsync(EchoHubDbContext db, ILogger logger) + { + var messages = await db.Messages + .Where(m => m.EmbedJson != null) + .ToListAsync(); + + var toMigrate = messages + .Where(m => m.EmbedJson!.TrimStart().StartsWith('{')) + .ToList(); + + if (toMigrate.Count == 0) + return; + + logger.LogInformation("Found {Count} messages with legacy single-embed JSON. Migrating to array format...", toMigrate.Count); + + var modified = 0; + foreach (var message in toMigrate) + { + try + { + var single = System.Text.Json.JsonSerializer.Deserialize<EmbedDto>(message.EmbedJson!); + if (single is not null) + { + message.EmbedJson = System.Text.Json.JsonSerializer.Serialize(new[] { single }); + modified++; + } + } + catch + { + // Skip malformed JSON + } + } + + if (modified > 0) + { + await db.SaveChangesAsync(); + logger.LogInformation("Migrated {Count} embed records from single-object to array format.", modified); + } + } }