feat: enhance IRC support with display name handling, private channel tracking, and connection identification

This commit is contained in:
HueByte
2026-07-16 18:22:34 +02:00
parent b62729dc95
commit b6c01dab15
11 changed files with 118 additions and 20 deletions
@@ -381,6 +381,9 @@ public sealed class ChatMessageManager
private List<ChatLine> FormatMessage(MessageDto message) private List<ChatLine> FormatMessage(MessageDto message)
{ {
var time = FormatTime(message.SentAt); var time = FormatTime(message.SentAt);
// Show the display name, but keep color + click identity keyed to the username
// so they stay consistent with the user list and profile lookups.
var senderName = message.SenderDisplayName ?? message.SenderUsername;
var senderColor = HexColorHelper.ParseHexColor(message.SenderNicknameColor) var senderColor = HexColorHelper.ParseHexColor(message.SenderNicknameColor)
?? NickColorHelper.GetAttribute(message.SenderUsername); ?? NickColorHelper.GetAttribute(message.SenderUsername);
@@ -394,7 +397,7 @@ public sealed class ChatMessageManager
var displayContent = EmojiHelper.ReplaceEmoji(message.Content); var displayContent = EmojiHelper.ReplaceEmoji(message.Content);
var contentLines = displayContent.Split('\n'); var contentLines = displayContent.Split('\n');
var header = HeaderSegments(time, message.SenderUsername, senderColor); var header = HeaderSegments(time, senderName, senderColor);
header.AddRange(ChatColors.SplitMentions(contentLines[0].TrimEnd('\r'))); header.AddRange(ChatColors.SplitMentions(contentLines[0].TrimEnd('\r')));
lines.Add(new ChatLine(header)); lines.Add(new ChatLine(header));
@@ -413,7 +416,7 @@ public sealed class ChatMessageManager
1 => $"[{attachments[0].Kind.ToString().ToLowerInvariant()}]", 1 => $"[{attachments[0].Kind.ToString().ToLowerInvariant()}]",
_ => $"[{attachments.Count} attachments]", _ => $"[{attachments.Count} attachments]",
}; };
var header = HeaderSegments(time, message.SenderUsername, senderColor); var header = HeaderSegments(time, senderName, senderColor);
header.Add(new(summary, null)); header.Add(new(summary, null));
lines.Add(new ChatLine(header)); lines.Add(new ChatLine(header));
} }
@@ -18,6 +18,7 @@ public class ChannelListSource : IListDataSource
private readonly Dictionary<string, int> _unreadCounts = []; private readonly Dictionary<string, int> _unreadCounts = [];
private readonly HashSet<string> _protectedChannels = []; private readonly HashSet<string> _protectedChannels = [];
private readonly HashSet<string> _mentionChannels = []; private readonly HashSet<string> _mentionChannels = [];
private readonly HashSet<string> _privateChannels = [];
private string _activeChannel = string.Empty; private string _activeChannel = string.Empty;
public event NotifyCollectionChangedEventHandler? CollectionChanged; public event NotifyCollectionChangedEventHandler? CollectionChanged;
@@ -32,7 +33,8 @@ public class ChannelListSource : IListDataSource
private static readonly Attribute MentionAttr = new(new Color(230, 140, 60), Color.None); private static readonly Attribute MentionAttr = new(new Color(230, 140, 60), Color.None);
public void Update(List<string> channels, Dictionary<string, int> unread, string activeChannel, public void Update(List<string> channels, Dictionary<string, int> unread, string activeChannel,
IReadOnlySet<string>? protectedChannels = null, IReadOnlySet<string>? mentionChannels = null) IReadOnlySet<string>? protectedChannels = null, IReadOnlySet<string>? mentionChannels = null,
IReadOnlySet<string>? privateChannels = null)
{ {
_channelNames.Clear(); _channelNames.Clear();
_channelNames.AddRange(channels); _channelNames.AddRange(channels);
@@ -45,6 +47,9 @@ public class ChannelListSource : IListDataSource
_mentionChannels.Clear(); _mentionChannels.Clear();
if (mentionChannels is not null) if (mentionChannels is not null)
_mentionChannels.UnionWith(mentionChannels); _mentionChannels.UnionWith(mentionChannels);
_privateChannels.Clear();
if (privateChannels is not null)
_privateChannels.UnionWith(privateChannels);
_activeChannel = activeChannel; _activeChannel = activeChannel;
MaxItemLength = channels.Count > 0 ? channels.Max(c => c.Length + 6) : 0; MaxItemLength = channels.Count > 0 ? channels.Max(c => c.Length + 6) : 0;
if (!SuspendCollectionChangedEvent) if (!SuspendCollectionChangedEvent)
@@ -67,8 +72,12 @@ public class ChannelListSource : IListDataSource
var normalAttr = listView.GetAttributeForRole(VisualRole.Normal); var normalAttr = listView.GetAttributeForRole(VisualRole.Normal);
var focusAttr = listView.GetAttributeForRole(VisualRole.Focus); var focusAttr = listView.GetAttributeForRole(VisualRole.Focus);
var prefix = isActive ? "> " : " "; var prefix = isActive ? "> " : " ";
// Trailing * marks password-protected (+k) channels // Trailing * marks password-protected (+k) channels; ~ marks private (unlisted) ones
var channelText = _protectedChannels.Contains(name) ? $"#{name}*" : $"#{name}"; var channelText = $"#{name}";
if (_protectedChannels.Contains(name))
channelText += "*";
if (_privateChannels.Contains(name))
channelText += "~";
var badge = hasUnread ? $" ({unread})" : ""; var badge = hasUnread ? $" ({unread})" : "";
// Resolve Transparent backgrounds to the view's actual background // Resolve Transparent backgrounds to the view's actual background
+8 -1
View File
@@ -1317,8 +1317,11 @@ public sealed partial class MainWindow : Runnable
/// </summary> /// </summary>
private void RefreshChannelList() private void RefreshChannelList()
{ {
var privateChannels = _channelNames
.Where(n => _channelPublic.TryGetValue(n, out var isPublic) && !isPublic)
.ToHashSet();
_channelListSource.Update(_channelNames, _messageManager.GetUnreadCounts(), _messageManager.CurrentChannel, _channelListSource.Update(_channelNames, _messageManager.GetUnreadCounts(), _messageManager.CurrentChannel,
_channelProtected, _messageManager.MentionChannels); _channelProtected, _messageManager.MentionChannels, privateChannels);
_channelList.Source = _channelListSource; _channelList.Source = _channelListSource;
// Restore selection to current channel // Restore selection to current channel
@@ -1394,6 +1397,10 @@ public sealed partial class MainWindow : Runnable
var text = roleTag.Length > 0 var text = roleTag.Length > 0
? $"{statusIcon} {roleTag} {name}" ? $"{statusIcon} {roleTag} {name}"
: $"{statusIcon} {name}"; : $"{statusIcon} {name}";
// Users connected only via the IRC gateway get a tag — they lack client features
// (encryption, attachments, profiles), which is useful context in conversation.
if (u.IsIrc)
text += " [irc]";
// Fall back to the deterministic per-nick palette so user-list colors // Fall back to the deterministic per-nick palette so user-list colors
// match the same user's messages in chat. // match the same user's messages in chat.
var nameColor = HexColorHelper.ParseHexColor(u.NicknameColor) var nameColor = HexColorHelper.ParseHexColor(u.NicknameColor)
@@ -4,6 +4,12 @@ public static class HubConstants
{ {
public const string ChatHubPath = "/hubs/chat"; public const string ChatHubPath = "/hubs/chat";
public const string DefaultChannel = "general"; public const string DefaultChannel = "general";
/// <summary>
/// Connection-id prefix for IRC gateway connections. The presence tracker uses it to
/// tell IRC-only users apart from native (SignalR) clients.
/// </summary>
public const string IrcConnectionIdPrefix = "irc-";
public const int DefaultHistoryCount = 100; public const int DefaultHistoryCount = 100;
public const int MaxMessageLength = 2000; public const int MaxMessageLength = 2000;
public const int MaxImageSizeBytes = 10 * 1024 * 1024; // 10 MB public const int MaxImageSizeBytes = 10 * 1024 * 1024; // 10 MB
+2 -1
View File
@@ -10,7 +10,8 @@ public record MessageDto(
string ChannelName, string ChannelName,
DateTimeOffset SentAt, DateTimeOffset SentAt,
List<AttachmentDto>? Attachments = null, List<AttachmentDto>? Attachments = null,
List<EmbedDto>? Embeds = null); List<EmbedDto>? Embeds = null,
string? SenderDisplayName = null);
/// <summary> /// <summary>
/// A file attached to a message. <see cref="AsciiPreview"/> holds the color-tag art for /// A file attached to a message. <see cref="AsciiPreview"/> holds the color-tag art for
+2 -1
View File
@@ -30,6 +30,7 @@ public record UserPresenceDto(
string? NicknameColor, string? NicknameColor,
UserStatus Status, UserStatus Status,
string? StatusMessage, string? StatusMessage,
ServerRole Role); ServerRole Role,
bool IsIrc = false);
public record AvatarUploadResponse(string AvatarAscii); public record AvatarUploadResponse(string AvatarAscii);
@@ -1,5 +1,6 @@
using System.Net.Sockets; using System.Net.Sockets;
using System.Text; using System.Text;
using EchoHub.Core.Constants;
namespace EchoHub.Server.Irc; namespace EchoHub.Server.Irc;
@@ -14,7 +15,7 @@ public sealed class IrcClientConnection : IAsyncDisposable
private readonly SemaphoreSlim _writeLock = new(1, 1); private readonly SemaphoreSlim _writeLock = new(1, 1);
// Connection identity // Connection identity
public string ConnectionId { get; } = $"irc-{Guid.NewGuid()}"; public string ConnectionId { get; } = $"{HubConstants.IrcConnectionIdPrefix}{Guid.NewGuid()}";
// Registration state // Registration state
public string? Nickname { get; set; } public string? Nickname { get; set; }
@@ -308,7 +308,8 @@ public class ChannelsController : ControllerBase
sender?.NicknameColor, sender?.NicknameColor,
channelName, channelName,
message.SentAt, message.SentAt,
attachmentDtos); attachmentDtos,
SenderDisplayName: sender?.DisplayName);
await _chatService.BroadcastMessageAsync(channelName, messageDto); await _chatService.BroadcastMessageAsync(channelName, messageDto);
@@ -444,7 +445,8 @@ public class ChannelsController : ControllerBase
sender?.NicknameColor, sender?.NicknameColor,
channelName, channelName,
message.SentAt, message.SentAt,
[new AttachmentDto(AttachmentKind.Image, attachmentUrl, fileName, imageBytes.Length, _encryption.Encrypt(preview))]); [new AttachmentDto(AttachmentKind.Image, attachmentUrl, fileName, imageBytes.Length, _encryption.Encrypt(preview))],
SenderDisplayName: sender?.DisplayName);
await _chatService.BroadcastMessageAsync(channelName, messageDto); await _chatService.BroadcastMessageAsync(channelName, messageDto);
+17 -9
View File
@@ -121,7 +121,8 @@ public class ChatService : IChatService
{ {
presence = new UserPresenceDto( presence = new UserPresenceDto(
user.Username, user.DisplayName, user.NicknameColor, user.Username, user.DisplayName, user.NicknameColor,
user.Status, user.StatusMessage, user.Role); user.Status, user.StatusMessage, user.Role,
_presenceTracker.IsIrcOnly(user.Username));
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -236,7 +237,8 @@ public class ChatService : IChatService
sender?.NicknameColor, sender?.NicknameColor,
channelName, channelName,
message.SentAt, message.SentAt,
Embeds: embeds); Embeds: embeds,
SenderDisplayName: sender?.DisplayName);
await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto)); await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto));
@@ -279,7 +281,8 @@ public class ChatService : IChatService
user.NicknameColor, user.NicknameColor,
status, status,
statusMessage, statusMessage,
user.Role); user.Role,
_presenceTracker.IsIrcOnly(user.Username));
var channels = _presenceTracker.GetChannelsForUser(username); var channels = _presenceTracker.GetChannelsForUser(username);
await BroadcastToAllAsync(b => b.SendUserStatusChangedAsync(channels, presence)); await BroadcastToAllAsync(b => b.SendUserStatusChangedAsync(channels, presence));
@@ -295,16 +298,20 @@ public class ChatService : IChatService
using var scope = _scopeFactory.CreateScope(); using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>(); var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
return await db.Users var users = await db.Users
.Where(u => onlineUsernames.Contains(u.Username) && u.Status != UserStatus.Invisible) .Where(u => onlineUsernames.Contains(u.Username) && u.Status != UserStatus.Invisible)
.Select(u => new UserPresenceDto( .ToListAsync();
// IsIrcOnly comes from the in-memory tracker, so map outside the EF query
return users.Select(u => new UserPresenceDto(
u.Username, u.Username,
u.DisplayName, u.DisplayName,
u.NicknameColor, u.NicknameColor,
u.Status, u.Status,
u.StatusMessage, u.StatusMessage,
u.Role)) u.Role,
.ToListAsync(); _presenceTracker.IsIrcOnly(u.Username)))
.ToList();
} }
public Task BroadcastMessageAsync(string channelName, MessageDto message) public Task BroadcastMessageAsync(string channelName, MessageDto message)
@@ -380,7 +387,7 @@ public class ChatService : IChatService
.Join(db.Users, .Join(db.Users,
m => m.SenderUserId, m => m.SenderUserId,
u => u.Id, u => u.Id,
(m, u) => new { m, u.NicknameColor }) (m, u) => new { m, u.NicknameColor, u.DisplayName })
.ToListAsync(); .ToListAsync();
raw.Reverse(); raw.Reverse();
@@ -448,7 +455,8 @@ public class ChatService : IChatService
channelName, channelName,
x.m.SentAt, x.m.SentAt,
attachments, attachments,
embeds)); embeds,
x.DisplayName));
} }
// Lazily delete the pruned messages (+ their attachment rows) as they're encountered. // Lazily delete the pruned messages (+ their attachment rows) as they're encountered.
@@ -1,4 +1,5 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using EchoHub.Core.Constants;
namespace EchoHub.Server.Services; namespace EchoHub.Server.Services;
@@ -174,6 +175,20 @@ public class PresenceTracker
return _userConnections.TryGetValue(username, out var connections) && connections.Count > 0; return _userConnections.TryGetValue(username, out var connections) && connections.Count > 0;
} }
/// <summary>
/// True when the user is online exclusively through the IRC gateway. A user who also has a
/// native client connected has full features, so they don't count as IRC-only.
/// </summary>
public bool IsIrcOnly(string username)
{
lock (_lock)
{
return _userConnections.TryGetValue(username, out var connections)
&& connections.Count > 0
&& connections.All(c => c.StartsWith(HubConstants.IrcConnectionIdPrefix, StringComparison.Ordinal));
}
}
public int GetOnlineUserCount() public int GetOnlineUserCount()
{ {
return _userConnections.Count; return _userConnections.Count;
+45
View File
@@ -1,3 +1,4 @@
using EchoHub.Core.Constants;
using EchoHub.Server.Services; using EchoHub.Server.Services;
using Xunit; using Xunit;
@@ -65,4 +66,48 @@ public class PresenceTrackerTests
Assert.Contains("general", channels); Assert.Contains("general", channels);
Assert.Contains("random", channels); Assert.Contains("random", channels);
} }
[Fact]
public void IsIrcOnly_AllConnectionsIrc_ReturnsTrue()
{
var tracker = new PresenceTracker();
tracker.UserConnected($"{HubConstants.IrcConnectionIdPrefix}conn1", Guid.NewGuid(), "alice");
Assert.True(tracker.IsIrcOnly("alice"));
}
[Fact]
public void IsIrcOnly_NativeConnection_ReturnsFalse()
{
var tracker = new PresenceTracker();
tracker.UserConnected("conn1", Guid.NewGuid(), "alice");
Assert.False(tracker.IsIrcOnly("alice"));
}
[Fact]
public void IsIrcOnly_MixedConnections_ReturnsFalse()
{
var tracker = new PresenceTracker();
var userId = Guid.NewGuid();
tracker.UserConnected($"{HubConstants.IrcConnectionIdPrefix}conn1", userId, "alice");
tracker.UserConnected("conn2", userId, "alice");
Assert.False(tracker.IsIrcOnly("alice"));
}
[Fact]
public void IsIrcOnly_OfflineUser_ReturnsFalse()
{
var tracker = new PresenceTracker();
Assert.False(tracker.IsIrcOnly("nobody"));
}
[Fact]
public void IsIrcOnly_NativeConnectionDisconnects_BecomesTrue()
{
var tracker = new PresenceTracker();
var userId = Guid.NewGuid();
tracker.UserConnected($"{HubConstants.IrcConnectionIdPrefix}conn1", userId, "alice");
tracker.UserConnected("conn2", userId, "alice");
tracker.UserDisconnected("conn2");
Assert.True(tracker.IsIrcOnly("alice"));
}
} }