mirror of
https://github.com/Stone-Red-Code/EchoHub.git
synced 2026-09-04 09:06:07 +02:00
feat: implement notification sound functionality with user customization options
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
@@ -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"));
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user