diff --git a/src/EchoHub.Client/UI/ChatRenderer.cs b/src/EchoHub.Client/UI/ChatRenderer.cs index b7b5f95..80ac81b 100644 --- a/src/EchoHub.Client/UI/ChatRenderer.cs +++ b/src/EchoHub.Client/UI/ChatRenderer.cs @@ -231,11 +231,11 @@ public class ChatListSource : IListDataSource attr = new Attribute(attr.Foreground, mentionBg.Value); listView.SetAttribute(attr); - foreach (var ch in segment.Text) + foreach (var rune in segment.Text.EnumerateRunes()) { if (charPos >= viewportX && drawnChars < width) { - listView.AddRune(new Rune(ch)); + listView.AddRune(rune); drawnChars++; } charPos++; @@ -324,9 +324,9 @@ public class ChannelListSource : IListDataSource if (selected) { listView.SetAttribute(focusAttr); - foreach (var ch in (prefix + channelText + badge)) + foreach (var rune in (prefix + channelText + badge).EnumerateRunes()) { - if (drawnChars < width) { listView.AddRune(new Rune(ch)); drawnChars++; } + if (drawnChars < width) { listView.AddRune(rune); drawnChars++; } } } else @@ -334,26 +334,26 @@ public class ChannelListSource : IListDataSource // Prefix var prefixAttr = isActive ? ActiveAttr : NormalAttr; listView.SetAttribute(prefixAttr); - foreach (var ch in prefix) + foreach (var rune in prefix.EnumerateRunes()) { - if (drawnChars < width) { listView.AddRune(new Rune(ch)); drawnChars++; } + if (drawnChars < width) { listView.AddRune(rune); drawnChars++; } } // Channel name var nameAttr = isActive ? ActiveAttr : hasUnread ? UnreadAttr : NormalAttr; listView.SetAttribute(nameAttr); - foreach (var ch in channelText) + foreach (var rune in channelText.EnumerateRunes()) { - if (drawnChars < width) { listView.AddRune(new Rune(ch)); drawnChars++; } + if (drawnChars < width) { listView.AddRune(rune); drawnChars++; } } // Unread badge if (hasUnread) { listView.SetAttribute(BadgeAttr); - foreach (var ch in badge) + foreach (var rune in badge.EnumerateRunes()) { - if (drawnChars < width) { listView.AddRune(new Rune(ch)); drawnChars++; } + if (drawnChars < width) { listView.AddRune(rune); drawnChars++; } } } } @@ -403,24 +403,25 @@ public class UserListSource : IListDataSource var (text, nameColor) = _users[item]; var normalAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal); + // Convert to runes for safe surrogate handling + var runes = text.EnumerateRunes().ToArray(); + // Find where the name starts (after status icon + space + optional role badge) // Format: "● ★Username" or "● Username" int nameStart = 0; - int i = 0; - // Skip status icon - while (i < text.Length && !char.IsLetterOrDigit(text[i]) && text[i] != '_') i++; - nameStart = i; + while (nameStart < runes.Length && !Rune.IsLetterOrDigit(runes[nameStart]) && runes[nameStart].Value != '_') + nameStart++; int drawnChars = 0; // Draw prefix (status icon + role badge) in normal color var prefixAttr = normalAttr; - for (int c = 0; c < nameStart && c < text.Length; c++) + for (int c = 0; c < nameStart && c < runes.Length; c++) { if (drawnChars < width) { listView.SetAttribute(prefixAttr); - listView.AddRune(new Rune(text[c])); + listView.AddRune(runes[c]); drawnChars++; } } @@ -428,12 +429,12 @@ public class UserListSource : IListDataSource // Draw name in nickname color var userAttr = nameColor ?? normalAttr; if (selected) userAttr = normalAttr; // use focus attr when selected - for (int c = nameStart; c < text.Length; c++) + for (int c = nameStart; c < runes.Length; c++) { if (drawnChars < width) { listView.SetAttribute(userAttr); - listView.AddRune(new Rune(text[c])); + listView.AddRune(runes[c]); drawnChars++; } } diff --git a/src/EchoHub.Core/Constants/HubConstants.cs b/src/EchoHub.Core/Constants/HubConstants.cs index 2b980c3..85764ea 100644 --- a/src/EchoHub.Core/Constants/HubConstants.cs +++ b/src/EchoHub.Core/Constants/HubConstants.cs @@ -8,6 +8,8 @@ public static class HubConstants public const int MaxMessageLength = 2000; 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 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 8d60c35..dd3767b 100644 --- a/src/EchoHub.Server/Services/ChatService.cs +++ b/src/EchoHub.Server/Services/ChatService.cs @@ -144,6 +144,9 @@ public class ChatService : IChatService if (content.Length > HubConstants.MaxMessageLength) return $"Message exceeds maximum length of {HubConstants.MaxMessageLength} characters."; + // Sanitize excessive newlines + content = SanitizeNewlines(content); + using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); @@ -343,6 +346,28 @@ public class ChatService : IChatService return (user.Id, user.Username); } + /// + /// Collapse consecutive newlines and cap total line count to prevent newline spam. + /// + private static string SanitizeNewlines(string content) + { + // 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 + var lines = content.Split('\n'); + if (lines.Length > HubConstants.MaxMessageNewlines) + content = string.Join('\n', lines.Take(HubConstants.MaxMessageNewlines)); + + return content; + } + private static async Task> GetChannelHistoryInternalAsync(EchoHubDbContext db, string channelName, int count) { var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);