mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-08 07:36:01 +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
|
- Theme system and customization
|
||||||
- Command system reference
|
- Command system reference
|
||||||
- Configuration management
|
- 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
|
## 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 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"));
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user