diff --git a/docs/api/client-articles/index.md b/docs/api/client-articles/index.md index 7fde157..171da8f 100644 --- a/docs/api/client-articles/index.md +++ b/docs/api/client-articles/index.md @@ -8,3 +8,4 @@ Articles related to the EchoHub TUI client built with Terminal.Gui v2. - Theme system and customization - Command system reference - Configuration management +- [Notification sounds](../../articles/notification-sounds.md) diff --git a/docs/articles/notification-sounds.md b/docs/articles/notification-sounds.md new file mode 100644 index 0000000..03f9767 --- /dev/null +++ b/docs/articles/notification-sounds.md @@ -0,0 +1,43 @@ +# Notification Sounds + +EchoHub can play a notification sound when someone @mentions you. This is **disabled by default** and must be enabled in your profile settings. + +## Enabling Notifications + +Open your profile (`/profile`) and check the **"Notification sound on @mention"** checkbox, then save. You can also adjust the **Volume** (0-100, default 30). All settings are persisted in `~/.echohub/config.json`. + +## Customizing the Sound + +The client ships with a default `Notification.mp3` in the `Assets` folder. To use your own notification sound, replace the file at: + +``` +/Assets/Notification.mp3 +``` + +The file must be a valid `.mp3` or `.wav` audio file. The replacement takes effect on the next app launch. + +Alternatively, set a custom path in `~/.echohub/config.json`: + +```json +{ + "notifications": { + "enabled": true, + "volume": 30, + "soundFile": "/path/to/your/sound.mp3" + } +} +``` + +When `soundFile` is set, EchoHub uses that file instead of the bundled default. + +## Disabling Notifications + +Uncheck the option in your profile, or edit the config directly: + +```json +{ + "notifications": { + "enabled": false + } +} +``` diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 7fac42f..647908c 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -4,7 +4,7 @@ Release history for EchoHub. ## Releases -- [v0.2.3](v0.2.3.md) - Moderation, Private Channels & UI Overhaul +- [v0.2.3](v0.2.3.md) - Moderation, Embeds & UI Overhaul - [v0.2.2](v0.2.2.md) - Startup & Shutdown Fixes - [v0.2.1](v0.2.1.md) - Shutdown & CI Fixes - [v0.2.0](v0.2.0.md) - IRC Gateway diff --git a/docs/changelog/v0.2.3.md b/docs/changelog/v0.2.3.md index e428fad..0b78e8b 100644 --- a/docs/changelog/v0.2.3.md +++ b/docs/changelog/v0.2.3.md @@ -1,4 +1,4 @@ -# v0.2.3 - Moderation, Private Channels & UI Overhaul +# v0.2.3 - Moderation, Embeds & UI Overhaul ## Features @@ -16,6 +16,21 @@ - `GET /api/channels` returns the combined list: public channels + user's joined private channels - Channel creators are automatically added as members +### OpenGraph Link Embeds +- Messages containing URLs now show a rich preview below the message text +- Server-side fetching: detects the first URL in a message, fetches the page, and parses OpenGraph meta tags (`og:title`, `og:description`, `og:image`, `og:site_name`) +- OG images are converted to a small 24x12 colored ASCII thumbnail using the existing half-block renderer +- Embeds are persisted in the database and included in channel history +- TUI client renders embeds with a `▏` left border bar — site name and border in blue, title in white, description in gray, followed by the ASCII thumbnail +- IRC gateway receives a text-only embed preview (site name, title, description) — no ASCII thumbnail to keep IRC output clean +- Falls back to `` tag when no OG tags are present; gracefully skips if no useful metadata is found +- 3-second fetch timeout ensures message delivery is never significantly delayed +- SSRF protection rejects private/loopback IP addresses before fetching + +### Notification Sounds +- Incoming messages play a notification sound when the terminal is not focused +- Embedded MP3 asset with cross-platform playback support + ### Online Users Panel - Collapsible right-side panel showing online users in the current channel (toggle with F2) - Users displayed with status indicators, role badges, and their custom nickname colors @@ -36,6 +51,7 @@ - Version number shown in the status bar - Custom colored rendering for channel list (active indicator, unread count badges) - Avatar upload field added to the profile edit dialog (file path or URL) +- Profile avatar now renders with full color tag support in the profile view dialog - Update check notification on connect — shows a system message if a newer GitHub release exists - Chat messages no longer show selection/focus highlight - Exit shortcut changed from Ctrl+C to Alt+Q — frees Ctrl+C for copy @@ -48,12 +64,19 @@ - Wired `OnChannelUpdated` SignalR event so new public channels appear for all connected users in real time - Fixed color tag parser using wrong regex group numbers (6,7,8 instead of 1,2,3) — new ASCII art was rendering without colors - Full Unicode/emoji support — renderers use Terminal.Gui v2 grapheme cluster API (`GraphemeHelper`, `AddStr`) for proper wide character handling +- Emoji-to-text shortcode conversion for consistent cross-platform rendering - Fixed `/send` and `/avatar` commands not handling file paths with spaces correctly, even when quoted -- Profile avatar now renders with full color tag support instead of showing raw tags - Server-side newline spam protection — consecutive blank/whitespace-only lines collapsed to 1 and total lines capped at 30 ## Infrastructure +- New `LinkEmbedService` on the server — URL detection, HTML fetching (first 64KB), OG tag parsing via regex, image thumbnail generation +- `EmbedDto` record added to shared Core DTOs; `MessageDto` extended with optional `Embed` field (backward-compatible) +- `EmbedJson` nullable column on the `Message` table stores serialized embed data (max 8KB) +- Dedicated `"OgFetch"` named HttpClient with bot User-Agent header and 5-second timeout +- `NotificationSoundService` for cross-platform audio playback of embedded notification sounds - Startup `DataMigrationService` automatically converts old ANSI-format messages to the new color tag format on server boot, logging the count of migrated records -- Three new EF Core migrations: `AddModerationRoles`, `AddChannelIsPublic`, `AddChannelMembership` +- `EmojiHelper` utility for emoji-to-shortcode conversion +- Heartbeat handling in `ServerDirectoryService` for connection health checks +- Four new EF Core migrations: `AddModerationRoles`, `AddChannelIsPublic`, `AddChannelMembership`, `AddMessageEmbed` - `ChannelMembership` table with cascade delete on both channel and user removal diff --git a/docs/changelog/v0.2.4.md b/docs/changelog/v0.2.4.md deleted file mode 100644 index 3b8c67a..0000000 --- a/docs/changelog/v0.2.4.md +++ /dev/null @@ -1,22 +0,0 @@ -# v0.2.4 - Link Embeds - -## Features - -### OpenGraph Link Embeds -- Messages containing URLs now show a rich preview below the message text, similar to Discord -- Server-side fetching: detects the first URL in a message, fetches the page, and parses OpenGraph meta tags (`og:title`, `og:description`, `og:image`, `og:site_name`) -- OG images are converted to a small 24x12 colored ASCII thumbnail using the existing half-block renderer -- Embeds are persisted in the database and included in channel history -- TUI client renders embeds with a `▏` left border bar — site name and border in blue, title in white, description in gray, followed by the ASCII thumbnail -- IRC gateway receives a text-only embed preview (site name, title, description) — no ASCII thumbnail to keep IRC output clean -- Falls back to `<title>` tag when no OG tags are present; gracefully skips if no useful metadata is found -- 3-second fetch timeout ensures message delivery is never significantly delayed -- SSRF protection rejects private/loopback IP addresses before fetching - -## Infrastructure - -- New `LinkEmbedService` on the server — URL detection, HTML fetching (first 64KB), OG tag parsing via regex, image thumbnail generation -- `EmbedDto` record added to shared Core DTOs; `MessageDto` extended with optional `Embed` field (backward-compatible) -- `EmbedJson` nullable column on the `Message` table stores serialized embed data (max 8KB) -- Dedicated `"OgFetch"` named HttpClient with bot User-Agent header and 5-second timeout -- New EF Core migration: `AddMessageEmbed` diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs index ba641b4..4c90e97 100644 --- a/src/EchoHub.Client/AppOrchestrator.cs +++ b/src/EchoHub.Client/AppOrchestrator.cs @@ -21,6 +21,7 @@ public sealed class AppOrchestrator : IDisposable private readonly IApplication _app; private readonly MainWindow _mainWindow; private readonly CommandHandler _commandHandler; + private readonly NotificationSoundService _notificationSound; private EchoHubConnection? _connection; private ApiClient? _apiClient; @@ -41,6 +42,7 @@ public sealed class AppOrchestrator : IDisposable _config = config; _mainWindow = new MainWindow(app); _commandHandler = new CommandHandler(); + _notificationSound = new NotificationSoundService(config.Notifications); WireMainWindowEvents(); WireCommandHandlerEvents(); @@ -572,7 +574,9 @@ public sealed class AppOrchestrator : IDisposable var editResult = ProfileEditDialog.Show(_app, currentProfile?.DisplayName, currentProfile?.Bio, - currentProfile?.NicknameColor); + currentProfile?.NicknameColor, + _config.Notifications.Enabled, + _config.Notifications.Volume); if (editResult is null) return; @@ -633,6 +637,18 @@ public sealed class AppOrchestrator : IDisposable } } + if (editResult.NotificationSoundEnabled.HasValue) + { + _config.Notifications.Enabled = editResult.NotificationSoundEnabled.Value; + _notificationSound.SetEnabled(editResult.NotificationSoundEnabled.Value); + } + + if (editResult.NotificationVolume.HasValue) + { + _config.Notifications.Volume = editResult.NotificationVolume.Value; + _notificationSound.SetVolume(editResult.NotificationVolume.Value); + } + _config.DefaultPreset = new AccountPreset { DisplayName = editResult.DisplayName, @@ -768,8 +784,17 @@ public sealed class AppOrchestrator : IDisposable private void WireConnectionEvents(EchoHubConnection connection) { connection.OnMessageReceived += message => + { InvokeUI(() => _mainWindow.AddMessage(message)); + if (!string.IsNullOrEmpty(_currentUsername) + && !message.SenderUsername.Equals(_currentUsername, StringComparison.OrdinalIgnoreCase) + && message.Content.Contains($"@{_currentUsername}", StringComparison.OrdinalIgnoreCase)) + { + _ = _notificationSound.PlayAsync(); + } + }; + connection.OnUserJoined += (channelName, username) => { InvokeUI(() => _mainWindow.AddSystemMessage(channelName, $"{username} joined the channel")); diff --git a/src/EchoHub.Client/Config/ClientConfig.cs b/src/EchoHub.Client/Config/ClientConfig.cs index 7536419..25cdb45 100644 --- a/src/EchoHub.Client/Config/ClientConfig.cs +++ b/src/EchoHub.Client/Config/ClientConfig.cs @@ -10,7 +10,8 @@ public class ClientConfig public class NotificationConfig { - public bool Enabled { get; set; } = true; + public bool Enabled { get; set; } = false; + public byte Volume { get; set; } = 30; public string? SoundFile { get; set; } } diff --git a/src/EchoHub.Client/Services/NotificationSoundService.cs b/src/EchoHub.Client/Services/NotificationSoundService.cs index cd6f416..d9573c0 100644 --- a/src/EchoHub.Client/Services/NotificationSoundService.cs +++ b/src/EchoHub.Client/Services/NotificationSoundService.cs @@ -16,6 +16,10 @@ public class NotificationSoundService ResolveSoundPath(); } + public void SetEnabled(bool enabled) => _config.Enabled = enabled; + + public void SetVolume(byte volume) => _config.Volume = Math.Min(volume, (byte)100); + public async Task PlayAsync() { if (!_config.Enabled || _resolvedSoundPath is null) @@ -26,6 +30,7 @@ public class NotificationSoundService if (_player.Playing) await _player.Stop(); + await _player.SetVolume(_config.Volume); await _player.Play(_resolvedSoundPath); } catch (Exception ex) diff --git a/src/EchoHub.Client/UI/ProfileEditDialog.cs b/src/EchoHub.Client/UI/ProfileEditDialog.cs index 6481e16..0cbc2c1 100644 --- a/src/EchoHub.Client/UI/ProfileEditDialog.cs +++ b/src/EchoHub.Client/UI/ProfileEditDialog.cs @@ -9,7 +9,7 @@ namespace EchoHub.Client.UI; /// <summary> /// Result returned from the profile edit dialog. /// </summary> -public record ProfileEditResult(string? DisplayName, string? Bio, string? NicknameColor, string? AvatarPath); +public record ProfileEditResult(string? DisplayName, string? Bio, string? NicknameColor, string? AvatarPath, bool? NotificationSoundEnabled, byte? NotificationVolume); /// <summary> /// A Terminal.Gui dialog for editing the user's profile (display name, bio, nickname color). @@ -19,11 +19,11 @@ public sealed class ProfileEditDialog /// <summary> /// Shows the profile edit dialog and returns the result, or null if cancelled. /// </summary> - public static ProfileEditResult? Show(IApplication app, string? currentDisplayName, string? currentBio, string? currentColor) + public static ProfileEditResult? Show(IApplication app, string? currentDisplayName, string? currentBio, string? currentColor, bool notificationSoundEnabled = false, byte notificationVolume = 30) { ProfileEditResult? result = null; - var dialog = new Dialog { Title = "Edit Profile", Width = 60, Height = 22 }; + var dialog = new Dialog { Title = "Edit Profile", Width = 60, Height = 26 }; // Display Name var nameLabel = new Label @@ -148,20 +148,53 @@ public sealed class ProfileEditDialog } }; + // Notification Sound + var notifCheckbox = new CheckBox + { + Text = "Notification sound on @mention", + X = 1, + Y = 13, + Value = notificationSoundEnabled ? CheckState.Checked : CheckState.UnChecked + }; + + var volumeLabel = new Label + { + Text = "Volume:", + X = 1, + Y = 15 + }; + var volumeField = new TextField + { + Text = notificationVolume.ToString(), + X = 17, + Y = 15, + Width = 6 + }; + var volumeHintLabel = new Label + { + Text = "(0-100)", + X = 24, + Y = 15 + }; + volumeHintLabel.SetScheme(new Scheme + { + Normal = new Attribute(Color.DarkGray, Color.Blue) + }); + // Buttons var saveButton = new Button { Text = "Save", IsDefault = true, X = Pos.Center() - 10, - Y = 14 + Y = 18 }; var cancelButton = new Button { Text = "Cancel", X = Pos.Center() + 5, - Y = 14 + Y = 18 }; saveButton.Accepting += (s, e) => @@ -171,7 +204,8 @@ public sealed class ProfileEditDialog var nicknameColor = NullIfEmpty(colorField.Text?.Trim()); var avatarPath = NullIfEmpty(avatarField.Text?.Trim()); - result = new ProfileEditResult(displayName, bio, nicknameColor, avatarPath); + byte? volume = byte.TryParse(volumeField.Text, out var v) ? Math.Min(v, (byte)100) : null; + result = new ProfileEditResult(displayName, bio, nicknameColor, avatarPath, notifCheckbox.Value == CheckState.Checked, volume); e.Handled = true; app.RequestStop(); }; @@ -186,6 +220,7 @@ public sealed class ProfileEditDialog dialog.Add(nameLabel, nameField, bioLabel, bioField, colorLabel, colorField, colorHintLabel, previewLabel, colorPreview, avatarLabel, avatarField, browseButton, avatarHintLabel, + notifCheckbox, volumeLabel, volumeField, volumeHintLabel, saveButton, cancelButton); nameField.SetFocus(); diff --git a/src/EchoHub.Core/Contracts/IChatBroadcaster.cs b/src/EchoHub.Core/Contracts/IChatBroadcaster.cs index 84d1e7d..c88bc81 100644 --- a/src/EchoHub.Core/Contracts/IChatBroadcaster.cs +++ b/src/EchoHub.Core/Contracts/IChatBroadcaster.cs @@ -14,4 +14,5 @@ public interface IChatBroadcaster Task SendMessageDeletedAsync(string channelName, Guid messageId); Task SendChannelNukedAsync(string channelName); Task SendErrorAsync(string connectionId, string message); + Task ForceDisconnectUserAsync(List<string> connectionIds, string reason); } diff --git a/src/EchoHub.Core/Contracts/IEchoHubClient.cs b/src/EchoHub.Core/Contracts/IEchoHubClient.cs index 86875d1..242a66e 100644 --- a/src/EchoHub.Core/Contracts/IEchoHubClient.cs +++ b/src/EchoHub.Core/Contracts/IEchoHubClient.cs @@ -16,5 +16,6 @@ public interface IEchoHubClient Task UserBanned(string username, string? reason); Task MessageDeleted(string channelName, Guid messageId); Task ChannelNuked(string channelName); + Task ForceDisconnect(string reason); Task Error(string message); } diff --git a/src/EchoHub.Server.Irc/IrcBroadcaster.cs b/src/EchoHub.Server.Irc/IrcBroadcaster.cs index 6597b5c..40b88a3 100644 --- a/src/EchoHub.Server.Irc/IrcBroadcaster.cs +++ b/src/EchoHub.Server.Irc/IrcBroadcaster.cs @@ -106,4 +106,22 @@ public class IrcBroadcaster : IChatBroadcaster await conn.SendAsync($":{_gateway.Options.ServerName} NOTICE {conn.Nickname ?? "*"} :{message}"); } } + + public async Task ForceDisconnectUserAsync(List<string> connectionIds, string reason) + { + foreach (var connId in connectionIds) + { + if (!connId.StartsWith("irc-")) continue; + + if (_gateway.Connections.TryGetValue(connId, out var conn)) + { + try + { + await conn.SendAsync($"ERROR :Closing Link: {reason}"); + await conn.DisposeAsync(); + } + catch { /* connection may already be closed */ } + } + } + } } diff --git a/src/EchoHub.Server/Controllers/ModerationController.cs b/src/EchoHub.Server/Controllers/ModerationController.cs index 26eef4d..b048d12 100644 --- a/src/EchoHub.Server/Controllers/ModerationController.cs +++ b/src/EchoHub.Server/Controllers/ModerationController.cs @@ -72,13 +72,17 @@ public class ModerationController : ControllerBase if (target.Role >= caller!.Role) return BadRequest(new ErrorResponse("Cannot kick a user with equal or higher role.")); - // Broadcast kick to all channels the user is in + // Broadcast kick to all channels the user is in, then clean up presence var channels = _presenceTracker.GetChannelsForUser(target.Username); foreach (var channel in channels) { await BroadcastToAllAsync(b => b.SendUserKickedAsync(channel, target.Username, request?.Reason)); } + // Remove from presence tracker and force disconnect all connections + var reason = request?.Reason ?? "You have been kicked from the server."; + await ForceDisconnectAndCleanupAsync(target.Username, reason); + return Ok(new { Message = $"{target.Username} has been kicked." }); } @@ -98,8 +102,12 @@ public class ModerationController : ControllerBase target.IsBanned = true; await _db.SaveChangesAsync(); + // Broadcast ban notification, then force disconnect await BroadcastToAllAsync(b => b.SendUserBannedAsync(target.Username, request?.Reason)); + var reason = request?.Reason ?? "You have been banned from this server."; + await ForceDisconnectAndCleanupAsync(target.Username, reason); + return Ok(new { Message = $"{target.Username} has been banned." }); } @@ -217,6 +225,36 @@ public class ModerationController : ControllerBase return (caller, null); } + /// <summary> + /// Remove user from presence tracking, broadcast their departure from all channels, + /// send a ForceDisconnect signal, and update their DB status. + /// </summary> + private async Task ForceDisconnectAndCleanupAsync(string username, string reason) + { + var (connectionIds, channels) = _presenceTracker.ForceRemoveUser(username); + + // Notify remaining users that this person left each channel + foreach (var channel in channels) + { + await BroadcastToAllAsync(b => b.SendUserLeftAsync(channel, username)); + } + + // Signal the user's clients to disconnect + if (connectionIds.Count > 0) + { + await BroadcastToAllAsync(b => b.ForceDisconnectUserAsync(connectionIds, reason)); + } + + // Mark user offline in DB + var user = await _db.Users.FirstOrDefaultAsync(u => u.Username == username); + if (user is not null) + { + user.Status = UserStatus.Invisible; + user.LastSeenAt = DateTimeOffset.UtcNow; + await _db.SaveChangesAsync(); + } + } + private async Task BroadcastToAllAsync(Func<IChatBroadcaster, Task> action) { foreach (var broadcaster in _broadcasters) diff --git a/src/EchoHub.Server/Services/PresenceTracker.cs b/src/EchoHub.Server/Services/PresenceTracker.cs index 627718d..0ae3d42 100644 --- a/src/EchoHub.Server/Services/PresenceTracker.cs +++ b/src/EchoHub.Server/Services/PresenceTracker.cs @@ -151,4 +151,27 @@ public class PresenceTracker { return _userConnections.Count; } + + /// <summary> + /// Forcibly remove a user from all tracking. Returns their connection IDs and channels + /// so the caller can broadcast departures and force-disconnect connections. + /// </summary> + public (List<string> ConnectionIds, List<string> Channels) ForceRemoveUser(string username) + { + lock (_lock) + { + var channels = _userChannels.TryRemove(username, out var ch) + ? ch.ToList() + : []; + + var connectionIds = _userConnections.TryRemove(username, out var conns) + ? conns.ToList() + : []; + + foreach (var connId in connectionIds) + _connections.TryRemove(connId, out _); + + return (connectionIds, channels); + } + } } diff --git a/src/EchoHub.Server/Services/SignalRBroadcaster.cs b/src/EchoHub.Server/Services/SignalRBroadcaster.cs index a29ee42..546dde6 100644 --- a/src/EchoHub.Server/Services/SignalRBroadcaster.cs +++ b/src/EchoHub.Server/Services/SignalRBroadcaster.cs @@ -73,4 +73,13 @@ public class SignalRBroadcaster : IChatBroadcaster return HubContext.Clients.Client(connectionId).Error(message); } + + public Task ForceDisconnectUserAsync(List<string> connectionIds, string reason) + { + var signalRIds = connectionIds.Where(c => !c.StartsWith("irc-")).ToList(); + if (signalRIds.Count == 0) + return Task.CompletedTask; + + return HubContext.Clients.Clients(signalRIds).ForceDisconnect(reason); + } }