From 9f303b531113207ff37422c41ec340c29de14876 Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 19 Feb 2026 20:41:36 +0100 Subject: [PATCH] feat: enhance chat message handling by converting emojis to text shortcodes and improving newline sanitization --- docs/changelog/v0.2.3.md | 3 +- src/EchoHub.Client/UI/ChatRenderer.cs | 70 ++++++++----- src/EchoHub.Core/Constants/HubConstants.cs | 2 +- src/EchoHub.Server/Services/ChatService.cs | 115 +++++++++++++++++++-- 4 files changed, 154 insertions(+), 36 deletions(-) diff --git a/docs/changelog/v0.2.3.md b/docs/changelog/v0.2.3.md index 5629738..902b244 100644 --- a/docs/changelog/v0.2.3.md +++ b/docs/changelog/v0.2.3.md @@ -48,9 +48,10 @@ - Wired `OnChannelUpdated` SignalR event so new public channels appear for all connected users in real time - Fixed color tag parser using wrong regex group numbers (6,7,8 instead of 1,2,3) — new ASCII art was rendering without colors - Fixed crash when receiving emoji or other non-BMP Unicode characters — all list renderers now use `EnumerateRunes()` instead of `char` iteration +- Emoji and non-BMP characters are now converted to text shortcodes server-side (e.g. `:smile:`, `:fire:`) for reliable TUI rendering - Fixed `/send` and `/avatar` commands not handling file paths with spaces correctly, even when quoted - Profile avatar now renders with full color tag support instead of showing raw tags -- Server-side newline spam protection — consecutive newlines collapsed (max 2) and total lines capped at 30 +- Server-side newline spam protection — consecutive blank/whitespace-only lines collapsed to 1 and total lines capped at 30 ## Infrastructure diff --git a/src/EchoHub.Client/UI/ChatRenderer.cs b/src/EchoHub.Client/UI/ChatRenderer.cs index 80ac81b..605d3cd 100644 --- a/src/EchoHub.Client/UI/ChatRenderer.cs +++ b/src/EchoHub.Client/UI/ChatRenderer.cs @@ -3,6 +3,7 @@ using System.Collections.Specialized; using System.Text; using System.Text.RegularExpressions; using Terminal.Gui.Drawing; +using Terminal.Gui.Text; using Terminal.Gui.Views; using Attribute = Terminal.Gui.Drawing.Attribute; @@ -26,15 +27,21 @@ public partial class ChatLine public ChatLine(string plainText) { Segments = [new ChatSegment(plainText, null)]; - TextLength = plainText.Length; + TextLength = DisplayWidth(plainText); } public ChatLine(List segments) { Segments = segments; - TextLength = segments.Sum(s => s.Text.Length); + TextLength = segments.Sum(s => DisplayWidth(s.Text)); } + /// + /// Compute the display column width of a string, accounting for wide characters (emoji, CJK). + /// + private static int DisplayWidth(string text) => + text.EnumerateRunes().Sum(r => Math.Max(r.GetColumns(), 1)); + public override string ToString() => string.Concat(Segments.Select(s => s.Text)); /// @@ -52,17 +59,24 @@ public partial class ChatLine foreach (var segment in Segments) { - int segPos = 0; - while (segPos < segment.Text.Length) + var text = segment.Text; + int chunkStart = 0; // char index where current chunk starts + int charPos = 0; + + foreach (var rune in text.EnumerateRunes()) { - int remaining = width - col; - if (remaining <= 0) + var runeCols = Math.Max(rune.GetColumns(), 1); + + if (col + runeCols > width) { + // Flush accumulated text from this segment chunk + if (charPos > chunkStart) + currentSegments.Add(new ChatSegment(text[chunkStart..charPos], segment.Color)); + // Emit current line and start a new one results.Add(new ChatLine(currentSegments)); currentSegments = []; - // Add indent for continuation if (continuationIndent > 0) { currentSegments.Add(new ChatSegment(new string(' ', continuationIndent), null)); @@ -73,14 +87,16 @@ public partial class ChatLine col = 0; } - remaining = width - col; + chunkStart = charPos; } - int take = Math.Min(segment.Text.Length - segPos, remaining); - currentSegments.Add(new ChatSegment(segment.Text.Substring(segPos, take), segment.Color)); - col += take; - segPos += take; + col += runeCols; + charPos += rune.Utf16SequenceLength; } + + // Flush remaining chunk of this segment + if (chunkStart < text.Length) + currentSegments.Add(new ChatSegment(text[chunkStart..], segment.Color)); } if (currentSegments.Count > 0) @@ -233,12 +249,14 @@ public class ChatListSource : IListDataSource foreach (var rune in segment.Text.EnumerateRunes()) { - if (charPos >= viewportX && drawnChars < width) + var cols = rune.GetColumns(); + if (cols < 1) cols = 1; + if (charPos >= viewportX && drawnChars + cols <= width) { listView.AddRune(rune); - drawnChars++; + drawnChars += cols; } - charPos++; + charPos += cols; } } @@ -326,7 +344,8 @@ public class ChannelListSource : IListDataSource listView.SetAttribute(focusAttr); foreach (var rune in (prefix + channelText + badge).EnumerateRunes()) { - if (drawnChars < width) { listView.AddRune(rune); drawnChars++; } + var cols = Math.Max(rune.GetColumns(), 1); + if (drawnChars + cols <= width) { listView.AddRune(rune); drawnChars += cols; } } } else @@ -336,7 +355,8 @@ public class ChannelListSource : IListDataSource listView.SetAttribute(prefixAttr); foreach (var rune in prefix.EnumerateRunes()) { - if (drawnChars < width) { listView.AddRune(rune); drawnChars++; } + var cols = Math.Max(rune.GetColumns(), 1); + if (drawnChars + cols <= width) { listView.AddRune(rune); drawnChars += cols; } } // Channel name @@ -344,7 +364,8 @@ public class ChannelListSource : IListDataSource listView.SetAttribute(nameAttr); foreach (var rune in channelText.EnumerateRunes()) { - if (drawnChars < width) { listView.AddRune(rune); drawnChars++; } + var cols = Math.Max(rune.GetColumns(), 1); + if (drawnChars + cols <= width) { listView.AddRune(rune); drawnChars += cols; } } // Unread badge @@ -353,7 +374,8 @@ public class ChannelListSource : IListDataSource listView.SetAttribute(BadgeAttr); foreach (var rune in badge.EnumerateRunes()) { - if (drawnChars < width) { listView.AddRune(rune); drawnChars++; } + var cols = Math.Max(rune.GetColumns(), 1); + if (drawnChars + cols <= width) { listView.AddRune(rune); drawnChars += cols; } } } } @@ -418,11 +440,12 @@ public class UserListSource : IListDataSource var prefixAttr = normalAttr; for (int c = 0; c < nameStart && c < runes.Length; c++) { - if (drawnChars < width) + var cols = Math.Max(runes[c].GetColumns(), 1); + if (drawnChars + cols <= width) { listView.SetAttribute(prefixAttr); listView.AddRune(runes[c]); - drawnChars++; + drawnChars += cols; } } @@ -431,11 +454,12 @@ public class UserListSource : IListDataSource if (selected) userAttr = normalAttr; // use focus attr when selected for (int c = nameStart; c < runes.Length; c++) { - if (drawnChars < width) + var cols = Math.Max(runes[c].GetColumns(), 1); + if (drawnChars + cols <= width) { listView.SetAttribute(userAttr); listView.AddRune(runes[c]); - drawnChars++; + drawnChars += cols; } } diff --git a/src/EchoHub.Core/Constants/HubConstants.cs b/src/EchoHub.Core/Constants/HubConstants.cs index 85764ea..e79e431 100644 --- a/src/EchoHub.Core/Constants/HubConstants.cs +++ b/src/EchoHub.Core/Constants/HubConstants.cs @@ -9,7 +9,7 @@ public static class HubConstants public const int MaxFileSizeBytes = 10 * 1024 * 1024; // 10 MB public const int MaxAvatarSizeBytes = 2 * 1024 * 1024; // 2 MB public const int MaxMessageNewlines = 30; - public const int MaxConsecutiveNewlines = 2; + public const int MaxConsecutiveNewlines = 1; public const int AsciiArtWidth = 80; public const int AsciiArtHeight = 40; public const int AsciiArtHeightHalfBlock = 80; diff --git a/src/EchoHub.Server/Services/ChatService.cs b/src/EchoHub.Server/Services/ChatService.cs index dd3767b..5203176 100644 --- a/src/EchoHub.Server/Services/ChatService.cs +++ b/src/EchoHub.Server/Services/ChatService.cs @@ -1,3 +1,4 @@ +using System.Text; using EchoHub.Core.Constants; using EchoHub.Core.Contracts; using EchoHub.Core.DTOs; @@ -144,7 +145,8 @@ public class ChatService : IChatService if (content.Length > HubConstants.MaxMessageLength) return $"Message exceeds maximum length of {HubConstants.MaxMessageLength} characters."; - // Sanitize excessive newlines + // Sanitize: convert emoji to text, collapse newlines + content = ConvertEmoji(content); content = SanitizeNewlines(content); using var scope = _scopeFactory.CreateScope(); @@ -346,6 +348,84 @@ public class ChatService : IChatService return (user.Id, user.Username); } + /// + /// Replace emoji with text shortcodes. TUI terminals can't render wide chars reliably. + /// + private static string ConvertEmoji(string content) + { + var sb = new StringBuilder(content.Length); + foreach (var rune in content.EnumerateRunes()) + { + if (EmojiMap.TryGetValue(rune.Value, out var name)) + sb.Append(name); + else if (rune.Value >= 0x1F000) // supplementary emoji planes + sb.Append($"[?]"); + else if (rune.Value is 0x200D or 0xFE0F or 0xFE0E) // ZWJ, variation selectors + { } // strip silently + else + sb.Append(rune.ToString()); + } + return sb.ToString(); + } + + private static readonly Dictionary EmojiMap = new() + { + [0x1F600] = ":grinning:", [0x1F601] = ":grin:", [0x1F602] = ":joy:", + [0x1F603] = ":smiley:", [0x1F604] = ":smile:", [0x1F605] = ":sweat_smile:", + [0x1F606] = ":laughing:", [0x1F607] = ":angel:", [0x1F608] = ":imp:", + [0x1F609] = ":wink:", [0x1F60A] = ":blush:", [0x1F60B] = ":yum:", + [0x1F60C] = ":relieved:", [0x1F60D] = ":heart_eyes:", [0x1F60E] = ":sunglasses:", + [0x1F60F] = ":smirk:", [0x1F610] = ":neutral:", [0x1F611] = ":expressionless:", + [0x1F612] = ":unamused:", [0x1F613] = ":sweat:", [0x1F614] = ":pensive:", + [0x1F615] = ":confused:", [0x1F616] = ":confounded:", [0x1F617] = ":kiss:", + [0x1F618] = ":kissing_heart:", [0x1F619] = ":kissing:", [0x1F61A] = ":kissing_closed_eyes:", + [0x1F61B] = ":tongue:", [0x1F61C] = ":wink_tongue:", [0x1F61D] = ":squint_tongue:", + [0x1F61E] = ":disappointed:", [0x1F61F] = ":worried:", [0x1F620] = ":angry:", + [0x1F621] = ":rage:", [0x1F622] = ":cry:", [0x1F623] = ":persevere:", + [0x1F624] = ":triumph:", [0x1F625] = ":disappointed_relieved:", [0x1F626] = ":frowning:", + [0x1F627] = ":anguished:", [0x1F628] = ":fearful:", [0x1F629] = ":weary:", + [0x1F62A] = ":sleepy:", [0x1F62B] = ":tired:", [0x1F62C] = ":grimacing:", + [0x1F62D] = ":sob:", [0x1F62E] = ":open_mouth:", [0x1F62F] = ":hushed:", + [0x1F630] = ":cold_sweat:", [0x1F631] = ":scream:", [0x1F632] = ":astonished:", + [0x1F633] = ":flushed:", [0x1F634] = ":sleeping:", [0x1F635] = ":dizzy_face:", + [0x1F636] = ":no_mouth:", [0x1F637] = ":mask:", [0x1F638] = ":smile_cat:", + [0x1F642] = ":slight_smile:", [0x1F643] = ":upside_down:", + [0x1F644] = ":roll_eyes:", [0x1F910] = ":zipper_mouth:", + [0x1F911] = ":money_mouth:", [0x1F912] = ":thermometer_face:", + [0x1F913] = ":nerd:", [0x1F914] = ":thinking:", [0x1F915] = ":head_bandage:", + [0x1F920] = ":cowboy:", [0x1F921] = ":clown:", [0x1F923] = ":rofl:", + [0x1F924] = ":drooling:", [0x1F925] = ":lying:", + [0x1F970] = ":smiling_hearts:", [0x1F971] = ":yawning:", + [0x1F972] = ":smiling_tear:", [0x1F973] = ":party:", + [0x1F974] = ":woozy:", [0x1F975] = ":hot:", [0x1F976] = ":cold:", + [0x1F978] = ":disguised:", [0x1F979] = ":holding_back_tears:", + [0x1F97A] = ":pleading:", [0x1F92A] = ":zany:", [0x1F92B] = ":shushing:", + [0x1F92C] = ":censored:", [0x1F92D] = ":hand_over_mouth:", + [0x1F92E] = ":vomiting:", [0x1F92F] = ":exploding_head:", + // Gestures + [0x1F44D] = ":+1:", [0x1F44E] = ":-1:", [0x1F44F] = ":clap:", + [0x1F44B] = ":wave:", [0x1F44C] = ":ok_hand:", [0x1F44A] = ":punch:", + [0x1F4AA] = ":muscle:", [0x1F64F] = ":pray:", [0x1F91D] = ":handshake:", + [0x1F90C] = ":pinched_fingers:", [0x1F918] = ":metal:", [0x1F919] = ":call_me:", + // Hearts + [0x2764] = "<3", [0x1F494] = " /// Collapse consecutive newlines and cap total line count to prevent newline spam. /// @@ -354,18 +434,31 @@ public class ChatService : IChatService // Normalize \r\n → \n content = content.Replace("\r\n", "\n").Replace('\r', '\n'); - // Collapse runs of >MaxConsecutiveNewlines into MaxConsecutiveNewlines - var maxRun = new string('\n', HubConstants.MaxConsecutiveNewlines + 1); - var replacement = new string('\n', HubConstants.MaxConsecutiveNewlines); - while (content.Contains(maxRun)) - content = content.Replace(maxRun, replacement); - - // Cap total newlines + // Collapse consecutive blank/whitespace-only lines into max 1 blank line var lines = content.Split('\n'); - if (lines.Length > HubConstants.MaxMessageNewlines) - content = string.Join('\n', lines.Take(HubConstants.MaxMessageNewlines)); + var result = new List(lines.Length); + int consecutiveBlanks = 0; - return content; + foreach (var line in lines) + { + if (string.IsNullOrWhiteSpace(line)) + { + consecutiveBlanks++; + if (consecutiveBlanks <= HubConstants.MaxConsecutiveNewlines) + result.Add(line); + } + else + { + consecutiveBlanks = 0; + result.Add(line); + } + } + + // Cap total lines + if (result.Count > HubConstants.MaxMessageNewlines) + result = result.Take(HubConstants.MaxMessageNewlines).ToList(); + + return string.Join('\n', result); } private static async Task> GetChannelHistoryInternalAsync(EchoHubDbContext db, string channelName, int count)