feat: implement notification sound functionality with user customization options

This commit is contained in:
HueByte
2026-02-19 22:03:16 +01:00
parent b508a5854a
commit 7a8b5f02d3
15 changed files with 236 additions and 35 deletions
+1
View File
@@ -8,3 +8,4 @@ Articles related to the EchoHub TUI client built with Terminal.Gui v2.
- Theme system and customization - Theme system and customization
- Command system reference - Command system reference
- Configuration management - Configuration management
- [Notification sounds](../../articles/notification-sounds.md)
+43
View File
@@ -0,0 +1,43 @@
# Notification Sounds
EchoHub can play a notification sound when someone @mentions you. This is **disabled by default** and must be enabled in your profile settings.
## Enabling Notifications
Open your profile (`/profile`) and check the **"Notification sound on @mention"** checkbox, then save. You can also adjust the **Volume** (0-100, default 30). All settings are persisted in `~/.echohub/config.json`.
## Customizing the Sound
The client ships with a default `Notification.mp3` in the `Assets` folder. To use your own notification sound, replace the file at:
```
<app-directory>/Assets/Notification.mp3
```
The file must be a valid `.mp3` or `.wav` audio file. The replacement takes effect on the next app launch.
Alternatively, set a custom path in `~/.echohub/config.json`:
```json
{
"notifications": {
"enabled": true,
"volume": 30,
"soundFile": "/path/to/your/sound.mp3"
}
}
```
When `soundFile` is set, EchoHub uses that file instead of the bundled default.
## Disabling Notifications
Uncheck the option in your profile, or edit the config directly:
```json
{
"notifications": {
"enabled": false
}
}
```
+1 -1
View File
@@ -4,7 +4,7 @@ Release history for EchoHub.
## Releases ## 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.2](v0.2.2.md) - Startup & Shutdown Fixes
- [v0.2.1](v0.2.1.md) - Shutdown & CI Fixes - [v0.2.1](v0.2.1.md) - Shutdown & CI Fixes
- [v0.2.0](v0.2.0.md) - IRC Gateway - [v0.2.0](v0.2.0.md) - IRC Gateway
+26 -3
View File
@@ -1,4 +1,4 @@
# v0.2.3 - Moderation, Private Channels & UI Overhaul # v0.2.3 - Moderation, Embeds & UI Overhaul
## Features ## Features
@@ -16,6 +16,21 @@
- `GET /api/channels` returns the combined list: public channels + user's joined private channels - `GET /api/channels` returns the combined list: public channels + user's joined private channels
- Channel creators are automatically added as members - Channel creators are automatically added as members
### OpenGraph Link Embeds
- Messages containing URLs now show a rich preview below the message text
- Server-side fetching: detects the first URL in a message, fetches the page, and parses OpenGraph meta tags (`og:title`, `og:description`, `og:image`, `og:site_name`)
- OG images are converted to a small 24x12 colored ASCII thumbnail using the existing half-block renderer
- Embeds are persisted in the database and included in channel history
- TUI client renders embeds with a `▏` left border bar — site name and border in blue, title in white, description in gray, followed by the ASCII thumbnail
- IRC gateway receives a text-only embed preview (site name, title, description) — no ASCII thumbnail to keep IRC output clean
- Falls back to `<title>` tag when no OG tags are present; gracefully skips if no useful metadata is found
- 3-second fetch timeout ensures message delivery is never significantly delayed
- SSRF protection rejects private/loopback IP addresses before fetching
### Notification Sounds
- Incoming messages play a notification sound when the terminal is not focused
- Embedded MP3 asset with cross-platform playback support
### Online Users Panel ### Online Users Panel
- Collapsible right-side panel showing online users in the current channel (toggle with F2) - 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 - Users displayed with status indicators, role badges, and their custom nickname colors
@@ -36,6 +51,7 @@
- Version number shown in the status bar - Version number shown in the status bar
- Custom colored rendering for channel list (active indicator, unread count badges) - Custom colored rendering for channel list (active indicator, unread count badges)
- Avatar upload field added to the profile edit dialog (file path or URL) - 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 - Update check notification on connect — shows a system message if a newer GitHub release exists
- Chat messages no longer show selection/focus highlight - Chat messages no longer show selection/focus highlight
- Exit shortcut changed from Ctrl+C to Alt+Q — frees Ctrl+C for copy - 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 - Wired `OnChannelUpdated` SignalR event so new public channels appear for all connected users in real time
- Fixed color tag parser using wrong regex group numbers (6,7,8 instead of 1,2,3) — new ASCII art was rendering without colors - Fixed 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 - 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 - 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 - Server-side newline spam protection — consecutive blank/whitespace-only lines collapsed to 1 and total lines capped at 30
## Infrastructure ## 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 - 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 - `ChannelMembership` table with cascade delete on both channel and user removal
-22
View File
@@ -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`
+26 -1
View File
@@ -21,6 +21,7 @@ public sealed class AppOrchestrator : IDisposable
private readonly IApplication _app; private readonly IApplication _app;
private readonly MainWindow _mainWindow; private readonly MainWindow _mainWindow;
private readonly CommandHandler _commandHandler; private readonly CommandHandler _commandHandler;
private readonly NotificationSoundService _notificationSound;
private EchoHubConnection? _connection; private EchoHubConnection? _connection;
private ApiClient? _apiClient; private ApiClient? _apiClient;
@@ -41,6 +42,7 @@ public sealed class AppOrchestrator : IDisposable
_config = config; _config = config;
_mainWindow = new MainWindow(app); _mainWindow = new MainWindow(app);
_commandHandler = new CommandHandler(); _commandHandler = new CommandHandler();
_notificationSound = new NotificationSoundService(config.Notifications);
WireMainWindowEvents(); WireMainWindowEvents();
WireCommandHandlerEvents(); WireCommandHandlerEvents();
@@ -572,7 +574,9 @@ public sealed class AppOrchestrator : IDisposable
var editResult = ProfileEditDialog.Show(_app, var editResult = ProfileEditDialog.Show(_app,
currentProfile?.DisplayName, currentProfile?.DisplayName,
currentProfile?.Bio, currentProfile?.Bio,
currentProfile?.NicknameColor); currentProfile?.NicknameColor,
_config.Notifications.Enabled,
_config.Notifications.Volume);
if (editResult is null) return; 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 _config.DefaultPreset = new AccountPreset
{ {
DisplayName = editResult.DisplayName, DisplayName = editResult.DisplayName,
@@ -768,8 +784,17 @@ public sealed class AppOrchestrator : IDisposable
private void WireConnectionEvents(EchoHubConnection connection) private void WireConnectionEvents(EchoHubConnection connection)
{ {
connection.OnMessageReceived += message => connection.OnMessageReceived += message =>
{
InvokeUI(() => _mainWindow.AddMessage(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) => connection.OnUserJoined += (channelName, username) =>
{ {
InvokeUI(() => _mainWindow.AddSystemMessage(channelName, $"{username} joined the channel")); InvokeUI(() => _mainWindow.AddSystemMessage(channelName, $"{username} joined the channel"));
+2 -1
View File
@@ -10,7 +10,8 @@ public class ClientConfig
public class NotificationConfig 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; } public string? SoundFile { get; set; }
} }
@@ -16,6 +16,10 @@ public class NotificationSoundService
ResolveSoundPath(); 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() public async Task PlayAsync()
{ {
if (!_config.Enabled || _resolvedSoundPath is null) if (!_config.Enabled || _resolvedSoundPath is null)
@@ -26,6 +30,7 @@ public class NotificationSoundService
if (_player.Playing) if (_player.Playing)
await _player.Stop(); await _player.Stop();
await _player.SetVolume(_config.Volume);
await _player.Play(_resolvedSoundPath); await _player.Play(_resolvedSoundPath);
} }
catch (Exception ex) catch (Exception ex)
+41 -6
View File
@@ -9,7 +9,7 @@ namespace EchoHub.Client.UI;
/// <summary> /// <summary>
/// Result returned from the profile edit dialog. /// Result returned from the profile edit dialog.
/// </summary> /// </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> /// <summary>
/// A Terminal.Gui dialog for editing the user's profile (display name, bio, nickname color). /// A Terminal.Gui dialog for editing the user's profile (display name, bio, nickname color).
@@ -19,11 +19,11 @@ public sealed class ProfileEditDialog
/// <summary> /// <summary>
/// Shows the profile edit dialog and returns the result, or null if cancelled. /// Shows the profile edit dialog and returns the result, or null if cancelled.
/// </summary> /// </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; 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 // Display Name
var nameLabel = new Label 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 // Buttons
var saveButton = new Button var saveButton = new Button
{ {
Text = "Save", Text = "Save",
IsDefault = true, IsDefault = true,
X = Pos.Center() - 10, X = Pos.Center() - 10,
Y = 14 Y = 18
}; };
var cancelButton = new Button var cancelButton = new Button
{ {
Text = "Cancel", Text = "Cancel",
X = Pos.Center() + 5, X = Pos.Center() + 5,
Y = 14 Y = 18
}; };
saveButton.Accepting += (s, e) => saveButton.Accepting += (s, e) =>
@@ -171,7 +204,8 @@ public sealed class ProfileEditDialog
var nicknameColor = NullIfEmpty(colorField.Text?.Trim()); var nicknameColor = NullIfEmpty(colorField.Text?.Trim());
var avatarPath = NullIfEmpty(avatarField.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; e.Handled = true;
app.RequestStop(); app.RequestStop();
}; };
@@ -186,6 +220,7 @@ public sealed class ProfileEditDialog
dialog.Add(nameLabel, nameField, bioLabel, bioField, colorLabel, colorField, dialog.Add(nameLabel, nameField, bioLabel, bioField, colorLabel, colorField,
colorHintLabel, previewLabel, colorPreview, colorHintLabel, previewLabel, colorPreview,
avatarLabel, avatarField, browseButton, avatarHintLabel, avatarLabel, avatarField, browseButton, avatarHintLabel,
notifCheckbox, volumeLabel, volumeField, volumeHintLabel,
saveButton, cancelButton); saveButton, cancelButton);
nameField.SetFocus(); nameField.SetFocus();
@@ -14,4 +14,5 @@ public interface IChatBroadcaster
Task SendMessageDeletedAsync(string channelName, Guid messageId); Task SendMessageDeletedAsync(string channelName, Guid messageId);
Task SendChannelNukedAsync(string channelName); Task SendChannelNukedAsync(string channelName);
Task SendErrorAsync(string connectionId, string message); Task SendErrorAsync(string connectionId, string message);
Task ForceDisconnectUserAsync(List<string> connectionIds, string reason);
} }
@@ -16,5 +16,6 @@ public interface IEchoHubClient
Task UserBanned(string username, string? reason); Task UserBanned(string username, string? reason);
Task MessageDeleted(string channelName, Guid messageId); Task MessageDeleted(string channelName, Guid messageId);
Task ChannelNuked(string channelName); Task ChannelNuked(string channelName);
Task ForceDisconnect(string reason);
Task Error(string message); Task Error(string message);
} }
+18
View File
@@ -106,4 +106,22 @@ public class IrcBroadcaster : IChatBroadcaster
await conn.SendAsync($":{_gateway.Options.ServerName} NOTICE {conn.Nickname ?? "*"} :{message}"); 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 */ }
}
}
}
} }
@@ -72,13 +72,17 @@ public class ModerationController : ControllerBase
if (target.Role >= caller!.Role) if (target.Role >= caller!.Role)
return BadRequest(new ErrorResponse("Cannot kick a user with equal or higher 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); var channels = _presenceTracker.GetChannelsForUser(target.Username);
foreach (var channel in channels) foreach (var channel in channels)
{ {
await BroadcastToAllAsync(b => b.SendUserKickedAsync(channel, target.Username, request?.Reason)); 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." }); return Ok(new { Message = $"{target.Username} has been kicked." });
} }
@@ -98,8 +102,12 @@ public class ModerationController : ControllerBase
target.IsBanned = true; target.IsBanned = true;
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
// Broadcast ban notification, then force disconnect
await BroadcastToAllAsync(b => b.SendUserBannedAsync(target.Username, request?.Reason)); 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." }); return Ok(new { Message = $"{target.Username} has been banned." });
} }
@@ -217,6 +225,36 @@ public class ModerationController : ControllerBase
return (caller, null); 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) private async Task BroadcastToAllAsync(Func<IChatBroadcaster, Task> action)
{ {
foreach (var broadcaster in _broadcasters) foreach (var broadcaster in _broadcasters)
@@ -151,4 +151,27 @@ public class PresenceTracker
{ {
return _userConnections.Count; 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);
}
}
} }
@@ -73,4 +73,13 @@ public class SignalRBroadcaster : IChatBroadcaster
return HubContext.Clients.Client(connectionId).Error(message); 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);
}
} }