From b4c3ebd254c05a5b9220ccdb88e2b3fe76070af1 Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 19 Feb 2026 17:47:10 +0100 Subject: [PATCH] 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-"))