diff --git a/docs/changelog/v0.2.8.md b/docs/changelog/v0.2.8.md index d4c7a8b..8cf62c3 100644 --- a/docs/changelog/v0.2.8.md +++ b/docs/changelog/v0.2.8.md @@ -7,6 +7,14 @@ - Fix connection failure cleanup — `ConnectionManager.ConnectAsync` now properly disposes `ApiClient` and `EchoHubConnection` on any failure path (previously only cleaned up on saved-token auth failures) - Fix IRC gateway sending UTF-8 BOM on first message, breaking CAP negotiation and SASL auth for all clients - Handle `AUTHENTICATE *` (SASL abort) instead of crashing on invalid base64 +- Fix userlist not refreshing when creating a new channel — now fetches online users after channel creation +- Fix unmute timer not working — add `MuteExpirationService` background job that proactively unmutes users when their timed mute expires (previously only checked on message send) +- Fix missing space between mod/admin role icon and username in the userlist +- Fix invisible users still visible in the userlist — `GetOnlineUsersAsync` now filters invisible users; server skips `UserJoined` broadcast for invisible users +- Fix invisible→online transition — user reappears in cached userlist when switching from invisible back to a visible status +- Fix thread safety — `_channelUsers` presence cache now protected by `Lock` to prevent races between SignalR events and background fetches +- Fix `@mention` regex matching email addresses and `#channel` regex matching hex colors / issue numbers — both now use lookbehind and letter-requirement guards +- Fix `ParseThemeColor` accepting non-hex characters — now validates `[0-9a-fA-F]` digits ## New Features @@ -15,17 +23,31 @@ - Auto-updater rollback — pre-update backup created automatically before each update; restore via File > Rollback menu or `--rollback` CLI flag - Update failure recovery — if an update fails mid-extraction, offers to restore from the backup immediately - Defensive Unix permission check — verify execute permission on startup after auto-update (defense-in-depth) +- Clickable usernames — press Enter on a username in the userlist or message sender to view their profile +- Clickable @mentions — press Enter on a message containing `@username` to open that user's profile +- Clickable #channels — press Enter on a message containing `#channel` to join/switch to that channel; `#channel` references are now highlighted in chat +- Embed theme colors — embed vertical border line now uses the source site's `theme-color` meta tag when available +- Stateful userlist — user presence is cached per channel and updated incrementally via SignalR events instead of re-fetching the full list on every join/leave/status change + +## Security + +- Restrict file auto-open — only safe file types (video, PDF, text) are opened with the system default app; all other files are downloaded to temp without executing (prevents script execution via `.bat`, `.exe`, etc.) ## Refactoring - Extract `IUserService`/`UserService` — consolidate user registration, authentication, and profile management into a dedicated service, eliminating duplicated logic between `AuthController` and `ChatService` - IRC gateway now checks ban status during authentication (previously skipped) +- EchoHubSpace directory updates — server now only sends user count when it actually changes instead of every 30 seconds ## Distribution - Add Chocolatey package — `choco install echohub` for Windows users, auto-published from CI on each release - Add Linux/macOS install script — `curl -sSfL .../install.sh | sh` with automatic OS/arch detection +## Documentation + +- Add Flows section — Mermaid sequence diagrams documenting all major request/event flows (auth, connection, messaging, channels, moderation, file upload, link embeds, server directory) with inline code references + ## CI - Add Docker workflow — builds and pushes multi-arch (`amd64`/`arm64`) server image to GHCR on release diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs index 8bd4957..5c1f52d 100644 --- a/src/EchoHub.Client/AppOrchestrator.cs +++ b/src/EchoHub.Client/AppOrchestrator.cs @@ -28,6 +28,8 @@ public sealed class AppOrchestrator : IDisposable private readonly AudioPlaybackService _audioPlayback = new(); private readonly UpdateChecker _updateService; private readonly ConnectionManager _conn = new(); + private readonly Dictionary> _channelUsers = new(StringComparer.OrdinalIgnoreCase); + private readonly Lock _channelUsersLock = new(); private ClientConfig _config; private readonly UserSession _session = new(); @@ -87,6 +89,8 @@ public sealed class AppOrchestrator : IDisposable _mainWindow.OnFileDownloadRequested += HandleFileDownloadRequested; _mainWindow.OnCheckForUpdatesRequested += HandleCheckForUpdatesRequested; _mainWindow.OnRollbackRequested += HandleRollbackRequested; + _mainWindow.OnUserProfileRequested += HandleViewProfile; + _mainWindow.OnChannelJoinRequested += HandleChannelJoinFromMessage; } // ── Command Handler Wiring ───────────────────────────────────────────── @@ -381,17 +385,48 @@ public sealed class AppOrchestrator : IDisposable } }; - _conn.UserJoined += (channelName, username) => + _conn.UserJoined += (channelName, username, presence) => { InvokeUI(() => _messageManager.AddSystemMessage(channelName, $"{username} joined the channel")); - if (channelName == _mainWindow.CurrentChannel) + + List? snapshot = null; + lock (_channelUsersLock) + { + if (presence is not null && _channelUsers.TryGetValue(channelName, out var users)) + { + if (!users.Any(u => u.Username.Equals(presence.Username, StringComparison.OrdinalIgnoreCase))) + users.Add(presence); + + if (channelName.Equals(_mainWindow.CurrentChannel, StringComparison.OrdinalIgnoreCase)) + snapshot = [.. users]; + } + } + + if (snapshot is not null) + InvokeUI(() => _mainWindow.UpdateOnlineUsers(snapshot)); + else if (channelName.Equals(_mainWindow.CurrentChannel, StringComparison.OrdinalIgnoreCase)) FetchAndUpdateOnlineUsers(); }; _conn.UserLeft += (channelName, username) => { InvokeUI(() => _messageManager.AddSystemMessage(channelName, $"{username} left the channel")); - if (channelName == _mainWindow.CurrentChannel) + + List? snapshot = null; + lock (_channelUsersLock) + { + if (_channelUsers.TryGetValue(channelName, out var users)) + { + users.RemoveAll(u => u.Username.Equals(username, StringComparison.OrdinalIgnoreCase)); + + if (channelName.Equals(_mainWindow.CurrentChannel, StringComparison.OrdinalIgnoreCase)) + snapshot = [.. users]; + } + } + + if (snapshot is not null) + InvokeUI(() => _mainWindow.UpdateOnlineUsers(snapshot)); + else if (channelName.Equals(_mainWindow.CurrentChannel, StringComparison.OrdinalIgnoreCase)) FetchAndUpdateOnlineUsers(); }; @@ -407,18 +442,75 @@ public sealed class AppOrchestrator : IDisposable foreach (var channelName in _mainWindow.GetChannelNames()) _messageManager.AddStatusMessage(channelName, displayName, statusText); }); - FetchAndUpdateOnlineUsers(); + + // Update presence in all cached channel lists + List? snapshot = null; + lock (_channelUsersLock) + { + foreach (var (channel, users) in _channelUsers) + { + var idx = users.FindIndex(u => u.Username.Equals(presence.Username, StringComparison.OrdinalIgnoreCase)); + if (idx >= 0) + { + if (presence.Status == UserStatus.Invisible) + users.RemoveAt(idx); + else + users[idx] = presence; + } + else if (presence.Status != UserStatus.Invisible) + { + // User came back from invisible — re-add them + users.Add(presence); + } + } + + var currentChannel = _mainWindow.CurrentChannel; + if (!string.IsNullOrEmpty(currentChannel) && _channelUsers.TryGetValue(currentChannel, out var currentUsers)) + snapshot = [.. currentUsers]; + } + + if (snapshot is not null) + InvokeUI(() => _mainWindow.UpdateOnlineUsers(snapshot)); }; _conn.UserKicked += (channelName, username, reason) => { var reasonText = reason is not null ? $" ({reason})" : ""; InvokeUI(() => _messageManager.AddSystemMessage(channelName, $"{username} was kicked{reasonText}")); + + List? snapshot = null; + lock (_channelUsersLock) + { + if (_channelUsers.TryGetValue(channelName, out var users)) + { + users.RemoveAll(u => u.Username.Equals(username, StringComparison.OrdinalIgnoreCase)); + if (channelName.Equals(_mainWindow.CurrentChannel, StringComparison.OrdinalIgnoreCase)) + snapshot = [.. users]; + } + } + + if (snapshot is not null) + InvokeUI(() => _mainWindow.UpdateOnlineUsers(snapshot)); }; _conn.UserBanned += (username, reason) => { var reasonText = reason is not null ? $" ({reason})" : ""; + + List? snapshot = null; + lock (_channelUsersLock) + { + // Remove banned user from all cached channel lists + foreach (var (channel, users) in _channelUsers) + { + users.RemoveAll(u => u.Username.Equals(username, StringComparison.OrdinalIgnoreCase)); + } + + var currentChannel = _mainWindow.CurrentChannel; + if (!string.IsNullOrEmpty(currentChannel) && _channelUsers.TryGetValue(currentChannel, out var currentUsers)) + snapshot = [.. currentUsers]; + } + InvokeUI(() => { if (!username.Equals(_session.Username, StringComparison.OrdinalIgnoreCase)) @@ -426,6 +518,9 @@ public sealed class AppOrchestrator : IDisposable var channel = _mainWindow.CurrentChannel; if (!string.IsNullOrEmpty(channel)) _messageManager.AddSystemMessage(channel, $"{username} was banned{reasonText}"); + + if (snapshot is not null) + _mainWindow.UpdateOnlineUsers(snapshot); } }); }; @@ -469,6 +564,7 @@ public sealed class AppOrchestrator : IDisposable _conn.Reconnected += () => { + lock (_channelUsersLock) _channelUsers.Clear(); RunAsync( async () => await _conn.RejoinChannelsAsync(), "Failed to rejoin channels after reconnect"); @@ -535,6 +631,7 @@ public sealed class AppOrchestrator : IDisposable private void HandleDisconnect() { Log.Information("Disconnecting from server"); + lock (_channelUsersLock) _channelUsers.Clear(); RunAsync(async () => { @@ -623,6 +720,19 @@ public sealed class AppOrchestrator : IDisposable }, "Failed to join channel"); } + private void HandleChannelJoinFromMessage(string channelName) + { + if (!_conn.IsConnected) return; + + InvokeUI(() => + { + _mainWindow.EnsureChannelInList(channelName); + _mainWindow.SwitchToChannel(channelName); + }); + + HandleChannelSelected(channelName); + } + private void HandleProfileRequested() { HandleViewProfile(null); @@ -826,6 +936,8 @@ public sealed class AppOrchestrator : IDisposable if (history.Count > 0) _messageManager.LoadHistory(channel.Name, history); }); + + FetchAndUpdateOnlineUsers(); }, "Failed to create channel"); } @@ -881,6 +993,16 @@ public sealed class AppOrchestrator : IDisposable }, "Failed to play audio"); } + /// + /// File extensions considered safe to open with the system default application. + /// Everything else is downloaded only — never auto-opened via UseShellExecute. + /// + private static readonly HashSet SafeOpenExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".mp4", ".webm", ".mkv", ".avi", ".mov", // video + ".pdf", ".txt", ".csv", ".json", ".xml", // documents + }; + private void HandleFileDownloadRequested(string attachmentUrl, string fileName) { if (!_conn.IsAuthenticated) return; @@ -890,14 +1012,22 @@ public sealed class AppOrchestrator : IDisposable InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}...")); var tempPath = await _conn.Api!.DownloadFileToTempAsync(attachmentUrl, fileName); - try + var ext = Path.GetExtension(fileName); + if (SafeOpenExtensions.Contains(ext)) { - var psi = new System.Diagnostics.ProcessStartInfo(tempPath) { UseShellExecute = true }; - System.Diagnostics.Process.Start(psi); + try + { + var psi = new System.Diagnostics.ProcessStartInfo(tempPath) { UseShellExecute = true }; + System.Diagnostics.Process.Start(psi); + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to open file with default app: {Path}", tempPath); + InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloaded to: {tempPath}")); + } } - catch (Exception ex) + else { - Log.Warning(ex, "Failed to open file with default app: {Path}", tempPath); InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloaded to: {tempPath}")); } }, "Failed to download file"); @@ -946,6 +1076,7 @@ public sealed class AppOrchestrator : IDisposable try { var users = await _conn.GetOnlineUsersAsync(channel); + lock (_channelUsersLock) _channelUsers[channel] = users; InvokeUI(() => _mainWindow.UpdateOnlineUsers(users)); } catch (Exception ex) diff --git a/src/EchoHub.Client/Services/ConnectionManager.cs b/src/EchoHub.Client/Services/ConnectionManager.cs index 6a45d5f..ebb4285 100644 --- a/src/EchoHub.Client/Services/ConnectionManager.cs +++ b/src/EchoHub.Client/Services/ConnectionManager.cs @@ -35,7 +35,7 @@ internal sealed class ConnectionManager : IAsyncDisposable // ── Events (forwarded from SignalR) ─────────────────────────────────── public event Action? MessageReceived; - public event Action? UserJoined; + public event Action? UserJoined; public event Action? UserLeft; public event Action? UserStatusChanged; public event Action? UserKicked; @@ -234,7 +234,7 @@ internal sealed class ConnectionManager : IAsyncDisposable private void WireConnectionEvents(EchoHubConnection connection) { connection.OnMessageReceived += msg => MessageReceived?.Invoke(msg); - connection.OnUserJoined += (ch, user) => UserJoined?.Invoke(ch, user); + connection.OnUserJoined += (ch, user, presence) => UserJoined?.Invoke(ch, user, presence); connection.OnUserLeft += (ch, user) => UserLeft?.Invoke(ch, user); connection.OnUserStatusChanged += p => UserStatusChanged?.Invoke(p); connection.OnUserKicked += (ch, user, reason) => UserKicked?.Invoke(ch, user, reason); diff --git a/src/EchoHub.Client/Services/EchoHubConnection.cs b/src/EchoHub.Client/Services/EchoHubConnection.cs index 6e6a17b..d59ec6b 100644 --- a/src/EchoHub.Client/Services/EchoHubConnection.cs +++ b/src/EchoHub.Client/Services/EchoHubConnection.cs @@ -11,7 +11,7 @@ public sealed class EchoHubConnection : IAsyncDisposable private readonly ClientEncryptionService _encryption; public event Action? OnMessageReceived; - public event Action? OnUserJoined; + public event Action? OnUserJoined; public event Action? OnUserLeft; public event Action? OnChannelUpdated; public event Action? OnUserStatusChanged; @@ -70,9 +70,9 @@ public sealed class EchoHubConnection : IAsyncDisposable OnMessageReceived?.Invoke(decrypted); }); - _connection.On(nameof(Core.Contracts.IEchoHubClient.UserJoined), (channelName, username) => + _connection.On(nameof(Core.Contracts.IEchoHubClient.UserJoined), (channelName, username, presence) => { - OnUserJoined?.Invoke(channelName, username); + OnUserJoined?.Invoke(channelName, username, presence); }); _connection.On(nameof(Core.Contracts.IEchoHubClient.UserLeft), (channelName, username) => diff --git a/src/EchoHub.Client/UI/Chat/ChatColors.cs b/src/EchoHub.Client/UI/Chat/ChatColors.cs index aeeb2ab..f6fc1ec 100644 --- a/src/EchoHub.Client/UI/Chat/ChatColors.cs +++ b/src/EchoHub.Client/UI/Chat/ChatColors.cs @@ -13,6 +13,7 @@ public static partial class ChatColors public static readonly Attribute SystemAttr = new(new Color(0, 180, 180), Color.None); public static readonly Attribute MentionHighlightAttr = new(Color.White, new Color(80, 40, 0)); public static readonly Attribute MentionTextAttr = new(new Color(255, 180, 50), Color.None); + public static readonly Attribute ChannelRefAttr = new(new Color(100, 200, 255), Color.None); public static readonly Attribute EmbedBorderAttr = new(new Color(91, 155, 213), Color.None); public static readonly Attribute EmbedTitleAttr = new(Color.White, Color.None); public static readonly Attribute EmbedDescAttr = new(new Color(160, 160, 160), Color.None); @@ -21,8 +22,8 @@ public static partial class ChatColors public static readonly Attribute FileAttr = new(new Color(100, 180, 255), Color.None); /// - /// Split text around @mentions, giving each @word the MentionTextAttr accent color. - /// Non-mention text uses the provided default color. + /// Split text around @mentions and #channels, giving each the appropriate accent color. + /// Non-special text uses the provided default color. /// public static List SplitMentions(string text, Attribute? defaultColor = null) { @@ -38,12 +39,40 @@ public static partial class ChatColors lastIndex = match.Index + match.Length; } - if (lastIndex < text.Length) - segments.Add(new ChatSegment(text[lastIndex..], defaultColor)); + // Second pass: highlight #channels in non-mention segments + var mentionSegments = segments; + segments = []; + foreach (var seg in mentionSegments) + { + if (seg.Color != null && seg.Color != defaultColor) + { + // Already colored (mention) — keep as-is + segments.Add(seg); + continue; + } + + int segLast = 0; + foreach (Match match in ChannelRefRegex().Matches(seg.Text)) + { + if (match.Index > segLast) + segments.Add(new ChatSegment(seg.Text[segLast..match.Index], defaultColor)); + + segments.Add(new ChatSegment(match.Value, ChannelRefAttr)); + segLast = match.Index + match.Length; + } + + if (segLast < seg.Text.Length) + segments.Add(new ChatSegment(seg.Text[segLast..], defaultColor)); + } return segments; } - [GeneratedRegex(@"@[\w-]+")] + // @mention — not preceded by a word char (avoids emails) + [GeneratedRegex(@"(?Number of spaces to prepend on continuation lines when this line is word-wrapped. public int ContinuationIndent { get; set; } @@ -118,13 +119,14 @@ public partial class ChatLine if (results.Count == 0) return [this]; - // Propagate attachment/type metadata to all wrapped lines so they remain clickable + // Propagate metadata to all wrapped lines so they remain clickable foreach (var wrapped in results) { wrapped.AttachmentUrl = AttachmentUrl; wrapped.AttachmentFileName = AttachmentFileName; wrapped.Type = Type; wrapped.MessageId = MessageId; + wrapped.SenderUsername = SenderUsername; } return results; diff --git a/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs b/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs index 7e921ed..440b57e 100644 --- a/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs +++ b/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs @@ -272,7 +272,10 @@ public sealed class ChatMessageManager } foreach (var line in lines) + { line.MessageId = message.Id; + line.SenderUsername = message.SenderUsername; + } if (!string.IsNullOrEmpty(_currentUser) && message.Type == MessageType.Text) { @@ -329,18 +332,20 @@ public sealed class ChatMessageManager int textWidth = chatWidth - indentCols - borderCols; if (textWidth < 20) textWidth = 20; + var borderAttr = HexColorHelper.ParseHexColor(embed.ThemeColor) ?? ChatColors.EmbedBorderAttr; + void AddTextLine(string text, Attribute? color) { lines.Add(new ChatLine( [ new ChatSegment(indent, null), - new ChatSegment(border, ChatColors.EmbedBorderAttr), + new ChatSegment(border, borderAttr), new ChatSegment(text, color) ])); } if (!string.IsNullOrWhiteSpace(embed.SiteName)) - AddTextLine(embed.SiteName, ChatColors.EmbedBorderAttr); + AddTextLine(embed.SiteName, borderAttr); if (!string.IsNullOrWhiteSpace(embed.Title)) { diff --git a/src/EchoHub.Client/UI/ListSources/UserListSource.cs b/src/EchoHub.Client/UI/ListSources/UserListSource.cs index 50075b2..55010e9 100644 --- a/src/EchoHub.Client/UI/ListSources/UserListSource.cs +++ b/src/EchoHub.Client/UI/ListSources/UserListSource.cs @@ -12,14 +12,14 @@ namespace EchoHub.Client.UI.ListSources; /// public class UserListSource : IListDataSource { - private readonly List<(string Text, Attribute? NameColor)> _users = []; + private readonly List<(string Text, Attribute? NameColor, string Username)> _users = []; public event NotifyCollectionChangedEventHandler? CollectionChanged; public int Count => _users.Count; public int MaxItemLength { get; private set; } public bool SuspendCollectionChangedEvent { get; set; } - public void Update(List<(string Text, Attribute? NameColor)> users) + public void Update(List<(string Text, Attribute? NameColor, string Username)> users) { _users.Clear(); _users.AddRange(users); @@ -28,15 +28,18 @@ public class UserListSource : IListDataSource CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); } + public string? GetUsername(int index) => + index >= 0 && index < _users.Count ? _users[index].Username : null; + public bool IsMarked(int item) => false; public void SetMark(int item, bool value) { } - public IList ToList() => _users.Select(u => u.Text).ToList(); + public IList ToList() => _users.Select(u => (object)u.Text).ToList(); public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0) { listView.Move(Math.Max(col - viewportX, 0), row); - var (text, nameColor) = _users[item]; + var (text, nameColor, _) = _users[item]; var normalAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal); // Find where the name starts (after status icon + space + optional role badge) diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index 45a6a18..1904d6d 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -1,3 +1,4 @@ +using System.Text.RegularExpressions; using EchoHub.Client.Services; using EchoHub.Client.Themes; using EchoHub.Client.UI.Chat; @@ -19,7 +20,7 @@ namespace EchoHub.Client.UI; /// /// Main Terminal.Gui window for the EchoHub chat client. /// -public sealed class MainWindow : Runnable +public sealed partial class MainWindow : Runnable { private readonly IApplication _app; private readonly ListView _channelList; @@ -140,6 +141,16 @@ public sealed class MainWindow : Runnable /// public event Action? OnFileDownloadRequested; + /// + /// Fired when the user activates a username (in userlist or message). Parameter is the username. + /// + public event Action? OnUserProfileRequested; + + /// + /// Fired when the user activates a #channel reference in a message. Parameter is the channel name. + /// + public event Action? OnChannelJoinRequested; + public MainWindow(IApplication app, ChatMessageManager messageManager) { _app = app; @@ -250,6 +261,7 @@ public sealed class MainWindow : Runnable }; _usersListSource = new UserListSource(); _usersList.Source = _usersListSource; + _usersList.Accepting += OnUsersListAccepting; _usersFrame.Add(_usersList); Add(_usersFrame); @@ -410,17 +422,66 @@ public sealed class MainWindow : Runnable return; var line = source.GetLine(index.Value); - if (line?.AttachmentUrl is null || line.AttachmentFileName is null) - return; + if (line is null) return; - if (line.Type == MessageType.Audio) + // Audio/file attachments take priority + if (line.AttachmentUrl is not null && line.AttachmentFileName is not null) { - OnAudioPlayRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName); + if (line.Type == MessageType.Audio) + { + OnAudioPlayRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName); + e.Handled = true; + return; + } + + if (line.Type == MessageType.File) + { + OnFileDownloadRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName); + e.Handled = true; + return; + } + } + + var lineText = line.ToString(); + + // Check for @mention — open mentioned user's profile + // Negative lookbehind prevents matching emails (user@domain) + var mentionMatch = ClickMentionRegex().Match(lineText); + if (mentionMatch.Success) + { + OnUserProfileRequested?.Invoke(mentionMatch.Groups[1].Value); + e.Handled = true; + return; + } + + // Check for #channel — join/switch to that channel + // Require at least one letter to avoid matching hex colors (#ff0000) or issue numbers (#123) + var channelMatch = ClickChannelRegex().Match(lineText); + if (channelMatch.Success) + { + OnChannelJoinRequested?.Invoke(channelMatch.Groups[1].Value); + e.Handled = true; + return; + } + + // Default: open sender's profile + if (line.SenderUsername is not null) + { + OnUserProfileRequested?.Invoke(line.SenderUsername); e.Handled = true; } - else if (line.Type == MessageType.File) + } + + private void OnUsersListAccepting(object? sender, CommandEventArgs e) + { + var index = _usersList.SelectedItem; + if (!index.HasValue || index.Value < 0 || index.Value >= _usersListSource.Count) + return; + + var username = _usersListSource.GetUsername(index.Value); + if (username is not null) { - OnFileDownloadRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName); + OnUserProfileRequested?.Invoke(username); e.Handled = true; } } @@ -862,14 +923,14 @@ public sealed class MainWindow : Runnable var name = u.DisplayName ?? u.Username; var roleTag = u.Role switch { - ServerRole.Owner => "\u2605", // ★ - ServerRole.Admin => "\u2666", // ♦ - ServerRole.Mod => "\u2740", // ❀ + ServerRole.Owner => "\u2605 ", // ★ + ServerRole.Admin => "\u2666 ", // ♦ + ServerRole.Mod => "\u2740 ", // ❀ _ => "" }; var text = $"{statusIcon} {roleTag}{name}"; var nameColor = HexColorHelper.ParseHexColor(u.NicknameColor); - return (text, nameColor); + return (text, nameColor, u.Username); }).ToList(); _usersListSource.Update(displayItems); @@ -877,4 +938,11 @@ public sealed class MainWindow : Runnable _usersFrame.Title = $"Users ({users.Count})"; } + // @mention — not preceded by a word char (avoids emails) + [GeneratedRegex(@"(? channelNames, UserPresenceDto presence); diff --git a/src/EchoHub.Core/Contracts/IEchoHubClient.cs b/src/EchoHub.Core/Contracts/IEchoHubClient.cs index 242a66e..87e6cf0 100644 --- a/src/EchoHub.Core/Contracts/IEchoHubClient.cs +++ b/src/EchoHub.Core/Contracts/IEchoHubClient.cs @@ -8,7 +8,7 @@ namespace EchoHub.Core.Contracts; public interface IEchoHubClient { Task ReceiveMessage(MessageDto message); - Task UserJoined(string channelName, string username); + Task UserJoined(string channelName, string username, UserPresenceDto? presence); Task UserLeft(string channelName, string username); Task ChannelUpdated(ChannelDto channel); Task UserStatusChanged(UserPresenceDto presence); diff --git a/src/EchoHub.Core/DTOs/ChatDtos.cs b/src/EchoHub.Core/DTOs/ChatDtos.cs index ce742d9..32cd71f 100644 --- a/src/EchoHub.Core/DTOs/ChatDtos.cs +++ b/src/EchoHub.Core/DTOs/ChatDtos.cs @@ -44,4 +44,5 @@ public record EmbedDto( string? Title, string? Description, string? ImageAscii, - string Url); + string Url, + string? ThemeColor = null); diff --git a/src/EchoHub.Server.Irc/IrcBroadcaster.cs b/src/EchoHub.Server.Irc/IrcBroadcaster.cs index 058cd79..07b41e4 100644 --- a/src/EchoHub.Server.Irc/IrcBroadcaster.cs +++ b/src/EchoHub.Server.Irc/IrcBroadcaster.cs @@ -31,7 +31,7 @@ public class IrcBroadcaster : IChatBroadcaster } } - public async Task SendUserJoinedAsync(string channelName, string username, string? excludeConnectionId = null) + public async Task SendUserJoinedAsync(string channelName, string username, UserPresenceDto? presence, string? excludeConnectionId = null) { foreach (var conn in _gateway.GetConnectionsInChannel(channelName)) { diff --git a/src/EchoHub.Server/Program.cs b/src/EchoHub.Server/Program.cs index f1ac5fb..b6fde78 100644 --- a/src/EchoHub.Server/Program.cs +++ b/src/EchoHub.Server/Program.cs @@ -109,6 +109,7 @@ while (true) builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); + builder.Services.AddHostedService(); // ── Encryption ───────────────────────────────────────────────────── builder.Services.AddSingleton(); diff --git a/src/EchoHub.Server/Services/ChatService.cs b/src/EchoHub.Server/Services/ChatService.cs index 0f41271..cf7bb17 100644 --- a/src/EchoHub.Server/Services/ChatService.cs +++ b/src/EchoHub.Server/Services/ChatService.cs @@ -107,7 +107,31 @@ public class ChatService : IChatService if (isNewJoin) { - await BroadcastToAllAsync(b => b.SendUserJoinedAsync(channelName, username, connectionId)); + // Fetch presence data so clients can update their lists incrementally + UserPresenceDto? presence = null; + try + { + using var presenceScope = _scopeFactory.CreateScope(); + var presenceDb = presenceScope.ServiceProvider.GetRequiredService(); + var user = await presenceDb.Users.FindAsync(userId); + if (user is not null) + { + presence = new UserPresenceDto( + user.Username, user.DisplayName, user.NicknameColor, + user.Status, user.StatusMessage, user.Role); + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to fetch presence for {User} on join", username); + } + + // Don't broadcast join for invisible users — they still get history but stay hidden + if (presence is null || presence.Status != UserStatus.Invisible) + { + await BroadcastToAllAsync(b => b.SendUserJoinedAsync(channelName, username, presence, connectionId)); + } + _logger.LogInformation("{User} joined channel '{Channel}'", username, channelName); } @@ -272,7 +296,7 @@ public class ChatService : IChatService var db = scope.ServiceProvider.GetRequiredService(); return await db.Users - .Where(u => onlineUsernames.Contains(u.Username)) + .Where(u => onlineUsernames.Contains(u.Username) && u.Status != UserStatus.Invisible) .Select(u => new UserPresenceDto( u.Username, u.DisplayName, diff --git a/src/EchoHub.Server/Services/LinkEmbedService.cs b/src/EchoHub.Server/Services/LinkEmbedService.cs index e424730..ca87838 100644 --- a/src/EchoHub.Server/Services/LinkEmbedService.cs +++ b/src/EchoHub.Server/Services/LinkEmbedService.cs @@ -108,9 +108,47 @@ public partial class LinkEmbedService siteName = siteName is not null ? WebUtility.HtmlDecode(siteName) : null; description = description is not null ? WebUtility.HtmlDecode(description) : null; - return new EmbedDto(siteName, title, description, null, url); + // Extract theme-color meta tag for embed border color + var themeColor = ParseThemeColor(html); + + return new EmbedDto(siteName, title, description, null, url, themeColor); } + private static string? ParseThemeColor(string html) + { + // ThemeColorRegex: group 3 = color value + var match = ThemeColorRegex().Match(html); + var color = match.Success ? match.Groups[3].Value.Trim() : null; + + if (color is null) + { + // ThemeColorReversedRegex: group 2 = color value + match = ThemeColorReversedRegex().Match(html); + color = match.Success ? match.Groups[2].Value.Trim() : null; + } + + if (color is null) + return null; + + if (color.Length == 4 && color[0] == '#' + && IsHexDigit(color[1]) && IsHexDigit(color[2]) && IsHexDigit(color[3])) + { + // Expand #RGB to #RRGGBB + return $"#{color[1]}{color[1]}{color[2]}{color[2]}{color[3]}{color[3]}"; + } + + if (color.Length == 7 && color[0] == '#' + && color[1..].All(IsHexDigit)) + { + return color; + } + + return null; + } + + private static bool IsHexDigit(char c) => + c is (>= '0' and <= '9') or (>= 'a' and <= 'f') or (>= 'A' and <= 'F'); + private static List ExtractUrls(string content) { var urls = new List(); @@ -213,4 +251,14 @@ public partial class LinkEmbedService [GeneratedRegex(@"]*>([^<]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled)] private static partial Regex TitleTagRegex(); + + // + [GeneratedRegex(@"]*?name\s*=\s*([""'])theme-color\1[^>]*?content\s*=\s*([""'])(.*?)\2[^>]*/?>", + RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled)] + private static partial Regex ThemeColorRegex(); + + // + [GeneratedRegex(@"]*?content\s*=\s*([""'])(.*?)\1[^>]*?name\s*=\s*([""'])theme-color\3[^>]*/?>", + RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled)] + private static partial Regex ThemeColorReversedRegex(); } diff --git a/src/EchoHub.Server/Services/MuteExpirationService.cs b/src/EchoHub.Server/Services/MuteExpirationService.cs new file mode 100644 index 0000000..cfbdb99 --- /dev/null +++ b/src/EchoHub.Server/Services/MuteExpirationService.cs @@ -0,0 +1,63 @@ +using EchoHub.Server.Data; +using Microsoft.EntityFrameworkCore; + +namespace EchoHub.Server.Services; + +/// +/// Background service that periodically unmutes users whose timed mute has expired. +/// +public sealed class MuteExpirationService : BackgroundService +{ + private static readonly TimeSpan CheckInterval = TimeSpan.FromSeconds(15); + + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + public MuteExpirationService(IServiceScopeFactory scopeFactory, ILogger logger) + { + _scopeFactory = scopeFactory; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await Task.Yield(); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await UnmuteExpiredUsersAsync(stoppingToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "Error checking mute expirations"); + } + + await Task.Delay(CheckInterval, stoppingToken); + } + } + + private async Task UnmuteExpiredUsersAsync(CancellationToken ct) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var now = DateTimeOffset.UtcNow; + var expired = await db.Users + .Where(u => u.IsMuted && u.MutedUntil.HasValue && u.MutedUntil.Value <= now) + .ToListAsync(ct); + + if (expired.Count == 0) + return; + + foreach (var user in expired) + { + user.IsMuted = false; + user.MutedUntil = null; + _logger.LogInformation("Auto-unmuted user {Username} (timed mute expired)", user.Username); + } + + await db.SaveChangesAsync(ct); + } +} diff --git a/src/EchoHub.Server/Services/PresenceTracker.cs b/src/EchoHub.Server/Services/PresenceTracker.cs index 0ae3d42..f97278d 100644 --- a/src/EchoHub.Server/Services/PresenceTracker.cs +++ b/src/EchoHub.Server/Services/PresenceTracker.cs @@ -14,6 +14,8 @@ public class PresenceTracker { _connections[connectionId] = (userId, username); + // Lock is required: ConcurrentDictionary only protects its own slots, not the HashSet values inside. + // It also makes the TryGetValue → add sequence atomic to prevent race conditions. lock (_lock) { if (!_userConnections.TryGetValue(username, out var connections)) diff --git a/src/EchoHub.Server/Services/ServerDirectoryService.cs b/src/EchoHub.Server/Services/ServerDirectoryService.cs index 633f5d7..521f619 100644 --- a/src/EchoHub.Server/Services/ServerDirectoryService.cs +++ b/src/EchoHub.Server/Services/ServerDirectoryService.cs @@ -170,6 +170,9 @@ public sealed class ServerDirectoryService : BackgroundService var currentCount = _presenceTracker.GetOnlineUserCount(); + if (currentCount == _lastReportedUserCount) + continue; + try { await connection.InvokeAsync("UpdateUserCount", currentCount, ct); diff --git a/src/EchoHub.Server/Services/SignalRBroadcaster.cs b/src/EchoHub.Server/Services/SignalRBroadcaster.cs index 546dde6..4c8b660 100644 --- a/src/EchoHub.Server/Services/SignalRBroadcaster.cs +++ b/src/EchoHub.Server/Services/SignalRBroadcaster.cs @@ -23,12 +23,12 @@ public class SignalRBroadcaster : IChatBroadcaster public Task SendMessageToChannelAsync(string channelName, MessageDto message) => HubContext.Clients.Group(channelName).ReceiveMessage(message); - public Task SendUserJoinedAsync(string channelName, string username, string? excludeConnectionId = null) + public Task SendUserJoinedAsync(string channelName, string username, UserPresenceDto? presence, string? excludeConnectionId = null) { if (excludeConnectionId is not null && !excludeConnectionId.StartsWith("irc-")) - return HubContext.Clients.GroupExcept(channelName, [excludeConnectionId]).UserJoined(channelName, username); + return HubContext.Clients.GroupExcept(channelName, [excludeConnectionId]).UserJoined(channelName, username, presence); - return HubContext.Clients.Group(channelName).UserJoined(channelName, username); + return HubContext.Clients.Group(channelName).UserJoined(channelName, username, presence); } public Task SendUserLeftAsync(string channelName, string username) diff --git a/src/EchoHub.Tests/Irc/IrcBroadcasterTests.cs b/src/EchoHub.Tests/Irc/IrcBroadcasterTests.cs index eca1170..65c372a 100644 --- a/src/EchoHub.Tests/Irc/IrcBroadcasterTests.cs +++ b/src/EchoHub.Tests/Irc/IrcBroadcasterTests.cs @@ -132,7 +132,7 @@ public class IrcBroadcasterTests { var (_, bobStream) = AddConnectionWithCapture("bob", "general"); - await _broadcaster.SendUserJoinedAsync("general", "alice"); + await _broadcaster.SendUserJoinedAsync("general", "alice", null); var output = bobStream.GetOutputLines(); Assert.Contains(output, l => l.Contains("JOIN #general") && l.Contains("alice")); @@ -144,7 +144,7 @@ public class IrcBroadcasterTests var (conn, excludedStream) = AddConnectionWithCapture("alice", "general"); var (_, bobStream) = AddConnectionWithCapture("bob", "general"); - await _broadcaster.SendUserJoinedAsync("general", "alice", conn.ConnectionId); + await _broadcaster.SendUserJoinedAsync("general", "alice", null, conn.ConnectionId); // Excluded connection should not get the message Assert.Empty(excludedStream.GetOutputLines());