feat: implement newline sanitization in chat messages to prevent excessive newlines

This commit is contained in:
HueByte
2026-02-19 20:00:51 +01:00
parent 7181748d40
commit 5d51558c5c
3 changed files with 46 additions and 18 deletions
+19 -18
View File
@@ -231,11 +231,11 @@ public class ChatListSource : IListDataSource
attr = new Attribute(attr.Foreground, mentionBg.Value); attr = new Attribute(attr.Foreground, mentionBg.Value);
listView.SetAttribute(attr); listView.SetAttribute(attr);
foreach (var ch in segment.Text) foreach (var rune in segment.Text.EnumerateRunes())
{ {
if (charPos >= viewportX && drawnChars < width) if (charPos >= viewportX && drawnChars < width)
{ {
listView.AddRune(new Rune(ch)); listView.AddRune(rune);
drawnChars++; drawnChars++;
} }
charPos++; charPos++;
@@ -324,9 +324,9 @@ public class ChannelListSource : IListDataSource
if (selected) if (selected)
{ {
listView.SetAttribute(focusAttr); 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 else
@@ -334,26 +334,26 @@ public class ChannelListSource : IListDataSource
// Prefix // Prefix
var prefixAttr = isActive ? ActiveAttr : NormalAttr; var prefixAttr = isActive ? ActiveAttr : NormalAttr;
listView.SetAttribute(prefixAttr); 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 // Channel name
var nameAttr = isActive ? ActiveAttr : hasUnread ? UnreadAttr : NormalAttr; var nameAttr = isActive ? ActiveAttr : hasUnread ? UnreadAttr : NormalAttr;
listView.SetAttribute(nameAttr); 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 // Unread badge
if (hasUnread) if (hasUnread)
{ {
listView.SetAttribute(BadgeAttr); 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 (text, nameColor) = _users[item];
var normalAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal); 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) // Find where the name starts (after status icon + space + optional role badge)
// Format: "● ★Username" or "● Username" // Format: "● ★Username" or "● Username"
int nameStart = 0; int nameStart = 0;
int i = 0; while (nameStart < runes.Length && !Rune.IsLetterOrDigit(runes[nameStart]) && runes[nameStart].Value != '_')
// Skip status icon nameStart++;
while (i < text.Length && !char.IsLetterOrDigit(text[i]) && text[i] != '_') i++;
nameStart = i;
int drawnChars = 0; int drawnChars = 0;
// Draw prefix (status icon + role badge) in normal color // Draw prefix (status icon + role badge) in normal color
var prefixAttr = normalAttr; 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) if (drawnChars < width)
{ {
listView.SetAttribute(prefixAttr); listView.SetAttribute(prefixAttr);
listView.AddRune(new Rune(text[c])); listView.AddRune(runes[c]);
drawnChars++; drawnChars++;
} }
} }
@@ -428,12 +429,12 @@ public class UserListSource : IListDataSource
// Draw name in nickname color // Draw name in nickname color
var userAttr = nameColor ?? normalAttr; var userAttr = nameColor ?? normalAttr;
if (selected) userAttr = normalAttr; // use focus attr when selected 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) if (drawnChars < width)
{ {
listView.SetAttribute(userAttr); listView.SetAttribute(userAttr);
listView.AddRune(new Rune(text[c])); listView.AddRune(runes[c]);
drawnChars++; drawnChars++;
} }
} }
@@ -8,6 +8,8 @@ public static class HubConstants
public const int MaxMessageLength = 2000; public const int MaxMessageLength = 2000;
public const int MaxFileSizeBytes = 10 * 1024 * 1024; // 10 MB public const int MaxFileSizeBytes = 10 * 1024 * 1024; // 10 MB
public const int MaxAvatarSizeBytes = 2 * 1024 * 1024; // 2 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 AsciiArtWidth = 80;
public const int AsciiArtHeight = 40; public const int AsciiArtHeight = 40;
public const int AsciiArtHeightHalfBlock = 80; public const int AsciiArtHeightHalfBlock = 80;
@@ -144,6 +144,9 @@ public class ChatService : IChatService
if (content.Length > HubConstants.MaxMessageLength) if (content.Length > HubConstants.MaxMessageLength)
return $"Message exceeds maximum length of {HubConstants.MaxMessageLength} characters."; return $"Message exceeds maximum length of {HubConstants.MaxMessageLength} characters.";
// Sanitize excessive newlines
content = SanitizeNewlines(content);
using var scope = _scopeFactory.CreateScope(); using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>(); var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
@@ -343,6 +346,28 @@ public class ChatService : IChatService
return (user.Id, user.Username); return (user.Id, user.Username);
} }
/// <summary>
/// Collapse consecutive newlines and cap total line count to prevent newline spam.
/// </summary>
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<List<MessageDto>> GetChannelHistoryInternalAsync(EchoHubDbContext db, string channelName, int count) private static async Task<List<MessageDto>> GetChannelHistoryInternalAsync(EchoHubDbContext db, string channelName, int count)
{ {
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);