From 257ac34224bbee1e63a4e4c189428d52d1568b8f Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 19 Feb 2026 15:15:09 +0100 Subject: [PATCH 01/22] feat: Add heartbeat handling to ServerDirectoryService for connection health checks --- .../Services/ServerDirectoryService.cs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/EchoHub.Server/Services/ServerDirectoryService.cs b/src/EchoHub.Server/Services/ServerDirectoryService.cs index 75b3784..633f5d7 100644 --- a/src/EchoHub.Server/Services/ServerDirectoryService.cs +++ b/src/EchoHub.Server/Services/ServerDirectoryService.cs @@ -61,6 +61,19 @@ public sealed class ServerDirectoryService : BackgroundService { var connectionPermanentlyClosed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + connection.On("Ping", async () => + { + _logger.LogDebug("Received alive check from directory — sending heartbeat"); + try + { + await connection.InvokeAsync("Heartbeat"); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to send heartbeat response"); + } + }); + connection.Reconnected += async _ => { _logger.LogInformation("Reconnected to directory — re-registering server"); @@ -156,8 +169,6 @@ public sealed class ServerDirectoryService : BackgroundService continue; var currentCount = _presenceTracker.GetOnlineUserCount(); - if (currentCount == _lastReportedUserCount) - continue; try { From b4c3ebd254c05a5b9220ccdb88e2b3fe76070af1 Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 19 Feb 2026 17:47:10 +0100 Subject: [PATCH 02/22] feat: enhance profile editing with avatar support and UI adjustments - Added AvatarPath to ProfileEditResult for user profile updates. - Updated ProfileEditDialog to include avatar selection with a browse button. - Increased dialog height to accommodate new avatar input fields. - Implemented avatar file path handling in the profile edit dialog. feat: introduce moderation features and user roles - Added ServerRole enum to define user roles (Member, Mod, Admin, Owner). - Extended User model to include role, mute, and ban status. - Created ModerationController for user role assignment, kicking, banning, and muting. - Implemented methods in IChatBroadcaster and SignalRBroadcaster for user moderation actions. - Updated database schema with new columns for user roles and moderation states. fix: ensure muted users cannot send messages - Added mute status checks in ChatService to prevent message sending for muted users. - Updated user presence and status handling to reflect role changes and moderation actions. chore: update constants for ASCII art rendering - Introduced AsciiArtHeightHalfBlock constant for improved ASCII art rendering. --- src/EchoHub.Client/AppOrchestrator.cs | 204 ++++++++++++++++ src/EchoHub.Client/Commands/CommandHandler.cs | 113 ++++++++- src/EchoHub.Client/Services/ApiClient.cs | 67 +++++ .../Services/EchoHubConnection.cs | 24 ++ src/EchoHub.Client/UI/ChatRenderer.cs | 149 +++++++++++- src/EchoHub.Client/UI/MainWindow.cs | 196 ++++++++++++--- src/EchoHub.Client/UI/ProfileEditDialog.cs | 61 ++++- src/EchoHub.Core/Constants/HubConstants.cs | 1 + .../Contracts/IChatBroadcaster.cs | 4 + src/EchoHub.Core/Contracts/IEchoHubClient.cs | 4 + src/EchoHub.Core/DTOs/ModerationDtos.cs | 8 + src/EchoHub.Core/DTOs/ProfileDtos.cs | 4 +- src/EchoHub.Core/Models/ServerRole.cs | 9 + src/EchoHub.Core/Models/User.cs | 4 + src/EchoHub.Server.Irc/IrcBroadcaster.cs | 35 +++ src/EchoHub.Server.Irc/IrcGatewayService.cs | 5 + src/EchoHub.Server/Auth/JwtTokenService.cs | 1 + .../Controllers/AuthController.cs | 7 + .../Controllers/ChannelsController.cs | 6 +- .../Controllers/ModerationController.cs | 228 ++++++++++++++++++ .../Controllers/UsersController.cs | 1 + src/EchoHub.Server/Data/EchoHubDbContext.cs | 1 + ...60219162414_AddModerationRoles.Designer.cs | 222 +++++++++++++++++ .../20260219162414_AddModerationRoles.cs | 61 +++++ .../EchoHubDbContextModelSnapshot.cs | 12 + src/EchoHub.Server/Services/ChatService.cs | 26 +- .../Services/ImageToAsciiService.cs | 65 +++-- .../Services/SignalRBroadcaster.cs | 12 + 28 files changed, 1457 insertions(+), 73 deletions(-) create mode 100644 src/EchoHub.Core/DTOs/ModerationDtos.cs create mode 100644 src/EchoHub.Core/Models/ServerRole.cs create mode 100644 src/EchoHub.Server/Controllers/ModerationController.cs create mode 100644 src/EchoHub.Server/Data/Migrations/20260219162414_AddModerationRoles.Designer.cs create mode 100644 src/EchoHub.Server/Data/Migrations/20260219162414_AddModerationRoles.cs diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs index 29e7d62..3c9c23d 100644 --- a/src/EchoHub.Client/AppOrchestrator.cs +++ b/src/EchoHub.Client/AppOrchestrator.cs @@ -76,6 +76,7 @@ public sealed class AppOrchestrator : IDisposable _mainWindow.OnThemeSelected += HandleThemeSelected; _mainWindow.OnSavedServersRequested += HandleSavedServersRequested; _mainWindow.OnCreateChannelRequested += HandleCreateChannelRequested; + _mainWindow.OnDeleteChannelRequested += HandleDeleteChannelRequested; } // ── Command Handler Wiring ───────────────────────────────────────────── @@ -292,6 +293,56 @@ public sealed class AppOrchestrator : IDisposable } }; + _commandHandler.OnKickUser += async (username, reason) => + { + if (!IsAuthenticated) return; + await _apiClient!.KickUserAsync(username, reason); + }; + + _commandHandler.OnBanUser += async (username, reason) => + { + if (!IsAuthenticated) return; + await _apiClient!.BanUserAsync(username, reason); + }; + + _commandHandler.OnUnbanUser += async (username) => + { + if (!IsAuthenticated) return; + await _apiClient!.UnbanUserAsync(username); + }; + + _commandHandler.OnMuteUser += async (username, duration) => + { + if (!IsAuthenticated) return; + await _apiClient!.MuteUserAsync(username, duration); + }; + + _commandHandler.OnUnmuteUser += async (username) => + { + if (!IsAuthenticated) return; + await _apiClient!.UnmuteUserAsync(username); + }; + + _commandHandler.OnAssignRole += async (username, roleStr) => + { + if (!IsAuthenticated) return; + var role = roleStr switch + { + "admin" => ServerRole.Admin, + "mod" => ServerRole.Mod, + _ => ServerRole.Member, + }; + await _apiClient!.AssignRoleAsync(username, role); + }; + + _commandHandler.OnNukeChannel += async () => + { + if (!IsAuthenticated) return; + var channel = _mainWindow.CurrentChannel; + if (string.IsNullOrEmpty(channel)) return; + await _apiClient!.NukeChannelAsync(channel); + }; + _commandHandler.OnQuit += () => { InvokeUI(() => _app.RequestStop()); @@ -361,6 +412,7 @@ public sealed class AppOrchestrator : IDisposable // History might not be available } + FetchAndUpdateOnlineUsers(); SaveServerToConfig(result); }, "Connection failed", "Connect"); } @@ -440,6 +492,8 @@ public sealed class AppOrchestrator : IDisposable { // History might not be available } + + FetchAndUpdateOnlineUsers(); }, "Failed to join channel"); } @@ -526,6 +580,45 @@ public sealed class AppOrchestrator : IDisposable }); } + // Upload avatar if specified + if (editResult.AvatarPath is not null) + { + try + { + Stream stream; + string fileName; + + if (Uri.TryCreate(editResult.AvatarPath, UriKind.Absolute, out var uri) + && (uri.Scheme == "http" || uri.Scheme == "https")) + { + using var http = new HttpClient(); + var bytes = await http.GetByteArrayAsync(uri); + stream = new MemoryStream(bytes); + fileName = Path.GetFileName(uri.LocalPath); + if (string.IsNullOrWhiteSpace(fileName) || !fileName.Contains('.')) + fileName = "avatar.png"; + } + else + { + stream = File.OpenRead(editResult.AvatarPath); + fileName = Path.GetFileName(editResult.AvatarPath); + } + + await using (stream) + { + await _apiClient!.UploadAvatarAsync(stream, fileName); + var channel = _mainWindow.CurrentChannel; + if (!string.IsNullOrEmpty(channel)) + InvokeUI(() => _mainWindow.AddSystemMessage(channel, "Avatar updated.")); + } + } + catch (Exception ex) + { + Log.Error(ex, "Avatar upload failed for {Target}", editResult.AvatarPath); + InvokeUI(() => _mainWindow.ShowError($"Avatar upload failed: {ex.Message}")); + } + } + _config.DefaultPreset = new AccountPreset { DisplayName = editResult.DisplayName, @@ -617,6 +710,47 @@ public sealed class AppOrchestrator : IDisposable }, "Failed to create channel"); } + private void HandleDeleteChannelRequested() + { + if (!IsAuthenticated || !IsConnected) + { + _mainWindow.ShowError("Not connected to a server."); + return; + } + + var channel = _mainWindow.CurrentChannel; + if (string.IsNullOrEmpty(channel)) + { + _mainWindow.ShowError("No channel selected."); + return; + } + + if (channel == HubConstants.DefaultChannel) + { + _mainWindow.ShowError($"The #{HubConstants.DefaultChannel} channel cannot be deleted."); + return; + } + + var confirm = MessageBox.Query(_app, "Delete Channel", + $"Are you sure you want to delete #{channel}?\nThis will remove all messages permanently.", "Delete", "Cancel"); + + if (confirm != 0) return; + + RunAsync(async () => + { + await _apiClient!.DeleteChannelAsync(channel); + _joinedChannels.Remove(channel); + + var channels = await _apiClient.GetChannelsAsync(); + InvokeUI(() => + { + _mainWindow.SetChannels(channels); + _mainWindow.SwitchToChannel(HubConstants.DefaultChannel); + _mainWindow.AddSystemMessage(HubConstants.DefaultChannel, $"Channel #{channel} has been deleted."); + }); + }, "Failed to delete channel"); + } + // ── Connection Event Wiring ──────────────────────────────────────────── private void WireConnectionEvents(EchoHubConnection connection) @@ -625,10 +759,18 @@ public sealed class AppOrchestrator : IDisposable InvokeUI(() => _mainWindow.AddMessage(message)); connection.OnUserJoined += (channelName, username) => + { InvokeUI(() => _mainWindow.AddSystemMessage(channelName, $"{username} joined the channel")); + if (channelName == _mainWindow.CurrentChannel) + FetchAndUpdateOnlineUsers(); + }; connection.OnUserLeft += (channelName, username) => + { InvokeUI(() => _mainWindow.AddSystemMessage(channelName, $"{username} left the channel")); + if (channelName == _mainWindow.CurrentChannel) + FetchAndUpdateOnlineUsers(); + }; connection.OnUserStatusChanged += presence => { @@ -642,6 +784,49 @@ public sealed class AppOrchestrator : IDisposable foreach (var channelName in _mainWindow.GetChannelNames()) _mainWindow.AddStatusMessage(channelName, displayName, statusText); }); + FetchAndUpdateOnlineUsers(); + }; + + connection.OnUserKicked += (channelName, username, reason) => + { + var reasonText = reason is not null ? $" ({reason})" : ""; + InvokeUI(() => + { + _mainWindow.AddSystemMessage(channelName, $"{username} was kicked{reasonText}"); + if (username.Equals(_currentUsername, StringComparison.OrdinalIgnoreCase)) + { + _mainWindow.AddSystemMessage(channelName, "You were kicked from this channel."); + } + }); + }; + + connection.OnUserBanned += (username, reason) => + { + InvokeUI(() => + { + if (username.Equals(_currentUsername, StringComparison.OrdinalIgnoreCase)) + { + _mainWindow.ShowError("You have been banned from this server."); + HandleDisconnect(); + } + }); + }; + + connection.OnMessageDeleted += (channelName, messageId) => + { + InvokeUI(() => + { + _mainWindow.RemoveMessage(channelName, messageId); + }); + }; + + connection.OnChannelNuked += channelName => + { + InvokeUI(() => + { + _mainWindow.ClearChannelMessages(channelName); + _mainWindow.AddSystemMessage(channelName, "Channel history has been cleared by a moderator."); + }); }; connection.OnError += errorMessage => @@ -673,6 +858,25 @@ public sealed class AppOrchestrator : IDisposable // ── Private Helpers ──────────────────────────────────────────────────── + private void FetchAndUpdateOnlineUsers() + { + var channel = _mainWindow.CurrentChannel; + if (string.IsNullOrEmpty(channel) || !IsConnected) return; + + Task.Run(async () => + { + try + { + var users = await _connection!.GetOnlineUsersAsync(channel); + InvokeUI(() => _mainWindow.UpdateOnlineUsers(users)); + } + catch (Exception ex) + { + Log.Debug(ex, "Failed to fetch online users for {Channel}", channel); + } + }); + } + private void SaveServerToConfig(ConnectDialogResult result) { var savedServer = new SavedServer diff --git a/src/EchoHub.Client/Commands/CommandHandler.cs b/src/EchoHub.Client/Commands/CommandHandler.cs index f552fec..05596cc 100644 --- a/src/EchoHub.Client/Commands/CommandHandler.cs +++ b/src/EchoHub.Client/Commands/CommandHandler.cs @@ -18,6 +18,13 @@ public class CommandHandler public event Func? OnSetTopic; public event Func? OnListUsers; public event Func? OnSetAvatar; + public event Func? OnKickUser; + public event Func? OnBanUser; + public event Func? OnUnbanUser; + public event Func? OnMuteUser; + public event Func? OnUnmuteUser; + public event Func? OnAssignRole; + public event Func? OnNukeChannel; public event Func? OnQuit; public event Func? OnHelp; @@ -46,6 +53,13 @@ public class CommandHandler "leave" => await HandleLeave(), "topic" => await HandleTopic(args), "users" => await HandleUsers(), + "kick" => await HandleKick(args), + "ban" => await HandleBan(args), + "unban" => await HandleUnban(args), + "mute" => await HandleMute(args), + "unmute" => await HandleUnmute(args), + "role" => await HandleRole(args), + "nuke" => await HandleNuke(), "quit" or "exit" => await HandleQuit(), "help" or "?" => await HandleHelp(), _ => new CommandResult(true, $"Unknown command: /{command}. Type /help for available commands.", IsError: true), @@ -212,6 +226,95 @@ public class CommandHandler return new CommandResult(true); } + private async Task HandleKick(string args) + { + if (string.IsNullOrWhiteSpace(args)) + return new CommandResult(true, "Usage: /kick [reason]", IsError: true); + + var parts = args.Split(' ', 2, StringSplitOptions.TrimEntries); + var username = parts[0]; + var reason = parts.Length > 1 ? parts[1] : null; + + if (OnKickUser is not null) + await OnKickUser(username, reason); + return new CommandResult(true, $"Kicking {username}..."); + } + + private async Task HandleBan(string args) + { + if (string.IsNullOrWhiteSpace(args)) + return new CommandResult(true, "Usage: /ban [reason]", IsError: true); + + var parts = args.Split(' ', 2, StringSplitOptions.TrimEntries); + var username = parts[0]; + var reason = parts.Length > 1 ? parts[1] : null; + + if (OnBanUser is not null) + await OnBanUser(username, reason); + return new CommandResult(true, $"Banning {username}..."); + } + + private async Task HandleUnban(string args) + { + if (string.IsNullOrWhiteSpace(args)) + return new CommandResult(true, "Usage: /unban ", IsError: true); + + if (OnUnbanUser is not null) + await OnUnbanUser(args.Trim()); + return new CommandResult(true, $"Unbanning {args.Trim()}..."); + } + + private async Task HandleMute(string args) + { + if (string.IsNullOrWhiteSpace(args)) + return new CommandResult(true, "Usage: /mute [duration_minutes]", IsError: true); + + var parts = args.Split(' ', 2, StringSplitOptions.TrimEntries); + var username = parts[0]; + int? duration = parts.Length > 1 && int.TryParse(parts[1], out var d) ? d : null; + + if (OnMuteUser is not null) + await OnMuteUser(username, duration); + return new CommandResult(true, $"Muting {username}..."); + } + + private async Task HandleUnmute(string args) + { + if (string.IsNullOrWhiteSpace(args)) + return new CommandResult(true, "Usage: /unmute ", IsError: true); + + if (OnUnmuteUser is not null) + await OnUnmuteUser(args.Trim()); + return new CommandResult(true, $"Unmuting {args.Trim()}..."); + } + + private async Task HandleRole(string args) + { + if (string.IsNullOrWhiteSpace(args)) + return new CommandResult(true, "Usage: /role ", IsError: true); + + var parts = args.Split(' ', 2, StringSplitOptions.TrimEntries); + if (parts.Length < 2) + return new CommandResult(true, "Usage: /role ", IsError: true); + + var username = parts[0]; + var role = parts[1].ToLowerInvariant(); + + if (role is not ("admin" or "mod" or "member")) + return new CommandResult(true, "Invalid role. Use: admin, mod, or member", IsError: true); + + if (OnAssignRole is not null) + await OnAssignRole(username, role); + return new CommandResult(true, $"Setting {username} to {role}..."); + } + + private async Task HandleNuke() + { + if (OnNukeChannel is not null) + await OnNukeChannel(); + return new CommandResult(true, "Nuking channel history..."); + } + private async Task HandleHelp() { if (OnHelp is not null) @@ -225,12 +328,20 @@ public class CommandHandler /theme - Switch theme /send - Send a file or image /avatar - Set your avatar - /profile [username] - View a profile (yours if no name given) + /profile [username] - View a profile /servers - Open saved servers /join - Join a channel /leave - Leave current channel /topic - Set channel topic /users - List online users + Moderation: + /kick [reason] - Kick a user (Mod+) + /ban [reason] - Ban a user (Admin+) + /unban - Unban a user (Admin+) + /mute [minutes] - Mute a user (Mod+) + /unmute - Unmute a user (Mod+) + /role - Assign role (Admin+) + /nuke - Clear channel history (Mod+) /quit - Exit the app """); } diff --git a/src/EchoHub.Client/Services/ApiClient.cs b/src/EchoHub.Client/Services/ApiClient.cs index adeed96..64059f9 100644 --- a/src/EchoHub.Client/Services/ApiClient.cs +++ b/src/EchoHub.Client/Services/ApiClient.cs @@ -3,6 +3,7 @@ using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using EchoHub.Core.DTOs; +using EchoHub.Core.Models; namespace EchoHub.Client.Services; @@ -211,6 +212,72 @@ public sealed class ApiClient : IDisposable await EnsureSuccessAsync(response); } + // ── Moderation ──────────────────────────────────────────────────────── + + public async Task AssignRoleAsync(string username, ServerRole role) + { + EnsureAuthenticated(); + var response = await AuthenticatedRequestAsync(() => + _http.PostAsJsonAsync("/api/moderation/role", new AssignRoleRequest(username, role))); + await EnsureSuccessAsync(response); + } + + public async Task KickUserAsync(string username, string? reason = null) + { + EnsureAuthenticated(); + var response = await AuthenticatedRequestAsync(() => + _http.PostAsJsonAsync($"/api/moderation/kick/{Uri.EscapeDataString(username)}", new KickRequest(reason))); + await EnsureSuccessAsync(response); + } + + public async Task BanUserAsync(string username, string? reason = null) + { + EnsureAuthenticated(); + var response = await AuthenticatedRequestAsync(() => + _http.PostAsJsonAsync($"/api/moderation/ban/{Uri.EscapeDataString(username)}", new BanRequest(reason))); + await EnsureSuccessAsync(response); + } + + public async Task UnbanUserAsync(string username) + { + EnsureAuthenticated(); + var response = await AuthenticatedRequestAsync(() => + _http.PostAsJsonAsync($"/api/moderation/unban/{Uri.EscapeDataString(username)}", new {})); + await EnsureSuccessAsync(response); + } + + public async Task MuteUserAsync(string username, int? durationMinutes = null, string? reason = null) + { + EnsureAuthenticated(); + var response = await AuthenticatedRequestAsync(() => + _http.PostAsJsonAsync($"/api/moderation/mute/{Uri.EscapeDataString(username)}", new MuteRequest(reason, durationMinutes))); + await EnsureSuccessAsync(response); + } + + public async Task UnmuteUserAsync(string username) + { + EnsureAuthenticated(); + var response = await AuthenticatedRequestAsync(() => + _http.PostAsJsonAsync($"/api/moderation/unmute/{Uri.EscapeDataString(username)}", new {})); + await EnsureSuccessAsync(response); + } + + public async Task DeleteMessageAsync(Guid messageId) + { + EnsureAuthenticated(); + var response = await AuthenticatedRequestAsync(() => + _http.DeleteAsync($"/api/moderation/messages/{messageId}")); + await EnsureSuccessAsync(response); + } + + public async Task NukeChannelAsync(string channelName) + { + EnsureAuthenticated(); + var response = await AuthenticatedRequestAsync(() => + _http.DeleteAsync($"/api/moderation/channels/{Uri.EscapeDataString(channelName)}/nuke")); + await EnsureSuccessAsync(response); + } + private void SetTokens(LoginResponse result) { _accessToken = result.Token; diff --git a/src/EchoHub.Client/Services/EchoHubConnection.cs b/src/EchoHub.Client/Services/EchoHubConnection.cs index d5fc78d..5d15d20 100644 --- a/src/EchoHub.Client/Services/EchoHubConnection.cs +++ b/src/EchoHub.Client/Services/EchoHubConnection.cs @@ -14,6 +14,10 @@ public sealed class EchoHubConnection : IAsyncDisposable public event Action? OnUserLeft; public event Action? OnChannelUpdated; public event Action? OnUserStatusChanged; + public event Action? OnUserKicked; + public event Action? OnUserBanned; + public event Action? OnMessageDeleted; + public event Action? OnChannelNuked; public event Action? OnError; public event Action? OnConnectionStateChanged; public event Action? OnReconnected; @@ -81,6 +85,26 @@ public sealed class EchoHubConnection : IAsyncDisposable OnUserStatusChanged?.Invoke(presence); }); + _connection.On(nameof(Core.Contracts.IEchoHubClient.UserKicked), (channelName, username, reason) => + { + OnUserKicked?.Invoke(channelName, username, reason); + }); + + _connection.On(nameof(Core.Contracts.IEchoHubClient.UserBanned), (username, reason) => + { + OnUserBanned?.Invoke(username, reason); + }); + + _connection.On(nameof(Core.Contracts.IEchoHubClient.MessageDeleted), (channelName, messageId) => + { + OnMessageDeleted?.Invoke(channelName, messageId); + }); + + _connection.On(nameof(Core.Contracts.IEchoHubClient.ChannelNuked), channelName => + { + OnChannelNuked?.Invoke(channelName); + }); + _connection.On(nameof(Core.Contracts.IEchoHubClient.Error), message => { OnError?.Invoke(message); diff --git a/src/EchoHub.Client/UI/ChatRenderer.cs b/src/EchoHub.Client/UI/ChatRenderer.cs index 919913d..b5bd92a 100644 --- a/src/EchoHub.Client/UI/ChatRenderer.cs +++ b/src/EchoHub.Client/UI/ChatRenderer.cs @@ -20,6 +20,8 @@ public partial class ChatLine { public List Segments { get; } public int TextLength { get; } + public Guid? MessageId { get; set; } + public bool IsMention { get; set; } public ChatLine(string plainText) { @@ -89,14 +91,25 @@ public partial class ChatLine /// /// Parse a string containing ANSI 24-bit color escape codes into colored segments. - /// Format: \x1b[38;2;R;G;Bm (foreground color), \x1b[0m (reset) + /// Supports foreground (\x1b[38;2;R;G;Bm), background (\x1b[48;2;R;G;Bm), and reset (\x1b[0m). /// public static ChatLine FromAnsi(string ansiText, Attribute? defaultAttr = null) { var segments = new List(); var regex = AnsiColorRegex(); int lastIndex = 0; - Attribute? currentColor = defaultAttr; + Color? currentFg = null; + Color? currentBg = null; + var defaultFg = defaultAttr?.Foreground; + var defaultBg = defaultAttr?.Background ?? Color.Black; + + Attribute? BuildAttr() + { + if (currentFg is null && currentBg is null) return defaultAttr; + var fg = currentFg ?? defaultFg ?? Color.White; + var bg = currentBg ?? defaultBg; + return new Attribute(fg, bg); + } foreach (Match match in regex.Matches(ansiText)) { @@ -105,22 +118,26 @@ public partial class ChatLine { var text = ansiText[lastIndex..match.Index]; if (text.Length > 0) - segments.Add(new ChatSegment(text, currentColor)); + segments.Add(new ChatSegment(text, BuildAttr())); } // Parse the escape sequence if (match.Groups[1].Value == "0") { // Reset - currentColor = defaultAttr; + currentFg = null; + currentBg = null; } else if (match.Groups[2].Success) { - // 38;2;R;G;B — 24-bit foreground color var r = int.Parse(match.Groups[3].Value); var g = int.Parse(match.Groups[4].Value); var b = int.Parse(match.Groups[5].Value); - currentColor = new Attribute(new Color(r, g, b), Color.Black); + + if (match.Groups[2].Value == "38;2") + currentFg = new Color(r, g, b); + else // 48;2 + currentBg = new Color(r, g, b); } lastIndex = match.Index + match.Length; @@ -131,14 +148,14 @@ public partial class ChatLine { var text = ansiText[lastIndex..]; if (text.Length > 0) - segments.Add(new ChatSegment(text, currentColor)); + segments.Add(new ChatSegment(text, BuildAttr())); } return segments.Count > 0 ? new ChatLine(segments) : new ChatLine(""); } - // Matches: \x1b[0m (reset) or \x1b[38;2;R;G;Bm (24-bit foreground) - [GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")] + // Matches: \x1b[0m (reset), \x1b[38;2;R;G;Bm (fg), or \x1b[48;2;R;G;Bm (bg) + [GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2|48;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")] private static partial Regex AnsiColorRegex(); } @@ -198,6 +215,7 @@ public class ChatListSource : IListDataSource var chatLine = _lines[item]; var normalAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal); + var mentionBg = chatLine.IsMention ? ChatColors.MentionHighlightAttr.Background : (Color?)null; int charPos = 0; int drawnChars = 0; @@ -205,6 +223,9 @@ public class ChatListSource : IListDataSource foreach (var segment in chatLine.Segments) { var attr = segment.Color ?? normalAttr; + // Override background for mention-highlighted lines + if (mentionBg.HasValue) + attr = new Attribute(attr.Foreground, mentionBg.Value); listView.SetAttribute(attr); foreach (var ch in segment.Text) @@ -218,8 +239,9 @@ public class ChatListSource : IListDataSource } } - // Fill remaining width with spaces using default colors - listView.SetAttribute(normalAttr); + // Fill remaining width with spaces + var fillAttr = mentionBg.HasValue ? new Attribute(normalAttr.Foreground, mentionBg.Value) : normalAttr; + listView.SetAttribute(fillAttr); while (drawnChars < width) { listView.AddRune(new Rune(' ')); @@ -242,6 +264,110 @@ public class ChatListSource : IListDataSource public void Dispose() { } } +/// +/// Custom list data source for colored channel list rendering. +/// Active channel gets a > indicator, unread channels are bright with a count badge. +/// +public class ChannelListSource : IListDataSource +{ + private readonly List _channelNames = []; + private readonly Dictionary _unreadCounts = []; + private string _activeChannel = string.Empty; + + public event NotifyCollectionChangedEventHandler? CollectionChanged; + public int Count => _channelNames.Count; + public int MaxItemLength { get; private set; } + public bool SuspendCollectionChangedEvent { get; set; } + + private static readonly Attribute ActiveAttr = new(Color.White, Color.Black); + private static readonly Attribute UnreadAttr = new(Color.BrightCyan, Color.Black); + private static readonly Attribute NormalAttr = new(Color.DarkGray, Color.Black); + private static readonly Attribute BadgeAttr = new(Color.BrightYellow, Color.Black); + + public void Update(List channels, Dictionary unread, string activeChannel) + { + _channelNames.Clear(); + _channelNames.AddRange(channels); + _unreadCounts.Clear(); + foreach (var kv in unread) + _unreadCounts[kv.Key] = kv.Value; + _activeChannel = activeChannel; + MaxItemLength = channels.Count > 0 ? channels.Max(c => c.Length + 6) : 0; + if (!SuspendCollectionChangedEvent) + CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + } + + public bool IsMarked(int item) => false; + public void SetMark(int item, bool value) { } + public IList ToList() => _channelNames.Select(n => $"#{n}").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 name = _channelNames[item]; + var isActive = name == _activeChannel; + _unreadCounts.TryGetValue(name, out var unread); + var hasUnread = unread > 0; + + var focusAttr = listView.GetAttributeForRole(VisualRole.Focus); + var prefix = isActive ? "> " : " "; + var channelText = $"#{name}"; + var badge = hasUnread ? $" ({unread})" : ""; + + int drawnChars = 0; + + // Use focus attr if this row is selected + if (selected) + { + listView.SetAttribute(focusAttr); + foreach (var ch in (prefix + channelText + badge)) + { + if (drawnChars < width) { listView.AddRune(new Rune(ch)); drawnChars++; } + } + } + else + { + // Prefix + var prefixAttr = isActive ? ActiveAttr : NormalAttr; + listView.SetAttribute(prefixAttr); + foreach (var ch in prefix) + { + if (drawnChars < width) { listView.AddRune(new Rune(ch)); drawnChars++; } + } + + // Channel name + var nameAttr = isActive ? ActiveAttr : hasUnread ? UnreadAttr : NormalAttr; + listView.SetAttribute(nameAttr); + foreach (var ch in channelText) + { + if (drawnChars < width) { listView.AddRune(new Rune(ch)); drawnChars++; } + } + + // Unread badge + if (hasUnread) + { + listView.SetAttribute(BadgeAttr); + foreach (var ch in badge) + { + if (drawnChars < width) { listView.AddRune(new Rune(ch)); drawnChars++; } + } + } + } + + // Fill rest + var fillAttr = selected ? focusAttr : listView.GetAttributeForRole(VisualRole.Normal); + listView.SetAttribute(fillAttr); + while (drawnChars < width) + { + listView.AddRune(new Rune(' ')); + drawnChars++; + } + } + + public void Dispose() { } +} + /// /// Shared color attributes for chat rendering (timestamps, system messages). /// @@ -249,6 +375,7 @@ public static class ChatColors { public static readonly Attribute TimestampAttr = new(Color.DarkGray, Color.Black); public static readonly Attribute SystemAttr = new(new Color(0, 180, 180), Color.Black); + public static readonly Attribute MentionHighlightAttr = new(Color.White, new Color(80, 40, 0)); } /// diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index 77a953e..8f7d5c9 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -1,4 +1,5 @@ using System.Collections.ObjectModel; +using System.Text.RegularExpressions; using EchoHub.Client.Themes; using EchoHub.Core.DTOs; using EchoHub.Core.Models; @@ -21,10 +22,21 @@ public sealed class MainWindow : Runnable private readonly ListView _messageList; private readonly TextView _inputField; private readonly FrameView _chatFrame; + private readonly FrameView _inputFrame; private readonly Label _statusLabel; private readonly Label _topicLabel; private MenuBar _menuBar; + // Online users panel + private readonly FrameView _usersFrame; + private readonly ListView _usersList; + private bool _usersPanelVisible = true; + private const int UsersPanelWidth = 22; + private static readonly Key F2Key = Key.F2; + + private static readonly string AppVersion = + typeof(MainWindow).Assembly.GetName().Version?.ToString(3) ?? "?"; + // Cached Key constants — compare via .KeyCode to avoid Key.Equals (which also checks Handled) private static readonly Key EnterKey = Key.Enter; private static readonly Key NewlineKey = Key.N.WithCtrl; @@ -36,13 +48,15 @@ public sealed class MainWindow : Runnable [ "/status", "/nick", "/color", "/theme", "/send", "/avatar", "/profile", "/servers", "/join", "/leave", - "/topic", "/users", "/quit", "/help" + "/topic", "/users", "/kick", "/ban", "/unban", + "/mute", "/unmute", "/role", "/nuke", "/quit", "/help" ]; private readonly List _channelNames = []; private readonly Dictionary> _channelMessages = []; private readonly Dictionary _channelUnread = []; private readonly Dictionary _channelTopics = []; + private readonly ChannelListSource _channelListSource; private string _currentChannel = string.Empty; private string _currentUser = string.Empty; private int _lastChatWidth; @@ -92,6 +106,11 @@ public sealed class MainWindow : Runnable /// public event Action? OnCreateChannelRequested; + /// + /// Fired when the user requests to delete the current channel. + /// + public event Action? OnDeleteChannelRequested; + public MainWindow(IApplication app) { _app = app; @@ -107,7 +126,7 @@ public sealed class MainWindow : Runnable Title = "Channels", X = 0, Y = 1, // below menu bar - Width = 25, + Width = 22, Height = Dim.Fill(1) // leave room for status bar }; @@ -118,7 +137,8 @@ public sealed class MainWindow : Runnable Width = Dim.Fill(), Height = Dim.Fill() }; - _channelList.SetSource(new ObservableCollection(_channelNames)); + _channelListSource = new ChannelListSource(); + _channelList.Source = _channelListSource; _channelList.ValueChanged += OnChannelListSelectionChanged; channelsFrame.Add(_channelList); Add(channelsFrame); @@ -127,9 +147,9 @@ public sealed class MainWindow : Runnable _topicLabel = new Label { Text = "", - X = 25, + X = 22, Y = 1, - Width = Dim.Fill(), + Width = Dim.Fill(UsersPanelWidth), Height = 1, Visible = false }; @@ -139,9 +159,9 @@ public sealed class MainWindow : Runnable _chatFrame = new FrameView { Title = "Chat", - X = 25, + X = 22, Y = 1, // below menu bar (shifts to 2 when topic is visible) - Width = Dim.Fill(), + Width = Dim.Fill(UsersPanelWidth), Height = Dim.Fill(6) // leave room for input area and status bar }; @@ -157,12 +177,12 @@ public sealed class MainWindow : Runnable Add(_chatFrame); // Bottom input area - var inputFrame = new FrameView + _inputFrame = new FrameView { - Title = "Message (Enter=send, Ctrl+N=newline, Tab=autocomplete)", - X = 25, + Title = "Message \u2502 Enter=send \u2502 Ctrl+N=newline \u2502 Tab=complete", + X = 22, Y = Pos.Bottom(_chatFrame), - Width = Dim.Fill(), + Width = Dim.Fill(UsersPanelWidth), Height = 5 }; @@ -175,8 +195,29 @@ public sealed class MainWindow : Runnable WordWrap = true }; _inputField.KeyDown += OnInputKeyDown; - inputFrame.Add(_inputField); - Add(inputFrame); + _inputFrame.Add(_inputField); + Add(_inputFrame); + + // Right panel - online users + _usersFrame = new FrameView + { + Title = "Users", + X = Pos.AnchorEnd(UsersPanelWidth), + Y = 1, + Width = UsersPanelWidth, + Height = Dim.Fill(1) + }; + + _usersList = new ListView + { + X = 0, + Y = 0, + Width = Dim.Fill(), + Height = Dim.Fill() + }; + _usersList.SetSource(new ObservableCollection()); + _usersFrame.Add(_usersList); + Add(_usersFrame); // Status bar at the very bottom _statusLabel = new Label @@ -198,7 +239,7 @@ public sealed class MainWindow : Runnable _messageList.ViewportChanged += (_, _) => OnChatViewportChanged(); _chatFrame.ViewportChanged += (_, _) => OnChatViewportChanged(); - // Window-level key handling for Ctrl+C (quit) + // Window-level key handling for Ctrl+C (quit), F2 (toggle users panel) KeyDown += OnWindowKeyDown; } @@ -265,7 +306,11 @@ public sealed class MainWindow : Runnable new MenuItem("_Disconnect", "Disconnect from server", () => OnDisconnectRequested?.Invoke(), Key.Empty), new Line(), new MenuItem("New C_hannel...", "Create a new channel", () => OnCreateChannelRequested?.Invoke(), Key.Empty), - new MenuItem("_Saved Servers...", "View saved servers", () => OnSavedServersRequested?.Invoke(), Key.Empty) + new MenuItem("_Delete Channel", "Delete the current channel", () => OnDeleteChannelRequested?.Invoke(), Key.Empty), + new Line(), + new MenuItem("_Saved Servers...", "View saved servers", () => OnSavedServersRequested?.Invoke(), Key.Empty), + new Line(), + new MenuItem("Toggle _Users Panel", "Toggle online users (F2)", () => ToggleUsersPanel(), Key.Empty) }), new MenuBarItem("_User", allUserItems) ]); @@ -386,12 +431,16 @@ public sealed class MainWindow : Runnable private void OnWindowKeyDown(object? sender, Key e) { - // Ctrl+C quits from anywhere if (e.KeyCode == CtrlCKey.KeyCode) { _app.RequestStop(); e.Handled = true; } + else if (e.KeyCode == F2Key.KeyCode) + { + ToggleUsersPanel(); + e.Handled = true; + } } /// @@ -475,6 +524,32 @@ public sealed class MainWindow : Runnable } } + /// + /// Remove all lines associated with a specific message ID. + /// + public void RemoveMessage(string channelName, Guid messageId) + { + if (_channelMessages.TryGetValue(channelName, out var messages)) + { + messages.RemoveAll(l => l.MessageId == messageId); + if (channelName == _currentChannel) + RefreshMessages(); + } + } + + /// + /// Clear all messages from a specific channel. + /// + public void ClearChannelMessages(string channelName) + { + if (_channelMessages.TryGetValue(channelName, out var messages)) + { + messages.Clear(); + if (channelName == _currentChannel) + RefreshMessages(); + } + } + /// /// Set the list of available channels, storing topics, and refresh the channel list view. /// @@ -515,9 +590,9 @@ public sealed class MainWindow : Runnable /// public void UpdateStatusBar(string status) { - var userPart = string.IsNullOrEmpty(_currentUser) ? "" : $" | User: {_currentUser}"; - var channelPart = string.IsNullOrEmpty(_currentChannel) ? "" : $" | #{_currentChannel}"; - _statusLabel.Text = $" {status}{userPart}{channelPart}"; + var userPart = string.IsNullOrEmpty(_currentUser) ? "" : $" \u2502 User: {_currentUser}"; + var channelPart = string.IsNullOrEmpty(_currentChannel) ? "" : $" \u2502 #{_currentChannel}"; + _statusLabel.Text = $" v{AppVersion} \u2502 {status}{userPart}{channelPart}"; } /// @@ -585,10 +660,13 @@ public sealed class MainWindow : Runnable _channelTopics.Clear(); _currentChannel = string.Empty; _currentUser = string.Empty; - _channelList.SetSource(new ObservableCollection(_channelNames)); + _channelListSource.Update([], [], string.Empty); + _channelList.Source = _channelListSource; _chatFrame.Title = "Chat"; _topicLabel.Visible = false; _chatFrame.Y = 1; + _usersList.SetSource(new ObservableCollection()); + _usersFrame.Title = "Users"; RefreshMessages(); } @@ -640,13 +718,8 @@ public sealed class MainWindow : Runnable /// private void RefreshChannelList() { - var displayNames = _channelNames.Select(name => - { - _channelUnread.TryGetValue(name, out var unread); - return unread > 0 ? $"#{name} ({unread})" : $"#{name}"; - }).ToList(); - - _channelList.SetSource(new ObservableCollection(displayNames)); + _channelListSource.Update(_channelNames, _channelUnread, _currentChannel); + _channelList.Source = _channelListSource; // Restore selection to current channel var idx = _channelNames.IndexOf(_currentChannel); @@ -673,11 +746,63 @@ public sealed class MainWindow : Runnable } } + /// + /// Adjusts widths of chat, topic, and input frames based on users panel visibility. + /// + private void UpdateLayout() + { + var rightMargin = _usersPanelVisible ? UsersPanelWidth : 0; + _chatFrame.Width = Dim.Fill(rightMargin); + _topicLabel.Width = Dim.Fill(rightMargin); + _inputFrame.Width = Dim.Fill(rightMargin); + _usersFrame.Visible = _usersPanelVisible; + SetNeedsDraw(); + } + + /// + /// Toggle the online users panel visibility (F2). + /// + public void ToggleUsersPanel() + { + _usersPanelVisible = !_usersPanelVisible; + UpdateLayout(); + } + + /// + /// Update the online users list display. + /// + public void UpdateOnlineUsers(List users) + { + var displayItems = users.Select(u => + { + var statusIcon = u.Status switch + { + UserStatus.Online => "\u25cf", // ● + UserStatus.Away => "\u25cb", // ○ + UserStatus.DoNotDisturb => "\u25d0", // ◐ + UserStatus.Invisible => "\u25cc", // ◌ + _ => " " + }; + var name = u.DisplayName ?? u.Username; + var roleTag = u.Role switch + { + ServerRole.Owner => "\u2605", // ★ + ServerRole.Admin => "\u2666", // ♦ + ServerRole.Mod => "\u2740", // ❀ + _ => "" + }; + return $"{statusIcon} {roleTag}{name}"; + }).ToList(); + + _usersList.SetSource(new ObservableCollection(displayItems)); + _usersFrame.Title = $"Users ({users.Count})"; + } + /// /// Format a message DTO into one or more display lines based on its MessageType. /// Timestamps are dimmed and sender names are colored. /// - private static List FormatMessage(MessageDto message) + private List FormatMessage(MessageDto message) { var time = message.SentAt.ToLocalTime().ToString("HH:mm"); var senderName = message.SenderUsername + ":"; @@ -724,6 +849,21 @@ public sealed class MainWindow : Runnable break; } + // Tag all lines with the message ID for deletion support + foreach (var line in lines) + line.MessageId = message.Id; + + // Check for @mention of current user + if (!string.IsNullOrEmpty(_currentUser) && message.Type == MessageType.Text) + { + var pattern = $@"@{Regex.Escape(_currentUser)}\b"; + if (Regex.IsMatch(message.Content, pattern, RegexOptions.IgnoreCase)) + { + foreach (var line in lines) + line.IsMention = true; + } + } + return lines; } diff --git a/src/EchoHub.Client/UI/ProfileEditDialog.cs b/src/EchoHub.Client/UI/ProfileEditDialog.cs index b932df3..6481e16 100644 --- a/src/EchoHub.Client/UI/ProfileEditDialog.cs +++ b/src/EchoHub.Client/UI/ProfileEditDialog.cs @@ -9,7 +9,7 @@ namespace EchoHub.Client.UI; /// /// Result returned from the profile edit dialog. /// -public record ProfileEditResult(string? DisplayName, string? Bio, string? NicknameColor); +public record ProfileEditResult(string? DisplayName, string? Bio, string? NicknameColor, string? AvatarPath); /// /// A Terminal.Gui dialog for editing the user's profile (display name, bio, nickname color). @@ -23,7 +23,7 @@ public sealed class ProfileEditDialog { ProfileEditResult? result = null; - var dialog = new Dialog { Title = "Edit Profile", Width = 60, Height = 18 }; + var dialog = new Dialog { Title = "Edit Profile", Width = 60, Height = 22 }; // Display Name var nameLabel = new Label @@ -102,20 +102,66 @@ public sealed class ProfileEditDialog UpdateColorPreview(colorPreview, colorField.Text); }; + // Avatar + var avatarLabel = new Label + { + Text = "Avatar:", + X = 1, + Y = 10 + }; + var avatarField = new TextField + { + Text = "", + X = 17, + Y = 10, + Width = Dim.Fill(12) + }; + var browseButton = new Button + { + Text = "Browse", + X = Pos.AnchorEnd(10), + Y = 10 + }; + var avatarHintLabel = new Label + { + Text = "(file path or URL)", + X = 17, + Y = 11 + }; + avatarHintLabel.SetScheme(new Scheme + { + Normal = new Attribute(Color.DarkGray, Color.Blue) + }); + + browseButton.Accepting += (s, e) => + { + e.Handled = true; + var openDialog = new OpenDialog + { + Title = "Select Avatar Image", + OpenMode = OpenMode.File, + }; + app.Run(openDialog); + if (openDialog.FilePaths.Count > 0) + { + avatarField.Text = openDialog.FilePaths[0]; + } + }; + // Buttons var saveButton = new Button { Text = "Save", IsDefault = true, X = Pos.Center() - 10, - Y = 10 + Y = 14 }; var cancelButton = new Button { Text = "Cancel", X = Pos.Center() + 5, - Y = 10 + Y = 14 }; saveButton.Accepting += (s, e) => @@ -123,8 +169,9 @@ public sealed class ProfileEditDialog var displayName = NullIfEmpty(nameField.Text?.Trim()); var bio = NullIfEmpty(bioField.Text?.Trim()); var nicknameColor = NullIfEmpty(colorField.Text?.Trim()); + var avatarPath = NullIfEmpty(avatarField.Text?.Trim()); - result = new ProfileEditResult(displayName, bio, nicknameColor); + result = new ProfileEditResult(displayName, bio, nicknameColor, avatarPath); e.Handled = true; app.RequestStop(); }; @@ -137,7 +184,9 @@ public sealed class ProfileEditDialog }; dialog.Add(nameLabel, nameField, bioLabel, bioField, colorLabel, colorField, - colorHintLabel, previewLabel, colorPreview, saveButton, cancelButton); + colorHintLabel, previewLabel, colorPreview, + avatarLabel, avatarField, browseButton, avatarHintLabel, + saveButton, cancelButton); nameField.SetFocus(); app.Run(dialog); diff --git a/src/EchoHub.Core/Constants/HubConstants.cs b/src/EchoHub.Core/Constants/HubConstants.cs index 6efeba0..5a67cf0 100644 --- a/src/EchoHub.Core/Constants/HubConstants.cs +++ b/src/EchoHub.Core/Constants/HubConstants.cs @@ -10,4 +10,5 @@ public static class HubConstants public const int MaxAvatarSizeBytes = 2 * 1024 * 1024; // 2 MB public const int AsciiArtWidth = 80; public const int AsciiArtHeight = 40; + public const int AsciiArtHeightHalfBlock = 80; } diff --git a/src/EchoHub.Core/Contracts/IChatBroadcaster.cs b/src/EchoHub.Core/Contracts/IChatBroadcaster.cs index 6e2a11c..84d1e7d 100644 --- a/src/EchoHub.Core/Contracts/IChatBroadcaster.cs +++ b/src/EchoHub.Core/Contracts/IChatBroadcaster.cs @@ -9,5 +9,9 @@ public interface IChatBroadcaster Task SendUserLeftAsync(string channelName, string username); Task SendChannelUpdatedAsync(ChannelDto channel, string? channelName = null); Task SendUserStatusChangedAsync(List channelNames, UserPresenceDto presence); + Task SendUserKickedAsync(string channelName, string username, string? reason); + Task SendUserBannedAsync(string username, string? reason); + Task SendMessageDeletedAsync(string channelName, Guid messageId); + Task SendChannelNukedAsync(string channelName); Task SendErrorAsync(string connectionId, string message); } diff --git a/src/EchoHub.Core/Contracts/IEchoHubClient.cs b/src/EchoHub.Core/Contracts/IEchoHubClient.cs index fc33e43..86875d1 100644 --- a/src/EchoHub.Core/Contracts/IEchoHubClient.cs +++ b/src/EchoHub.Core/Contracts/IEchoHubClient.cs @@ -12,5 +12,9 @@ public interface IEchoHubClient Task UserLeft(string channelName, string username); Task ChannelUpdated(ChannelDto channel); Task UserStatusChanged(UserPresenceDto presence); + Task UserKicked(string channelName, string username, string? reason); + Task UserBanned(string username, string? reason); + Task MessageDeleted(string channelName, Guid messageId); + Task ChannelNuked(string channelName); Task Error(string message); } diff --git a/src/EchoHub.Core/DTOs/ModerationDtos.cs b/src/EchoHub.Core/DTOs/ModerationDtos.cs new file mode 100644 index 0000000..60e1c4a --- /dev/null +++ b/src/EchoHub.Core/DTOs/ModerationDtos.cs @@ -0,0 +1,8 @@ +using EchoHub.Core.Models; + +namespace EchoHub.Core.DTOs; + +public record AssignRoleRequest(string Username, ServerRole Role); +public record MuteRequest(string? Reason = null, int? DurationMinutes = null); +public record BanRequest(string? Reason = null); +public record KickRequest(string? Reason = null); diff --git a/src/EchoHub.Core/DTOs/ProfileDtos.cs b/src/EchoHub.Core/DTOs/ProfileDtos.cs index de392c4..b4b28e7 100644 --- a/src/EchoHub.Core/DTOs/ProfileDtos.cs +++ b/src/EchoHub.Core/DTOs/ProfileDtos.cs @@ -11,6 +11,7 @@ public record UserProfileDto( string? AvatarAscii, UserStatus Status, string? StatusMessage, + ServerRole Role, DateTimeOffset CreatedAt, DateTimeOffset LastSeenAt); @@ -28,6 +29,7 @@ public record UserPresenceDto( string? DisplayName, string? NicknameColor, UserStatus Status, - string? StatusMessage); + string? StatusMessage, + ServerRole Role); public record AvatarUploadResponse(string AvatarAscii); diff --git a/src/EchoHub.Core/Models/ServerRole.cs b/src/EchoHub.Core/Models/ServerRole.cs new file mode 100644 index 0000000..fbe6202 --- /dev/null +++ b/src/EchoHub.Core/Models/ServerRole.cs @@ -0,0 +1,9 @@ +namespace EchoHub.Core.Models; + +public enum ServerRole +{ + Member = 0, + Mod = 1, + Admin = 2, + Owner = 3 +} diff --git a/src/EchoHub.Core/Models/User.cs b/src/EchoHub.Core/Models/User.cs index 4df2479..ad8e682 100644 --- a/src/EchoHub.Core/Models/User.cs +++ b/src/EchoHub.Core/Models/User.cs @@ -11,6 +11,10 @@ public class User public string? AvatarAscii { get; set; } public UserStatus Status { get; set; } = UserStatus.Online; public string? StatusMessage { get; set; } + public ServerRole Role { get; set; } = ServerRole.Member; + public bool IsMuted { get; set; } + public DateTimeOffset? MutedUntil { get; set; } + public bool IsBanned { get; set; } public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset LastSeenAt { get; set; } = DateTimeOffset.UtcNow; } diff --git a/src/EchoHub.Server.Irc/IrcBroadcaster.cs b/src/EchoHub.Server.Irc/IrcBroadcaster.cs index 4d9c1bc..6597b5c 100644 --- a/src/EchoHub.Server.Irc/IrcBroadcaster.cs +++ b/src/EchoHub.Server.Irc/IrcBroadcaster.cs @@ -62,6 +62,41 @@ public class IrcBroadcaster : IChatBroadcaster return Task.CompletedTask; } + public async Task SendUserKickedAsync(string channelName, string username, string? reason) + { + var reasonText = reason is not null ? $" :{reason}" : ""; + foreach (var conn in _gateway.GetConnectionsInChannel(channelName)) + { + await conn.SendAsync($":{_gateway.Options.ServerName} KICK #{channelName} {username}{reasonText}"); + } + } + + public async Task SendUserBannedAsync(string username, string? reason) + { + var reasonText = reason ?? "You have been banned."; + foreach (var conn in _gateway.GetAllConnections()) + { + if (conn.Nickname == username) + await conn.SendAsync($":{_gateway.Options.ServerName} NOTICE {username} :You have been banned: {reasonText}"); + } + } + + public async Task SendMessageDeletedAsync(string channelName, Guid messageId) + { + foreach (var conn in _gateway.GetConnectionsInChannel(channelName)) + { + await conn.SendAsync($":{_gateway.Options.ServerName} NOTICE {conn.Nickname ?? "*"} :Message {messageId} was deleted in #{channelName}"); + } + } + + public async Task SendChannelNukedAsync(string channelName) + { + foreach (var conn in _gateway.GetConnectionsInChannel(channelName)) + { + await conn.SendAsync($":{_gateway.Options.ServerName} NOTICE {conn.Nickname ?? "*"} :All messages in #{channelName} have been cleared"); + } + } + public async Task SendErrorAsync(string connectionId, string message) { if (!connectionId.StartsWith("irc-")) return; diff --git a/src/EchoHub.Server.Irc/IrcGatewayService.cs b/src/EchoHub.Server.Irc/IrcGatewayService.cs index 6f063a5..e09ec61 100644 --- a/src/EchoHub.Server.Irc/IrcGatewayService.cs +++ b/src/EchoHub.Server.Irc/IrcGatewayService.cs @@ -37,6 +37,11 @@ public sealed class IrcGatewayService : BackgroundService .Where(c => c.IsAuthenticated && c.JoinedChannels.Contains(channelName)); } + public IEnumerable GetAllConnections() + { + return _connections.Values.Where(c => c.IsAuthenticated); + } + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await Task.Yield(); diff --git a/src/EchoHub.Server/Auth/JwtTokenService.cs b/src/EchoHub.Server/Auth/JwtTokenService.cs index 5f1e525..d0e9477 100644 --- a/src/EchoHub.Server/Auth/JwtTokenService.cs +++ b/src/EchoHub.Server/Auth/JwtTokenService.cs @@ -37,6 +37,7 @@ public class JwtTokenService new(JwtRegisteredClaimNames.Sub, user.Id.ToString()), new("username", user.Username), new("display_name", user.DisplayName ?? user.Username), + new("role", user.Role.ToString()), new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), ]; diff --git a/src/EchoHub.Server/Controllers/AuthController.cs b/src/EchoHub.Server/Controllers/AuthController.cs index 4796cbd..39702e3 100644 --- a/src/EchoHub.Server/Controllers/AuthController.cs +++ b/src/EchoHub.Server/Controllers/AuthController.cs @@ -42,12 +42,16 @@ public class AuthController : ControllerBase if (await _db.Users.AnyAsync(u => u.Username == normalizedUsername)) return Conflict(new ErrorResponse("Username is already taken.")); + // First registered user on the server becomes the Owner + var isFirstUser = !await _db.Users.AnyAsync(); + var user = new User { Id = Guid.NewGuid(), Username = normalizedUsername, PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password), DisplayName = request.DisplayName?.Trim(), + Role = isFirstUser ? ServerRole.Owner : ServerRole.Member, }; _db.Users.Add(user); @@ -80,6 +84,9 @@ public class AuthController : ControllerBase if (user is null || !BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash)) return Unauthorized(new ErrorResponse("Invalid username or password.")); + if (user.IsBanned) + return Unauthorized(new ErrorResponse("Your account has been banned.")); + user.LastSeenAt = DateTimeOffset.UtcNow; await _db.SaveChangesAsync(); diff --git a/src/EchoHub.Server/Controllers/ChannelsController.cs b/src/EchoHub.Server/Controllers/ChannelsController.cs index 76f8836..af6bffb 100644 --- a/src/EchoHub.Server/Controllers/ChannelsController.cs +++ b/src/EchoHub.Server/Controllers/ChannelsController.cs @@ -141,8 +141,10 @@ public class ChannelsController : ControllerBase if (dbChannel is null) return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist.")); - if (dbChannel.CreatedByUserId != Guid.Parse(userIdClaim)) - return StatusCode(403, new ErrorResponse("Only the channel creator can delete the channel.")); + var userId = Guid.Parse(userIdClaim); + var caller = await _db.Users.FindAsync(userId); + if (dbChannel.CreatedByUserId != userId && (caller is null || caller.Role < ServerRole.Admin)) + return StatusCode(403, new ErrorResponse("Only the channel creator or an admin can delete the channel.")); _db.Channels.Remove(dbChannel); await _db.SaveChangesAsync(); diff --git a/src/EchoHub.Server/Controllers/ModerationController.cs b/src/EchoHub.Server/Controllers/ModerationController.cs new file mode 100644 index 0000000..26eef4d --- /dev/null +++ b/src/EchoHub.Server/Controllers/ModerationController.cs @@ -0,0 +1,228 @@ +using System.Security.Claims; +using EchoHub.Core.Contracts; +using EchoHub.Core.DTOs; +using EchoHub.Core.Models; +using EchoHub.Server.Data; +using EchoHub.Server.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.EntityFrameworkCore; + +namespace EchoHub.Server.Controllers; + +[ApiController] +[Route("api/moderation")] +[Authorize] +[EnableRateLimiting("general")] +public class ModerationController : ControllerBase +{ + private readonly EchoHubDbContext _db; + private readonly IChatService _chatService; + private readonly PresenceTracker _presenceTracker; + private readonly IEnumerable _broadcasters; + + public ModerationController( + EchoHubDbContext db, + IChatService chatService, + PresenceTracker presenceTracker, + IEnumerable broadcasters) + { + _db = db; + _chatService = chatService; + _presenceTracker = presenceTracker; + _broadcasters = broadcasters; + } + + [HttpPost("role")] + public async Task AssignRole([FromBody] AssignRoleRequest request) + { + var (caller, error) = await GetCallerAsync(ServerRole.Admin); + if (error is not null) return error; + + if (request.Role == ServerRole.Owner) + return BadRequest(new ErrorResponse("Cannot assign the Owner role.")); + + var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == request.Username.ToLowerInvariant()); + if (target is null) + return NotFound(new ErrorResponse($"User '{request.Username}' not found.")); + + if (target.Role == ServerRole.Owner) + return BadRequest(new ErrorResponse("Cannot change the server owner's role.")); + + if (request.Role >= caller!.Role) + return BadRequest(new ErrorResponse("Cannot assign a role equal to or above your own.")); + + target.Role = request.Role; + await _db.SaveChangesAsync(); + + return Ok(new { Message = $"{target.Username} is now {request.Role}." }); + } + + [HttpPost("kick/{username}")] + public async Task KickUser(string username, [FromBody] KickRequest? request = null) + { + var (caller, error) = await GetCallerAsync(ServerRole.Mod); + if (error is not null) return error; + + var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant()); + if (target is null) + return NotFound(new ErrorResponse($"User '{username}' not found.")); + + if (target.Role >= caller!.Role) + return BadRequest(new ErrorResponse("Cannot kick a user with equal or higher role.")); + + // Broadcast kick to all channels the user is in + var channels = _presenceTracker.GetChannelsForUser(target.Username); + foreach (var channel in channels) + { + await BroadcastToAllAsync(b => b.SendUserKickedAsync(channel, target.Username, request?.Reason)); + } + + return Ok(new { Message = $"{target.Username} has been kicked." }); + } + + [HttpPost("ban/{username}")] + public async Task BanUser(string username, [FromBody] BanRequest? request = null) + { + var (caller, error) = await GetCallerAsync(ServerRole.Admin); + if (error is not null) return error; + + var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant()); + if (target is null) + return NotFound(new ErrorResponse($"User '{username}' not found.")); + + if (target.Role >= caller!.Role) + return BadRequest(new ErrorResponse("Cannot ban a user with equal or higher role.")); + + target.IsBanned = true; + await _db.SaveChangesAsync(); + + await BroadcastToAllAsync(b => b.SendUserBannedAsync(target.Username, request?.Reason)); + + return Ok(new { Message = $"{target.Username} has been banned." }); + } + + [HttpPost("unban/{username}")] + public async Task UnbanUser(string username) + { + var (_, error) = await GetCallerAsync(ServerRole.Admin); + if (error is not null) return error; + + var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant()); + if (target is null) + return NotFound(new ErrorResponse($"User '{username}' not found.")); + + target.IsBanned = false; + await _db.SaveChangesAsync(); + + return Ok(new { Message = $"{target.Username} has been unbanned." }); + } + + [HttpPost("mute/{username}")] + public async Task MuteUser(string username, [FromBody] MuteRequest? request = null) + { + var (caller, error) = await GetCallerAsync(ServerRole.Mod); + if (error is not null) return error; + + var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant()); + if (target is null) + return NotFound(new ErrorResponse($"User '{username}' not found.")); + + if (target.Role >= caller!.Role) + return BadRequest(new ErrorResponse("Cannot mute a user with equal or higher role.")); + + target.IsMuted = true; + target.MutedUntil = request?.DurationMinutes is > 0 + ? DateTimeOffset.UtcNow.AddMinutes(request.DurationMinutes.Value) + : null; + await _db.SaveChangesAsync(); + + var durationText = request?.DurationMinutes is > 0 ? $" for {request.DurationMinutes} minutes" : ""; + return Ok(new { Message = $"{target.Username} has been muted{durationText}." }); + } + + [HttpPost("unmute/{username}")] + public async Task UnmuteUser(string username) + { + var (_, error) = await GetCallerAsync(ServerRole.Mod); + if (error is not null) return error; + + var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant()); + if (target is null) + return NotFound(new ErrorResponse($"User '{username}' not found.")); + + target.IsMuted = false; + target.MutedUntil = null; + await _db.SaveChangesAsync(); + + return Ok(new { Message = $"{target.Username} has been unmuted." }); + } + + [HttpDelete("messages/{messageId:guid}")] + public async Task DeleteMessage(Guid messageId) + { + var (_, error) = await GetCallerAsync(ServerRole.Mod); + if (error is not null) return error; + + var message = await _db.Messages + .Include(m => m.Channel) + .FirstOrDefaultAsync(m => m.Id == messageId); + + if (message is null) + return NotFound(new ErrorResponse("Message not found.")); + + var channelName = message.Channel!.Name; + _db.Messages.Remove(message); + await _db.SaveChangesAsync(); + + await BroadcastToAllAsync(b => b.SendMessageDeletedAsync(channelName, messageId)); + + return Ok(new { Message = "Message deleted." }); + } + + [HttpDelete("channels/{channel}/nuke")] + public async Task NukeChannel(string channel) + { + var (_, error) = await GetCallerAsync(ServerRole.Mod); + if (error is not null) return error; + + var channelName = channel.ToLowerInvariant().Trim(); + var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); + if (dbChannel is null) + return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist.")); + + var messages = await _db.Messages.Where(m => m.ChannelId == dbChannel.Id).ToListAsync(); + _db.Messages.RemoveRange(messages); + await _db.SaveChangesAsync(); + + await BroadcastToAllAsync(b => b.SendChannelNukedAsync(channelName)); + + return Ok(new { Message = $"All messages in #{channelName} have been cleared." }); + } + + private async Task<(User? Caller, IActionResult? Error)> GetCallerAsync(ServerRole minimumRole) + { + var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (userIdClaim is null) + return (null, Unauthorized(new ErrorResponse("Authentication required."))); + + var caller = await _db.Users.FindAsync(Guid.Parse(userIdClaim)); + if (caller is null) + return (null, Unauthorized(new ErrorResponse("User not found."))); + + if (caller.Role < minimumRole) + return (null, StatusCode(403, new ErrorResponse($"Requires {minimumRole} role or higher."))); + + return (caller, null); + } + + private async Task BroadcastToAllAsync(Func action) + { + foreach (var broadcaster in _broadcasters) + { + try { await action(broadcaster); } + catch { /* logged by broadcaster */ } + } + } +} diff --git a/src/EchoHub.Server/Controllers/UsersController.cs b/src/EchoHub.Server/Controllers/UsersController.cs index 2d4f0c9..6909a0f 100644 --- a/src/EchoHub.Server/Controllers/UsersController.cs +++ b/src/EchoHub.Server/Controllers/UsersController.cs @@ -120,6 +120,7 @@ public class UsersController : ControllerBase user.AvatarAscii, user.Status, user.StatusMessage, + user.Role, user.CreatedAt, user.LastSeenAt); } diff --git a/src/EchoHub.Server/Data/EchoHubDbContext.cs b/src/EchoHub.Server/Data/EchoHubDbContext.cs index 801dca6..43396e6 100644 --- a/src/EchoHub.Server/Data/EchoHubDbContext.cs +++ b/src/EchoHub.Server/Data/EchoHubDbContext.cs @@ -34,6 +34,7 @@ public class EchoHubDbContext : DbContext entity.Property(u => u.NicknameColor).HasMaxLength(7); entity.Property(u => u.AvatarAscii).HasMaxLength(10000); entity.Property(u => u.StatusMessage).HasMaxLength(100); + entity.Property(u => u.Role).HasConversion(); }); modelBuilder.Entity(entity => diff --git a/src/EchoHub.Server/Data/Migrations/20260219162414_AddModerationRoles.Designer.cs b/src/EchoHub.Server/Data/Migrations/20260219162414_AddModerationRoles.Designer.cs new file mode 100644 index 0000000..01d67db --- /dev/null +++ b/src/EchoHub.Server/Data/Migrations/20260219162414_AddModerationRoles.Designer.cs @@ -0,0 +1,222 @@ +// +using System; +using EchoHub.Server.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EchoHub.Server.Data.Migrations +{ + [DbContext(typeof(EchoHubDbContext))] + [Migration("20260219162414_AddModerationRoles")] + partial class AddModerationRoles + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.3"); + + modelBuilder.Entity("EchoHub.Core.Models.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("CreatedByUserId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Topic") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AttachmentFileName") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AttachmentUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("SenderUserId") + .HasColumnType("TEXT"); + + b.Property("SenderUsername") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("SentAt") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ExpiresAt") + .HasColumnType("INTEGER"); + + b.Property("RevokedAt") + .HasColumnType("INTEGER"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash"); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AvatarAscii") + .HasMaxLength(10000) + .HasColumnType("TEXT"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IsBanned") + .HasColumnType("INTEGER"); + + b.Property("IsMuted") + .HasColumnType("INTEGER"); + + b.Property("LastSeenAt") + .HasColumnType("INTEGER"); + + b.Property("MutedUntil") + .HasColumnType("INTEGER"); + + b.Property("NicknameColor") + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StatusMessage") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Message", b => + { + b.HasOne("EchoHub.Core.Models.Channel", "Channel") + .WithMany("Messages") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Channel"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b => + { + b.HasOne("EchoHub.Core.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Channel", b => + { + b.Navigation("Messages"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EchoHub.Server/Data/Migrations/20260219162414_AddModerationRoles.cs b/src/EchoHub.Server/Data/Migrations/20260219162414_AddModerationRoles.cs new file mode 100644 index 0000000..edca4e2 --- /dev/null +++ b/src/EchoHub.Server/Data/Migrations/20260219162414_AddModerationRoles.cs @@ -0,0 +1,61 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EchoHub.Server.Data.Migrations +{ + /// + public partial class AddModerationRoles : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsBanned", + table: "Users", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "IsMuted", + table: "Users", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "MutedUntil", + table: "Users", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "Role", + table: "Users", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsBanned", + table: "Users"); + + migrationBuilder.DropColumn( + name: "IsMuted", + table: "Users"); + + migrationBuilder.DropColumn( + name: "MutedUntil", + table: "Users"); + + migrationBuilder.DropColumn( + name: "Role", + table: "Users"); + } + } +} diff --git a/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs b/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs index 8c72662..8b4ec60 100644 --- a/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs +++ b/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs @@ -144,9 +144,18 @@ namespace EchoHub.Server.Data.Migrations .HasMaxLength(100) .HasColumnType("TEXT"); + b.Property("IsBanned") + .HasColumnType("INTEGER"); + + b.Property("IsMuted") + .HasColumnType("INTEGER"); + b.Property("LastSeenAt") .HasColumnType("INTEGER"); + b.Property("MutedUntil") + .HasColumnType("INTEGER"); + b.Property("NicknameColor") .HasMaxLength(7) .HasColumnType("TEXT"); @@ -155,6 +164,9 @@ namespace EchoHub.Server.Data.Migrations .IsRequired() .HasColumnType("TEXT"); + b.Property("Role") + .HasColumnType("INTEGER"); + b.Property("Status") .HasColumnType("INTEGER"); diff --git a/src/EchoHub.Server/Services/ChatService.cs b/src/EchoHub.Server/Services/ChatService.cs index 8bd9065..443ed12 100644 --- a/src/EchoHub.Server/Services/ChatService.cs +++ b/src/EchoHub.Server/Services/ChatService.cs @@ -72,7 +72,8 @@ public class ChatService : IChatService user.DisplayName, user.NicknameColor, UserStatus.Invisible, - user.StatusMessage); + user.StatusMessage, + user.Role); await BroadcastToAllAsync(b => b.SendUserStatusChangedAsync(channelsBeforeDisconnect, presence)); } @@ -139,6 +140,21 @@ public class ChatService : IChatService var sender = await db.Users.FindAsync(userId); + // Check mute status + if (sender is not null && sender.IsMuted) + { + if (sender.MutedUntil.HasValue && sender.MutedUntil.Value <= DateTimeOffset.UtcNow) + { + sender.IsMuted = false; + sender.MutedUntil = null; + await db.SaveChangesAsync(); + } + else + { + return "You are muted and cannot send messages."; + } + } + var message = new Message { Id = Guid.NewGuid(), @@ -203,7 +219,8 @@ public class ChatService : IChatService user.DisplayName, user.NicknameColor, status, - statusMessage); + statusMessage, + user.Role); var channels = _presenceTracker.GetChannelsForUser(username); await BroadcastToAllAsync(b => b.SendUserStatusChangedAsync(channels, presence)); @@ -226,7 +243,8 @@ public class ChatService : IChatService u.DisplayName, u.NicknameColor, u.Status, - u.StatusMessage)) + u.StatusMessage, + u.Role)) .ToListAsync(); } @@ -264,7 +282,7 @@ public class ChatService : IChatService return new UserProfileDto( user.Id, user.Username, user.DisplayName, user.Bio, user.NicknameColor, user.AvatarAscii, user.Status, - user.StatusMessage, user.CreatedAt, user.LastSeenAt); + user.StatusMessage, user.Role, user.CreatedAt, user.LastSeenAt); } public async Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName) diff --git a/src/EchoHub.Server/Services/ImageToAsciiService.cs b/src/EchoHub.Server/Services/ImageToAsciiService.cs index 921273d..0b5d534 100644 --- a/src/EchoHub.Server/Services/ImageToAsciiService.cs +++ b/src/EchoHub.Server/Services/ImageToAsciiService.cs @@ -8,47 +8,72 @@ namespace EchoHub.Server.Services; public class ImageToAsciiService { - private static readonly char[] AsciiChars = " .:-=+*#%@".ToCharArray(); - - public string ConvertToAscii(Stream imageStream, int width = HubConstants.AsciiArtWidth, int height = HubConstants.AsciiArtHeight) + /// + /// Converts an image to ASCII art using half-block characters (▀▄█) with + /// 24-bit ANSI foreground and background colors for 2x vertical resolution. + /// Each character cell represents two vertical pixels. + /// + public string ConvertToAscii(Stream imageStream, int width = HubConstants.AsciiArtWidth, int height = HubConstants.AsciiArtHeightHalfBlock) { using var image = Image.Load(imageStream); + // Ensure height is even for pair processing + if (height % 2 != 0) height++; + image.Mutate(x => x.Resize(width, height)); var sb = new StringBuilder(); - byte lastR = 0, lastG = 0, lastB = 0; - bool hasLastColor = false; - - for (int y = 0; y < image.Height; y++) + for (int y = 0; y < image.Height; y += 2) { + byte lastFgR = 0, lastFgG = 0, lastFgB = 0; + byte lastBgR = 0, lastBgG = 0, lastBgB = 0; + bool hasLastColor = false; + for (int x = 0; x < image.Width; x++) { - var pixel = image[x, y]; - var brightness = 0.299 * pixel.R + 0.587 * pixel.G + 0.114 * pixel.B; + var topPixel = image[x, y]; + var bottomPixel = (y + 1 < image.Height) ? image[x, y + 1] : topPixel; - // Map brightness (0-255) to ASCII char index - var index = (int)((brightness / 255.0) * (AsciiChars.Length - 1)); + byte fgR, fgG, fgB, bgR, bgG, bgB; + char blockChar; - // Emit ANSI 24-bit color only when it changes - if (!hasLastColor || pixel.R != lastR || pixel.G != lastG || pixel.B != lastB) + if (topPixel.R == bottomPixel.R && topPixel.G == bottomPixel.G && topPixel.B == bottomPixel.B) { - sb.Append($"\x1b[38;2;{pixel.R};{pixel.G};{pixel.B}m"); - lastR = pixel.R; - lastG = pixel.G; - lastB = pixel.B; - hasLastColor = true; + // Both pixels same color — full block + fgR = topPixel.R; fgG = topPixel.G; fgB = topPixel.B; + bgR = topPixel.R; bgG = topPixel.G; bgB = topPixel.B; + blockChar = '\u2588'; // █ + } + else + { + // Top pixel = foreground, bottom pixel = background, upper half block + fgR = topPixel.R; fgG = topPixel.G; fgB = topPixel.B; + bgR = bottomPixel.R; bgG = bottomPixel.G; bgB = bottomPixel.B; + blockChar = '\u2580'; // ▀ } - sb.Append(AsciiChars[index]); + // Emit color codes only when they change + bool fgChanged = !hasLastColor || fgR != lastFgR || fgG != lastFgG || fgB != lastFgB; + bool bgChanged = !hasLastColor || bgR != lastBgR || bgG != lastBgG || bgB != lastBgB; + + if (fgChanged) + sb.Append($"\x1b[38;2;{fgR};{fgG};{fgB}m"); + if (bgChanged) + sb.Append($"\x1b[48;2;{bgR};{bgG};{bgB}m"); + + sb.Append(blockChar); + + lastFgR = fgR; lastFgG = fgG; lastFgB = fgB; + lastBgR = bgR; lastBgG = bgG; lastBgB = bgB; + hasLastColor = true; } // Reset color at end of line sb.Append("\x1b[0m"); hasLastColor = false; - if (y < image.Height - 1) + if (y + 2 < image.Height) { sb.AppendLine(); } diff --git a/src/EchoHub.Server/Services/SignalRBroadcaster.cs b/src/EchoHub.Server/Services/SignalRBroadcaster.cs index 47dfd90..a29ee42 100644 --- a/src/EchoHub.Server/Services/SignalRBroadcaster.cs +++ b/src/EchoHub.Server/Services/SignalRBroadcaster.cs @@ -54,6 +54,18 @@ public class SignalRBroadcaster : IChatBroadcaster return HubContext.Clients.Clients(connections).UserStatusChanged(presence); } + public Task SendUserKickedAsync(string channelName, string username, string? reason) + => HubContext.Clients.Group(channelName).UserKicked(channelName, username, reason); + + public Task SendUserBannedAsync(string username, string? reason) + => HubContext.Clients.All.UserBanned(username, reason); + + public Task SendMessageDeletedAsync(string channelName, Guid messageId) + => HubContext.Clients.Group(channelName).MessageDeleted(channelName, messageId); + + public Task SendChannelNukedAsync(string channelName) + => HubContext.Clients.Group(channelName).ChannelNuked(channelName); + public Task SendErrorAsync(string connectionId, string message) { if (connectionId.StartsWith("irc-")) From baebc093f5f1a0c0fffc8327ce0ae5a9233ea607 Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 19 Feb 2026 18:22:33 +0100 Subject: [PATCH 03/22] feat: implement background update check and add version notification in AppOrchestrator --- src/EchoHub.Client/AppOrchestrator.cs | 12 +++++ src/EchoHub.Client/Services/UpdateChecker.cs | 47 ++++++++++++++++++++ src/EchoHub.Client/UI/MainWindow.cs | 32 +++++++++---- 3 files changed, 82 insertions(+), 9 deletions(-) create mode 100644 src/EchoHub.Client/Services/UpdateChecker.cs diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs index 3c9c23d..a0fd696 100644 --- a/src/EchoHub.Client/AppOrchestrator.cs +++ b/src/EchoHub.Client/AppOrchestrator.cs @@ -414,6 +414,18 @@ public sealed class AppOrchestrator : IDisposable FetchAndUpdateOnlineUsers(); SaveServerToConfig(result); + + // Check for newer version in the background + _ = Task.Run(async () => + { + var newVersion = await UpdateChecker.CheckForUpdateAsync(); + if (newVersion is not null) + { + InvokeUI(() => _mainWindow.AddSystemMessage( + HubConstants.DefaultChannel, + $"A new version of EchoHub is available: v{newVersion} (current: v{MainWindow.AppVersion}). Visit https://github.com/HueByte/EchoHub/releases")); + } + }); }, "Connection failed", "Connect"); } diff --git a/src/EchoHub.Client/Services/UpdateChecker.cs b/src/EchoHub.Client/Services/UpdateChecker.cs new file mode 100644 index 0000000..d73c9df --- /dev/null +++ b/src/EchoHub.Client/Services/UpdateChecker.cs @@ -0,0 +1,47 @@ +using System.Net.Http.Json; +using System.Text.Json.Serialization; + +namespace EchoHub.Client.Services; + +public static class UpdateChecker +{ + private static readonly Uri ReleaseUrl = + new("https://api.github.com/repos/HueByte/EchoHub/releases/latest"); + + /// + /// Checks GitHub for a newer release. Returns the new version string if one exists, or null. + /// Never throws — all errors are silently swallowed. + /// + public static async Task CheckForUpdateAsync() + { + try + { + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; + http.DefaultRequestHeaders.UserAgent.ParseAdd("EchoHub-Client"); + + var release = await http.GetFromJsonAsync(ReleaseUrl); + if (release?.TagName is null) + return null; + + var tag = release.TagName.TrimStart('v', 'V'); + if (!Version.TryParse(tag, out var latest)) + return null; + + var currentStr = typeof(UpdateChecker).Assembly.GetName().Version?.ToString(3); + if (currentStr is null || !Version.TryParse(currentStr, out var current)) + return null; + + return latest > current ? tag : null; + } + catch + { + return null; + } + } + + private sealed class GitHubRelease + { + [JsonPropertyName("tag_name")] + public string? TagName { get; set; } + } +} diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index 8f7d5c9..9fc8b14 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -34,7 +34,7 @@ public sealed class MainWindow : Runnable private const int UsersPanelWidth = 22; private static readonly Key F2Key = Key.F2; - private static readonly string AppVersion = + internal static readonly string AppVersion = typeof(MainWindow).Assembly.GetName().Version?.ToString(3) ?? "?"; // Cached Key constants — compare via .KeyCode to avoid Key.Equals (which also checks Handled) @@ -479,19 +479,33 @@ public sealed class MainWindow : Runnable /// public void AddSystemMessage(string channelName, string text) { - var time = DateTimeOffset.Now.ToString("HH:mm"); - var segments = new List - { - new($"[{time}] ", ChatColors.TimestampAttr), - new($"** {text}", ChatColors.SystemAttr) - }; - if (!_channelMessages.TryGetValue(channelName, out var messages)) { messages = []; _channelMessages[channelName] = messages; } - messages.Add(new ChatLine(segments)); + + var time = DateTimeOffset.Now.ToString("HH:mm"); + var textLines = text.Split('\n'); + + // First line gets timestamp prefix + messages.Add(new ChatLine( + [ + new($"[{time}] ", ChatColors.TimestampAttr), + new($"** {textLines[0].TrimEnd('\r')}", ChatColors.SystemAttr) + ])); + + // Continuation lines are indented to align + var indent = new string(' ', $"[{time}] ** ".Length); + for (int i = 1; i < textLines.Length; i++) + { + var line = textLines[i].TrimEnd('\r'); + if (string.IsNullOrWhiteSpace(line)) continue; + messages.Add(new ChatLine( + [ + new($"{indent}{line}", ChatColors.SystemAttr) + ])); + } if (channelName == _currentChannel) { From 3a0ea0d321df9265c1e09f9a2ac5fe87512aca12 Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 19 Feb 2026 18:27:02 +0100 Subject: [PATCH 04/22] feat: enhance file upload and URL sending with optional size parameter --- src/EchoHub.Client/AppOrchestrator.cs | 6 +- src/EchoHub.Client/Commands/CommandHandler.cs | 28 +++++-- src/EchoHub.Client/Services/ApiClient.cs | 10 ++- src/EchoHub.Client/UI/ChatRenderer.cs | 82 ++++++++++++++++++- src/EchoHub.Client/UI/MainWindow.cs | 15 ++-- .../Controllers/ChannelsController.cs | 10 ++- .../Services/ImageToAsciiService.cs | 11 +++ 7 files changed, 139 insertions(+), 23 deletions(-) diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs index a0fd696..4e3474a 100644 --- a/src/EchoHub.Client/AppOrchestrator.cs +++ b/src/EchoHub.Client/AppOrchestrator.cs @@ -117,7 +117,7 @@ public sealed class AppOrchestrator : IDisposable return Task.CompletedTask; }; - _commandHandler.OnSendFile += async (target) => + _commandHandler.OnSendFile += async (target, size) => { if (!IsAuthenticated || !IsConnected) return; @@ -129,13 +129,13 @@ public sealed class AppOrchestrator : IDisposable if (Uri.TryCreate(target, UriKind.Absolute, out var uri) && (uri.Scheme == "http" || uri.Scheme == "https")) { - await _apiClient!.SendUrlAsync(channel, target); + await _apiClient!.SendUrlAsync(channel, target, size); } else { await using var stream = File.OpenRead(target); var fileName = Path.GetFileName(target); - await _apiClient!.UploadFileAsync(channel, stream, fileName); + await _apiClient!.UploadFileAsync(channel, stream, fileName, size); } } catch (Exception ex) diff --git a/src/EchoHub.Client/Commands/CommandHandler.cs b/src/EchoHub.Client/Commands/CommandHandler.cs index 05596cc..c33d4d5 100644 --- a/src/EchoHub.Client/Commands/CommandHandler.cs +++ b/src/EchoHub.Client/Commands/CommandHandler.cs @@ -10,7 +10,7 @@ public class CommandHandler public event Func? OnSetNick; public event Func? OnSetColor; public event Func? OnSetTheme; - public event Func? OnSendFile; + public event Func? OnSendFile; public event Func? OnOpenProfile; public event Func? OnOpenServers; public event Func? OnJoinChannel; @@ -134,15 +134,31 @@ public class CommandHandler private async Task HandleSend(string args) { if (string.IsNullOrWhiteSpace(args)) - return new CommandResult(true, "Usage: /send ", IsError: true); + return new CommandResult(true, "Usage: /send [-s|-m|-l]", IsError: true); - var target = args.Trim().Trim('"'); + // Parse optional size flag (-s, -m, -l) + string? size = null; + var parts = args.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var targetParts = new List(); + + foreach (var part in parts) + { + if (part is "-s" or "-m" or "-l") + size = part[1..]; // "s", "m", or "l" + else + targetParts.Add(part); + } + + var target = string.Join(' ', targetParts).Trim('"'); + + if (string.IsNullOrWhiteSpace(target)) + return new CommandResult(true, "Usage: /send [-s|-m|-l]", IsError: true); if (Uri.TryCreate(target, UriKind.Absolute, out var uri) && (uri.Scheme == "http" || uri.Scheme == "https")) { if (OnSendFile is not null) - await OnSendFile(target); + await OnSendFile(target, size); var fileName = Path.GetFileName(uri.LocalPath); if (string.IsNullOrWhiteSpace(fileName)) fileName = "image"; @@ -153,7 +169,7 @@ public class CommandHandler return new CommandResult(true, $"File not found: {target}", IsError: true); if (OnSendFile is not null) - await OnSendFile(target); + await OnSendFile(target, size); return new CommandResult(true, $"Uploading: {Path.GetFileName(target)}..."); } @@ -326,7 +342,7 @@ public class CommandHandler /nick - Set display name /color <#hex> - Set nickname color /theme - Switch theme - /send - Send a file or image + /send [-s|-m|-l] - Send a file or image (size: small/medium/large) /avatar - Set your avatar /profile [username] - View a profile /servers - Open saved servers diff --git a/src/EchoHub.Client/Services/ApiClient.cs b/src/EchoHub.Client/Services/ApiClient.cs index 64059f9..06f8249 100644 --- a/src/EchoHub.Client/Services/ApiClient.cs +++ b/src/EchoHub.Client/Services/ApiClient.cs @@ -160,7 +160,7 @@ public sealed class ApiClient : IDisposable return result?.AvatarAscii; } - public async Task UploadFileAsync(string channelName, Stream fileStream, string fileName) + public async Task UploadFileAsync(string channelName, Stream fileStream, string fileName, string? size = null) { EnsureAuthenticated(); using var content = new MultipartFormDataContent(); @@ -168,18 +168,20 @@ public sealed class ApiClient : IDisposable streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(fileName)); content.Add(streamContent, "file", fileName); + var sizeQuery = size is not null ? $"?size={size}" : ""; var response = await AuthenticatedRequestAsync(() => - _http.PostAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/upload", content)); + _http.PostAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/upload{sizeQuery}", content)); await EnsureSuccessAsync(response); return await response.Content.ReadFromJsonAsync(); } - public async Task SendUrlAsync(string channelName, string url) + public async Task SendUrlAsync(string channelName, string url, string? size = null) { EnsureAuthenticated(); var request = new SendUrlRequest(url); + var sizeQuery = size is not null ? $"?size={size}" : ""; var response = await AuthenticatedRequestAsync(() => - _http.PostAsJsonAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/send-url", request)); + _http.PostAsJsonAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/send-url{sizeQuery}", request)); await EnsureSuccessAsync(response); return await response.Content.ReadFromJsonAsync(); } diff --git a/src/EchoHub.Client/UI/ChatRenderer.cs b/src/EchoHub.Client/UI/ChatRenderer.cs index b5bd92a..66681b8 100644 --- a/src/EchoHub.Client/UI/ChatRenderer.cs +++ b/src/EchoHub.Client/UI/ChatRenderer.cs @@ -214,7 +214,8 @@ public class ChatListSource : IListDataSource listView.Move(Math.Max(col - viewportX, 0), row); var chatLine = _lines[item]; - var normalAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal); + // Always use Normal — chat messages should not show focus/selection highlight + var normalAttr = listView.GetAttributeForRole(VisualRole.Normal); var mentionBg = chatLine.IsMention ? ChatColors.MentionHighlightAttr.Background : (Color?)null; int charPos = 0; @@ -368,6 +369,85 @@ public class ChannelListSource : IListDataSource public void Dispose() { } } +/// +/// Custom list data source for the online users panel with per-user nickname colors. +/// +public class UserListSource : IListDataSource +{ + private readonly List<(string Text, Attribute? NameColor)> _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) + { + _users.Clear(); + _users.AddRange(users); + MaxItemLength = users.Count > 0 ? users.Max(u => u.Text.Length) : 0; + if (!SuspendCollectionChangedEvent) + CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + } + + public bool IsMarked(int item) => false; + public void SetMark(int item, bool value) { } + public IList ToList() => _users.Select(u => 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 normalAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal); + + // Find where the name starts (after status icon + space + optional role badge) + // Format: "● ★Username" or "● Username" + int nameStart = 0; + int i = 0; + // Skip status icon + while (i < text.Length && !char.IsLetterOrDigit(text[i]) && text[i] != '_') i++; + nameStart = i; + + int drawnChars = 0; + + // Draw prefix (status icon + role badge) in normal color + var prefixAttr = normalAttr; + for (int c = 0; c < nameStart && c < text.Length; c++) + { + if (drawnChars < width) + { + listView.SetAttribute(prefixAttr); + listView.AddRune(new Rune(text[c])); + drawnChars++; + } + } + + // Draw name in nickname color + var userAttr = nameColor ?? normalAttr; + if (selected) userAttr = normalAttr; // use focus attr when selected + for (int c = nameStart; c < text.Length; c++) + { + if (drawnChars < width) + { + listView.SetAttribute(userAttr); + listView.AddRune(new Rune(text[c])); + drawnChars++; + } + } + + // Fill rest + listView.SetAttribute(normalAttr); + while (drawnChars < width) + { + listView.AddRune(new Rune(' ')); + drawnChars++; + } + } + + public void Dispose() { } +} + /// /// Shared color attributes for chat rendering (timestamps, system messages). /// diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index 9fc8b14..98d85c2 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -1,4 +1,3 @@ -using System.Collections.ObjectModel; using System.Text.RegularExpressions; using EchoHub.Client.Themes; using EchoHub.Core.DTOs; @@ -30,6 +29,7 @@ public sealed class MainWindow : Runnable // Online users panel private readonly FrameView _usersFrame; private readonly ListView _usersList; + private readonly UserListSource _usersListSource; private bool _usersPanelVisible = true; private const int UsersPanelWidth = 22; private static readonly Key F2Key = Key.F2; @@ -215,7 +215,8 @@ public sealed class MainWindow : Runnable Width = Dim.Fill(), Height = Dim.Fill() }; - _usersList.SetSource(new ObservableCollection()); + _usersListSource = new UserListSource(); + _usersList.Source = _usersListSource; _usersFrame.Add(_usersList); Add(_usersFrame); @@ -679,7 +680,8 @@ public sealed class MainWindow : Runnable _chatFrame.Title = "Chat"; _topicLabel.Visible = false; _chatFrame.Y = 1; - _usersList.SetSource(new ObservableCollection()); + _usersListSource.Update([]); + _usersList.Source = _usersListSource; _usersFrame.Title = "Users"; RefreshMessages(); } @@ -805,10 +807,13 @@ public sealed class MainWindow : Runnable ServerRole.Mod => "\u2740", // ❀ _ => "" }; - return $"{statusIcon} {roleTag}{name}"; + var text = $"{statusIcon} {roleTag}{name}"; + var nameColor = ColorHelper.ParseHexColor(u.NicknameColor); + return (text, nameColor); }).ToList(); - _usersList.SetSource(new ObservableCollection(displayItems)); + _usersListSource.Update(displayItems); + _usersList.Source = _usersListSource; _usersFrame.Title = $"Users ({users.Count})"; } diff --git a/src/EchoHub.Server/Controllers/ChannelsController.cs b/src/EchoHub.Server/Controllers/ChannelsController.cs index af6bffb..eabef78 100644 --- a/src/EchoHub.Server/Controllers/ChannelsController.cs +++ b/src/EchoHub.Server/Controllers/ChannelsController.cs @@ -154,7 +154,7 @@ public class ChannelsController : ControllerBase [HttpPost("{channel}/upload")] [EnableRateLimiting("upload")] - public async Task Upload(string channel) + public async Task Upload(string channel, [FromQuery] string? size = null) { var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); var usernameClaim = User.FindFirstValue("username"); @@ -190,8 +190,9 @@ public class ChannelsController : ControllerBase if (isImage) { + var (w, h) = ImageToAsciiService.GetDimensions(size); using var imageStream = System.IO.File.OpenRead(filePath); - content = _asciiService.ConvertToAscii(imageStream); + content = _asciiService.ConvertToAscii(imageStream, w, h); } else { @@ -235,7 +236,7 @@ public class ChannelsController : ControllerBase [HttpPost("{channel}/send-url")] [EnableRateLimiting("upload")] - public async Task SendUrl(string channel, [FromBody] SendUrlRequest request) + public async Task SendUrl(string channel, [FromBody] SendUrlRequest request, [FromQuery] string? size = null) { var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); var usernameClaim = User.FindFirstValue("username"); @@ -310,9 +311,10 @@ public class ChannelsController : ControllerBase var (fileId, filePath) = await _fileStorage.SaveFileAsync(memoryStream, fileName); string content; + var (w, h) = ImageToAsciiService.GetDimensions(size); using (var imageStream = System.IO.File.OpenRead(filePath)) { - content = _asciiService.ConvertToAscii(imageStream); + content = _asciiService.ConvertToAscii(imageStream, w, h); } var attachmentUrl = $"/api/files/{fileId}"; diff --git a/src/EchoHub.Server/Services/ImageToAsciiService.cs b/src/EchoHub.Server/Services/ImageToAsciiService.cs index 0b5d534..a354a29 100644 --- a/src/EchoHub.Server/Services/ImageToAsciiService.cs +++ b/src/EchoHub.Server/Services/ImageToAsciiService.cs @@ -8,6 +8,17 @@ namespace EchoHub.Server.Services; public class ImageToAsciiService { + /// + /// Returns (width, height) dimensions for the given size code. + /// s = small (40x40), m = medium/default (80x80), l = large (120x120). + /// + public static (int Width, int Height) GetDimensions(string? size) => size?.ToLowerInvariant() switch + { + "s" => (40, 40), + "l" => (120, 120), + _ => (HubConstants.AsciiArtWidth, HubConstants.AsciiArtHeightHalfBlock), + }; + /// /// Converts an image to ASCII art using half-block characters (▀▄█) with /// 24-bit ANSI foreground and background colors for 2x vertical resolution. From 8dfb1a4fb8f3823e49f2c245951c3c239adad841 Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 19 Feb 2026 18:54:50 +0100 Subject: [PATCH 05/22] feat: add IsPublic property to channels and enhance channel creation with visibility options --- src/EchoHub.Client/AppOrchestrator.cs | 7 +- src/EchoHub.Client/Program.cs | 2 + src/EchoHub.Client/Services/ApiClient.cs | 4 +- src/EchoHub.Client/UI/ChatRenderer.cs | 92 ++++--- src/EchoHub.Client/UI/CreateChannelDialog.cs | 23 +- src/EchoHub.Client/UI/MainWindow.cs | 20 +- src/EchoHub.Core/DTOs/ChatDtos.cs | 3 +- src/EchoHub.Core/Models/Channel.cs | 1 + src/EchoHub.Server.Irc/IrcMessageFormatter.cs | 34 ++- .../Controllers/ChannelsController.cs | 14 +- ...60219172834_AddChannelIsPublic.Designer.cs | 225 ++++++++++++++++++ .../20260219172834_AddChannelIsPublic.cs | 29 +++ .../EchoHubDbContextModelSnapshot.cs | 3 + .../Services/ImageToAsciiService.cs | 14 +- 14 files changed, 412 insertions(+), 59 deletions(-) create mode 100644 src/EchoHub.Server/Data/Migrations/20260219172834_AddChannelIsPublic.Designer.cs create mode 100644 src/EchoHub.Server/Data/Migrations/20260219172834_AddChannelIsPublic.cs diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs index 4e3474a..0e4268d 100644 --- a/src/EchoHub.Client/AppOrchestrator.cs +++ b/src/EchoHub.Client/AppOrchestrator.cs @@ -212,6 +212,8 @@ public sealed class AppOrchestrator : IDisposable var history = await _connection!.JoinChannelAsync(channelName); InvokeUI(() => { + // Add to channel list if not already there (e.g. private channels) + _mainWindow.EnsureChannelInList(channelName); _mainWindow.SwitchToChannel(channelName); if (history.Count > 0) _mainWindow.LoadHistory(channelName, history); @@ -704,17 +706,18 @@ public sealed class AppOrchestrator : IDisposable RunAsync(async () => { - var channel = await _apiClient!.CreateChannelAsync(result.Name, result.Topic); + var channel = await _apiClient!.CreateChannelAsync(result.Name, result.Topic, result.IsPublic); if (channel is null) return; _joinedChannels.Add(channel.Name); var history = await _connection!.JoinChannelAsync(channel.Name); - // Refresh the channel list + // Refresh the channel list and ensure private channels show up var channels = await _apiClient.GetChannelsAsync(); InvokeUI(() => { _mainWindow.SetChannels(channels); + _mainWindow.EnsureChannelInList(channel.Name); _mainWindow.SwitchToChannel(channel.Name); if (history.Count > 0) _mainWindow.LoadHistory(channel.Name, history); diff --git a/src/EchoHub.Client/Program.cs b/src/EchoHub.Client/Program.cs index 23b0598..2955339 100644 --- a/src/EchoHub.Client/Program.cs +++ b/src/EchoHub.Client/Program.cs @@ -4,6 +4,8 @@ using EchoHub.Client.Themes; using Microsoft.Extensions.Configuration; using Serilog; using Terminal.Gui.App; +using Terminal.Gui.Drawing; + var appSettingsPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json"); if (!File.Exists(appSettingsPath)) diff --git a/src/EchoHub.Client/Services/ApiClient.cs b/src/EchoHub.Client/Services/ApiClient.cs index 06f8249..8c6acd9 100644 --- a/src/EchoHub.Client/Services/ApiClient.cs +++ b/src/EchoHub.Client/Services/ApiClient.cs @@ -186,10 +186,10 @@ public sealed class ApiClient : IDisposable return await response.Content.ReadFromJsonAsync(); } - public async Task CreateChannelAsync(string name, string? topic = null) + public async Task CreateChannelAsync(string name, string? topic = null, bool isPublic = true) { EnsureAuthenticated(); - var request = new CreateChannelRequest(name, topic); + var request = new CreateChannelRequest(name, topic, isPublic); var response = await AuthenticatedRequestAsync(() => _http.PostAsJsonAsync("/api/channels", request)); await EnsureSuccessAsync(response); diff --git a/src/EchoHub.Client/UI/ChatRenderer.cs b/src/EchoHub.Client/UI/ChatRenderer.cs index 66681b8..29b4284 100644 --- a/src/EchoHub.Client/UI/ChatRenderer.cs +++ b/src/EchoHub.Client/UI/ChatRenderer.cs @@ -90,13 +90,23 @@ public partial class ChatLine } /// - /// Parse a string containing ANSI 24-bit color escape codes into colored segments. - /// Supports foreground (\x1b[38;2;R;G;Bm), background (\x1b[48;2;R;G;Bm), and reset (\x1b[0m). + /// Returns true if a line contains color tags (new format or legacy ANSI). /// - public static ChatLine FromAnsi(string ansiText, Attribute? defaultAttr = null) + public static bool HasColorTags(string text) => + text.Contains("{F:") || text.Contains("{B:") || text.Contains("{X}") || text.Contains('\x1b'); + + /// + /// Parse a string containing color tags into colored segments. + /// Supports the new printable format ({F:RRGGBB}, {B:RRGGBB}, {X}) + /// and legacy ANSI format (\x1b[38;2;R;G;Bm, \x1b[48;2;R;G;Bm, \x1b[0m). + /// + public static ChatLine FromColoredText(string text, Attribute? defaultAttr = null) { + // Detect which format is used and pick the right regex + var regex = text.Contains('\x1b') ? AnsiColorRegex() : ColorTagRegex(); + bool isAnsi = text.Contains('\x1b'); + var segments = new List(); - var regex = AnsiColorRegex(); int lastIndex = 0; Color? currentFg = null; Color? currentBg = null; @@ -111,52 +121,76 @@ public partial class ChatLine return new Attribute(fg, bg); } - foreach (Match match in regex.Matches(ansiText)) + foreach (Match match in regex.Matches(text)) { - // Add any text before this escape sequence if (match.Index > lastIndex) { - var text = ansiText[lastIndex..match.Index]; - if (text.Length > 0) - segments.Add(new ChatSegment(text, BuildAttr())); + var t = text[lastIndex..match.Index]; + if (t.Length > 0) + segments.Add(new ChatSegment(t, BuildAttr())); } - // Parse the escape sequence - if (match.Groups[1].Value == "0") + if (isAnsi) { - // Reset - currentFg = null; - currentBg = null; + // Legacy ANSI format + if (match.Groups[1].Value == "0") + { + currentFg = null; + currentBg = null; + } + else if (match.Groups[2].Success) + { + var r = int.Parse(match.Groups[3].Value); + var g = int.Parse(match.Groups[4].Value); + var b = int.Parse(match.Groups[5].Value); + if (match.Groups[2].Value == "38;2") + currentFg = new Color(r, g, b); + else + currentBg = new Color(r, g, b); + } } - else if (match.Groups[2].Success) + else { - var r = int.Parse(match.Groups[3].Value); - var g = int.Parse(match.Groups[4].Value); - var b = int.Parse(match.Groups[5].Value); - - if (match.Groups[2].Value == "38;2") - currentFg = new Color(r, g, b); - else // 48;2 - currentBg = new Color(r, g, b); + // New printable tag format: {F:RRGGBB}, {B:RRGGBB}, {X} + if (match.Groups[6].Success) + { + // Reset {X} + currentFg = null; + currentBg = null; + } + else if (match.Groups[7].Success) + { + var hex = match.Groups[8].Value; + var r = Convert.ToInt32(hex[..2], 16); + var g = Convert.ToInt32(hex[2..4], 16); + var b = Convert.ToInt32(hex[4..6], 16); + if (match.Groups[7].Value == "F") + currentFg = new Color(r, g, b); + else + currentBg = new Color(r, g, b); + } } lastIndex = match.Index + match.Length; } - // Add remaining text - if (lastIndex < ansiText.Length) + if (lastIndex < text.Length) { - var text = ansiText[lastIndex..]; - if (text.Length > 0) - segments.Add(new ChatSegment(text, BuildAttr())); + var t = text[lastIndex..]; + if (t.Length > 0) + segments.Add(new ChatSegment(t, BuildAttr())); } return segments.Count > 0 ? new ChatLine(segments) : new ChatLine(""); } - // Matches: \x1b[0m (reset), \x1b[38;2;R;G;Bm (fg), or \x1b[48;2;R;G;Bm (bg) + // Legacy: \x1b[0m, \x1b[38;2;R;G;Bm, \x1b[48;2;R;G;Bm [GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2|48;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")] private static partial Regex AnsiColorRegex(); + + // New: {X} (reset), {F:RRGGBB} (foreground), {B:RRGGBB} (background) + [GeneratedRegex(@"\{(?:(X)|(?:(F|B):([0-9A-Fa-f]{6})))\}")] + private static partial Regex ColorTagRegex(); } /// diff --git a/src/EchoHub.Client/UI/CreateChannelDialog.cs b/src/EchoHub.Client/UI/CreateChannelDialog.cs index 573afe3..8c407b6 100644 --- a/src/EchoHub.Client/UI/CreateChannelDialog.cs +++ b/src/EchoHub.Client/UI/CreateChannelDialog.cs @@ -4,7 +4,7 @@ using Terminal.Gui.ViewBase; namespace EchoHub.Client.UI; -public record CreateChannelResult(string Name, string? Topic); +public record CreateChannelResult(string Name, string? Topic, bool IsPublic); public sealed class CreateChannelDialog { @@ -12,7 +12,7 @@ public sealed class CreateChannelDialog { CreateChannelResult? result = null; - var dialog = new Dialog { Title = "Create Channel", Width = 50, Height = 12 }; + var dialog = new Dialog { Title = "Create Channel", Width = 50, Height = 14 }; var nameLabel = new Label { Text = "Name:", X = 1, Y = 1 }; var nameField = new TextField { X = 10, Y = 1, Width = Dim.Fill(2) }; @@ -20,11 +20,19 @@ public sealed class CreateChannelDialog var topicLabel = new Label { Text = "Topic:", X = 1, Y = 3 }; var topicField = new TextField { X = 10, Y = 3, Width = Dim.Fill(2) }; + var publicCheckbox = new CheckBox + { + Text = "Public (visible to all users)", + X = 1, + Y = 5, + Value = CheckState.Checked + }; + var hintLabel = new Label { Text = "Lowercase letters, digits, hyphens, underscores (2-100 chars)", X = 1, - Y = 5, + Y = 7, }; var createButton = new Button @@ -32,14 +40,14 @@ public sealed class CreateChannelDialog Text = "Create", IsDefault = true, X = Pos.Center() - 10, - Y = 7 + Y = 9 }; var cancelButton = new Button { Text = "Cancel", X = Pos.Center() + 5, - Y = 7 + Y = 9 }; createButton.Accepting += (s, e) => @@ -55,7 +63,8 @@ public sealed class CreateChannelDialog if (string.IsNullOrWhiteSpace(topic)) topic = null; - result = new CreateChannelResult(name, topic); + var isPublic = publicCheckbox.Value == CheckState.Checked; + result = new CreateChannelResult(name, topic, isPublic); e.Handled = true; app.RequestStop(); }; @@ -67,7 +76,7 @@ public sealed class CreateChannelDialog app.RequestStop(); }; - dialog.Add(nameLabel, nameField, topicLabel, topicField, hintLabel, createButton, cancelButton); + dialog.Add(nameLabel, nameField, topicLabel, topicField, publicCheckbox, hintLabel, createButton, cancelButton); nameField.SetFocus(); app.Run(dialog); diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index 98d85c2..5f94915 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -582,6 +582,20 @@ public sealed class MainWindow : Runnable RefreshChannelList(); } + /// + /// Ensure a channel exists in the left panel list (used for private channels joined via /join). + /// + public void EnsureChannelInList(string channelName) + { + if (_channelNames.Contains(channelName)) + return; + + _channelNames.Add(channelName); + if (!_channelMessages.ContainsKey(channelName)) + _channelMessages[channelName] = []; + RefreshChannelList(); + } + /// /// Update the topic for a specific channel. /// @@ -838,10 +852,10 @@ public sealed class MainWindow : Runnable { foreach (var artLine in message.Content.Split('\n')) { - // Parse ANSI color codes from colored ASCII art + // Parse color tags from colored ASCII art var trimmed = artLine.TrimEnd('\r'); - if (trimmed.Contains('\x1b')) - lines.Add(ChatLine.FromAnsi(" " + trimmed)); + if (ChatLine.HasColorTags(trimmed)) + lines.Add(ChatLine.FromColoredText(" " + trimmed)); else lines.Add(new ChatLine($" {trimmed}")); } diff --git a/src/EchoHub.Core/DTOs/ChatDtos.cs b/src/EchoHub.Core/DTOs/ChatDtos.cs index 25a84e3..d24b1f1 100644 --- a/src/EchoHub.Core/DTOs/ChatDtos.cs +++ b/src/EchoHub.Core/DTOs/ChatDtos.cs @@ -17,6 +17,7 @@ public record ChannelDto( Guid Id, string Name, string? Topic, + bool IsPublic, int MessageCount, DateTimeOffset CreatedAt); @@ -30,7 +31,7 @@ public record UserDto( public record SendMessageRequest(string ChannelName, string Content); -public record CreateChannelRequest(string Name, string? Topic = null); +public record CreateChannelRequest(string Name, string? Topic = null, bool IsPublic = true); public record UpdateTopicRequest(string? Topic); diff --git a/src/EchoHub.Core/Models/Channel.cs b/src/EchoHub.Core/Models/Channel.cs index dca533c..b936e6e 100644 --- a/src/EchoHub.Core/Models/Channel.cs +++ b/src/EchoHub.Core/Models/Channel.cs @@ -5,6 +5,7 @@ public class Channel public Guid Id { get; set; } public required string Name { get; set; } public string? Topic { get; set; } + public bool IsPublic { get; set; } = true; public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public Guid CreatedByUserId { get; set; } diff --git a/src/EchoHub.Server.Irc/IrcMessageFormatter.cs b/src/EchoHub.Server.Irc/IrcMessageFormatter.cs index a161313..e0963d1 100644 --- a/src/EchoHub.Server.Irc/IrcMessageFormatter.cs +++ b/src/EchoHub.Server.Irc/IrcMessageFormatter.cs @@ -1,10 +1,11 @@ using System.Text; +using System.Text.RegularExpressions; using EchoHub.Core.DTOs; using EchoHub.Core.Models; namespace EchoHub.Server.Irc; -public static class IrcMessageFormatter +public static partial class IrcMessageFormatter { private const int MaxIrcLineContentBytes = 400; @@ -33,7 +34,7 @@ public static class IrcMessageFormatter { var trimmed = line.TrimEnd('\r'); if (trimmed.Length > 0) - lines.Add($"{prefix} PRIVMSG {ircChannel} :{trimmed}"); + lines.Add($"{prefix} PRIVMSG {ircChannel} :{ColorTagsToAnsi(trimmed)}"); } break; @@ -45,6 +46,35 @@ public static class IrcMessageFormatter return lines; } + /// + /// Convert printable color tags ({F:RRGGBB}, {B:RRGGBB}, {X}) to ANSI escape codes for IRC clients. + /// Also passes through content that already uses ANSI codes unchanged. + /// + public static string ColorTagsToAnsi(string text) + { + if (!text.Contains('{')) + return text; + + return ColorTagRegex().Replace(text, match => + { + if (match.Groups[1].Success) // {X} reset + return "\x1b[0m"; + if (match.Groups[2].Success) // {F:RRGGBB} or {B:RRGGBB} + { + var hex = match.Groups[3].Value; + var r = Convert.ToInt32(hex[..2], 16); + var g = Convert.ToInt32(hex[2..4], 16); + var b = Convert.ToInt32(hex[4..6], 16); + var code = match.Groups[2].Value == "F" ? "38" : "48"; + return $"\x1b[{code};2;{r};{g};{b}m"; + } + return match.Value; + }); + } + + [GeneratedRegex(@"\{(?:(X)|(?:(F|B):([0-9A-Fa-f]{6})))\}")] + private static partial Regex ColorTagRegex(); + /// /// Split a message into chunks of approximately maxBytes (UTF-8), at word boundaries. /// diff --git a/src/EchoHub.Server/Controllers/ChannelsController.cs b/src/EchoHub.Server/Controllers/ChannelsController.cs index eabef78..9051fd9 100644 --- a/src/EchoHub.Server/Controllers/ChannelsController.cs +++ b/src/EchoHub.Server/Controllers/ChannelsController.cs @@ -43,9 +43,10 @@ public class ChannelsController : ControllerBase offset = Math.Max(0, offset); limit = Math.Clamp(limit, 1, 100); - var total = await _db.Channels.CountAsync(); + var query = _db.Channels.Where(c => c.IsPublic); + var total = await query.CountAsync(); - var channels = await _db.Channels + var channels = await query .OrderBy(c => c.Name) .Skip(offset) .Take(limit) @@ -53,6 +54,7 @@ public class ChannelsController : ControllerBase c.Id, c.Name, c.Topic, + c.IsPublic, c.Messages.Count, c.CreatedAt)) .ToListAsync(); @@ -83,14 +85,16 @@ public class ChannelsController : ControllerBase Id = Guid.NewGuid(), Name = channelName, Topic = request.Topic?.Trim(), + IsPublic = request.IsPublic, CreatedByUserId = Guid.Parse(userIdClaim), }; _db.Channels.Add(channel); await _db.SaveChangesAsync(); - var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, 0, channel.CreatedAt); - await _chatService.BroadcastChannelUpdatedAsync(dto); + var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, channel.IsPublic, 0, channel.CreatedAt); + if (channel.IsPublic) + await _chatService.BroadcastChannelUpdatedAsync(dto); return Created($"/api/channels/{channelName}", dto); } @@ -118,7 +122,7 @@ public class ChannelsController : ControllerBase await _db.SaveChangesAsync(); var messageCount = await _db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id); - var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, messageCount, dbChannel.CreatedAt); + var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, messageCount, dbChannel.CreatedAt); await _chatService.BroadcastChannelUpdatedAsync(dto, channelName); return Ok(dto); diff --git a/src/EchoHub.Server/Data/Migrations/20260219172834_AddChannelIsPublic.Designer.cs b/src/EchoHub.Server/Data/Migrations/20260219172834_AddChannelIsPublic.Designer.cs new file mode 100644 index 0000000..9c49ce0 --- /dev/null +++ b/src/EchoHub.Server/Data/Migrations/20260219172834_AddChannelIsPublic.Designer.cs @@ -0,0 +1,225 @@ +// +using System; +using EchoHub.Server.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EchoHub.Server.Data.Migrations +{ + [DbContext(typeof(EchoHubDbContext))] + [Migration("20260219172834_AddChannelIsPublic")] + partial class AddChannelIsPublic + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.3"); + + modelBuilder.Entity("EchoHub.Core.Models.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("CreatedByUserId") + .HasColumnType("TEXT"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Topic") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AttachmentFileName") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AttachmentUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("SenderUserId") + .HasColumnType("TEXT"); + + b.Property("SenderUsername") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("SentAt") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ExpiresAt") + .HasColumnType("INTEGER"); + + b.Property("RevokedAt") + .HasColumnType("INTEGER"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash"); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AvatarAscii") + .HasMaxLength(10000) + .HasColumnType("TEXT"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IsBanned") + .HasColumnType("INTEGER"); + + b.Property("IsMuted") + .HasColumnType("INTEGER"); + + b.Property("LastSeenAt") + .HasColumnType("INTEGER"); + + b.Property("MutedUntil") + .HasColumnType("INTEGER"); + + b.Property("NicknameColor") + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StatusMessage") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Message", b => + { + b.HasOne("EchoHub.Core.Models.Channel", "Channel") + .WithMany("Messages") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Channel"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b => + { + b.HasOne("EchoHub.Core.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Channel", b => + { + b.Navigation("Messages"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EchoHub.Server/Data/Migrations/20260219172834_AddChannelIsPublic.cs b/src/EchoHub.Server/Data/Migrations/20260219172834_AddChannelIsPublic.cs new file mode 100644 index 0000000..8cb5a71 --- /dev/null +++ b/src/EchoHub.Server/Data/Migrations/20260219172834_AddChannelIsPublic.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EchoHub.Server.Data.Migrations +{ + /// + public partial class AddChannelIsPublic : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsPublic", + table: "Channels", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsPublic", + table: "Channels"); + } + } +} diff --git a/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs b/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs index 8b4ec60..84a9178 100644 --- a/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs +++ b/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs @@ -29,6 +29,9 @@ namespace EchoHub.Server.Data.Migrations b.Property("CreatedByUserId") .HasColumnType("TEXT"); + b.Property("IsPublic") + .HasColumnType("INTEGER"); + b.Property("Name") .IsRequired() .HasMaxLength(100) diff --git a/src/EchoHub.Server/Services/ImageToAsciiService.cs b/src/EchoHub.Server/Services/ImageToAsciiService.cs index a354a29..a1520de 100644 --- a/src/EchoHub.Server/Services/ImageToAsciiService.cs +++ b/src/EchoHub.Server/Services/ImageToAsciiService.cs @@ -21,8 +21,10 @@ public class ImageToAsciiService /// /// Converts an image to ASCII art using half-block characters (▀▄█) with - /// 24-bit ANSI foreground and background colors for 2x vertical resolution. + /// printable color tags for 2x vertical resolution. /// Each character cell represents two vertical pixels. + /// Format: {F:RRGGBB} foreground, {B:RRGGBB} background, {X} reset. + /// Uses only printable ASCII — no terminal escape bytes. /// public string ConvertToAscii(Stream imageStream, int width = HubConstants.AsciiArtWidth, int height = HubConstants.AsciiArtHeightHalfBlock) { @@ -51,27 +53,24 @@ public class ImageToAsciiService if (topPixel.R == bottomPixel.R && topPixel.G == bottomPixel.G && topPixel.B == bottomPixel.B) { - // Both pixels same color — full block fgR = topPixel.R; fgG = topPixel.G; fgB = topPixel.B; bgR = topPixel.R; bgG = topPixel.G; bgB = topPixel.B; blockChar = '\u2588'; // █ } else { - // Top pixel = foreground, bottom pixel = background, upper half block fgR = topPixel.R; fgG = topPixel.G; fgB = topPixel.B; bgR = bottomPixel.R; bgG = bottomPixel.G; bgB = bottomPixel.B; blockChar = '\u2580'; // ▀ } - // Emit color codes only when they change bool fgChanged = !hasLastColor || fgR != lastFgR || fgG != lastFgG || fgB != lastFgB; bool bgChanged = !hasLastColor || bgR != lastBgR || bgG != lastBgG || bgB != lastBgB; if (fgChanged) - sb.Append($"\x1b[38;2;{fgR};{fgG};{fgB}m"); + sb.Append($"{{F:{fgR:X2}{fgG:X2}{fgB:X2}}}"); if (bgChanged) - sb.Append($"\x1b[48;2;{bgR};{bgG};{bgB}m"); + sb.Append($"{{B:{bgR:X2}{bgG:X2}{bgB:X2}}}"); sb.Append(blockChar); @@ -80,8 +79,7 @@ public class ImageToAsciiService hasLastColor = true; } - // Reset color at end of line - sb.Append("\x1b[0m"); + sb.Append("{X}"); hasLastColor = false; if (y + 2 < image.Height) From d9a66749e76eabedd309dbe91704d5e8bc375b44 Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 19 Feb 2026 18:57:57 +0100 Subject: [PATCH 06/22] feat: implement data migration service to convert ANSI escape codes to printable color tags --- src/EchoHub.Client/UI/ChatRenderer.cs | 70 +++++------------ .../Setup/DataMigrationService.cs | 78 +++++++++++++++++++ src/EchoHub.Server/Setup/DatabaseSetup.cs | 3 + 3 files changed, 100 insertions(+), 51 deletions(-) create mode 100644 src/EchoHub.Server/Setup/DataMigrationService.cs diff --git a/src/EchoHub.Client/UI/ChatRenderer.cs b/src/EchoHub.Client/UI/ChatRenderer.cs index 29b4284..d229ca6 100644 --- a/src/EchoHub.Client/UI/ChatRenderer.cs +++ b/src/EchoHub.Client/UI/ChatRenderer.cs @@ -90,22 +90,17 @@ public partial class ChatLine } /// - /// Returns true if a line contains color tags (new format or legacy ANSI). + /// Returns true if a line contains printable color tags. /// public static bool HasColorTags(string text) => - text.Contains("{F:") || text.Contains("{B:") || text.Contains("{X}") || text.Contains('\x1b'); + text.Contains("{F:") || text.Contains("{B:") || text.Contains("{X}"); /// - /// Parse a string containing color tags into colored segments. - /// Supports the new printable format ({F:RRGGBB}, {B:RRGGBB}, {X}) - /// and legacy ANSI format (\x1b[38;2;R;G;Bm, \x1b[48;2;R;G;Bm, \x1b[0m). + /// Parse a string containing printable color tags into colored segments. + /// Format: {F:RRGGBB} (foreground), {B:RRGGBB} (background), {X} (reset). /// public static ChatLine FromColoredText(string text, Attribute? defaultAttr = null) { - // Detect which format is used and pick the right regex - var regex = text.Contains('\x1b') ? AnsiColorRegex() : ColorTagRegex(); - bool isAnsi = text.Contains('\x1b'); - var segments = new List(); int lastIndex = 0; Color? currentFg = null; @@ -121,7 +116,7 @@ public partial class ChatLine return new Attribute(fg, bg); } - foreach (Match match in regex.Matches(text)) + foreach (Match match in ColorTagRegex().Matches(text)) { if (match.Index > lastIndex) { @@ -130,45 +125,22 @@ public partial class ChatLine segments.Add(new ChatSegment(t, BuildAttr())); } - if (isAnsi) + if (match.Groups[1].Success) { - // Legacy ANSI format - if (match.Groups[1].Value == "0") - { - currentFg = null; - currentBg = null; - } - else if (match.Groups[2].Success) - { - var r = int.Parse(match.Groups[3].Value); - var g = int.Parse(match.Groups[4].Value); - var b = int.Parse(match.Groups[5].Value); - if (match.Groups[2].Value == "38;2") - currentFg = new Color(r, g, b); - else - currentBg = new Color(r, g, b); - } + // Reset {X} + currentFg = null; + currentBg = null; } - else + else if (match.Groups[2].Success) { - // New printable tag format: {F:RRGGBB}, {B:RRGGBB}, {X} - if (match.Groups[6].Success) - { - // Reset {X} - currentFg = null; - currentBg = null; - } - else if (match.Groups[7].Success) - { - var hex = match.Groups[8].Value; - var r = Convert.ToInt32(hex[..2], 16); - var g = Convert.ToInt32(hex[2..4], 16); - var b = Convert.ToInt32(hex[4..6], 16); - if (match.Groups[7].Value == "F") - currentFg = new Color(r, g, b); - else - currentBg = new Color(r, g, b); - } + var hex = match.Groups[3].Value; + var r = Convert.ToInt32(hex[..2], 16); + var g = Convert.ToInt32(hex[2..4], 16); + var b = Convert.ToInt32(hex[4..6], 16); + if (match.Groups[2].Value == "F") + currentFg = new Color(r, g, b); + else + currentBg = new Color(r, g, b); } lastIndex = match.Index + match.Length; @@ -184,11 +156,7 @@ public partial class ChatLine return segments.Count > 0 ? new ChatLine(segments) : new ChatLine(""); } - // Legacy: \x1b[0m, \x1b[38;2;R;G;Bm, \x1b[48;2;R;G;Bm - [GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2|48;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")] - private static partial Regex AnsiColorRegex(); - - // New: {X} (reset), {F:RRGGBB} (foreground), {B:RRGGBB} (background) + // {X} (reset), {F:RRGGBB} (foreground), {B:RRGGBB} (background) [GeneratedRegex(@"\{(?:(X)|(?:(F|B):([0-9A-Fa-f]{6})))\}")] private static partial Regex ColorTagRegex(); } diff --git a/src/EchoHub.Server/Setup/DataMigrationService.cs b/src/EchoHub.Server/Setup/DataMigrationService.cs new file mode 100644 index 0000000..55c2095 --- /dev/null +++ b/src/EchoHub.Server/Setup/DataMigrationService.cs @@ -0,0 +1,78 @@ +using System.Text.RegularExpressions; +using EchoHub.Server.Data; +using Microsoft.EntityFrameworkCore; + +namespace EchoHub.Server.Setup; + +public static partial class DataMigrationService +{ + public static async Task RunAsync(IServiceProvider services) + { + using var scope = services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var logger = scope.ServiceProvider.GetRequiredService() + .CreateLogger("EchoHub.Server.Setup.DataMigration"); + + await MigrateAnsiMessagesAsync(db, logger); + } + + private static async Task MigrateAnsiMessagesAsync(EchoHubDbContext db, ILogger logger) + { + // Load messages that contain the ESC byte (0x1B) — these have legacy ANSI color codes. + // Filter by Image type first (only images have ANSI art), then check content in memory. + var messages = await db.Messages + .Where(m => m.Type == Core.Models.MessageType.Image) + .ToListAsync(); + + var toMigrate = messages.Where(m => m.Content.Contains('\x1b')).ToList(); + + if (toMigrate.Count == 0) + return; + + logger.LogInformation("Found {Count} messages with legacy ANSI color codes. Migrating to color tag format...", toMigrate.Count); + + var modified = 0; + foreach (var message in toMigrate) + { + var converted = AnsiToColorTags(message.Content); + if (converted != message.Content) + { + message.Content = converted; + modified++; + } + } + + if (modified > 0) + { + await db.SaveChangesAsync(); + logger.LogInformation("Migrated {Count} messages from ANSI escape codes to printable color tags.", modified); + } + } + + /// + /// Convert ANSI escape codes to printable color tags. + /// \x1b[38;2;R;G;Bm → {F:RRGGBB}, \x1b[48;2;R;G;Bm → {B:RRGGBB}, \x1b[0m → {X} + /// + public static string AnsiToColorTags(string text) + { + return AnsiColorRegex().Replace(text, match => + { + if (match.Groups[1].Value == "0") + return "{X}"; + + if (match.Groups[2].Success) + { + var r = int.Parse(match.Groups[3].Value); + var g = int.Parse(match.Groups[4].Value); + var b = int.Parse(match.Groups[5].Value); + var type = match.Groups[2].Value == "38;2" ? "F" : "B"; + return $"{{{type}:{r:X2}{g:X2}{b:X2}}}"; + } + + return match.Value; + }); + } + + [GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2|48;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")] + private static partial Regex AnsiColorRegex(); +} diff --git a/src/EchoHub.Server/Setup/DatabaseSetup.cs b/src/EchoHub.Server/Setup/DatabaseSetup.cs index 6b61510..fa65fbc 100644 --- a/src/EchoHub.Server/Setup/DatabaseSetup.cs +++ b/src/EchoHub.Server/Setup/DatabaseSetup.cs @@ -16,6 +16,9 @@ public static class DatabaseSetup await MigrateAsync(db, logger); await SeedDefaultChannelAsync(db, logger); + + // Run data migrations (e.g. ANSI → color tag format) + await DataMigrationService.RunAsync(services); } private static async Task MigrateAsync(EchoHubDbContext db, ILogger logger) From 246eb2b0bb25cd8b9aa74677dd18c73811d10337 Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 19 Feb 2026 18:59:12 +0100 Subject: [PATCH 07/22] feat: add @mention highlighting in chat lines with new SplitMentions method --- src/EchoHub.Client/UI/ChatRenderer.cs | 31 ++++++++++++++++++++++++++- src/EchoHub.Client/UI/MainWindow.cs | 19 ++++++++++++++-- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/EchoHub.Client/UI/ChatRenderer.cs b/src/EchoHub.Client/UI/ChatRenderer.cs index d229ca6..b7b5f95 100644 --- a/src/EchoHub.Client/UI/ChatRenderer.cs +++ b/src/EchoHub.Client/UI/ChatRenderer.cs @@ -453,11 +453,40 @@ public class UserListSource : IListDataSource /// /// Shared color attributes for chat rendering (timestamps, system messages). /// -public static class ChatColors +public static partial class ChatColors { public static readonly Attribute TimestampAttr = new(Color.DarkGray, Color.Black); public static readonly Attribute SystemAttr = new(new Color(0, 180, 180), Color.Black); 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.Black); + + /// + /// Split text around @mentions, giving each @word the MentionTextAttr accent color. + /// Non-mention text uses the provided default color. + /// + public static List SplitMentions(string text, Attribute? defaultColor = null) + { + var segments = new List(); + int lastIndex = 0; + + foreach (Match match in MentionRegex().Matches(text)) + { + if (match.Index > lastIndex) + segments.Add(new ChatSegment(text[lastIndex..match.Index], defaultColor)); + + segments.Add(new ChatSegment(match.Value, MentionTextAttr)); + lastIndex = match.Index + match.Length; + } + + if (lastIndex < text.Length) + segments.Add(new ChatSegment(text[lastIndex..], defaultColor)); + + return segments; + } + + // Matches @username (letters, digits, underscores, hyphens — same as channel name chars) + [GeneratedRegex(@"@[\w-]+")] + private static partial Regex MentionRegex(); } /// diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index 5f94915..7bbf3bd 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -872,12 +872,13 @@ public sealed class MainWindow : Runnable default: var contentLines = message.Content.Split('\n'); var firstLine = contentLines[0].TrimEnd('\r'); - lines.Add(BuildChatLine(time, senderName, senderColor, $" {firstLine}")); + lines.Add(BuildChatLineWithMentions(time, senderName, senderColor, $" {firstLine}")); // Continuation lines indented to align with first line's content var indent = new string(' ', $"[{time}] {senderName} ".Length); for (int i = 1; i < contentLines.Length; i++) { - lines.Add(new ChatLine($"{indent}{contentLines[i].TrimEnd('\r')}")); + var contText = $"{indent}{contentLines[i].TrimEnd('\r')}"; + lines.Add(new ChatLine(ChatColors.SplitMentions(contText))); } break; } @@ -913,4 +914,18 @@ public sealed class MainWindow : Runnable }; return new ChatLine(segments); } + + /// + /// Build a chat line with @mention highlighting in the suffix text. + /// + private static ChatLine BuildChatLineWithMentions(string time, string senderName, Attribute? senderColor, string suffix) + { + var segments = new List + { + new($"[{time}] ", ChatColors.TimestampAttr), + new(senderName, senderColor), + }; + segments.AddRange(ChatColors.SplitMentions(suffix)); + return new ChatLine(segments); + } } From 38b60e5626c4e7df4808020eac2dff56d72a75d0 Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 19 Feb 2026 19:21:27 +0100 Subject: [PATCH 08/22] feat: implement channel membership management and ensure default channels are public --- src/EchoHub.Client/AppOrchestrator.cs | 17 +- src/EchoHub.Client/UI/MainWindow.cs | 10 + src/EchoHub.Core/Models/ChannelMembership.cs | 8 + .../Controllers/ChannelsController.cs | 17 +- src/EchoHub.Server/Data/EchoHubDbContext.cs | 18 ++ .../20260219172834_AddChannelIsPublic.cs | 2 +- ...219181720_AddChannelMembership.Designer.cs | 260 ++++++++++++++++++ .../20260219181720_AddChannelMembership.cs | 57 ++++ .../EchoHubDbContextModelSnapshot.cs | 35 +++ src/EchoHub.Server/Services/ChatService.cs | 13 + .../Setup/DataMigrationService.cs | 16 ++ 11 files changed, 446 insertions(+), 7 deletions(-) create mode 100644 src/EchoHub.Core/Models/ChannelMembership.cs create mode 100644 src/EchoHub.Server/Data/Migrations/20260219181720_AddChannelMembership.Designer.cs create mode 100644 src/EchoHub.Server/Data/Migrations/20260219181720_AddChannelMembership.cs diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs index 0e4268d..ba641b4 100644 --- a/src/EchoHub.Client/AppOrchestrator.cs +++ b/src/EchoHub.Client/AppOrchestrator.cs @@ -712,12 +712,10 @@ public sealed class AppOrchestrator : IDisposable _joinedChannels.Add(channel.Name); var history = await _connection!.JoinChannelAsync(channel.Name); - // Refresh the channel list and ensure private channels show up - var channels = await _apiClient.GetChannelsAsync(); InvokeUI(() => { - _mainWindow.SetChannels(channels); _mainWindow.EnsureChannelInList(channel.Name); + _mainWindow.SetChannelTopic(channel.Name, channel.Topic); _mainWindow.SwitchToChannel(channel.Name); if (history.Count > 0) _mainWindow.LoadHistory(channel.Name, history); @@ -756,10 +754,9 @@ public sealed class AppOrchestrator : IDisposable await _apiClient!.DeleteChannelAsync(channel); _joinedChannels.Remove(channel); - var channels = await _apiClient.GetChannelsAsync(); InvokeUI(() => { - _mainWindow.SetChannels(channels); + _mainWindow.RemoveChannel(channel); _mainWindow.SwitchToChannel(HubConstants.DefaultChannel); _mainWindow.AddSystemMessage(HubConstants.DefaultChannel, $"Channel #{channel} has been deleted."); }); @@ -844,6 +841,16 @@ public sealed class AppOrchestrator : IDisposable }); }; + connection.OnChannelUpdated += channel => + { + InvokeUI(() => + { + if (channel.IsPublic) + _mainWindow.EnsureChannelInList(channel.Name); + _mainWindow.SetChannelTopic(channel.Name, channel.Topic); + }); + }; + connection.OnError += errorMessage => InvokeUI(() => _mainWindow.ShowError(errorMessage)); diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index 7bbf3bd..ed7c9f5 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -596,6 +596,16 @@ public sealed class MainWindow : Runnable RefreshChannelList(); } + /// + /// Remove a channel from the left panel list. + /// + public void RemoveChannel(string channelName) + { + _channelNames.Remove(channelName); + _channelTopics.Remove(channelName); + RefreshChannelList(); + } + /// /// Update the topic for a specific channel. /// diff --git a/src/EchoHub.Core/Models/ChannelMembership.cs b/src/EchoHub.Core/Models/ChannelMembership.cs new file mode 100644 index 0000000..23c4770 --- /dev/null +++ b/src/EchoHub.Core/Models/ChannelMembership.cs @@ -0,0 +1,8 @@ +namespace EchoHub.Core.Models; + +public class ChannelMembership +{ + public Guid UserId { get; set; } + public Guid ChannelId { get; set; } + public DateTimeOffset JoinedAt { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/src/EchoHub.Server/Controllers/ChannelsController.cs b/src/EchoHub.Server/Controllers/ChannelsController.cs index 9051fd9..6631e50 100644 --- a/src/EchoHub.Server/Controllers/ChannelsController.cs +++ b/src/EchoHub.Server/Controllers/ChannelsController.cs @@ -40,10 +40,17 @@ public class ChannelsController : ControllerBase [HttpGet] public async Task GetChannels([FromQuery] int offset = 0, [FromQuery] int limit = 50) { + var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (userIdClaim is null) + return Unauthorized(new ErrorResponse("Authentication required.")); + + var userId = Guid.Parse(userIdClaim); offset = Math.Max(0, offset); limit = Math.Clamp(limit, 1, 100); - var query = _db.Channels.Where(c => c.IsPublic); + // Public channels + private channels the user has joined + var query = _db.Channels.Where(c => + c.IsPublic || _db.ChannelMemberships.Any(m => m.ChannelId == c.Id && m.UserId == userId)); var total = await query.CountAsync(); var channels = await query @@ -90,6 +97,14 @@ public class ChannelsController : ControllerBase }; _db.Channels.Add(channel); + + // Creator automatically becomes a member + _db.ChannelMemberships.Add(new ChannelMembership + { + UserId = Guid.Parse(userIdClaim), + ChannelId = channel.Id, + }); + await _db.SaveChangesAsync(); var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, channel.IsPublic, 0, channel.CreatedAt); diff --git a/src/EchoHub.Server/Data/EchoHubDbContext.cs b/src/EchoHub.Server/Data/EchoHubDbContext.cs index 43396e6..f4a32ae 100644 --- a/src/EchoHub.Server/Data/EchoHubDbContext.cs +++ b/src/EchoHub.Server/Data/EchoHubDbContext.cs @@ -11,6 +11,7 @@ public class EchoHubDbContext : DbContext public DbSet Channels => Set(); public DbSet Messages => Set(); public DbSet RefreshTokens => Set(); + public DbSet ChannelMemberships => Set(); protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { @@ -60,6 +61,23 @@ public class EchoHubDbContext : DbContext entity.Property(m => m.AttachmentFileName).HasMaxLength(255); }); + modelBuilder.Entity(entity => + { + entity.HasKey(cm => new { cm.UserId, cm.ChannelId }); + entity.HasIndex(cm => cm.UserId); + entity.HasIndex(cm => cm.ChannelId); + + entity.HasOne() + .WithMany() + .HasForeignKey(cm => cm.ChannelId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne() + .WithMany() + .HasForeignKey(cm => cm.UserId) + .OnDelete(DeleteBehavior.Cascade); + }); + modelBuilder.Entity(entity => { entity.HasKey(r => r.Id); diff --git a/src/EchoHub.Server/Data/Migrations/20260219172834_AddChannelIsPublic.cs b/src/EchoHub.Server/Data/Migrations/20260219172834_AddChannelIsPublic.cs index 8cb5a71..a105dd8 100644 --- a/src/EchoHub.Server/Data/Migrations/20260219172834_AddChannelIsPublic.cs +++ b/src/EchoHub.Server/Data/Migrations/20260219172834_AddChannelIsPublic.cs @@ -15,7 +15,7 @@ namespace EchoHub.Server.Data.Migrations table: "Channels", type: "INTEGER", nullable: false, - defaultValue: false); + defaultValue: true); } /// diff --git a/src/EchoHub.Server/Data/Migrations/20260219181720_AddChannelMembership.Designer.cs b/src/EchoHub.Server/Data/Migrations/20260219181720_AddChannelMembership.Designer.cs new file mode 100644 index 0000000..2485bbe --- /dev/null +++ b/src/EchoHub.Server/Data/Migrations/20260219181720_AddChannelMembership.Designer.cs @@ -0,0 +1,260 @@ +// +using System; +using EchoHub.Server.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EchoHub.Server.Data.Migrations +{ + [DbContext(typeof(EchoHubDbContext))] + [Migration("20260219181720_AddChannelMembership")] + partial class AddChannelMembership + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.3"); + + modelBuilder.Entity("EchoHub.Core.Models.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("CreatedByUserId") + .HasColumnType("TEXT"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Topic") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "ChannelId"); + + b.HasIndex("ChannelId"); + + b.HasIndex("UserId"); + + b.ToTable("ChannelMemberships"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AttachmentFileName") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AttachmentUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("SenderUserId") + .HasColumnType("TEXT"); + + b.Property("SenderUsername") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("SentAt") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ExpiresAt") + .HasColumnType("INTEGER"); + + b.Property("RevokedAt") + .HasColumnType("INTEGER"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash"); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AvatarAscii") + .HasMaxLength(10000) + .HasColumnType("TEXT"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IsBanned") + .HasColumnType("INTEGER"); + + b.Property("IsMuted") + .HasColumnType("INTEGER"); + + b.Property("LastSeenAt") + .HasColumnType("INTEGER"); + + b.Property("MutedUntil") + .HasColumnType("INTEGER"); + + b.Property("NicknameColor") + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StatusMessage") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b => + { + b.HasOne("EchoHub.Core.Models.Channel", null) + .WithMany() + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EchoHub.Core.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Message", b => + { + b.HasOne("EchoHub.Core.Models.Channel", "Channel") + .WithMany("Messages") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Channel"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b => + { + b.HasOne("EchoHub.Core.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Channel", b => + { + b.Navigation("Messages"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EchoHub.Server/Data/Migrations/20260219181720_AddChannelMembership.cs b/src/EchoHub.Server/Data/Migrations/20260219181720_AddChannelMembership.cs new file mode 100644 index 0000000..d8f0c8a --- /dev/null +++ b/src/EchoHub.Server/Data/Migrations/20260219181720_AddChannelMembership.cs @@ -0,0 +1,57 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EchoHub.Server.Data.Migrations +{ + /// + public partial class AddChannelMembership : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ChannelMemberships", + columns: table => new + { + UserId = table.Column(type: "TEXT", nullable: false), + ChannelId = table.Column(type: "TEXT", nullable: false), + JoinedAt = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ChannelMemberships", x => new { x.UserId, x.ChannelId }); + table.ForeignKey( + name: "FK_ChannelMemberships_Channels_ChannelId", + column: x => x.ChannelId, + principalTable: "Channels", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ChannelMemberships_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ChannelMemberships_ChannelId", + table: "ChannelMemberships", + column: "ChannelId"); + + migrationBuilder.CreateIndex( + name: "IX_ChannelMemberships_UserId", + table: "ChannelMemberships", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ChannelMemberships"); + } + } +} diff --git a/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs b/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs index 84a9178..adbf800 100644 --- a/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs +++ b/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs @@ -49,6 +49,26 @@ namespace EchoHub.Server.Data.Migrations b.ToTable("Channels"); }); + modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "ChannelId"); + + b.HasIndex("ChannelId"); + + b.HasIndex("UserId"); + + b.ToTable("ChannelMemberships"); + }); + modelBuilder.Entity("EchoHub.Core.Models.Message", b => { b.Property("Id") @@ -190,6 +210,21 @@ namespace EchoHub.Server.Data.Migrations b.ToTable("Users"); }); + modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b => + { + b.HasOne("EchoHub.Core.Models.Channel", null) + .WithMany() + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EchoHub.Core.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("EchoHub.Core.Models.Message", b => { b.HasOne("EchoHub.Core.Models.Channel", "Channel") diff --git a/src/EchoHub.Server/Services/ChatService.cs b/src/EchoHub.Server/Services/ChatService.cs index 443ed12..8d60c35 100644 --- a/src/EchoHub.Server/Services/ChatService.cs +++ b/src/EchoHub.Server/Services/ChatService.cs @@ -98,6 +98,19 @@ public class ChatService : IChatService if (channel is null) return ([], $"Channel '{channelName}' does not exist. Create it first via the channel list."); + // Persist membership so the channel shows in the user's channel list + var hasMembership = await db.ChannelMemberships + .AnyAsync(m => m.UserId == userId && m.ChannelId == channel.Id); + if (!hasMembership) + { + db.ChannelMemberships.Add(new ChannelMembership + { + UserId = userId, + ChannelId = channel.Id, + }); + await db.SaveChangesAsync(); + } + var isNewJoin = _presenceTracker.JoinChannel(username, channelName); if (isNewJoin) diff --git a/src/EchoHub.Server/Setup/DataMigrationService.cs b/src/EchoHub.Server/Setup/DataMigrationService.cs index 55c2095..8a85280 100644 --- a/src/EchoHub.Server/Setup/DataMigrationService.cs +++ b/src/EchoHub.Server/Setup/DataMigrationService.cs @@ -1,4 +1,5 @@ using System.Text.RegularExpressions; +using EchoHub.Core.Constants; using EchoHub.Server.Data; using Microsoft.EntityFrameworkCore; @@ -13,9 +14,24 @@ public static partial class DataMigrationService var logger = scope.ServiceProvider.GetRequiredService() .CreateLogger("EchoHub.Server.Setup.DataMigration"); + await EnsureDefaultChannelsPublicAsync(db, logger); await MigrateAnsiMessagesAsync(db, logger); } + /// + /// Ensure the #general channel (and any pre-existing channels from before the IsPublic column) are public. + /// + private static async Task EnsureDefaultChannelsPublicAsync(EchoHubDbContext db, ILogger logger) + { + var general = await db.Channels.FirstOrDefaultAsync(c => c.Name == HubConstants.DefaultChannel); + if (general is not null && !general.IsPublic) + { + general.IsPublic = true; + await db.SaveChangesAsync(); + logger.LogInformation("Marked #{Channel} as public.", HubConstants.DefaultChannel); + } + } + private static async Task MigrateAnsiMessagesAsync(EchoHubDbContext db, ILogger logger) { // Load messages that contain the ESC byte (0x1B) — these have legacy ANSI color codes. From 0dbb02d12a45f54db7f595ceb337a10d5bd8376a Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 19 Feb 2026 19:37:45 +0100 Subject: [PATCH 09/22] feat: release v0.2.3 with moderation system, private channels, and UI enhancements --- docs/changelog/index.md | 1 + docs/changelog/toc.yml | 2 + docs/changelog/v0.2.3.md | 55 ++++++++++++++++++++++ src/Directory.Build.props | 2 +- src/EchoHub.Client/UI/MainWindow.cs | 8 ++-- src/EchoHub.Core/Constants/HubConstants.cs | 2 +- 6 files changed, 64 insertions(+), 6 deletions(-) create mode 100644 docs/changelog/v0.2.3.md diff --git a/docs/changelog/index.md b/docs/changelog/index.md index a15eed2..7fac42f 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -4,6 +4,7 @@ Release history for EchoHub. ## Releases +- [v0.2.3](v0.2.3.md) - Moderation, Private Channels & UI Overhaul - [v0.2.2](v0.2.2.md) - Startup & Shutdown Fixes - [v0.2.1](v0.2.1.md) - Shutdown & CI Fixes - [v0.2.0](v0.2.0.md) - IRC Gateway diff --git a/docs/changelog/toc.yml b/docs/changelog/toc.yml index da8e733..addf516 100644 --- a/docs/changelog/toc.yml +++ b/docs/changelog/toc.yml @@ -1,5 +1,7 @@ - name: Overview href: index.md +- name: v0.2.3 + href: v0.2.3.md - name: v0.2.2 href: v0.2.2.md - name: v0.2.1 diff --git a/docs/changelog/v0.2.3.md b/docs/changelog/v0.2.3.md new file mode 100644 index 0000000..f4a4986 --- /dev/null +++ b/docs/changelog/v0.2.3.md @@ -0,0 +1,55 @@ +# v0.2.3 - Moderation, Private Channels & UI Overhaul + +## Features + +### Moderation System +- Added server roles: Owner, Admin, Mod, Member — first registered user is automatically Owner +- New `/kick`, `/ban`, `/unban`, `/mute`, `/unmute`, `/role`, `/nuke` commands for moderators and admins +- `ModerationController` with full REST API for role assignment, kicks, bans, mutes, message deletion, and channel nuking +- Mutes support optional duration (auto-expire) and blocked users cannot log in +- Role claim included in JWT tokens; role badges shown in the online users panel + +### Private Channels +- Channels can be created as public or private via a checkbox in the Create Channel dialog +- Public channels are visible to all users; private channels only appear for members who joined them +- Persistent channel membership tracked in the database (`ChannelMembership` table) +- `GET /api/channels` returns the combined list: public channels + user's joined private channels +- Channel creators are automatically added as members + +### Online Users Panel +- Collapsible right-side panel showing online users in the current channel (toggle with F2) +- Users displayed with status indicators, role badges, and their custom nickname colors +- Panel updates on join, leave, and status change events + +### @mention Highlighting +- `@username` text rendered in orange accent color in all messages +- Messages mentioning the current user get a full-line amber background highlight +- Works across multi-line messages and continuation lines + +### ASCII Art Improvements +- Half-block character rendering (`▀`/`█`) with separate foreground + background colors for 2x vertical resolution +- Switched from ANSI escape codes to printable color tags (`{F:RRGGBB}`, `{B:RRGGBB}`, `{X}`) — no control bytes in stored content +- Optional size parameter for `/send` command: `-s` (40x40), `-m` (80x80, default), `-l` (120x120) +- IRC gateway converts color tags back to ANSI for IRC client compatibility + +### Client UI +- Version number shown in the status bar +- Custom colored rendering for channel list (active indicator, unread count badges) +- Avatar upload field added to the profile edit dialog (file path or URL) +- Update check notification on connect — shows a system message if a newer GitHub release exists +- Chat messages no longer show selection/focus highlight +- Exit shortcut changed from Ctrl+C to Alt+Q — frees Ctrl+C for copy +- Default history increased from 50 to 100 messages on channel join + +## Fixes + +- Fixed `#general` channel not visible after adding the `IsPublic` column — migration default changed to `true` and startup service ensures it +- Fixed channels disappearing when creating a new channel — replaced full re-fetch with incremental updates +- Wired `OnChannelUpdated` SignalR event so new public channels appear for all connected users in real time +- Fixed color tag parser using wrong regex group numbers (6,7,8 instead of 1,2,3) — new ASCII art was rendering without colors + +## Infrastructure + +- Startup `DataMigrationService` automatically converts old ANSI-format messages to the new color tag format on server boot, logging the count of migrated records +- Three new EF Core migrations: `AddModerationRoles`, `AddChannelIsPublic`, `AddChannelMembership` +- `ChannelMembership` table with cascade delete on both channel and user removal diff --git a/src/Directory.Build.props b/src/Directory.Build.props index f79de17..d137f55 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,6 +1,6 @@ - 0.2.2 + 0.2.3 true $(NoWarn);CS1591 diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index ed7c9f5..0d70ebe 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -40,7 +40,7 @@ public sealed class MainWindow : Runnable // Cached Key constants — compare via .KeyCode to avoid Key.Equals (which also checks Handled) private static readonly Key EnterKey = Key.Enter; private static readonly Key NewlineKey = Key.N.WithCtrl; - private static readonly Key CtrlCKey = Key.C.WithCtrl; + private static readonly Key AltQKey = Key.Q.WithAlt; private static readonly Key TabKey = Key.Tab; // Available slash commands for Tab autocomplete @@ -240,7 +240,7 @@ public sealed class MainWindow : Runnable _messageList.ViewportChanged += (_, _) => OnChatViewportChanged(); _chatFrame.ViewportChanged += (_, _) => OnChatViewportChanged(); - // Window-level key handling for Ctrl+C (quit), F2 (toggle users panel) + // Window-level key handling for Alt+Q (quit), F2 (toggle users panel) KeyDown += OnWindowKeyDown; } @@ -379,7 +379,7 @@ public sealed class MainWindow : Runnable } e.Handled = true; } - else if (e.KeyCode == CtrlCKey.KeyCode) + else if (e.KeyCode == AltQKey.KeyCode) { _app.RequestStop(); e.Handled = true; @@ -432,7 +432,7 @@ public sealed class MainWindow : Runnable private void OnWindowKeyDown(object? sender, Key e) { - if (e.KeyCode == CtrlCKey.KeyCode) + if (e.KeyCode == AltQKey.KeyCode) { _app.RequestStop(); e.Handled = true; diff --git a/src/EchoHub.Core/Constants/HubConstants.cs b/src/EchoHub.Core/Constants/HubConstants.cs index 5a67cf0..2b980c3 100644 --- a/src/EchoHub.Core/Constants/HubConstants.cs +++ b/src/EchoHub.Core/Constants/HubConstants.cs @@ -4,7 +4,7 @@ public static class HubConstants { public const string ChatHubPath = "/hubs/chat"; public const string DefaultChannel = "general"; - public const int DefaultHistoryCount = 50; + public const int DefaultHistoryCount = 100; public const int MaxMessageLength = 2000; public const int MaxFileSizeBytes = 10 * 1024 * 1024; // 10 MB public const int MaxAvatarSizeBytes = 2 * 1024 * 1024; // 2 MB From 7181748d4097a81eee7912ee3ea95758b66a9b25 Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 19 Feb 2026 19:48:48 +0100 Subject: [PATCH 10/22] feat: enhance profile view dialog to render ASCII avatars with color tags --- src/EchoHub.Client/Commands/CommandHandler.cs | 63 ++++++++++++++----- src/EchoHub.Client/UI/ProfileViewDialog.cs | 31 +++++++-- 2 files changed, 74 insertions(+), 20 deletions(-) diff --git a/src/EchoHub.Client/Commands/CommandHandler.cs b/src/EchoHub.Client/Commands/CommandHandler.cs index c33d4d5..19df340 100644 --- a/src/EchoHub.Client/Commands/CommandHandler.cs +++ b/src/EchoHub.Client/Commands/CommandHandler.cs @@ -136,20 +136,8 @@ public class CommandHandler if (string.IsNullOrWhiteSpace(args)) return new CommandResult(true, "Usage: /send [-s|-m|-l]", IsError: true); - // Parse optional size flag (-s, -m, -l) - string? size = null; - var parts = args.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - var targetParts = new List(); - - foreach (var part in parts) - { - if (part is "-s" or "-m" or "-l") - size = part[1..]; // "s", "m", or "l" - else - targetParts.Add(part); - } - - var target = string.Join(' ', targetParts).Trim('"'); + // Extract optional size flag from end or start, respecting quoted paths + var (target, size) = ParsePathAndSizeFlag(args); if (string.IsNullOrWhiteSpace(target)) return new CommandResult(true, "Usage: /send [-s|-m|-l]", IsError: true); @@ -186,7 +174,7 @@ public class CommandHandler if (string.IsNullOrWhiteSpace(args)) return new CommandResult(true, "Usage: /avatar ", IsError: true); - var target = args.Trim().Trim('"'); + var target = StripQuotes(args.Trim()); if (OnSetAvatar is not null) await OnSetAvatar(target); @@ -362,6 +350,51 @@ public class CommandHandler """); } + /// + /// Extract a file path (possibly quoted) and an optional size flag (-s, -m, -l). + /// The flag can appear before or after the path. + /// + private static (string Path, string? Size) ParsePathAndSizeFlag(string args) + { + var trimmed = args.Trim(); + string? size = null; + + // Check for flag at the end: "path" -m or path -m + if (trimmed.Length > 3) + { + var suffix = trimmed[^2..]; + if (suffix is "-s" or "-m" or "-l" && trimmed[^3] == ' ') + { + size = suffix[1..]; + trimmed = trimmed[..^3].TrimEnd(); + } + } + + // Check for flag at the start: -m "path" or -m path + if (size is null && trimmed.Length > 3) + { + var prefix = trimmed[..2]; + if (prefix is "-s" or "-m" or "-l" && trimmed[2] == ' ') + { + size = prefix[1..]; + trimmed = trimmed[3..].TrimStart(); + } + } + + return (StripQuotes(trimmed), size); + } + + /// + /// Remove matching surrounding quotes (double or single) from a string. + /// + private static string StripQuotes(string s) + { + if (s.Length >= 2 && + ((s[0] == '"' && s[^1] == '"') || (s[0] == '\'' && s[^1] == '\''))) + return s[1..^1]; + return s; + } + private static bool IsValidHex(string s) => s.All(c => char.IsAsciiHexDigit(c)); } diff --git a/src/EchoHub.Client/UI/ProfileViewDialog.cs b/src/EchoHub.Client/UI/ProfileViewDialog.cs index 096c315..8e14631 100644 --- a/src/EchoHub.Client/UI/ProfileViewDialog.cs +++ b/src/EchoHub.Client/UI/ProfileViewDialog.cs @@ -140,12 +140,20 @@ public sealed class ProfileViewDialog dialog.Add(bioView); row += 3; - // ASCII Avatar + // ASCII Avatar — render with color tags if (!string.IsNullOrWhiteSpace(profile.AvatarAscii)) { row++; - var avatarLines = profile.AvatarAscii.Split('\n').Length; - var avatarHeight = Math.Min(avatarLines + 2, 6); + var rawLines = profile.AvatarAscii.Split('\n'); + var avatarSource = new ChatListSource(); + foreach (var line in rawLines) + { + avatarSource.Add(ChatLine.HasColorTags(line) + ? ChatLine.FromColoredText(line) + : new ChatLine(line)); + } + + var avatarHeight = Math.Min(rawLines.Length + 2, 24); var avatarFrame = new FrameView { Title = "Avatar", @@ -154,9 +162,22 @@ public sealed class ProfileViewDialog Width = Dim.Fill(2), Height = avatarHeight }; - avatarFrame.Add(new Label { Text = profile.AvatarAscii, X = 0, Y = 0 }); + var avatarList = new ListView + { + X = 0, + Y = 0, + Width = Dim.Fill(), + Height = Dim.Fill(), + Source = avatarSource + }; + avatarFrame.Add(avatarList); dialog.Add(avatarFrame); - // Grow dialog to fit avatar + + // Grow dialog to fit avatar + widen for art + var artWidth = rawLines.Max(l => ChatLine.HasColorTags(l) + ? ChatLine.FromColoredText(l).TextLength + : l.Length); + dialog.Width = Math.Max(50, artWidth + 6); dialog.Height = row + avatarHeight + 4; } From 5d51558c5c45941d9754be77b156f4af01d6ea58 Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 19 Feb 2026 20:00:51 +0100 Subject: [PATCH 11/22] feat: implement newline sanitization in chat messages to prevent excessive newlines --- src/EchoHub.Client/UI/ChatRenderer.cs | 37 +++++++++++----------- src/EchoHub.Core/Constants/HubConstants.cs | 2 ++ src/EchoHub.Server/Services/ChatService.cs | 25 +++++++++++++++ 3 files changed, 46 insertions(+), 18 deletions(-) diff --git a/src/EchoHub.Client/UI/ChatRenderer.cs b/src/EchoHub.Client/UI/ChatRenderer.cs index b7b5f95..80ac81b 100644 --- a/src/EchoHub.Client/UI/ChatRenderer.cs +++ b/src/EchoHub.Client/UI/ChatRenderer.cs @@ -231,11 +231,11 @@ public class ChatListSource : IListDataSource attr = new Attribute(attr.Foreground, mentionBg.Value); listView.SetAttribute(attr); - foreach (var ch in segment.Text) + foreach (var rune in segment.Text.EnumerateRunes()) { if (charPos >= viewportX && drawnChars < width) { - listView.AddRune(new Rune(ch)); + listView.AddRune(rune); drawnChars++; } charPos++; @@ -324,9 +324,9 @@ public class ChannelListSource : IListDataSource if (selected) { 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 @@ -334,26 +334,26 @@ public class ChannelListSource : IListDataSource // Prefix var prefixAttr = isActive ? ActiveAttr : NormalAttr; 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 var nameAttr = isActive ? ActiveAttr : hasUnread ? UnreadAttr : NormalAttr; 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 if (hasUnread) { 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 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) // Format: "● ★Username" or "● Username" int nameStart = 0; - int i = 0; - // Skip status icon - while (i < text.Length && !char.IsLetterOrDigit(text[i]) && text[i] != '_') i++; - nameStart = i; + while (nameStart < runes.Length && !Rune.IsLetterOrDigit(runes[nameStart]) && runes[nameStart].Value != '_') + nameStart++; int drawnChars = 0; // Draw prefix (status icon + role badge) in normal color 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) { listView.SetAttribute(prefixAttr); - listView.AddRune(new Rune(text[c])); + listView.AddRune(runes[c]); drawnChars++; } } @@ -428,12 +429,12 @@ public class UserListSource : IListDataSource // Draw name in nickname color var userAttr = nameColor ?? normalAttr; 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) { listView.SetAttribute(userAttr); - listView.AddRune(new Rune(text[c])); + listView.AddRune(runes[c]); drawnChars++; } } diff --git a/src/EchoHub.Core/Constants/HubConstants.cs b/src/EchoHub.Core/Constants/HubConstants.cs index 2b980c3..85764ea 100644 --- a/src/EchoHub.Core/Constants/HubConstants.cs +++ b/src/EchoHub.Core/Constants/HubConstants.cs @@ -8,6 +8,8 @@ public static class HubConstants public const int MaxMessageLength = 2000; public const int MaxFileSizeBytes = 10 * 1024 * 1024; // 10 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 AsciiArtHeight = 40; public const int AsciiArtHeightHalfBlock = 80; diff --git a/src/EchoHub.Server/Services/ChatService.cs b/src/EchoHub.Server/Services/ChatService.cs index 8d60c35..dd3767b 100644 --- a/src/EchoHub.Server/Services/ChatService.cs +++ b/src/EchoHub.Server/Services/ChatService.cs @@ -144,6 +144,9 @@ public class ChatService : IChatService if (content.Length > HubConstants.MaxMessageLength) return $"Message exceeds maximum length of {HubConstants.MaxMessageLength} characters."; + // Sanitize excessive newlines + content = SanitizeNewlines(content); + using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); @@ -343,6 +346,28 @@ public class ChatService : IChatService return (user.Id, user.Username); } + /// + /// Collapse consecutive newlines and cap total line count to prevent newline spam. + /// + 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> GetChannelHistoryInternalAsync(EchoHubDbContext db, string channelName, int count) { var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); From 8fc95e81895b1f07e3ac061843fb9ec0082d3271 Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 19 Feb 2026 20:01:57 +0100 Subject: [PATCH 12/22] fix: resolve crashes and command issues with emoji handling, file paths, and avatar rendering; implement newline spam protection --- docs/changelog/v0.2.3.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/changelog/v0.2.3.md b/docs/changelog/v0.2.3.md index f4a4986..5629738 100644 --- a/docs/changelog/v0.2.3.md +++ b/docs/changelog/v0.2.3.md @@ -47,6 +47,10 @@ - Fixed channels disappearing when creating a new channel — replaced full re-fetch with incremental updates - Wired `OnChannelUpdated` SignalR event so new public channels appear for all connected users in real time - Fixed color tag parser using wrong regex group numbers (6,7,8 instead of 1,2,3) — new ASCII art was rendering without colors +- Fixed crash when receiving emoji or other non-BMP Unicode characters — all list renderers now use `EnumerateRunes()` instead of `char` iteration +- Fixed `/send` and `/avatar` commands not handling file paths with spaces correctly, even when quoted +- Profile avatar now renders with full color tag support instead of showing raw tags +- Server-side newline spam protection — consecutive newlines collapsed (max 2) and total lines capped at 30 ## Infrastructure From 9f303b531113207ff37422c41ec340c29de14876 Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 19 Feb 2026 20:41:36 +0100 Subject: [PATCH 13/22] feat: enhance chat message handling by converting emojis to text shortcodes and improving newline sanitization --- docs/changelog/v0.2.3.md | 3 +- src/EchoHub.Client/UI/ChatRenderer.cs | 70 ++++++++----- src/EchoHub.Core/Constants/HubConstants.cs | 2 +- src/EchoHub.Server/Services/ChatService.cs | 115 +++++++++++++++++++-- 4 files changed, 154 insertions(+), 36 deletions(-) diff --git a/docs/changelog/v0.2.3.md b/docs/changelog/v0.2.3.md index 5629738..902b244 100644 --- a/docs/changelog/v0.2.3.md +++ b/docs/changelog/v0.2.3.md @@ -48,9 +48,10 @@ - Wired `OnChannelUpdated` SignalR event so new public channels appear for all connected users in real time - Fixed color tag parser using wrong regex group numbers (6,7,8 instead of 1,2,3) — new ASCII art was rendering without colors - Fixed crash when receiving emoji or other non-BMP Unicode characters — all list renderers now use `EnumerateRunes()` instead of `char` iteration +- Emoji and non-BMP characters are now converted to text shortcodes server-side (e.g. `:smile:`, `:fire:`) for reliable TUI rendering - Fixed `/send` and `/avatar` commands not handling file paths with spaces correctly, even when quoted - Profile avatar now renders with full color tag support instead of showing raw tags -- Server-side newline spam protection — consecutive newlines collapsed (max 2) and total lines capped at 30 +- Server-side newline spam protection — consecutive blank/whitespace-only lines collapsed to 1 and total lines capped at 30 ## Infrastructure diff --git a/src/EchoHub.Client/UI/ChatRenderer.cs b/src/EchoHub.Client/UI/ChatRenderer.cs index 80ac81b..605d3cd 100644 --- a/src/EchoHub.Client/UI/ChatRenderer.cs +++ b/src/EchoHub.Client/UI/ChatRenderer.cs @@ -3,6 +3,7 @@ using System.Collections.Specialized; using System.Text; using System.Text.RegularExpressions; using Terminal.Gui.Drawing; +using Terminal.Gui.Text; using Terminal.Gui.Views; using Attribute = Terminal.Gui.Drawing.Attribute; @@ -26,15 +27,21 @@ public partial class ChatLine public ChatLine(string plainText) { Segments = [new ChatSegment(plainText, null)]; - TextLength = plainText.Length; + TextLength = DisplayWidth(plainText); } public ChatLine(List segments) { Segments = segments; - TextLength = segments.Sum(s => s.Text.Length); + TextLength = segments.Sum(s => DisplayWidth(s.Text)); } + /// + /// Compute the display column width of a string, accounting for wide characters (emoji, CJK). + /// + private static int DisplayWidth(string text) => + text.EnumerateRunes().Sum(r => Math.Max(r.GetColumns(), 1)); + public override string ToString() => string.Concat(Segments.Select(s => s.Text)); /// @@ -52,17 +59,24 @@ public partial class ChatLine foreach (var segment in Segments) { - int segPos = 0; - while (segPos < segment.Text.Length) + var text = segment.Text; + int chunkStart = 0; // char index where current chunk starts + int charPos = 0; + + foreach (var rune in text.EnumerateRunes()) { - int remaining = width - col; - if (remaining <= 0) + var runeCols = Math.Max(rune.GetColumns(), 1); + + if (col + runeCols > width) { + // Flush accumulated text from this segment chunk + if (charPos > chunkStart) + currentSegments.Add(new ChatSegment(text[chunkStart..charPos], segment.Color)); + // Emit current line and start a new one results.Add(new ChatLine(currentSegments)); currentSegments = []; - // Add indent for continuation if (continuationIndent > 0) { currentSegments.Add(new ChatSegment(new string(' ', continuationIndent), null)); @@ -73,14 +87,16 @@ public partial class ChatLine col = 0; } - remaining = width - col; + chunkStart = charPos; } - int take = Math.Min(segment.Text.Length - segPos, remaining); - currentSegments.Add(new ChatSegment(segment.Text.Substring(segPos, take), segment.Color)); - col += take; - segPos += take; + col += runeCols; + charPos += rune.Utf16SequenceLength; } + + // Flush remaining chunk of this segment + if (chunkStart < text.Length) + currentSegments.Add(new ChatSegment(text[chunkStart..], segment.Color)); } if (currentSegments.Count > 0) @@ -233,12 +249,14 @@ public class ChatListSource : IListDataSource foreach (var rune in segment.Text.EnumerateRunes()) { - if (charPos >= viewportX && drawnChars < width) + var cols = rune.GetColumns(); + if (cols < 1) cols = 1; + if (charPos >= viewportX && drawnChars + cols <= width) { listView.AddRune(rune); - drawnChars++; + drawnChars += cols; } - charPos++; + charPos += cols; } } @@ -326,7 +344,8 @@ public class ChannelListSource : IListDataSource listView.SetAttribute(focusAttr); foreach (var rune in (prefix + channelText + badge).EnumerateRunes()) { - if (drawnChars < width) { listView.AddRune(rune); drawnChars++; } + var cols = Math.Max(rune.GetColumns(), 1); + if (drawnChars + cols <= width) { listView.AddRune(rune); drawnChars += cols; } } } else @@ -336,7 +355,8 @@ public class ChannelListSource : IListDataSource listView.SetAttribute(prefixAttr); foreach (var rune in prefix.EnumerateRunes()) { - if (drawnChars < width) { listView.AddRune(rune); drawnChars++; } + var cols = Math.Max(rune.GetColumns(), 1); + if (drawnChars + cols <= width) { listView.AddRune(rune); drawnChars += cols; } } // Channel name @@ -344,7 +364,8 @@ public class ChannelListSource : IListDataSource listView.SetAttribute(nameAttr); foreach (var rune in channelText.EnumerateRunes()) { - if (drawnChars < width) { listView.AddRune(rune); drawnChars++; } + var cols = Math.Max(rune.GetColumns(), 1); + if (drawnChars + cols <= width) { listView.AddRune(rune); drawnChars += cols; } } // Unread badge @@ -353,7 +374,8 @@ public class ChannelListSource : IListDataSource listView.SetAttribute(BadgeAttr); foreach (var rune in badge.EnumerateRunes()) { - if (drawnChars < width) { listView.AddRune(rune); drawnChars++; } + var cols = Math.Max(rune.GetColumns(), 1); + if (drawnChars + cols <= width) { listView.AddRune(rune); drawnChars += cols; } } } } @@ -418,11 +440,12 @@ public class UserListSource : IListDataSource var prefixAttr = normalAttr; for (int c = 0; c < nameStart && c < runes.Length; c++) { - if (drawnChars < width) + var cols = Math.Max(runes[c].GetColumns(), 1); + if (drawnChars + cols <= width) { listView.SetAttribute(prefixAttr); listView.AddRune(runes[c]); - drawnChars++; + drawnChars += cols; } } @@ -431,11 +454,12 @@ public class UserListSource : IListDataSource if (selected) userAttr = normalAttr; // use focus attr when selected for (int c = nameStart; c < runes.Length; c++) { - if (drawnChars < width) + var cols = Math.Max(runes[c].GetColumns(), 1); + if (drawnChars + cols <= width) { listView.SetAttribute(userAttr); listView.AddRune(runes[c]); - drawnChars++; + drawnChars += cols; } } diff --git a/src/EchoHub.Core/Constants/HubConstants.cs b/src/EchoHub.Core/Constants/HubConstants.cs index 85764ea..e79e431 100644 --- a/src/EchoHub.Core/Constants/HubConstants.cs +++ b/src/EchoHub.Core/Constants/HubConstants.cs @@ -9,7 +9,7 @@ public static class HubConstants public const int MaxFileSizeBytes = 10 * 1024 * 1024; // 10 MB public const int MaxAvatarSizeBytes = 2 * 1024 * 1024; // 2 MB public const int MaxMessageNewlines = 30; - public const int MaxConsecutiveNewlines = 2; + public const int MaxConsecutiveNewlines = 1; public const int AsciiArtWidth = 80; public const int AsciiArtHeight = 40; public const int AsciiArtHeightHalfBlock = 80; diff --git a/src/EchoHub.Server/Services/ChatService.cs b/src/EchoHub.Server/Services/ChatService.cs index dd3767b..5203176 100644 --- a/src/EchoHub.Server/Services/ChatService.cs +++ b/src/EchoHub.Server/Services/ChatService.cs @@ -1,3 +1,4 @@ +using System.Text; using EchoHub.Core.Constants; using EchoHub.Core.Contracts; using EchoHub.Core.DTOs; @@ -144,7 +145,8 @@ public class ChatService : IChatService if (content.Length > HubConstants.MaxMessageLength) return $"Message exceeds maximum length of {HubConstants.MaxMessageLength} characters."; - // Sanitize excessive newlines + // Sanitize: convert emoji to text, collapse newlines + content = ConvertEmoji(content); content = SanitizeNewlines(content); using var scope = _scopeFactory.CreateScope(); @@ -346,6 +348,84 @@ public class ChatService : IChatService return (user.Id, user.Username); } + /// + /// Replace emoji with text shortcodes. TUI terminals can't render wide chars reliably. + /// + private static string ConvertEmoji(string content) + { + var sb = new StringBuilder(content.Length); + foreach (var rune in content.EnumerateRunes()) + { + if (EmojiMap.TryGetValue(rune.Value, out var name)) + sb.Append(name); + else if (rune.Value >= 0x1F000) // supplementary emoji planes + sb.Append($"[?]"); + else if (rune.Value is 0x200D or 0xFE0F or 0xFE0E) // ZWJ, variation selectors + { } // strip silently + else + sb.Append(rune.ToString()); + } + return sb.ToString(); + } + + private static readonly Dictionary EmojiMap = new() + { + [0x1F600] = ":grinning:", [0x1F601] = ":grin:", [0x1F602] = ":joy:", + [0x1F603] = ":smiley:", [0x1F604] = ":smile:", [0x1F605] = ":sweat_smile:", + [0x1F606] = ":laughing:", [0x1F607] = ":angel:", [0x1F608] = ":imp:", + [0x1F609] = ":wink:", [0x1F60A] = ":blush:", [0x1F60B] = ":yum:", + [0x1F60C] = ":relieved:", [0x1F60D] = ":heart_eyes:", [0x1F60E] = ":sunglasses:", + [0x1F60F] = ":smirk:", [0x1F610] = ":neutral:", [0x1F611] = ":expressionless:", + [0x1F612] = ":unamused:", [0x1F613] = ":sweat:", [0x1F614] = ":pensive:", + [0x1F615] = ":confused:", [0x1F616] = ":confounded:", [0x1F617] = ":kiss:", + [0x1F618] = ":kissing_heart:", [0x1F619] = ":kissing:", [0x1F61A] = ":kissing_closed_eyes:", + [0x1F61B] = ":tongue:", [0x1F61C] = ":wink_tongue:", [0x1F61D] = ":squint_tongue:", + [0x1F61E] = ":disappointed:", [0x1F61F] = ":worried:", [0x1F620] = ":angry:", + [0x1F621] = ":rage:", [0x1F622] = ":cry:", [0x1F623] = ":persevere:", + [0x1F624] = ":triumph:", [0x1F625] = ":disappointed_relieved:", [0x1F626] = ":frowning:", + [0x1F627] = ":anguished:", [0x1F628] = ":fearful:", [0x1F629] = ":weary:", + [0x1F62A] = ":sleepy:", [0x1F62B] = ":tired:", [0x1F62C] = ":grimacing:", + [0x1F62D] = ":sob:", [0x1F62E] = ":open_mouth:", [0x1F62F] = ":hushed:", + [0x1F630] = ":cold_sweat:", [0x1F631] = ":scream:", [0x1F632] = ":astonished:", + [0x1F633] = ":flushed:", [0x1F634] = ":sleeping:", [0x1F635] = ":dizzy_face:", + [0x1F636] = ":no_mouth:", [0x1F637] = ":mask:", [0x1F638] = ":smile_cat:", + [0x1F642] = ":slight_smile:", [0x1F643] = ":upside_down:", + [0x1F644] = ":roll_eyes:", [0x1F910] = ":zipper_mouth:", + [0x1F911] = ":money_mouth:", [0x1F912] = ":thermometer_face:", + [0x1F913] = ":nerd:", [0x1F914] = ":thinking:", [0x1F915] = ":head_bandage:", + [0x1F920] = ":cowboy:", [0x1F921] = ":clown:", [0x1F923] = ":rofl:", + [0x1F924] = ":drooling:", [0x1F925] = ":lying:", + [0x1F970] = ":smiling_hearts:", [0x1F971] = ":yawning:", + [0x1F972] = ":smiling_tear:", [0x1F973] = ":party:", + [0x1F974] = ":woozy:", [0x1F975] = ":hot:", [0x1F976] = ":cold:", + [0x1F978] = ":disguised:", [0x1F979] = ":holding_back_tears:", + [0x1F97A] = ":pleading:", [0x1F92A] = ":zany:", [0x1F92B] = ":shushing:", + [0x1F92C] = ":censored:", [0x1F92D] = ":hand_over_mouth:", + [0x1F92E] = ":vomiting:", [0x1F92F] = ":exploding_head:", + // Gestures + [0x1F44D] = ":+1:", [0x1F44E] = ":-1:", [0x1F44F] = ":clap:", + [0x1F44B] = ":wave:", [0x1F44C] = ":ok_hand:", [0x1F44A] = ":punch:", + [0x1F4AA] = ":muscle:", [0x1F64F] = ":pray:", [0x1F91D] = ":handshake:", + [0x1F90C] = ":pinched_fingers:", [0x1F918] = ":metal:", [0x1F919] = ":call_me:", + // Hearts + [0x2764] = "<3", [0x1F494] = " /// Collapse consecutive newlines and cap total line count to prevent newline spam. /// @@ -354,18 +434,31 @@ public class ChatService : IChatService // 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 + // Collapse consecutive blank/whitespace-only lines into max 1 blank line var lines = content.Split('\n'); - if (lines.Length > HubConstants.MaxMessageNewlines) - content = string.Join('\n', lines.Take(HubConstants.MaxMessageNewlines)); + var result = new List(lines.Length); + int consecutiveBlanks = 0; - return content; + foreach (var line in lines) + { + if (string.IsNullOrWhiteSpace(line)) + { + consecutiveBlanks++; + if (consecutiveBlanks <= HubConstants.MaxConsecutiveNewlines) + result.Add(line); + } + else + { + consecutiveBlanks = 0; + result.Add(line); + } + } + + // Cap total lines + if (result.Count > HubConstants.MaxMessageNewlines) + result = result.Take(HubConstants.MaxMessageNewlines).ToList(); + + return string.Join('\n', result); } private static async Task> GetChannelHistoryInternalAsync(EchoHubDbContext db, string channelName, int count) From 6965ba573e9561f938c96f0862054f59e89fe4ce Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 19 Feb 2026 21:11:06 +0100 Subject: [PATCH 14/22] fix: enhance chat message handling by implementing full Unicode support and collapsing excessive newlines --- docs/changelog/v0.2.3.md | 3 +- src/EchoHub.Client/UI/ChatRenderer.cs | 164 +++++++++------------ src/EchoHub.Server/Services/ChatService.cs | 82 +---------- 3 files changed, 69 insertions(+), 180 deletions(-) diff --git a/docs/changelog/v0.2.3.md b/docs/changelog/v0.2.3.md index 902b244..e428fad 100644 --- a/docs/changelog/v0.2.3.md +++ b/docs/changelog/v0.2.3.md @@ -47,8 +47,7 @@ - Fixed channels disappearing when creating a new channel — replaced full re-fetch with incremental updates - Wired `OnChannelUpdated` SignalR event so new public channels appear for all connected users in real time - Fixed color tag parser using wrong regex group numbers (6,7,8 instead of 1,2,3) — new ASCII art was rendering without colors -- Fixed crash when receiving emoji or other non-BMP Unicode characters — all list renderers now use `EnumerateRunes()` instead of `char` iteration -- Emoji and non-BMP characters are now converted to text shortcodes server-side (e.g. `:smile:`, `:fire:`) for reliable TUI rendering +- Full Unicode/emoji support — renderers use Terminal.Gui v2 grapheme cluster API (`GraphemeHelper`, `AddStr`) for proper wide character handling - Fixed `/send` and `/avatar` commands not handling file paths with spaces correctly, even when quoted - Profile avatar now renders with full color tag support instead of showing raw tags - Server-side newline spam protection — consecutive blank/whitespace-only lines collapsed to 1 and total lines capped at 30 diff --git a/src/EchoHub.Client/UI/ChatRenderer.cs b/src/EchoHub.Client/UI/ChatRenderer.cs index 605d3cd..ef6330a 100644 --- a/src/EchoHub.Client/UI/ChatRenderer.cs +++ b/src/EchoHub.Client/UI/ChatRenderer.cs @@ -1,6 +1,5 @@ using System.Collections; using System.Collections.Specialized; -using System.Text; using System.Text.RegularExpressions; using Terminal.Gui.Drawing; using Terminal.Gui.Text; @@ -27,21 +26,15 @@ public partial class ChatLine public ChatLine(string plainText) { Segments = [new ChatSegment(plainText, null)]; - TextLength = DisplayWidth(plainText); + TextLength = plainText.GetColumns(); } public ChatLine(List segments) { Segments = segments; - TextLength = segments.Sum(s => DisplayWidth(s.Text)); + TextLength = segments.Sum(s => s.Text.GetColumns()); } - /// - /// Compute the display column width of a string, accounting for wide characters (emoji, CJK). - /// - private static int DisplayWidth(string text) => - text.EnumerateRunes().Sum(r => Math.Max(r.GetColumns(), 1)); - public override string ToString() => string.Concat(Segments.Select(s => s.Text)); /// @@ -60,20 +53,18 @@ public partial class ChatLine foreach (var segment in Segments) { var text = segment.Text; - int chunkStart = 0; // char index where current chunk starts + int chunkStart = 0; int charPos = 0; - foreach (var rune in text.EnumerateRunes()) + foreach (var grapheme in GraphemeHelper.GetGraphemes(text)) { - var runeCols = Math.Max(rune.GetColumns(), 1); + var graphemeCols = Math.Max(grapheme.GetColumns(), 1); - if (col + runeCols > width) + if (col + graphemeCols > width) { - // Flush accumulated text from this segment chunk if (charPos > chunkStart) currentSegments.Add(new ChatSegment(text[chunkStart..charPos], segment.Color)); - // Emit current line and start a new one results.Add(new ChatLine(currentSegments)); currentSegments = []; @@ -90,11 +81,10 @@ public partial class ChatLine chunkStart = charPos; } - col += runeCols; - charPos += rune.Utf16SequenceLength; + col += graphemeCols; + charPos += grapheme.Length; } - // Flush remaining chunk of this segment if (chunkStart < text.Length) currentSegments.Add(new ChatSegment(text[chunkStart..], segment.Color)); } @@ -143,7 +133,6 @@ public partial class ChatLine if (match.Groups[1].Success) { - // Reset {X} currentFg = null; currentBg = null; } @@ -172,13 +161,12 @@ public partial class ChatLine return segments.Count > 0 ? new ChatLine(segments) : new ChatLine(""); } - // {X} (reset), {F:RRGGBB} (foreground), {B:RRGGBB} (background) [GeneratedRegex(@"\{(?:(X)|(?:(F|B):([0-9A-Fa-f]{6})))\}")] private static partial Regex ColorTagRegex(); } /// -/// Custom list data source for chat messages with per-character coloring. +/// Custom list data source for chat messages with per-segment coloring. /// public class ChatListSource : IListDataSource { @@ -232,7 +220,6 @@ public class ChatListSource : IListDataSource listView.Move(Math.Max(col - viewportX, 0), row); var chatLine = _lines[item]; - // Always use Normal — chat messages should not show focus/selection highlight var normalAttr = listView.GetAttributeForRole(VisualRole.Normal); var mentionBg = chatLine.IsMention ? ChatColors.MentionHighlightAttr.Background : (Color?)null; @@ -242,32 +229,26 @@ public class ChatListSource : IListDataSource foreach (var segment in chatLine.Segments) { var attr = segment.Color ?? normalAttr; - // Override background for mention-highlighted lines if (mentionBg.HasValue) attr = new Attribute(attr.Foreground, mentionBg.Value); listView.SetAttribute(attr); - foreach (var rune in segment.Text.EnumerateRunes()) + foreach (var grapheme in GraphemeHelper.GetGraphemes(segment.Text)) { - var cols = rune.GetColumns(); - if (cols < 1) cols = 1; + var cols = Math.Max(grapheme.GetColumns(), 1); if (charPos >= viewportX && drawnChars + cols <= width) { - listView.AddRune(rune); + listView.AddStr(grapheme); drawnChars += cols; } charPos += cols; } } - // Fill remaining width with spaces var fillAttr = mentionBg.HasValue ? new Attribute(normalAttr.Foreground, mentionBg.Value) : normalAttr; listView.SetAttribute(fillAttr); - while (drawnChars < width) - { - listView.AddRune(new Rune(' ')); - drawnChars++; - } + for (int i = drawnChars; i < width; i++) + listView.AddStr(" "); } private void UpdateMaxLength(ChatLine line) @@ -338,56 +319,30 @@ public class ChannelListSource : IListDataSource int drawnChars = 0; - // Use focus attr if this row is selected if (selected) { listView.SetAttribute(focusAttr); - foreach (var rune in (prefix + channelText + badge).EnumerateRunes()) - { - var cols = Math.Max(rune.GetColumns(), 1); - if (drawnChars + cols <= width) { listView.AddRune(rune); drawnChars += cols; } - } + drawnChars = RenderHelpers.WriteText(listView, prefix + channelText + badge, drawnChars, width); } else { - // Prefix - var prefixAttr = isActive ? ActiveAttr : NormalAttr; - listView.SetAttribute(prefixAttr); - foreach (var rune in prefix.EnumerateRunes()) - { - var cols = Math.Max(rune.GetColumns(), 1); - if (drawnChars + cols <= width) { listView.AddRune(rune); drawnChars += cols; } - } + listView.SetAttribute(isActive ? ActiveAttr : NormalAttr); + drawnChars = RenderHelpers.WriteText(listView, prefix, drawnChars, width); - // Channel name - var nameAttr = isActive ? ActiveAttr : hasUnread ? UnreadAttr : NormalAttr; - listView.SetAttribute(nameAttr); - foreach (var rune in channelText.EnumerateRunes()) - { - var cols = Math.Max(rune.GetColumns(), 1); - if (drawnChars + cols <= width) { listView.AddRune(rune); drawnChars += cols; } - } + listView.SetAttribute(isActive ? ActiveAttr : hasUnread ? UnreadAttr : NormalAttr); + drawnChars = RenderHelpers.WriteText(listView, channelText, drawnChars, width); - // Unread badge if (hasUnread) { listView.SetAttribute(BadgeAttr); - foreach (var rune in badge.EnumerateRunes()) - { - var cols = Math.Max(rune.GetColumns(), 1); - if (drawnChars + cols <= width) { listView.AddRune(rune); drawnChars += cols; } - } + drawnChars = RenderHelpers.WriteText(listView, badge, drawnChars, width); } } - // Fill rest var fillAttr = selected ? focusAttr : listView.GetAttributeForRole(VisualRole.Normal); listView.SetAttribute(fillAttr); - while (drawnChars < width) - { - listView.AddRune(new Rune(' ')); - drawnChars++; - } + for (int i = drawnChars; i < width; i++) + listView.AddStr(" "); } public void Dispose() { } @@ -409,7 +364,7 @@ public class UserListSource : IListDataSource { _users.Clear(); _users.AddRange(users); - MaxItemLength = users.Count > 0 ? users.Max(u => u.Text.Length) : 0; + MaxItemLength = users.Count > 0 ? users.Max(u => u.Text.GetColumns()) : 0; if (!SuspendCollectionChangedEvent) CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); } @@ -425,56 +380,72 @@ public class UserListSource : IListDataSource var (text, nameColor) = _users[item]; 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) // Format: "● ★Username" or "● Username" + var graphemes = GraphemeHelper.GetGraphemes(text).ToList(); int nameStart = 0; - while (nameStart < runes.Length && !Rune.IsLetterOrDigit(runes[nameStart]) && runes[nameStart].Value != '_') + while (nameStart < graphemes.Count) + { + var g = graphemes[nameStart]; + if (g.Length > 0 && (char.IsLetterOrDigit(g[0]) || g[0] == '_')) + break; nameStart++; + } int drawnChars = 0; // Draw prefix (status icon + role badge) in normal color - var prefixAttr = normalAttr; - for (int c = 0; c < nameStart && c < runes.Length; c++) + listView.SetAttribute(normalAttr); + for (int i = 0; i < nameStart; i++) { - var cols = Math.Max(runes[c].GetColumns(), 1); - if (drawnChars + cols <= width) - { - listView.SetAttribute(prefixAttr); - listView.AddRune(runes[c]); - drawnChars += cols; - } + var cols = Math.Max(graphemes[i].GetColumns(), 1); + if (drawnChars + cols > width) break; + listView.AddStr(graphemes[i]); + drawnChars += cols; } // Draw name in nickname color - var userAttr = nameColor ?? normalAttr; - if (selected) userAttr = normalAttr; // use focus attr when selected - for (int c = nameStart; c < runes.Length; c++) + var userAttr = selected ? normalAttr : nameColor ?? normalAttr; + listView.SetAttribute(userAttr); + for (int i = nameStart; i < graphemes.Count; i++) { - var cols = Math.Max(runes[c].GetColumns(), 1); - if (drawnChars + cols <= width) - { - listView.SetAttribute(userAttr); - listView.AddRune(runes[c]); - drawnChars += cols; - } + var cols = Math.Max(graphemes[i].GetColumns(), 1); + if (drawnChars + cols > width) break; + listView.AddStr(graphemes[i]); + drawnChars += cols; } // Fill rest listView.SetAttribute(normalAttr); - while (drawnChars < width) - { - listView.AddRune(new Rune(' ')); - drawnChars++; - } + for (int i = drawnChars; i < width; i++) + listView.AddStr(" "); } public void Dispose() { } } +/// +/// Shared rendering helpers for IListDataSource implementations. +/// +static class RenderHelpers +{ + /// + /// Write text grapheme-by-grapheme to a ListView, respecting a width limit. + /// Returns the updated drawn-columns count. + /// + public static int WriteText(ListView lv, string text, int drawn, int maxWidth) + { + foreach (var grapheme in GraphemeHelper.GetGraphemes(text)) + { + var cols = Math.Max(grapheme.GetColumns(), 1); + if (drawn + cols > maxWidth) break; + lv.AddStr(grapheme); + drawn += cols; + } + return drawn; + } +} + /// /// Shared color attributes for chat rendering (timestamps, system messages). /// @@ -509,7 +480,6 @@ public static partial class ChatColors return segments; } - // Matches @username (letters, digits, underscores, hyphens — same as channel name chars) [GeneratedRegex(@"@[\w-]+")] private static partial Regex MentionRegex(); } diff --git a/src/EchoHub.Server/Services/ChatService.cs b/src/EchoHub.Server/Services/ChatService.cs index 5203176..3232033 100644 --- a/src/EchoHub.Server/Services/ChatService.cs +++ b/src/EchoHub.Server/Services/ChatService.cs @@ -1,4 +1,3 @@ -using System.Text; using EchoHub.Core.Constants; using EchoHub.Core.Contracts; using EchoHub.Core.DTOs; @@ -145,8 +144,7 @@ public class ChatService : IChatService if (content.Length > HubConstants.MaxMessageLength) return $"Message exceeds maximum length of {HubConstants.MaxMessageLength} characters."; - // Sanitize: convert emoji to text, collapse newlines - content = ConvertEmoji(content); + // Sanitize: collapse excessive newlines content = SanitizeNewlines(content); using var scope = _scopeFactory.CreateScope(); @@ -348,84 +346,6 @@ public class ChatService : IChatService return (user.Id, user.Username); } - /// - /// Replace emoji with text shortcodes. TUI terminals can't render wide chars reliably. - /// - private static string ConvertEmoji(string content) - { - var sb = new StringBuilder(content.Length); - foreach (var rune in content.EnumerateRunes()) - { - if (EmojiMap.TryGetValue(rune.Value, out var name)) - sb.Append(name); - else if (rune.Value >= 0x1F000) // supplementary emoji planes - sb.Append($"[?]"); - else if (rune.Value is 0x200D or 0xFE0F or 0xFE0E) // ZWJ, variation selectors - { } // strip silently - else - sb.Append(rune.ToString()); - } - return sb.ToString(); - } - - private static readonly Dictionary EmojiMap = new() - { - [0x1F600] = ":grinning:", [0x1F601] = ":grin:", [0x1F602] = ":joy:", - [0x1F603] = ":smiley:", [0x1F604] = ":smile:", [0x1F605] = ":sweat_smile:", - [0x1F606] = ":laughing:", [0x1F607] = ":angel:", [0x1F608] = ":imp:", - [0x1F609] = ":wink:", [0x1F60A] = ":blush:", [0x1F60B] = ":yum:", - [0x1F60C] = ":relieved:", [0x1F60D] = ":heart_eyes:", [0x1F60E] = ":sunglasses:", - [0x1F60F] = ":smirk:", [0x1F610] = ":neutral:", [0x1F611] = ":expressionless:", - [0x1F612] = ":unamused:", [0x1F613] = ":sweat:", [0x1F614] = ":pensive:", - [0x1F615] = ":confused:", [0x1F616] = ":confounded:", [0x1F617] = ":kiss:", - [0x1F618] = ":kissing_heart:", [0x1F619] = ":kissing:", [0x1F61A] = ":kissing_closed_eyes:", - [0x1F61B] = ":tongue:", [0x1F61C] = ":wink_tongue:", [0x1F61D] = ":squint_tongue:", - [0x1F61E] = ":disappointed:", [0x1F61F] = ":worried:", [0x1F620] = ":angry:", - [0x1F621] = ":rage:", [0x1F622] = ":cry:", [0x1F623] = ":persevere:", - [0x1F624] = ":triumph:", [0x1F625] = ":disappointed_relieved:", [0x1F626] = ":frowning:", - [0x1F627] = ":anguished:", [0x1F628] = ":fearful:", [0x1F629] = ":weary:", - [0x1F62A] = ":sleepy:", [0x1F62B] = ":tired:", [0x1F62C] = ":grimacing:", - [0x1F62D] = ":sob:", [0x1F62E] = ":open_mouth:", [0x1F62F] = ":hushed:", - [0x1F630] = ":cold_sweat:", [0x1F631] = ":scream:", [0x1F632] = ":astonished:", - [0x1F633] = ":flushed:", [0x1F634] = ":sleeping:", [0x1F635] = ":dizzy_face:", - [0x1F636] = ":no_mouth:", [0x1F637] = ":mask:", [0x1F638] = ":smile_cat:", - [0x1F642] = ":slight_smile:", [0x1F643] = ":upside_down:", - [0x1F644] = ":roll_eyes:", [0x1F910] = ":zipper_mouth:", - [0x1F911] = ":money_mouth:", [0x1F912] = ":thermometer_face:", - [0x1F913] = ":nerd:", [0x1F914] = ":thinking:", [0x1F915] = ":head_bandage:", - [0x1F920] = ":cowboy:", [0x1F921] = ":clown:", [0x1F923] = ":rofl:", - [0x1F924] = ":drooling:", [0x1F925] = ":lying:", - [0x1F970] = ":smiling_hearts:", [0x1F971] = ":yawning:", - [0x1F972] = ":smiling_tear:", [0x1F973] = ":party:", - [0x1F974] = ":woozy:", [0x1F975] = ":hot:", [0x1F976] = ":cold:", - [0x1F978] = ":disguised:", [0x1F979] = ":holding_back_tears:", - [0x1F97A] = ":pleading:", [0x1F92A] = ":zany:", [0x1F92B] = ":shushing:", - [0x1F92C] = ":censored:", [0x1F92D] = ":hand_over_mouth:", - [0x1F92E] = ":vomiting:", [0x1F92F] = ":exploding_head:", - // Gestures - [0x1F44D] = ":+1:", [0x1F44E] = ":-1:", [0x1F44F] = ":clap:", - [0x1F44B] = ":wave:", [0x1F44C] = ":ok_hand:", [0x1F44A] = ":punch:", - [0x1F4AA] = ":muscle:", [0x1F64F] = ":pray:", [0x1F91D] = ":handshake:", - [0x1F90C] = ":pinched_fingers:", [0x1F918] = ":metal:", [0x1F919] = ":call_me:", - // Hearts - [0x2764] = "<3", [0x1F494] = " /// Collapse consecutive newlines and cap total line count to prevent newline spam. /// From 7167b40013dc0b7d50aaa913fd00b2ce4a085f33 Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 19 Feb 2026 21:21:51 +0100 Subject: [PATCH 15/22] feat: implement link embed functionality to enhance message previews with metadata and thumbnails --- src/EchoHub.Client/UI/ChatRenderer.cs | 3 + src/EchoHub.Client/UI/MainWindow.cs | 67 +++++ src/EchoHub.Core/Constants/HubConstants.cs | 7 + src/EchoHub.Core/DTOs/ChatDtos.cs | 10 +- src/EchoHub.Core/Models/Message.cs | 1 + src/EchoHub.Server.Irc/IrcMessageFormatter.cs | 31 ++ .../Controllers/AuthController.cs | 1 + .../Controllers/ChannelsController.cs | 1 + .../Controllers/FilesController.cs | 1 + .../Controllers/ServerController.cs | 1 + .../Controllers/UsersController.cs | 1 + src/EchoHub.Server/Data/EchoHubDbContext.cs | 1 + ...20260219201704_AddMessageEmbed.Designer.cs | 264 +++++++++++++++++ .../20260219201704_AddMessageEmbed.cs | 29 ++ .../EchoHubDbContextModelSnapshot.cs | 4 + src/EchoHub.Server/Program.cs | 8 + src/EchoHub.Server/Services/ChatService.cs | 57 +++- .../Services/LinkEmbedService.cs | 268 ++++++++++++++++++ 18 files changed, 740 insertions(+), 15 deletions(-) create mode 100644 src/EchoHub.Server/Data/Migrations/20260219201704_AddMessageEmbed.Designer.cs create mode 100644 src/EchoHub.Server/Data/Migrations/20260219201704_AddMessageEmbed.cs create mode 100644 src/EchoHub.Server/Services/LinkEmbedService.cs diff --git a/src/EchoHub.Client/UI/ChatRenderer.cs b/src/EchoHub.Client/UI/ChatRenderer.cs index ef6330a..b8b91a4 100644 --- a/src/EchoHub.Client/UI/ChatRenderer.cs +++ b/src/EchoHub.Client/UI/ChatRenderer.cs @@ -455,6 +455,9 @@ public static partial class ChatColors public static readonly Attribute SystemAttr = new(new Color(0, 180, 180), Color.Black); 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.Black); + public static readonly Attribute EmbedBorderAttr = new(new Color(91, 155, 213), Color.Black); + public static readonly Attribute EmbedTitleAttr = new(Color.White, Color.Black); + public static readonly Attribute EmbedDescAttr = new(new Color(160, 160, 160), Color.Black); /// /// Split text around @mentions, giving each @word the MentionTextAttr accent color. diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index 0d70ebe..ffc2a87 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -890,6 +890,10 @@ public sealed class MainWindow : Runnable var contText = $"{indent}{contentLines[i].TrimEnd('\r')}"; lines.Add(new ChatLine(ChatColors.SplitMentions(contText))); } + + // Render link embed if present + if (message.Embed is not null) + lines.AddRange(FormatEmbed(message.Embed, indent)); break; } @@ -938,4 +942,67 @@ public sealed class MainWindow : Runnable segments.AddRange(ChatColors.SplitMentions(suffix)); return new ChatLine(segments); } + + /// + /// Format a link embed as indented chat lines with a left border bar. + /// + private static List FormatEmbed(EmbedDto embed, string indent) + { + var lines = new List(); + const string border = "\u258f "; // ▏ + space + + // Site name + if (!string.IsNullOrWhiteSpace(embed.SiteName)) + { + lines.Add(new ChatLine(new List + { + new(indent, null), + new(border, ChatColors.EmbedBorderAttr), + new(embed.SiteName, ChatColors.EmbedBorderAttr) + })); + } + + // Title + if (!string.IsNullOrWhiteSpace(embed.Title)) + { + lines.Add(new ChatLine(new List + { + new(indent, null), + new(border, ChatColors.EmbedBorderAttr), + new(embed.Title, ChatColors.EmbedTitleAttr) + })); + } + + // Description (truncated) + if (!string.IsNullOrWhiteSpace(embed.Description)) + { + var desc = embed.Description.Length > 120 + ? embed.Description[..117] + "..." + : embed.Description; + + lines.Add(new ChatLine(new List + { + new(indent, null), + new(border, ChatColors.EmbedBorderAttr), + new(desc, ChatColors.EmbedDescAttr) + })); + } + + // ASCII image thumbnail + if (!string.IsNullOrWhiteSpace(embed.ImageAscii)) + { + foreach (var artLine in embed.ImageAscii.Split('\n')) + { + var trimmed = artLine.TrimEnd('\r'); + if (string.IsNullOrEmpty(trimmed)) continue; + + if (ChatLine.HasColorTags(trimmed)) + lines.Add(ChatLine.FromColoredText(indent + border + trimmed)); + else + lines.Add(new ChatLine(indent + border + trimmed)); + } + } + + return lines; + } } diff --git a/src/EchoHub.Core/Constants/HubConstants.cs b/src/EchoHub.Core/Constants/HubConstants.cs index e79e431..5b2120d 100644 --- a/src/EchoHub.Core/Constants/HubConstants.cs +++ b/src/EchoHub.Core/Constants/HubConstants.cs @@ -13,4 +13,11 @@ public static class HubConstants public const int AsciiArtWidth = 80; public const int AsciiArtHeight = 40; public const int AsciiArtHeightHalfBlock = 80; + + // Link embed constants + public const int EmbedThumbnailWidth = 24; + public const int EmbedThumbnailHeight = 12; + public const int EmbedMaxDescriptionLength = 200; + public const int EmbedMaxHtmlBytes = 64 * 1024; // 64 KB + public const int EmbedFetchTimeoutSeconds = 3; } diff --git a/src/EchoHub.Core/DTOs/ChatDtos.cs b/src/EchoHub.Core/DTOs/ChatDtos.cs index d24b1f1..5c35564 100644 --- a/src/EchoHub.Core/DTOs/ChatDtos.cs +++ b/src/EchoHub.Core/DTOs/ChatDtos.cs @@ -11,7 +11,8 @@ public record MessageDto( MessageType Type, string? AttachmentUrl, string? AttachmentFileName, - DateTimeOffset SentAt); + DateTimeOffset SentAt, + EmbedDto? Embed = null); public record ChannelDto( Guid Id, @@ -36,3 +37,10 @@ public record CreateChannelRequest(string Name, string? Topic = null, bool IsPub public record UpdateTopicRequest(string? Topic); public record SendUrlRequest(string Url); + +public record EmbedDto( + string? SiteName, + string? Title, + string? Description, + string? ImageAscii, + string Url); diff --git a/src/EchoHub.Core/Models/Message.cs b/src/EchoHub.Core/Models/Message.cs index 7c35de3..2ace5ac 100644 --- a/src/EchoHub.Core/Models/Message.cs +++ b/src/EchoHub.Core/Models/Message.cs @@ -7,6 +7,7 @@ public class Message public MessageType Type { get; set; } = MessageType.Text; public string? AttachmentUrl { get; set; } public string? AttachmentFileName { get; set; } + public string? EmbedJson { get; set; } public DateTimeOffset SentAt { get; set; } = DateTimeOffset.UtcNow; public Guid ChannelId { get; set; } diff --git a/src/EchoHub.Server.Irc/IrcMessageFormatter.cs b/src/EchoHub.Server.Irc/IrcMessageFormatter.cs index e0963d1..774b081 100644 --- a/src/EchoHub.Server.Irc/IrcMessageFormatter.cs +++ b/src/EchoHub.Server.Irc/IrcMessageFormatter.cs @@ -23,6 +23,10 @@ public static partial class IrcMessageFormatter case MessageType.Text: foreach (var chunk in SplitMessage(message.Content, MaxIrcLineContentBytes)) lines.Add($"{prefix} PRIVMSG {ircChannel} :{chunk}"); + + // Append embed preview if present + if (message.Embed is not null) + lines.AddRange(FormatEmbed(prefix, ircChannel, message.Embed)); break; case MessageType.Image: @@ -46,6 +50,33 @@ public static partial class IrcMessageFormatter return lines; } + /// + /// Format a link embed as IRC PRIVMSG lines (text-only, no ASCII thumbnail). + /// + private static List FormatEmbed(string prefix, string ircChannel, EmbedDto embed) + { + var lines = new List(); + + var header = new List(); + if (!string.IsNullOrWhiteSpace(embed.SiteName)) + header.Add(embed.SiteName); + if (!string.IsNullOrWhiteSpace(embed.Title)) + header.Add(embed.Title); + + if (header.Count > 0) + lines.Add($"{prefix} PRIVMSG {ircChannel} :\u2502 {string.Join(" \u2014 ", header)}"); + + if (!string.IsNullOrWhiteSpace(embed.Description)) + { + var desc = embed.Description.Length > 200 + ? embed.Description[..197] + "..." + : embed.Description; + lines.Add($"{prefix} PRIVMSG {ircChannel} :\u2502 {desc}"); + } + + return lines; + } + /// /// Convert printable color tags ({F:RRGGBB}, {B:RRGGBB}, {X}) to ANSI escape codes for IRC clients. /// Also passes through content that already uses ANSI codes unchanged. diff --git a/src/EchoHub.Server/Controllers/AuthController.cs b/src/EchoHub.Server/Controllers/AuthController.cs index 39702e3..ce61426 100644 --- a/src/EchoHub.Server/Controllers/AuthController.cs +++ b/src/EchoHub.Server/Controllers/AuthController.cs @@ -22,6 +22,7 @@ public class AuthController : ControllerBase _db = db; _jwt = jwt; } + [HttpPost("register")] public async Task Register([FromBody] RegisterRequest request) { diff --git a/src/EchoHub.Server/Controllers/ChannelsController.cs b/src/EchoHub.Server/Controllers/ChannelsController.cs index 6631e50..5db6ccf 100644 --- a/src/EchoHub.Server/Controllers/ChannelsController.cs +++ b/src/EchoHub.Server/Controllers/ChannelsController.cs @@ -37,6 +37,7 @@ public class ChannelsController : ControllerBase _httpClientFactory = httpClientFactory; _chatService = chatService; } + [HttpGet] public async Task GetChannels([FromQuery] int offset = 0, [FromQuery] int limit = 50) { diff --git a/src/EchoHub.Server/Controllers/FilesController.cs b/src/EchoHub.Server/Controllers/FilesController.cs index 2364171..2bcacf9 100644 --- a/src/EchoHub.Server/Controllers/FilesController.cs +++ b/src/EchoHub.Server/Controllers/FilesController.cs @@ -18,6 +18,7 @@ public class FilesController : ControllerBase { _fileStorage = fileStorage; } + [HttpGet("{fileId}")] public IActionResult GetFile(string fileId) { diff --git a/src/EchoHub.Server/Controllers/ServerController.cs b/src/EchoHub.Server/Controllers/ServerController.cs index 383840b..8b47906 100644 --- a/src/EchoHub.Server/Controllers/ServerController.cs +++ b/src/EchoHub.Server/Controllers/ServerController.cs @@ -17,6 +17,7 @@ public class ServerController : ControllerBase _db = db; _config = config; } + [HttpGet("info")] public async Task GetInfo() { diff --git a/src/EchoHub.Server/Controllers/UsersController.cs b/src/EchoHub.Server/Controllers/UsersController.cs index 6909a0f..9c0d4c8 100644 --- a/src/EchoHub.Server/Controllers/UsersController.cs +++ b/src/EchoHub.Server/Controllers/UsersController.cs @@ -24,6 +24,7 @@ public class UsersController : ControllerBase _db = db; _asciiService = asciiService; } + [HttpGet("{username}/profile")] public async Task GetProfile(string username) { diff --git a/src/EchoHub.Server/Data/EchoHubDbContext.cs b/src/EchoHub.Server/Data/EchoHubDbContext.cs index f4a32ae..4eff95c 100644 --- a/src/EchoHub.Server/Data/EchoHubDbContext.cs +++ b/src/EchoHub.Server/Data/EchoHubDbContext.cs @@ -59,6 +59,7 @@ public class EchoHubDbContext : DbContext entity.Property(m => m.SenderUsername).IsRequired().HasMaxLength(50); entity.Property(m => m.AttachmentUrl).HasMaxLength(500); entity.Property(m => m.AttachmentFileName).HasMaxLength(255); + entity.Property(m => m.EmbedJson).HasMaxLength(8000); }); modelBuilder.Entity(entity => diff --git a/src/EchoHub.Server/Data/Migrations/20260219201704_AddMessageEmbed.Designer.cs b/src/EchoHub.Server/Data/Migrations/20260219201704_AddMessageEmbed.Designer.cs new file mode 100644 index 0000000..493ab2c --- /dev/null +++ b/src/EchoHub.Server/Data/Migrations/20260219201704_AddMessageEmbed.Designer.cs @@ -0,0 +1,264 @@ +// +using System; +using EchoHub.Server.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EchoHub.Server.Data.Migrations +{ + [DbContext(typeof(EchoHubDbContext))] + [Migration("20260219201704_AddMessageEmbed")] + partial class AddMessageEmbed + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.3"); + + modelBuilder.Entity("EchoHub.Core.Models.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("CreatedByUserId") + .HasColumnType("TEXT"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Topic") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "ChannelId"); + + b.HasIndex("ChannelId"); + + b.HasIndex("UserId"); + + b.ToTable("ChannelMemberships"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AttachmentFileName") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AttachmentUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EmbedJson") + .HasMaxLength(8000) + .HasColumnType("TEXT"); + + b.Property("SenderUserId") + .HasColumnType("TEXT"); + + b.Property("SenderUsername") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("SentAt") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ExpiresAt") + .HasColumnType("INTEGER"); + + b.Property("RevokedAt") + .HasColumnType("INTEGER"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash"); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AvatarAscii") + .HasMaxLength(10000) + .HasColumnType("TEXT"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IsBanned") + .HasColumnType("INTEGER"); + + b.Property("IsMuted") + .HasColumnType("INTEGER"); + + b.Property("LastSeenAt") + .HasColumnType("INTEGER"); + + b.Property("MutedUntil") + .HasColumnType("INTEGER"); + + b.Property("NicknameColor") + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StatusMessage") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b => + { + b.HasOne("EchoHub.Core.Models.Channel", null) + .WithMany() + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EchoHub.Core.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Message", b => + { + b.HasOne("EchoHub.Core.Models.Channel", "Channel") + .WithMany("Messages") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Channel"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b => + { + b.HasOne("EchoHub.Core.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Channel", b => + { + b.Navigation("Messages"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EchoHub.Server/Data/Migrations/20260219201704_AddMessageEmbed.cs b/src/EchoHub.Server/Data/Migrations/20260219201704_AddMessageEmbed.cs new file mode 100644 index 0000000..ede5f16 --- /dev/null +++ b/src/EchoHub.Server/Data/Migrations/20260219201704_AddMessageEmbed.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EchoHub.Server.Data.Migrations +{ + /// + public partial class AddMessageEmbed : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "EmbedJson", + table: "Messages", + type: "TEXT", + maxLength: 8000, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "EmbedJson", + table: "Messages"); + } + } +} diff --git a/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs b/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs index adbf800..66f12ce 100644 --- a/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs +++ b/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs @@ -91,6 +91,10 @@ namespace EchoHub.Server.Data.Migrations .HasMaxLength(2000) .HasColumnType("TEXT"); + b.Property("EmbedJson") + .HasMaxLength(8000) + .HasColumnType("TEXT"); + b.Property("SenderUserId") .HasColumnType("TEXT"); diff --git a/src/EchoHub.Server/Program.cs b/src/EchoHub.Server/Program.cs index f5a38a9..4aa6972 100644 --- a/src/EchoHub.Server/Program.cs +++ b/src/EchoHub.Server/Program.cs @@ -106,6 +106,7 @@ while (true) builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddHostedService(); // ── Chat Service + Broadcasters ───────────────────────────────────── @@ -121,6 +122,13 @@ while (true) client.MaxResponseContentBufferSize = 10 * 1024 * 1024; // 10 MB }); + builder.Services.AddHttpClient("OgFetch", client => + { + client.Timeout = TimeSpan.FromSeconds(5); + client.MaxResponseContentBufferSize = 256 * 1024; // 256 KB + client.DefaultRequestHeaders.UserAgent.ParseAdd("EchoHub/1.0 (Link Preview Bot)"); + }); + // ── Rate Limiting ──────────────────────────────────────────────────── builder.Services.AddRateLimiter(options => { diff --git a/src/EchoHub.Server/Services/ChatService.cs b/src/EchoHub.Server/Services/ChatService.cs index 3232033..8dd3844 100644 --- a/src/EchoHub.Server/Services/ChatService.cs +++ b/src/EchoHub.Server/Services/ChatService.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using EchoHub.Core.Constants; using EchoHub.Core.Contracts; using EchoHub.Core.DTOs; @@ -14,17 +15,20 @@ public class ChatService : IChatService private readonly IServiceScopeFactory _scopeFactory; private readonly PresenceTracker _presenceTracker; private readonly IEnumerable _broadcasters; + private readonly LinkEmbedService _embedService; private readonly ILogger _logger; public ChatService( IServiceScopeFactory scopeFactory, PresenceTracker presenceTracker, IEnumerable broadcasters, + LinkEmbedService embedService, ILogger logger) { _scopeFactory = scopeFactory; _presenceTracker = presenceTracker; _broadcasters = broadcasters; + _embedService = embedService; _logger = logger; } @@ -171,6 +175,17 @@ public class ChatService : IChatService } } + // Attempt to fetch link embed for URLs in the message + EmbedDto? embed = null; + try + { + embed = await _embedService.TryGetEmbedAsync(content); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to fetch link embed for message in '{Channel}'", channelName); + } + var message = new Message { Id = Guid.NewGuid(), @@ -180,6 +195,7 @@ public class ChatService : IChatService ChannelId = channel.Id, SenderUserId = userId, SenderUsername = username, + EmbedJson = embed is not null ? JsonSerializer.Serialize(embed) : null, }; db.Messages.Add(message); @@ -194,7 +210,8 @@ public class ChatService : IChatService MessageType.Text, null, null, - message.SentAt); + message.SentAt, + embed); await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto)); @@ -387,26 +404,38 @@ public class ChatService : IChatService if (channel is null) return []; - var messages = await db.Messages + var raw = await db.Messages .Where(m => m.ChannelId == channel.Id) .OrderByDescending(m => m.SentAt) .Take(count) .Join(db.Users, m => m.SenderUserId, u => u.Id, - (m, u) => new MessageDto( - m.Id, - m.Content, - m.SenderUsername, - u.NicknameColor, - channelName, - m.Type, - m.AttachmentUrl, - m.AttachmentFileName, - m.SentAt)) + (m, u) => new { m, u.NicknameColor }) .ToListAsync(); - messages.Reverse(); - return messages; + raw.Reverse(); + + return raw.Select(x => + { + EmbedDto? embed = null; + if (x.m.EmbedJson is not null) + { + try { embed = JsonSerializer.Deserialize(x.m.EmbedJson); } + catch { /* ignore malformed JSON */ } + } + + return new MessageDto( + x.m.Id, + x.m.Content, + x.m.SenderUsername, + x.NicknameColor, + channelName, + x.m.Type, + x.m.AttachmentUrl, + x.m.AttachmentFileName, + x.m.SentAt, + embed); + }).ToList(); } } diff --git a/src/EchoHub.Server/Services/LinkEmbedService.cs b/src/EchoHub.Server/Services/LinkEmbedService.cs new file mode 100644 index 0000000..a854b8c --- /dev/null +++ b/src/EchoHub.Server/Services/LinkEmbedService.cs @@ -0,0 +1,268 @@ +using System.Net; +using System.Text; +using System.Text.RegularExpressions; +using EchoHub.Core.Constants; +using EchoHub.Core.DTOs; +using Microsoft.Extensions.Logging; + +namespace EchoHub.Server.Services; + +public partial class LinkEmbedService +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly ImageToAsciiService _asciiService; + private readonly ILogger _logger; + + public LinkEmbedService( + IHttpClientFactory httpClientFactory, + ImageToAsciiService asciiService, + ILogger logger) + { + _httpClientFactory = httpClientFactory; + _asciiService = asciiService; + _logger = logger; + } + + /// + /// Detect the first URL in message content and attempt to fetch OG embed data. + /// Returns null if no URL found, fetch fails, or no useful OG data. + /// Never throws — all errors are caught internally. + /// + public async Task TryGetEmbedAsync(string content) + { + try + { + var url = ExtractFirstUrl(content); + if (url is null) + return null; + + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + return null; + + if (uri.Scheme is not ("http" or "https")) + return null; + + if (IsPrivateHost(uri)) + return null; + + using var cts = new CancellationTokenSource( + TimeSpan.FromSeconds(HubConstants.EmbedFetchTimeoutSeconds)); + + var client = _httpClientFactory.CreateClient("OgFetch"); + + using var request = new HttpRequestMessage(HttpMethod.Get, uri); + using var response = await client.SendAsync(request, + HttpCompletionOption.ResponseHeadersRead, cts.Token); + + if (!response.IsSuccessStatusCode) + return null; + + var contentType = response.Content.Headers.ContentType?.MediaType; + if (contentType is null || !contentType.StartsWith("text/html", StringComparison.OrdinalIgnoreCase)) + return null; + + var html = await ReadLimitedAsync(response, HubConstants.EmbedMaxHtmlBytes, cts.Token); + if (string.IsNullOrWhiteSpace(html)) + return null; + + var ogTags = ParseOgTags(html); + + // Try og:title, fallback to tag + var title = ogTags.GetValueOrDefault("title"); + if (string.IsNullOrWhiteSpace(title)) + { + var titleMatch = TitleTagRegex().Match(html); + if (titleMatch.Success) + title = WebUtility.HtmlDecode(titleMatch.Groups[1].Value.Trim()); + } + + // If no title at all, nothing useful to show + if (string.IsNullOrWhiteSpace(title)) + return null; + + var siteName = ogTags.GetValueOrDefault("site_name"); + var description = ogTags.GetValueOrDefault("description"); + + // Truncate description + if (description is not null && description.Length > HubConstants.EmbedMaxDescriptionLength) + description = description[..(HubConstants.EmbedMaxDescriptionLength - 3)] + "..."; + + // HTML decode text fields + title = WebUtility.HtmlDecode(title); + siteName = siteName is not null ? WebUtility.HtmlDecode(siteName) : null; + description = description is not null ? WebUtility.HtmlDecode(description) : null; + + // Attempt to fetch OG image thumbnail + string? imageAscii = null; + var imageUrl = ogTags.GetValueOrDefault("image"); + if (!string.IsNullOrWhiteSpace(imageUrl)) + { + imageAscii = await FetchImageThumbnailAsync(imageUrl, uri, cts.Token); + } + + return new EmbedDto(siteName, title, description, imageAscii, url); + } + catch (OperationCanceledException) + { + _logger.LogDebug("Embed fetch timed out for message content"); + return null; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to fetch embed"); + return null; + } + } + + private static string? ExtractFirstUrl(string content) + { + var match = UrlRegex().Match(content); + if (!match.Success) + return null; + + var url = match.Value; + + // Strip trailing punctuation that's likely not part of the URL + url = url.TrimEnd('.', ',', '!', '?', ')', ']', ';', ':'); + + return url; + } + + private static bool IsPrivateHost(Uri uri) + { + if (uri.IsLoopback) + return true; + + if (IPAddress.TryParse(uri.Host, out var ip)) + { + var bytes = ip.GetAddressBytes(); + if (bytes.Length == 4) + { + if (bytes[0] == 10) return true; + if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) return true; + if (bytes[0] == 192 && bytes[1] == 168) return true; + if (bytes[0] == 127) return true; + if (bytes[0] == 0) return true; + } + } + + // Also check hostname-based loopback + if (uri.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase)) + return true; + + return false; + } + + private static Dictionary<string, string> ParseOgTags(string html) + { + var tags = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + + // Match: <meta property="og:key" content="value" /> + foreach (Match match in OgTagRegex().Matches(html)) + { + var key = match.Groups[1].Value; + var value = match.Groups[2].Value; + tags.TryAdd(key, value); + } + + // Match reversed order: <meta content="value" property="og:key" /> + foreach (Match match in OgTagReversedRegex().Matches(html)) + { + var value = match.Groups[1].Value; + var key = match.Groups[2].Value; + tags.TryAdd(key, value); + } + + return tags; + } + + private async Task<string?> FetchImageThumbnailAsync(string imageUrl, Uri pageUri, CancellationToken ct) + { + try + { + // Resolve relative image URLs against the page URI + if (!Uri.TryCreate(imageUrl, UriKind.Absolute, out var imageUri)) + { + if (!Uri.TryCreate(pageUri, imageUrl, out imageUri)) + return null; + } + + if (imageUri.Scheme is not ("http" or "https")) + return null; + + if (IsPrivateHost(imageUri)) + return null; + + var client = _httpClientFactory.CreateClient("OgFetch"); + using var response = await client.GetAsync(imageUri, ct); + + if (!response.IsSuccessStatusCode) + return null; + + var contentType = response.Content.Headers.ContentType?.MediaType; + if (contentType is null || !contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) + return null; + + await using var stream = await response.Content.ReadAsStreamAsync(ct); + + // Buffer into a MemoryStream for validation + conversion + using var memoryStream = new MemoryStream(); + await stream.CopyToAsync(memoryStream, ct); + + if (memoryStream.Length == 0 || memoryStream.Length > HubConstants.MaxFileSizeBytes) + return null; + + memoryStream.Position = 0; + + if (!FileValidationHelper.IsValidImage(memoryStream)) + return null; + + memoryStream.Position = 0; + + return _asciiService.ConvertToAscii(memoryStream, + HubConstants.EmbedThumbnailWidth, + HubConstants.EmbedThumbnailHeight); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to fetch OG image thumbnail from {Url}", imageUrl); + return null; + } + } + + private static async Task<string> ReadLimitedAsync(HttpResponseMessage response, int maxBytes, CancellationToken ct) + { + await using var stream = await response.Content.ReadAsStreamAsync(ct); + var buffer = new byte[maxBytes]; + var totalRead = 0; + + while (totalRead < maxBytes) + { + var read = await stream.ReadAsync(buffer.AsMemory(totalRead, maxBytes - totalRead), ct); + if (read == 0) break; + totalRead += read; + } + + // Try to detect encoding from Content-Type, default to UTF-8 + var charset = response.Content.Headers.ContentType?.CharSet; + var encoding = charset is not null + ? Encoding.GetEncoding(charset) + : Encoding.UTF8; + + return encoding.GetString(buffer, 0, totalRead); + } + + [GeneratedRegex(@"https?://[^\s<>""')\]]+", RegexOptions.IgnoreCase)] + private static partial Regex UrlRegex(); + + [GeneratedRegex(@"<meta\s+[^>]*?property\s*=\s*[""']og:(\w+)[""'][^>]*?content\s*=\s*[""']([^""']*)[""'][^>]*/?>", + RegexOptions.IgnoreCase | RegexOptions.Singleline)] + private static partial Regex OgTagRegex(); + + [GeneratedRegex(@"<meta\s+[^>]*?content\s*=\s*[""']([^""']*)[""'][^>]*?property\s*=\s*[""']og:(\w+)[""'][^>]*/?>", + RegexOptions.IgnoreCase | RegexOptions.Singleline)] + private static partial Regex OgTagReversedRegex(); + + [GeneratedRegex(@"<title[^>]*>([^<]+)", RegexOptions.IgnoreCase)] + private static partial Regex TitleTagRegex(); +} From b508a5854a2244d2fd9066f471d330dfa2ffe75e Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 19 Feb 2026 21:52:18 +0100 Subject: [PATCH 16/22] feat: Add notification sound functionality and embed support - Introduced NotificationConfig to manage notification settings, including sound file. - Added Notification.mp3 asset for notifications. - Implemented NotificationSoundService to handle playing notification sounds. - Enhanced ChatRenderer and MainWindow to support emoji rendering. - Updated MessageDto to allow multiple embeds per message. - Modified LinkEmbedService to fetch multiple embeds from message content. - Added data migration for legacy embed JSON format to new array format. - Adjusted embed handling in ChatService and IrcMessageFormatter to accommodate multiple embeds. - Updated HubConstants for new embed dimensions and limits. --- docs/changelog/v0.2.4.md | 22 ++ src/EchoHub.Client/Assets/Notification.mp3 | Bin 0 -> 7104 bytes src/EchoHub.Client/Config/ClientConfig.cs | 7 + src/EchoHub.Client/EchoHub.Client.csproj | 4 + .../Services/NotificationSoundService.cs | 64 ++++ src/EchoHub.Client/UI/ChatRenderer.cs | 7 + src/EchoHub.Client/UI/EmojiHelper.cs | 333 ++++++++++++++++++ src/EchoHub.Client/UI/MainWindow.cs | 191 +++++++--- src/EchoHub.Core/Constants/HubConstants.cs | 7 +- src/EchoHub.Core/DTOs/ChatDtos.cs | 2 +- src/EchoHub.Server.Irc/IrcMessageFormatter.cs | 9 +- src/EchoHub.Server/Services/ChatService.cs | 18 +- .../Services/LinkEmbedService.cs | 193 +++++----- .../Setup/DataMigrationService.cs | 45 +++ 14 files changed, 746 insertions(+), 156 deletions(-) create mode 100644 docs/changelog/v0.2.4.md create mode 100644 src/EchoHub.Client/Assets/Notification.mp3 create mode 100644 src/EchoHub.Client/Services/NotificationSoundService.cs create mode 100644 src/EchoHub.Client/UI/EmojiHelper.cs diff --git a/docs/changelog/v0.2.4.md b/docs/changelog/v0.2.4.md new file mode 100644 index 0000000..3b8c67a --- /dev/null +++ b/docs/changelog/v0.2.4.md @@ -0,0 +1,22 @@ +# v0.2.4 - Link Embeds + +## Features + +### OpenGraph Link Embeds +- Messages containing URLs now show a rich preview below the message text, similar to Discord +- Server-side fetching: detects the first URL in a message, fetches the page, and parses OpenGraph meta tags (`og:title`, `og:description`, `og:image`, `og:site_name`) +- OG images are converted to a small 24x12 colored ASCII thumbnail using the existing half-block renderer +- Embeds are persisted in the database and included in channel history +- TUI client renders embeds with a `▏` left border bar — site name and border in blue, title in white, description in gray, followed by the ASCII thumbnail +- IRC gateway receives a text-only embed preview (site name, title, description) — no ASCII thumbnail to keep IRC output clean +- Falls back to `` tag when no OG tags are present; gracefully skips if no useful metadata is found +- 3-second fetch timeout ensures message delivery is never significantly delayed +- SSRF protection rejects private/loopback IP addresses before fetching + +## Infrastructure + +- New `LinkEmbedService` on the server — URL detection, HTML fetching (first 64KB), OG tag parsing via regex, image thumbnail generation +- `EmbedDto` record added to shared Core DTOs; `MessageDto` extended with optional `Embed` field (backward-compatible) +- `EmbedJson` nullable column on the `Message` table stores serialized embed data (max 8KB) +- Dedicated `"OgFetch"` named HttpClient with bot User-Agent header and 5-second timeout +- New EF Core migration: `AddMessageEmbed` diff --git a/src/EchoHub.Client/Assets/Notification.mp3 b/src/EchoHub.Client/Assets/Notification.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..dcf02928edc4babccf8fdd7a3f2efc8a52638450 GIT binary patch literal 7104 zcmeI%XHXN}<LL2FgisA77y@X35JG4HLKP&09-2TXL6KrWT2QJ8=#LUgXodihst_R3 zg-{d_l_pZ8gMf<CL9BrKSh+uaX6}po_Ws}9`<dB&v1exY%zkIjoS8lRK^p-4pR7au zFaG!1(f=MG06^*y0Q48^FZ5s7zfS+l`>#uXCHzJESNUJfe?9tZ@?Wq2`uNx3U;llU zzO9L!xt5v+8qM+a&y-Yr0RZs?I2RoU03`gG2XwBC`e)$(C5L|kaXP;New5`N5N$HK zRY<BtVgcf3`r8JES$0}L(3sJGPnk2r&q1cfSr1nP?~OiO)zYWiPY}7kXfA&RsT3J$ z96d~ee0>3eB?;RWuN({>xBtARa&xG|whv;fs*~OS$mi+@_oSom7ZN-!w_LwUOX7y5 zdMzJ9<-Y#tkmVj`4FEwVYrn4;q|eC7%UM@|u!(}k7ncX(5T@$A6d)`{&-3NYn=f~b zrqY9V_z(_e4MI4}aux1!Gucq(z)8hS1)4yrjpLwowJhhskM6z&mZMKwiJx;YCrV0I zjKhy!DHy|~N_tfH40-WPt!di_%S)WkMx-2*V)E$ZuD0TTB3r*VRTn#1vs1)3tL54k z?mugn$Wo)EeH>>v)T}%UA8a4at$Oq$;`m^NWkx2m)R$jCB!8IL0h_aUhCd%#Ik<1r z6mTpvP;a*7SA}3^OQ+MSM)HC=t98R$803diSHcEFWmh}>y!Tlr7bz;3D1T|@W4(Nr z<v-u({nERmYld3!1F<4*XHUpvqL9(xMtMqcwYXi;{yQ)H;Sc%%EI~R$@V7P#z~wqv zYpjdqjBzk_+`a(x`+-6k_+Pu^U6y?8`8B7N;wR$j5|Z_DGLLSp-j9>!Ma}XJDS_*| z^ZR<gX}!I6CBQ7ATDi#mlyUIsDG05{GXJ_4LUZnwDvR>oU{JNYe*AludsXljSkLLp zis+HJ*(Y+wl!i)as{Y}p@7vdBkL$HM>Ncug%c?he;xKW-yQ89gGhmqaT^U+o8LHrZ z^CjP>$fuR96>{f@U30{_(nk<U$-&pgQrX#klSuC#3*IM#0#w+R%Hxeq-{ZlohliX0 z;SS?D14(29cyer>rt7N_giBSWE#HR(i`|<!E}*6ywtUvkJ|xk7^4?hkEu0}@$a|<3 zJiD<cY!2j5l|-{!Y(W5>z9_YJE!8)~v(&uwlB|Slt4H5_>#Wu`K&kQEk?BuZ5%-P3 zC9_#Qzw(~%3e=n!l02sTk}n|f@bI!po&UK}kMzQ_VJ6I!$NOV5Xb+*k5y`iI^xs8e z-<0#xCvLNsnVak1x_CBuQnTuqaxpcRBDrBJKv*JI<m_^uz4M<!`~XNxxAS5hfd3U% z*6p4*2fU@-E43nQz}sgDnt<Mqja*7>;4m>=$u3TZ@_LhbX{#MPey^E{R9JgVd|xgP zGhsMoTJ`}?6bQm4>%(!wR6GyCvvW-Z0Bz=j@+_s})x_lX0{+7gw9Yfh7OU~PGxyUS z3kRHOQ4(=UGduo|uI`<=LjeV$1G|A8VdXq?`sCy6TZe}O2nB8l@lR)j9Fyd8*(#eb zb9TVZ-MPcVpX_s5YK3_$9u?ByTKK1ZNI54~QqYN})-Ms=_b7swZ|ssMW}t07Q2tn| zpn;qQ#G6|#cqe1V8$N<h7?-L|KBsS54h>iNq8C>hd>=5y7F8D%bySYu84YfnQmkn- zSy?4GsePER*R&LGX$1QA9}|rIdB4xU;#&_*k9Kw0b>@s?0JCbzDQ@RIVdi3IU0$?g z5W2InrgJ%~EL}ZR%6~Z{;-a#>3;a#=Y>_VQ`O@be*4*8QOYQUJ(?2fi#Fl2ej9H$3 zz4->j^L}nWXtXtYQe#4!<Ym3jRZv!J_raa2YXw`X{64LB{6BvRi6psC^Kq-L$Z|n@ zy$kNcC2`$>a)EMNBl@+B7VgdG%6yX*Fh_z#VI1H3rQOwgA#3XW$~ZxGZChk^epzk* z+%O+%J9|p=*EZNrP!O>M56SHjW0(s0aef-l`mQqd<XY0Rn>Vvu9cy%$7RTD9?D?(s z42z%ExP0_3{B){<o_DdMLJiaEixBB>PYP)=iolL82I{fh2!!Q9ojd#Ku(tTAb8_(~ z^(tv6RKcQ|!5+X<lL;Str3_((j8G*5?kXo!WUm%yEpP+W2`LZFeQLT1yl!4exg=}V zo19)fqC;Qg(x@gRX+PcJ+*2F~t?N{zog&6a)-J{ydn~%<+pekIbF$VM&%4tEOD#D^ zeJ0;psj5?XQlmfn(He&NmKo@TIO9}P#K9aJ^n<Vav@za6sN-QaAzgR;CLPR!9G47J zC#Z{AqxZ_9_|If+7N{#ZBc4&tIOSg(`-!J_EvOXscM-4OHTJ0yE7mh5wY{Dp2KbiH zEnDa0O)rY-WBnxN;fcw}bX<c>cmENTi2LkOmE(F=C>LsF-QPl7L>)eEp%$bK9~PF< z0e9ru!AxT9cjSo{_R^>lpo?#Spa;^L;~A#6Grx4wS7M|d$=<2JTrT&i!nZ1k5L8J5 zU+CBhaglrlw=y|3`Yt3%CiFrkvQ<c0#?HsJ&P7bXnzJFi+5;CBcP<>^s-%;vY{^hb zE3jc_pqOT*=e5t(N>2Vn-<t3QYeXRouQFzBtC(F9*~5OO7lJfgqV4R%cYSw8_@A)s z&kJo%J9}>Xk1iJCc@}1~@?*%XV<%?yPsxeLA~L1f+v6E~=pZdD(Uu5`FAi6WcT)1D z2b5KjhZxw@XWa(wYf8@RN$ECCj_DR~F{7X=GNOV0T(g4PCFoKzvQ|fgUlwi`B0gZP zCGL9KcCaWQ5vjR+rv;M{X94%qExP5KE1}@X=c16L_yS!(8bO<XdYlK6?a03XQm|KX zw5sw&PKOj1m?#h~s0K>8FqZry8=E`ze<cliNg72LX4+t$F$uH9clmbrza)$40FWI0 z6rBbk>AE2PwnRW=iCIp-&TT|@e6GbgduArB68!MrZ-~evz}?s)XCr6go!9^uv2PII zlbt7%hC-^+Ht{Lda`u<&EPBq<*hv36I}r=a<Gc>5<^4d339efCKr^#LGRr#rl>+OF zM2)CgPDn|dUQUjVwXIlwFbLeA3GLq|9c(xn1rHP4EAq)rpVM!|#YxOyU8+#m2X696 zk;hq?z{wk^w&pjjN#+8#!$m3x4)|=bo6hoHY;wnqT6DtrvJr{U&Fn7Gj0#jTQuO=e zzy6t8270doR7-OWv#qfL$X?(ba(4F=o5yuwbRenXXDVm4%z3u5I_}@{pKRL9L3mb9 zllj>E?{sw}34V8BxTtkSknhHg92L|#8AbL|V4cN_okq33!6To|g0zYg>Xq-yP%an_ zY93k7HM?W=BTqZ5RLX8!Oa*U8jng?pZ|>EEb^VeIre%cLAtaEsx^1MG-Zrb7JtEo~ zcjc@JGun)?@kd2@%10}7CL7Jj-^%E!Hm;79Be$nA)`?K~H7go!3>6*{$)b?C9svwr zR|z!crynaa7S7haJ@@t^gmajVq}aTolh*$fI+`G!#3kCc4-jZlCqL9K=X{hT0g$B( zwWPblET2EQkh*UyL+vr!TfTEfOI?cGdTr-M(<GK+c{k_P=;a3gw1BT+K<mPtJpdpl z@#d>G=Wij;My}4qxJ5`^wtXB;b-*WM#y)f{EIr-)(0ejHuXhH`BQXHf(<3IJWgwJ1 z#o^~$(a*2X_-m?&>YqFhy2L46q>}(oar2|-m`1x|m*2=k(+9klkU&&20%UiGm)9^i z>l50i%nc(pu{#40A4J!9ozEeGtbA5t`nYG07-Gy*C{Wpr4GpgC!&mzd$X#0M8O;&5 zi--RJeHrKS^?+R@ZpNH^rsu=MuPad#HsZ_XIzO~)pzvo84-Y@y*zj?Gmed^Grmf~> zC2V)%@bFM=B}U3}k2oZ&E-B|_6k>^L9<v^C`7?5uqJV2wu@oKB`tXcoJ?rHW;D6@2 zvP<iqp~Ej5rQ^~qpQsnmwLYVnZyHt0rl)^n+H2V}?|z?9-xAsDlkI(hQGjl63#J3$ z$!<;_{O#-7x9hZO$hJ0pgC^zuLdjWw3JG&KQ+dT-jso~8rz%nggdGEqa9IXC;=uj` z5#q)AU=%o#o6W>Iun+)l#5!q(Q>UBNE_KVqVoL<A>rupOvUt&)QQZowUVK}Iw+oK; z_cQW%8RRjwblV{+aQ48-qYmG$dORwOH6H2NOTqA{n-$%yHhT7I=V0m8jUR6Y$8js3 z(YjE^#IvCux6zmN^>Y+S+)ID`*xARe%=YJzE&kAB+z{LKOS}ttZRoS<4MiK-He~hl zuvK?OktZzu<&~tj+s+&y74F*RAdwU$IbM-0e=cCJ=%nuz3j&`kww7@0h!pZRtBKki z^g#$X!r3Q91$DCy*xJ#TD$d4k*M&aXCJ9g9wU}#OFydL5gWHKup^jXq+M_o&lS<cJ z!AF0NA|)))r4nITkQgms0v60E39#CiNhmCxJ1I6kz~K=Dtj=H9@s@t{NNo69RrWX~ zpv4z-0%*_l8)jJro|}Kp9>~3_G&K~0X+b(!4qNwr%c{R0Fs~NLPj_A-=3M-RYEW>{ zwyu;hYCQ>tcm|JO%XGbs?>}|hxcW~a?IeQr8h>N?WJWPo7F<<0)$aB7m&RvF^5v^0 z-S&Qtvjxj>1J$R_&aM?H(Ac`g%j7z(3!oYsmy;Q~T3at4go3qks})*voO@TTwJc8a zeaKLRZ}t@M6w{S*;dBL#ja4O)c=^znz0a`Ikf89hY9J~nmw_MzKmHPtPI@st@A^bP zT|x<*sT<j3aT>mB1Sh6F1=U``L#l#od5Dph8-kHf)fGGPcZ}G9FU&no9F4*^?vv>5 z3MyT?G*80A!rkv~eDymQM}6bJR{BP`9$cBzjU`k!6W9G~wiq$2ZzBy$wE^=>0cpQ8 zkW~H3vU&gHoVFI@4J*!jJ^fc)3CYvfojtf9J<1h{6DKKSb)(cSSXFvqkcX1*W$2!R zko5sg9}rwOaxc{?CtwrVCWa`p(zGb#ahibP1;jhTE=aAwwE5<V=am|MUU00+lA(Ww zD>p*n`b$ybL3aqU+OEWnCDq%PV?MK@r*xrb5&wCD&#gS}8ZsR}{jqLAe~kTLqUU4< z{jowSB+8;Vk&xyudOq38sF!i=!k<EJ$u1ro+(PKbBZXL)Ii;v`bpNDFx7+vw$cxH{ zqIH%NzV$8&3n?V;3=36)m_-T)H1&7LddC>PM`ys?z2`Wk<TK1*rv2dB*IiF$T6xu* zCN0Evf#YR9Ezv<`v#)t0_@wJz8X1Vtc~4&sa|G3ONVdp$Tq021p;GmZA7MPlzY^}{ z=-KQ3KK}id#p3q9S`WijW|UONnhj=$+ch!vLN)D^VkgI@wAvWA&ETWkk`h$;DWe^u z{ZVw4UU=yNSR;h#=b*n|vtlq!9iVQn-ME5ifkvjbRJnc#RA}MDFhYtJaw&X8<PH7v z`z%VcqqtM~7Q@GE?6CpLa|0RSbRzY2{^MDnfIzo2QALF!iQMtt@J<bb9(go6OrAKV z|CEMs0P{A1qa-XfuiqU}Z)%&jc6W?Z+K}O6ZR5_;Jr0N6cIannvk9`sDE<1+sEtwx zR`e(=!DNs!`Td*9i!F@ev71_x<|;*kr+NZiNi4tXB`pX|YM3MQ(ntyoN@hq}w`fSt z^(Y3!ohJH8dw|>SB!QAdIR6xiN)Yej{>=j$%D7?T4aqHe0(F#5YK9d!ntUj(RHNNj zyBeZ-I`6iHj{?+3lSjM6u;6^F`Fcmk?U^yVA~MGIOa5ZaJLMiNm6_Wj4rQS4u@LQ0 z;_a&9AH=C+M;lycD&y{+c(+4KRvewOl2tnI_`N`c3kd)<#~lT^u%dTEMAvAew?DC% z4;O;=wZaN_9`A^Vz-RYF#+bZQzE*jvZ{BUcuc3b^)SqSkEOaTJe8bvWbi8tFcjw+V z7{Zt+CS3<$a3IrA7w&Am;aLI9_orcQBx=b-^4Z4?*D|C?w+I=Ew!y)QP8)4`sTOya z_GE^eUNg@1-U(<n1a%~V{ofTiXkDm(J{2_dQGX0ywm7Uz77H+qfK?&{r@zFlWT%ge zq_SIvS9edn4SgXoI<Gbx_%<23GC7!#9za?0FhP?`3vA>G5kfv!Ok%?Pq-|M*YLn?o z0r%tTavg@r)KRo~VauSIKJ5dWFDILjpId!YgHI9Go+ofmHk)wy2w`hIe|zJ6arE)k z%cd4;^FB6ogCmfx&a+^Zg7iOy7yv@7;O_zWXkUu)s{xW!n2~J7gYGUJC;mJ_9`R}D z@Y&M5g;j{RNViqPDF<UyE(C*rSFYH?paPy{oA<7?RS3n2p8*Ua&U*78FKm>CX-<#4 z_Q{DM3!8@FR#Ol>gx-^CFFmN4;o6pI)>`@Gz_h}ch*4E)=&3A3P2b0Mi7umCHb|Oi zP>NMoa`yKeA9>7E^&4;NE^9bbI=Ue31~W=1E%<$w6^h@l#FB>X1DNydD>2uZsk1a> z$^8-JdfBc#9v9>Q`DnoshG!M2J_5jWjGzVjTf{{j_S&$&sTbs}C<*)QK7;hl{4>_* z<gC5EJR^O?EZyIsiu?6Wvm7U9_O9Sq{!kG57kOp`y1w#}c{Rdy)4U|q#SETWp)Vd{ z8qU>Xt)!y4R^Har9ui@dayIl>PQ2l-2)t3v{0zRwx>1v7SYRIWQf`M<SS#NZA1vtn z*rv)u_(6^{JXb*FLE!6v)ibXz9m7)KXA8J^>tJFvRT=8hN;<RZ+~_Yyciz}hMhjK0 zAIKVaz^Fh4mE0RQfC88P6zT%FAb>x)1Q+;e*julCicO5!^lN$pG(}DC4yWwsVc(#K zcS)DpVw+}k2MVoE?@SFzDmm^)`bf&pu$u?%04}_W-7B-mSk0lDhuqat3Uleqw^j%J zilpFi7C|Qdt-7|ZB9zw6-g{9hhO9r8PsHR789Dt!qzvD*xq(uP>?yCsf6flR+Q(Zx z;uMjjqN+WUYA5HEU@dxiI2qDzp473lCw96#@;x(===8WttZGrO&Cx8^w&Qr9WsoJ& z0-FyhLQ|QLa&<qtrQDJGS%_>Aisw_?pF6zF<Y;Wxln^^QO6-FVFFpMPzIMG|RIfoB z#K>ZIqme0Y7H7+M(r)IsvG3y(-{8d^HB?sjbNf-`JgG!iFu4(&jk=z{(-4+#f6S^( zTSRl_mez2*URx=!jmo)Cg%+A*b*ygG`grj8;F2`3G-^Dqun1_E`pD+JY!FnU5R!!B zDekxF6bD!2atmkpB|@<Yv?>v^qI^45And3dzGe702T{JCGgGQ&g8%bY@f&`=VtF6j zm<NvWy5c9yAmi^|ZZVnQ5Bx1JIBp|s;srPlzamXoi=|5EZ*z-Py#7+sK@)N6OFB(H zFp5qnWr`nkBKU!Puc-=FBVtCOej8L;21HRO{g@$(nFIZbGrW%pw4lKzq%EjsETTD0 zo7Z<t+LyyM&aT32)pA%u-nt!~vp!iZ0#?^-#DS~lV>PPdN71Qhwo)%gEDqn60k!OD zDF8BWle3LJhr0=mjio3)f$MKU`{)K#ehf-qGTm+dCN?I>x{??{9c*#m5A!B%X*vj< zq64eJm^!*&G~#+)cJy;IU!gBU)3`;1CLLqGTsug*0`<U0_1z~kV;Qn!nRJp(xU(qK z<&2rUBU@|f+y&E3Ru?YM_VWQtmcBkTf%aHSa;8{Qd7W%Z3xBC7n^Sc;P9(!e$xZA# z(RSh^?Z~LHQQ>=Oc?+;ufsTNVTo4^b$d;yX_bc?S+jfEgyt0C=nQDAkv}+5zuC<s0 zeFWR<a>XO%Ne2UA>qKg<1yKr=@HQBinySq6;<Bf$I+aAw?siiufiUZE4_Co?8hya2 z;_qJWZSo>Y_#ZAw)qbeSD?lCsSVavdSa(OFt}w+os~0mdEn?@Y=L1R3SshK>cIToT zje*(b{*6o~ft3m@&S#Wpy5M{fbOfP1=nbk9++7bml5L@JFg<~D{|-K4M{2wkL#=zY zMxXNwh&a!4hwOfYdS|&#Nx>ge3?{k=2lA=<H=s(Y<q$ZqO1Bv0%}nanLPK4xr@06) zog@R1_xaX-`3ybVQ`In@R5#3`*Q0XhgbR@O)h!wA#j}_Prrbgeu2rS5H0-v4A$T4a zX_%2T*`}vhjB@H^6sHP3@lFLweYn}GYI3b(Z!Q0cU8hXMZb*;O+^Dl`b#h4CcAb%s zywyklm|yBvK18W{MnPJxUX_>CgW^a}CGotLc+z$qP8K`?9+V-Uy!HF-`{yMpZzm3; zqc423Duth}oIDqcVK(Xb$lJR7@4qK5|6~5Fa=RRbD8YlOB83?q6BjdvRPF!R-T(iM J{_o$w{{riJALal6 literal 0 HcmV?d00001 diff --git a/src/EchoHub.Client/Config/ClientConfig.cs b/src/EchoHub.Client/Config/ClientConfig.cs index 8fcfe07..7536419 100644 --- a/src/EchoHub.Client/Config/ClientConfig.cs +++ b/src/EchoHub.Client/Config/ClientConfig.cs @@ -5,6 +5,13 @@ public class ClientConfig public List<SavedServer> SavedServers { get; set; } = []; public AccountPreset DefaultPreset { get; set; } = new(); public string ActiveTheme { get; set; } = "Default"; + public NotificationConfig Notifications { get; set; } = new(); +} + +public class NotificationConfig +{ + public bool Enabled { get; set; } = true; + public string? SoundFile { get; set; } } public class SavedServer diff --git a/src/EchoHub.Client/EchoHub.Client.csproj b/src/EchoHub.Client/EchoHub.Client.csproj index 2878f87..5e838b3 100644 --- a/src/EchoHub.Client/EchoHub.Client.csproj +++ b/src/EchoHub.Client/EchoHub.Client.csproj @@ -7,6 +7,7 @@ <ItemGroup> <PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.3" /> <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.3" /> + <PackageReference Include="NetCoreAudio" Version="2.0.1" /> <PackageReference Include="Serilog" Version="4.3.1" /> <PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" /> <PackageReference Include="Serilog.Sinks.File" Version="7.0.0" /> @@ -17,6 +18,9 @@ <Content Include="appsettings.json" Condition="Exists('appsettings.json')"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </Content> + <Content Include="Assets\**"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> <EmbeddedResource Include="appsettings.example.json"> <LogicalName>EchoHub.Client.appsettings.example.json</LogicalName> </EmbeddedResource> diff --git a/src/EchoHub.Client/Services/NotificationSoundService.cs b/src/EchoHub.Client/Services/NotificationSoundService.cs new file mode 100644 index 0000000..cd6f416 --- /dev/null +++ b/src/EchoHub.Client/Services/NotificationSoundService.cs @@ -0,0 +1,64 @@ +using EchoHub.Client.Config; +using NetCoreAudio; +using Serilog; + +namespace EchoHub.Client.Services; + +public class NotificationSoundService +{ + private readonly Player _player = new(); + private readonly NotificationConfig _config; + private string? _resolvedSoundPath; + + public NotificationSoundService(NotificationConfig config) + { + _config = config; + ResolveSoundPath(); + } + + public async Task PlayAsync() + { + if (!_config.Enabled || _resolvedSoundPath is null) + return; + + try + { + if (_player.Playing) + await _player.Stop(); + + await _player.Play(_resolvedSoundPath); + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to play notification sound"); + } + } + + private void ResolveSoundPath() + { + // 1. Explicit path from config (~/.echohub/config.json) + if (!string.IsNullOrWhiteSpace(_config.SoundFile)) + { + if (File.Exists(_config.SoundFile)) + { + _resolvedSoundPath = Path.GetFullPath(_config.SoundFile); + Log.Debug("Notification sound: {Path} (from config)", _resolvedSoundPath); + return; + } + + Log.Warning("Configured sound file not found: {Path}", _config.SoundFile); + } + + // 2. Default: Notification.mp3 bundled next to the executable + var defaultPath = Path.Combine(AppContext.BaseDirectory, "Assets", "Notification.mp3"); + + if (File.Exists(defaultPath)) + { + _resolvedSoundPath = defaultPath; + Log.Debug("Notification sound: {Path} (default)", _resolvedSoundPath); + return; + } + + Log.Information("No notification sound file found — notifications will be silent"); + } +} diff --git a/src/EchoHub.Client/UI/ChatRenderer.cs b/src/EchoHub.Client/UI/ChatRenderer.cs index b8b91a4..d4727fc 100644 --- a/src/EchoHub.Client/UI/ChatRenderer.cs +++ b/src/EchoHub.Client/UI/ChatRenderer.cs @@ -101,6 +101,12 @@ public partial class ChatLine public static bool HasColorTags(string text) => text.Contains("{F:") || text.Contains("{B:") || text.Contains("{X}"); + /// <summary> + /// Remove all color tags from text, returning only the visible characters. + /// </summary> + public static string StripColorTags(string text) => + ColorTagRegex().Replace(text, ""); + /// <summary> /// Parse a string containing printable color tags into colored segments. /// Format: {F:RRGGBB} (foreground), {B:RRGGBB} (background), {X} (reset). @@ -458,6 +464,7 @@ public static partial class ChatColors public static readonly Attribute EmbedBorderAttr = new(new Color(91, 155, 213), Color.Black); public static readonly Attribute EmbedTitleAttr = new(Color.White, Color.Black); public static readonly Attribute EmbedDescAttr = new(new Color(160, 160, 160), Color.Black); + public static readonly Attribute EmbedUrlAttr = new(new Color(100, 100, 100), Color.Black); /// <summary> /// Split text around @mentions, giving each @word the MentionTextAttr accent color. diff --git a/src/EchoHub.Client/UI/EmojiHelper.cs b/src/EchoHub.Client/UI/EmojiHelper.cs new file mode 100644 index 0000000..c1d794a --- /dev/null +++ b/src/EchoHub.Client/UI/EmojiHelper.cs @@ -0,0 +1,333 @@ +using System.Globalization; +using System.Text; + +namespace EchoHub.Client.UI; + +/// <summary> +/// Converts emoji grapheme clusters to text shortcodes for safe TUI rendering. +/// Terminal width calculations for emoji are unreliable across different terminals, +/// so we replace them with fixed-width ASCII shortcodes for display only. +/// </summary> +public static class EmojiHelper +{ + /// <summary> + /// Replace emoji graphemes in the text with :shortcode: equivalents. + /// Non-emoji text passes through unchanged. + /// </summary> + public static string ReplaceEmoji(string text) + { + if (string.IsNullOrEmpty(text)) + return text; + + // Quick check: if no characters above BMP or supplementary emoji ranges, skip processing + bool hasEmoji = false; + foreach (var rune in text.EnumerateRunes()) + { + if (IsEmojiRune(rune)) + { + hasEmoji = true; + break; + } + } + + if (!hasEmoji) + return text; + + var sb = new StringBuilder(text.Length); + var enumerator = StringInfo.GetTextElementEnumerator(text); + + while (enumerator.MoveNext()) + { + var grapheme = enumerator.GetTextElement(); + + // Check if this grapheme contains emoji runes + bool graphemeHasEmoji = false; + foreach (var rune in grapheme.EnumerateRunes()) + { + if (IsEmojiRune(rune)) + { + graphemeHasEmoji = true; + break; + } + } + + if (graphemeHasEmoji) + { + // Try to find a shortcode for the whole grapheme first + if (EmojiShortcodes.TryGetValue(grapheme, out var shortcode)) + { + sb.Append(shortcode); + } + else + { + // Try the base emoji (first rune only, stripping modifiers/ZWJ) + var baseRune = GetBaseEmoji(grapheme); + if (baseRune is not null && EmojiShortcodes.TryGetValue(baseRune, out shortcode)) + { + sb.Append(shortcode); + } + else + { + // Unknown emoji — use generic placeholder + sb.Append("[emoji]"); + } + } + } + else + { + sb.Append(grapheme); + } + } + + return sb.ToString(); + } + + private static bool IsEmojiRune(Rune rune) + { + var value = rune.Value; + + // Common emoji ranges + if (value >= 0x1F600 && value <= 0x1F64F) return true; // Emoticons + if (value >= 0x1F300 && value <= 0x1F5FF) return true; // Misc Symbols & Pictographs + if (value >= 0x1F680 && value <= 0x1F6FF) return true; // Transport & Map + if (value >= 0x1F900 && value <= 0x1F9FF) return true; // Supplemental Symbols + if (value >= 0x1FA00 && value <= 0x1FA6F) return true; // Chess Symbols + if (value >= 0x1FA70 && value <= 0x1FAFF) return true; // Symbols Extended-A + if (value >= 0x2600 && value <= 0x26FF) return true; // Misc Symbols + if (value >= 0x2700 && value <= 0x27BF) return true; // Dingbats + if (value >= 0xFE00 && value <= 0xFE0F) return true; // Variation Selectors + if (value >= 0x200D && value <= 0x200D) return true; // ZWJ + if (value >= 0x1F1E0 && value <= 0x1F1FF) return true; // Regional Indicators (flags) + if (value >= 0x231A && value <= 0x23F3) return true; // Misc Technical (watch, hourglass) + if (value >= 0x2934 && value <= 0x2935) return true; // Arrows + if (value >= 0x25AA && value <= 0x25FE) return true; // Geometric Shapes + if (value >= 0x2B05 && value <= 0x2B55) return true; // Misc Symbols & Arrows + if (value >= 0x3030 && value <= 0x303D) return true; // CJK Symbols + if (value == 0x00A9 || value == 0x00AE) return true; // © ® + if (value == 0x2122) return true; // ™ + if (value >= 0x1F000 && value <= 0x1F02F) return true; // Mahjong & Dominos + + return false; + } + + /// <summary> + /// Extract the base emoji string (first non-modifier, non-ZWJ rune) for lookup. + /// </summary> + private static string? GetBaseEmoji(string grapheme) + { + foreach (var rune in grapheme.EnumerateRunes()) + { + // Skip ZWJ, variation selectors, skin tone modifiers + if (rune.Value == 0x200D) continue; + if (rune.Value >= 0xFE00 && rune.Value <= 0xFE0F) continue; + if (rune.Value >= 0x1F3FB && rune.Value <= 0x1F3FF) continue; + + if (IsEmojiRune(rune)) + return rune.ToString(); + } + + return null; + } + + // Common emoji → shortcode mapping (display-only, covers most frequently used emoji) + private static readonly Dictionary<string, string> EmojiShortcodes = new() + { + // Smileys & Emotion + ["\U0001F600"] = ":grinning:", + ["\U0001F601"] = ":grin:", + ["\U0001F602"] = ":joy:", + ["\U0001F603"] = ":smiley:", + ["\U0001F604"] = ":smile:", + ["\U0001F605"] = ":sweat_smile:", + ["\U0001F606"] = ":laughing:", + ["\U0001F607"] = ":innocent:", + ["\U0001F608"] = ":smiling_imp:", + ["\U0001F609"] = ":wink:", + ["\U0001F60A"] = ":blush:", + ["\U0001F60B"] = ":yum:", + ["\U0001F60C"] = ":relieved:", + ["\U0001F60D"] = ":heart_eyes:", + ["\U0001F60E"] = ":sunglasses:", + ["\U0001F60F"] = ":smirk:", + ["\U0001F610"] = ":neutral_face:", + ["\U0001F611"] = ":expressionless:", + ["\U0001F612"] = ":unamused:", + ["\U0001F613"] = ":sweat:", + ["\U0001F614"] = ":pensive:", + ["\U0001F615"] = ":confused:", + ["\U0001F616"] = ":confounded:", + ["\U0001F617"] = ":kissing:", + ["\U0001F618"] = ":kissing_heart:", + ["\U0001F619"] = ":kissing_smiling_eyes:", + ["\U0001F61A"] = ":kissing_closed_eyes:", + ["\U0001F61B"] = ":stuck_out_tongue:", + ["\U0001F61C"] = ":stuck_out_tongue_winking_eye:", + ["\U0001F61D"] = ":stuck_out_tongue_closed_eyes:", + ["\U0001F61E"] = ":disappointed:", + ["\U0001F61F"] = ":worried:", + ["\U0001F620"] = ":angry:", + ["\U0001F621"] = ":rage:", + ["\U0001F622"] = ":cry:", + ["\U0001F623"] = ":persevere:", + ["\U0001F624"] = ":triumph:", + ["\U0001F625"] = ":disappointed_relieved:", + ["\U0001F626"] = ":frowning:", + ["\U0001F627"] = ":anguished:", + ["\U0001F628"] = ":fearful:", + ["\U0001F629"] = ":weary:", + ["\U0001F62A"] = ":sleepy:", + ["\U0001F62B"] = ":tired_face:", + ["\U0001F62C"] = ":grimacing:", + ["\U0001F62D"] = ":sob:", + ["\U0001F62E"] = ":open_mouth:", + ["\U0001F62F"] = ":hushed:", + ["\U0001F630"] = ":cold_sweat:", + ["\U0001F631"] = ":scream:", + ["\U0001F632"] = ":astonished:", + ["\U0001F633"] = ":flushed:", + ["\U0001F634"] = ":sleeping:", + ["\U0001F635"] = ":dizzy_face:", + ["\U0001F636"] = ":no_mouth:", + ["\U0001F637"] = ":mask:", + ["\U0001F641"] = ":slightly_frowning_face:", + ["\U0001F642"] = ":slightly_smiling_face:", + ["\U0001F643"] = ":upside_down_face:", + ["\U0001F644"] = ":roll_eyes:", + ["\U0001F910"] = ":zipper_mouth:", + ["\U0001F911"] = ":money_mouth:", + ["\U0001F912"] = ":thermometer_face:", + ["\U0001F913"] = ":nerd:", + ["\U0001F914"] = ":thinking:", + ["\U0001F915"] = ":head_bandage:", + ["\U0001F920"] = ":cowboy:", + ["\U0001F921"] = ":clown:", + ["\U0001F922"] = ":nauseated:", + ["\U0001F923"] = ":rofl:", + ["\U0001F924"] = ":drooling:", + ["\U0001F925"] = ":lying:", + ["\U0001F929"] = ":star_struck:", + ["\U0001F92A"] = ":zany:", + ["\U0001F92B"] = ":shushing:", + ["\U0001F92C"] = ":cursing:", + ["\U0001F92D"] = ":hand_over_mouth:", + ["\U0001F92E"] = ":vomiting:", + ["\U0001F92F"] = ":exploding_head:", + ["\U0001F970"] = ":smiling_face_with_hearts:", + ["\U0001F971"] = ":yawning:", + ["\U0001F972"] = ":smiling_with_tear:", + ["\U0001F973"] = ":partying:", + ["\U0001F974"] = ":woozy:", + ["\U0001F975"] = ":hot_face:", + ["\U0001F976"] = ":cold_face:", + ["\U0001F979"] = ":holding_back_tears:", + ["\U0001F97A"] = ":pleading:", + ["\U0001FAE0"] = ":melting:", + ["\U0001FAE1"] = ":saluting:", + ["\U0001FAE2"] = ":face_with_open_eyes_hand_over_mouth:", + ["\U0001FAE3"] = ":face_with_peeking_eye:", + ["\U0001FAE4"] = ":face_with_diagonal_mouth:", + + // Gestures + ["\U0001F44D"] = ":+1:", + ["\U0001F44E"] = ":-1:", + ["\U0001F44B"] = ":wave:", + ["\U0001F44C"] = ":ok_hand:", + ["\U0001F44F"] = ":clap:", + ["\U0001F44A"] = ":fist:", + ["\U0001F91D"] = ":handshake:", + ["\U0001F91E"] = ":crossed_fingers:", + ["\U0001F91F"] = ":love_you:", + ["\U0001F918"] = ":metal:", + ["\U0001F919"] = ":call_me:", + ["\U0001F590"] = ":raised_hand:", + ["\U0001F4AA"] = ":muscle:", + ["\U0001F926"] = ":facepalm:", + ["\U0001F937"] = ":shrug:", + ["\U0001F64F"] = ":pray:", + ["\U0001F64C"] = ":raised_hands:", + ["\U0001F64B"] = ":raising_hand:", + + // Hearts & Symbols + ["\u2764"] = "<3", + ["\U0001F494"] = "</3", + ["\U0001F495"] = ":two_hearts:", + ["\U0001F496"] = ":sparkling_heart:", + ["\U0001F497"] = ":heartpulse:", + ["\U0001F498"] = ":cupid:", + ["\U0001F499"] = ":blue_heart:", + ["\U0001F49A"] = ":green_heart:", + ["\U0001F49B"] = ":yellow_heart:", + ["\U0001F49C"] = ":purple_heart:", + ["\U0001F49D"] = ":gift_heart:", + ["\U0001F49E"] = ":revolving_hearts:", + ["\U0001F49F"] = ":heart_decoration:", + ["\U0001F90D"] = ":white_heart:", + ["\U0001F90E"] = ":brown_heart:", + ["\U0001F5A4"] = ":black_heart:", + ["\U0001F9E1"] = ":orange_heart:", + + // Objects & Nature + ["\U0001F525"] = ":fire:", + ["\U0001F4A9"] = ":poop:", + ["\U0001F480"] = ":skull:", + ["\U0001F47B"] = ":ghost:", + ["\U0001F47D"] = ":alien:", + ["\U0001F916"] = ":robot:", + ["\U0001F4AF"] = ":100:", + ["\U0001F4A5"] = ":boom:", + ["\U0001F4A4"] = ":zzz:", + ["\U0001F4A2"] = ":anger:", + ["\U0001F4AC"] = ":speech_balloon:", + ["\U0001F440"] = ":eyes:", + ["\U0001F3B5"] = ":musical_note:", + ["\U0001F3B6"] = ":notes:", + ["\U0001F389"] = ":tada:", + ["\U0001F38A"] = ":confetti:", + ["\U0001F381"] = ":gift:", + ["\U0001F3C6"] = ":trophy:", + ["\U0001F4B0"] = ":money_bag:", + ["\U0001F4BB"] = ":computer:", + ["\U0001F4F1"] = ":phone:", + ["\U0001F4E7"] = ":email:", + ["\U0001F511"] = ":key:", + ["\U0001F512"] = ":lock:", + ["\U0001F513"] = ":unlock:", + ["\U0001F6A8"] = ":rotating_light:", + ["\U0001F6AB"] = ":no_entry:", + + // Animals + ["\U0001F436"] = ":dog:", + ["\U0001F431"] = ":cat:", + ["\U0001F42D"] = ":mouse:", + ["\U0001F430"] = ":rabbit:", + ["\U0001F43B"] = ":bear:", + ["\U0001F427"] = ":penguin:", + ["\U0001F41D"] = ":bee:", + ["\U0001F40D"] = ":snake:", + ["\U0001F422"] = ":turtle:", + + // Food & Drink + ["\U0001F355"] = ":pizza:", + ["\U0001F354"] = ":hamburger:", + ["\U0001F37A"] = ":beer:", + ["\U0001F377"] = ":wine:", + ["\U0001F370"] = ":cake:", + ["\u2615"] = ":coffee:", + ["\U0001F382"] = ":birthday:", + + // Misc symbols (BMP) + ["\u2705"] = ":white_check_mark:", + ["\u274C"] = ":x:", + ["\u274E"] = ":negative_squared_cross_mark:", + ["\u2714"] = ":heavy_check_mark:", + ["\u2716"] = ":heavy_multiplication_x:", + ["\u26A0"] = ":warning:", + ["\u2B50"] = ":star:", + ["\u2728"] = ":sparkles:", + ["\u267B"] = ":recycle:", + ["\u2611"] = ":ballot_box_with_check:", + ["\u23F0"] = ":alarm_clock:", + ["\u231A"] = ":watch:", + ["\u231B"] = ":hourglass:", + }; +} diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index ffc2a87..1032d5c 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -4,7 +4,9 @@ using EchoHub.Core.DTOs; using EchoHub.Core.Models; using Terminal.Gui.App; using Terminal.Gui.Configuration; +using Terminal.Gui.Drawing; using Terminal.Gui.Input; +using Terminal.Gui.Text; using Terminal.Gui.ViewBase; using Terminal.Gui.Views; using Attribute = Terminal.Gui.Drawing.Attribute; @@ -880,7 +882,8 @@ public sealed class MainWindow : Runnable case MessageType.Text: default: - var contentLines = message.Content.Split('\n'); + var displayContent = EmojiHelper.ReplaceEmoji(message.Content); + var contentLines = displayContent.Split('\n'); var firstLine = contentLines[0].TrimEnd('\r'); lines.Add(BuildChatLineWithMentions(time, senderName, senderColor, $" {firstLine}")); // Continuation lines indented to align with first line's content @@ -891,9 +894,12 @@ public sealed class MainWindow : Runnable lines.Add(new ChatLine(ChatColors.SplitMentions(contText))); } - // Render link embed if present - if (message.Embed is not null) - lines.AddRange(FormatEmbed(message.Embed, indent)); + // Render link embeds if present + if (message.Embeds is { Count: > 0 }) + { + foreach (var embed in message.Embeds) + lines.AddRange(FormatEmbed(embed, indent)); + } break; } @@ -944,65 +950,152 @@ public sealed class MainWindow : Runnable } /// <summary> - /// Format a link embed as indented chat lines with a left border bar. + /// Format a link embed as indented chat lines with a left border bar, + /// optional description wrapping, and a small icon on the right (Discord-style). + /// Layout: ▏ {text column} {icon column} /// </summary> private static List<ChatLine> FormatEmbed(EmbedDto embed, string indent) { var lines = new List<ChatLine>(); const string border = "\u258f "; // ▏ + space + const int borderCols = 2; // ▏ = 1 col + space = 1 col + const int iconGap = 1; // space between text and icon - // Site name - if (!string.IsNullOrWhiteSpace(embed.SiteName)) - { - lines.Add(new ChatLine(new List<ChatSegment> - { - new(indent, null), - new(border, ChatColors.EmbedBorderAttr), - new(embed.SiteName, ChatColors.EmbedBorderAttr) - })); - } - - // Title - if (!string.IsNullOrWhiteSpace(embed.Title)) - { - lines.Add(new ChatLine(new List<ChatSegment> - { - new(indent, null), - new(border, ChatColors.EmbedBorderAttr), - new(embed.Title, ChatColors.EmbedTitleAttr) - })); - } - - // Description (truncated) - if (!string.IsNullOrWhiteSpace(embed.Description)) - { - var desc = embed.Description.Length > 120 - ? embed.Description[..117] + "..." - : embed.Description; - - lines.Add(new ChatLine(new List<ChatSegment> - { - new(indent, null), - new(border, ChatColors.EmbedBorderAttr), - new(desc, ChatColors.EmbedDescAttr) - })); - } - - // ASCII image thumbnail + // Parse icon lines if present + var iconLines = new List<string>(); + int iconWidth = 0; if (!string.IsNullOrWhiteSpace(embed.ImageAscii)) { foreach (var artLine in embed.ImageAscii.Split('\n')) { var trimmed = artLine.TrimEnd('\r'); - if (string.IsNullOrEmpty(trimmed)) continue; - - if (ChatLine.HasColorTags(trimmed)) - lines.Add(ChatLine.FromColoredText(indent + border + trimmed)); - else - lines.Add(new ChatLine(indent + border + trimmed)); + if (!string.IsNullOrEmpty(trimmed)) + iconLines.Add(trimmed); } + if (iconLines.Count > 0) + { + // Measure icon width from the first line (strip color tags for measurement) + var stripped = ChatLine.StripColorTags(iconLines[0]); + iconWidth = stripped.GetColumns(); + } + } + + bool hasIcon = iconLines.Count > 0 && iconWidth > 0; + int indentCols = indent.GetColumns(); + + // We don't know the terminal width at format time, so use a reasonable default + // for text wrapping. The ChatListSource.Render will handle final clipping. + const int estimatedWidth = 80; + int availableForText = estimatedWidth - indentCols - borderCols; + int textColWidth = hasIcon + ? availableForText - iconWidth - iconGap + : availableForText; + if (textColWidth < 20) textColWidth = 20; + + // Collect all text rows (site name, title, wrapped description, URL) + var textRows = new List<(string Text, Attribute? Color)>(); + + if (!string.IsNullOrWhiteSpace(embed.SiteName)) + textRows.Add((embed.SiteName, ChatColors.EmbedBorderAttr)); + + if (!string.IsNullOrWhiteSpace(embed.Title)) + textRows.Add((embed.Title, ChatColors.EmbedTitleAttr)); + + // Word-wrap description + if (!string.IsNullOrWhiteSpace(embed.Description)) + { + foreach (var wrappedLine in WordWrap(embed.Description, textColWidth)) + textRows.Add((wrappedLine, ChatColors.EmbedDescAttr)); + } + + // Dim URL at the bottom + textRows.Add((embed.Url, ChatColors.EmbedUrlAttr)); + + // Merge text rows with icon rows side-by-side + int totalLines = Math.Max(textRows.Count, iconLines.Count); + for (int i = 0; i < totalLines; i++) + { + var segments = new List<ChatSegment>(); + segments.Add(new ChatSegment(indent, null)); + segments.Add(new ChatSegment(border, ChatColors.EmbedBorderAttr)); + + if (i < textRows.Count) + { + var (text, color) = textRows[i]; + segments.Add(new ChatSegment(text, color)); + + // Pad to align icon column + if (hasIcon && i < iconLines.Count) + { + int textCols = text.GetColumns(); + int padding = textColWidth - textCols + iconGap; + if (padding > 0) + segments.Add(new ChatSegment(new string(' ', padding), null)); + } + } + else if (hasIcon && i < iconLines.Count) + { + // No text row, pad the full text column + gap + segments.Add(new ChatSegment(new string(' ', textColWidth + iconGap), null)); + } + + // Append icon line + if (hasIcon && i < iconLines.Count) + { + var iconLine = iconLines[i]; + if (ChatLine.HasColorTags(iconLine)) + { + // Build a composite: plain segments + colored icon + var plainPart = new ChatLine(segments); + var iconPart = ChatLine.FromColoredText(iconLine); + var merged = new List<ChatSegment>(plainPart.Segments); + merged.AddRange(iconPart.Segments); + lines.Add(new ChatLine(merged)); + continue; + } + else + { + segments.Add(new ChatSegment(iconLine, null)); + } + } + + lines.Add(new ChatLine(segments)); } return lines; } + + /// <summary> + /// Simple word-wrap: splits text into lines that fit within maxCols display columns. + /// </summary> + private static List<string> WordWrap(string text, int maxCols) + { + if (maxCols <= 0) + return [text]; + + var result = new List<string>(); + var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var currentLine = ""; + + foreach (var word in words) + { + var candidate = currentLine.Length == 0 ? word : currentLine + " " + word; + if (candidate.GetColumns() <= maxCols) + { + currentLine = candidate; + } + else + { + if (currentLine.Length > 0) + result.Add(currentLine); + // If a single word exceeds maxCols, just add it as-is + currentLine = word; + } + } + + if (currentLine.Length > 0) + result.Add(currentLine); + + return result; + } } diff --git a/src/EchoHub.Core/Constants/HubConstants.cs b/src/EchoHub.Core/Constants/HubConstants.cs index 5b2120d..7db4fff 100644 --- a/src/EchoHub.Core/Constants/HubConstants.cs +++ b/src/EchoHub.Core/Constants/HubConstants.cs @@ -15,9 +15,10 @@ public static class HubConstants public const int AsciiArtHeightHalfBlock = 80; // Link embed constants - public const int EmbedThumbnailWidth = 24; - public const int EmbedThumbnailHeight = 12; - public const int EmbedMaxDescriptionLength = 200; + public const int EmbedIconWidth = 12; + public const int EmbedIconHeight = 6; + public const int EmbedMaxDescriptionLength = 500; public const int EmbedMaxHtmlBytes = 64 * 1024; // 64 KB public const int EmbedFetchTimeoutSeconds = 3; + public const int EmbedMaxUrlsPerMessage = 3; } diff --git a/src/EchoHub.Core/DTOs/ChatDtos.cs b/src/EchoHub.Core/DTOs/ChatDtos.cs index 5c35564..be772b2 100644 --- a/src/EchoHub.Core/DTOs/ChatDtos.cs +++ b/src/EchoHub.Core/DTOs/ChatDtos.cs @@ -12,7 +12,7 @@ public record MessageDto( string? AttachmentUrl, string? AttachmentFileName, DateTimeOffset SentAt, - EmbedDto? Embed = null); + List<EmbedDto>? Embeds = null); public record ChannelDto( Guid Id, diff --git a/src/EchoHub.Server.Irc/IrcMessageFormatter.cs b/src/EchoHub.Server.Irc/IrcMessageFormatter.cs index 774b081..fb017c5 100644 --- a/src/EchoHub.Server.Irc/IrcMessageFormatter.cs +++ b/src/EchoHub.Server.Irc/IrcMessageFormatter.cs @@ -24,9 +24,12 @@ public static partial class IrcMessageFormatter foreach (var chunk in SplitMessage(message.Content, MaxIrcLineContentBytes)) lines.Add($"{prefix} PRIVMSG {ircChannel} :{chunk}"); - // Append embed preview if present - if (message.Embed is not null) - lines.AddRange(FormatEmbed(prefix, ircChannel, message.Embed)); + // Append embed previews if present + if (message.Embeds is { Count: > 0 }) + { + foreach (var embed in message.Embeds) + lines.AddRange(FormatEmbed(prefix, ircChannel, embed)); + } break; case MessageType.Image: diff --git a/src/EchoHub.Server/Services/ChatService.cs b/src/EchoHub.Server/Services/ChatService.cs index 8dd3844..d590a65 100644 --- a/src/EchoHub.Server/Services/ChatService.cs +++ b/src/EchoHub.Server/Services/ChatService.cs @@ -175,15 +175,15 @@ public class ChatService : IChatService } } - // Attempt to fetch link embed for URLs in the message - EmbedDto? embed = null; + // Attempt to fetch link embeds for URLs in the message + List<EmbedDto>? embeds = null; try { - embed = await _embedService.TryGetEmbedAsync(content); + embeds = await _embedService.TryGetEmbedsAsync(content); } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to fetch link embed for message in '{Channel}'", channelName); + _logger.LogWarning(ex, "Failed to fetch link embeds for message in '{Channel}'", channelName); } var message = new Message @@ -195,7 +195,7 @@ public class ChatService : IChatService ChannelId = channel.Id, SenderUserId = userId, SenderUsername = username, - EmbedJson = embed is not null ? JsonSerializer.Serialize(embed) : null, + EmbedJson = embeds is not null ? JsonSerializer.Serialize(embeds) : null, }; db.Messages.Add(message); @@ -211,7 +211,7 @@ public class ChatService : IChatService null, null, message.SentAt, - embed); + embeds); await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto)); @@ -418,10 +418,10 @@ public class ChatService : IChatService return raw.Select(x => { - EmbedDto? embed = null; + List<EmbedDto>? embeds = null; if (x.m.EmbedJson is not null) { - try { embed = JsonSerializer.Deserialize<EmbedDto>(x.m.EmbedJson); } + try { embeds = JsonSerializer.Deserialize<List<EmbedDto>>(x.m.EmbedJson); } catch { /* ignore malformed JSON */ } } @@ -435,7 +435,7 @@ public class ChatService : IChatService x.m.AttachmentUrl, x.m.AttachmentFileName, x.m.SentAt, - embed); + embeds); }).ToList(); } } diff --git a/src/EchoHub.Server/Services/LinkEmbedService.cs b/src/EchoHub.Server/Services/LinkEmbedService.cs index a854b8c..8d032cc 100644 --- a/src/EchoHub.Server/Services/LinkEmbedService.cs +++ b/src/EchoHub.Server/Services/LinkEmbedService.cs @@ -24,108 +24,119 @@ public partial class LinkEmbedService } /// <summary> - /// Detect the first URL in message content and attempt to fetch OG embed data. - /// Returns null if no URL found, fetch fails, or no useful OG data. + /// Detect all URLs in message content and attempt to fetch OG embed data for each. + /// Returns null if no URLs found or all fetches fail. /// Never throws — all errors are caught internally. /// </summary> - public async Task<EmbedDto?> TryGetEmbedAsync(string content) + public async Task<List<EmbedDto>?> TryGetEmbedsAsync(string content) { - try - { - var url = ExtractFirstUrl(content); - if (url is null) - return null; - - if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) - return null; - - if (uri.Scheme is not ("http" or "https")) - return null; - - if (IsPrivateHost(uri)) - return null; - - using var cts = new CancellationTokenSource( - TimeSpan.FromSeconds(HubConstants.EmbedFetchTimeoutSeconds)); - - var client = _httpClientFactory.CreateClient("OgFetch"); - - using var request = new HttpRequestMessage(HttpMethod.Get, uri); - using var response = await client.SendAsync(request, - HttpCompletionOption.ResponseHeadersRead, cts.Token); - - if (!response.IsSuccessStatusCode) - return null; - - var contentType = response.Content.Headers.ContentType?.MediaType; - if (contentType is null || !contentType.StartsWith("text/html", StringComparison.OrdinalIgnoreCase)) - return null; - - var html = await ReadLimitedAsync(response, HubConstants.EmbedMaxHtmlBytes, cts.Token); - if (string.IsNullOrWhiteSpace(html)) - return null; - - var ogTags = ParseOgTags(html); - - // Try og:title, fallback to <title> tag - var title = ogTags.GetValueOrDefault("title"); - if (string.IsNullOrWhiteSpace(title)) - { - var titleMatch = TitleTagRegex().Match(html); - if (titleMatch.Success) - title = WebUtility.HtmlDecode(titleMatch.Groups[1].Value.Trim()); - } - - // If no title at all, nothing useful to show - if (string.IsNullOrWhiteSpace(title)) - return null; - - var siteName = ogTags.GetValueOrDefault("site_name"); - var description = ogTags.GetValueOrDefault("description"); - - // Truncate description - if (description is not null && description.Length > HubConstants.EmbedMaxDescriptionLength) - description = description[..(HubConstants.EmbedMaxDescriptionLength - 3)] + "..."; - - // HTML decode text fields - title = WebUtility.HtmlDecode(title); - siteName = siteName is not null ? WebUtility.HtmlDecode(siteName) : null; - description = description is not null ? WebUtility.HtmlDecode(description) : null; - - // Attempt to fetch OG image thumbnail - string? imageAscii = null; - var imageUrl = ogTags.GetValueOrDefault("image"); - if (!string.IsNullOrWhiteSpace(imageUrl)) - { - imageAscii = await FetchImageThumbnailAsync(imageUrl, uri, cts.Token); - } - - return new EmbedDto(siteName, title, description, imageAscii, url); - } - catch (OperationCanceledException) - { - _logger.LogDebug("Embed fetch timed out for message content"); + var urls = ExtractUrls(content); + if (urls.Count == 0) return null; - } - catch (Exception ex) + + var embeds = new List<EmbedDto>(); + + foreach (var url in urls) { - _logger.LogDebug(ex, "Failed to fetch embed"); - return null; + try + { + var embed = await FetchEmbedForUrlAsync(url); + if (embed is not null) + embeds.Add(embed); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to fetch embed for {Url}", url); + } } + + return embeds.Count > 0 ? embeds : null; } - private static string? ExtractFirstUrl(string content) + private async Task<EmbedDto?> FetchEmbedForUrlAsync(string url) { - var match = UrlRegex().Match(content); - if (!match.Success) + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return null; - var url = match.Value; + if (uri.Scheme is not ("http" or "https")) + return null; - // Strip trailing punctuation that's likely not part of the URL - url = url.TrimEnd('.', ',', '!', '?', ')', ']', ';', ':'); + if (IsPrivateHost(uri)) + return null; - return url; + using var cts = new CancellationTokenSource( + TimeSpan.FromSeconds(HubConstants.EmbedFetchTimeoutSeconds)); + + var client = _httpClientFactory.CreateClient("OgFetch"); + + using var request = new HttpRequestMessage(HttpMethod.Get, uri); + using var response = await client.SendAsync(request, + HttpCompletionOption.ResponseHeadersRead, cts.Token); + + if (!response.IsSuccessStatusCode) + return null; + + var contentType = response.Content.Headers.ContentType?.MediaType; + if (contentType is null || !contentType.StartsWith("text/html", StringComparison.OrdinalIgnoreCase)) + return null; + + var html = await ReadLimitedAsync(response, HubConstants.EmbedMaxHtmlBytes, cts.Token); + if (string.IsNullOrWhiteSpace(html)) + return null; + + var ogTags = ParseOgTags(html); + + // Try og:title, fallback to <title> tag + var title = ogTags.GetValueOrDefault("title"); + if (string.IsNullOrWhiteSpace(title)) + { + var titleMatch = TitleTagRegex().Match(html); + if (titleMatch.Success) + title = WebUtility.HtmlDecode(titleMatch.Groups[1].Value.Trim()); + } + + // If no title at all, nothing useful to show + if (string.IsNullOrWhiteSpace(title)) + return null; + + var siteName = ogTags.GetValueOrDefault("site_name"); + var description = ogTags.GetValueOrDefault("description"); + + // Truncate very long descriptions but keep a generous limit + if (description is not null && description.Length > HubConstants.EmbedMaxDescriptionLength) + description = description[..(HubConstants.EmbedMaxDescriptionLength - 3)] + "..."; + + // HTML decode text fields + title = WebUtility.HtmlDecode(title); + siteName = siteName is not null ? WebUtility.HtmlDecode(siteName) : null; + description = description is not null ? WebUtility.HtmlDecode(description) : null; + + // Attempt to fetch OG image as small icon + string? imageAscii = null; + var imageUrl = ogTags.GetValueOrDefault("image"); + if (!string.IsNullOrWhiteSpace(imageUrl)) + { + imageAscii = await FetchImageThumbnailAsync(imageUrl, uri, cts.Token); + } + + return new EmbedDto(siteName, title, description, imageAscii, url); + } + + private static List<string> ExtractUrls(string content) + { + var urls = new List<string>(); + + foreach (Match match in UrlRegex().Matches(content)) + { + var url = match.Value.TrimEnd('.', ',', '!', '?', ')', ']', ';', ':'); + if (!urls.Contains(url)) + urls.Add(url); + + if (urls.Count >= HubConstants.EmbedMaxUrlsPerMessage) + break; + } + + return urls; } private static bool IsPrivateHost(Uri uri) @@ -220,8 +231,8 @@ public partial class LinkEmbedService memoryStream.Position = 0; return _asciiService.ConvertToAscii(memoryStream, - HubConstants.EmbedThumbnailWidth, - HubConstants.EmbedThumbnailHeight); + HubConstants.EmbedIconWidth, + HubConstants.EmbedIconHeight); } catch (Exception ex) { diff --git a/src/EchoHub.Server/Setup/DataMigrationService.cs b/src/EchoHub.Server/Setup/DataMigrationService.cs index 8a85280..34028c8 100644 --- a/src/EchoHub.Server/Setup/DataMigrationService.cs +++ b/src/EchoHub.Server/Setup/DataMigrationService.cs @@ -1,5 +1,6 @@ using System.Text.RegularExpressions; using EchoHub.Core.Constants; +using EchoHub.Core.DTOs; using EchoHub.Server.Data; using Microsoft.EntityFrameworkCore; @@ -16,6 +17,7 @@ public static partial class DataMigrationService await EnsureDefaultChannelsPublicAsync(db, logger); await MigrateAnsiMessagesAsync(db, logger); + await MigrateEmbedJsonToArrayAsync(db, logger); } /// <summary> @@ -91,4 +93,47 @@ public static partial class DataMigrationService [GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2|48;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")] private static partial Regex AnsiColorRegex(); + + /// <summary> + /// Migrate old single-object EmbedJson ("{...}") to array format ("[{...}]"). + /// </summary> + private static async Task MigrateEmbedJsonToArrayAsync(EchoHubDbContext db, ILogger logger) + { + var messages = await db.Messages + .Where(m => m.EmbedJson != null) + .ToListAsync(); + + var toMigrate = messages + .Where(m => m.EmbedJson!.TrimStart().StartsWith('{')) + .ToList(); + + if (toMigrate.Count == 0) + return; + + logger.LogInformation("Found {Count} messages with legacy single-embed JSON. Migrating to array format...", toMigrate.Count); + + var modified = 0; + foreach (var message in toMigrate) + { + try + { + var single = System.Text.Json.JsonSerializer.Deserialize<EmbedDto>(message.EmbedJson!); + if (single is not null) + { + message.EmbedJson = System.Text.Json.JsonSerializer.Serialize(new[] { single }); + modified++; + } + } + catch + { + // Skip malformed JSON + } + } + + if (modified > 0) + { + await db.SaveChangesAsync(); + logger.LogInformation("Migrated {Count} embed records from single-object to array format.", modified); + } + } } From 7a8b5f02d369412fea21b7192a073ca9597142fe Mon Sep 17 00:00:00 2001 From: HueByte <ihuebyte@gmail.com> Date: Thu, 19 Feb 2026 22:03:16 +0100 Subject: [PATCH 17/22] feat: implement notification sound functionality with user customization options --- docs/api/client-articles/index.md | 1 + docs/articles/notification-sounds.md | 43 +++++++++++++++++ docs/changelog/index.md | 2 +- docs/changelog/v0.2.3.md | 29 ++++++++++-- docs/changelog/v0.2.4.md | 22 --------- src/EchoHub.Client/AppOrchestrator.cs | 27 ++++++++++- src/EchoHub.Client/Config/ClientConfig.cs | 3 +- .../Services/NotificationSoundService.cs | 5 ++ src/EchoHub.Client/UI/ProfileEditDialog.cs | 47 ++++++++++++++++--- .../Contracts/IChatBroadcaster.cs | 1 + src/EchoHub.Core/Contracts/IEchoHubClient.cs | 1 + src/EchoHub.Server.Irc/IrcBroadcaster.cs | 18 +++++++ .../Controllers/ModerationController.cs | 40 +++++++++++++++- .../Services/PresenceTracker.cs | 23 +++++++++ .../Services/SignalRBroadcaster.cs | 9 ++++ 15 files changed, 236 insertions(+), 35 deletions(-) create mode 100644 docs/articles/notification-sounds.md delete mode 100644 docs/changelog/v0.2.4.md diff --git a/docs/api/client-articles/index.md b/docs/api/client-articles/index.md index 7fde157..171da8f 100644 --- a/docs/api/client-articles/index.md +++ b/docs/api/client-articles/index.md @@ -8,3 +8,4 @@ Articles related to the EchoHub TUI client built with Terminal.Gui v2. - Theme system and customization - Command system reference - Configuration management +- [Notification sounds](../../articles/notification-sounds.md) diff --git a/docs/articles/notification-sounds.md b/docs/articles/notification-sounds.md new file mode 100644 index 0000000..03f9767 --- /dev/null +++ b/docs/articles/notification-sounds.md @@ -0,0 +1,43 @@ +# Notification Sounds + +EchoHub can play a notification sound when someone @mentions you. This is **disabled by default** and must be enabled in your profile settings. + +## Enabling Notifications + +Open your profile (`/profile`) and check the **"Notification sound on @mention"** checkbox, then save. You can also adjust the **Volume** (0-100, default 30). All settings are persisted in `~/.echohub/config.json`. + +## Customizing the Sound + +The client ships with a default `Notification.mp3` in the `Assets` folder. To use your own notification sound, replace the file at: + +``` +<app-directory>/Assets/Notification.mp3 +``` + +The file must be a valid `.mp3` or `.wav` audio file. The replacement takes effect on the next app launch. + +Alternatively, set a custom path in `~/.echohub/config.json`: + +```json +{ + "notifications": { + "enabled": true, + "volume": 30, + "soundFile": "/path/to/your/sound.mp3" + } +} +``` + +When `soundFile` is set, EchoHub uses that file instead of the bundled default. + +## Disabling Notifications + +Uncheck the option in your profile, or edit the config directly: + +```json +{ + "notifications": { + "enabled": false + } +} +``` diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 7fac42f..647908c 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -4,7 +4,7 @@ Release history for EchoHub. ## Releases -- [v0.2.3](v0.2.3.md) - Moderation, Private Channels & UI Overhaul +- [v0.2.3](v0.2.3.md) - Moderation, Embeds & UI Overhaul - [v0.2.2](v0.2.2.md) - Startup & Shutdown Fixes - [v0.2.1](v0.2.1.md) - Shutdown & CI Fixes - [v0.2.0](v0.2.0.md) - IRC Gateway diff --git a/docs/changelog/v0.2.3.md b/docs/changelog/v0.2.3.md index e428fad..0b78e8b 100644 --- a/docs/changelog/v0.2.3.md +++ b/docs/changelog/v0.2.3.md @@ -1,4 +1,4 @@ -# v0.2.3 - Moderation, Private Channels & UI Overhaul +# v0.2.3 - Moderation, Embeds & UI Overhaul ## Features @@ -16,6 +16,21 @@ - `GET /api/channels` returns the combined list: public channels + user's joined private channels - Channel creators are automatically added as members +### OpenGraph Link Embeds +- Messages containing URLs now show a rich preview below the message text +- Server-side fetching: detects the first URL in a message, fetches the page, and parses OpenGraph meta tags (`og:title`, `og:description`, `og:image`, `og:site_name`) +- OG images are converted to a small 24x12 colored ASCII thumbnail using the existing half-block renderer +- Embeds are persisted in the database and included in channel history +- TUI client renders embeds with a `▏` left border bar — site name and border in blue, title in white, description in gray, followed by the ASCII thumbnail +- IRC gateway receives a text-only embed preview (site name, title, description) — no ASCII thumbnail to keep IRC output clean +- Falls back to `<title>` tag when no OG tags are present; gracefully skips if no useful metadata is found +- 3-second fetch timeout ensures message delivery is never significantly delayed +- SSRF protection rejects private/loopback IP addresses before fetching + +### Notification Sounds +- Incoming messages play a notification sound when the terminal is not focused +- Embedded MP3 asset with cross-platform playback support + ### Online Users Panel - Collapsible right-side panel showing online users in the current channel (toggle with F2) - Users displayed with status indicators, role badges, and their custom nickname colors @@ -36,6 +51,7 @@ - Version number shown in the status bar - Custom colored rendering for channel list (active indicator, unread count badges) - Avatar upload field added to the profile edit dialog (file path or URL) +- Profile avatar now renders with full color tag support in the profile view dialog - Update check notification on connect — shows a system message if a newer GitHub release exists - Chat messages no longer show selection/focus highlight - Exit shortcut changed from Ctrl+C to Alt+Q — frees Ctrl+C for copy @@ -48,12 +64,19 @@ - Wired `OnChannelUpdated` SignalR event so new public channels appear for all connected users in real time - Fixed color tag parser using wrong regex group numbers (6,7,8 instead of 1,2,3) — new ASCII art was rendering without colors - Full Unicode/emoji support — renderers use Terminal.Gui v2 grapheme cluster API (`GraphemeHelper`, `AddStr`) for proper wide character handling +- Emoji-to-text shortcode conversion for consistent cross-platform rendering - Fixed `/send` and `/avatar` commands not handling file paths with spaces correctly, even when quoted -- Profile avatar now renders with full color tag support instead of showing raw tags - Server-side newline spam protection — consecutive blank/whitespace-only lines collapsed to 1 and total lines capped at 30 ## Infrastructure +- New `LinkEmbedService` on the server — URL detection, HTML fetching (first 64KB), OG tag parsing via regex, image thumbnail generation +- `EmbedDto` record added to shared Core DTOs; `MessageDto` extended with optional `Embed` field (backward-compatible) +- `EmbedJson` nullable column on the `Message` table stores serialized embed data (max 8KB) +- Dedicated `"OgFetch"` named HttpClient with bot User-Agent header and 5-second timeout +- `NotificationSoundService` for cross-platform audio playback of embedded notification sounds - Startup `DataMigrationService` automatically converts old ANSI-format messages to the new color tag format on server boot, logging the count of migrated records -- Three new EF Core migrations: `AddModerationRoles`, `AddChannelIsPublic`, `AddChannelMembership` +- `EmojiHelper` utility for emoji-to-shortcode conversion +- Heartbeat handling in `ServerDirectoryService` for connection health checks +- Four new EF Core migrations: `AddModerationRoles`, `AddChannelIsPublic`, `AddChannelMembership`, `AddMessageEmbed` - `ChannelMembership` table with cascade delete on both channel and user removal diff --git a/docs/changelog/v0.2.4.md b/docs/changelog/v0.2.4.md deleted file mode 100644 index 3b8c67a..0000000 --- a/docs/changelog/v0.2.4.md +++ /dev/null @@ -1,22 +0,0 @@ -# v0.2.4 - Link Embeds - -## Features - -### OpenGraph Link Embeds -- Messages containing URLs now show a rich preview below the message text, similar to Discord -- Server-side fetching: detects the first URL in a message, fetches the page, and parses OpenGraph meta tags (`og:title`, `og:description`, `og:image`, `og:site_name`) -- OG images are converted to a small 24x12 colored ASCII thumbnail using the existing half-block renderer -- Embeds are persisted in the database and included in channel history -- TUI client renders embeds with a `▏` left border bar — site name and border in blue, title in white, description in gray, followed by the ASCII thumbnail -- IRC gateway receives a text-only embed preview (site name, title, description) — no ASCII thumbnail to keep IRC output clean -- Falls back to `<title>` tag when no OG tags are present; gracefully skips if no useful metadata is found -- 3-second fetch timeout ensures message delivery is never significantly delayed -- SSRF protection rejects private/loopback IP addresses before fetching - -## Infrastructure - -- New `LinkEmbedService` on the server — URL detection, HTML fetching (first 64KB), OG tag parsing via regex, image thumbnail generation -- `EmbedDto` record added to shared Core DTOs; `MessageDto` extended with optional `Embed` field (backward-compatible) -- `EmbedJson` nullable column on the `Message` table stores serialized embed data (max 8KB) -- Dedicated `"OgFetch"` named HttpClient with bot User-Agent header and 5-second timeout -- New EF Core migration: `AddMessageEmbed` diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs index ba641b4..4c90e97 100644 --- a/src/EchoHub.Client/AppOrchestrator.cs +++ b/src/EchoHub.Client/AppOrchestrator.cs @@ -21,6 +21,7 @@ public sealed class AppOrchestrator : IDisposable private readonly IApplication _app; private readonly MainWindow _mainWindow; private readonly CommandHandler _commandHandler; + private readonly NotificationSoundService _notificationSound; private EchoHubConnection? _connection; private ApiClient? _apiClient; @@ -41,6 +42,7 @@ public sealed class AppOrchestrator : IDisposable _config = config; _mainWindow = new MainWindow(app); _commandHandler = new CommandHandler(); + _notificationSound = new NotificationSoundService(config.Notifications); WireMainWindowEvents(); WireCommandHandlerEvents(); @@ -572,7 +574,9 @@ public sealed class AppOrchestrator : IDisposable var editResult = ProfileEditDialog.Show(_app, currentProfile?.DisplayName, currentProfile?.Bio, - currentProfile?.NicknameColor); + currentProfile?.NicknameColor, + _config.Notifications.Enabled, + _config.Notifications.Volume); if (editResult is null) return; @@ -633,6 +637,18 @@ public sealed class AppOrchestrator : IDisposable } } + if (editResult.NotificationSoundEnabled.HasValue) + { + _config.Notifications.Enabled = editResult.NotificationSoundEnabled.Value; + _notificationSound.SetEnabled(editResult.NotificationSoundEnabled.Value); + } + + if (editResult.NotificationVolume.HasValue) + { + _config.Notifications.Volume = editResult.NotificationVolume.Value; + _notificationSound.SetVolume(editResult.NotificationVolume.Value); + } + _config.DefaultPreset = new AccountPreset { DisplayName = editResult.DisplayName, @@ -768,8 +784,17 @@ public sealed class AppOrchestrator : IDisposable private void WireConnectionEvents(EchoHubConnection connection) { connection.OnMessageReceived += message => + { InvokeUI(() => _mainWindow.AddMessage(message)); + if (!string.IsNullOrEmpty(_currentUsername) + && !message.SenderUsername.Equals(_currentUsername, StringComparison.OrdinalIgnoreCase) + && message.Content.Contains($"@{_currentUsername}", StringComparison.OrdinalIgnoreCase)) + { + _ = _notificationSound.PlayAsync(); + } + }; + connection.OnUserJoined += (channelName, username) => { InvokeUI(() => _mainWindow.AddSystemMessage(channelName, $"{username} joined the channel")); diff --git a/src/EchoHub.Client/Config/ClientConfig.cs b/src/EchoHub.Client/Config/ClientConfig.cs index 7536419..25cdb45 100644 --- a/src/EchoHub.Client/Config/ClientConfig.cs +++ b/src/EchoHub.Client/Config/ClientConfig.cs @@ -10,7 +10,8 @@ public class ClientConfig public class NotificationConfig { - public bool Enabled { get; set; } = true; + public bool Enabled { get; set; } = false; + public byte Volume { get; set; } = 30; public string? SoundFile { get; set; } } diff --git a/src/EchoHub.Client/Services/NotificationSoundService.cs b/src/EchoHub.Client/Services/NotificationSoundService.cs index cd6f416..d9573c0 100644 --- a/src/EchoHub.Client/Services/NotificationSoundService.cs +++ b/src/EchoHub.Client/Services/NotificationSoundService.cs @@ -16,6 +16,10 @@ public class NotificationSoundService ResolveSoundPath(); } + public void SetEnabled(bool enabled) => _config.Enabled = enabled; + + public void SetVolume(byte volume) => _config.Volume = Math.Min(volume, (byte)100); + public async Task PlayAsync() { if (!_config.Enabled || _resolvedSoundPath is null) @@ -26,6 +30,7 @@ public class NotificationSoundService if (_player.Playing) await _player.Stop(); + await _player.SetVolume(_config.Volume); await _player.Play(_resolvedSoundPath); } catch (Exception ex) diff --git a/src/EchoHub.Client/UI/ProfileEditDialog.cs b/src/EchoHub.Client/UI/ProfileEditDialog.cs index 6481e16..0cbc2c1 100644 --- a/src/EchoHub.Client/UI/ProfileEditDialog.cs +++ b/src/EchoHub.Client/UI/ProfileEditDialog.cs @@ -9,7 +9,7 @@ namespace EchoHub.Client.UI; /// <summary> /// Result returned from the profile edit dialog. /// </summary> -public record ProfileEditResult(string? DisplayName, string? Bio, string? NicknameColor, string? AvatarPath); +public record ProfileEditResult(string? DisplayName, string? Bio, string? NicknameColor, string? AvatarPath, bool? NotificationSoundEnabled, byte? NotificationVolume); /// <summary> /// A Terminal.Gui dialog for editing the user's profile (display name, bio, nickname color). @@ -19,11 +19,11 @@ public sealed class ProfileEditDialog /// <summary> /// Shows the profile edit dialog and returns the result, or null if cancelled. /// </summary> - public static ProfileEditResult? Show(IApplication app, string? currentDisplayName, string? currentBio, string? currentColor) + public static ProfileEditResult? Show(IApplication app, string? currentDisplayName, string? currentBio, string? currentColor, bool notificationSoundEnabled = false, byte notificationVolume = 30) { ProfileEditResult? result = null; - var dialog = new Dialog { Title = "Edit Profile", Width = 60, Height = 22 }; + var dialog = new Dialog { Title = "Edit Profile", Width = 60, Height = 26 }; // Display Name var nameLabel = new Label @@ -148,20 +148,53 @@ public sealed class ProfileEditDialog } }; + // Notification Sound + var notifCheckbox = new CheckBox + { + Text = "Notification sound on @mention", + X = 1, + Y = 13, + Value = notificationSoundEnabled ? CheckState.Checked : CheckState.UnChecked + }; + + var volumeLabel = new Label + { + Text = "Volume:", + X = 1, + Y = 15 + }; + var volumeField = new TextField + { + Text = notificationVolume.ToString(), + X = 17, + Y = 15, + Width = 6 + }; + var volumeHintLabel = new Label + { + Text = "(0-100)", + X = 24, + Y = 15 + }; + volumeHintLabel.SetScheme(new Scheme + { + Normal = new Attribute(Color.DarkGray, Color.Blue) + }); + // Buttons var saveButton = new Button { Text = "Save", IsDefault = true, X = Pos.Center() - 10, - Y = 14 + Y = 18 }; var cancelButton = new Button { Text = "Cancel", X = Pos.Center() + 5, - Y = 14 + Y = 18 }; saveButton.Accepting += (s, e) => @@ -171,7 +204,8 @@ public sealed class ProfileEditDialog var nicknameColor = NullIfEmpty(colorField.Text?.Trim()); var avatarPath = NullIfEmpty(avatarField.Text?.Trim()); - result = new ProfileEditResult(displayName, bio, nicknameColor, avatarPath); + byte? volume = byte.TryParse(volumeField.Text, out var v) ? Math.Min(v, (byte)100) : null; + result = new ProfileEditResult(displayName, bio, nicknameColor, avatarPath, notifCheckbox.Value == CheckState.Checked, volume); e.Handled = true; app.RequestStop(); }; @@ -186,6 +220,7 @@ public sealed class ProfileEditDialog dialog.Add(nameLabel, nameField, bioLabel, bioField, colorLabel, colorField, colorHintLabel, previewLabel, colorPreview, avatarLabel, avatarField, browseButton, avatarHintLabel, + notifCheckbox, volumeLabel, volumeField, volumeHintLabel, saveButton, cancelButton); nameField.SetFocus(); diff --git a/src/EchoHub.Core/Contracts/IChatBroadcaster.cs b/src/EchoHub.Core/Contracts/IChatBroadcaster.cs index 84d1e7d..c88bc81 100644 --- a/src/EchoHub.Core/Contracts/IChatBroadcaster.cs +++ b/src/EchoHub.Core/Contracts/IChatBroadcaster.cs @@ -14,4 +14,5 @@ public interface IChatBroadcaster Task SendMessageDeletedAsync(string channelName, Guid messageId); Task SendChannelNukedAsync(string channelName); Task SendErrorAsync(string connectionId, string message); + Task ForceDisconnectUserAsync(List<string> connectionIds, string reason); } diff --git a/src/EchoHub.Core/Contracts/IEchoHubClient.cs b/src/EchoHub.Core/Contracts/IEchoHubClient.cs index 86875d1..242a66e 100644 --- a/src/EchoHub.Core/Contracts/IEchoHubClient.cs +++ b/src/EchoHub.Core/Contracts/IEchoHubClient.cs @@ -16,5 +16,6 @@ public interface IEchoHubClient Task UserBanned(string username, string? reason); Task MessageDeleted(string channelName, Guid messageId); Task ChannelNuked(string channelName); + Task ForceDisconnect(string reason); Task Error(string message); } diff --git a/src/EchoHub.Server.Irc/IrcBroadcaster.cs b/src/EchoHub.Server.Irc/IrcBroadcaster.cs index 6597b5c..40b88a3 100644 --- a/src/EchoHub.Server.Irc/IrcBroadcaster.cs +++ b/src/EchoHub.Server.Irc/IrcBroadcaster.cs @@ -106,4 +106,22 @@ public class IrcBroadcaster : IChatBroadcaster await conn.SendAsync($":{_gateway.Options.ServerName} NOTICE {conn.Nickname ?? "*"} :{message}"); } } + + public async Task ForceDisconnectUserAsync(List<string> connectionIds, string reason) + { + foreach (var connId in connectionIds) + { + if (!connId.StartsWith("irc-")) continue; + + if (_gateway.Connections.TryGetValue(connId, out var conn)) + { + try + { + await conn.SendAsync($"ERROR :Closing Link: {reason}"); + await conn.DisposeAsync(); + } + catch { /* connection may already be closed */ } + } + } + } } diff --git a/src/EchoHub.Server/Controllers/ModerationController.cs b/src/EchoHub.Server/Controllers/ModerationController.cs index 26eef4d..b048d12 100644 --- a/src/EchoHub.Server/Controllers/ModerationController.cs +++ b/src/EchoHub.Server/Controllers/ModerationController.cs @@ -72,13 +72,17 @@ public class ModerationController : ControllerBase if (target.Role >= caller!.Role) return BadRequest(new ErrorResponse("Cannot kick a user with equal or higher role.")); - // Broadcast kick to all channels the user is in + // Broadcast kick to all channels the user is in, then clean up presence var channels = _presenceTracker.GetChannelsForUser(target.Username); foreach (var channel in channels) { await BroadcastToAllAsync(b => b.SendUserKickedAsync(channel, target.Username, request?.Reason)); } + // Remove from presence tracker and force disconnect all connections + var reason = request?.Reason ?? "You have been kicked from the server."; + await ForceDisconnectAndCleanupAsync(target.Username, reason); + return Ok(new { Message = $"{target.Username} has been kicked." }); } @@ -98,8 +102,12 @@ public class ModerationController : ControllerBase target.IsBanned = true; await _db.SaveChangesAsync(); + // Broadcast ban notification, then force disconnect await BroadcastToAllAsync(b => b.SendUserBannedAsync(target.Username, request?.Reason)); + var reason = request?.Reason ?? "You have been banned from this server."; + await ForceDisconnectAndCleanupAsync(target.Username, reason); + return Ok(new { Message = $"{target.Username} has been banned." }); } @@ -217,6 +225,36 @@ public class ModerationController : ControllerBase return (caller, null); } + /// <summary> + /// Remove user from presence tracking, broadcast their departure from all channels, + /// send a ForceDisconnect signal, and update their DB status. + /// </summary> + private async Task ForceDisconnectAndCleanupAsync(string username, string reason) + { + var (connectionIds, channels) = _presenceTracker.ForceRemoveUser(username); + + // Notify remaining users that this person left each channel + foreach (var channel in channels) + { + await BroadcastToAllAsync(b => b.SendUserLeftAsync(channel, username)); + } + + // Signal the user's clients to disconnect + if (connectionIds.Count > 0) + { + await BroadcastToAllAsync(b => b.ForceDisconnectUserAsync(connectionIds, reason)); + } + + // Mark user offline in DB + var user = await _db.Users.FirstOrDefaultAsync(u => u.Username == username); + if (user is not null) + { + user.Status = UserStatus.Invisible; + user.LastSeenAt = DateTimeOffset.UtcNow; + await _db.SaveChangesAsync(); + } + } + private async Task BroadcastToAllAsync(Func<IChatBroadcaster, Task> action) { foreach (var broadcaster in _broadcasters) diff --git a/src/EchoHub.Server/Services/PresenceTracker.cs b/src/EchoHub.Server/Services/PresenceTracker.cs index 627718d..0ae3d42 100644 --- a/src/EchoHub.Server/Services/PresenceTracker.cs +++ b/src/EchoHub.Server/Services/PresenceTracker.cs @@ -151,4 +151,27 @@ public class PresenceTracker { return _userConnections.Count; } + + /// <summary> + /// Forcibly remove a user from all tracking. Returns their connection IDs and channels + /// so the caller can broadcast departures and force-disconnect connections. + /// </summary> + public (List<string> ConnectionIds, List<string> Channels) ForceRemoveUser(string username) + { + lock (_lock) + { + var channels = _userChannels.TryRemove(username, out var ch) + ? ch.ToList() + : []; + + var connectionIds = _userConnections.TryRemove(username, out var conns) + ? conns.ToList() + : []; + + foreach (var connId in connectionIds) + _connections.TryRemove(connId, out _); + + return (connectionIds, channels); + } + } } diff --git a/src/EchoHub.Server/Services/SignalRBroadcaster.cs b/src/EchoHub.Server/Services/SignalRBroadcaster.cs index a29ee42..546dde6 100644 --- a/src/EchoHub.Server/Services/SignalRBroadcaster.cs +++ b/src/EchoHub.Server/Services/SignalRBroadcaster.cs @@ -73,4 +73,13 @@ public class SignalRBroadcaster : IChatBroadcaster return HubContext.Clients.Client(connectionId).Error(message); } + + public Task ForceDisconnectUserAsync(List<string> connectionIds, string reason) + { + var signalRIds = connectionIds.Where(c => !c.StartsWith("irc-")).ToList(); + if (signalRIds.Count == 0) + return Task.CompletedTask; + + return HubContext.Clients.Clients(signalRIds).ForceDisconnect(reason); + } } From 107152298e9aadfa00a47a36d1fe1fc00467c100 Mon Sep 17 00:00:00 2001 From: HueByte <ihuebyte@gmail.com> Date: Thu, 19 Feb 2026 22:09:54 +0100 Subject: [PATCH 18/22] feat: add force disconnect handling and improve user ban notifications --- src/EchoHub.Client/AppOrchestrator.cs | 22 +++++++++++----- .../Services/EchoHubConnection.cs | 6 +++++ src/EchoHub.Client/UI/MainWindow.cs | 26 +++++++++++++++++++ 3 files changed, 47 insertions(+), 7 deletions(-) diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs index 4c90e97..64b226f 100644 --- a/src/EchoHub.Client/AppOrchestrator.cs +++ b/src/EchoHub.Client/AppOrchestrator.cs @@ -830,25 +830,33 @@ public sealed class AppOrchestrator : IDisposable InvokeUI(() => { _mainWindow.AddSystemMessage(channelName, $"{username} was kicked{reasonText}"); - if (username.Equals(_currentUsername, StringComparison.OrdinalIgnoreCase)) - { - _mainWindow.AddSystemMessage(channelName, "You were kicked from this channel."); - } }); }; connection.OnUserBanned += (username, reason) => { + var reasonText = reason is not null ? $" ({reason})" : ""; InvokeUI(() => { - if (username.Equals(_currentUsername, StringComparison.OrdinalIgnoreCase)) + // Show ban notification for other users in the channel + if (!username.Equals(_currentUsername, StringComparison.OrdinalIgnoreCase)) { - _mainWindow.ShowError("You have been banned from this server."); - HandleDisconnect(); + var channel = _mainWindow.CurrentChannel; + if (!string.IsNullOrEmpty(channel)) + _mainWindow.AddSystemMessage(channel, $"{username} was banned{reasonText}"); } }); }; + connection.OnForceDisconnect += reason => + { + InvokeUI(() => + { + _mainWindow.ShowError(reason); + HandleDisconnect(); + }); + }; + connection.OnMessageDeleted += (channelName, messageId) => { InvokeUI(() => diff --git a/src/EchoHub.Client/Services/EchoHubConnection.cs b/src/EchoHub.Client/Services/EchoHubConnection.cs index 5d15d20..3ee18fd 100644 --- a/src/EchoHub.Client/Services/EchoHubConnection.cs +++ b/src/EchoHub.Client/Services/EchoHubConnection.cs @@ -18,6 +18,7 @@ public sealed class EchoHubConnection : IAsyncDisposable public event Action<string, string?>? OnUserBanned; public event Action<string, Guid>? OnMessageDeleted; public event Action<string>? OnChannelNuked; + public event Action<string>? OnForceDisconnect; public event Action<string>? OnError; public event Action<string>? OnConnectionStateChanged; public event Action? OnReconnected; @@ -105,6 +106,11 @@ public sealed class EchoHubConnection : IAsyncDisposable OnChannelNuked?.Invoke(channelName); }); + _connection.On<string>(nameof(Core.Contracts.IEchoHubClient.ForceDisconnect), reason => + { + OnForceDisconnect?.Invoke(reason); + }); + _connection.On<string>(nameof(Core.Contracts.IEchoHubClient.Error), message => { OnError?.Invoke(message); diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index 1032d5c..bf5f22b 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -197,6 +197,7 @@ public sealed class MainWindow : Runnable WordWrap = true }; _inputField.KeyDown += OnInputKeyDown; + _inputField.ContentsChanged += OnInputContentsChanged; _inputFrame.Add(_inputField); Add(_inputFrame); @@ -388,6 +389,31 @@ public sealed class MainWindow : Runnable } } + private bool _suppressEmojiReplace; + + private void OnInputContentsChanged(object? sender, ContentsChangedEventArgs e) + { + if (_suppressEmojiReplace) + return; + + var text = _inputField.Text; + if (string.IsNullOrEmpty(text)) + return; + + var replaced = EmojiHelper.ReplaceEmoji(text); + if (replaced == text) + return; + + // Calculate where cursor should land after replacement + var lengthDelta = replaced.Length - text.Length; + var newCol = Math.Max(0, _inputField.CurrentColumn + lengthDelta); + + _suppressEmojiReplace = true; + _inputField.Text = replaced; + _inputField.InsertionPoint = new System.Drawing.Point(newCol, _inputField.CurrentRow); + _suppressEmojiReplace = false; + } + /// <summary> /// Tab-complete slash commands in the input field. /// </summary> From f20ce3d50f86127ae3008ad63b397bc568702f0e Mon Sep 17 00:00:00 2001 From: HueByte <ihuebyte@gmail.com> Date: Thu, 19 Feb 2026 22:42:20 +0100 Subject: [PATCH 19/22] feat: add test sound command and enable notifications by default --- docs/changelog/v0.2.3.md | 23 ++-- src/EchoHub.Client/AppOrchestrator.cs | 6 +- src/EchoHub.Client/Commands/CommandHandler.cs | 10 ++ src/EchoHub.Client/Config/ClientConfig.cs | 2 +- .../Services/NotificationSoundService.cs | 18 ++- src/EchoHub.Client/UI/MainWindow.cs | 125 ++++-------------- src/EchoHub.Core/Constants/HubConstants.cs | 4 +- .../Services/LinkEmbedService.cs | 89 ++----------- 8 files changed, 89 insertions(+), 188 deletions(-) diff --git a/docs/changelog/v0.2.3.md b/docs/changelog/v0.2.3.md index 0b78e8b..9170622 100644 --- a/docs/changelog/v0.2.3.md +++ b/docs/changelog/v0.2.3.md @@ -8,6 +8,8 @@ - `ModerationController` with full REST API for role assignment, kicks, bans, mutes, message deletion, and channel nuking - Mutes support optional duration (auto-expire) and blocked users cannot log in - Role claim included in JWT tokens; role badges shown in the online users panel +- Kicked and banned users are forcibly disconnected in real time — server cleans up presence, broadcasts departures, and signals client disconnect +- Works for both SignalR and IRC connections; client shows an error dialog with the reason ### Private Channels - Channels can be created as public or private via a checkbox in the Create Channel dialog @@ -18,13 +20,13 @@ ### OpenGraph Link Embeds - Messages containing URLs now show a rich preview below the message text -- Server-side fetching: detects the first URL in a message, fetches the page, and parses OpenGraph meta tags (`og:title`, `og:description`, `og:image`, `og:site_name`) -- OG images are converted to a small 24x12 colored ASCII thumbnail using the existing half-block renderer -- Embeds are persisted in the database and included in channel history -- TUI client renders embeds with a `▏` left border bar — site name and border in blue, title in white, description in gray, followed by the ASCII thumbnail -- IRC gateway receives a text-only embed preview (site name, title, description) — no ASCII thumbnail to keep IRC output clean +- Multiple URLs per message supported (up to 3) — each gets its own embed +- Server-side fetching: detects URLs in a message, fetches each page, and parses OpenGraph meta tags (`og:title`, `og:description`, `og:site_name`) +- Embeds are persisted in the database as a JSON array and included in channel history +- TUI client renders embeds with a `▏` left border bar — site name and border in blue, title in white, description in gray; text word-wraps at actual viewport width +- IRC gateway receives a text-only embed preview (site name, title, description) - Falls back to `<title>` tag when no OG tags are present; gracefully skips if no useful metadata is found -- 3-second fetch timeout ensures message delivery is never significantly delayed +- 5-second fetch timeout ensures message delivery is never significantly delayed - SSRF protection rejects private/loopback IP addresses before fetching ### Notification Sounds @@ -67,13 +69,16 @@ - Emoji-to-text shortcode conversion for consistent cross-platform rendering - Fixed `/send` and `/avatar` commands not handling file paths with spaces correctly, even when quoted - Server-side newline spam protection — consecutive blank/whitespace-only lines collapsed to 1 and total lines capped at 30 +- Fixed OG tag regex truncating descriptions containing apostrophes (e.g. `"HueByte's portfolio"` was cut to `"HueByte"`) — switched to backreference-based quote pairing ## Infrastructure -- New `LinkEmbedService` on the server — URL detection, HTML fetching (first 64KB), OG tag parsing via regex, image thumbnail generation -- `EmbedDto` record added to shared Core DTOs; `MessageDto` extended with optional `Embed` field (backward-compatible) -- `EmbedJson` nullable column on the `Message` table stores serialized embed data (max 8KB) +- New `LinkEmbedService` on the server — URL detection, HTML fetching (first 64KB), OG tag parsing via compiled regex +- `EmbedDto` record added to shared Core DTOs; `MessageDto.Embeds` list for multiple embeds per message +- `EmbedJson` nullable column on the `Message` table stores serialized embed data as JSON array (max 8KB); `DataMigrationService` auto-migrates old single-object format - Dedicated `"OgFetch"` named HttpClient with bot User-Agent header and 5-second timeout +- `PresenceTracker.ForceRemoveUser()` for atomic user cleanup on kick/ban +- `IChatBroadcaster.ForceDisconnectUserAsync()` and `IEchoHubClient.ForceDisconnect` for force-disconnect signaling - `NotificationSoundService` for cross-platform audio playback of embedded notification sounds - Startup `DataMigrationService` automatically converts old ANSI-format messages to the new color tag format on server boot, logging the count of migrated records - `EmojiHelper` utility for emoji-to-shortcode conversion diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs index 64b226f..1c9f65f 100644 --- a/src/EchoHub.Client/AppOrchestrator.cs +++ b/src/EchoHub.Client/AppOrchestrator.cs @@ -347,6 +347,11 @@ public sealed class AppOrchestrator : IDisposable await _apiClient!.NukeChannelAsync(channel); }; + _commandHandler.OnTestSound += async () => + { + await _notificationSound.PlayTestAsync(); + }; + _commandHandler.OnQuit += () => { InvokeUI(() => _app.RequestStop()); @@ -788,7 +793,6 @@ public sealed class AppOrchestrator : IDisposable InvokeUI(() => _mainWindow.AddMessage(message)); if (!string.IsNullOrEmpty(_currentUsername) - && !message.SenderUsername.Equals(_currentUsername, StringComparison.OrdinalIgnoreCase) && message.Content.Contains($"@{_currentUsername}", StringComparison.OrdinalIgnoreCase)) { _ = _notificationSound.PlayAsync(); diff --git a/src/EchoHub.Client/Commands/CommandHandler.cs b/src/EchoHub.Client/Commands/CommandHandler.cs index 19df340..b2304dd 100644 --- a/src/EchoHub.Client/Commands/CommandHandler.cs +++ b/src/EchoHub.Client/Commands/CommandHandler.cs @@ -25,6 +25,7 @@ public class CommandHandler public event Func<string, Task>? OnUnmuteUser; public event Func<string, string, Task>? OnAssignRole; public event Func<Task>? OnNukeChannel; + public event Func<Task>? OnTestSound; public event Func<Task>? OnQuit; public event Func<Task>? OnHelp; @@ -60,6 +61,7 @@ public class CommandHandler "unmute" => await HandleUnmute(args), "role" => await HandleRole(args), "nuke" => await HandleNuke(), + "test-sound" => await HandleTestSound(), "quit" or "exit" => await HandleQuit(), "help" or "?" => await HandleHelp(), _ => new CommandResult(true, $"Unknown command: /{command}. Type /help for available commands.", IsError: true), @@ -319,6 +321,13 @@ public class CommandHandler return new CommandResult(true, "Nuking channel history..."); } + private async Task<CommandResult> HandleTestSound() + { + if (OnTestSound is not null) + await OnTestSound(); + return new CommandResult(true, "Playing notification sound..."); + } + private async Task<CommandResult> HandleHelp() { if (OnHelp is not null) @@ -346,6 +355,7 @@ public class CommandHandler /unmute <user> - Unmute a user (Mod+) /role <user> <admin|mod|member> - Assign role (Admin+) /nuke - Clear channel history (Mod+) + /test-sound - Play notification sound /quit - Exit the app """); } diff --git a/src/EchoHub.Client/Config/ClientConfig.cs b/src/EchoHub.Client/Config/ClientConfig.cs index 25cdb45..80052a7 100644 --- a/src/EchoHub.Client/Config/ClientConfig.cs +++ b/src/EchoHub.Client/Config/ClientConfig.cs @@ -10,7 +10,7 @@ public class ClientConfig public class NotificationConfig { - public bool Enabled { get; set; } = false; + public bool Enabled { get; set; } = true; public byte Volume { get; set; } = 30; public string? SoundFile { get; set; } } diff --git a/src/EchoHub.Client/Services/NotificationSoundService.cs b/src/EchoHub.Client/Services/NotificationSoundService.cs index d9573c0..2118d40 100644 --- a/src/EchoHub.Client/Services/NotificationSoundService.cs +++ b/src/EchoHub.Client/Services/NotificationSoundService.cs @@ -25,13 +25,29 @@ public class NotificationSoundService if (!_config.Enabled || _resolvedSoundPath is null) return; + await PlayInternal(); + } + + /// <summary> + /// Plays the notification sound regardless of the Enabled setting (for /test-sound). + /// </summary> + public async Task PlayTestAsync() + { + if (_resolvedSoundPath is null) + return; + + await PlayInternal(); + } + + private async Task PlayInternal() + { try { if (_player.Playing) await _player.Stop(); await _player.SetVolume(_config.Volume); - await _player.Play(_resolvedSoundPath); + await _player.Play(_resolvedSoundPath!); } catch (Exception ex) { diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index bf5f22b..804ef21 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -51,7 +51,7 @@ public sealed class MainWindow : Runnable "/status", "/nick", "/color", "/theme", "/send", "/avatar", "/profile", "/servers", "/join", "/leave", "/topic", "/users", "/kick", "/ban", "/unban", - "/mute", "/unmute", "/role", "/nuke", "/quit", "/help" + "/mute", "/unmute", "/role", "/nuke", "/test-sound", "/quit", "/help" ]; private readonly List<string> _channelNames = []; @@ -923,8 +923,9 @@ public sealed class MainWindow : Runnable // Render link embeds if present if (message.Embeds is { Count: > 0 }) { + var chatWidth = _lastChatWidth > 0 ? _lastChatWidth : 80; foreach (var embed in message.Embeds) - lines.AddRange(FormatEmbed(embed, indent)); + lines.AddRange(FormatEmbed(embed, indent, chatWidth)); } break; } @@ -977,115 +978,45 @@ public sealed class MainWindow : Runnable /// <summary> /// Format a link embed as indented chat lines with a left border bar, - /// optional description wrapping, and a small icon on the right (Discord-style). - /// Layout: ▏ {text column} {icon column} + /// text at full width, and optional icon below (preview image). + /// Each line is pre-wrapped to fit chatWidth so ChatLine.Wrap won't break layout. /// </summary> - private static List<ChatLine> FormatEmbed(EmbedDto embed, string indent) + private static List<ChatLine> FormatEmbed(EmbedDto embed, string indent, int chatWidth) { var lines = new List<ChatLine>(); const string border = "\u258f "; // ▏ + space - const int borderCols = 2; // ▏ = 1 col + space = 1 col - const int iconGap = 1; // space between text and icon + const int borderCols = 2; + int indentCols = indent.GetColumns(); + int textWidth = chatWidth - indentCols - borderCols; + if (textWidth < 20) textWidth = 20; - // Parse icon lines if present - var iconLines = new List<string>(); - int iconWidth = 0; - if (!string.IsNullOrWhiteSpace(embed.ImageAscii)) + // Helper: create a bordered text line + void AddTextLine(string text, Attribute? color) { - foreach (var artLine in embed.ImageAscii.Split('\n')) - { - var trimmed = artLine.TrimEnd('\r'); - if (!string.IsNullOrEmpty(trimmed)) - iconLines.Add(trimmed); - } - if (iconLines.Count > 0) - { - // Measure icon width from the first line (strip color tags for measurement) - var stripped = ChatLine.StripColorTags(iconLines[0]); - iconWidth = stripped.GetColumns(); - } + lines.Add(new ChatLine( + [ + new ChatSegment(indent, null), + new ChatSegment(border, ChatColors.EmbedBorderAttr), + new ChatSegment(text, color) + ])); } - bool hasIcon = iconLines.Count > 0 && iconWidth > 0; - int indentCols = indent.GetColumns(); - - // We don't know the terminal width at format time, so use a reasonable default - // for text wrapping. The ChatListSource.Render will handle final clipping. - const int estimatedWidth = 80; - int availableForText = estimatedWidth - indentCols - borderCols; - int textColWidth = hasIcon - ? availableForText - iconWidth - iconGap - : availableForText; - if (textColWidth < 20) textColWidth = 20; - - // Collect all text rows (site name, title, wrapped description, URL) - var textRows = new List<(string Text, Attribute? Color)>(); - + // Site name if (!string.IsNullOrWhiteSpace(embed.SiteName)) - textRows.Add((embed.SiteName, ChatColors.EmbedBorderAttr)); + AddTextLine(embed.SiteName, ChatColors.EmbedBorderAttr); + // Title if (!string.IsNullOrWhiteSpace(embed.Title)) - textRows.Add((embed.Title, ChatColors.EmbedTitleAttr)); + { + foreach (var wrapped in WordWrap(embed.Title, textWidth)) + AddTextLine(wrapped, ChatColors.EmbedTitleAttr); + } - // Word-wrap description + // Description (word-wrapped at full available width) if (!string.IsNullOrWhiteSpace(embed.Description)) { - foreach (var wrappedLine in WordWrap(embed.Description, textColWidth)) - textRows.Add((wrappedLine, ChatColors.EmbedDescAttr)); - } - - // Dim URL at the bottom - textRows.Add((embed.Url, ChatColors.EmbedUrlAttr)); - - // Merge text rows with icon rows side-by-side - int totalLines = Math.Max(textRows.Count, iconLines.Count); - for (int i = 0; i < totalLines; i++) - { - var segments = new List<ChatSegment>(); - segments.Add(new ChatSegment(indent, null)); - segments.Add(new ChatSegment(border, ChatColors.EmbedBorderAttr)); - - if (i < textRows.Count) - { - var (text, color) = textRows[i]; - segments.Add(new ChatSegment(text, color)); - - // Pad to align icon column - if (hasIcon && i < iconLines.Count) - { - int textCols = text.GetColumns(); - int padding = textColWidth - textCols + iconGap; - if (padding > 0) - segments.Add(new ChatSegment(new string(' ', padding), null)); - } - } - else if (hasIcon && i < iconLines.Count) - { - // No text row, pad the full text column + gap - segments.Add(new ChatSegment(new string(' ', textColWidth + iconGap), null)); - } - - // Append icon line - if (hasIcon && i < iconLines.Count) - { - var iconLine = iconLines[i]; - if (ChatLine.HasColorTags(iconLine)) - { - // Build a composite: plain segments + colored icon - var plainPart = new ChatLine(segments); - var iconPart = ChatLine.FromColoredText(iconLine); - var merged = new List<ChatSegment>(plainPart.Segments); - merged.AddRange(iconPart.Segments); - lines.Add(new ChatLine(merged)); - continue; - } - else - { - segments.Add(new ChatSegment(iconLine, null)); - } - } - - lines.Add(new ChatLine(segments)); + foreach (var wrapped in WordWrap(embed.Description, textWidth)) + AddTextLine(wrapped, ChatColors.EmbedDescAttr); } return lines; diff --git a/src/EchoHub.Core/Constants/HubConstants.cs b/src/EchoHub.Core/Constants/HubConstants.cs index 7db4fff..024d956 100644 --- a/src/EchoHub.Core/Constants/HubConstants.cs +++ b/src/EchoHub.Core/Constants/HubConstants.cs @@ -15,10 +15,8 @@ public static class HubConstants public const int AsciiArtHeightHalfBlock = 80; // Link embed constants - public const int EmbedIconWidth = 12; - public const int EmbedIconHeight = 6; public const int EmbedMaxDescriptionLength = 500; public const int EmbedMaxHtmlBytes = 64 * 1024; // 64 KB - public const int EmbedFetchTimeoutSeconds = 3; + public const int EmbedFetchTimeoutSeconds = 5; public const int EmbedMaxUrlsPerMessage = 3; } diff --git a/src/EchoHub.Server/Services/LinkEmbedService.cs b/src/EchoHub.Server/Services/LinkEmbedService.cs index 8d032cc..e424730 100644 --- a/src/EchoHub.Server/Services/LinkEmbedService.cs +++ b/src/EchoHub.Server/Services/LinkEmbedService.cs @@ -10,16 +10,13 @@ namespace EchoHub.Server.Services; public partial class LinkEmbedService { private readonly IHttpClientFactory _httpClientFactory; - private readonly ImageToAsciiService _asciiService; private readonly ILogger<LinkEmbedService> _logger; public LinkEmbedService( IHttpClientFactory httpClientFactory, - ImageToAsciiService asciiService, ILogger<LinkEmbedService> logger) { _httpClientFactory = httpClientFactory; - _asciiService = asciiService; _logger = logger; } @@ -111,15 +108,7 @@ public partial class LinkEmbedService siteName = siteName is not null ? WebUtility.HtmlDecode(siteName) : null; description = description is not null ? WebUtility.HtmlDecode(description) : null; - // Attempt to fetch OG image as small icon - string? imageAscii = null; - var imageUrl = ogTags.GetValueOrDefault("image"); - if (!string.IsNullOrWhiteSpace(imageUrl)) - { - imageAscii = await FetchImageThumbnailAsync(imageUrl, uri, cts.Token); - } - - return new EmbedDto(siteName, title, description, imageAscii, url); + return new EmbedDto(siteName, title, description, null, url); } private static List<string> ExtractUrls(string content) @@ -169,78 +158,26 @@ public partial class LinkEmbedService var tags = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); // Match: <meta property="og:key" content="value" /> + // Groups: 1=prop quote, 2=key, 3=content quote, 4=value foreach (Match match in OgTagRegex().Matches(html)) { - var key = match.Groups[1].Value; - var value = match.Groups[2].Value; + var key = match.Groups[2].Value; + var value = match.Groups[4].Value; tags.TryAdd(key, value); } // Match reversed order: <meta content="value" property="og:key" /> + // Groups: 1=content quote, 2=value, 3=prop quote, 4=key foreach (Match match in OgTagReversedRegex().Matches(html)) { - var value = match.Groups[1].Value; - var key = match.Groups[2].Value; + var value = match.Groups[2].Value; + var key = match.Groups[4].Value; tags.TryAdd(key, value); } return tags; } - private async Task<string?> FetchImageThumbnailAsync(string imageUrl, Uri pageUri, CancellationToken ct) - { - try - { - // Resolve relative image URLs against the page URI - if (!Uri.TryCreate(imageUrl, UriKind.Absolute, out var imageUri)) - { - if (!Uri.TryCreate(pageUri, imageUrl, out imageUri)) - return null; - } - - if (imageUri.Scheme is not ("http" or "https")) - return null; - - if (IsPrivateHost(imageUri)) - return null; - - var client = _httpClientFactory.CreateClient("OgFetch"); - using var response = await client.GetAsync(imageUri, ct); - - if (!response.IsSuccessStatusCode) - return null; - - var contentType = response.Content.Headers.ContentType?.MediaType; - if (contentType is null || !contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) - return null; - - await using var stream = await response.Content.ReadAsStreamAsync(ct); - - // Buffer into a MemoryStream for validation + conversion - using var memoryStream = new MemoryStream(); - await stream.CopyToAsync(memoryStream, ct); - - if (memoryStream.Length == 0 || memoryStream.Length > HubConstants.MaxFileSizeBytes) - return null; - - memoryStream.Position = 0; - - if (!FileValidationHelper.IsValidImage(memoryStream)) - return null; - - memoryStream.Position = 0; - - return _asciiService.ConvertToAscii(memoryStream, - HubConstants.EmbedIconWidth, - HubConstants.EmbedIconHeight); - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Failed to fetch OG image thumbnail from {Url}", imageUrl); - return null; - } - } - private static async Task<string> ReadLimitedAsync(HttpResponseMessage response, int maxBytes, CancellationToken ct) { await using var stream = await response.Content.ReadAsStreamAsync(ct); @@ -263,17 +200,17 @@ public partial class LinkEmbedService return encoding.GetString(buffer, 0, totalRead); } - [GeneratedRegex(@"https?://[^\s<>""')\]]+", RegexOptions.IgnoreCase)] + [GeneratedRegex(@"https?://[^\s<>""')\]]+", RegexOptions.IgnoreCase | RegexOptions.Compiled)] private static partial Regex UrlRegex(); - [GeneratedRegex(@"<meta\s+[^>]*?property\s*=\s*[""']og:(\w+)[""'][^>]*?content\s*=\s*[""']([^""']*)[""'][^>]*/?>", - RegexOptions.IgnoreCase | RegexOptions.Singleline)] + [GeneratedRegex(@"<meta\s+[^>]*?property\s*=\s*([""'])og:(\w+)\1[^>]*?content\s*=\s*([""'])(.*?)\3[^>]*/?>", + RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled)] private static partial Regex OgTagRegex(); - [GeneratedRegex(@"<meta\s+[^>]*?content\s*=\s*[""']([^""']*)[""'][^>]*?property\s*=\s*[""']og:(\w+)[""'][^>]*/?>", - RegexOptions.IgnoreCase | RegexOptions.Singleline)] + [GeneratedRegex(@"<meta\s+[^>]*?content\s*=\s*([""'])(.*?)\1[^>]*?property\s*=\s*([""'])og:(\w+)\3[^>]*/?>", + RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled)] private static partial Regex OgTagReversedRegex(); - [GeneratedRegex(@"<title[^>]*>([^<]+)", RegexOptions.IgnoreCase)] + [GeneratedRegex(@"]*>([^<]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled)] private static partial Regex TitleTagRegex(); } From 8f46ea0f2c12388c6d4153a3182a7b34cb12164a Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 19 Feb 2026 22:45:47 +0100 Subject: [PATCH 20/22] Dotnet format --- src/EchoHub.Client/Services/ApiClient.cs | 4 ++-- .../Data/Migrations/20260219162414_AddModerationRoles.cs | 2 +- .../Data/Migrations/20260219172834_AddChannelIsPublic.cs | 2 +- .../Data/Migrations/20260219181720_AddChannelMembership.cs | 2 +- .../Data/Migrations/20260219201704_AddMessageEmbed.cs | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/EchoHub.Client/Services/ApiClient.cs b/src/EchoHub.Client/Services/ApiClient.cs index 8c6acd9..d250e2b 100644 --- a/src/EchoHub.Client/Services/ApiClient.cs +++ b/src/EchoHub.Client/Services/ApiClient.cs @@ -244,7 +244,7 @@ public sealed class ApiClient : IDisposable { EnsureAuthenticated(); var response = await AuthenticatedRequestAsync(() => - _http.PostAsJsonAsync($"/api/moderation/unban/{Uri.EscapeDataString(username)}", new {})); + _http.PostAsJsonAsync($"/api/moderation/unban/{Uri.EscapeDataString(username)}", new { })); await EnsureSuccessAsync(response); } @@ -260,7 +260,7 @@ public sealed class ApiClient : IDisposable { EnsureAuthenticated(); var response = await AuthenticatedRequestAsync(() => - _http.PostAsJsonAsync($"/api/moderation/unmute/{Uri.EscapeDataString(username)}", new {})); + _http.PostAsJsonAsync($"/api/moderation/unmute/{Uri.EscapeDataString(username)}", new { })); await EnsureSuccessAsync(response); } diff --git a/src/EchoHub.Server/Data/Migrations/20260219162414_AddModerationRoles.cs b/src/EchoHub.Server/Data/Migrations/20260219162414_AddModerationRoles.cs index edca4e2..d461fc4 100644 --- a/src/EchoHub.Server/Data/Migrations/20260219162414_AddModerationRoles.cs +++ b/src/EchoHub.Server/Data/Migrations/20260219162414_AddModerationRoles.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/EchoHub.Server/Data/Migrations/20260219172834_AddChannelIsPublic.cs b/src/EchoHub.Server/Data/Migrations/20260219172834_AddChannelIsPublic.cs index a105dd8..52ca48e 100644 --- a/src/EchoHub.Server/Data/Migrations/20260219172834_AddChannelIsPublic.cs +++ b/src/EchoHub.Server/Data/Migrations/20260219172834_AddChannelIsPublic.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/EchoHub.Server/Data/Migrations/20260219181720_AddChannelMembership.cs b/src/EchoHub.Server/Data/Migrations/20260219181720_AddChannelMembership.cs index d8f0c8a..e5aac36 100644 --- a/src/EchoHub.Server/Data/Migrations/20260219181720_AddChannelMembership.cs +++ b/src/EchoHub.Server/Data/Migrations/20260219181720_AddChannelMembership.cs @@ -1,4 +1,4 @@ -using System; +using System; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/EchoHub.Server/Data/Migrations/20260219201704_AddMessageEmbed.cs b/src/EchoHub.Server/Data/Migrations/20260219201704_AddMessageEmbed.cs index ede5f16..481ca85 100644 --- a/src/EchoHub.Server/Data/Migrations/20260219201704_AddMessageEmbed.cs +++ b/src/EchoHub.Server/Data/Migrations/20260219201704_AddMessageEmbed.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable From c90ed271949fdb966e315a37c7e5eff8d597e36b Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 19 Feb 2026 22:46:49 +0100 Subject: [PATCH 21/22] chore: update changelog for v0.2.3 with new features and improvements --- docs/changelog/v0.2.3.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/changelog/v0.2.3.md b/docs/changelog/v0.2.3.md index 9170622..bd305a7 100644 --- a/docs/changelog/v0.2.3.md +++ b/docs/changelog/v0.2.3.md @@ -3,6 +3,7 @@ ## Features ### Moderation System + - Added server roles: Owner, Admin, Mod, Member — first registered user is automatically Owner - New `/kick`, `/ban`, `/unban`, `/mute`, `/unmute`, `/role`, `/nuke` commands for moderators and admins - `ModerationController` with full REST API for role assignment, kicks, bans, mutes, message deletion, and channel nuking @@ -12,6 +13,7 @@ - Works for both SignalR and IRC connections; client shows an error dialog with the reason ### Private Channels + - Channels can be created as public or private via a checkbox in the Create Channel dialog - Public channels are visible to all users; private channels only appear for members who joined them - Persistent channel membership tracked in the database (`ChannelMembership` table) @@ -19,6 +21,7 @@ - Channel creators are automatically added as members ### OpenGraph Link Embeds + - Messages containing URLs now show a rich preview below the message text - Multiple URLs per message supported (up to 3) — each gets its own embed - Server-side fetching: detects URLs in a message, fetches each page, and parses OpenGraph meta tags (`og:title`, `og:description`, `og:site_name`) @@ -30,26 +33,31 @@ - SSRF protection rejects private/loopback IP addresses before fetching ### Notification Sounds + - Incoming messages play a notification sound when the terminal is not focused - Embedded MP3 asset with cross-platform playback support ### Online Users Panel + - Collapsible right-side panel showing online users in the current channel (toggle with F2) - Users displayed with status indicators, role badges, and their custom nickname colors - Panel updates on join, leave, and status change events ### @mention Highlighting + - `@username` text rendered in orange accent color in all messages - Messages mentioning the current user get a full-line amber background highlight - Works across multi-line messages and continuation lines ### ASCII Art Improvements + - Half-block character rendering (`▀`/`█`) with separate foreground + background colors for 2x vertical resolution - Switched from ANSI escape codes to printable color tags (`{F:RRGGBB}`, `{B:RRGGBB}`, `{X}`) — no control bytes in stored content - Optional size parameter for `/send` command: `-s` (40x40), `-m` (80x80, default), `-l` (120x120) - IRC gateway converts color tags back to ANSI for IRC client compatibility ### Client UI + - Version number shown in the status bar - Custom colored rendering for channel list (active indicator, unread count badges) - Avatar upload field added to the profile edit dialog (file path or URL) From 981875c4955a36f4b0db9bc238ccd6af62359c1a Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 19 Feb 2026 22:48:00 +0100 Subject: [PATCH 22/22] fix: correct code block syntax for notification sound file path --- docs/articles/notification-sounds.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/articles/notification-sounds.md b/docs/articles/notification-sounds.md index 03f9767..9315f09 100644 --- a/docs/articles/notification-sounds.md +++ b/docs/articles/notification-sounds.md @@ -10,7 +10,7 @@ Open your profile (`/profile`) and check the **"Notification sound on @mention"* The client ships with a default `Notification.mp3` in the `Assets` folder. To use your own notification sound, replace the file at: -``` +```text /Assets/Notification.mp3 ```