mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
feat: enhance IRC support with display name handling, private channel tracking, and connection identification
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user