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
- Command system reference
- 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
- [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
+26 -3
View File
@@ -1,4 +1,4 @@
# v0.2.3 - Moderation, Private Channels & UI Overhaul
# v0.2.3 - Moderation, Embeds & UI Overhaul
## Features
@@ -16,6 +16,21 @@
- `GET /api/channels` returns the combined list: public channels + user's joined private channels
- Channel creators are automatically added as members
### OpenGraph Link Embeds
- Messages containing URLs now show a rich preview below the message text
- Server-side fetching: detects the first URL in a message, fetches the page, and parses OpenGraph meta tags (`og:title`, `og:description`, `og:image`, `og:site_name`)
- OG images are converted to a small 24x12 colored ASCII thumbnail using the existing half-block renderer
- Embeds are persisted in the database and included in channel history
- TUI client renders embeds with a `▏` left border bar — site name and border in blue, title in white, description in gray, followed by the ASCII thumbnail
- IRC gateway receives a text-only embed preview (site name, title, description) — no ASCII thumbnail to keep IRC output clean
- Falls back to `<title>` tag when no OG tags are present; gracefully skips if no useful metadata is found
- 3-second fetch timeout ensures message delivery is never significantly delayed
- SSRF protection rejects private/loopback IP addresses before fetching
### Notification Sounds
- Incoming messages play a notification sound when the terminal is not focused
- Embedded MP3 asset with cross-platform playback support
### Online Users Panel
- Collapsible right-side panel showing online users in the current channel (toggle with F2)
- Users displayed with status indicators, role badges, and their custom nickname colors
@@ -36,6 +51,7 @@
- Version number shown in the status bar
- Custom colored rendering for channel list (active indicator, unread count badges)
- Avatar upload field added to the profile edit dialog (file path or URL)
- Profile avatar now renders with full color tag support in the profile view dialog
- Update check notification on connect — shows a system message if a newer GitHub release exists
- Chat messages no longer show selection/focus highlight
- Exit shortcut changed from Ctrl+C to Alt+Q — frees Ctrl+C for copy
@@ -48,12 +64,19 @@
- Wired `OnChannelUpdated` SignalR event so new public channels appear for all connected users in real time
- Fixed color tag parser using wrong regex group numbers (6,7,8 instead of 1,2,3) — new ASCII art was rendering without colors
- Full Unicode/emoji support — renderers use Terminal.Gui v2 grapheme cluster API (`GraphemeHelper`, `AddStr`) for proper wide character handling
- Emoji-to-text shortcode conversion for consistent cross-platform rendering
- Fixed `/send` and `/avatar` commands not handling file paths with spaces correctly, even when quoted
- Profile avatar now renders with full color tag support instead of showing raw tags
- Server-side newline spam protection — consecutive blank/whitespace-only lines collapsed to 1 and total lines capped at 30
## Infrastructure
- New `LinkEmbedService` on the server — URL detection, HTML fetching (first 64KB), OG tag parsing via regex, image thumbnail generation
- `EmbedDto` record added to shared Core DTOs; `MessageDto` extended with optional `Embed` field (backward-compatible)
- `EmbedJson` nullable column on the `Message` table stores serialized embed data (max 8KB)
- Dedicated `"OgFetch"` named HttpClient with bot User-Agent header and 5-second timeout
- `NotificationSoundService` for cross-platform audio playback of embedded notification sounds
- Startup `DataMigrationService` automatically converts old ANSI-format messages to the new color tag format on server boot, logging the count of migrated records
- Three new EF Core migrations: `AddModerationRoles`, `AddChannelIsPublic`, `AddChannelMembership`
- `EmojiHelper` utility for emoji-to-shortcode conversion
- Heartbeat handling in `ServerDirectoryService` for connection health checks
- Four new EF Core migrations: `AddModerationRoles`, `AddChannelIsPublic`, `AddChannelMembership`, `AddMessageEmbed`
- `ChannelMembership` table with cascade delete on both channel and user removal
-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 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"));
+2 -1
View File
@@ -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; }
}
@@ -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)
+41 -6
View File
@@ -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();
@@ -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);
}
@@ -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);
}
+18
View File
@@ -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 */ }
}
}
}
}
@@ -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)
@@ -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);
}
}
}
@@ -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);
}
}