From b6c01dab155b15d9826f6943a9ebab333a1dfae0 Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 16 Jul 2026 18:22:34 +0200 Subject: [PATCH] feat: enhance IRC support with display name handling, private channel tracking, and connection identification --- .../UI/Chat/ChatMessageManager.cs | 7 ++- .../UI/ListSources/ChannelListSource.cs | 15 +++++-- src/EchoHub.Client/UI/MainWindow.cs | 9 +++- src/EchoHub.Core/Constants/HubConstants.cs | 6 +++ src/EchoHub.Core/DTOs/ChatDtos.cs | 3 +- src/EchoHub.Core/DTOs/ProfileDtos.cs | 3 +- src/EchoHub.Server.Irc/IrcClientConnection.cs | 3 +- .../Controllers/ChannelsController.cs | 6 ++- src/EchoHub.Server/Services/ChatService.cs | 26 +++++++---- .../Services/PresenceTracker.cs | 15 +++++++ src/EchoHub.Tests/PresenceTrackerTests.cs | 45 +++++++++++++++++++ 11 files changed, 118 insertions(+), 20 deletions(-) diff --git a/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs b/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs index 6f9a66a..44ac51e 100644 --- a/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs +++ b/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs @@ -381,6 +381,9 @@ public sealed class ChatMessageManager private List FormatMessage(MessageDto message) { 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) ?? NickColorHelper.GetAttribute(message.SenderUsername); @@ -394,7 +397,7 @@ public sealed class ChatMessageManager var displayContent = EmojiHelper.ReplaceEmoji(message.Content); 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'))); lines.Add(new ChatLine(header)); @@ -413,7 +416,7 @@ public sealed class ChatMessageManager 1 => $"[{attachments[0].Kind.ToString().ToLowerInvariant()}]", _ => $"[{attachments.Count} attachments]", }; - var header = HeaderSegments(time, message.SenderUsername, senderColor); + var header = HeaderSegments(time, senderName, senderColor); header.Add(new(summary, null)); lines.Add(new ChatLine(header)); } diff --git a/src/EchoHub.Client/UI/ListSources/ChannelListSource.cs b/src/EchoHub.Client/UI/ListSources/ChannelListSource.cs index 5079c76..0b4ea34 100644 --- a/src/EchoHub.Client/UI/ListSources/ChannelListSource.cs +++ b/src/EchoHub.Client/UI/ListSources/ChannelListSource.cs @@ -18,6 +18,7 @@ public class ChannelListSource : IListDataSource private readonly Dictionary _unreadCounts = []; private readonly HashSet _protectedChannels = []; private readonly HashSet _mentionChannels = []; + private readonly HashSet _privateChannels = []; private string _activeChannel = string.Empty; 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); public void Update(List channels, Dictionary unread, string activeChannel, - IReadOnlySet? protectedChannels = null, IReadOnlySet? mentionChannels = null) + IReadOnlySet? protectedChannels = null, IReadOnlySet? mentionChannels = null, + IReadOnlySet? privateChannels = null) { _channelNames.Clear(); _channelNames.AddRange(channels); @@ -45,6 +47,9 @@ public class ChannelListSource : IListDataSource _mentionChannels.Clear(); if (mentionChannels is not null) _mentionChannels.UnionWith(mentionChannels); + _privateChannels.Clear(); + if (privateChannels is not null) + _privateChannels.UnionWith(privateChannels); _activeChannel = activeChannel; MaxItemLength = channels.Count > 0 ? channels.Max(c => c.Length + 6) : 0; if (!SuspendCollectionChangedEvent) @@ -67,8 +72,12 @@ public class ChannelListSource : IListDataSource var normalAttr = listView.GetAttributeForRole(VisualRole.Normal); var focusAttr = listView.GetAttributeForRole(VisualRole.Focus); var prefix = isActive ? "> " : " "; - // Trailing * marks password-protected (+k) channels - var channelText = _protectedChannels.Contains(name) ? $"#{name}*" : $"#{name}"; + // Trailing * marks password-protected (+k) channels; ~ marks private (unlisted) ones + var channelText = $"#{name}"; + if (_protectedChannels.Contains(name)) + channelText += "*"; + if (_privateChannels.Contains(name)) + channelText += "~"; var badge = hasUnread ? $" ({unread})" : ""; // Resolve Transparent backgrounds to the view's actual background diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index b5fc39f..94919e6 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -1317,8 +1317,11 @@ public sealed partial class MainWindow : Runnable /// private void RefreshChannelList() { + var privateChannels = _channelNames + .Where(n => _channelPublic.TryGetValue(n, out var isPublic) && !isPublic) + .ToHashSet(); _channelListSource.Update(_channelNames, _messageManager.GetUnreadCounts(), _messageManager.CurrentChannel, - _channelProtected, _messageManager.MentionChannels); + _channelProtected, _messageManager.MentionChannels, privateChannels); _channelList.Source = _channelListSource; // Restore selection to current channel @@ -1394,6 +1397,10 @@ public sealed partial class MainWindow : Runnable var text = roleTag.Length > 0 ? $"{statusIcon} {roleTag} {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 // match the same user's messages in chat. var nameColor = HexColorHelper.ParseHexColor(u.NicknameColor) diff --git a/src/EchoHub.Core/Constants/HubConstants.cs b/src/EchoHub.Core/Constants/HubConstants.cs index a7ead47..101935c 100644 --- a/src/EchoHub.Core/Constants/HubConstants.cs +++ b/src/EchoHub.Core/Constants/HubConstants.cs @@ -4,6 +4,12 @@ public static class HubConstants { public const string ChatHubPath = "/hubs/chat"; public const string DefaultChannel = "general"; + + /// + /// Connection-id prefix for IRC gateway connections. The presence tracker uses it to + /// tell IRC-only users apart from native (SignalR) clients. + /// + public const string IrcConnectionIdPrefix = "irc-"; public const int DefaultHistoryCount = 100; public const int MaxMessageLength = 2000; public const int MaxImageSizeBytes = 10 * 1024 * 1024; // 10 MB diff --git a/src/EchoHub.Core/DTOs/ChatDtos.cs b/src/EchoHub.Core/DTOs/ChatDtos.cs index 34d1291..13b4a06 100644 --- a/src/EchoHub.Core/DTOs/ChatDtos.cs +++ b/src/EchoHub.Core/DTOs/ChatDtos.cs @@ -10,7 +10,8 @@ public record MessageDto( string ChannelName, DateTimeOffset SentAt, List? Attachments = null, - List? Embeds = null); + List? Embeds = null, + string? SenderDisplayName = null); /// /// A file attached to a message. holds the color-tag art for diff --git a/src/EchoHub.Core/DTOs/ProfileDtos.cs b/src/EchoHub.Core/DTOs/ProfileDtos.cs index b4b28e7..e75e252 100644 --- a/src/EchoHub.Core/DTOs/ProfileDtos.cs +++ b/src/EchoHub.Core/DTOs/ProfileDtos.cs @@ -30,6 +30,7 @@ public record UserPresenceDto( string? NicknameColor, UserStatus Status, string? StatusMessage, - ServerRole Role); + ServerRole Role, + bool IsIrc = false); public record AvatarUploadResponse(string AvatarAscii); diff --git a/src/EchoHub.Server.Irc/IrcClientConnection.cs b/src/EchoHub.Server.Irc/IrcClientConnection.cs index 3b1b8e4..2ec2ff1 100644 --- a/src/EchoHub.Server.Irc/IrcClientConnection.cs +++ b/src/EchoHub.Server.Irc/IrcClientConnection.cs @@ -1,5 +1,6 @@ using System.Net.Sockets; using System.Text; +using EchoHub.Core.Constants; namespace EchoHub.Server.Irc; @@ -14,7 +15,7 @@ public sealed class IrcClientConnection : IAsyncDisposable private readonly SemaphoreSlim _writeLock = new(1, 1); // Connection identity - public string ConnectionId { get; } = $"irc-{Guid.NewGuid()}"; + public string ConnectionId { get; } = $"{HubConstants.IrcConnectionIdPrefix}{Guid.NewGuid()}"; // Registration state public string? Nickname { get; set; } diff --git a/src/EchoHub.Server/Controllers/ChannelsController.cs b/src/EchoHub.Server/Controllers/ChannelsController.cs index 97dbf89..1889e61 100644 --- a/src/EchoHub.Server/Controllers/ChannelsController.cs +++ b/src/EchoHub.Server/Controllers/ChannelsController.cs @@ -308,7 +308,8 @@ public class ChannelsController : ControllerBase sender?.NicknameColor, channelName, message.SentAt, - attachmentDtos); + attachmentDtos, + SenderDisplayName: sender?.DisplayName); await _chatService.BroadcastMessageAsync(channelName, messageDto); @@ -444,7 +445,8 @@ public class ChannelsController : ControllerBase sender?.NicknameColor, channelName, 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); diff --git a/src/EchoHub.Server/Services/ChatService.cs b/src/EchoHub.Server/Services/ChatService.cs index f5509ad..889b345 100644 --- a/src/EchoHub.Server/Services/ChatService.cs +++ b/src/EchoHub.Server/Services/ChatService.cs @@ -121,7 +121,8 @@ public class ChatService : IChatService { presence = new UserPresenceDto( 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) @@ -236,7 +237,8 @@ public class ChatService : IChatService sender?.NicknameColor, channelName, message.SentAt, - Embeds: embeds); + Embeds: embeds, + SenderDisplayName: sender?.DisplayName); await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto)); @@ -279,7 +281,8 @@ public class ChatService : IChatService user.NicknameColor, status, statusMessage, - user.Role); + user.Role, + _presenceTracker.IsIrcOnly(user.Username)); var channels = _presenceTracker.GetChannelsForUser(username); await BroadcastToAllAsync(b => b.SendUserStatusChangedAsync(channels, presence)); @@ -295,16 +298,20 @@ public class ChatService : IChatService using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - return await db.Users + var users = await db.Users .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.DisplayName, u.NicknameColor, u.Status, u.StatusMessage, - u.Role)) - .ToListAsync(); + u.Role, + _presenceTracker.IsIrcOnly(u.Username))) + .ToList(); } public Task BroadcastMessageAsync(string channelName, MessageDto message) @@ -380,7 +387,7 @@ public class ChatService : IChatService .Join(db.Users, m => m.SenderUserId, u => u.Id, - (m, u) => new { m, u.NicknameColor }) + (m, u) => new { m, u.NicknameColor, u.DisplayName }) .ToListAsync(); raw.Reverse(); @@ -448,7 +455,8 @@ public class ChatService : IChatService channelName, x.m.SentAt, attachments, - embeds)); + embeds, + x.DisplayName)); } // Lazily delete the pruned messages (+ their attachment rows) as they're encountered. diff --git a/src/EchoHub.Server/Services/PresenceTracker.cs b/src/EchoHub.Server/Services/PresenceTracker.cs index b00e38a..ce5c7b8 100644 --- a/src/EchoHub.Server/Services/PresenceTracker.cs +++ b/src/EchoHub.Server/Services/PresenceTracker.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using EchoHub.Core.Constants; namespace EchoHub.Server.Services; @@ -174,6 +175,20 @@ public class PresenceTracker return _userConnections.TryGetValue(username, out var connections) && connections.Count > 0; } + /// + /// 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. + /// + 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() { return _userConnections.Count; diff --git a/src/EchoHub.Tests/PresenceTrackerTests.cs b/src/EchoHub.Tests/PresenceTrackerTests.cs index de96738..b8c580f 100644 --- a/src/EchoHub.Tests/PresenceTrackerTests.cs +++ b/src/EchoHub.Tests/PresenceTrackerTests.cs @@ -1,3 +1,4 @@ +using EchoHub.Core.Constants; using EchoHub.Server.Services; using Xunit; @@ -65,4 +66,48 @@ public class PresenceTrackerTests Assert.Contains("general", 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")); + } }