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..9315f09 --- /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: + +```text +/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 a15eed2..647908c 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, 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/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..bd305a7 --- /dev/null +++ b/docs/changelog/v0.2.3.md @@ -0,0 +1,95 @@ +# v0.2.3 - Moderation, Embeds & 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 +- 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 +- 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 + +### 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`) +- 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 `` tag when no OG tags are present; gracefully skips if no useful metadata is found +- 5-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 +- 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) +- 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 +- 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 +- 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 +- 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 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 +- 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/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 @@ <Project> <PropertyGroup> - <Version>0.2.2</Version> + <Version>0.2.3</Version> <GenerateDocumentationFile>true</GenerateDocumentationFile> <NoWarn>$(NoWarn);CS1591</NoWarn> </PropertyGroup> diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs index 29e7d62..1c9f65f 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(); @@ -76,6 +78,7 @@ public sealed class AppOrchestrator : IDisposable _mainWindow.OnThemeSelected += HandleThemeSelected; _mainWindow.OnSavedServersRequested += HandleSavedServersRequested; _mainWindow.OnCreateChannelRequested += HandleCreateChannelRequested; + _mainWindow.OnDeleteChannelRequested += HandleDeleteChannelRequested; } // ── Command Handler Wiring ───────────────────────────────────────────── @@ -116,7 +119,7 @@ public sealed class AppOrchestrator : IDisposable return Task.CompletedTask; }; - _commandHandler.OnSendFile += async (target) => + _commandHandler.OnSendFile += async (target, size) => { if (!IsAuthenticated || !IsConnected) return; @@ -128,13 +131,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) @@ -211,6 +214,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); @@ -292,6 +297,61 @@ 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.OnTestSound += async () => + { + await _notificationSound.PlayTestAsync(); + }; + _commandHandler.OnQuit += () => { InvokeUI(() => _app.RequestStop()); @@ -361,7 +421,20 @@ public sealed class AppOrchestrator : IDisposable // History might not be available } + 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"); } @@ -440,6 +513,8 @@ public sealed class AppOrchestrator : IDisposable { // History might not be available } + + FetchAndUpdateOnlineUsers(); }, "Failed to join channel"); } @@ -504,7 +579,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; @@ -526,6 +603,57 @@ 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}")); + } + } + + 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, @@ -599,17 +727,16 @@ 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 - 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); @@ -617,18 +744,74 @@ 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); + + InvokeUI(() => + { + _mainWindow.RemoveChannel(channel); + _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) { connection.OnMessageReceived += message => + { InvokeUI(() => _mainWindow.AddMessage(message)); + if (!string.IsNullOrEmpty(_currentUsername) + && message.Content.Contains($"@{_currentUsername}", StringComparison.OrdinalIgnoreCase)) + { + _ = _notificationSound.PlayAsync(); + } + }; + 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 +825,67 @@ 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}"); + }); + }; + + connection.OnUserBanned += (username, reason) => + { + var reasonText = reason is not null ? $" ({reason})" : ""; + InvokeUI(() => + { + // Show ban notification for other users in the channel + if (!username.Equals(_currentUsername, StringComparison.OrdinalIgnoreCase)) + { + 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(() => + { + _mainWindow.RemoveMessage(channelName, messageId); + }); + }; + + connection.OnChannelNuked += channelName => + { + InvokeUI(() => + { + _mainWindow.ClearChannelMessages(channelName); + _mainWindow.AddSystemMessage(channelName, "Channel history has been cleared by a moderator."); + }); + }; + + connection.OnChannelUpdated += channel => + { + InvokeUI(() => + { + if (channel.IsPublic) + _mainWindow.EnsureChannelInList(channel.Name); + _mainWindow.SetChannelTopic(channel.Name, channel.Topic); + }); }; connection.OnError += errorMessage => @@ -673,6 +917,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/Assets/Notification.mp3 b/src/EchoHub.Client/Assets/Notification.mp3 new file mode 100644 index 0000000..dcf0292 Binary files /dev/null and b/src/EchoHub.Client/Assets/Notification.mp3 differ diff --git a/src/EchoHub.Client/Commands/CommandHandler.cs b/src/EchoHub.Client/Commands/CommandHandler.cs index f552fec..b2304dd 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<string, Task>? OnSetNick; public event Func<string, Task>? OnSetColor; public event Func<string, Task>? OnSetTheme; - public event Func<string, Task>? OnSendFile; + public event Func<string, string?, Task>? OnSendFile; public event Func<string?, Task>? OnOpenProfile; public event Func<Task>? OnOpenServers; public event Func<string, Task>? OnJoinChannel; @@ -18,6 +18,14 @@ public class CommandHandler public event Func<string, Task>? OnSetTopic; public event Func<Task>? OnListUsers; public event Func<string, Task>? OnSetAvatar; + public event Func<string, string?, Task>? OnKickUser; + public event Func<string, string?, Task>? OnBanUser; + public event Func<string, Task>? OnUnbanUser; + public event Func<string, int?, Task>? OnMuteUser; + 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; @@ -46,6 +54,14 @@ 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(), + "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), @@ -120,15 +136,19 @@ public class CommandHandler private async Task<CommandResult> HandleSend(string args) { if (string.IsNullOrWhiteSpace(args)) - return new CommandResult(true, "Usage: /send <filepath or URL>", IsError: true); + return new CommandResult(true, "Usage: /send <filepath or URL> [-s|-m|-l]", IsError: true); - var target = args.Trim().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 <filepath or URL> [-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"; @@ -139,7 +159,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)}..."); } @@ -156,7 +176,7 @@ public class CommandHandler if (string.IsNullOrWhiteSpace(args)) return new CommandResult(true, "Usage: /avatar <URL or filepath>", IsError: true); - var target = args.Trim().Trim('"'); + var target = StripQuotes(args.Trim()); if (OnSetAvatar is not null) await OnSetAvatar(target); @@ -212,6 +232,102 @@ public class CommandHandler return new CommandResult(true); } + private async Task<CommandResult> HandleKick(string args) + { + if (string.IsNullOrWhiteSpace(args)) + return new CommandResult(true, "Usage: /kick <username> [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<CommandResult> HandleBan(string args) + { + if (string.IsNullOrWhiteSpace(args)) + return new CommandResult(true, "Usage: /ban <username> [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<CommandResult> HandleUnban(string args) + { + if (string.IsNullOrWhiteSpace(args)) + return new CommandResult(true, "Usage: /unban <username>", IsError: true); + + if (OnUnbanUser is not null) + await OnUnbanUser(args.Trim()); + return new CommandResult(true, $"Unbanning {args.Trim()}..."); + } + + private async Task<CommandResult> HandleMute(string args) + { + if (string.IsNullOrWhiteSpace(args)) + return new CommandResult(true, "Usage: /mute <username> [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<CommandResult> HandleUnmute(string args) + { + if (string.IsNullOrWhiteSpace(args)) + return new CommandResult(true, "Usage: /unmute <username>", IsError: true); + + if (OnUnmuteUser is not null) + await OnUnmuteUser(args.Trim()); + return new CommandResult(true, $"Unmuting {args.Trim()}..."); + } + + private async Task<CommandResult> HandleRole(string args) + { + if (string.IsNullOrWhiteSpace(args)) + return new CommandResult(true, "Usage: /role <username> <admin|mod|member>", IsError: true); + + var parts = args.Split(' ', 2, StringSplitOptions.TrimEntries); + if (parts.Length < 2) + return new CommandResult(true, "Usage: /role <username> <admin|mod|member>", 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<CommandResult> HandleNuke() + { + if (OnNukeChannel is not null) + await OnNukeChannel(); + 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) @@ -223,18 +339,72 @@ public class CommandHandler /nick <name> - Set display name /color <#hex> - Set nickname color /theme <name> - Switch theme - /send <filepath or URL> - Send a file or image + /send <filepath or URL> [-s|-m|-l] - Send a file or image (size: small/medium/large) /avatar <URL or filepath> - Set your avatar - /profile [username] - View a profile (yours if no name given) + /profile [username] - View a profile /servers - Open saved servers /join <channel> - Join a channel /leave - Leave current channel /topic <text> - Set channel topic /users - List online users + Moderation: + /kick <user> [reason] - Kick a user (Mod+) + /ban <user> [reason] - Ban a user (Admin+) + /unban <user> - Unban a user (Admin+) + /mute <user> [minutes] - Mute a user (Mod+) + /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 """); } + /// <summary> + /// Extract a file path (possibly quoted) and an optional size flag (-s, -m, -l). + /// The flag can appear before or after the path. + /// </summary> + 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); + } + + /// <summary> + /// Remove matching surrounding quotes (double or single) from a string. + /// </summary> + 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/Config/ClientConfig.cs b/src/EchoHub.Client/Config/ClientConfig.cs index 8fcfe07..80052a7 100644 --- a/src/EchoHub.Client/Config/ClientConfig.cs +++ b/src/EchoHub.Client/Config/ClientConfig.cs @@ -5,6 +5,14 @@ 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 byte Volume { get; set; } = 30; + 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/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 adeed96..d250e2b 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; @@ -159,7 +160,7 @@ public sealed class ApiClient : IDisposable return result?.AvatarAscii; } - public async Task<MessageDto?> UploadFileAsync(string channelName, Stream fileStream, string fileName) + public async Task<MessageDto?> UploadFileAsync(string channelName, Stream fileStream, string fileName, string? size = null) { EnsureAuthenticated(); using var content = new MultipartFormDataContent(); @@ -167,26 +168,28 @@ 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<MessageDto>(); } - public async Task<MessageDto?> SendUrlAsync(string channelName, string url) + public async Task<MessageDto?> 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<MessageDto>(); } - public async Task<ChannelDto?> CreateChannelAsync(string name, string? topic = null) + public async Task<ChannelDto?> 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); @@ -211,6 +214,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..3ee18fd 100644 --- a/src/EchoHub.Client/Services/EchoHubConnection.cs +++ b/src/EchoHub.Client/Services/EchoHubConnection.cs @@ -14,6 +14,11 @@ public sealed class EchoHubConnection : IAsyncDisposable public event Action<string, string>? OnUserLeft; public event Action<ChannelDto>? OnChannelUpdated; public event Action<UserPresenceDto>? OnUserStatusChanged; + public event Action<string, string, string?>? OnUserKicked; + 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; @@ -81,6 +86,31 @@ public sealed class EchoHubConnection : IAsyncDisposable OnUserStatusChanged?.Invoke(presence); }); + _connection.On<string, string, string?>(nameof(Core.Contracts.IEchoHubClient.UserKicked), (channelName, username, reason) => + { + OnUserKicked?.Invoke(channelName, username, reason); + }); + + _connection.On<string, string?>(nameof(Core.Contracts.IEchoHubClient.UserBanned), (username, reason) => + { + OnUserBanned?.Invoke(username, reason); + }); + + _connection.On<string, Guid>(nameof(Core.Contracts.IEchoHubClient.MessageDeleted), (channelName, messageId) => + { + OnMessageDeleted?.Invoke(channelName, messageId); + }); + + _connection.On<string>(nameof(Core.Contracts.IEchoHubClient.ChannelNuked), channelName => + { + 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/Services/NotificationSoundService.cs b/src/EchoHub.Client/Services/NotificationSoundService.cs new file mode 100644 index 0000000..2118d40 --- /dev/null +++ b/src/EchoHub.Client/Services/NotificationSoundService.cs @@ -0,0 +1,85 @@ +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 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) + 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!); + } + 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/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"); + + /// <summary> + /// Checks GitHub for a newer release. Returns the new version string if one exists, or null. + /// Never throws — all errors are silently swallowed. + /// </summary> + public static async Task<string?> CheckForUpdateAsync() + { + try + { + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; + http.DefaultRequestHeaders.UserAgent.ParseAdd("EchoHub-Client"); + + var release = await http.GetFromJsonAsync<GitHubRelease>(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/ChatRenderer.cs b/src/EchoHub.Client/UI/ChatRenderer.cs index 919913d..d4727fc 100644 --- a/src/EchoHub.Client/UI/ChatRenderer.cs +++ b/src/EchoHub.Client/UI/ChatRenderer.cs @@ -1,8 +1,8 @@ using System.Collections; 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; @@ -20,17 +20,19 @@ public partial class ChatLine { public List<ChatSegment> Segments { get; } public int TextLength { get; } + public Guid? MessageId { get; set; } + public bool IsMention { get; set; } public ChatLine(string plainText) { Segments = [new ChatSegment(plainText, null)]; - TextLength = plainText.Length; + TextLength = plainText.GetColumns(); } public ChatLine(List<ChatSegment> segments) { Segments = segments; - TextLength = segments.Sum(s => s.Text.Length); + TextLength = segments.Sum(s => s.Text.GetColumns()); } public override string ToString() => string.Concat(Segments.Select(s => s.Text)); @@ -50,17 +52,22 @@ public partial class ChatLine foreach (var segment in Segments) { - int segPos = 0; - while (segPos < segment.Text.Length) + var text = segment.Text; + int chunkStart = 0; + int charPos = 0; + + foreach (var grapheme in GraphemeHelper.GetGraphemes(text)) { - int remaining = width - col; - if (remaining <= 0) + var graphemeCols = Math.Max(grapheme.GetColumns(), 1); + + if (col + graphemeCols > width) { - // Emit current line and start a new one + if (charPos > chunkStart) + currentSegments.Add(new ChatSegment(text[chunkStart..charPos], segment.Color)); + results.Add(new ChatLine(currentSegments)); currentSegments = []; - // Add indent for continuation if (continuationIndent > 0) { currentSegments.Add(new ChatSegment(new string(' ', continuationIndent), null)); @@ -71,14 +78,15 @@ 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 += graphemeCols; + charPos += grapheme.Length; } + + if (chunkStart < text.Length) + currentSegments.Add(new ChatSegment(text[chunkStart..], segment.Color)); } if (currentSegments.Count > 0) @@ -88,62 +96,83 @@ public partial class ChatLine } /// <summary> - /// 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) + /// Returns true if a line contains printable color tags. /// </summary> - public static ChatLine FromAnsi(string ansiText, Attribute? defaultAttr = null) + 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). + /// </summary> + public static ChatLine FromColoredText(string text, Attribute? defaultAttr = null) { var segments = new List<ChatSegment>(); - 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; - foreach (Match match in regex.Matches(ansiText)) + 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 ColorTagRegex().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, currentColor)); + 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 (match.Groups[1].Success) { - // 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); + 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; } - // Add remaining text - if (lastIndex < ansiText.Length) + if (lastIndex < text.Length) { - var text = ansiText[lastIndex..]; - if (text.Length > 0) - segments.Add(new ChatSegment(text, currentColor)); + 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) 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")] - private static partial Regex AnsiColorRegex(); + [GeneratedRegex(@"\{(?:(X)|(?:(F|B):([0-9A-Fa-f]{6})))\}")] + private static partial Regex ColorTagRegex(); } /// <summary> -/// Custom list data source for chat messages with per-character coloring. +/// Custom list data source for chat messages with per-segment coloring. /// </summary> public class ChatListSource : IListDataSource { @@ -197,7 +226,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); + var normalAttr = listView.GetAttributeForRole(VisualRole.Normal); + var mentionBg = chatLine.IsMention ? ChatColors.MentionHighlightAttr.Background : (Color?)null; int charPos = 0; int drawnChars = 0; @@ -205,26 +235,26 @@ public class ChatListSource : IListDataSource foreach (var segment in chatLine.Segments) { var attr = segment.Color ?? normalAttr; + if (mentionBg.HasValue) + attr = new Attribute(attr.Foreground, mentionBg.Value); listView.SetAttribute(attr); - foreach (var ch in segment.Text) + foreach (var grapheme in GraphemeHelper.GetGraphemes(segment.Text)) { - if (charPos >= viewportX && drawnChars < width) + var cols = Math.Max(grapheme.GetColumns(), 1); + if (charPos >= viewportX && drawnChars + cols <= width) { - listView.AddRune(new Rune(ch)); - drawnChars++; + listView.AddStr(grapheme); + drawnChars += cols; } - charPos++; + charPos += cols; } } - // Fill remaining width with spaces using default colors - listView.SetAttribute(normalAttr); - while (drawnChars < width) - { - listView.AddRune(new Rune(' ')); - drawnChars++; - } + var fillAttr = mentionBg.HasValue ? new Attribute(normalAttr.Foreground, mentionBg.Value) : normalAttr; + listView.SetAttribute(fillAttr); + for (int i = drawnChars; i < width; i++) + listView.AddStr(" "); } private void UpdateMaxLength(ChatLine line) @@ -242,13 +272,226 @@ public class ChatListSource : IListDataSource public void Dispose() { } } +/// <summary> +/// Custom list data source for colored channel list rendering. +/// Active channel gets a > indicator, unread channels are bright with a count badge. +/// </summary> +public class ChannelListSource : IListDataSource +{ + private readonly List<string> _channelNames = []; + private readonly Dictionary<string, int> _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<string> channels, Dictionary<string, int> 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; + + if (selected) + { + listView.SetAttribute(focusAttr); + drawnChars = RenderHelpers.WriteText(listView, prefix + channelText + badge, drawnChars, width); + } + else + { + listView.SetAttribute(isActive ? ActiveAttr : NormalAttr); + drawnChars = RenderHelpers.WriteText(listView, prefix, drawnChars, width); + + listView.SetAttribute(isActive ? ActiveAttr : hasUnread ? UnreadAttr : NormalAttr); + drawnChars = RenderHelpers.WriteText(listView, channelText, drawnChars, width); + + if (hasUnread) + { + listView.SetAttribute(BadgeAttr); + drawnChars = RenderHelpers.WriteText(listView, badge, drawnChars, width); + } + } + + var fillAttr = selected ? focusAttr : listView.GetAttributeForRole(VisualRole.Normal); + listView.SetAttribute(fillAttr); + for (int i = drawnChars; i < width; i++) + listView.AddStr(" "); + } + + public void Dispose() { } +} + +/// <summary> +/// Custom list data source for the online users panel with per-user nickname colors. +/// </summary> +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.GetColumns()) : 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" + var graphemes = GraphemeHelper.GetGraphemes(text).ToList(); + int nameStart = 0; + 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 + listView.SetAttribute(normalAttr); + for (int i = 0; i < nameStart; i++) + { + 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 = selected ? normalAttr : nameColor ?? normalAttr; + listView.SetAttribute(userAttr); + for (int i = nameStart; i < graphemes.Count; i++) + { + var cols = Math.Max(graphemes[i].GetColumns(), 1); + if (drawnChars + cols > width) break; + listView.AddStr(graphemes[i]); + drawnChars += cols; + } + + // Fill rest + listView.SetAttribute(normalAttr); + for (int i = drawnChars; i < width; i++) + listView.AddStr(" "); + } + + public void Dispose() { } +} + +/// <summary> +/// Shared rendering helpers for IListDataSource implementations. +/// </summary> +static class RenderHelpers +{ + /// <summary> + /// Write text grapheme-by-grapheme to a ListView, respecting a width limit. + /// Returns the updated drawn-columns count. + /// </summary> + 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; + } +} + /// <summary> /// Shared color attributes for chat rendering (timestamps, system messages). /// </summary> -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); + 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. + /// Non-mention text uses the provided default color. + /// </summary> + public static List<ChatSegment> SplitMentions(string text, Attribute? defaultColor = null) + { + var segments = new List<ChatSegment>(); + 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; + } + + [GeneratedRegex(@"@[\w-]+")] + private static partial Regex MentionRegex(); } /// <summary> 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/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 77a953e..804ef21 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -1,10 +1,12 @@ -using System.Collections.ObjectModel; +using System.Text.RegularExpressions; using EchoHub.Client.Themes; 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; @@ -21,14 +23,26 @@ 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 readonly UserListSource _usersListSource; + private bool _usersPanelVisible = true; + private const int UsersPanelWidth = 22; + private static readonly Key F2Key = Key.F2; + + 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) 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 @@ -36,13 +50,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", "/test-sound", "/quit", "/help" ]; private readonly List<string> _channelNames = []; private readonly Dictionary<string, List<ChatLine>> _channelMessages = []; private readonly Dictionary<string, int> _channelUnread = []; private readonly Dictionary<string, string?> _channelTopics = []; + private readonly ChannelListSource _channelListSource; private string _currentChannel = string.Empty; private string _currentUser = string.Empty; private int _lastChatWidth; @@ -92,6 +108,11 @@ public sealed class MainWindow : Runnable /// </summary> public event Action? OnCreateChannelRequested; + /// <summary> + /// Fired when the user requests to delete the current channel. + /// </summary> + public event Action? OnDeleteChannelRequested; + public MainWindow(IApplication app) { _app = app; @@ -107,7 +128,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 +139,8 @@ public sealed class MainWindow : Runnable Width = Dim.Fill(), Height = Dim.Fill() }; - _channelList.SetSource(new ObservableCollection<string>(_channelNames)); + _channelListSource = new ChannelListSource(); + _channelList.Source = _channelListSource; _channelList.ValueChanged += OnChannelListSelectionChanged; channelsFrame.Add(_channelList); Add(channelsFrame); @@ -127,9 +149,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 +161,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 +179,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 +197,31 @@ public sealed class MainWindow : Runnable WordWrap = true }; _inputField.KeyDown += OnInputKeyDown; - inputFrame.Add(_inputField); - Add(inputFrame); + _inputField.ContentsChanged += OnInputContentsChanged; + _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() + }; + _usersListSource = new UserListSource(); + _usersList.Source = _usersListSource; + _usersFrame.Add(_usersList); + Add(_usersFrame); // Status bar at the very bottom _statusLabel = new Label @@ -198,7 +243,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 Alt+Q (quit), F2 (toggle users panel) KeyDown += OnWindowKeyDown; } @@ -265,7 +310,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) ]); @@ -333,13 +382,38 @@ 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; } } + 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> @@ -386,12 +460,16 @@ public sealed class MainWindow : Runnable private void OnWindowKeyDown(object? sender, Key e) { - // Ctrl+C quits from anywhere - if (e.KeyCode == CtrlCKey.KeyCode) + if (e.KeyCode == AltQKey.KeyCode) { _app.RequestStop(); e.Handled = true; } + else if (e.KeyCode == F2Key.KeyCode) + { + ToggleUsersPanel(); + e.Handled = true; + } } /// <summary> @@ -430,19 +508,33 @@ public sealed class MainWindow : Runnable /// </summary> public void AddSystemMessage(string channelName, string text) { - var time = DateTimeOffset.Now.ToString("HH:mm"); - var segments = new List<ChatSegment> - { - 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) { @@ -475,6 +567,32 @@ public sealed class MainWindow : Runnable } } + /// <summary> + /// Remove all lines associated with a specific message ID. + /// </summary> + public void RemoveMessage(string channelName, Guid messageId) + { + if (_channelMessages.TryGetValue(channelName, out var messages)) + { + messages.RemoveAll(l => l.MessageId == messageId); + if (channelName == _currentChannel) + RefreshMessages(); + } + } + + /// <summary> + /// Clear all messages from a specific channel. + /// </summary> + public void ClearChannelMessages(string channelName) + { + if (_channelMessages.TryGetValue(channelName, out var messages)) + { + messages.Clear(); + if (channelName == _currentChannel) + RefreshMessages(); + } + } + /// <summary> /// Set the list of available channels, storing topics, and refresh the channel list view. /// </summary> @@ -492,6 +610,30 @@ public sealed class MainWindow : Runnable RefreshChannelList(); } + /// <summary> + /// Ensure a channel exists in the left panel list (used for private channels joined via /join). + /// </summary> + public void EnsureChannelInList(string channelName) + { + if (_channelNames.Contains(channelName)) + return; + + _channelNames.Add(channelName); + if (!_channelMessages.ContainsKey(channelName)) + _channelMessages[channelName] = []; + RefreshChannelList(); + } + + /// <summary> + /// Remove a channel from the left panel list. + /// </summary> + public void RemoveChannel(string channelName) + { + _channelNames.Remove(channelName); + _channelTopics.Remove(channelName); + RefreshChannelList(); + } + /// <summary> /// Update the topic for a specific channel. /// </summary> @@ -515,9 +657,9 @@ public sealed class MainWindow : Runnable /// </summary> 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}"; } /// <summary> @@ -585,10 +727,14 @@ public sealed class MainWindow : Runnable _channelTopics.Clear(); _currentChannel = string.Empty; _currentUser = string.Empty; - _channelList.SetSource(new ObservableCollection<string>(_channelNames)); + _channelListSource.Update([], [], string.Empty); + _channelList.Source = _channelListSource; _chatFrame.Title = "Chat"; _topicLabel.Visible = false; _chatFrame.Y = 1; + _usersListSource.Update([]); + _usersList.Source = _usersListSource; + _usersFrame.Title = "Users"; RefreshMessages(); } @@ -640,13 +786,8 @@ public sealed class MainWindow : Runnable /// </summary> 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<string>(displayNames)); + _channelListSource.Update(_channelNames, _channelUnread, _currentChannel); + _channelList.Source = _channelListSource; // Restore selection to current channel var idx = _channelNames.IndexOf(_currentChannel); @@ -673,11 +814,66 @@ public sealed class MainWindow : Runnable } } + /// <summary> + /// Adjusts widths of chat, topic, and input frames based on users panel visibility. + /// </summary> + 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(); + } + + /// <summary> + /// Toggle the online users panel visibility (F2). + /// </summary> + public void ToggleUsersPanel() + { + _usersPanelVisible = !_usersPanelVisible; + UpdateLayout(); + } + + /// <summary> + /// Update the online users list display. + /// </summary> + public void UpdateOnlineUsers(List<UserPresenceDto> 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", // ❀ + _ => "" + }; + var text = $"{statusIcon} {roleTag}{name}"; + var nameColor = ColorHelper.ParseHexColor(u.NicknameColor); + return (text, nameColor); + }).ToList(); + + _usersListSource.Update(displayItems); + _usersList.Source = _usersListSource; + _usersFrame.Title = $"Users ({users.Count})"; + } + /// <summary> /// Format a message DTO into one or more display lines based on its MessageType. /// Timestamps are dimmed and sender names are colored. /// </summary> - private static List<ChatLine> FormatMessage(MessageDto message) + private List<ChatLine> FormatMessage(MessageDto message) { var time = message.SentAt.ToLocalTime().ToString("HH:mm"); var senderName = message.SenderUsername + ":"; @@ -694,10 +890,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}")); } @@ -712,18 +908,43 @@ 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(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))); + } + + // 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, chatWidth)); } 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; } @@ -740,4 +961,98 @@ public sealed class MainWindow : Runnable }; return new ChatLine(segments); } + + /// <summary> + /// Build a chat line with @mention highlighting in the suffix text. + /// </summary> + private static ChatLine BuildChatLineWithMentions(string time, string senderName, Attribute? senderColor, string suffix) + { + var segments = new List<ChatSegment> + { + new($"[{time}] ", ChatColors.TimestampAttr), + new(senderName, senderColor), + }; + segments.AddRange(ChatColors.SplitMentions(suffix)); + return new ChatLine(segments); + } + + /// <summary> + /// Format a link embed as indented chat lines with a left border bar, + /// 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, int chatWidth) + { + var lines = new List<ChatLine>(); + const string border = "\u258f "; // ▏ + space + const int borderCols = 2; + int indentCols = indent.GetColumns(); + int textWidth = chatWidth - indentCols - borderCols; + if (textWidth < 20) textWidth = 20; + + // Helper: create a bordered text line + void AddTextLine(string text, Attribute? color) + { + lines.Add(new ChatLine( + [ + new ChatSegment(indent, null), + new ChatSegment(border, ChatColors.EmbedBorderAttr), + new ChatSegment(text, color) + ])); + } + + // Site name + if (!string.IsNullOrWhiteSpace(embed.SiteName)) + AddTextLine(embed.SiteName, ChatColors.EmbedBorderAttr); + + // Title + if (!string.IsNullOrWhiteSpace(embed.Title)) + { + foreach (var wrapped in WordWrap(embed.Title, textWidth)) + AddTextLine(wrapped, ChatColors.EmbedTitleAttr); + } + + // Description (word-wrapped at full available width) + if (!string.IsNullOrWhiteSpace(embed.Description)) + { + foreach (var wrapped in WordWrap(embed.Description, textWidth)) + AddTextLine(wrapped, ChatColors.EmbedDescAttr); + } + + 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.Client/UI/ProfileEditDialog.cs b/src/EchoHub.Client/UI/ProfileEditDialog.cs index b932df3..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); +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 = 18 }; + var dialog = new Dialog { Title = "Edit Profile", Width = 60, Height = 26 }; // Display Name var nameLabel = new Label @@ -102,20 +102,99 @@ 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]; + } + }; + + // 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 = 10 + Y = 18 }; var cancelButton = new Button { Text = "Cancel", X = Pos.Center() + 5, - Y = 10 + Y = 18 }; saveButton.Accepting += (s, e) => @@ -123,8 +202,10 @@ 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); + 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(); }; @@ -137,7 +218,10 @@ public sealed class ProfileEditDialog }; dialog.Add(nameLabel, nameField, bioLabel, bioField, colorLabel, colorField, - colorHintLabel, previewLabel, colorPreview, saveButton, cancelButton); + colorHintLabel, previewLabel, colorPreview, + avatarLabel, avatarField, browseButton, avatarHintLabel, + notifCheckbox, volumeLabel, volumeField, volumeHintLabel, + saveButton, cancelButton); nameField.SetFocus(); app.Run(dialog); 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; } diff --git a/src/EchoHub.Core/Constants/HubConstants.cs b/src/EchoHub.Core/Constants/HubConstants.cs index 6efeba0..024d956 100644 --- a/src/EchoHub.Core/Constants/HubConstants.cs +++ b/src/EchoHub.Core/Constants/HubConstants.cs @@ -4,10 +4,19 @@ 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 + public const int MaxMessageNewlines = 30; + public const int MaxConsecutiveNewlines = 1; public const int AsciiArtWidth = 80; public const int AsciiArtHeight = 40; + public const int AsciiArtHeightHalfBlock = 80; + + // Link embed constants + public const int EmbedMaxDescriptionLength = 500; + public const int EmbedMaxHtmlBytes = 64 * 1024; // 64 KB + public const int EmbedFetchTimeoutSeconds = 5; + public const int EmbedMaxUrlsPerMessage = 3; } diff --git a/src/EchoHub.Core/Contracts/IChatBroadcaster.cs b/src/EchoHub.Core/Contracts/IChatBroadcaster.cs index 6e2a11c..c88bc81 100644 --- a/src/EchoHub.Core/Contracts/IChatBroadcaster.cs +++ b/src/EchoHub.Core/Contracts/IChatBroadcaster.cs @@ -9,5 +9,10 @@ public interface IChatBroadcaster Task SendUserLeftAsync(string channelName, string username); Task SendChannelUpdatedAsync(ChannelDto channel, string? channelName = null); Task SendUserStatusChangedAsync(List<string> 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); + Task ForceDisconnectUserAsync(List<string> connectionIds, string reason); } diff --git a/src/EchoHub.Core/Contracts/IEchoHubClient.cs b/src/EchoHub.Core/Contracts/IEchoHubClient.cs index fc33e43..242a66e 100644 --- a/src/EchoHub.Core/Contracts/IEchoHubClient.cs +++ b/src/EchoHub.Core/Contracts/IEchoHubClient.cs @@ -12,5 +12,10 @@ 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 ForceDisconnect(string reason); Task Error(string message); } diff --git a/src/EchoHub.Core/DTOs/ChatDtos.cs b/src/EchoHub.Core/DTOs/ChatDtos.cs index 25a84e3..be772b2 100644 --- a/src/EchoHub.Core/DTOs/ChatDtos.cs +++ b/src/EchoHub.Core/DTOs/ChatDtos.cs @@ -11,12 +11,14 @@ public record MessageDto( MessageType Type, string? AttachmentUrl, string? AttachmentFileName, - DateTimeOffset SentAt); + DateTimeOffset SentAt, + List<EmbedDto>? Embeds = null); public record ChannelDto( Guid Id, string Name, string? Topic, + bool IsPublic, int MessageCount, DateTimeOffset CreatedAt); @@ -30,8 +32,15 @@ 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); public record SendUrlRequest(string Url); + +public record EmbedDto( + string? SiteName, + string? Title, + string? Description, + string? ImageAscii, + string Url); 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/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.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.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.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..40b88a3 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; @@ -71,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.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<IrcClientConnection> GetAllConnections() + { + return _connections.Values.Where(c => c.IsAuthenticated); + } + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await Task.Yield(); diff --git a/src/EchoHub.Server.Irc/IrcMessageFormatter.cs b/src/EchoHub.Server.Irc/IrcMessageFormatter.cs index a161313..fb017c5 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; @@ -22,6 +23,13 @@ public static class IrcMessageFormatter case MessageType.Text: foreach (var chunk in SplitMessage(message.Content, MaxIrcLineContentBytes)) lines.Add($"{prefix} PRIVMSG {ircChannel} :{chunk}"); + + // 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: @@ -33,7 +41,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 +53,62 @@ public static class IrcMessageFormatter return lines; } + /// <summary> + /// Format a link embed as IRC PRIVMSG lines (text-only, no ASCII thumbnail). + /// </summary> + private static List<string> FormatEmbed(string prefix, string ircChannel, EmbedDto embed) + { + var lines = new List<string>(); + + var header = new List<string>(); + 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; + } + + /// <summary> + /// 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. + /// </summary> + 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(); + /// <summary> /// Split a message into chunks of approximately maxBytes (UTF-8), at word boundaries. /// </summary> 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..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<IActionResult> Register([FromBody] RegisterRequest request) { @@ -42,12 +43,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 +85,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..5db6ccf 100644 --- a/src/EchoHub.Server/Controllers/ChannelsController.cs +++ b/src/EchoHub.Server/Controllers/ChannelsController.cs @@ -37,15 +37,24 @@ public class ChannelsController : ControllerBase _httpClientFactory = httpClientFactory; _chatService = chatService; } + [HttpGet] public async Task<IActionResult> 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 total = await _db.Channels.CountAsync(); + // 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 _db.Channels + var channels = await query .OrderBy(c => c.Name) .Skip(offset) .Take(limit) @@ -53,6 +62,7 @@ public class ChannelsController : ControllerBase c.Id, c.Name, c.Topic, + c.IsPublic, c.Messages.Count, c.CreatedAt)) .ToListAsync(); @@ -83,14 +93,24 @@ public class ChannelsController : ControllerBase Id = Guid.NewGuid(), Name = channelName, Topic = request.Topic?.Trim(), + IsPublic = request.IsPublic, CreatedByUserId = Guid.Parse(userIdClaim), }; _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, 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 +138,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); @@ -141,8 +161,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(); @@ -152,7 +174,7 @@ public class ChannelsController : ControllerBase [HttpPost("{channel}/upload")] [EnableRateLimiting("upload")] - public async Task<IActionResult> Upload(string channel) + public async Task<IActionResult> Upload(string channel, [FromQuery] string? size = null) { var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); var usernameClaim = User.FindFirstValue("username"); @@ -188,8 +210,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 { @@ -233,7 +256,7 @@ public class ChannelsController : ControllerBase [HttpPost("{channel}/send-url")] [EnableRateLimiting("upload")] - public async Task<IActionResult> SendUrl(string channel, [FromBody] SendUrlRequest request) + public async Task<IActionResult> SendUrl(string channel, [FromBody] SendUrlRequest request, [FromQuery] string? size = null) { var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); var usernameClaim = User.FindFirstValue("username"); @@ -308,9 +331,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/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/ModerationController.cs b/src/EchoHub.Server/Controllers/ModerationController.cs new file mode 100644 index 0000000..b048d12 --- /dev/null +++ b/src/EchoHub.Server/Controllers/ModerationController.cs @@ -0,0 +1,266 @@ +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<IChatBroadcaster> _broadcasters; + + public ModerationController( + EchoHubDbContext db, + IChatService chatService, + PresenceTracker presenceTracker, + IEnumerable<IChatBroadcaster> broadcasters) + { + _db = db; + _chatService = chatService; + _presenceTracker = presenceTracker; + _broadcasters = broadcasters; + } + + [HttpPost("role")] + public async Task<IActionResult> 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<IActionResult> 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, 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." }); + } + + [HttpPost("ban/{username}")] + public async Task<IActionResult> 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(); + + // 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." }); + } + + [HttpPost("unban/{username}")] + public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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); + } + + /// <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) + { + try { await action(broadcaster); } + catch { /* logged by broadcaster */ } + } + } +} 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<IActionResult> GetInfo() { diff --git a/src/EchoHub.Server/Controllers/UsersController.cs b/src/EchoHub.Server/Controllers/UsersController.cs index 2d4f0c9..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<IActionResult> GetProfile(string username) { @@ -120,6 +121,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..4eff95c 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<Channel> Channels => Set<Channel>(); public DbSet<Message> Messages => Set<Message>(); public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>(); + public DbSet<ChannelMembership> ChannelMemberships => Set<ChannelMembership>(); protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { @@ -34,6 +35,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<int>(); }); modelBuilder.Entity<Channel>(entity => @@ -57,6 +59,24 @@ 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<ChannelMembership>(entity => + { + entity.HasKey(cm => new { cm.UserId, cm.ChannelId }); + entity.HasIndex(cm => cm.UserId); + entity.HasIndex(cm => cm.ChannelId); + + entity.HasOne<Channel>() + .WithMany() + .HasForeignKey(cm => cm.ChannelId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne<User>() + .WithMany() + .HasForeignKey(cm => cm.UserId) + .OnDelete(DeleteBehavior.Cascade); }); modelBuilder.Entity<RefreshToken>(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 @@ +// <auto-generated /> +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 + { + /// <inheritdoc /> + 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<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<long>("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property<Guid>("CreatedByUserId") + .HasColumnType("TEXT"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property<string>("Topic") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Message", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("AttachmentFileName") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<string>("AttachmentUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property<Guid>("ChannelId") + .HasColumnType("TEXT"); + + b.Property<string>("Content") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property<Guid>("SenderUserId") + .HasColumnType("TEXT"); + + b.Property<string>("SenderUsername") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property<long>("SentAt") + .HasColumnType("INTEGER"); + + b.Property<int>("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<long>("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property<long>("ExpiresAt") + .HasColumnType("INTEGER"); + + b.Property<long?>("RevokedAt") + .HasColumnType("INTEGER"); + + b.Property<string>("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash"); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.User", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("AvatarAscii") + .HasMaxLength(10000) + .HasColumnType("TEXT"); + + b.Property<string>("Bio") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property<long>("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property<string>("DisplayName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property<bool>("IsBanned") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsMuted") + .HasColumnType("INTEGER"); + + b.Property<long>("LastSeenAt") + .HasColumnType("INTEGER"); + + b.Property<long?>("MutedUntil") + .HasColumnType("INTEGER"); + + b.Property<string>("NicknameColor") + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property<string>("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<int>("Role") + .HasColumnType("INTEGER"); + + b.Property<int>("Status") + .HasColumnType("INTEGER"); + + b.Property<string>("StatusMessage") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property<string>("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..d461fc4 --- /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 +{ + /// <inheritdoc /> + public partial class AddModerationRoles : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn<bool>( + name: "IsBanned", + table: "Users", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn<bool>( + name: "IsMuted", + table: "Users", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn<long>( + name: "MutedUntil", + table: "Users", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn<int>( + name: "Role", + table: "Users", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + + /// <inheritdoc /> + 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/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 @@ +// <auto-generated /> +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 + { + /// <inheritdoc /> + 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<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<long>("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property<Guid>("CreatedByUserId") + .HasColumnType("TEXT"); + + b.Property<bool>("IsPublic") + .HasColumnType("INTEGER"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property<string>("Topic") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Message", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("AttachmentFileName") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<string>("AttachmentUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property<Guid>("ChannelId") + .HasColumnType("TEXT"); + + b.Property<string>("Content") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property<Guid>("SenderUserId") + .HasColumnType("TEXT"); + + b.Property<string>("SenderUsername") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property<long>("SentAt") + .HasColumnType("INTEGER"); + + b.Property<int>("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<long>("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property<long>("ExpiresAt") + .HasColumnType("INTEGER"); + + b.Property<long?>("RevokedAt") + .HasColumnType("INTEGER"); + + b.Property<string>("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash"); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.User", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("AvatarAscii") + .HasMaxLength(10000) + .HasColumnType("TEXT"); + + b.Property<string>("Bio") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property<long>("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property<string>("DisplayName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property<bool>("IsBanned") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsMuted") + .HasColumnType("INTEGER"); + + b.Property<long>("LastSeenAt") + .HasColumnType("INTEGER"); + + b.Property<long?>("MutedUntil") + .HasColumnType("INTEGER"); + + b.Property<string>("NicknameColor") + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property<string>("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<int>("Role") + .HasColumnType("INTEGER"); + + b.Property<int>("Status") + .HasColumnType("INTEGER"); + + b.Property<string>("StatusMessage") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property<string>("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..52ca48e --- /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 +{ + /// <inheritdoc /> + public partial class AddChannelIsPublic : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn<bool>( + name: "IsPublic", + table: "Channels", + type: "INTEGER", + nullable: false, + defaultValue: true); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsPublic", + table: "Channels"); + } + } +} 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 @@ +// <auto-generated /> +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 + { + /// <inheritdoc /> + 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<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<long>("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property<Guid>("CreatedByUserId") + .HasColumnType("TEXT"); + + b.Property<bool>("IsPublic") + .HasColumnType("INTEGER"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property<string>("Topic") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b => + { + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<Guid>("ChannelId") + .HasColumnType("TEXT"); + + b.Property<long>("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<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("AttachmentFileName") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<string>("AttachmentUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property<Guid>("ChannelId") + .HasColumnType("TEXT"); + + b.Property<string>("Content") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property<Guid>("SenderUserId") + .HasColumnType("TEXT"); + + b.Property<string>("SenderUsername") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property<long>("SentAt") + .HasColumnType("INTEGER"); + + b.Property<int>("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<long>("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property<long>("ExpiresAt") + .HasColumnType("INTEGER"); + + b.Property<long?>("RevokedAt") + .HasColumnType("INTEGER"); + + b.Property<string>("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash"); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.User", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("AvatarAscii") + .HasMaxLength(10000) + .HasColumnType("TEXT"); + + b.Property<string>("Bio") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property<long>("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property<string>("DisplayName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property<bool>("IsBanned") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsMuted") + .HasColumnType("INTEGER"); + + b.Property<long>("LastSeenAt") + .HasColumnType("INTEGER"); + + b.Property<long?>("MutedUntil") + .HasColumnType("INTEGER"); + + b.Property<string>("NicknameColor") + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property<string>("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<int>("Role") + .HasColumnType("INTEGER"); + + b.Property<int>("Status") + .HasColumnType("INTEGER"); + + b.Property<string>("StatusMessage") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property<string>("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..e5aac36 --- /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 +{ + /// <inheritdoc /> + public partial class AddChannelMembership : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ChannelMemberships", + columns: table => new + { + UserId = table.Column<Guid>(type: "TEXT", nullable: false), + ChannelId = table.Column<Guid>(type: "TEXT", nullable: false), + JoinedAt = table.Column<long>(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"); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ChannelMemberships"); + } + } +} 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 @@ +// <auto-generated /> +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 + { + /// <inheritdoc /> + 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<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<long>("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property<Guid>("CreatedByUserId") + .HasColumnType("TEXT"); + + b.Property<bool>("IsPublic") + .HasColumnType("INTEGER"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property<string>("Topic") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b => + { + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<Guid>("ChannelId") + .HasColumnType("TEXT"); + + b.Property<long>("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<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("AttachmentFileName") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<string>("AttachmentUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property<Guid>("ChannelId") + .HasColumnType("TEXT"); + + b.Property<string>("Content") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property<string>("EmbedJson") + .HasMaxLength(8000) + .HasColumnType("TEXT"); + + b.Property<Guid>("SenderUserId") + .HasColumnType("TEXT"); + + b.Property<string>("SenderUsername") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property<long>("SentAt") + .HasColumnType("INTEGER"); + + b.Property<int>("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<long>("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property<long>("ExpiresAt") + .HasColumnType("INTEGER"); + + b.Property<long?>("RevokedAt") + .HasColumnType("INTEGER"); + + b.Property<string>("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash"); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.User", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("AvatarAscii") + .HasMaxLength(10000) + .HasColumnType("TEXT"); + + b.Property<string>("Bio") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property<long>("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property<string>("DisplayName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property<bool>("IsBanned") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsMuted") + .HasColumnType("INTEGER"); + + b.Property<long>("LastSeenAt") + .HasColumnType("INTEGER"); + + b.Property<long?>("MutedUntil") + .HasColumnType("INTEGER"); + + b.Property<string>("NicknameColor") + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property<string>("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<int>("Role") + .HasColumnType("INTEGER"); + + b.Property<int>("Status") + .HasColumnType("INTEGER"); + + b.Property<string>("StatusMessage") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property<string>("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..481ca85 --- /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 +{ + /// <inheritdoc /> + public partial class AddMessageEmbed : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn<string>( + name: "EmbedJson", + table: "Messages", + type: "TEXT", + maxLength: 8000, + nullable: true); + } + + /// <inheritdoc /> + 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 8c72662..66f12ce 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<Guid>("CreatedByUserId") .HasColumnType("TEXT"); + b.Property<bool>("IsPublic") + .HasColumnType("INTEGER"); + b.Property<string>("Name") .IsRequired() .HasMaxLength(100) @@ -46,6 +49,26 @@ namespace EchoHub.Server.Data.Migrations b.ToTable("Channels"); }); + modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b => + { + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<Guid>("ChannelId") + .HasColumnType("TEXT"); + + b.Property<long>("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<Guid>("Id") @@ -68,6 +91,10 @@ namespace EchoHub.Server.Data.Migrations .HasMaxLength(2000) .HasColumnType("TEXT"); + b.Property<string>("EmbedJson") + .HasMaxLength(8000) + .HasColumnType("TEXT"); + b.Property<Guid>("SenderUserId") .HasColumnType("TEXT"); @@ -144,9 +171,18 @@ namespace EchoHub.Server.Data.Migrations .HasMaxLength(100) .HasColumnType("TEXT"); + b.Property<bool>("IsBanned") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsMuted") + .HasColumnType("INTEGER"); + b.Property<long>("LastSeenAt") .HasColumnType("INTEGER"); + b.Property<long?>("MutedUntil") + .HasColumnType("INTEGER"); + b.Property<string>("NicknameColor") .HasMaxLength(7) .HasColumnType("TEXT"); @@ -155,6 +191,9 @@ namespace EchoHub.Server.Data.Migrations .IsRequired() .HasColumnType("TEXT"); + b.Property<int>("Role") + .HasColumnType("INTEGER"); + b.Property<int>("Status") .HasColumnType("INTEGER"); @@ -175,6 +214,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/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<PresenceTracker>(); builder.Services.AddSingleton<ImageToAsciiService>(); builder.Services.AddSingleton<FileStorageService>(); + builder.Services.AddSingleton<LinkEmbedService>(); builder.Services.AddHostedService<ServerDirectoryService>(); // ── 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 8bd9065..d590a65 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<IChatBroadcaster> _broadcasters; + private readonly LinkEmbedService _embedService; private readonly ILogger<ChatService> _logger; public ChatService( IServiceScopeFactory scopeFactory, PresenceTracker presenceTracker, IEnumerable<IChatBroadcaster> broadcasters, + LinkEmbedService embedService, ILogger<ChatService> logger) { _scopeFactory = scopeFactory; _presenceTracker = presenceTracker; _broadcasters = broadcasters; + _embedService = embedService; _logger = logger; } @@ -72,7 +76,8 @@ public class ChatService : IChatService user.DisplayName, user.NicknameColor, UserStatus.Invisible, - user.StatusMessage); + user.StatusMessage, + user.Role); await BroadcastToAllAsync(b => b.SendUserStatusChangedAsync(channelsBeforeDisconnect, presence)); } @@ -97,6 +102,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) @@ -130,6 +148,9 @@ public class ChatService : IChatService if (content.Length > HubConstants.MaxMessageLength) return $"Message exceeds maximum length of {HubConstants.MaxMessageLength} characters."; + // Sanitize: collapse excessive newlines + content = SanitizeNewlines(content); + using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>(); @@ -139,6 +160,32 @@ 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."; + } + } + + // Attempt to fetch link embeds for URLs in the message + List<EmbedDto>? embeds = null; + try + { + embeds = await _embedService.TryGetEmbedsAsync(content); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to fetch link embeds for message in '{Channel}'", channelName); + } + var message = new Message { Id = Guid.NewGuid(), @@ -148,6 +195,7 @@ public class ChatService : IChatService ChannelId = channel.Id, SenderUserId = userId, SenderUsername = username, + EmbedJson = embeds is not null ? JsonSerializer.Serialize(embeds) : null, }; db.Messages.Add(message); @@ -162,7 +210,8 @@ public class ChatService : IChatService MessageType.Text, null, null, - message.SentAt); + message.SentAt, + embeds); await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto)); @@ -203,7 +252,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 +276,8 @@ public class ChatService : IChatService u.DisplayName, u.NicknameColor, u.Status, - u.StatusMessage)) + u.StatusMessage, + u.Role)) .ToListAsync(); } @@ -264,7 +315,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) @@ -312,32 +363,79 @@ public class ChatService : IChatService return (user.Id, user.Username); } + /// <summary> + /// Collapse consecutive newlines and cap total line count to prevent newline spam. + /// </summary> + private static string SanitizeNewlines(string content) + { + // Normalize \r\n → \n + content = content.Replace("\r\n", "\n").Replace('\r', '\n'); + + // Collapse consecutive blank/whitespace-only lines into max 1 blank line + var lines = content.Split('\n'); + var result = new List<string>(lines.Length); + int consecutiveBlanks = 0; + + 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<List<MessageDto>> GetChannelHistoryInternalAsync(EchoHubDbContext db, string channelName, int count) { var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); 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 => + { + List<EmbedDto>? embeds = null; + if (x.m.EmbedJson is not null) + { + try { embeds = JsonSerializer.Deserialize<List<EmbedDto>>(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, + embeds); + }).ToList(); } } diff --git a/src/EchoHub.Server/Services/ImageToAsciiService.cs b/src/EchoHub.Server/Services/ImageToAsciiService.cs index 921273d..a1520de 100644 --- a/src/EchoHub.Server/Services/ImageToAsciiService.cs +++ b/src/EchoHub.Server/Services/ImageToAsciiService.cs @@ -8,47 +8,81 @@ namespace EchoHub.Server.Services; public class ImageToAsciiService { - private static readonly char[] AsciiChars = " .:-=+*#%@".ToCharArray(); + /// <summary> + /// Returns (width, height) dimensions for the given size code. + /// s = small (40x40), m = medium/default (80x80), l = large (120x120). + /// </summary> + public static (int Width, int Height) GetDimensions(string? size) => size?.ToLowerInvariant() switch + { + "s" => (40, 40), + "l" => (120, 120), + _ => (HubConstants.AsciiArtWidth, HubConstants.AsciiArtHeightHalfBlock), + }; - public string ConvertToAscii(Stream imageStream, int width = HubConstants.AsciiArtWidth, int height = HubConstants.AsciiArtHeight) + /// <summary> + /// Converts an image to ASCII art using half-block characters (▀▄█) with + /// 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. + /// </summary> + public string ConvertToAscii(Stream imageStream, int width = HubConstants.AsciiArtWidth, int height = HubConstants.AsciiArtHeightHalfBlock) { using var image = Image.Load<Rgba32>(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; + fgR = topPixel.R; fgG = topPixel.G; fgB = topPixel.B; + bgR = topPixel.R; bgG = topPixel.G; bgB = topPixel.B; + blockChar = '\u2588'; // █ + } + else + { + fgR = topPixel.R; fgG = topPixel.G; fgB = topPixel.B; + bgR = bottomPixel.R; bgG = bottomPixel.G; bgB = bottomPixel.B; + blockChar = '\u2580'; // ▀ } - sb.Append(AsciiChars[index]); + bool fgChanged = !hasLastColor || fgR != lastFgR || fgG != lastFgG || fgB != lastFgB; + bool bgChanged = !hasLastColor || bgR != lastBgR || bgG != lastBgG || bgB != lastBgB; + + if (fgChanged) + sb.Append($"{{F:{fgR:X2}{fgG:X2}{fgB:X2}}}"); + if (bgChanged) + sb.Append($"{{B:{bgR:X2}{bgG:X2}{bgB:X2}}}"); + + 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"); + sb.Append("{X}"); hasLastColor = false; - if (y < image.Height - 1) + if (y + 2 < image.Height) { sb.AppendLine(); } diff --git a/src/EchoHub.Server/Services/LinkEmbedService.cs b/src/EchoHub.Server/Services/LinkEmbedService.cs new file mode 100644 index 0000000..e424730 --- /dev/null +++ b/src/EchoHub.Server/Services/LinkEmbedService.cs @@ -0,0 +1,216 @@ +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 ILogger<LinkEmbedService> _logger; + + public LinkEmbedService( + IHttpClientFactory httpClientFactory, + ILogger<LinkEmbedService> logger) + { + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + /// <summary> + /// 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<List<EmbedDto>?> TryGetEmbedsAsync(string content) + { + var urls = ExtractUrls(content); + if (urls.Count == 0) + return null; + + var embeds = new List<EmbedDto>(); + + foreach (var url in urls) + { + 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 async Task<EmbedDto?> FetchEmbedForUrlAsync(string url) + { + 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 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; + + return new EmbedDto(siteName, title, description, null, 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) + { + 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" /> + // Groups: 1=prop quote, 2=key, 3=content quote, 4=value + foreach (Match match in OgTagRegex().Matches(html)) + { + 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[2].Value; + var key = match.Groups[4].Value; + tags.TryAdd(key, value); + } + + return tags; + } + + 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 | RegexOptions.Compiled)] + private static partial Regex UrlRegex(); + + [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*([""'])(.*?)\1[^>]*?property\s*=\s*([""'])og:(\w+)\3[^>]*/?>", + RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled)] + private static partial Regex OgTagReversedRegex(); + + [GeneratedRegex(@"<title[^>]*>([^<]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled)] + private static partial Regex TitleTagRegex(); +} 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; } + + /// + /// Forcibly remove a user from all tracking. Returns their connection IDs and channels + /// so the caller can broadcast departures and force-disconnect connections. + /// + public (List ConnectionIds, List 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/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 { diff --git a/src/EchoHub.Server/Services/SignalRBroadcaster.cs b/src/EchoHub.Server/Services/SignalRBroadcaster.cs index 47dfd90..546dde6 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-")) @@ -61,4 +73,13 @@ public class SignalRBroadcaster : IChatBroadcaster return HubContext.Clients.Client(connectionId).Error(message); } + + public Task ForceDisconnectUserAsync(List 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); + } } diff --git a/src/EchoHub.Server/Setup/DataMigrationService.cs b/src/EchoHub.Server/Setup/DataMigrationService.cs new file mode 100644 index 0000000..34028c8 --- /dev/null +++ b/src/EchoHub.Server/Setup/DataMigrationService.cs @@ -0,0 +1,139 @@ +using System.Text.RegularExpressions; +using EchoHub.Core.Constants; +using EchoHub.Core.DTOs; +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 EnsureDefaultChannelsPublicAsync(db, logger); + await MigrateAnsiMessagesAsync(db, logger); + await MigrateEmbedJsonToArrayAsync(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. + // 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(); + + /// + /// Migrate old single-object EmbedJson ("{...}") to array format ("[{...}]"). + /// + 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(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); + } + } +} 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)