diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fc58994..0aabca1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -22,6 +22,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+ with:
+ submodules: recursive
+
+ # TEMPORARY: Terminal.Gui submodule's nuget.config breaks restore — remove until PR #4234 is merged
+ - name: Remove submodule NuGet config
+ run: rm -f src/Terminal.Gui/nuget.config
- name: Setup .NET 10
uses: actions/setup-dotnet@v4
@@ -29,7 +35,7 @@ jobs:
dotnet-version: '10.0.x'
- name: Check formatting
- run: dotnet format src/EchoHub.slnx --verify-no-changes --verbosity diagnostic
+ run: dotnet format src/EchoHub.slnx --verify-no-changes --verbosity diagnostic --exclude src/Terminal.Gui/
build-and-test:
name: Build & Test
@@ -38,6 +44,11 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
+ submodules: recursive
+
+ # TEMPORARY: Terminal.Gui submodule's nuget.config breaks restore — remove until PR #4234 is merged
+ - name: Remove submodule NuGet config
+ run: rm -f src/Terminal.Gui/nuget.config
- name: Check for src/ changes
id: changes
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 5156116..cbc2dde 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -16,6 +16,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+ with:
+ submodules: recursive
+
+ # TEMPORARY: Terminal.Gui submodule's nuget.config breaks restore — remove until PR #4234 is merged
+ - name: Remove submodule NuGet config
+ run: rm -f src/Terminal.Gui/nuget.config
- name: Setup .NET 10
uses: actions/setup-dotnet@v4
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 8bee4c9..1a200d6 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -16,6 +16,11 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
+ submodules: recursive
+
+ # TEMPORARY: Terminal.Gui submodule's nuget.config breaks restore — remove until PR #4234 is merged
+ - name: Remove submodule NuGet config
+ run: rm -f src/Terminal.Gui/nuget.config
- name: Check for src/ changes
id: changes
diff --git a/.gitignore b/.gitignore
index 886d4b6..db8d52d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -433,3 +433,6 @@ src/EchoHub.Server/uploads/*
# DocFx generated output
docs/_site/
docs/_api_meta/
+
+# Diff files
+*.diff
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..4803285
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "src/Terminal.Gui"]
+ path = src/Terminal.Gui
+ url = https://github.com/HueByte/Terminal.Gui.git
diff --git a/assets/hue_icon.ico b/assets/hue_icon.ico
new file mode 100644
index 0000000..dd2668c
Binary files /dev/null and b/assets/hue_icon.ico differ
diff --git a/assets/hue_icon.png b/assets/hue_icon.png
new file mode 100644
index 0000000..92c03cd
Binary files /dev/null and b/assets/hue_icon.png differ
diff --git a/assets/hue_icon.svg b/assets/hue_icon.svg
new file mode 100644
index 0000000..fb3a92e
--- /dev/null
+++ b/assets/hue_icon.svg
@@ -0,0 +1,11 @@
+
diff --git a/docs/api/client-articles/index.md b/docs/api/client-articles/index.md
index 171da8f..504bade 100644
--- a/docs/api/client-articles/index.md
+++ b/docs/api/client-articles/index.md
@@ -5,7 +5,9 @@ Articles related to the EchoHub TUI client built with Terminal.Gui v2.
## Topics
- Terminal.Gui v2 patterns and conventions
-- Theme system and customization
+- Theme system and customization (including transparent theme)
- Command system reference
-- Configuration management
+- Configuration and session persistence
+- Audio playback and file downloads
+- Automatic update checking
- [Notification sounds](../../articles/notification-sounds.md)
diff --git a/docs/api/core-articles/index.md b/docs/api/core-articles/index.md
index d8545b1..9eecf4c 100644
--- a/docs/api/core-articles/index.md
+++ b/docs/api/core-articles/index.md
@@ -5,5 +5,5 @@ Articles related to the EchoHub.Core shared library.
## Topics
- Data models and DTOs
-- Contract interfaces (IChatService, IChatBroadcaster, IEchoHubClient)
+- Contract interfaces (IChatService, IChannelService, IChatBroadcaster, IEchoHubClient)
- Validation constants and shared rules
diff --git a/docs/api/index.md b/docs/api/index.md
index b62904c..dc06867 100644
--- a/docs/api/index.md
+++ b/docs/api/index.md
@@ -10,11 +10,11 @@ Terminal.Gui v2 TUI application -- UI components, services, themes, and configur
### Core
-Shared library -- DTOs, models, constants, and contracts (`IChatService`, `IChatBroadcaster`, `IEchoHubClient`).
+Shared library -- DTOs, models, constants, and contracts (`IChatService`, `IChannelService`, `IChatBroadcaster`, `IEchoHubClient`).
### Server
-ASP.NET Core server -- controllers, hubs, ChatService, SignalRBroadcaster, authentication, and data access.
+ASP.NET Core server -- controllers, hubs, ChatService, ChannelService, SignalRBroadcaster, authentication, file cleanup, and data access.
### Server.Irc
diff --git a/docs/api/server-articles/index.md b/docs/api/server-articles/index.md
index 6cafdf8..9ceecae 100644
--- a/docs/api/server-articles/index.md
+++ b/docs/api/server-articles/index.md
@@ -8,6 +8,7 @@ Articles related to the EchoHub server built with ASP.NET Core.
- SignalR hub and real-time messaging
- IRC gateway and protocol bridging
- ChatService and broadcaster pattern
-- File upload and validation
+- ChannelService and channel CRUD
+- File upload, validation, and cleanup
- Rate limiting configuration
-- Database schema and migrations
+- Database schema, migrations, and DataMigrationService
diff --git a/docs/articles/architecture.md b/docs/articles/architecture.md
index bc59652..7ab7075 100644
--- a/docs/articles/architecture.md
+++ b/docs/articles/architecture.md
@@ -22,7 +22,7 @@ Shared library containing:
- **Models**: `User`, `Channel`, `Message`, `RefreshToken`
- **DTOs**: Record types for API requests/responses
-- **Contracts**: `IChatService` (protocol-agnostic chat operations), `IChatBroadcaster` (event fan-out interface), `IEchoHubClient` (SignalR client interface)
+- **Contracts**: `IChatService` (protocol-agnostic chat operations), `IChannelService` (channel CRUD and membership), `IChatBroadcaster` (event fan-out interface), `IEchoHubClient` (SignalR client interface)
- **Constants**: `ValidationConstants` (shared regex patterns), `HubConstants`
### EchoHub.Server
@@ -33,7 +33,7 @@ ASP.NET Core web application:
- **Hubs**: SignalR `ChatHub` -- thin adapter delegating to `IChatService`
- **Auth**: JWT token service (15-min access tokens, 30-day refresh tokens)
- **Data**: EF Core with SQLite
-- **Services**: `ChatService` (core business logic), `SignalRBroadcaster`, presence tracking, file storage, image-to-ASCII conversion
+- **Services**: `ChatService` (core business logic), `ChannelService` (channel CRUD and membership), `SignalRBroadcaster`, presence tracking, file storage, image-to-ASCII conversion, `FileCleanupService` (periodic removal of expired uploads), `DataMigrationService` (startup schema/data evolution)
### EchoHub.Server.Irc
@@ -51,9 +51,9 @@ IRC users authenticate with existing EchoHub accounts via `PASS`/`NICK`/`USER` o
Terminal.Gui v2 TUI application:
- **UI**: Main window, dialogs, chat renderer with ANSI color support
-- **Services**: API client with automatic token refresh, SignalR connection wrapper
-- **Themes**: 13 built-in color themes
-- **Config**: Client configuration management
+- **Services**: API client with automatic token refresh, SignalR connection wrapper, audio playback (NetCoreAudio), automatic update checker (AlwaysUpToDate)
+- **Themes**: 13 built-in color themes (including transparent theme with true terminal transparency)
+- **Config**: Client configuration management with session persistence ("Remember Me" refresh tokens)
## Communication
diff --git a/docs/changelog/index.md b/docs/changelog/index.md
index 647908c..f35822f 100644
--- a/docs/changelog/index.md
+++ b/docs/changelog/index.md
@@ -4,6 +4,8 @@ Release history for EchoHub.
## Releases
+- [v0.2.5](v0.2.5.md) - Session Persistence, Auto-Updates, Audio & Transparent Theme
+- [v0.2.4](v0.2.4.md) - E2E Message Encryption
- [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
diff --git a/docs/changelog/toc.yml b/docs/changelog/toc.yml
index 60d383d..1079748 100644
--- a/docs/changelog/toc.yml
+++ b/docs/changelog/toc.yml
@@ -1,5 +1,7 @@
- name: Overview
href: index.md
+- name: v0.2.5
+ href: v0.2.5.md
- name: v0.2.4
href: v0.2.4.md
- name: v0.2.3
diff --git a/docs/changelog/v0.2.4.md b/docs/changelog/v0.2.4.md
index 6663103..2bae94e 100644
--- a/docs/changelog/v0.2.4.md
+++ b/docs/changelog/v0.2.4.md
@@ -28,5 +28,5 @@
- `Encryption:EncryptDatabase` server setting (default `false`) controls whether messages are encrypted at rest
- DB column max lengths increased for encrypted content: `Message.Content` 2000 → 16000, `Message.EmbedJson` 8000 → 32000
- EF Core migration: `AddEncryptionSupport`
-- Encryption test suite: server-side, client-side, and cross-compatibility tests (87 total)
+- Encryption test suite: server-side, client-side, and cross-compatibility tests
- Documentation article: `docs/articles/encryption.md`
diff --git a/docs/changelog/v0.2.5.md b/docs/changelog/v0.2.5.md
new file mode 100644
index 0000000..45e6b67
--- /dev/null
+++ b/docs/changelog/v0.2.5.md
@@ -0,0 +1,119 @@
+# v0.2.5 - Session Persistence, Auto-Updates, Audio & Transparent Theme
+
+## Features
+
+### Audio Message Support
+
+- New `Audio` message type — uploaded audio files (`.mp3`, `.wav`, `.ogg`, `.flac`, `.aac`, `.m4a`, `.wma`) are automatically detected and categorized
+- TUI client renders audio messages with `♪ [Audio: filename] (Enter to play)` indicator
+- Press Enter on an audio message to open the **Audio Player dialog** with animated wave visualization, play/pause/stop controls, and volume slider
+- Per-type upload limits: **10 MB** images, **10 MB** audio, **100 MB** generic files (with Kestrel request size configured to match)
+- `AudioPlaybackService` enhanced with pause/resume, volume control, and playback-finished events
+- Fixed: wrapped audio/file messages now remain clickable on all lines (attachment metadata propagated through word-wrap)
+- IRC gateway formats audio messages as `♪ [Audio: filename] url`
+- Server detects audio files by extension via `FileValidationHelper.IsAudioFile()`
+
+### File Downloads
+
+- Press Enter on a file message in the TUI client to download and open it with the system default application
+- `ApiClient.DownloadFileAsync()` streams file downloads from the server
+- File and audio messages in the chat list now show colored indicators with interaction hints
+
+### File Cleanup Service
+
+- `FileCleanupService` (BackgroundService) periodically removes old uploaded files
+- Configurable via `Storage:CleanupIntervalHours` (default 1h) and `Storage:RetentionDays` (default 30d)
+
+### Data Migration Service
+
+- `DataMigrationService` runs at startup to handle schema/data evolution
+- Ensures `#general` channel and pre-existing channels are marked public
+- Migrates legacy ANSI escape codes in image messages to printable color tags (`{F:RRGGBB}`, `{B:RRGGBB}`, `{X}`)
+- Migrates legacy single-object `EmbedJson` to array format
+- Promotes usernames listed in `Server:Admins` config to Admin role
+
+### True Transparent Background
+
+- Transparent theme now uses the terminal's native background instead of solid black
+- Powered by `Color.None` (alpha=0) which emits ANSI `CSI 49m` (default background) instead of explicit RGB
+- Terminal transparency, acrylic, wallpaper effects now show through the TUI
+- Dialogs retain solid `DarkGray` background for readability
+- Uses local Terminal.Gui fork (submodule) with transparent color support pending upstream merge ([gui-cs/Terminal.Gui#4234](https://github.com/gui-cs/Terminal.Gui/pull/4234))
+
+### Enhanced Status Bar
+
+- Status bar now shows **EchoHub** branding at the start
+- Connection state is color-coded: green (Connected), red (Disconnected), yellow (transitional states like Connecting, Reconnecting, Authenticating)
+- Current channel shows its type: `#channel - public` or `#channel - private`
+
+### Remember Me (Session Persistence)
+
+- "Remember me" checkbox in the connect dialog — saves a 30-day refresh token so users can reconnect without entering their password
+- Saved servers with active sessions show `[session]` indicator in the connect dialog and saved servers list
+- Token-based login: selecting a saved server with a session lets you click Login with an empty password
+- Graceful expiry handling: if the saved session is expired or revoked, shows an error and prompts for password
+- Refresh token rotation: rotated tokens are automatically persisted to config so the session stays valid across refreshes
+- New "Logout" menu item (Server menu): revokes the refresh token server-side and clears the saved session
+- Removed dead `SavedServer.Token` field (stored 15-min access token that was never read back)
+
+### Automatic Update Checking
+
+- `UpdateChecker` rewritten to use the [AlwaysUpToDate](https://github.com/AuriRex/AlwaysUpToDate) library for self-updating
+- Polls the EchoHub version manifest hourly (active only in `RELEASE` builds)
+- `UpdateConfirmDialog` shows current vs. available version with Update/Cancel buttons
+- `UpdateProgressDialog` displays real-time download progress bar
+- Errors are logged via Serilog instead of being silently swallowed
+
+### Windows Installer
+
+- New Inno Setup script (`installer/Installer.iss`) to build a Windows installer
+- Auto-reads product version from the built EXE
+- User-level install by default (no admin required), with admin override option
+- Creates desktop and quick-launch shortcuts (optional)
+- Supports English and German languages
+
+### Application Icons
+
+- New `hue_icon` branding assets (ICO, PNG, SVG) in `assets/`
+- Application icon set on both Client and Server projects
+
+### #general Channel Protection
+
+- `#general` channel is now auto-recreated if somehow missing (both in `GetChannels` endpoint and `JoinChannel` flow)
+- Users cannot `/leave` the #general channel (client-side guard)
+- Connecting while already connected now prompts to disconnect first instead of silently leaking the previous connection
+
+## Fixes
+
+### IRC Gateway
+
+- **Fixed: IRC JOIN history replay showed encrypted gibberish** — `IrcCommandHandler.HandleJoinAsync` now decrypts channel history before formatting for IRC clients (history was encrypted for SignalR transport but sent raw to IRC)
+- **Fixed: `JoinedChannels` race condition** — `IrcClientConnection.JoinedChannels` replaced with thread-safe methods (`JoinChannel`, `LeaveChannel`, `IsInChannel`, `GetJoinedChannels`) using lock synchronization; prevents crashes when broadcaster threads read while the command handler writes
+- **Fixed: `RequireRegistered` fire-and-forget** — converted from sync `bool` to `async Task` (`RequireRegisteredAsync`) so the error reply is properly awaited before the handler returns
+
+### ChannelService Extraction
+
+- Extracted channel management logic from `ChannelsController` and `ChatService` into a dedicated `IChannelService` / `ChannelService`
+- `ChannelsController` is now a thin adapter — delegates CRUD operations to `IChannelService` and maps `ChannelError` to HTTP status codes
+- `ChatService.JoinChannelAsync` delegates channel validation + membership to `IChannelService.EnsureChannelMembershipAsync()`
+- New `ChannelOperationResult` result type with `ChannelError` enum for typed error handling across service boundaries
+- IRC gateway uses `IChannelService` for topic queries and channel listing (instead of `IChatService`)
+
+### EchoHub Branding
+
+- Status bar "EchoHub" text now uses golden color (218, 165, 32)
+
+## Infrastructure
+
+- Audio MIME types in `FilesController` (mp3, wav, ogg, flac, aac, m4a, wma)
+- CI workflows updated to handle Terminal.Gui submodule `nuget.config` workaround (`rm -f` step)
+- Root `nuget.config` added for package source management
+- Terminal.Gui formatting excluded from CI format checks
+- Recursive submodule fetching enabled in CI workflows
+- `Server:Admins` config array in `appsettings.example.json` for designating admin usernames
+- `Storage:CleanupIntervalHours` and `Storage:RetentionDays` added to `appsettings.example.json`
+- `FakeChannelService` test helper added for IRC unit tests
+- Test suites: ChatLine, CommandHandler, DataMigrationService, FileValidationHelper, ImageToAsciiService, IrcMessageFormatter, JwtTokenService, LinkEmbedService
+- IRC abstraction layer test suite: IrcMessage parsing, IrcMessageFormatter, IrcClientConnection, IrcCommandHandler, IrcBroadcaster
+- Test helpers: `TestDuplexStream`, `TestIrcConnectionFactory`, `FakeChatService`, `FakeChannelService`, `FakeEncryptionService` for IRC unit testing without network I/O
+- 346 total tests
diff --git a/installer/EchoHub-Installer.exe b/installer/EchoHub-Installer.exe
new file mode 100644
index 0000000..22a0eb0
Binary files /dev/null and b/installer/EchoHub-Installer.exe differ
diff --git a/installer/Installer.iss b/installer/Installer.iss
new file mode 100644
index 0000000..b833071
--- /dev/null
+++ b/installer/Installer.iss
@@ -0,0 +1,52 @@
+; Script generated by the Inno Setup Script Wizard.
+; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES!
+
+#expr Exec('cmd.exe', '/C dotnet build -o "' + SourcePath + '\publish" -c Release ' + SourcePath + '..\src\EchoHub.Client\')
+
+#define MyAppName "EchoHub"
+#define MyAppVersion GetStringFileInfo("/publish/EchoHub.Client.exe","ProductVersion")
+#define MyAppPublisher "Hue"
+#define MyAppExeName "EchoHub.Client.exe"
+
+[Setup]
+; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications.
+; (To generate a new GUID, click Tools | Generate GUID inside the IDE.)
+AppId=c95b1292-3022-4c62-a131-4d46ace370f5
+AppName={#MyAppName}
+AppVersion={#MyAppVersion}
+;AppVerName={#MyAppName} {#MyAppVersion}
+AppPublisher={#MyAppPublisher}
+DefaultDirName={autopf}\{#MyAppName}
+DisableProgramGroupPage=yes
+SetupIconFile=../assets/hue_icon.ico
+; The [Icons] "quicklaunchicon" entry uses {userappdata} but its [Tasks] entry has a proper IsAdminInstallMode Check.
+UsedUserAreasWarning=no
+; Remove the following line to run in administrative install mode (install for all users.)
+PrivilegesRequired=lowest
+PrivilegesRequiredOverridesAllowed=dialog
+OutputBaseFilename={#MyAppName}-Installer
+OutputDir=.
+Compression=lzma
+SolidCompression=yes
+WizardStyle=modern
+
+[Languages]
+Name: "english"; MessagesFile: "compiler:Default.isl"
+Name: "german"; MessagesFile: "compiler:Languages\German.isl"
+
+[Tasks]
+Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
+Name: "quicklaunchicon"; Description: "{cm:CreateQuickLaunchIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked; OnlyBelowVersion: 6.1; Check: not IsAdminInstallMode
+
+[Files]
+Source: "publish\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs
+; NOTE: Don't use "Flags: ignoreversion" on any shared system files
+
+[Icons]
+Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
+Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
+Name: "{userappdata}\Microsoft\Internet Explorer\Quick Launch\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: quicklaunchicon
+
+[Run]
+Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
+
diff --git a/nuget.config b/nuget.config
new file mode 100644
index 0000000..4e800bf
--- /dev/null
+++ b/nuget.config
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index e2dfc80..381226e 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -1,6 +1,6 @@
- 0.2.4
+ 0.2.5
true
$(NoWarn);CS1591
diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs
index 40748ac..23449d2 100644
--- a/src/EchoHub.Client/AppOrchestrator.cs
+++ b/src/EchoHub.Client/AppOrchestrator.cs
@@ -22,6 +22,8 @@ public sealed class AppOrchestrator : IDisposable
private readonly MainWindow _mainWindow;
private readonly CommandHandler _commandHandler;
private readonly NotificationSoundService _notificationSound;
+ private readonly AudioPlaybackService _audioPlayback = new();
+ private readonly UpdateChecker _updateService;
private EchoHubConnection? _connection;
private ApiClient? _apiClient;
@@ -44,10 +46,13 @@ public sealed class AppOrchestrator : IDisposable
_mainWindow = new MainWindow(app);
_commandHandler = new CommandHandler();
_notificationSound = new NotificationSoundService(config.Notifications);
+ _updateService = new UpdateChecker(app);
WireMainWindowEvents();
WireCommandHandlerEvents();
+ _updateService.Start();
+
_mainWindow.UpdateStatusBar("Disconnected");
}
@@ -55,6 +60,7 @@ public sealed class AppOrchestrator : IDisposable
{
_connection?.DisposeAsync().AsTask().GetAwaiter().GetResult();
_apiClient?.Dispose();
+ _updateService.Dispose();
}
// ── Convenience Helpers ────────────────────────────────────────────────
@@ -72,6 +78,7 @@ public sealed class AppOrchestrator : IDisposable
{
_mainWindow.OnConnectRequested += HandleConnect;
_mainWindow.OnDisconnectRequested += HandleDisconnect;
+ _mainWindow.OnLogoutRequested += HandleLogout;
_mainWindow.OnMessageSubmitted += HandleMessageSubmitted;
_mainWindow.OnChannelSelected += HandleChannelSelected;
_mainWindow.OnProfileRequested += HandleProfileRequested;
@@ -80,6 +87,8 @@ public sealed class AppOrchestrator : IDisposable
_mainWindow.OnSavedServersRequested += HandleSavedServersRequested;
_mainWindow.OnCreateChannelRequested += HandleCreateChannelRequested;
_mainWindow.OnDeleteChannelRequested += HandleDeleteChannelRequested;
+ _mainWindow.OnAudioPlayRequested += HandleAudioPlayRequested;
+ _mainWindow.OnFileDownloadRequested += HandleFileDownloadRequested;
}
// ── Command Handler Wiring ─────────────────────────────────────────────
@@ -235,6 +244,12 @@ public sealed class AppOrchestrator : IDisposable
var channel = _mainWindow.CurrentChannel;
if (string.IsNullOrEmpty(channel)) return;
+ if (channel == HubConstants.DefaultChannel)
+ {
+ InvokeUI(() => _mainWindow.ShowError($"You cannot leave the #{HubConstants.DefaultChannel} channel."));
+ return;
+ }
+
try
{
await _connection!.LeaveChannelAsync(channel);
@@ -364,6 +379,16 @@ public sealed class AppOrchestrator : IDisposable
private void HandleConnect()
{
+ if (IsConnected)
+ {
+ var confirm = MessageBox.Query(_app, "Already Connected",
+ "You are already connected to a server.\nDisconnect and connect to a new one?", "Yes", "Cancel");
+
+ if (confirm != 0) return;
+
+ HandleDisconnect();
+ }
+
var result = ConnectDialog.Show(_app, _config.SavedServers);
if (result is null) return;
@@ -377,12 +402,55 @@ public sealed class AppOrchestrator : IDisposable
InvokeUI(() => _mainWindow.UpdateStatusBar("Authenticating..."));
- var loginResponse = result.IsRegister
- ? await _apiClient.RegisterAsync(result.Username, result.Password)
- : await _apiClient.LoginAsync(result.Username, result.Password);
+ LoginResponse loginResponse;
+
+ if (result.SavedRefreshToken is not null)
+ {
+ try
+ {
+ loginResponse = await _apiClient.LoginWithRefreshTokenAsync(result.SavedRefreshToken);
+ Log.Information("Authenticated via saved session for {User}", loginResponse.Username);
+ }
+ catch (Exception ex)
+ {
+ Log.Warning(ex, "Saved session expired or revoked");
+ ClearSavedToken(result.ServerUrl);
+ InvokeUI(() =>
+ {
+ _mainWindow.UpdateStatusBar("Disconnected");
+ MessageBox.ErrorQuery(_app, "Session Expired",
+ "Your saved session has expired or was revoked.\nPlease log in with your password.", "OK");
+ });
+ _apiClient.Dispose();
+ _apiClient = null;
+ return;
+ }
+ }
+ else if (result.IsRegister)
+ {
+ loginResponse = await _apiClient.RegisterAsync(result.Username, result.Password);
+ }
+ else
+ {
+ loginResponse = await _apiClient.LoginAsync(result.Username, result.Password);
+ }
_currentUsername = loginResponse.Username;
+ // Persist rotated refresh tokens for Remember Me
+ _apiClient.OnTokensRefreshed += () =>
+ {
+ if (_apiClient?.RefreshToken is null) return;
+ var config = ConfigManager.Load();
+ var server = config.SavedServers.FirstOrDefault(s =>
+ string.Equals(s.Url, _apiClient.BaseUrl, StringComparison.OrdinalIgnoreCase));
+ if (server is not null && server.RememberMe)
+ {
+ server.RefreshToken = _apiClient.RefreshToken;
+ ConfigManager.Save(config);
+ }
+ };
+
// Fetch encryption key for E2E message encryption
InvokeUI(() => _mainWindow.UpdateStatusBar("Fetching encryption key..."));
try
@@ -437,18 +505,6 @@ public sealed class AppOrchestrator : IDisposable
FetchAndUpdateOnlineUsers();
SaveServerToConfig(result);
-
- // Check for newer version in the background
- _ = Task.Run(async () =>
- {
- var newVersion = await UpdateChecker.CheckForUpdateAsync();
- if (newVersion is not null)
- {
- InvokeUI(() => _mainWindow.AddSystemMessage(
- HubConstants.DefaultChannel,
- $"A new version of EchoHub is available: v{newVersion} (current: v{MainWindow.AppVersion}). Visit https://github.com/HueByte/EchoHub/releases"));
- }
- });
}, "Connection failed", "Connect");
}
@@ -477,6 +533,38 @@ public sealed class AppOrchestrator : IDisposable
}, "Disconnect error", "Disconnect");
}
+ private void HandleLogout()
+ {
+ Log.Information("Logging out from server");
+
+ RunAsync(async () =>
+ {
+ if (_apiClient is not null)
+ {
+ var baseUrl = _apiClient.BaseUrl;
+ await _apiClient.LogoutAsync();
+ ClearSavedToken(baseUrl);
+ }
+
+ if (_connection is not null)
+ {
+ await _connection.DisconnectAsync();
+ await _connection.DisposeAsync();
+ _connection = null;
+ }
+
+ _apiClient?.Dispose();
+ _apiClient = null;
+ _joinedChannels.Clear();
+
+ InvokeUI(() =>
+ {
+ _mainWindow.ClearAll();
+ _mainWindow.UpdateStatusBar("Disconnected");
+ });
+ }, "Logout error", "Logout");
+ }
+
private void HandleMessageSubmitted(string channelName, string content)
{
if (!IsConnected)
@@ -722,7 +810,11 @@ public sealed class AppOrchestrator : IDisposable
}
var serverLines = _config.SavedServers
- .Select(s => $"{s.Name} ({s.Url}) - {s.Username ?? "?"} - {s.LastConnected:yyyy-MM-dd}")
+ .Select(s =>
+ {
+ var session = !string.IsNullOrEmpty(s.RefreshToken) ? " [session saved]" : "";
+ return $"{s.Name} ({s.Url}) - {s.Username ?? "?"} - {s.LastConnected:yyyy-MM-dd}{session}";
+ })
.ToList();
MessageBox.Query(_app, "Saved Servers", string.Join("\n", serverLines), "OK");
@@ -798,6 +890,40 @@ public sealed class AppOrchestrator : IDisposable
}, "Failed to delete channel");
}
+ private void HandleAudioPlayRequested(string attachmentUrl, string fileName)
+ {
+ if (!IsAuthenticated) return;
+
+ RunAsync(async () =>
+ {
+ InvokeUI(() => _mainWindow.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}..."));
+ var tempPath = await _apiClient!.DownloadFileToTempAsync(attachmentUrl, fileName);
+ InvokeUI(() => AudioPlayerDialog.Show(_app, _audioPlayback, tempPath, fileName));
+ }, "Failed to play audio");
+ }
+
+ private void HandleFileDownloadRequested(string attachmentUrl, string fileName)
+ {
+ if (!IsAuthenticated) return;
+
+ RunAsync(async () =>
+ {
+ InvokeUI(() => _mainWindow.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}..."));
+ var tempPath = await _apiClient!.DownloadFileToTempAsync(attachmentUrl, fileName);
+
+ try
+ {
+ var psi = new System.Diagnostics.ProcessStartInfo(tempPath) { UseShellExecute = true };
+ System.Diagnostics.Process.Start(psi);
+ }
+ catch (Exception ex)
+ {
+ Log.Warning(ex, "Failed to open file with default app: {Path}", tempPath);
+ InvokeUI(() => _mainWindow.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloaded to: {tempPath}"));
+ }
+ }, "Failed to download file");
+ }
+
// ── Connection Event Wiring ────────────────────────────────────────────
private void WireConnectionEvents(EchoHubConnection connection)
@@ -897,7 +1023,7 @@ public sealed class AppOrchestrator : IDisposable
InvokeUI(() =>
{
if (channel.IsPublic)
- _mainWindow.EnsureChannelInList(channel.Name);
+ _mainWindow.EnsureChannelInList(channel.Name, channel.IsPublic);
_mainWindow.SetChannelTopic(channel.Name, channel.Topic);
});
};
@@ -957,11 +1083,25 @@ public sealed class AppOrchestrator : IDisposable
Name = new Uri(result.ServerUrl).Host,
Url = result.ServerUrl,
Username = result.Username,
- Token = _apiClient!.Token,
+ RefreshToken = result.RememberMe ? _apiClient!.RefreshToken : null,
+ RememberMe = result.RememberMe,
LastConnected = DateTimeOffset.Now
};
ConfigManager.SaveServer(savedServer);
_config = ConfigManager.Load();
Log.Information("Connected successfully to {Url}", result.ServerUrl);
}
+
+ private void ClearSavedToken(string serverUrl)
+ {
+ var config = ConfigManager.Load();
+ var server = config.SavedServers.FirstOrDefault(s =>
+ string.Equals(s.Url, serverUrl, StringComparison.OrdinalIgnoreCase));
+ if (server is not null)
+ {
+ server.RefreshToken = null;
+ ConfigManager.Save(config);
+ _config = config;
+ }
+ }
}
diff --git a/src/EchoHub.Client/Commands/CommandHandler.cs b/src/EchoHub.Client/Commands/CommandHandler.cs
index b2304dd..71afd56 100644
--- a/src/EchoHub.Client/Commands/CommandHandler.cs
+++ b/src/EchoHub.Client/Commands/CommandHandler.cs
@@ -339,7 +339,7 @@ public class CommandHandler
/nick - Set display name
/color <#hex> - Set nickname color
/theme - Switch theme
- /send [-s|-m|-l] - Send a file or image (size: small/medium/large)
+ /send [-s|-m|-l] - Send file/image/audio (size flag for images)
/avatar - Set your avatar
/profile [username] - View a profile
/servers - Open saved servers
diff --git a/src/EchoHub.Client/Config/ClientConfig.cs b/src/EchoHub.Client/Config/ClientConfig.cs
index 80052a7..02154bd 100644
--- a/src/EchoHub.Client/Config/ClientConfig.cs
+++ b/src/EchoHub.Client/Config/ClientConfig.cs
@@ -20,7 +20,8 @@ public class SavedServer
public required string Name { get; set; }
public required string Url { get; set; }
public string? Username { get; set; }
- public string? Token { get; set; }
+ public string? RefreshToken { get; set; }
+ public bool RememberMe { get; set; }
public DateTimeOffset LastConnected { get; set; }
}
diff --git a/src/EchoHub.Client/EchoHub.Client.csproj b/src/EchoHub.Client/EchoHub.Client.csproj
index 5e838b3..27b0268 100644
--- a/src/EchoHub.Client/EchoHub.Client.csproj
+++ b/src/EchoHub.Client/EchoHub.Client.csproj
@@ -5,19 +5,22 @@
+
-
+
+
PreserveNewest
+
PreserveNewest
@@ -31,6 +34,8 @@
net10.0
enable
enable
+
+ hue_icon.ico
diff --git a/src/EchoHub.Client/Services/ApiClient.cs b/src/EchoHub.Client/Services/ApiClient.cs
index 825cad0..fea3b2a 100644
--- a/src/EchoHub.Client/Services/ApiClient.cs
+++ b/src/EchoHub.Client/Services/ApiClient.cs
@@ -18,6 +18,8 @@ public sealed class ApiClient : IDisposable
public string? RefreshToken => _refreshToken;
public string BaseUrl { get; }
+ public event Action? OnTokensRefreshed;
+
public ApiClient(string baseUrl)
{
BaseUrl = baseUrl.TrimEnd('/');
@@ -68,6 +70,19 @@ public sealed class ApiClient : IDisposable
SetTokens(result);
}
+ public async Task LoginWithRefreshTokenAsync(string refreshToken)
+ {
+ var request = new RefreshRequest(refreshToken);
+ var response = await _http.PostAsJsonAsync("/api/auth/refresh", request);
+ await EnsureSuccessAsync(response);
+
+ var result = await response.Content.ReadFromJsonAsync()
+ ?? throw new InvalidOperationException("Token refresh returned empty response.");
+
+ SetTokens(result);
+ return result;
+ }
+
public async Task LogoutAsync()
{
if (!string.IsNullOrEmpty(_refreshToken))
@@ -196,6 +211,23 @@ public sealed class ApiClient : IDisposable
return await response.Content.ReadFromJsonAsync();
}
+ public async Task DownloadFileToTempAsync(string relativeUrl, string fileName)
+ {
+ EnsureAuthenticated();
+ var response = await AuthenticatedGetAsync(relativeUrl);
+ await EnsureSuccessAsync(response);
+
+ var tempDir = Path.Combine(Path.GetTempPath(), "EchoHub");
+ Directory.CreateDirectory(tempDir);
+ var tempPath = Path.Combine(tempDir, $"{Guid.NewGuid():N}_{fileName}");
+
+ await using var stream = await response.Content.ReadAsStreamAsync();
+ await using var file = File.Create(tempPath);
+ await stream.CopyToAsync(file);
+
+ return tempPath;
+ }
+
public async Task CreateChannelAsync(string name, string? topic = null, bool isPublic = true)
{
EnsureAuthenticated();
@@ -296,6 +328,7 @@ public sealed class ApiClient : IDisposable
_refreshToken = result.RefreshToken;
_expiresAt = result.ExpiresAt;
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
+ OnTokensRefreshed?.Invoke();
}
///
diff --git a/src/EchoHub.Client/Services/AudioPlaybackService.cs b/src/EchoHub.Client/Services/AudioPlaybackService.cs
new file mode 100644
index 0000000..7213ae7
--- /dev/null
+++ b/src/EchoHub.Client/Services/AudioPlaybackService.cs
@@ -0,0 +1,85 @@
+using NetCoreAudio;
+using Serilog;
+
+namespace EchoHub.Client.Services;
+
+public class AudioPlaybackService
+{
+ private readonly Player _player = new();
+
+ public bool IsPlaying => _player.Playing;
+ public bool IsPaused => _player.Paused;
+
+ public event EventHandler? PlaybackFinished;
+
+ public AudioPlaybackService()
+ {
+ _player.PlaybackFinished += (s, e) => PlaybackFinished?.Invoke(this, EventArgs.Empty);
+ }
+
+ public async Task PlayAsync(string filePath)
+ {
+ try
+ {
+ if (_player.Playing)
+ await _player.Stop();
+
+ await _player.Play(filePath);
+ }
+ catch (Exception ex)
+ {
+ Log.Warning(ex, "Failed to play audio file: {Path}", filePath);
+ }
+ }
+
+ public async Task PauseAsync()
+ {
+ try
+ {
+ if (_player.Playing && !_player.Paused)
+ await _player.Pause();
+ }
+ catch (Exception ex)
+ {
+ Log.Warning(ex, "Failed to pause audio playback");
+ }
+ }
+
+ public async Task ResumeAsync()
+ {
+ try
+ {
+ if (_player.Paused)
+ await _player.Resume();
+ }
+ catch (Exception ex)
+ {
+ Log.Warning(ex, "Failed to resume audio playback");
+ }
+ }
+
+ public async Task StopAsync()
+ {
+ try
+ {
+ if (_player.Playing)
+ await _player.Stop();
+ }
+ catch (Exception ex)
+ {
+ Log.Warning(ex, "Failed to stop audio playback");
+ }
+ }
+
+ public async Task SetVolumeAsync(byte volume)
+ {
+ try
+ {
+ await _player.SetVolume(Math.Min(volume, (byte)100));
+ }
+ catch (Exception ex)
+ {
+ Log.Warning(ex, "Failed to set audio volume");
+ }
+ }
+}
diff --git a/src/EchoHub.Client/Services/UpdateChecker.cs b/src/EchoHub.Client/Services/UpdateChecker.cs
index d73c9df..9a204ad 100644
--- a/src/EchoHub.Client/Services/UpdateChecker.cs
+++ b/src/EchoHub.Client/Services/UpdateChecker.cs
@@ -1,47 +1,106 @@
-using System.Net.Http.Json;
-using System.Text.Json.Serialization;
+using AlwaysUpToDate;
+
+using EchoHub.Client.UI;
+
+using Serilog;
+
+using Terminal.Gui.App;
namespace EchoHub.Client.Services;
-public static class UpdateChecker
+public sealed class UpdateChecker : IDisposable
{
- private static readonly Uri ReleaseUrl =
- new("https://api.github.com/repos/HueByte/EchoHub/releases/latest");
+ private const string ManifestUrl = "https://echohub.voidcube.cloud/api/app/version";
- ///
- /// Checks GitHub for a newer release. Returns the new version string if one exists, or null.
- /// Never throws — all errors are silently swallowed.
- ///
- public static async Task CheckForUpdateAsync()
+ private readonly Updater _updater;
+ private readonly IApplication _app;
+ private UpdateProgressDialog? _progressDialog;
+
+ public static string CurrentVersion => typeof(UpdateChecker).Assembly.GetName().Version?.ToString(3) ?? "0.0.0";
+
+ public UpdateChecker(IApplication app)
{
- try
- {
- using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
- http.DefaultRequestHeaders.UserAgent.ParseAdd("EchoHub-Client");
+ _app = app;
+ _updater = new Updater(TimeSpan.FromHours(1), ManifestUrl, false);
- var release = await http.GetFromJsonAsync(ReleaseUrl);
- if (release?.TagName is null)
- return null;
-
- var tag = release.TagName.TrimStart('v', 'V');
- if (!Version.TryParse(tag, out var latest))
- return null;
-
- var currentStr = typeof(UpdateChecker).Assembly.GetName().Version?.ToString(3);
- if (currentStr is null || !Version.TryParse(currentStr, out var current))
- return null;
-
- return latest > current ? tag : null;
- }
- catch
- {
- return null;
- }
+ _updater.UpdateAvailable += OnUpdateAvailable;
+ _updater.ProgressChanged += OnProgressChanged;
+ _updater.UpdateStarted += OnUpdateStarted;
+ _updater.NoUpdateAvailable += OnNoUpdateAvailable;
+ _updater.OnException += OnException;
}
- private sealed class GitHubRelease
+ public void Start()
{
- [JsonPropertyName("tag_name")]
- public string? TagName { get; set; }
+#if RELEASE
+ _updater.Start();
+#endif
+ }
+
+ private async void OnUpdateAvailable(string version, string changelogUrl)
+ {
+ Log.Information("Update available: v{Version}", version);
+
+ var confirmed = false;
+ _app.Invoke(() =>
+ {
+ confirmed = UpdateConfirmDialog.Show(_app, CurrentVersion, version);
+
+
+ if (confirmed)
+ {
+ _progressDialog = new UpdateProgressDialog(_app, version);
+
+ // Start the update; progress is reported via OnProgressChanged
+ _ = Task.Run(async () =>
+ {
+ await _updater.UpdateAsync();
+ });
+
+ _progressDialog?.Show();
+ }
+ });
+ }
+
+ private void OnProgressChanged(UpdateStep step, long itemsProcessed, long? totalItems, double? progressPercentage)
+ {
+ var fraction = progressPercentage.HasValue ? (float)(progressPercentage.Value / 100.0) : 0f;
+ var statusText = $"{step}: {itemsProcessed}/{totalItems ?? 0} ({progressPercentage ?? 0:F0}%)";
+
+ if (!progressPercentage.HasValue)
+ {
+ statusText = $"{step}...";
+ }
+
+ _progressDialog?.UpdateProgress(fraction, statusText);
+ }
+
+ private void OnUpdateStarted(string version)
+ {
+ Log.Information("Update started: v{Version}", version);
+ }
+
+ private void OnNoUpdateAvailable()
+ {
+ Log.Debug("No update available");
+ }
+
+ private void OnException(Exception exception)
+ {
+ Log.Error(exception, "Update check failed");
+ _app.Invoke(() =>
+ {
+ _progressDialog?.Close();
+ _progressDialog = null;
+ });
+ }
+
+ public void Dispose()
+ {
+ _updater.UpdateAvailable -= OnUpdateAvailable;
+ _updater.ProgressChanged -= OnProgressChanged;
+ _updater.UpdateStarted -= OnUpdateStarted;
+ _updater.NoUpdateAvailable -= OnNoUpdateAvailable;
+ _updater.OnException -= OnException;
}
}
diff --git a/src/EchoHub.Client/Themes/ThemeManager.cs b/src/EchoHub.Client/Themes/ThemeManager.cs
index 056e6f3..763057b 100644
--- a/src/EchoHub.Client/Themes/ThemeManager.cs
+++ b/src/EchoHub.Client/Themes/ThemeManager.cs
@@ -418,30 +418,30 @@ public static class ThemeManager
Base = new ThemeColors
{
Foreground = "White",
- Background = "Black",
+ Background = "None",
FocusForeground = "BrightCyan",
- FocusBackground = "Black"
+ FocusBackground = "None"
},
Menu = new ThemeColors
{
Foreground = "White",
- Background = "Black",
+ Background = "None",
FocusForeground = "BrightCyan",
- FocusBackground = "Black"
+ FocusBackground = "None"
},
Dialog = new ThemeColors
{
Foreground = "White",
- Background = "Black",
+ Background = "DarkGray",
FocusForeground = "BrightCyan",
FocusBackground = "Black"
},
Status = new ThemeColors
{
Foreground = "Gray",
- Background = "Black",
+ Background = "None",
FocusForeground = "Gray",
- FocusBackground = "Black"
+ FocusBackground = "None"
}
};
diff --git a/src/EchoHub.Client/UI/AudioPlayerDialog.cs b/src/EchoHub.Client/UI/AudioPlayerDialog.cs
new file mode 100644
index 0000000..448a705
--- /dev/null
+++ b/src/EchoHub.Client/UI/AudioPlayerDialog.cs
@@ -0,0 +1,410 @@
+using EchoHub.Client.Services;
+using Terminal.Gui.App;
+using Terminal.Gui.Drawing;
+using Terminal.Gui.ViewBase;
+using Terminal.Gui.Views;
+using Attribute = Terminal.Gui.Drawing.Attribute;
+
+namespace EchoHub.Client.UI;
+
+public sealed class AudioPlayerDialog
+{
+ // Block characters for wave animation (increasing height)
+ private static readonly string[] WaveBlocks = ["\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"];
+ private const int WaveBarCount = 24;
+ private const int AnimationIntervalMs = 150;
+
+ private static readonly Attribute WaveActiveAttr = new(new Color(180, 100, 255), Color.None);
+ private static readonly Attribute WaveIdleAttr = new(new Color(80, 50, 120), Color.None);
+ private static readonly Attribute FileNameAttr = new(new Color(180, 100, 255), Color.None);
+ private static readonly Attribute StatusPlayingAttr = new(new Color(0, 200, 0), Color.None);
+ private static readonly Attribute StatusPausedAttr = new(new Color(220, 180, 0), Color.None);
+ private static readonly Attribute StatusStoppedAttr = new(new Color(160, 160, 160), Color.None);
+
+ public static void Show(IApplication app, AudioPlaybackService audioService, string filePath, string fileName)
+ {
+ var dialog = new Dialog { Title = "Audio Player", Width = 52, Height = 14 };
+
+ // ── File name ──
+ var fileLabel = new Label
+ {
+ Text = $"\u266a {TruncateFileName(fileName, 44)}",
+ X = 2,
+ Y = 1,
+ Width = Dim.Fill(2)
+ };
+
+ // ── Wave visualization ──
+ var waveLabel = new Label
+ {
+ X = 2,
+ Y = 3,
+ Width = Dim.Fill(2),
+ Height = 1
+ };
+
+ // ── Status label ──
+ var statusLabel = new Label
+ {
+ Text = "Stopped",
+ X = 2,
+ Y = 5,
+ Width = 20
+ };
+
+ // ── Volume controls ──
+ var volumeHeaderLabel = new Label
+ {
+ Text = "Volume:",
+ X = 2,
+ Y = 7
+ };
+
+ byte currentVolume = 50;
+ var volumeBar = new ProgressBar
+ {
+ X = 14,
+ Y = 7,
+ Width = 20,
+ Height = 1,
+ Fraction = currentVolume / 100f,
+ ProgressBarStyle = ProgressBarStyle.Continuous
+ };
+
+ var volumePercentLabel = new Label
+ {
+ Text = $"{currentVolume}%",
+ X = 35,
+ Y = 7,
+ Width = 5
+ };
+
+ var volDownButton = new Button
+ {
+ Text = "-",
+ X = 10,
+ Y = 7,
+ Width = 3
+ };
+
+ var volUpButton = new Button
+ {
+ Text = "+",
+ X = 41,
+ Y = 7,
+ Width = 3
+ };
+
+ // ── Playback controls ──
+ var playButton = new Button
+ {
+ Text = "\u25b6 Play",
+ X = 2,
+ Y = 10,
+ IsDefault = true
+ };
+
+ var stopButton = new Button
+ {
+ Text = "\u25a0 Stop",
+ X = Pos.Right(playButton) + 2,
+ Y = 10
+ };
+
+ var closeButton = new Button
+ {
+ Text = "Close",
+ X = Pos.Right(stopButton) + 2,
+ Y = 10
+ };
+
+ // ── Animation state ──
+ var animationOffset = 0;
+ var random = new Random();
+ // Pre-generate a repeating wave pattern
+ var wavePattern = new int[WaveBarCount + 8];
+ for (int i = 0; i < wavePattern.Length; i++)
+ wavePattern[i] = random.Next(0, WaveBlocks.Length);
+
+ Timer? animationTimer = null;
+ var isDisposed = false;
+
+ // ── Helper functions ──
+ void UpdateWave(bool isActive)
+ {
+ if (isDisposed) return;
+
+ var bars = new string[WaveBarCount];
+ for (int i = 0; i < WaveBarCount; i++)
+ {
+ if (isActive)
+ {
+ var idx = wavePattern[(i + animationOffset) % wavePattern.Length];
+ bars[i] = WaveBlocks[idx];
+ }
+ else
+ {
+ bars[i] = WaveBlocks[1]; // low idle bars
+ }
+ }
+ waveLabel.Text = string.Join(" ", bars);
+ }
+
+ void UpdateStatus()
+ {
+ if (isDisposed) return;
+
+ if (audioService.IsPlaying && !audioService.IsPaused)
+ {
+ statusLabel.Text = "Playing";
+ playButton.Text = "\u23f8 Pause";
+ }
+ else if (audioService.IsPaused)
+ {
+ statusLabel.Text = "Paused";
+ playButton.Text = "\u25b6 Resume";
+ }
+ else
+ {
+ statusLabel.Text = "Stopped";
+ playButton.Text = "\u25b6 Play";
+ }
+ }
+
+ void StartAnimation()
+ {
+ animationTimer?.Dispose();
+ animationTimer = new Timer(_ =>
+ {
+ if (isDisposed) return;
+ animationOffset++;
+ // Shuffle a few bars each tick for organic movement
+ var idx = random.Next(0, wavePattern.Length);
+ wavePattern[idx] = random.Next(0, WaveBlocks.Length);
+
+ app.Invoke(() =>
+ {
+ if (isDisposed) return;
+ UpdateWave(true);
+ });
+ }, null, 0, AnimationIntervalMs);
+ }
+
+ void StopAnimation()
+ {
+ animationTimer?.Dispose();
+ animationTimer = null;
+ if (!isDisposed)
+ UpdateWave(false);
+ }
+
+ async Task UpdateVolume(byte newVolume)
+ {
+ currentVolume = Math.Clamp(newVolume, (byte)0, (byte)100);
+ await audioService.SetVolumeAsync(currentVolume);
+ if (!isDisposed)
+ {
+ volumeBar.Fraction = currentVolume / 100f;
+ volumePercentLabel.Text = $"{currentVolume}%";
+ }
+ }
+
+ // ── Custom drawing for colored elements ──
+ fileLabel.DrawingContent += (s, e) =>
+ {
+ var normalAttr = fileLabel.GetAttributeForRole(VisualRole.Normal);
+ var resolvedAttr = FileNameAttr.Background == Color.None
+ ? FileNameAttr with { Background = normalAttr.Background }
+ : FileNameAttr;
+ fileLabel.SetAttribute(resolvedAttr);
+ fileLabel.Move(0, 0);
+ var text = fileLabel.Text ?? "";
+ foreach (var g in Terminal.Gui.Drawing.GraphemeHelper.GetGraphemes(text))
+ fileLabel.AddStr(g);
+ // Fill remaining width
+ var width = fileLabel.Viewport.Width;
+ var textCols = Terminal.Gui.Text.StringExtensions.GetColumns(text);
+ for (int i = textCols; i < width; i++)
+ fileLabel.AddStr(" ");
+ e.Cancel = true;
+ };
+
+ waveLabel.DrawingContent += (s, e) =>
+ {
+ var normalAttr = waveLabel.GetAttributeForRole(VisualRole.Normal);
+ var attr = (audioService.IsPlaying && !audioService.IsPaused) ? WaveActiveAttr : WaveIdleAttr;
+ var resolvedAttr = attr.Background == Color.None
+ ? attr with { Background = normalAttr.Background }
+ : attr;
+ waveLabel.SetAttribute(resolvedAttr);
+ waveLabel.Move(0, 0);
+ var text = waveLabel.Text ?? "";
+ foreach (var g in Terminal.Gui.Drawing.GraphemeHelper.GetGraphemes(text))
+ waveLabel.AddStr(g);
+ var width = waveLabel.Viewport.Width;
+ var textCols = Terminal.Gui.Text.StringExtensions.GetColumns(text);
+ for (int i = textCols; i < width; i++)
+ waveLabel.AddStr(" ");
+ e.Cancel = true;
+ };
+
+ statusLabel.DrawingContent += (s, e) =>
+ {
+ var normalAttr = statusLabel.GetAttributeForRole(VisualRole.Normal);
+ Attribute attr;
+ if (audioService.IsPlaying && !audioService.IsPaused)
+ attr = StatusPlayingAttr;
+ else if (audioService.IsPaused)
+ attr = StatusPausedAttr;
+ else
+ attr = StatusStoppedAttr;
+
+ var resolvedAttr = attr.Background == Color.None
+ ? attr with { Background = normalAttr.Background }
+ : attr;
+ statusLabel.SetAttribute(resolvedAttr);
+ statusLabel.Move(0, 0);
+ var text = statusLabel.Text ?? "";
+ foreach (var g in Terminal.Gui.Drawing.GraphemeHelper.GetGraphemes(text))
+ statusLabel.AddStr(g);
+ var width = statusLabel.Viewport.Width;
+ var textCols = Terminal.Gui.Text.StringExtensions.GetColumns(text);
+ for (int i = textCols; i < width; i++)
+ statusLabel.AddStr(" ");
+ e.Cancel = true;
+ };
+
+ // ── Event handlers ──
+ playButton.Accepting += (s, e) =>
+ {
+ e.Handled = true;
+ Task.Run(async () =>
+ {
+ if (audioService.IsPaused)
+ {
+ await audioService.ResumeAsync();
+ app.Invoke(() =>
+ {
+ UpdateStatus();
+ StartAnimation();
+ });
+ }
+ else if (audioService.IsPlaying)
+ {
+ await audioService.PauseAsync();
+ app.Invoke(() =>
+ {
+ UpdateStatus();
+ StopAnimation();
+ });
+ }
+ else
+ {
+ await audioService.SetVolumeAsync(currentVolume);
+ await audioService.PlayAsync(filePath);
+ app.Invoke(() =>
+ {
+ UpdateStatus();
+ StartAnimation();
+ });
+ }
+ });
+ };
+
+ stopButton.Accepting += (s, e) =>
+ {
+ e.Handled = true;
+ Task.Run(async () =>
+ {
+ await audioService.StopAsync();
+ app.Invoke(() =>
+ {
+ UpdateStatus();
+ StopAnimation();
+ });
+ });
+ };
+
+ closeButton.Accepting += (s, e) =>
+ {
+ e.Handled = true;
+ isDisposed = true;
+ animationTimer?.Dispose();
+ _ = audioService.StopAsync(); // fire-and-forget
+ app.RequestStop();
+ };
+
+ volDownButton.Accepting += (s, e) =>
+ {
+ e.Handled = true;
+ var newVol = (byte)Math.Max(0, currentVolume - 10);
+ Task.Run(async () =>
+ {
+ await UpdateVolume(newVol);
+ app.Invoke(() =>
+ {
+ volumeBar.SetNeedsDraw();
+ volumePercentLabel.SetNeedsDraw();
+ });
+ });
+ };
+
+ volUpButton.Accepting += (s, e) =>
+ {
+ e.Handled = true;
+ var newVol = (byte)Math.Min(100, currentVolume + 10);
+ Task.Run(async () =>
+ {
+ await UpdateVolume(newVol);
+ app.Invoke(() =>
+ {
+ volumeBar.SetNeedsDraw();
+ volumePercentLabel.SetNeedsDraw();
+ });
+ });
+ };
+
+ // Handle playback finishing naturally
+ EventHandler? finishedHandler = null;
+ finishedHandler = (s, e) =>
+ {
+ app.Invoke(() =>
+ {
+ if (isDisposed) return;
+ UpdateStatus();
+ StopAnimation();
+ });
+ };
+ audioService.PlaybackFinished += finishedHandler;
+
+ // ── Initial state ──
+ UpdateWave(false);
+ UpdateStatus();
+
+ dialog.Add(fileLabel, waveLabel, statusLabel,
+ volumeHeaderLabel, volDownButton, volumeBar, volumePercentLabel, volUpButton,
+ playButton, stopButton, closeButton);
+
+ playButton.SetFocus();
+ app.Run(dialog);
+
+ // Cleanup
+ isDisposed = true;
+ animationTimer?.Dispose();
+ audioService.PlaybackFinished -= finishedHandler;
+ }
+
+ private static string TruncateFileName(string name, int maxLen)
+ {
+ if (name.Length <= maxLen)
+ return name;
+
+ var ext = Path.GetExtension(name);
+ var stem = Path.GetFileNameWithoutExtension(name);
+ var available = maxLen - ext.Length - 3; // 3 for "..."
+ if (available < 1)
+ return name[..maxLen];
+
+ return stem[..available] + "..." + ext;
+ }
+}
diff --git a/src/EchoHub.Client/UI/ChatRenderer.cs b/src/EchoHub.Client/UI/ChatRenderer.cs
index d4727fc..c6830b2 100644
--- a/src/EchoHub.Client/UI/ChatRenderer.cs
+++ b/src/EchoHub.Client/UI/ChatRenderer.cs
@@ -1,6 +1,7 @@
using System.Collections;
using System.Collections.Specialized;
using System.Text.RegularExpressions;
+using EchoHub.Core.Models;
using Terminal.Gui.Drawing;
using Terminal.Gui.Text;
using Terminal.Gui.Views;
@@ -22,6 +23,9 @@ public partial class ChatLine
public int TextLength { get; }
public Guid? MessageId { get; set; }
public bool IsMention { get; set; }
+ public string? AttachmentUrl { get; set; }
+ public string? AttachmentFileName { get; set; }
+ public MessageType? Type { get; set; }
public ChatLine(string plainText)
{
@@ -92,6 +96,15 @@ public partial class ChatLine
if (currentSegments.Count > 0)
results.Add(new ChatLine(currentSegments));
+ // Propagate attachment/type metadata to all wrapped lines so they remain clickable
+ foreach (var wrapped in results)
+ {
+ wrapped.AttachmentUrl = AttachmentUrl;
+ wrapped.AttachmentFileName = AttachmentFileName;
+ wrapped.Type = Type;
+ wrapped.MessageId = MessageId;
+ }
+
return results;
}
@@ -118,7 +131,7 @@ public partial class ChatLine
Color? currentFg = null;
Color? currentBg = null;
var defaultFg = defaultAttr?.Foreground;
- var defaultBg = defaultAttr?.Background ?? Color.Black;
+ var defaultBg = defaultAttr?.Background ?? Color.None;
Attribute? BuildAttr()
{
@@ -217,6 +230,8 @@ public class ChatListSource : IListDataSource
RaiseCollectionChanged();
}
+ public ChatLine? GetLine(int index) => index >= 0 && index < _lines.Count ? _lines[index] : null;
+
public bool IsMarked(int item) => false;
public void SetMark(int item, bool value) { }
public IList ToList() => _lines.Select(l => l.ToString()).ToList();
@@ -235,8 +250,10 @@ public class ChatListSource : IListDataSource
foreach (var segment in chatLine.Segments)
{
var attr = segment.Color ?? normalAttr;
+ if (attr.Background == Color.None)
+ attr = attr with { Background = normalAttr.Background };
if (mentionBg.HasValue)
- attr = new Attribute(attr.Foreground, mentionBg.Value);
+ attr = attr with { Background = mentionBg.Value };
listView.SetAttribute(attr);
foreach (var grapheme in GraphemeHelper.GetGraphemes(segment.Text))
@@ -287,10 +304,10 @@ public class ChannelListSource : IListDataSource
public int MaxItemLength { get; private set; }
public bool SuspendCollectionChangedEvent { get; set; }
- private static readonly Attribute ActiveAttr = new(Color.White, Color.Black);
- private static readonly Attribute UnreadAttr = new(Color.BrightCyan, Color.Black);
- private static readonly Attribute NormalAttr = new(Color.DarkGray, Color.Black);
- private static readonly Attribute BadgeAttr = new(Color.BrightYellow, Color.Black);
+ private static readonly Attribute ActiveAttr = new(Color.White, Color.None);
+ private static readonly Attribute UnreadAttr = new(Color.BrightCyan, Color.None);
+ private static readonly Attribute NormalAttr = new(Color.DarkGray, Color.None);
+ private static readonly Attribute BadgeAttr = new(Color.BrightYellow, Color.None);
public void Update(List channels, Dictionary unread, string activeChannel)
{
@@ -318,11 +335,16 @@ public class ChannelListSource : IListDataSource
_unreadCounts.TryGetValue(name, out var unread);
var hasUnread = unread > 0;
+ var normalAttr = listView.GetAttributeForRole(VisualRole.Normal);
var focusAttr = listView.GetAttributeForRole(VisualRole.Focus);
var prefix = isActive ? "> " : " ";
var channelText = $"#{name}";
var badge = hasUnread ? $" ({unread})" : "";
+ // Resolve Transparent backgrounds to the view's actual background
+ Attribute Resolve(Attribute attr) =>
+ attr.Background == Color.None ? attr with { Background = normalAttr.Background } : attr;
+
int drawnChars = 0;
if (selected)
@@ -332,20 +354,20 @@ public class ChannelListSource : IListDataSource
}
else
{
- listView.SetAttribute(isActive ? ActiveAttr : NormalAttr);
+ listView.SetAttribute(Resolve(isActive ? ActiveAttr : NormalAttr));
drawnChars = RenderHelpers.WriteText(listView, prefix, drawnChars, width);
- listView.SetAttribute(isActive ? ActiveAttr : hasUnread ? UnreadAttr : NormalAttr);
+ listView.SetAttribute(Resolve(isActive ? ActiveAttr : hasUnread ? UnreadAttr : NormalAttr));
drawnChars = RenderHelpers.WriteText(listView, channelText, drawnChars, width);
if (hasUnread)
{
- listView.SetAttribute(BadgeAttr);
+ listView.SetAttribute(Resolve(BadgeAttr));
drawnChars = RenderHelpers.WriteText(listView, badge, drawnChars, width);
}
}
- var fillAttr = selected ? focusAttr : listView.GetAttributeForRole(VisualRole.Normal);
+ var fillAttr = selected ? focusAttr : normalAttr;
listView.SetAttribute(fillAttr);
for (int i = drawnChars; i < width; i++)
listView.AddStr(" ");
@@ -457,14 +479,16 @@ static class RenderHelpers
///
public static partial class ChatColors
{
- public static readonly Attribute TimestampAttr = new(Color.DarkGray, Color.Black);
- public static readonly Attribute SystemAttr = new(new Color(0, 180, 180), Color.Black);
+ public static readonly Attribute TimestampAttr = new(Color.DarkGray, Color.None);
+ public static readonly Attribute SystemAttr = new(new Color(0, 180, 180), Color.None);
public static readonly Attribute MentionHighlightAttr = new(Color.White, new Color(80, 40, 0));
- public static readonly Attribute MentionTextAttr = new(new Color(255, 180, 50), Color.Black);
- public static readonly Attribute EmbedBorderAttr = new(new Color(91, 155, 213), Color.Black);
- public static readonly Attribute EmbedTitleAttr = new(Color.White, Color.Black);
- public static readonly Attribute EmbedDescAttr = new(new Color(160, 160, 160), Color.Black);
- public static readonly Attribute EmbedUrlAttr = new(new Color(100, 100, 100), Color.Black);
+ public static readonly Attribute MentionTextAttr = new(new Color(255, 180, 50), Color.None);
+ public static readonly Attribute EmbedBorderAttr = new(new Color(91, 155, 213), Color.None);
+ public static readonly Attribute EmbedTitleAttr = new(Color.White, Color.None);
+ public static readonly Attribute EmbedDescAttr = new(new Color(160, 160, 160), Color.None);
+ public static readonly Attribute EmbedUrlAttr = new(new Color(100, 100, 100), Color.None);
+ public static readonly Attribute AudioAttr = new(new Color(180, 100, 255), Color.None);
+ public static readonly Attribute FileAttr = new(new Color(100, 180, 255), Color.None);
///
/// Split text around @mentions, giving each @word the MentionTextAttr accent color.
@@ -513,7 +537,7 @@ public static class ColorHelper
var r = Convert.ToInt32(hex[..2], 16);
var g = Convert.ToInt32(hex[2..4], 16);
var b = Convert.ToInt32(hex[4..6], 16);
- return new Attribute(new Color(r, g, b), Color.Black);
+ return new Attribute(new Color(r, g, b), Color.None);
}
catch
{
diff --git a/src/EchoHub.Client/UI/ConnectDialog.cs b/src/EchoHub.Client/UI/ConnectDialog.cs
index 614911d..a3f770e 100644
--- a/src/EchoHub.Client/UI/ConnectDialog.cs
+++ b/src/EchoHub.Client/UI/ConnectDialog.cs
@@ -9,7 +9,9 @@ namespace EchoHub.Client.UI;
///
/// Result returned from the connect dialog.
///
-public record ConnectDialogResult(string ServerUrl, string Username, string Password, bool IsRegister);
+public record ConnectDialogResult(
+ string ServerUrl, string Username, string Password,
+ bool IsRegister, bool RememberMe, string? SavedRefreshToken);
///
/// A Terminal.Gui dialog for entering server connection and authentication details.
@@ -27,11 +29,12 @@ public sealed class ConnectDialog
savedServers ??= [];
var hasSavedServers = savedServers.Count > 0;
- var dialogHeight = hasSavedServers ? 20 : 16;
+ var dialogHeight = hasSavedServers ? 22 : 18;
var dialog = new Dialog { Title = "Connect to Server", Width = 60, Height = dialogHeight };
int yOffset = 0;
+ SavedServer? selectedSavedServer = null;
// -- Saved Servers section (if any) -----------------------------------
ListView? savedServerList = null;
@@ -46,7 +49,11 @@ public sealed class ConnectDialog
dialog.Add(savedLabel);
var serverDisplayNames = savedServers
- .Select(s => $"{s.Name} ({s.Username ?? "?"})")
+ .Select(s =>
+ {
+ var session = !string.IsNullOrEmpty(s.RefreshToken) ? " [session]" : "";
+ return $"{s.Name} ({s.Username ?? "?"}){session}";
+ })
.ToList();
savedServerList = new ListView
@@ -115,17 +122,34 @@ public sealed class ConnectDialog
Secret = true
};
+ var tokenHintLabel = new Label
+ {
+ Text = "Session saved \u2014 password optional",
+ X = 15,
+ Y = yOffset + 6,
+ Width = Dim.Fill(2),
+ Visible = false
+ };
+
+ var rememberMeCheckbox = new CheckBox
+ {
+ Text = "Remember me",
+ X = 15,
+ Y = yOffset + 7,
+ Value = CheckState.UnChecked
+ };
+
var displayLabel = new Label
{
Text = "Display Name:",
X = 1,
- Y = yOffset + 7
+ Y = yOffset + 9
};
var displayField = new TextField
{
Text = "",
X = 15,
- Y = yOffset + 7,
+ Y = yOffset + 9,
Width = Dim.Fill(2)
};
@@ -134,21 +158,21 @@ public sealed class ConnectDialog
Text = "Login",
IsDefault = true,
X = Pos.Center() - 20,
- Y = yOffset + 9
+ Y = yOffset + 11
};
var registerButton = new Button
{
Text = "Register",
X = Pos.Center() - 5,
- Y = yOffset + 9
+ Y = yOffset + 11
};
var cancelButton = new Button
{
Text = "Cancel",
X = Pos.Center() + 10,
- Y = yOffset + 9
+ Y = yOffset + 11
};
// Wire saved server selection to auto-fill fields
@@ -159,15 +183,32 @@ public sealed class ConnectDialog
var index = e.NewValue;
if (index.HasValue && index.Value >= 0 && index.Value < savedServers.Count)
{
- var server = savedServers[index.Value];
- urlField.Text = server.Url;
- userField.Text = server.Username ?? "";
+ selectedSavedServer = savedServers[index.Value];
+ urlField.Text = selectedSavedServer.Url;
+ userField.Text = selectedSavedServer.Username ?? "";
+ rememberMeCheckbox.Value = selectedSavedServer.RememberMe
+ ? CheckState.Checked : CheckState.UnChecked;
+
+ if (!string.IsNullOrEmpty(selectedSavedServer.RefreshToken))
+ {
+ passField.Text = "";
+ tokenHintLabel.Visible = true;
+ }
+ else
+ {
+ tokenHintLabel.Visible = false;
+ }
}
};
// Pre-fill with the first saved server
+ selectedSavedServer = savedServers[0];
urlField.Text = savedServers[0].Url;
userField.Text = savedServers[0].Username ?? "";
+ rememberMeCheckbox.Value = savedServers[0].RememberMe
+ ? CheckState.Checked : CheckState.UnChecked;
+ if (!string.IsNullOrEmpty(savedServers[0].RefreshToken))
+ tokenHintLabel.Visible = true;
}
loginButton.Accepting += (s, e) =>
@@ -175,15 +216,34 @@ public sealed class ConnectDialog
var url = urlField.Text?.Trim() ?? string.Empty;
var user = userField.Text?.Trim() ?? string.Empty;
var pass = passField.Text ?? string.Empty;
+ var rememberMe = rememberMeCheckbox.Value == CheckState.Checked;
- if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user) || string.IsNullOrEmpty(pass))
+ if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user))
{
- MessageBox.ErrorQuery(app, "Validation", "Server URL, username, and password are required.", "OK");
+ MessageBox.ErrorQuery(app, "Validation", "Server URL and username are required.", "OK");
e.Handled = true;
return;
}
- result = new ConnectDialogResult(url, user, pass, IsRegister: false);
+ // Determine if we can use a saved token
+ string? savedRefreshToken = null;
+ if (string.IsNullOrEmpty(pass)
+ && selectedSavedServer is not null
+ && !string.IsNullOrEmpty(selectedSavedServer.RefreshToken)
+ && string.Equals(selectedSavedServer.Url, url, StringComparison.OrdinalIgnoreCase)
+ && string.Equals(selectedSavedServer.Username, user, StringComparison.OrdinalIgnoreCase))
+ {
+ savedRefreshToken = selectedSavedServer.RefreshToken;
+ }
+
+ if (string.IsNullOrEmpty(pass) && savedRefreshToken is null)
+ {
+ MessageBox.ErrorQuery(app, "Validation", "Password is required.", "OK");
+ e.Handled = true;
+ return;
+ }
+
+ result = new ConnectDialogResult(url, user, pass, IsRegister: false, rememberMe, savedRefreshToken);
e.Handled = true;
app.RequestStop();
};
@@ -193,6 +253,7 @@ public sealed class ConnectDialog
var url = urlField.Text?.Trim() ?? string.Empty;
var user = userField.Text?.Trim() ?? string.Empty;
var pass = passField.Text ?? string.Empty;
+ var rememberMe = rememberMeCheckbox.Value == CheckState.Checked;
if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user) || string.IsNullOrEmpty(pass))
{
@@ -201,7 +262,7 @@ public sealed class ConnectDialog
return;
}
- result = new ConnectDialogResult(url, user, pass, IsRegister: true);
+ result = new ConnectDialogResult(url, user, pass, IsRegister: true, rememberMe, SavedRefreshToken: null);
e.Handled = true;
app.RequestStop();
};
@@ -214,7 +275,8 @@ public sealed class ConnectDialog
};
dialog.Add(urlLabel, urlField, userLabel, userField, passLabel, passField,
- displayLabel, displayField, loginButton, registerButton, cancelButton);
+ tokenHintLabel, rememberMeCheckbox, displayLabel, displayField,
+ loginButton, registerButton, cancelButton);
if (hasSavedServers && savedServerList is not null)
savedServerList.SetFocus();
diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs
index 804ef21..fdd4000 100644
--- a/src/EchoHub.Client/UI/MainWindow.cs
+++ b/src/EchoHub.Client/UI/MainWindow.cs
@@ -58,9 +58,11 @@ public sealed class MainWindow : Runnable
private readonly Dictionary> _channelMessages = [];
private readonly Dictionary _channelUnread = [];
private readonly Dictionary _channelTopics = [];
+ private readonly Dictionary _channelPublic = [];
private readonly ChannelListSource _channelListSource;
private string _currentChannel = string.Empty;
private string _currentUser = string.Empty;
+ private string _connectionStatus = "Disconnected";
private int _lastChatWidth;
///
@@ -83,6 +85,11 @@ public sealed class MainWindow : Runnable
///
public event Action? OnDisconnectRequested;
+ ///
+ /// Fired when the user requests to logout (disconnect + revoke session).
+ ///
+ public event Action? OnLogoutRequested;
+
///
/// Fired when the user requests to open their profile panel.
///
@@ -113,6 +120,16 @@ public sealed class MainWindow : Runnable
///
public event Action? OnDeleteChannelRequested;
+ ///
+ /// Fired when the user activates (Enter/click) an audio message. Parameters: attachmentUrl, fileName.
+ ///
+ public event Action? OnAudioPlayRequested;
+
+ ///
+ /// Fired when the user activates (Enter/click) a file message. Parameters: attachmentUrl, fileName.
+ ///
+ public event Action? OnFileDownloadRequested;
+
public MainWindow(IApplication app)
{
_app = app;
@@ -175,6 +192,7 @@ public sealed class MainWindow : Runnable
Height = Dim.Fill()
};
_messageList.Source = new ChatListSource();
+ _messageList.Accepting += OnMessageListAccepting;
_chatFrame.Add(_messageList);
Add(_chatFrame);
@@ -223,16 +241,17 @@ public sealed class MainWindow : Runnable
_usersFrame.Add(_usersList);
Add(_usersFrame);
- // Status bar at the very bottom
+ // Status bar at the very bottom — custom drawing for colored connection state
_statusLabel = new Label
{
- Text = "Disconnected",
+ Text = "",
X = 0,
Y = Pos.AnchorEnd(1),
Width = Dim.Fill(),
Height = 1
};
_statusLabel.SetScheme(SchemeManager.GetScheme("Menu"));
+ _statusLabel.DrawingContent += OnStatusBarDrawContent;
Add(_statusLabel);
// Apply our custom color schemes to all views
@@ -308,6 +327,7 @@ public sealed class MainWindow : Runnable
{
new MenuItem("_Connect...", "Connect to a server", () => OnConnectRequested?.Invoke(), Key.Empty),
new MenuItem("_Disconnect", "Disconnect from server", () => OnDisconnectRequested?.Invoke(), Key.Empty),
+ new MenuItem("_Logout", "Logout and clear session", () => OnLogoutRequested?.Invoke(), Key.Empty),
new Line(),
new MenuItem("New C_hannel...", "Create a new channel", () => OnCreateChannelRequested?.Invoke(), Key.Empty),
new MenuItem("_Delete Channel", "Delete the current channel", () => OnDeleteChannelRequested?.Invoke(), Key.Empty),
@@ -360,6 +380,31 @@ public sealed class MainWindow : Runnable
}
}
+ private void OnMessageListAccepting(object? sender, CommandEventArgs e)
+ {
+ if (_messageList.Source is not ChatListSource source)
+ return;
+
+ var index = _messageList.SelectedItem;
+ if (!index.HasValue || index.Value < 0 || index.Value >= source.Count)
+ return;
+
+ var line = source.GetLine(index.Value);
+ if (line?.AttachmentUrl is null || line.AttachmentFileName is null)
+ return;
+
+ if (line.Type == MessageType.Audio)
+ {
+ OnAudioPlayRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
+ e.Handled = true;
+ }
+ else if (line.Type == MessageType.File)
+ {
+ OnFileDownloadRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
+ e.Handled = true;
+ }
+ }
+
private void OnInputKeyDown(object? sender, Key e)
{
if (e.KeyCode == TabKey.KeyCode)
@@ -600,10 +645,12 @@ public sealed class MainWindow : Runnable
{
_channelNames.Clear();
_channelTopics.Clear();
+ _channelPublic.Clear();
foreach (var ch in channels)
{
_channelNames.Add(ch.Name);
_channelTopics[ch.Name] = ch.Topic;
+ _channelPublic[ch.Name] = ch.IsPublic;
if (!_channelMessages.ContainsKey(ch.Name))
_channelMessages[ch.Name] = [];
}
@@ -613,8 +660,11 @@ public sealed class MainWindow : Runnable
///
/// Ensure a channel exists in the left panel list (used for private channels joined via /join).
///
- public void EnsureChannelInList(string channelName)
+ public void EnsureChannelInList(string channelName, bool? isPublic = null)
{
+ if (isPublic.HasValue)
+ _channelPublic[channelName] = isPublic.Value;
+
if (_channelNames.Contains(channelName))
return;
@@ -631,6 +681,7 @@ public sealed class MainWindow : Runnable
{
_channelNames.Remove(channelName);
_channelTopics.Remove(channelName);
+ _channelPublic.Remove(channelName);
RefreshChannelList();
}
@@ -657,9 +708,76 @@ public sealed class MainWindow : Runnable
///
public void UpdateStatusBar(string status)
{
- var userPart = string.IsNullOrEmpty(_currentUser) ? "" : $" \u2502 User: {_currentUser}";
- var channelPart = string.IsNullOrEmpty(_currentChannel) ? "" : $" \u2502 #{_currentChannel}";
- _statusLabel.Text = $" v{AppVersion} \u2502 {status}{userPart}{channelPart}";
+ _connectionStatus = status;
+ _statusLabel.SetNeedsDraw();
+ }
+
+ private static readonly Attribute StatusConnectedAttr = new(new Color(0, 200, 0), Color.None);
+ private static readonly Attribute StatusDisconnectedAttr = new(new Color(220, 50, 50), Color.None);
+ private static readonly Attribute StatusTransitionalAttr = new(new Color(220, 180, 0), Color.None);
+ private static readonly Attribute StatusBrandAttr = new(new Color(218, 165, 32), Color.None);
+
+ private void OnStatusBarDrawContent(object? sender, DrawEventArgs e)
+ {
+ var menuScheme = SchemeManager.GetScheme("Menu");
+ var normalAttr = menuScheme?.Normal ?? _statusLabel.GetAttributeForRole(VisualRole.Normal);
+ var width = _statusLabel.Viewport.Width;
+ if (width <= 0) return;
+
+ // Resolve None background for colored segments
+ var bg = normalAttr.Background;
+ Attribute Resolve(Attribute a) => a.Background == Color.None ? a with { Background = bg } : a;
+
+ int col = 0;
+
+ void Write(string text, Attribute attr)
+ {
+ _statusLabel.SetAttribute(Resolve(attr));
+ foreach (var g in GraphemeHelper.GetGraphemes(text))
+ {
+ var cols = Math.Max(g.GetColumns(), 1);
+ if (col + cols > width) return;
+ _statusLabel.Move(col, 0);
+ _statusLabel.AddStr(g);
+ col += cols;
+ }
+ }
+
+ // EchoHub branding
+ Write(" EchoHub", Resolve(StatusBrandAttr));
+ Write($" \u2502 v{AppVersion} \u2502 ", normalAttr);
+
+ // Connection state with color
+ var statusAttr = _connectionStatus switch
+ {
+ "Connected" => StatusConnectedAttr,
+ "Disconnected" => StatusDisconnectedAttr,
+ _ => StatusTransitionalAttr // Connecting, Reconnecting, Authenticating, etc.
+ };
+ Write(_connectionStatus, Resolve(statusAttr));
+
+ // User
+ if (!string.IsNullOrEmpty(_currentUser))
+ Write($" \u2502 User: {_currentUser}", normalAttr);
+
+ // Channel + type
+ if (!string.IsNullOrEmpty(_currentChannel))
+ {
+ _channelPublic.TryGetValue(_currentChannel, out var isPublic);
+ var typeSuffix = isPublic ? "public" : "private";
+ Write($" \u2502 #{_currentChannel} - {typeSuffix}", normalAttr);
+ }
+
+ // Fill remaining space
+ _statusLabel.SetAttribute(normalAttr);
+ while (col < width)
+ {
+ _statusLabel.Move(col, 0);
+ _statusLabel.AddStr(" ");
+ col++;
+ }
+
+ e.Cancel = true;
}
///
@@ -694,6 +812,7 @@ public sealed class MainWindow : Runnable
RefreshMessages();
UpdateTopicBar();
+ _statusLabel.SetNeedsDraw();
// Update channel list selection
var idx = _channelNames.IndexOf(channelName);
@@ -725,6 +844,7 @@ public sealed class MainWindow : Runnable
_channelMessages.Clear();
_channelUnread.Clear();
_channelTopics.Clear();
+ _channelPublic.Clear();
_currentChannel = string.Empty;
_currentUser = string.Empty;
_channelListSource.Update([], [], string.Empty);
@@ -900,10 +1020,26 @@ public sealed class MainWindow : Runnable
}
break;
+ case MessageType.Audio:
+ var audioName = message.AttachmentFileName ?? "unknown";
+ var audioSize = FormatFileSize(message.AttachmentFileSize);
+ var audioLine = BuildChatLineColored(time, senderName, senderColor,
+ $" \u266a [Audio: {audioName}] [{audioSize}]", ChatColors.AudioAttr);
+ audioLine.AttachmentUrl = message.AttachmentUrl;
+ audioLine.AttachmentFileName = audioName;
+ audioLine.Type = MessageType.Audio;
+ lines.Add(audioLine);
+ break;
+
case MessageType.File:
var fileName = message.AttachmentFileName ?? "unknown";
- var fileContent = !string.IsNullOrWhiteSpace(message.Content) ? $" {message.Content}" : "";
- lines.Add(BuildChatLine(time, senderName, senderColor, $" [File: {fileName}]{fileContent}"));
+ var fileSize = FormatFileSize(message.AttachmentFileSize);
+ var fileLine = BuildChatLineColored(time, senderName, senderColor,
+ $" [File: {fileName}] [{fileSize}]", ChatColors.FileAttr);
+ fileLine.AttachmentUrl = message.AttachmentUrl;
+ fileLine.AttachmentFileName = fileName;
+ fileLine.Type = MessageType.File;
+ lines.Add(fileLine);
break;
case MessageType.Text:
@@ -962,6 +1098,20 @@ public sealed class MainWindow : Runnable
return new ChatLine(segments);
}
+ ///
+ /// Build a chat line with a colored suffix (used for audio/file indicators).
+ ///
+ private static ChatLine BuildChatLineColored(string time, string senderName, Attribute? senderColor, string suffix, Attribute suffixColor)
+ {
+ var segments = new List
+ {
+ new($"[{time}] ", ChatColors.TimestampAttr),
+ new(senderName, senderColor),
+ new(suffix, suffixColor)
+ };
+ return new ChatLine(segments);
+ }
+
///
/// Build a chat line with @mention highlighting in the suffix text.
///
@@ -1055,4 +1205,18 @@ public sealed class MainWindow : Runnable
return result;
}
+
+ private static string FormatFileSize(long? bytes)
+ {
+ if (bytes is null or 0)
+ return "?";
+
+ return bytes.Value switch
+ {
+ < 1024 => $"{bytes.Value} B",
+ < 1024 * 1024 => $"{bytes.Value / 1024.0:F1} KB",
+ < 1024 * 1024 * 1024 => $"{bytes.Value / (1024.0 * 1024.0):F1} MB",
+ _ => $"{bytes.Value / (1024.0 * 1024.0 * 1024.0):F1} GB"
+ };
+ }
}
diff --git a/src/EchoHub.Client/UI/UpdateConfirmDialog.cs b/src/EchoHub.Client/UI/UpdateConfirmDialog.cs
new file mode 100644
index 0000000..d305377
--- /dev/null
+++ b/src/EchoHub.Client/UI/UpdateConfirmDialog.cs
@@ -0,0 +1,58 @@
+using Terminal.Gui.App;
+using Terminal.Gui.Views;
+using Terminal.Gui.ViewBase;
+
+namespace EchoHub.Client.UI;
+
+public sealed class UpdateConfirmDialog
+{
+ public static bool Show(IApplication app, string currentVersion, string newVersion)
+ {
+ var confirmed = false;
+
+ var dialog = new Dialog { Title = "Update Available", Width = 50, Height = 10 };
+
+ var messageLabel = new Label
+ {
+ Text = $"A new version of EchoHub is available.\n\n Current: {currentVersion}\n Latest: {newVersion}",
+ X = 1,
+ Y = 1,
+ Width = Dim.Fill(2),
+ Height = 4
+ };
+
+ var updateButton = new Button
+ {
+ Text = "Update",
+ IsDefault = true,
+ X = Pos.Center() - 10,
+ Y = 6
+ };
+
+ var cancelButton = new Button
+ {
+ Text = "Cancel",
+ X = Pos.Center() + 5,
+ Y = 6
+ };
+
+ updateButton.Accepting += (s, e) =>
+ {
+ confirmed = true;
+ e.Handled = true;
+ app.RequestStop();
+ };
+
+ cancelButton.Accepting += (s, e) =>
+ {
+ confirmed = false;
+ e.Handled = true;
+ app.RequestStop();
+ };
+
+ dialog.Add(messageLabel, updateButton, cancelButton);
+ app.Run(dialog);
+
+ return confirmed;
+ }
+}
diff --git a/src/EchoHub.Client/UI/UpdateProgressDialog.cs b/src/EchoHub.Client/UI/UpdateProgressDialog.cs
new file mode 100644
index 0000000..1d71f04
--- /dev/null
+++ b/src/EchoHub.Client/UI/UpdateProgressDialog.cs
@@ -0,0 +1,61 @@
+using Terminal.Gui.App;
+using Terminal.Gui.Views;
+using Terminal.Gui.ViewBase;
+
+namespace EchoHub.Client.UI;
+
+public sealed class UpdateProgressDialog
+{
+ private readonly Dialog _dialog;
+ private readonly ProgressBar _progressBar;
+ private readonly Label _infoLabel;
+ private readonly IApplication _app;
+
+ public UpdateProgressDialog(IApplication app, string newVersion)
+ {
+ _app = app;
+
+ _dialog = new Dialog { Title = $"Updating to {newVersion}", Width = 50, Height = 10 };
+
+ _infoLabel = new Label
+ {
+ Text = "Preparing update...",
+ X = 1,
+ Y = 1,
+ Width = Dim.Fill(2)
+ };
+
+ _progressBar = new ProgressBar
+ {
+ X = 1,
+ Y = 3,
+ Width = Dim.Fill(2),
+ Fraction = 0f
+ };
+
+ var cancelButton = new Button
+ {
+ Text = "Cancel",
+ X = Pos.Center(),
+ Y = 6
+ };
+
+ _dialog.Add(_infoLabel, _progressBar);
+ }
+
+ public void UpdateProgress(float fraction, string statusText)
+ {
+ _progressBar.Fraction = fraction;
+ _infoLabel.Text = statusText;
+ }
+
+ public void Show()
+ {
+ _app.Run(_dialog);
+ }
+
+ public void Close()
+ {
+ _app.RequestStop();
+ }
+}
diff --git a/src/EchoHub.Client/hue_icon.ico b/src/EchoHub.Client/hue_icon.ico
new file mode 100644
index 0000000..dd2668c
Binary files /dev/null and b/src/EchoHub.Client/hue_icon.ico differ
diff --git a/src/EchoHub.Core/Constants/HubConstants.cs b/src/EchoHub.Core/Constants/HubConstants.cs
index 024d956..de149bf 100644
--- a/src/EchoHub.Core/Constants/HubConstants.cs
+++ b/src/EchoHub.Core/Constants/HubConstants.cs
@@ -6,7 +6,9 @@ public static class HubConstants
public const string DefaultChannel = "general";
public const int DefaultHistoryCount = 100;
public const int MaxMessageLength = 2000;
- public const int MaxFileSizeBytes = 10 * 1024 * 1024; // 10 MB
+ public const int MaxImageSizeBytes = 10 * 1024 * 1024; // 10 MB
+ public const int MaxAudioFileSizeBytes = 10 * 1024 * 1024; // 10 MB
+ public const int MaxFileSizeBytes = 100 * 1024 * 1024; // 100 MB
public const int MaxAvatarSizeBytes = 2 * 1024 * 1024; // 2 MB
public const int MaxMessageNewlines = 30;
public const int MaxConsecutiveNewlines = 1;
diff --git a/src/EchoHub.Core/Contracts/IChannelService.cs b/src/EchoHub.Core/Contracts/IChannelService.cs
new file mode 100644
index 0000000..e445160
--- /dev/null
+++ b/src/EchoHub.Core/Contracts/IChannelService.cs
@@ -0,0 +1,22 @@
+using EchoHub.Core.DTOs;
+
+namespace EchoHub.Core.Contracts;
+
+public interface IChannelService
+{
+ // Channel CRUD
+ Task> GetChannelsAsync(Guid userId, int offset, int limit);
+ Task CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic);
+ Task UpdateTopicAsync(Guid callerUserId, string channelName, string? topic);
+ Task DeleteChannelAsync(Guid callerUserId, string channelName);
+
+ // Channel queries
+ Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName);
+ Task> GetChannelListAsync();
+ Task GetChannelByNameAsync(string channelName);
+
+ // Membership
+ Task<(bool Success, string? Error)> EnsureChannelMembershipAsync(Guid userId, string channelName);
+}
+
+public record ChannelListItem(string Name, string? Topic, int OnlineCount);
diff --git a/src/EchoHub.Core/Contracts/IChatService.cs b/src/EchoHub.Core/Contracts/IChatService.cs
index c4102fb..03ea1c2 100644
--- a/src/EchoHub.Core/Contracts/IChatService.cs
+++ b/src/EchoHub.Core/Contracts/IChatService.cs
@@ -25,12 +25,8 @@ public interface IChatService
Task BroadcastMessageAsync(string channelName, MessageDto message);
Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null);
- // Query operations (used by IRC gateway for WHOIS, TOPIC, LIST, AUTH)
+ // Query operations (used by IRC gateway for WHOIS, AUTH)
Task GetUserProfileAsync(string username);
- Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName);
- Task> GetChannelListAsync();
Task> GetChannelsForUserAsync(string username);
Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password);
}
-
-public record ChannelListItem(string Name, string? Topic, int OnlineCount);
diff --git a/src/EchoHub.Core/DTOs/ChatDtos.cs b/src/EchoHub.Core/DTOs/ChatDtos.cs
index be772b2..ce742d9 100644
--- a/src/EchoHub.Core/DTOs/ChatDtos.cs
+++ b/src/EchoHub.Core/DTOs/ChatDtos.cs
@@ -12,6 +12,7 @@ public record MessageDto(
string? AttachmentUrl,
string? AttachmentFileName,
DateTimeOffset SentAt,
+ long? AttachmentFileSize = null,
List? Embeds = null);
public record ChannelDto(
diff --git a/src/EchoHub.Core/DTOs/CommonDtos.cs b/src/EchoHub.Core/DTOs/CommonDtos.cs
index a5d73c4..d41c4f6 100644
--- a/src/EchoHub.Core/DTOs/CommonDtos.cs
+++ b/src/EchoHub.Core/DTOs/CommonDtos.cs
@@ -1,5 +1,26 @@
namespace EchoHub.Core.DTOs;
+public record ApiResponse(bool Success, string? Message = null, List? Errors = null);
+
+public record ApiResponse(bool Success, string? Message = null, List? Errors = null, T? Data = default);
+
public record ErrorResponse(string Error, string? Detail = null);
public record PaginatedResponse(List Items, int Total, int Offset, int Limit);
+
+public enum ChannelError
+{
+ ValidationFailed,
+ AlreadyExists,
+ NotFound,
+ Forbidden,
+ Protected
+}
+
+public record ChannelOperationResult(ChannelDto? Channel, ChannelError? Error, string? ErrorMessage)
+{
+ public bool IsSuccess => Error is null;
+
+ public static ChannelOperationResult Success(ChannelDto channel) => new(channel, null, null);
+ public static ChannelOperationResult Fail(ChannelError error, string message) => new(null, error, message);
+}
diff --git a/src/EchoHub.Core/Models/Message.cs b/src/EchoHub.Core/Models/Message.cs
index 2ace5ac..a574b16 100644
--- a/src/EchoHub.Core/Models/Message.cs
+++ b/src/EchoHub.Core/Models/Message.cs
@@ -7,6 +7,7 @@ public class Message
public MessageType Type { get; set; } = MessageType.Text;
public string? AttachmentUrl { get; set; }
public string? AttachmentFileName { get; set; }
+ public long? AttachmentFileSize { get; set; }
public string? EmbedJson { get; set; }
public DateTimeOffset SentAt { get; set; } = DateTimeOffset.UtcNow;
diff --git a/src/EchoHub.Core/Models/MessageType.cs b/src/EchoHub.Core/Models/MessageType.cs
index 6957785..4ffb036 100644
--- a/src/EchoHub.Core/Models/MessageType.cs
+++ b/src/EchoHub.Core/Models/MessageType.cs
@@ -4,5 +4,6 @@ public enum MessageType
{
Text,
Image,
- File
+ File,
+ Audio
}
diff --git a/src/EchoHub.Server.Irc/IrcClientConnection.cs b/src/EchoHub.Server.Irc/IrcClientConnection.cs
index 8566268..bbce314 100644
--- a/src/EchoHub.Server.Irc/IrcClientConnection.cs
+++ b/src/EchoHub.Server.Irc/IrcClientConnection.cs
@@ -27,14 +27,20 @@ public sealed class IrcClientConnection : IAsyncDisposable
public bool IsSasl { get; set; }
public bool CapNegotiating { get; set; }
- // Channel state
- public HashSet JoinedChannels { get; } = new(StringComparer.OrdinalIgnoreCase);
+ // Channel state — thread-safe: written by command handler, read by broadcaster threads
+ private readonly HashSet _joinedChannels = new(StringComparer.OrdinalIgnoreCase);
+ private readonly object _channelLock = new();
// Away state
public string? AwayMessage { get; set; }
public string Hostmask => $"{Nickname}!{Username ?? Nickname}@echohub";
+ public void JoinChannel(string channel) { lock (_channelLock) _joinedChannels.Add(channel); }
+ public void LeaveChannel(string channel) { lock (_channelLock) _joinedChannels.Remove(channel); }
+ public bool IsInChannel(string channel) { lock (_channelLock) return _joinedChannels.Contains(channel); }
+ public List GetJoinedChannels() { lock (_channelLock) return [.. _joinedChannels]; }
+
public IrcClientConnection(TcpClient tcpClient, Stream stream)
{
_tcpClient = tcpClient;
diff --git a/src/EchoHub.Server.Irc/IrcCommandHandler.cs b/src/EchoHub.Server.Irc/IrcCommandHandler.cs
index 1bf1a6b..4d1fa30 100644
--- a/src/EchoHub.Server.Irc/IrcCommandHandler.cs
+++ b/src/EchoHub.Server.Irc/IrcCommandHandler.cs
@@ -12,6 +12,8 @@ public sealed class IrcCommandHandler
private readonly IrcClientConnection _conn;
private readonly IrcOptions _options;
private readonly IChatService _chatService;
+ private readonly IChannelService _channelService;
+ private readonly IMessageEncryptionService _encryption;
private readonly ILogger _logger;
private string ServerName => _options.ServerName;
@@ -20,11 +22,15 @@ public sealed class IrcCommandHandler
IrcClientConnection conn,
IrcOptions options,
IChatService chatService,
+ IChannelService channelService,
+ IMessageEncryptionService encryption,
ILogger logger)
{
_conn = conn;
_options = options;
_chatService = chatService;
+ _channelService = channelService;
+ _encryption = encryption;
_logger = logger;
}
@@ -319,7 +325,7 @@ public sealed class IrcCommandHandler
private async Task HandleJoinAsync(IrcMessage msg)
{
- if (!RequireRegistered()) return;
+ if (!await RequireRegisteredAsync()) return;
if (msg.Parameters.Count < 1)
{
@@ -350,7 +356,7 @@ public sealed class IrcCommandHandler
continue;
}
- _conn.JoinedChannels.Add(channelName);
+ _conn.JoinChannel(channelName);
// Confirm JOIN to the client
await _conn.SendAsync($":{_conn.Hostmask} JOIN #{channelName}");
@@ -361,10 +367,11 @@ public sealed class IrcCommandHandler
// Send NAMES list
await SendNamesReplyAsync(channelName);
- // Replay history
+ // Replay history (decrypt — history is encrypted for SignalR transport)
foreach (var m in history)
{
- var lines = IrcMessageFormatter.FormatMessage(m);
+ var decrypted = m with { Content = _encryption.Decrypt(m.Content) };
+ var lines = IrcMessageFormatter.FormatMessage(decrypted);
foreach (var line in lines)
await _conn.SendAsync(line);
}
@@ -373,7 +380,7 @@ public sealed class IrcCommandHandler
private async Task HandlePartAsync(IrcMessage msg)
{
- if (!RequireRegistered()) return;
+ if (!await RequireRegisteredAsync()) return;
if (msg.Parameters.Count < 1) return;
var channels = msg.Parameters[0].Split(',', StringSplitOptions.RemoveEmptyEntries);
@@ -385,7 +392,7 @@ public sealed class IrcCommandHandler
if (channelName is null) continue;
await _chatService.LeaveChannelAsync(_conn.ConnectionId, _conn.Nickname!, channelName);
- _conn.JoinedChannels.Remove(channelName);
+ _conn.LeaveChannel(channelName);
await _conn.SendAsync($":{_conn.Hostmask} PART #{channelName}" +
(partMessage is not null ? $" :{partMessage}" : ""));
@@ -394,7 +401,7 @@ public sealed class IrcCommandHandler
private async Task HandlePrivmsgAsync(IrcMessage msg)
{
- if (!RequireRegistered()) return;
+ if (!await RequireRegisteredAsync()) return;
if (msg.Parameters.Count < 2)
{
@@ -436,7 +443,7 @@ public sealed class IrcCommandHandler
private async Task HandleNamesAsync(IrcMessage msg)
{
- if (!RequireRegistered()) return;
+ if (!await RequireRegisteredAsync()) return;
if (msg.Parameters.Count < 1) return;
var channelName = IrcToEchoHubChannel(msg.Parameters[0]);
@@ -458,7 +465,7 @@ public sealed class IrcCommandHandler
private async Task HandleTopicAsync(IrcMessage msg)
{
- if (!RequireRegistered()) return;
+ if (!await RequireRegisteredAsync()) return;
if (msg.Parameters.Count < 1) return;
var channelName = IrcToEchoHubChannel(msg.Parameters[0]);
@@ -477,7 +484,7 @@ public sealed class IrcCommandHandler
private async Task SendChannelTopicAsync(string channelName)
{
- var (topic, exists) = await _chatService.GetChannelTopicAsync(channelName);
+ var (topic, exists) = await _channelService.GetChannelTopicAsync(channelName);
if (!exists) return;
@@ -495,7 +502,7 @@ public sealed class IrcCommandHandler
private async Task HandleWhoAsync(IrcMessage msg)
{
- if (!RequireRegistered()) return;
+ if (!await RequireRegisteredAsync()) return;
if (msg.Parameters.Count < 1) return;
var channelName = IrcToEchoHubChannel(msg.Parameters[0]);
@@ -516,7 +523,7 @@ public sealed class IrcCommandHandler
private async Task HandleWhoisAsync(IrcMessage msg)
{
- if (!RequireRegistered()) return;
+ if (!await RequireRegisteredAsync()) return;
if (msg.Parameters.Count < 1) return;
var nick = msg.Parameters[^1].ToLowerInvariant();
@@ -559,7 +566,7 @@ public sealed class IrcCommandHandler
private async Task HandleAwayAsync(IrcMessage msg)
{
- if (!RequireRegistered()) return;
+ if (!await RequireRegisteredAsync()) return;
if (msg.Parameters.Count > 0 && !string.IsNullOrWhiteSpace(msg.Parameters[0]))
{
@@ -581,9 +588,9 @@ public sealed class IrcCommandHandler
private async Task HandleListAsync(IrcMessage msg)
{
- if (!RequireRegistered()) return;
+ if (!await RequireRegisteredAsync()) return;
- var channels = await _chatService.GetChannelListAsync();
+ var channels = await _channelService.GetChannelListAsync();
foreach (var ch in channels)
{
@@ -597,7 +604,7 @@ public sealed class IrcCommandHandler
private async Task HandleModeAsync(IrcMessage msg)
{
- if (!RequireRegistered()) return;
+ if (!await RequireRegisteredAsync()) return;
if (msg.Parameters.Count < 1) return;
var target = msg.Parameters[0];
@@ -621,11 +628,11 @@ public sealed class IrcCommandHandler
// ── Helpers ──────────────────────────────────────────────────────────────
- private bool RequireRegistered()
+ private async Task RequireRegisteredAsync()
{
if (_conn.IsRegistered) return true;
- _ = _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOTREGISTERED,
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOTREGISTERED,
":You have not registered");
return false;
}
diff --git a/src/EchoHub.Server.Irc/IrcGatewayService.cs b/src/EchoHub.Server.Irc/IrcGatewayService.cs
index e09ec61..d9f555c 100644
--- a/src/EchoHub.Server.Irc/IrcGatewayService.cs
+++ b/src/EchoHub.Server.Irc/IrcGatewayService.cs
@@ -34,7 +34,7 @@ public sealed class IrcGatewayService : BackgroundService
public IEnumerable GetConnectionsInChannel(string channelName)
{
return _connections.Values
- .Where(c => c.IsAuthenticated && c.JoinedChannels.Contains(channelName));
+ .Where(c => c.IsAuthenticated && c.IsInChannel(channelName));
}
public IEnumerable GetAllConnections()
@@ -120,8 +120,10 @@ public sealed class IrcGatewayService : BackgroundService
try
{
chatService = _services.GetRequiredService();
+ var channelService = _services.GetRequiredService();
+ var encryption = _services.GetRequiredService();
var handler = new IrcCommandHandler(
- connection, _options, chatService, _logger);
+ connection, _options, chatService, channelService, encryption, _logger);
await handler.RunAsync(ct);
}
@@ -133,7 +135,7 @@ public sealed class IrcGatewayService : BackgroundService
{
if (connection.IsAuthenticated)
{
- foreach (var ch in connection.JoinedChannels.ToList())
+ foreach (var ch in connection.GetJoinedChannels())
{
if (chatService is null) break;
await chatService.LeaveChannelAsync(
diff --git a/src/EchoHub.Server.Irc/IrcMessageFormatter.cs b/src/EchoHub.Server.Irc/IrcMessageFormatter.cs
index fb017c5..c1a91ab 100644
--- a/src/EchoHub.Server.Irc/IrcMessageFormatter.cs
+++ b/src/EchoHub.Server.Irc/IrcMessageFormatter.cs
@@ -48,6 +48,10 @@ public static partial class IrcMessageFormatter
case MessageType.File:
lines.Add($"{prefix} PRIVMSG {ircChannel} :[File: {message.AttachmentFileName}] {message.AttachmentUrl}");
break;
+
+ case MessageType.Audio:
+ lines.Add($"{prefix} PRIVMSG {ircChannel} :\u266a [Audio: {message.AttachmentFileName}] {message.AttachmentUrl}");
+ break;
}
return lines;
diff --git a/src/EchoHub.Server/Controllers/ChannelsController.cs b/src/EchoHub.Server/Controllers/ChannelsController.cs
index 753da7f..610b26c 100644
--- a/src/EchoHub.Server/Controllers/ChannelsController.cs
+++ b/src/EchoHub.Server/Controllers/ChannelsController.cs
@@ -8,7 +8,6 @@ using EchoHub.Server.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
-using Microsoft.EntityFrameworkCore;
namespace EchoHub.Server.Controllers;
@@ -18,6 +17,7 @@ namespace EchoHub.Server.Controllers;
[EnableRateLimiting("general")]
public class ChannelsController : ControllerBase
{
+ private readonly IChannelService _channelService;
private readonly EchoHubDbContext _db;
private readonly FileStorageService _fileStorage;
private readonly ImageToAsciiService _asciiService;
@@ -26,6 +26,7 @@ public class ChannelsController : ControllerBase
private readonly IMessageEncryptionService _encryption;
public ChannelsController(
+ IChannelService channelService,
EchoHubDbContext db,
FileStorageService fileStorage,
ImageToAsciiService asciiService,
@@ -33,6 +34,7 @@ public class ChannelsController : ControllerBase
IChatService chatService,
IMessageEncryptionService encryption)
{
+ _channelService = channelService;
_db = db;
_fileStorage = fileStorage;
_asciiService = asciiService;
@@ -48,74 +50,29 @@ public class ChannelsController : ControllerBase
if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required."));
- var userId = Guid.Parse(userIdClaim);
offset = Math.Max(0, offset);
limit = Math.Clamp(limit, 1, 100);
- // Public channels + private channels the user has joined
- var query = _db.Channels.Where(c =>
- c.IsPublic || _db.ChannelMemberships.Any(m => m.ChannelId == c.Id && m.UserId == userId));
- var total = await query.CountAsync();
-
- var channels = await query
- .OrderBy(c => c.Name)
- .Skip(offset)
- .Take(limit)
- .Select(c => new ChannelDto(
- c.Id,
- c.Name,
- c.Topic,
- c.IsPublic,
- c.Messages.Count,
- c.CreatedAt))
- .ToListAsync();
-
- return Ok(new PaginatedResponse(channels, total, offset, limit));
+ var result = await _channelService.GetChannelsAsync(Guid.Parse(userIdClaim), offset, limit);
+ return Ok(result);
}
[HttpPost]
public async Task CreateChannel([FromBody] CreateChannelRequest request)
{
- if (string.IsNullOrWhiteSpace(request.Name))
- return BadRequest(new ErrorResponse("Channel name is required."));
-
- var channelName = request.Name.ToLowerInvariant().Trim();
-
- if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
- return BadRequest(new ErrorResponse("Channel name must be 2-100 characters and contain only letters, digits, underscores, or hyphens."));
-
- if (await _db.Channels.AnyAsync(c => c.Name == channelName))
- return Conflict(new ErrorResponse($"Channel '{channelName}' already exists."));
-
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required."));
- var channel = new Channel
- {
- Id = Guid.NewGuid(),
- Name = channelName,
- Topic = request.Topic?.Trim(),
- IsPublic = request.IsPublic,
- CreatedByUserId = Guid.Parse(userIdClaim),
- };
+ var result = await _channelService.CreateChannelAsync(
+ Guid.Parse(userIdClaim), request.Name, request.Topic, request.IsPublic);
+ if (!result.IsSuccess)
+ return MapChannelError(result);
- _db.Channels.Add(channel);
+ if (result.Channel!.IsPublic)
+ await _chatService.BroadcastChannelUpdatedAsync(result.Channel);
- // Creator automatically becomes a member
- _db.ChannelMemberships.Add(new ChannelMembership
- {
- UserId = Guid.Parse(userIdClaim),
- ChannelId = channel.Id,
- });
-
- await _db.SaveChangesAsync();
-
- var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, channel.IsPublic, 0, channel.CreatedAt);
- if (channel.IsPublic)
- await _chatService.BroadcastChannelUpdatedAsync(dto);
-
- return Created($"/api/channels/{channelName}", dto);
+ return Created($"/api/channels/{result.Channel.Name}", result.Channel);
}
[HttpPut("{channel}/topic")]
@@ -125,26 +82,13 @@ public class ChannelsController : ControllerBase
if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required."));
- var channelName = channel.ToLowerInvariant().Trim();
- var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
+ var result = await _channelService.UpdateTopicAsync(
+ Guid.Parse(userIdClaim), channel, request.Topic);
+ if (!result.IsSuccess)
+ return MapChannelError(result);
- if (dbChannel is null)
- return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
-
- if (dbChannel.CreatedByUserId != Guid.Parse(userIdClaim))
- return StatusCode(403, new ErrorResponse("Only the channel creator can update the topic."));
-
- if (request.Topic is not null && request.Topic.Length > ValidationConstants.MaxChannelTopicLength)
- return BadRequest(new ErrorResponse($"Topic must not exceed {ValidationConstants.MaxChannelTopicLength} characters."));
-
- dbChannel.Topic = request.Topic?.Trim();
- await _db.SaveChangesAsync();
-
- var messageCount = await _db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id);
- var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, messageCount, dbChannel.CreatedAt);
- await _chatService.BroadcastChannelUpdatedAsync(dto, channelName);
-
- return Ok(dto);
+ await _chatService.BroadcastChannelUpdatedAsync(result.Channel!, channel.ToLowerInvariant().Trim());
+ return Ok(result.Channel);
}
[HttpDelete("{channel}")]
@@ -154,29 +98,17 @@ public class ChannelsController : ControllerBase
if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required."));
- var channelName = channel.ToLowerInvariant().Trim();
-
- if (channelName == HubConstants.DefaultChannel)
- return BadRequest(new ErrorResponse($"The '{HubConstants.DefaultChannel}' channel cannot be deleted."));
-
- var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
-
- if (dbChannel is null)
- return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
-
- var userId = Guid.Parse(userIdClaim);
- var caller = await _db.Users.FindAsync(userId);
- if (dbChannel.CreatedByUserId != userId && (caller is null || caller.Role < ServerRole.Admin))
- return StatusCode(403, new ErrorResponse("Only the channel creator or an admin can delete the channel."));
-
- _db.Channels.Remove(dbChannel);
- await _db.SaveChangesAsync();
+ var result = await _channelService.DeleteChannelAsync(Guid.Parse(userIdClaim), channel);
+ if (!result.IsSuccess)
+ return MapChannelError(result);
return NoContent();
}
[HttpPost("{channel}/upload")]
[EnableRateLimiting("upload")]
+ [RequestSizeLimit(HubConstants.MaxFileSizeBytes)]
+ [RequestFormLimits(MultipartBodyLengthLimit = HubConstants.MaxFileSizeBytes)]
public async Task Upload(string channel, [FromQuery] string? size = null)
{
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
@@ -190,8 +122,8 @@ public class ChannelsController : ControllerBase
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
return BadRequest(new ErrorResponse("Invalid channel name format."));
- var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
- if (dbChannel is null)
+ var channelDto = await _channelService.GetChannelByNameAsync(channelName);
+ if (channelDto is null)
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
if (!Request.HasFormContentType || Request.Form.Files.Count == 0)
@@ -199,16 +131,23 @@ public class ChannelsController : ControllerBase
var file = Request.Form.Files[0];
- if (file.Length > HubConstants.MaxFileSizeBytes)
- return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB."));
-
- // Detect if file is an image by checking magic bytes
+ // Detect file type early so we can apply the correct size limit
using var stream = file.OpenReadStream();
var isImage = FileValidationHelper.IsValidImage(stream);
+ var isAudio = !isImage && FileValidationHelper.IsAudioFile(file.FileName);
+
+ var maxSize = isImage ? HubConstants.MaxImageSizeBytes
+ : isAudio ? HubConstants.MaxAudioFileSizeBytes
+ : HubConstants.MaxFileSizeBytes;
+
+ if (file.Length > maxSize)
+ return BadRequest(new ErrorResponse($"File size exceeds maximum of {maxSize / (1024 * 1024)} MB."));
var (fileId, filePath) = await _fileStorage.SaveFileAsync(stream, file.FileName);
- var messageType = isImage ? MessageType.Image : MessageType.File;
+ var messageType = isImage ? MessageType.Image
+ : isAudio ? MessageType.Audio
+ : MessageType.File;
string content;
if (isImage)
@@ -233,8 +172,9 @@ public class ChannelsController : ControllerBase
Type = messageType,
AttachmentUrl = attachmentUrl,
AttachmentFileName = file.FileName,
+ AttachmentFileSize = file.Length,
SentAt = DateTimeOffset.UtcNow,
- ChannelId = dbChannel.Id,
+ ChannelId = channelDto.Id,
SenderUserId = userId,
SenderUsername = usernameClaim,
};
@@ -252,7 +192,8 @@ public class ChannelsController : ControllerBase
messageType,
attachmentUrl,
file.FileName,
- message.SentAt);
+ message.SentAt,
+ file.Length);
await _chatService.BroadcastMessageAsync(channelName, messageDto);
@@ -274,8 +215,8 @@ public class ChannelsController : ControllerBase
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
return BadRequest(new ErrorResponse("Invalid channel name format."));
- var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
- if (dbChannel is null)
+ var channelDto = await _channelService.GetChannelByNameAsync(channelName);
+ if (channelDto is null)
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
if (string.IsNullOrWhiteSpace(request.Url))
@@ -295,13 +236,13 @@ public class ChannelsController : ControllerBase
response.EnsureSuccessStatusCode();
var contentLength = response.Content.Headers.ContentLength;
- if (contentLength > HubConstants.MaxFileSizeBytes)
- return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB."));
+ if (contentLength > HubConstants.MaxImageSizeBytes)
+ return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxImageSizeBytes / (1024 * 1024)} MB."));
imageBytes = await response.Content.ReadAsByteArrayAsync();
- if (imageBytes.Length > HubConstants.MaxFileSizeBytes)
- return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB."));
+ if (imageBytes.Length > HubConstants.MaxImageSizeBytes)
+ return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxImageSizeBytes / (1024 * 1024)} MB."));
fileName = Path.GetFileName(uri.LocalPath);
if (string.IsNullOrWhiteSpace(fileName) || !fileName.Contains('.'))
@@ -353,8 +294,9 @@ public class ChannelsController : ControllerBase
Type = MessageType.Image,
AttachmentUrl = attachmentUrl,
AttachmentFileName = fileName,
+ AttachmentFileSize = imageBytes.Length,
SentAt = DateTimeOffset.UtcNow,
- ChannelId = dbChannel.Id,
+ ChannelId = channelDto.Id,
SenderUserId = userId,
SenderUsername = usernameClaim,
};
@@ -372,10 +314,21 @@ public class ChannelsController : ControllerBase
MessageType.Image,
attachmentUrl,
fileName,
- message.SentAt);
+ message.SentAt,
+ imageBytes.Length);
await _chatService.BroadcastMessageAsync(channelName, messageDto);
return Ok(messageDto);
}
+
+ private IActionResult MapChannelError(ChannelOperationResult result) => result.Error switch
+ {
+ ChannelError.ValidationFailed => BadRequest(new ErrorResponse(result.ErrorMessage!)),
+ ChannelError.AlreadyExists => Conflict(new ErrorResponse(result.ErrorMessage!)),
+ ChannelError.NotFound => NotFound(new ErrorResponse(result.ErrorMessage!)),
+ ChannelError.Forbidden => StatusCode(403, new ErrorResponse(result.ErrorMessage!)),
+ ChannelError.Protected => BadRequest(new ErrorResponse(result.ErrorMessage!)),
+ _ => BadRequest(new ErrorResponse(result.ErrorMessage ?? "Unknown error.")),
+ };
}
diff --git a/src/EchoHub.Server/Controllers/FilesController.cs b/src/EchoHub.Server/Controllers/FilesController.cs
index 2bcacf9..42db03f 100644
--- a/src/EchoHub.Server/Controllers/FilesController.cs
+++ b/src/EchoHub.Server/Controllers/FilesController.cs
@@ -36,6 +36,13 @@ public class FilesController : ControllerBase
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
+ ".mp3" => "audio/mpeg",
+ ".wav" => "audio/wav",
+ ".ogg" => "audio/ogg",
+ ".flac" => "audio/flac",
+ ".aac" => "audio/aac",
+ ".m4a" => "audio/mp4",
+ ".wma" => "audio/x-ms-wma",
".pdf" => "application/pdf",
".txt" => "text/plain",
_ => "application/octet-stream"
diff --git a/src/EchoHub.Server/Data/Migrations/20260221193444_AddAttachmentFileSize.Designer.cs b/src/EchoHub.Server/Data/Migrations/20260221193444_AddAttachmentFileSize.Designer.cs
new file mode 100644
index 0000000..02d516e
--- /dev/null
+++ b/src/EchoHub.Server/Data/Migrations/20260221193444_AddAttachmentFileSize.Designer.cs
@@ -0,0 +1,267 @@
+//
+using System;
+using EchoHub.Server.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace EchoHub.Server.Data.Migrations
+{
+ [DbContext(typeof(EchoHubDbContext))]
+ [Migration("20260221193444_AddAttachmentFileSize")]
+ partial class AddAttachmentFileSize
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "10.0.3");
+
+ modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("CreatedByUserId")
+ .HasColumnType("TEXT");
+
+ b.Property("IsPublic")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("Topic")
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.ToTable("Channels");
+ });
+
+ modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property("ChannelId")
+ .HasColumnType("TEXT");
+
+ b.Property("JoinedAt")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("UserId", "ChannelId");
+
+ b.HasIndex("ChannelId");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("ChannelMemberships");
+ });
+
+ modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("AttachmentFileName")
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property("AttachmentFileSize")
+ .HasColumnType("INTEGER");
+
+ b.Property("AttachmentUrl")
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b.Property("ChannelId")
+ .HasColumnType("TEXT");
+
+ b.Property("Content")
+ .IsRequired()
+ .HasMaxLength(16000)
+ .HasColumnType("TEXT");
+
+ b.Property("EmbedJson")
+ .HasMaxLength(32000)
+ .HasColumnType("TEXT");
+
+ b.Property("SenderUserId")
+ .HasColumnType("TEXT");
+
+ b.Property("SenderUsername")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("TEXT");
+
+ b.Property("SentAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("Type")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ChannelId");
+
+ b.HasIndex("SentAt");
+
+ b.ToTable("Messages");
+ });
+
+ modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("RevokedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("TokenHash")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TokenHash");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("RefreshTokens");
+ });
+
+ modelBuilder.Entity("EchoHub.Core.Models.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("AvatarAscii")
+ .HasMaxLength(10000)
+ .HasColumnType("TEXT");
+
+ b.Property("Bio")
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("DisplayName")
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("IsBanned")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsMuted")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastSeenAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("MutedUntil")
+ .HasColumnType("INTEGER");
+
+ b.Property("NicknameColor")
+ .HasMaxLength(7)
+ .HasColumnType("TEXT");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Role")
+ .HasColumnType("INTEGER");
+
+ b.Property("Status")
+ .HasColumnType("INTEGER");
+
+ b.Property("StatusMessage")
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("Username")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Username")
+ .IsUnique();
+
+ b.ToTable("Users");
+ });
+
+ modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
+ {
+ b.HasOne("EchoHub.Core.Models.Channel", null)
+ .WithMany()
+ .HasForeignKey("ChannelId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("EchoHub.Core.Models.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
+ {
+ b.HasOne("EchoHub.Core.Models.Channel", "Channel")
+ .WithMany("Messages")
+ .HasForeignKey("ChannelId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Channel");
+ });
+
+ modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
+ {
+ b.HasOne("EchoHub.Core.Models.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
+ {
+ b.Navigation("Messages");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/EchoHub.Server/Data/Migrations/20260221193444_AddAttachmentFileSize.cs b/src/EchoHub.Server/Data/Migrations/20260221193444_AddAttachmentFileSize.cs
new file mode 100644
index 0000000..ffd143c
--- /dev/null
+++ b/src/EchoHub.Server/Data/Migrations/20260221193444_AddAttachmentFileSize.cs
@@ -0,0 +1,28 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace EchoHub.Server.Data.Migrations
+{
+ ///
+ public partial class AddAttachmentFileSize : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "AttachmentFileSize",
+ table: "Messages",
+ type: "INTEGER",
+ nullable: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "AttachmentFileSize",
+ table: "Messages");
+ }
+ }
+}
diff --git a/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs b/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs
index 715ed37..bf38795 100644
--- a/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs
+++ b/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs
@@ -79,6 +79,9 @@ namespace EchoHub.Server.Data.Migrations
.HasMaxLength(255)
.HasColumnType("TEXT");
+ b.Property("AttachmentFileSize")
+ .HasColumnType("INTEGER");
+
b.Property("AttachmentUrl")
.HasMaxLength(500)
.HasColumnType("TEXT");
diff --git a/src/EchoHub.Server/EchoHub.Server.csproj b/src/EchoHub.Server/EchoHub.Server.csproj
index 93bbe03..9795f48 100644
--- a/src/EchoHub.Server/EchoHub.Server.csproj
+++ b/src/EchoHub.Server/EchoHub.Server.csproj
@@ -1,5 +1,9 @@
+
+
+
+
@@ -22,6 +26,8 @@
net10.0
enable
enable
+
+ hue_icon.ico
diff --git a/src/EchoHub.Server/Program.cs b/src/EchoHub.Server/Program.cs
index 0e830a5..2ab4dcc 100644
--- a/src/EchoHub.Server/Program.cs
+++ b/src/EchoHub.Server/Program.cs
@@ -108,12 +108,14 @@ while (true)
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddHostedService();
+ builder.Services.AddHostedService();
// ── Encryption ─────────────────────────────────────────────────────
builder.Services.AddSingleton();
// ── Chat Service + Broadcasters ─────────────────────────────────────
builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
builder.Services.AddSingleton();
// ── IRC Gateway (optional) ──────────────────────────────────────────
diff --git a/src/EchoHub.Server/Services/ChannelService.cs b/src/EchoHub.Server/Services/ChannelService.cs
new file mode 100644
index 0000000..d4ef85e
--- /dev/null
+++ b/src/EchoHub.Server/Services/ChannelService.cs
@@ -0,0 +1,247 @@
+using EchoHub.Core.Constants;
+using EchoHub.Core.Contracts;
+using EchoHub.Core.DTOs;
+using EchoHub.Core.Models;
+using EchoHub.Server.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace EchoHub.Server.Services;
+
+public class ChannelService : IChannelService
+{
+ private readonly IServiceScopeFactory _scopeFactory;
+ private readonly PresenceTracker _presenceTracker;
+ private readonly ILogger _logger;
+
+ public ChannelService(
+ IServiceScopeFactory scopeFactory,
+ PresenceTracker presenceTracker,
+ ILogger logger)
+ {
+ _scopeFactory = scopeFactory;
+ _presenceTracker = presenceTracker;
+ _logger = logger;
+ }
+
+ public async Task> GetChannelsAsync(Guid userId, int offset, int limit)
+ {
+ using var scope = _scopeFactory.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ await EnsureDefaultChannelAsync(db);
+
+ var query = db.Channels.Where(c =>
+ c.IsPublic || db.ChannelMemberships.Any(m => m.ChannelId == c.Id && m.UserId == userId));
+ var total = await query.CountAsync();
+
+ var channels = await query
+ .OrderBy(c => c.Name)
+ .Skip(offset)
+ .Take(limit)
+ .Select(c => new ChannelDto(
+ c.Id, c.Name, c.Topic, c.IsPublic, c.Messages.Count, c.CreatedAt))
+ .ToListAsync();
+
+ return new PaginatedResponse(channels, total, offset, limit);
+ }
+
+ public async Task CreateChannelAsync(
+ Guid creatorUserId, string name, string? topic, bool isPublic)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ return ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Channel name is required.");
+
+ var channelName = name.ToLowerInvariant().Trim();
+
+ if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
+ return ChannelOperationResult.Fail(ChannelError.ValidationFailed,
+ "Channel name must be 2-100 characters and contain only letters, digits, underscores, or hyphens.");
+
+ using var scope = _scopeFactory.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ if (await db.Channels.AnyAsync(c => c.Name == channelName))
+ return ChannelOperationResult.Fail(ChannelError.AlreadyExists, $"Channel '{channelName}' already exists.");
+
+ var channel = new Channel
+ {
+ Id = Guid.NewGuid(),
+ Name = channelName,
+ Topic = topic?.Trim(),
+ IsPublic = isPublic,
+ CreatedByUserId = creatorUserId,
+ };
+
+ db.Channels.Add(channel);
+
+ // Creator automatically becomes a member
+ db.ChannelMemberships.Add(new ChannelMembership
+ {
+ UserId = creatorUserId,
+ ChannelId = channel.Id,
+ });
+
+ await db.SaveChangesAsync();
+
+ var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, channel.IsPublic, 0, channel.CreatedAt);
+ return ChannelOperationResult.Success(dto);
+ }
+
+ public async Task UpdateTopicAsync(
+ Guid callerUserId, string channelName, string? topic)
+ {
+ channelName = channelName.ToLowerInvariant().Trim();
+
+ if (topic is not null && topic.Length > ValidationConstants.MaxChannelTopicLength)
+ return ChannelOperationResult.Fail(ChannelError.ValidationFailed,
+ $"Topic must not exceed {ValidationConstants.MaxChannelTopicLength} characters.");
+
+ using var scope = _scopeFactory.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
+ if (dbChannel is null)
+ return ChannelOperationResult.Fail(ChannelError.NotFound, $"Channel '{channelName}' does not exist.");
+
+ if (dbChannel.CreatedByUserId != callerUserId)
+ return ChannelOperationResult.Fail(ChannelError.Forbidden, "Only the channel creator can update the topic.");
+
+ dbChannel.Topic = topic?.Trim();
+ await db.SaveChangesAsync();
+
+ var messageCount = await db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id);
+ var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, messageCount, dbChannel.CreatedAt);
+ return ChannelOperationResult.Success(dto);
+ }
+
+ public async Task DeleteChannelAsync(Guid callerUserId, string channelName)
+ {
+ channelName = channelName.ToLowerInvariant().Trim();
+
+ if (channelName == HubConstants.DefaultChannel)
+ return ChannelOperationResult.Fail(ChannelError.Protected,
+ $"The '{HubConstants.DefaultChannel}' channel cannot be deleted.");
+
+ using var scope = _scopeFactory.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
+ if (dbChannel is null)
+ return ChannelOperationResult.Fail(ChannelError.NotFound, $"Channel '{channelName}' does not exist.");
+
+ var caller = await db.Users.FindAsync(callerUserId);
+ if (dbChannel.CreatedByUserId != callerUserId && (caller is null || caller.Role < ServerRole.Admin))
+ return ChannelOperationResult.Fail(ChannelError.Forbidden,
+ "Only the channel creator or an admin can delete the channel.");
+
+ db.Channels.Remove(dbChannel);
+ await db.SaveChangesAsync();
+
+ var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, 0, dbChannel.CreatedAt);
+ return ChannelOperationResult.Success(dto);
+ }
+
+ public async Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName)
+ {
+ channelName = channelName.ToLowerInvariant().Trim();
+
+ using var scope = _scopeFactory.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
+ if (channel is null) return (null, false);
+
+ return (channel.Topic, true);
+ }
+
+ public async Task> GetChannelListAsync()
+ {
+ using var scope = _scopeFactory.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ var channels = await db.Channels.OrderBy(c => c.Name).ToListAsync();
+
+ return channels.Select(c => new ChannelListItem(
+ c.Name, c.Topic,
+ _presenceTracker.GetOnlineUsersInChannel(c.Name).Count)).ToList();
+ }
+
+ public async Task GetChannelByNameAsync(string channelName)
+ {
+ channelName = channelName.ToLowerInvariant().Trim();
+
+ using var scope = _scopeFactory.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ var c = await db.Channels.FirstOrDefaultAsync(ch => ch.Name == channelName);
+ if (c is null) return null;
+
+ var messageCount = await db.Messages.CountAsync(m => m.ChannelId == c.Id);
+ return new ChannelDto(c.Id, c.Name, c.Topic, c.IsPublic, messageCount, c.CreatedAt);
+ }
+
+ public async Task<(bool Success, string? Error)> EnsureChannelMembershipAsync(Guid userId, string channelName)
+ {
+ channelName = channelName.ToLowerInvariant().Trim();
+
+ if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
+ return (false, "Invalid channel name. Use 2-100 characters: letters, digits, underscores, or hyphens.");
+
+ using var scope = _scopeFactory.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
+ if (channel is null)
+ {
+ // Auto-recreate #general if it was somehow removed
+ if (channelName == HubConstants.DefaultChannel)
+ {
+ channel = new Channel
+ {
+ Id = Guid.NewGuid(),
+ Name = HubConstants.DefaultChannel,
+ Topic = "General discussion",
+ CreatedByUserId = Guid.Empty,
+ };
+ db.Channels.Add(channel);
+ await db.SaveChangesAsync();
+ _logger.LogWarning("Default channel '{Channel}' was missing and has been recreated", HubConstants.DefaultChannel);
+ }
+ else
+ {
+ return (false, $"Channel '{channelName}' does not exist. Create it first via the channel list.");
+ }
+ }
+
+ var hasMembership = await db.ChannelMemberships
+ .AnyAsync(m => m.UserId == userId && m.ChannelId == channel.Id);
+ if (!hasMembership)
+ {
+ db.ChannelMemberships.Add(new ChannelMembership
+ {
+ UserId = userId,
+ ChannelId = channel.Id,
+ });
+ await db.SaveChangesAsync();
+ }
+
+ return (true, null);
+ }
+
+ private static async Task EnsureDefaultChannelAsync(EchoHubDbContext db)
+ {
+ if (!await db.Channels.AnyAsync(c => c.Name == HubConstants.DefaultChannel))
+ {
+ db.Channels.Add(new Channel
+ {
+ Id = Guid.NewGuid(),
+ Name = HubConstants.DefaultChannel,
+ Topic = "General discussion",
+ CreatedByUserId = Guid.Empty,
+ });
+ await db.SaveChangesAsync();
+ }
+ }
+}
diff --git a/src/EchoHub.Server/Services/ChatService.cs b/src/EchoHub.Server/Services/ChatService.cs
index a75343b..b5010fd 100644
--- a/src/EchoHub.Server/Services/ChatService.cs
+++ b/src/EchoHub.Server/Services/ChatService.cs
@@ -17,6 +17,7 @@ public class ChatService : IChatService
private readonly IEnumerable _broadcasters;
private readonly LinkEmbedService _embedService;
private readonly IMessageEncryptionService _encryption;
+ private readonly IChannelService _channelService;
private readonly ILogger _logger;
public ChatService(
@@ -25,6 +26,7 @@ public class ChatService : IChatService
IEnumerable broadcasters,
LinkEmbedService embedService,
IMessageEncryptionService encryption,
+ IChannelService channelService,
ILogger logger)
{
_scopeFactory = scopeFactory;
@@ -32,6 +34,7 @@ public class ChatService : IChatService
_broadcasters = broadcasters;
_embedService = embedService;
_encryption = encryption;
+ _channelService = channelService;
_logger = logger;
}
@@ -95,28 +98,10 @@ public class ChatService : IChatService
{
channelName = channelName.ToLowerInvariant().Trim();
- if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
- return ([], "Invalid channel name. Use 2-100 characters: letters, digits, underscores, or hyphens.");
-
- using var scope = _scopeFactory.CreateScope();
- var db = scope.ServiceProvider.GetRequiredService();
-
- var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
- if (channel is null)
- return ([], $"Channel '{channelName}' does not exist. Create it first via the channel list.");
-
- // Persist membership so the channel shows in the user's channel list
- var hasMembership = await db.ChannelMemberships
- .AnyAsync(m => m.UserId == userId && m.ChannelId == channel.Id);
- if (!hasMembership)
- {
- db.ChannelMemberships.Add(new ChannelMembership
- {
- UserId = userId,
- ChannelId = channel.Id,
- });
- await db.SaveChangesAsync();
- }
+ // Delegate channel validation + membership to ChannelService
+ var (success, error) = await _channelService.EnsureChannelMembershipAsync(userId, channelName);
+ if (!success)
+ return ([], error);
var isNewJoin = _presenceTracker.JoinChannel(username, channelName);
@@ -126,7 +111,7 @@ public class ChatService : IChatService
_logger.LogInformation("{User} joined channel '{Channel}'", username, channelName);
}
- var history = await GetChannelHistoryInternalAsync(db, channelName, HubConstants.DefaultHistoryCount);
+ var history = await GetChannelHistoryAsync(channelName, HubConstants.DefaultHistoryCount);
return (history, null);
}
@@ -228,7 +213,7 @@ public class ChatService : IChatService
null,
null,
message.SentAt,
- embeds);
+ Embeds: embeds);
await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto));
@@ -335,32 +320,6 @@ public class ChatService : IChatService
user.StatusMessage, user.Role, user.CreatedAt, user.LastSeenAt);
}
- public async Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName)
- {
- channelName = channelName.ToLowerInvariant().Trim();
-
- using var scope = _scopeFactory.CreateScope();
- var db = scope.ServiceProvider.GetRequiredService();
-
- var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
- if (channel is null) return (null, false);
-
- return (channel.Topic, true);
- }
-
- public async Task> GetChannelListAsync()
- {
- using var scope = _scopeFactory.CreateScope();
- var db = scope.ServiceProvider.GetRequiredService();
-
- var channels = await db.Channels.OrderBy(c => c.Name).ToListAsync();
-
- return channels.Select(c => new ChannelListItem(
- c.Name,
- c.Topic,
- _presenceTracker.GetOnlineUsersInChannel(c.Name).Count)).ToList();
- }
-
public Task> GetChannelsForUserAsync(string username)
=> Task.FromResult(_presenceTracker.GetChannelsForUser(username));
@@ -457,6 +416,7 @@ public class ChatService : IChatService
x.m.AttachmentUrl,
x.m.AttachmentFileName,
x.m.SentAt,
+ x.m.AttachmentFileSize,
embeds);
}).ToList();
}
diff --git a/src/EchoHub.Server/Services/FileCleanupService.cs b/src/EchoHub.Server/Services/FileCleanupService.cs
new file mode 100644
index 0000000..c5d9cca
--- /dev/null
+++ b/src/EchoHub.Server/Services/FileCleanupService.cs
@@ -0,0 +1,69 @@
+namespace EchoHub.Server.Services;
+
+public sealed class FileCleanupService : BackgroundService
+{
+ private readonly IConfiguration _configuration;
+ private readonly ILogger _logger;
+
+ public FileCleanupService(IConfiguration configuration, ILogger logger)
+ {
+ _configuration = configuration;
+ _logger = logger;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ var intervalHours = _configuration.GetValue("Storage:CleanupIntervalHours", 1);
+ var retentionDays = _configuration.GetValue("Storage:RetentionDays", 30);
+ var storagePath = _configuration["Storage:Path"]
+ ?? Path.Combine(AppContext.BaseDirectory, "uploads");
+
+ _logger.LogInformation(
+ "File cleanup service started — interval: {Hours}h, retention: {Days}d, path: {Path}",
+ intervalHours, retentionDays, storagePath);
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ await Task.Delay(TimeSpan.FromHours(intervalHours), stoppingToken);
+
+ try
+ {
+ CleanupOldFiles(storagePath, retentionDays);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error during file cleanup");
+ }
+ }
+ }
+
+ private void CleanupOldFiles(string storagePath, int retentionDays)
+ {
+ if (!Directory.Exists(storagePath))
+ return;
+
+ var cutoff = DateTime.UtcNow.AddDays(-retentionDays);
+ var files = Directory.GetFiles(storagePath);
+ var deleted = 0;
+
+ foreach (var file in files)
+ {
+ var createdAt = File.GetCreationTimeUtc(file);
+ if (createdAt < cutoff)
+ {
+ try
+ {
+ File.Delete(file);
+ deleted++;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to delete old file: {File}", file);
+ }
+ }
+ }
+
+ if (deleted > 0)
+ _logger.LogInformation("File cleanup: deleted {Count} files older than {Days} days", deleted, retentionDays);
+ }
+}
diff --git a/src/EchoHub.Server/Services/FileValidationHelper.cs b/src/EchoHub.Server/Services/FileValidationHelper.cs
index adf7330..f1d3b40 100644
--- a/src/EchoHub.Server/Services/FileValidationHelper.cs
+++ b/src/EchoHub.Server/Services/FileValidationHelper.cs
@@ -52,6 +52,20 @@ public static class FileValidationHelper
}
}
+ private static readonly HashSet AudioExtensions = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ".mp3", ".wav", ".ogg", ".flac", ".aac", ".m4a", ".wma"
+ };
+
+ ///
+ /// Checks whether the file name has a recognized audio extension.
+ ///
+ public static bool IsAudioFile(string fileName)
+ {
+ var ext = Path.GetExtension(fileName);
+ return !string.IsNullOrEmpty(ext) && AudioExtensions.Contains(ext);
+ }
+
private static bool StartsWith(byte[] buffer, int length, byte[] magic)
{
if (length < magic.Length)
diff --git a/src/EchoHub.Server/Setup/DataMigrationService.cs b/src/EchoHub.Server/Setup/DataMigrationService.cs
index a61e00b..e1e1902 100644
--- a/src/EchoHub.Server/Setup/DataMigrationService.cs
+++ b/src/EchoHub.Server/Setup/DataMigrationService.cs
@@ -1,6 +1,7 @@
using System.Text.RegularExpressions;
using EchoHub.Core.Constants;
using EchoHub.Core.DTOs;
+using EchoHub.Core.Models;
using EchoHub.Server.Data;
using Microsoft.EntityFrameworkCore;
@@ -12,12 +13,14 @@ public static partial class DataMigrationService
{
using var scope = services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService();
+ var config = scope.ServiceProvider.GetRequiredService();
var logger = scope.ServiceProvider.GetRequiredService()
.CreateLogger("EchoHub.Server.Setup.DataMigration");
await EnsureDefaultChannelsPublicAsync(db, logger);
await MigrateAnsiMessagesAsync(db, logger);
await MigrateEmbedJsonToArrayAsync(db, logger);
+ await EnsureConfiguredAdminsAsync(db, config, logger);
}
///
@@ -94,6 +97,40 @@ public static partial class DataMigrationService
[GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2|48;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")]
private static partial Regex AnsiColorRegex();
+ ///
+ /// Ensure usernames listed in Server:Admins config are at least Admin role.
+ /// Acts as a safety net in case the first registered user didn't get Owner role.
+ ///
+ private static async Task EnsureConfiguredAdminsAsync(EchoHubDbContext db, IConfiguration config, ILogger logger)
+ {
+ var adminUsernames = config.GetSection("Server:Admins").Get();
+ if (adminUsernames is not { Length: > 0 })
+ return;
+
+ var promoted = 0;
+ foreach (var username in adminUsernames)
+ {
+ var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username);
+ if (user is null)
+ {
+ logger.LogWarning("Configured admin '{Username}' not found in database (not registered yet).", username);
+ continue;
+ }
+
+ if (user.Role < ServerRole.Admin)
+ {
+ var oldRole = user.Role;
+ user.Role = ServerRole.Admin;
+ promoted++;
+ logger.LogInformation("Promoted '{Username}' from {OldRole} to Admin (configured in Server:Admins).",
+ username, oldRole);
+ }
+ }
+
+ if (promoted > 0)
+ await db.SaveChangesAsync();
+ }
+
///
/// Migrate old single-object EmbedJson ("{...}") to array format ("[{...}]").
///
diff --git a/src/EchoHub.Server/appsettings.example.json b/src/EchoHub.Server/appsettings.example.json
index ff89ee0..5d21c45 100644
--- a/src/EchoHub.Server/appsettings.example.json
+++ b/src/EchoHub.Server/appsettings.example.json
@@ -12,7 +12,12 @@
"Name": "My EchoHub Server",
"Description": "A self-hosted EchoHub chat server",
"PublicServer": false,
- "PublicHost": ""
+ "PublicHost": "",
+ "Admins": []
+ },
+ "Storage": {
+ "CleanupIntervalHours": 1,
+ "RetentionDays": 30
},
"Encryption": {
"Key": "",
diff --git a/src/EchoHub.Server/hue_icon.ico b/src/EchoHub.Server/hue_icon.ico
new file mode 100644
index 0000000..dd2668c
Binary files /dev/null and b/src/EchoHub.Server/hue_icon.ico differ
diff --git a/src/EchoHub.Tests/ChatLineTests.cs b/src/EchoHub.Tests/ChatLineTests.cs
new file mode 100644
index 0000000..16abab5
--- /dev/null
+++ b/src/EchoHub.Tests/ChatLineTests.cs
@@ -0,0 +1,107 @@
+using EchoHub.Client.UI;
+using Xunit;
+
+namespace EchoHub.Tests;
+
+///
+/// Tests for static string-utility methods on ChatLine.
+/// Note: Tests that construct ChatLine/ChatListSource or use Terminal.Gui types
+/// (Attribute, Color) are excluded because Terminal.Gui's module initializer
+/// requires a display driver which is unavailable in CI/test environments.
+///
+public class ChatLineTests
+{
+ // ── HasColorTags ──────────────────────────────────────────────────
+
+ [Fact]
+ public void HasColorTags_ForegroundTag_ReturnsTrue()
+ {
+ Assert.True(ChatLine.HasColorTags("Hello {F:FF0000}world"));
+ }
+
+ [Fact]
+ public void HasColorTags_BackgroundTag_ReturnsTrue()
+ {
+ Assert.True(ChatLine.HasColorTags("Hello {B:00FF00}world"));
+ }
+
+ [Fact]
+ public void HasColorTags_ResetTag_ReturnsTrue()
+ {
+ Assert.True(ChatLine.HasColorTags("Hello{X}"));
+ }
+
+ [Fact]
+ public void HasColorTags_NoTags_ReturnsFalse()
+ {
+ Assert.False(ChatLine.HasColorTags("Hello world"));
+ }
+
+ [Fact]
+ public void HasColorTags_PartialTag_ReturnsFalse()
+ {
+ // {Z:...} is not a valid tag (only F or B)
+ Assert.False(ChatLine.HasColorTags("Hello {Z:000000}"));
+ }
+
+ [Fact]
+ public void HasColorTags_EmptyString_ReturnsFalse()
+ {
+ Assert.False(ChatLine.HasColorTags(""));
+ }
+
+ [Theory]
+ [InlineData("{F:AABBCC}text")]
+ [InlineData("prefix{B:112233}suffix")]
+ [InlineData("a{X}b")]
+ [InlineData("{F:000000}{B:FFFFFF}{X}")]
+ public void HasColorTags_VariousValidTags_ReturnsTrue(string input)
+ {
+ Assert.True(ChatLine.HasColorTags(input));
+ }
+
+ // ── StripColorTags ────────────────────────────────────────────────
+
+ [Fact]
+ public void StripColorTags_RemovesAllTags()
+ {
+ var result = ChatLine.StripColorTags("{F:FF0000}red{B:00FF00}green{X}");
+ Assert.Equal("redgreen", result);
+ }
+
+ [Fact]
+ public void StripColorTags_NoTags_ReturnsOriginal()
+ {
+ var result = ChatLine.StripColorTags("plain text");
+ Assert.Equal("plain text", result);
+ }
+
+ [Fact]
+ public void StripColorTags_OnlyTags_ReturnsEmpty()
+ {
+ var result = ChatLine.StripColorTags("{F:AABBCC}{B:112233}{X}");
+ Assert.Equal("", result);
+ }
+
+ [Fact]
+ public void StripColorTags_MixedContent_KeepsText()
+ {
+ var result = ChatLine.StripColorTags("before{F:FF0000}middle{X}after");
+ Assert.Equal("beforemiddleafter", result);
+ }
+
+ [Fact]
+ public void StripColorTags_MultipleConsecutiveTags_AllStripped()
+ {
+ var result = ChatLine.StripColorTags("{F:FF0000}{B:00FF00}{X}{F:0000FF}text{X}");
+ Assert.Equal("text", result);
+ }
+
+ [Fact]
+ public void StripColorTags_PreservesNonTagBraces()
+ {
+ // {Hello} is not a valid tag and should be preserved
+ var result = ChatLine.StripColorTags("{Hello} world");
+ Assert.Equal("{Hello} world", result);
+ }
+}
diff --git a/src/EchoHub.Tests/CommandHandlerTests.cs b/src/EchoHub.Tests/CommandHandlerTests.cs
new file mode 100644
index 0000000..5c58bf7
--- /dev/null
+++ b/src/EchoHub.Tests/CommandHandlerTests.cs
@@ -0,0 +1,462 @@
+using EchoHub.Client.Commands;
+using EchoHub.Core.Models;
+using Xunit;
+
+namespace EchoHub.Tests;
+
+public class CommandHandlerTests
+{
+ private CommandHandler CreateHandler() => new();
+
+ // ── IsCommand ─────────────────────────────────────────────────────
+
+ [Fact]
+ public void IsCommand_StartsWithSlash_ReturnsTrue()
+ {
+ var handler = CreateHandler();
+ Assert.True(handler.IsCommand("/help"));
+ }
+
+ [Fact]
+ public void IsCommand_NoSlash_ReturnsFalse()
+ {
+ var handler = CreateHandler();
+ Assert.False(handler.IsCommand("hello"));
+ }
+
+ [Fact]
+ public void IsCommand_EmptyString_ReturnsFalse()
+ {
+ var handler = CreateHandler();
+ Assert.False(handler.IsCommand(""));
+ }
+
+ // ── HandleAsync — not a command ───────────────────────────────────
+
+ [Fact]
+ public async Task HandleAsync_NotCommand_ReturnsFalse()
+ {
+ var handler = CreateHandler();
+ var result = await handler.HandleAsync("hello world");
+
+ Assert.False(result.Handled);
+ }
+
+ // ── HandleAsync — unknown command ─────────────────────────────────
+
+ [Fact]
+ public async Task HandleAsync_UnknownCommand_ReturnsError()
+ {
+ var handler = CreateHandler();
+ var result = await handler.HandleAsync("/doesnotexist");
+
+ Assert.True(result.Handled);
+ Assert.True(result.IsError);
+ Assert.Contains("Unknown command", result.Message);
+ }
+
+ // ── /status ───────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task HandleAsync_StatusOnline_SetsOnlineStatus()
+ {
+ var handler = CreateHandler();
+ UserStatus? capturedStatus = null;
+ handler.OnSetStatus += (status, msg) => { capturedStatus = status; return Task.CompletedTask; };
+
+ var result = await handler.HandleAsync("/status online");
+
+ Assert.True(result.Handled);
+ Assert.False(result.IsError);
+ Assert.Equal(UserStatus.Online, capturedStatus);
+ }
+
+ [Fact]
+ public async Task HandleAsync_StatusAway_SetsAwayStatus()
+ {
+ var handler = CreateHandler();
+ UserStatus? capturedStatus = null;
+ handler.OnSetStatus += (status, msg) => { capturedStatus = status; return Task.CompletedTask; };
+
+ await handler.HandleAsync("/status away");
+ Assert.Equal(UserStatus.Away, capturedStatus);
+ }
+
+ [Fact]
+ public async Task HandleAsync_StatusCustomMessage_SetsStatusMessage()
+ {
+ var handler = CreateHandler();
+ string? capturedMessage = null;
+ handler.OnSetStatus += (status, msg) => { capturedMessage = msg; return Task.CompletedTask; };
+
+ var result = await handler.HandleAsync("/status brb lunch");
+
+ Assert.True(result.Handled);
+ Assert.Contains("brb lunch", result.Message);
+ Assert.Equal("brb lunch", capturedMessage);
+ }
+
+ [Fact]
+ public async Task HandleAsync_StatusNoArgs_ReturnsError()
+ {
+ var handler = CreateHandler();
+ var result = await handler.HandleAsync("/status");
+
+ Assert.True(result.IsError);
+ Assert.Contains("Usage", result.Message);
+ }
+
+ // ── /nick ─────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task HandleAsync_Nick_SetsDisplayName()
+ {
+ var handler = CreateHandler();
+ string? capturedNick = null;
+ handler.OnSetNick += nick => { capturedNick = nick; return Task.CompletedTask; };
+
+ var result = await handler.HandleAsync("/nick Bob Smith");
+
+ Assert.True(result.Handled);
+ Assert.Equal("Bob Smith", capturedNick);
+ }
+
+ [Fact]
+ public async Task HandleAsync_Nick_EmptyArgs_ReturnsError()
+ {
+ var handler = CreateHandler();
+ var result = await handler.HandleAsync("/nick");
+
+ Assert.True(result.IsError);
+ Assert.Contains("Usage", result.Message);
+ }
+
+ // ── /color ────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task HandleAsync_Color_ValidHex_Succeeds()
+ {
+ var handler = CreateHandler();
+ string? capturedColor = null;
+ handler.OnSetColor += color => { capturedColor = color; return Task.CompletedTask; };
+
+ var result = await handler.HandleAsync("/color #FF5733");
+
+ Assert.True(result.Handled);
+ Assert.False(result.IsError);
+ Assert.Equal("#FF5733", capturedColor);
+ }
+
+ [Fact]
+ public async Task HandleAsync_Color_WithoutHash_AddsHash()
+ {
+ var handler = CreateHandler();
+ string? capturedColor = null;
+ handler.OnSetColor += color => { capturedColor = color; return Task.CompletedTask; };
+
+ await handler.HandleAsync("/color FF5733");
+ Assert.Equal("#FF5733", capturedColor);
+ }
+
+ [Fact]
+ public async Task HandleAsync_Color_InvalidHex_ReturnsError()
+ {
+ var handler = CreateHandler();
+ var result = await handler.HandleAsync("/color #ZZZZZZ");
+
+ Assert.True(result.IsError);
+ Assert.Contains("Invalid color", result.Message);
+ }
+
+ [Fact]
+ public async Task HandleAsync_Color_TooShort_ReturnsError()
+ {
+ var handler = CreateHandler();
+ var result = await handler.HandleAsync("/color #FFF");
+
+ Assert.True(result.IsError);
+ }
+
+ [Fact]
+ public async Task HandleAsync_Color_NoArgs_ReturnsError()
+ {
+ var handler = CreateHandler();
+ var result = await handler.HandleAsync("/color");
+
+ Assert.True(result.IsError);
+ }
+
+ // ── /join ─────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task HandleAsync_Join_StripsHashPrefix()
+ {
+ var handler = CreateHandler();
+ string? capturedChannel = null;
+ handler.OnJoinChannel += ch => { capturedChannel = ch; return Task.CompletedTask; };
+
+ await handler.HandleAsync("/join #random");
+ Assert.Equal("random", capturedChannel);
+ }
+
+ [Fact]
+ public async Task HandleAsync_Join_NoHash_PassedDirectly()
+ {
+ var handler = CreateHandler();
+ string? capturedChannel = null;
+ handler.OnJoinChannel += ch => { capturedChannel = ch; return Task.CompletedTask; };
+
+ await handler.HandleAsync("/join random");
+ Assert.Equal("random", capturedChannel);
+ }
+
+ [Fact]
+ public async Task HandleAsync_Join_NoArgs_ReturnsError()
+ {
+ var handler = CreateHandler();
+ var result = await handler.HandleAsync("/join");
+
+ Assert.True(result.IsError);
+ }
+
+ // ── /kick ─────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task HandleAsync_Kick_WithReason_ParsesUsernameAndReason()
+ {
+ var handler = CreateHandler();
+ string? capturedUser = null;
+ string? capturedReason = null;
+ handler.OnKickUser += (user, reason) =>
+ {
+ capturedUser = user;
+ capturedReason = reason;
+ return Task.CompletedTask;
+ };
+
+ await handler.HandleAsync("/kick baduser being rude");
+ Assert.Equal("baduser", capturedUser);
+ Assert.Equal("being rude", capturedReason);
+ }
+
+ [Fact]
+ public async Task HandleAsync_Kick_WithoutReason_NullReason()
+ {
+ var handler = CreateHandler();
+ string? capturedReason = "initial";
+ handler.OnKickUser += (user, reason) =>
+ {
+ capturedReason = reason;
+ return Task.CompletedTask;
+ };
+
+ await handler.HandleAsync("/kick baduser");
+ Assert.Null(capturedReason);
+ }
+
+ [Fact]
+ public async Task HandleAsync_Kick_NoArgs_ReturnsError()
+ {
+ var handler = CreateHandler();
+ var result = await handler.HandleAsync("/kick");
+ Assert.True(result.IsError);
+ }
+
+ // ── /mute ─────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task HandleAsync_Mute_WithDuration_ParsesDuration()
+ {
+ var handler = CreateHandler();
+ int? capturedDuration = null;
+ handler.OnMuteUser += (user, duration) =>
+ {
+ capturedDuration = duration;
+ return Task.CompletedTask;
+ };
+
+ await handler.HandleAsync("/mute alice 30");
+ Assert.Equal(30, capturedDuration);
+ }
+
+ [Fact]
+ public async Task HandleAsync_Mute_WithoutDuration_NullDuration()
+ {
+ var handler = CreateHandler();
+ int? capturedDuration = -1;
+ handler.OnMuteUser += (user, duration) =>
+ {
+ capturedDuration = duration;
+ return Task.CompletedTask;
+ };
+
+ await handler.HandleAsync("/mute alice");
+ Assert.Null(capturedDuration);
+ }
+
+ // ── /role ─────────────────────────────────────────────────────────
+
+ [Theory]
+ [InlineData("admin")]
+ [InlineData("mod")]
+ [InlineData("member")]
+ public async Task HandleAsync_Role_ValidRole_Succeeds(string role)
+ {
+ var handler = CreateHandler();
+ string? capturedRole = null;
+ handler.OnAssignRole += (user, r) => { capturedRole = r; return Task.CompletedTask; };
+
+ var result = await handler.HandleAsync($"/role alice {role}");
+
+ Assert.True(result.Handled);
+ Assert.False(result.IsError);
+ Assert.Equal(role, capturedRole);
+ }
+
+ [Fact]
+ public async Task HandleAsync_Role_InvalidRole_ReturnsError()
+ {
+ var handler = CreateHandler();
+ var result = await handler.HandleAsync("/role alice superadmin");
+
+ Assert.True(result.IsError);
+ Assert.Contains("Invalid role", result.Message);
+ }
+
+ [Fact]
+ public async Task HandleAsync_Role_MissingRole_ReturnsError()
+ {
+ var handler = CreateHandler();
+ var result = await handler.HandleAsync("/role alice");
+
+ Assert.True(result.IsError);
+ }
+
+ // ── /send ─────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task HandleAsync_Send_NoArgs_ReturnsUsageError()
+ {
+ var handler = CreateHandler();
+ var result = await handler.HandleAsync("/send");
+
+ Assert.True(result.IsError);
+ Assert.Contains("Usage", result.Message);
+ }
+
+ [Fact]
+ public async Task HandleAsync_Send_UrlInput_RecognizedAsUrl()
+ {
+ var handler = CreateHandler();
+ string? capturedTarget = null;
+ handler.OnSendFile += (target, size) => { capturedTarget = target; return Task.CompletedTask; };
+
+ var result = await handler.HandleAsync("/send https://example.com/image.png");
+
+ Assert.True(result.Handled);
+ Assert.False(result.IsError);
+ Assert.Equal("https://example.com/image.png", capturedTarget);
+ }
+
+ [Fact]
+ public async Task HandleAsync_Send_UrlWithSizeFlag_ExtractsSizeCorrectly()
+ {
+ var handler = CreateHandler();
+ string? capturedSize = null;
+ handler.OnSendFile += (target, size) => { capturedSize = size; return Task.CompletedTask; };
+
+ await handler.HandleAsync("/send https://example.com/photo.jpg -s");
+ Assert.Equal("s", capturedSize);
+ }
+
+ // ── /help ─────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task HandleAsync_Help_ReturnsHelpText()
+ {
+ var handler = CreateHandler();
+ var result = await handler.HandleAsync("/help");
+
+ Assert.True(result.Handled);
+ Assert.Contains("Available commands", result.Message);
+ }
+
+ [Fact]
+ public async Task HandleAsync_QuestionMark_ReturnsHelp()
+ {
+ var handler = CreateHandler();
+ var result = await handler.HandleAsync("/?");
+
+ Assert.True(result.Handled);
+ Assert.Contains("Available commands", result.Message);
+ }
+
+ // ── /ban ──────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task HandleAsync_Ban_WithReason_ParsesUsernameAndReason()
+ {
+ var handler = CreateHandler();
+ string? capturedUser = null;
+ string? capturedReason = null;
+ handler.OnBanUser += (user, reason) =>
+ {
+ capturedUser = user;
+ capturedReason = reason;
+ return Task.CompletedTask;
+ };
+
+ await handler.HandleAsync("/ban troll spamming links");
+ Assert.Equal("troll", capturedUser);
+ Assert.Equal("spamming links", capturedReason);
+ }
+
+ // ── /topic ────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task HandleAsync_Topic_SetsTopic()
+ {
+ var handler = CreateHandler();
+ string? capturedTopic = null;
+ handler.OnSetTopic += topic => { capturedTopic = topic; return Task.CompletedTask; };
+
+ var result = await handler.HandleAsync("/topic Welcome to our channel!");
+
+ Assert.True(result.Handled);
+ Assert.Equal("Welcome to our channel!", capturedTopic);
+ }
+
+ [Fact]
+ public async Task HandleAsync_Topic_NoArgs_ReturnsError()
+ {
+ var handler = CreateHandler();
+ var result = await handler.HandleAsync("/topic");
+ Assert.True(result.IsError);
+ }
+
+ // ── /quit and /exit ───────────────────────────────────────────────
+
+ [Fact]
+ public async Task HandleAsync_Quit_Handled()
+ {
+ var handler = CreateHandler();
+ var quitCalled = false;
+ handler.OnQuit += () => { quitCalled = true; return Task.CompletedTask; };
+
+ var result = await handler.HandleAsync("/quit");
+ Assert.True(result.Handled);
+ Assert.True(quitCalled);
+ }
+
+ [Fact]
+ public async Task HandleAsync_Exit_Handled()
+ {
+ var handler = CreateHandler();
+ var quitCalled = false;
+ handler.OnQuit += () => { quitCalled = true; return Task.CompletedTask; };
+
+ var result = await handler.HandleAsync("/exit");
+ Assert.True(result.Handled);
+ Assert.True(quitCalled);
+ }
+}
diff --git a/src/EchoHub.Tests/DataMigrationServiceTests.cs b/src/EchoHub.Tests/DataMigrationServiceTests.cs
new file mode 100644
index 0000000..31c2198
--- /dev/null
+++ b/src/EchoHub.Tests/DataMigrationServiceTests.cs
@@ -0,0 +1,74 @@
+using EchoHub.Server.Setup;
+using Xunit;
+
+namespace EchoHub.Tests;
+
+public class DataMigrationServiceTests
+{
+ // ── AnsiToColorTags ───────────────────────────────────────────────
+
+ [Fact]
+ public void AnsiToColorTags_ForegroundEscape_ConvertedToColorTag()
+ {
+ var ansi = "\x1b[38;2;255;0;0mred text";
+ var result = DataMigrationService.AnsiToColorTags(ansi);
+
+ Assert.Equal("{F:FF0000}red text", result);
+ }
+
+ [Fact]
+ public void AnsiToColorTags_BackgroundEscape_ConvertedToColorTag()
+ {
+ var ansi = "\x1b[48;2;0;255;0mgreen bg";
+ var result = DataMigrationService.AnsiToColorTags(ansi);
+
+ Assert.Equal("{B:00FF00}green bg", result);
+ }
+
+ [Fact]
+ public void AnsiToColorTags_ResetEscape_ConvertedToResetTag()
+ {
+ var ansi = "\x1b[0m";
+ var result = DataMigrationService.AnsiToColorTags(ansi);
+
+ Assert.Equal("{X}", result);
+ }
+
+ [Fact]
+ public void AnsiToColorTags_NoEscapes_ReturnsUnchanged()
+ {
+ var text = "Hello, world!";
+ var result = DataMigrationService.AnsiToColorTags(text);
+
+ Assert.Equal("Hello, world!", result);
+ }
+
+ [Fact]
+ public void AnsiToColorTags_MixedContent_ConvertsEscapesOnly()
+ {
+ var ansi = "before\x1b[38;2;100;200;50mtextafter";
+ var result = DataMigrationService.AnsiToColorTags(ansi);
+
+ Assert.Equal("before{F:64C832}textafter", result);
+ }
+
+ [Fact]
+ public void AnsiToColorTags_MultipleTags_AllConverted()
+ {
+ var ansi = "\x1b[38;2;255;0;0mred\x1b[48;2;0;0;255mblue bg\x1b[0mreset";
+ var result = DataMigrationService.AnsiToColorTags(ansi);
+
+ Assert.Equal("{F:FF0000}red{B:0000FF}blue bg{X}reset", result);
+ }
+
+ [Fact]
+ public void AnsiToColorTags_RoundTrip_WithColorTagsToAnsi()
+ {
+ // AnsiToColorTags and IrcMessageFormatter.ColorTagsToAnsi should be inverses
+ var original = "{F:FF0000}red{B:00FF00}green{X}";
+ var ansi = EchoHub.Server.Irc.IrcMessageFormatter.ColorTagsToAnsi(original);
+ var backToTags = DataMigrationService.AnsiToColorTags(ansi);
+
+ Assert.Equal(original, backToTags);
+ }
+}
diff --git a/src/EchoHub.Tests/EchoHub.Tests.csproj b/src/EchoHub.Tests/EchoHub.Tests.csproj
index 1a9b2a8..c3d45b4 100644
--- a/src/EchoHub.Tests/EchoHub.Tests.csproj
+++ b/src/EchoHub.Tests/EchoHub.Tests.csproj
@@ -21,6 +21,7 @@
+
diff --git a/src/EchoHub.Tests/FileValidationHelperTests.cs b/src/EchoHub.Tests/FileValidationHelperTests.cs
index 4a0c3eb..59bc283 100644
--- a/src/EchoHub.Tests/FileValidationHelperTests.cs
+++ b/src/EchoHub.Tests/FileValidationHelperTests.cs
@@ -53,4 +53,48 @@ public class FileValidationHelperTests
FileValidationHelper.IsValidImage(stream);
Assert.Equal(0, stream.Position);
}
+
+ // ── IsAudioFile tests ─────────────────────────────────────────────
+
+ [Theory]
+ [InlineData("song.mp3")]
+ [InlineData("track.wav")]
+ [InlineData("audio.ogg")]
+ [InlineData("music.flac")]
+ [InlineData("clip.aac")]
+ [InlineData("podcast.m4a")]
+ [InlineData("old.wma")]
+ public void IsAudioFile_SupportedExtensions_ReturnsTrue(string fileName)
+ {
+ Assert.True(FileValidationHelper.IsAudioFile(fileName));
+ }
+
+ [Theory]
+ [InlineData("song.MP3")]
+ [InlineData("track.Wav")]
+ [InlineData("audio.OGG")]
+ [InlineData("music.FLAC")]
+ public void IsAudioFile_CaseInsensitive_ReturnsTrue(string fileName)
+ {
+ Assert.True(FileValidationHelper.IsAudioFile(fileName));
+ }
+
+ [Theory]
+ [InlineData("document.txt")]
+ [InlineData("report.pdf")]
+ [InlineData("app.exe")]
+ [InlineData("photo.jpg")]
+ [InlineData("image.png")]
+ public void IsAudioFile_NonAudioExtension_ReturnsFalse(string fileName)
+ {
+ Assert.False(FileValidationHelper.IsAudioFile(fileName));
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData("noextension")]
+ public void IsAudioFile_EmptyOrNoExtension_ReturnsFalse(string fileName)
+ {
+ Assert.False(FileValidationHelper.IsAudioFile(fileName));
+ }
}
diff --git a/src/EchoHub.Tests/ImageToAsciiServiceTests.cs b/src/EchoHub.Tests/ImageToAsciiServiceTests.cs
new file mode 100644
index 0000000..9e9238c
--- /dev/null
+++ b/src/EchoHub.Tests/ImageToAsciiServiceTests.cs
@@ -0,0 +1,62 @@
+using EchoHub.Core.Constants;
+using EchoHub.Server.Services;
+using Xunit;
+
+namespace EchoHub.Tests;
+
+public class ImageToAsciiServiceTests
+{
+ [Fact]
+ public void GetDimensions_Small_Returns40x40()
+ {
+ var (w, h) = ImageToAsciiService.GetDimensions("s");
+ Assert.Equal(40, w);
+ Assert.Equal(40, h);
+ }
+
+ [Fact]
+ public void GetDimensions_Large_Returns120x120()
+ {
+ var (w, h) = ImageToAsciiService.GetDimensions("l");
+ Assert.Equal(120, w);
+ Assert.Equal(120, h);
+ }
+
+ [Fact]
+ public void GetDimensions_Default_Returns80x80()
+ {
+ var (w, h) = ImageToAsciiService.GetDimensions("m");
+ Assert.Equal(HubConstants.AsciiArtWidth, w);
+ Assert.Equal(HubConstants.AsciiArtHeightHalfBlock, h);
+ }
+
+ [Fact]
+ public void GetDimensions_Null_ReturnsDefault()
+ {
+ var (w, h) = ImageToAsciiService.GetDimensions(null);
+ Assert.Equal(HubConstants.AsciiArtWidth, w);
+ Assert.Equal(HubConstants.AsciiArtHeightHalfBlock, h);
+ }
+
+ [Fact]
+ public void GetDimensions_CaseInsensitive()
+ {
+ var (w1, h1) = ImageToAsciiService.GetDimensions("S");
+ var (w2, h2) = ImageToAsciiService.GetDimensions("s");
+ Assert.Equal(w1, w2);
+ Assert.Equal(h1, h2);
+
+ var (w3, h3) = ImageToAsciiService.GetDimensions("L");
+ var (w4, h4) = ImageToAsciiService.GetDimensions("l");
+ Assert.Equal(w3, w4);
+ Assert.Equal(h3, h4);
+ }
+
+ [Fact]
+ public void GetDimensions_UnknownSize_ReturnsDefault()
+ {
+ var (w, h) = ImageToAsciiService.GetDimensions("xl");
+ Assert.Equal(HubConstants.AsciiArtWidth, w);
+ Assert.Equal(HubConstants.AsciiArtHeightHalfBlock, h);
+ }
+}
diff --git a/src/EchoHub.Tests/Irc/IrcBroadcasterTests.cs b/src/EchoHub.Tests/Irc/IrcBroadcasterTests.cs
new file mode 100644
index 0000000..eca1170
--- /dev/null
+++ b/src/EchoHub.Tests/Irc/IrcBroadcasterTests.cs
@@ -0,0 +1,316 @@
+using System.Collections.Concurrent;
+using EchoHub.Core.DTOs;
+using EchoHub.Core.Models;
+using EchoHub.Server.Irc;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using Xunit;
+
+namespace EchoHub.Tests.Irc;
+
+public class IrcBroadcasterTests
+{
+ private readonly IrcOptions _options = new() { ServerName = "testserver", Enabled = true };
+ private readonly FakeEncryptionService _encryption = new();
+ private readonly IrcGatewayService _gateway;
+ private readonly IrcBroadcaster _broadcaster;
+
+ public IrcBroadcasterTests()
+ {
+ var services = new ServiceCollection()
+ .AddSingleton>(Options.Create(_options))
+ .BuildServiceProvider();
+
+ _gateway = new IrcGatewayService(
+ Options.Create(_options), services, NullLogger.Instance);
+
+ _broadcaster = new IrcBroadcaster(_gateway, _encryption);
+ }
+
+ ///
+ /// Injects a test connection into the gateway's internal connection map.
+ ///
+ private IrcClientConnection AddConnection(string nickname, params string[] channels)
+ {
+ var (conn, _) = TestIrcConnectionFactory.CreateAuthenticated(nickname);
+
+ foreach (var ch in channels)
+ conn.JoinChannel(ch);
+
+ // Insert into gateway's ConcurrentDictionary via the public IReadOnlyDictionary
+ var connections = (ConcurrentDictionary)_gateway.Connections;
+ connections[conn.ConnectionId] = conn;
+
+ return conn;
+ }
+
+ private static List CaptureOutput(IrcClientConnection conn)
+ {
+ // We need to get the stream from the connection — but it's private.
+ // Since we used TestIrcConnectionFactory, the TestDuplexStream was passed to the constructor.
+ // We can't easily access it. Instead, we create connections differently for these tests.
+ // Let's use a different approach.
+ throw new NotSupportedException("Use AddConnectionWithCapture instead");
+ }
+
+ ///
+ /// Creates a connection that can capture output and injects it into the gateway.
+ ///
+ private (IrcClientConnection Connection, TestDuplexStream Stream) AddConnectionWithCapture(
+ string nickname, params string[] channels)
+ {
+ var (conn, stream) = TestIrcConnectionFactory.CreateAuthenticated(nickname);
+
+ foreach (var ch in channels)
+ conn.JoinChannel(ch);
+
+ var connections = (ConcurrentDictionary)_gateway.Connections;
+ connections[conn.ConnectionId] = conn;
+
+ return (conn, stream);
+ }
+
+ // ── SendMessageToChannelAsync ────────────────────────────────────────
+
+ [Fact]
+ public async Task SendMessage_DecryptsContent()
+ {
+ var (_, stream) = AddConnectionWithCapture("bob", "general");
+
+ var encryptedContent = _encryption.Encrypt("Hello world!");
+ var message = new MessageDto(
+ Guid.NewGuid(), encryptedContent, "alice", null, "general",
+ MessageType.Text, null, null, DateTimeOffset.UtcNow);
+
+ await _broadcaster.SendMessageToChannelAsync("general", message);
+
+ var output = stream.GetOutputLines();
+ Assert.Contains(output, l => l.Contains("Hello world!"));
+ Assert.DoesNotContain(output, l => l.Contains("$ENC$"));
+ }
+
+ [Fact]
+ public async Task SendMessage_SkipsSender()
+ {
+ var (_, aliceStream) = AddConnectionWithCapture("alice", "general");
+ var (_, bobStream) = AddConnectionWithCapture("bob", "general");
+
+ var message = new MessageDto(
+ Guid.NewGuid(), _encryption.Encrypt("Hi"), "alice", null, "general",
+ MessageType.Text, null, null, DateTimeOffset.UtcNow);
+
+ await _broadcaster.SendMessageToChannelAsync("general", message);
+
+ // Alice (sender) should NOT receive the message
+ Assert.Empty(aliceStream.GetOutputLines());
+
+ // Bob should receive it
+ Assert.NotEmpty(bobStream.GetOutputLines());
+ }
+
+ [Fact]
+ public async Task SendMessage_OnlySendsToChannelMembers()
+ {
+ var (_, generalStream) = AddConnectionWithCapture("bob", "general");
+ var (_, randomStream) = AddConnectionWithCapture("charlie", "random");
+
+ var message = new MessageDto(
+ Guid.NewGuid(), _encryption.Encrypt("Hi"), "alice", null, "general",
+ MessageType.Text, null, null, DateTimeOffset.UtcNow);
+
+ await _broadcaster.SendMessageToChannelAsync("general", message);
+
+ Assert.NotEmpty(generalStream.GetOutputLines());
+ Assert.Empty(randomStream.GetOutputLines());
+ }
+
+ // ── SendUserJoinedAsync ──────────────────────────────────────────────
+
+ [Fact]
+ public async Task SendUserJoined_NotifiesOtherMembers()
+ {
+ var (_, bobStream) = AddConnectionWithCapture("bob", "general");
+
+ await _broadcaster.SendUserJoinedAsync("general", "alice");
+
+ var output = bobStream.GetOutputLines();
+ Assert.Contains(output, l => l.Contains("JOIN #general") && l.Contains("alice"));
+ }
+
+ [Fact]
+ public async Task SendUserJoined_ExcludesConnectionId()
+ {
+ var (conn, excludedStream) = AddConnectionWithCapture("alice", "general");
+ var (_, bobStream) = AddConnectionWithCapture("bob", "general");
+
+ await _broadcaster.SendUserJoinedAsync("general", "alice", conn.ConnectionId);
+
+ // Excluded connection should not get the message
+ Assert.Empty(excludedStream.GetOutputLines());
+ Assert.NotEmpty(bobStream.GetOutputLines());
+ }
+
+ // ── SendUserLeftAsync ────────────────────────────────────────────────
+
+ [Fact]
+ public async Task SendUserLeft_NotifiesOtherMembers()
+ {
+ var (_, bobStream) = AddConnectionWithCapture("bob", "general");
+ AddConnectionWithCapture("alice", "general");
+
+ await _broadcaster.SendUserLeftAsync("general", "alice");
+
+ var output = bobStream.GetOutputLines();
+ Assert.Contains(output, l => l.Contains("PART #general") && l.Contains("alice"));
+ }
+
+ [Fact]
+ public async Task SendUserLeft_SkipsSender()
+ {
+ var (_, aliceStream) = AddConnectionWithCapture("alice", "general");
+
+ await _broadcaster.SendUserLeftAsync("general", "alice");
+
+ Assert.Empty(aliceStream.GetOutputLines());
+ }
+
+ // ── SendChannelUpdatedAsync ──────────────────────────────────────────
+
+ [Fact]
+ public async Task SendChannelUpdated_WithTopic_SendsTopicMessage()
+ {
+ var (_, stream) = AddConnectionWithCapture("bob", "general");
+
+ var channel = new ChannelDto(
+ Guid.NewGuid(), "general", "New topic!", true, 0, DateTimeOffset.UtcNow);
+
+ await _broadcaster.SendChannelUpdatedAsync(channel, "general");
+
+ var output = stream.GetOutputLines();
+ Assert.Contains(output, l => l.Contains("TOPIC #general") && l.Contains("New topic!"));
+ }
+
+ [Fact]
+ public async Task SendChannelUpdated_NullTopic_DoesNotSend()
+ {
+ var (_, stream) = AddConnectionWithCapture("bob", "general");
+
+ var channel = new ChannelDto(
+ Guid.NewGuid(), "general", null, true, 0, DateTimeOffset.UtcNow);
+
+ await _broadcaster.SendChannelUpdatedAsync(channel, "general");
+
+ Assert.Empty(stream.GetOutputLines());
+ }
+
+ // ── SendErrorAsync ───────────────────────────────────────────────────
+
+ [Fact]
+ public async Task SendError_IrcConnection_SendsNotice()
+ {
+ var (conn, stream) = AddConnectionWithCapture("alice", "general");
+
+ await _broadcaster.SendErrorAsync(conn.ConnectionId, "Something went wrong");
+
+ var output = stream.GetOutputLines();
+ Assert.Contains(output, l => l.Contains("NOTICE") && l.Contains("Something went wrong"));
+ }
+
+ [Fact]
+ public async Task SendError_NonIrcConnection_DoesNothing()
+ {
+ // SignalR connection IDs don't start with "irc-"
+ await _broadcaster.SendErrorAsync("signalr-connection-123", "Error");
+ // No crash, no output — the method silently returns
+ }
+
+ // ── SendUserKickedAsync ──────────────────────────────────────────────
+
+ [Fact]
+ public async Task SendUserKicked_NotifiesChannel()
+ {
+ var (_, stream) = AddConnectionWithCapture("bob", "general");
+
+ await _broadcaster.SendUserKickedAsync("general", "alice", "Spam");
+
+ var output = stream.GetOutputLines();
+ Assert.Contains(output, l => l.Contains("KICK #general alice") && l.Contains("Spam"));
+ }
+
+ // ── SendUserBannedAsync ──────────────────────────────────────────────
+
+ [Fact]
+ public async Task SendUserBanned_NotifiesBannedUser()
+ {
+ var (_, stream) = AddConnectionWithCapture("alice", "general");
+
+ await _broadcaster.SendUserBannedAsync("alice", "Repeated violations");
+
+ var output = stream.GetOutputLines();
+ Assert.Contains(output, l => l.Contains("NOTICE") && l.Contains("banned"));
+ }
+
+ // ── SendMessageDeletedAsync ──────────────────────────────────────────
+
+ [Fact]
+ public async Task SendMessageDeleted_NotifiesChannel()
+ {
+ var (_, stream) = AddConnectionWithCapture("bob", "general");
+ var msgId = Guid.NewGuid();
+
+ await _broadcaster.SendMessageDeletedAsync("general", msgId);
+
+ var output = stream.GetOutputLines();
+ Assert.Contains(output, l => l.Contains("deleted") && l.Contains(msgId.ToString()));
+ }
+
+ // ── SendChannelNukedAsync ────────────────────────────────────────────
+
+ [Fact]
+ public async Task SendChannelNuked_NotifiesChannel()
+ {
+ var (_, stream) = AddConnectionWithCapture("bob", "general");
+
+ await _broadcaster.SendChannelNukedAsync("general");
+
+ var output = stream.GetOutputLines();
+ Assert.Contains(output, l => l.Contains("cleared"));
+ }
+
+ // ── ForceDisconnectUserAsync ─────────────────────────────────────────
+
+ [Fact]
+ public async Task ForceDisconnect_IrcConnection_SendsErrorAndCloses()
+ {
+ var (conn, stream) = AddConnectionWithCapture("alice", "general");
+
+ await _broadcaster.ForceDisconnectUserAsync([conn.ConnectionId], "Banned");
+
+ var output = stream.GetOutputLines();
+ Assert.Contains(output, l => l.Contains("ERROR") && l.Contains("Banned"));
+ }
+
+ [Fact]
+ public async Task ForceDisconnect_NonIrcConnection_Ignores()
+ {
+ // Should not throw when given SignalR connection IDs
+ await _broadcaster.ForceDisconnectUserAsync(["signalr-abc", "signalr-def"], "Banned");
+ }
+
+ // ── SendUserStatusChangedAsync ───────────────────────────────────────
+
+ [Fact]
+ public async Task SendUserStatusChanged_IsNoOp()
+ {
+ var (_, stream) = AddConnectionWithCapture("bob", "general");
+
+ var presence = new UserPresenceDto(
+ "alice", null, null, UserStatus.Away, "brb", ServerRole.Member);
+
+ await _broadcaster.SendUserStatusChangedAsync(["general"], presence);
+
+ // IRC doesn't push status changes — clients use WHOIS/WHO
+ Assert.Empty(stream.GetOutputLines());
+ }
+}
diff --git a/src/EchoHub.Tests/Irc/IrcClientConnectionTests.cs b/src/EchoHub.Tests/Irc/IrcClientConnectionTests.cs
new file mode 100644
index 0000000..e753ea9
--- /dev/null
+++ b/src/EchoHub.Tests/Irc/IrcClientConnectionTests.cs
@@ -0,0 +1,213 @@
+using EchoHub.Server.Irc;
+using Xunit;
+
+namespace EchoHub.Tests.Irc;
+
+public class IrcClientConnectionTests
+{
+ // ── Connection identity ──────────────────────────────────────────────
+
+ [Fact]
+ public void ConnectionId_StartsWithIrcPrefix()
+ {
+ var (conn, _) = TestIrcConnectionFactory.Create();
+ Assert.StartsWith("irc-", conn.ConnectionId);
+ }
+
+ [Fact]
+ public void ConnectionId_IsUnique()
+ {
+ var (conn1, _) = TestIrcConnectionFactory.Create();
+ var (conn2, _) = TestIrcConnectionFactory.Create();
+ Assert.NotEqual(conn1.ConnectionId, conn2.ConnectionId);
+ }
+
+ // ── Hostmask ─────────────────────────────────────────────────────────
+
+ [Fact]
+ public void Hostmask_WithNicknameAndUsername_FormatsCorrectly()
+ {
+ var (conn, _) = TestIrcConnectionFactory.Create();
+ conn.Nickname = "alice";
+ conn.Username = "alice_user";
+
+ Assert.Equal("alice!alice_user@echohub", conn.Hostmask);
+ }
+
+ [Fact]
+ public void Hostmask_WithoutUsername_FallsBackToNickname()
+ {
+ var (conn, _) = TestIrcConnectionFactory.Create();
+ conn.Nickname = "alice";
+
+ Assert.Equal("alice!alice@echohub", conn.Hostmask);
+ }
+
+ // ── I/O ──────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task ReadLineAsync_ReturnsInputLines()
+ {
+ var (conn, _) = TestIrcConnectionFactory.Create("PING", "PONG");
+
+ var line1 = await conn.ReadLineAsync(CancellationToken.None);
+ var line2 = await conn.ReadLineAsync(CancellationToken.None);
+
+ Assert.Equal("PING", line1);
+ Assert.Equal("PONG", line2);
+ }
+
+ [Fact]
+ public async Task ReadLineAsync_EndOfStream_ReturnsNull()
+ {
+ var (conn, _) = TestIrcConnectionFactory.Create("PING");
+
+ await conn.ReadLineAsync(CancellationToken.None); // consume "PING"
+ var result = await conn.ReadLineAsync(CancellationToken.None);
+
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public async Task SendAsync_WritesToOutput()
+ {
+ var (conn, stream) = TestIrcConnectionFactory.Create();
+
+ await conn.SendAsync(":server 001 alice :Welcome!");
+
+ var output = stream.GetOutputLines();
+ Assert.Single(output);
+ Assert.Equal(":server 001 alice :Welcome!", output[0]);
+ }
+
+ [Fact]
+ public async Task SendAsync_MultipleLines_AllCaptured()
+ {
+ var (conn, stream) = TestIrcConnectionFactory.Create();
+
+ await conn.SendAsync("line1");
+ await conn.SendAsync("line2");
+ await conn.SendAsync("line3");
+
+ var output = stream.GetOutputLines();
+ Assert.Equal(3, output.Count);
+ }
+
+ [Fact]
+ public async Task SendNumericAsync_FormatsCorrectly()
+ {
+ var (conn, stream) = TestIrcConnectionFactory.Create();
+ conn.Nickname = "alice";
+
+ await conn.SendNumericAsync("testserver", "001", ":Welcome!");
+
+ var output = stream.GetOutputLines();
+ Assert.Single(output);
+ Assert.Equal(":testserver 001 alice :Welcome!", output[0]);
+ }
+
+ [Fact]
+ public async Task SendNumericAsync_NoNickname_UsesStar()
+ {
+ var (conn, stream) = TestIrcConnectionFactory.Create();
+
+ await conn.SendNumericAsync("testserver", "451", ":Not registered");
+
+ var output = stream.GetOutputLines();
+ Assert.Contains("*", output[0]);
+ }
+
+ // ── Thread-safe channel operations ───────────────────────────────────
+
+ [Fact]
+ public void JoinChannel_AddsChannel()
+ {
+ var (conn, _) = TestIrcConnectionFactory.Create();
+ conn.JoinChannel("general");
+
+ Assert.True(conn.IsInChannel("general"));
+ }
+
+ [Fact]
+ public void LeaveChannel_RemovesChannel()
+ {
+ var (conn, _) = TestIrcConnectionFactory.Create();
+ conn.JoinChannel("general");
+ conn.LeaveChannel("general");
+
+ Assert.False(conn.IsInChannel("general"));
+ }
+
+ [Fact]
+ public void IsInChannel_CaseInsensitive()
+ {
+ var (conn, _) = TestIrcConnectionFactory.Create();
+ conn.JoinChannel("General");
+
+ Assert.True(conn.IsInChannel("general"));
+ Assert.True(conn.IsInChannel("GENERAL"));
+ }
+
+ [Fact]
+ public void GetJoinedChannels_ReturnsSnapshot()
+ {
+ var (conn, _) = TestIrcConnectionFactory.Create();
+ conn.JoinChannel("general");
+ conn.JoinChannel("random");
+
+ var channels = conn.GetJoinedChannels();
+ Assert.Equal(2, channels.Count);
+ Assert.Contains("general", channels);
+ Assert.Contains("random", channels);
+
+ // Modifying the returned list shouldn't affect the connection state
+ channels.Clear();
+ Assert.True(conn.IsInChannel("general"));
+ }
+
+ [Fact]
+ public async Task JoinedChannels_ConcurrentAccess_DoesNotThrow()
+ {
+ var (conn, _) = TestIrcConnectionFactory.Create();
+
+ // Simulate concurrent reads and writes (broadcaster reads while handler writes)
+ var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
+ var writerTask = Task.Run(async () =>
+ {
+ for (int i = 0; i < 1000 && !cts.IsCancellationRequested; i++)
+ {
+ conn.JoinChannel($"channel-{i}");
+ await Task.Yield();
+ if (i % 3 == 0) conn.LeaveChannel($"channel-{i}");
+ }
+ }, cts.Token);
+
+ var readerTask = Task.Run(async () =>
+ {
+ for (int i = 0; i < 1000 && !cts.IsCancellationRequested; i++)
+ {
+ _ = conn.IsInChannel($"channel-{i}");
+ _ = conn.GetJoinedChannels();
+ await Task.Yield();
+ }
+ }, cts.Token);
+
+ // Should complete without exceptions
+ await Task.WhenAll(writerTask, readerTask);
+ }
+
+ // ── Default state ────────────────────────────────────────────────────
+
+ [Fact]
+ public void NewConnection_IsNotRegistered()
+ {
+ var (conn, _) = TestIrcConnectionFactory.Create();
+
+ Assert.False(conn.IsRegistered);
+ Assert.False(conn.IsAuthenticated);
+ Assert.Null(conn.Nickname);
+ Assert.Null(conn.Username);
+ Assert.Null(conn.UserId);
+ Assert.Null(conn.AwayMessage);
+ }
+}
diff --git a/src/EchoHub.Tests/Irc/IrcCommandHandlerTests.cs b/src/EchoHub.Tests/Irc/IrcCommandHandlerTests.cs
new file mode 100644
index 0000000..9aed298
--- /dev/null
+++ b/src/EchoHub.Tests/Irc/IrcCommandHandlerTests.cs
@@ -0,0 +1,621 @@
+using System.Text;
+using EchoHub.Core.Contracts;
+using EchoHub.Core.DTOs;
+using EchoHub.Core.Models;
+using EchoHub.Server.Irc;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace EchoHub.Tests.Irc;
+
+public class IrcCommandHandlerTests
+{
+ private readonly IrcOptions _options = new() { ServerName = "testserver", Motd = null };
+ private readonly FakeChatService _chatService = new();
+ private readonly FakeChannelService _channelService = new();
+ private readonly FakeEncryptionService _encryption = new();
+
+ private IrcCommandHandler CreateHandler(IrcClientConnection conn) =>
+ new(conn, _options, _chatService, _channelService, _encryption, NullLogger.Instance);
+
+ private async Task> RunAndCapture(string[] inputLines,
+ Action? setup = null)
+ {
+ var (conn, stream) = TestIrcConnectionFactory.Create(inputLines);
+ setup?.Invoke(conn);
+
+ var handler = CreateHandler(conn);
+ await handler.RunAsync(CancellationToken.None);
+
+ return stream.GetOutputLines();
+ }
+
+ private async Task> RunAuthenticated(string[] inputLines,
+ string nickname = "alice", Guid? userId = null,
+ Action? setup = null)
+ {
+ var (conn, stream) = TestIrcConnectionFactory.CreateAuthenticated(nickname, userId, inputLines);
+ setup?.Invoke(conn);
+
+ var handler = CreateHandler(conn);
+ await handler.RunAsync(CancellationToken.None);
+
+ return stream.GetOutputLines();
+ }
+
+ // ── PING / PONG ──────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task Ping_RespondsWithPong()
+ {
+ var lines = await RunAuthenticated(["PING :mytoken"]);
+
+ Assert.Contains(lines, l => l.Contains("PONG") && l.Contains("mytoken"));
+ }
+
+ [Fact]
+ public async Task Ping_NoToken_UsesServerName()
+ {
+ var lines = await RunAuthenticated(["PING"]);
+
+ Assert.Contains(lines, l => l.Contains("PONG") && l.Contains("testserver"));
+ }
+
+ // ── Unregistered commands ────────────────────────────────────────────
+
+ [Fact]
+ public async Task UnregisteredUser_ChannelCommand_GetsNotRegisteredError()
+ {
+ var lines = await RunAndCapture(["JOIN #general"]);
+
+ Assert.Contains(lines, l => l.Contains("451") && l.Contains("not registered"));
+ }
+
+ [Fact]
+ public async Task UnregisteredUser_PrivmsgCommand_GetsNotRegisteredError()
+ {
+ var lines = await RunAndCapture(["PRIVMSG #general :hello"]);
+
+ Assert.Contains(lines, l => l.Contains("451"));
+ }
+
+ // ── PASS / NICK / USER registration ──────────────────────────────────
+
+ [Fact]
+ public async Task PassNickUser_ValidCredentials_Registers()
+ {
+ var userId = Guid.NewGuid();
+ _chatService.AuthResult = (userId, "alice");
+
+ var lines = await RunAndCapture([
+ "PASS secret123",
+ "NICK alice",
+ "USER alice 0 * :Alice Smith"
+ ]);
+
+ // Should get welcome burst (001)
+ Assert.Contains(lines, l => l.Contains("001") && l.Contains("Welcome"));
+ Assert.Contains("alice", _chatService.ConnectedUsers);
+ }
+
+ [Fact]
+ public async Task NickUser_NoPassword_GetsPasswordError()
+ {
+ var lines = await RunAndCapture([
+ "NICK alice",
+ "USER alice 0 * :Alice Smith"
+ ]);
+
+ Assert.Contains(lines, l => l.Contains("464") && l.Contains("Password required"));
+ }
+
+ [Fact]
+ public async Task PassNickUser_WrongPassword_GetsAuthError()
+ {
+ _chatService.AuthResult = null;
+
+ var lines = await RunAndCapture([
+ "PASS wrongpassword",
+ "NICK alice",
+ "USER alice 0 * :Alice Smith"
+ ]);
+
+ Assert.Contains(lines, l => l.Contains("464") && l.Contains("incorrect"));
+ }
+
+ [Fact]
+ public async Task Nick_InvalidNickname_GetsError()
+ {
+ var lines = await RunAndCapture(["NICK a"]); // too short
+
+ Assert.Contains(lines, l => l.Contains("432") && l.Contains("Erroneous nickname"));
+ }
+
+ [Fact]
+ public async Task Nick_NoParam_GetsNoNicknameError()
+ {
+ var lines = await RunAndCapture(["NICK"]);
+
+ Assert.Contains(lines, l => l.Contains("431") && l.Contains("No nickname given"));
+ }
+
+ [Fact]
+ public async Task User_AlreadyRegistered_GetsError()
+ {
+ var lines = await RunAuthenticated([
+ "USER alice 0 * :Alice"
+ ]);
+
+ Assert.Contains(lines, l => l.Contains("462") && l.Contains("reregister"));
+ }
+
+ [Fact]
+ public async Task Pass_AlreadyRegistered_GetsError()
+ {
+ var lines = await RunAuthenticated([
+ "PASS newpassword"
+ ]);
+
+ Assert.Contains(lines, l => l.Contains("462") && l.Contains("reregister"));
+ }
+
+ // ── CAP / SASL ──────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task CapLs_AdvertisesSasl()
+ {
+ var lines = await RunAndCapture(["CAP LS"]);
+
+ Assert.Contains(lines, l => l.Contains("CAP") && l.Contains("sasl"));
+ }
+
+ [Fact]
+ public async Task CapReqSasl_Acknowledged()
+ {
+ var lines = await RunAndCapture(["CAP REQ :sasl"]);
+
+ Assert.Contains(lines, l => l.Contains("ACK") && l.Contains("sasl"));
+ }
+
+ [Fact]
+ public async Task CapReqUnknown_GetsNak()
+ {
+ var lines = await RunAndCapture(["CAP REQ :multi-prefix"]);
+
+ Assert.Contains(lines, l => l.Contains("NAK"));
+ }
+
+ [Fact]
+ public async Task SaslPlain_ValidCredentials_Authenticates()
+ {
+ var userId = Guid.NewGuid();
+ _chatService.AuthResult = (userId, "alice");
+
+ var saslPayload = Convert.ToBase64String(Encoding.UTF8.GetBytes("\0alice\0password123"));
+
+ var lines = await RunAndCapture([
+ "CAP LS",
+ "CAP REQ :sasl",
+ $"AUTHENTICATE PLAIN",
+ $"AUTHENTICATE {saslPayload}",
+ "NICK alice",
+ "USER alice 0 * :Alice",
+ "CAP END"
+ ]);
+
+ Assert.Contains(lines, l => l.Contains("903") && l.Contains("SASL authentication successful"));
+ Assert.Contains(lines, l => l.Contains("001") && l.Contains("Welcome"));
+ }
+
+ [Fact]
+ public async Task SaslPlain_InvalidCredentials_GetsError()
+ {
+ _chatService.AuthResult = null;
+
+ var saslPayload = Convert.ToBase64String(Encoding.UTF8.GetBytes("\0alice\0wrongpwd"));
+
+ var lines = await RunAndCapture([
+ "CAP LS",
+ "CAP REQ :sasl",
+ "AUTHENTICATE PLAIN",
+ $"AUTHENTICATE {saslPayload}",
+ ]);
+
+ Assert.Contains(lines, l => l.Contains("904") && l.Contains("SASL authentication failed"));
+ }
+
+ [Fact]
+ public async Task SaslPlain_MalformedPayload_GetsError()
+ {
+ var saslPayload = Convert.ToBase64String(Encoding.UTF8.GetBytes("malformed"));
+
+ var lines = await RunAndCapture([
+ "CAP REQ :sasl",
+ "AUTHENTICATE PLAIN",
+ $"AUTHENTICATE {saslPayload}",
+ ]);
+
+ Assert.Contains(lines, l => l.Contains("904"));
+ }
+
+ // ── JOIN ─────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task Join_ValidChannel_ConfirmsJoin()
+ {
+ _channelService.TopicResult = ("Welcome!", true);
+
+ var lines = await RunAuthenticated(["JOIN #general"]);
+
+ Assert.Contains(lines, l => l.Contains("JOIN #general"));
+ Assert.Single(_chatService.JoinedChannels);
+ Assert.Equal("general", _chatService.JoinedChannels[0].Channel);
+ }
+
+ [Fact]
+ public async Task Join_SendsTopic()
+ {
+ _channelService.TopicResult = ("Welcome to general!", true);
+
+ var lines = await RunAuthenticated(["JOIN #general"]);
+
+ Assert.Contains(lines, l => l.Contains("332") && l.Contains("Welcome to general!"));
+ }
+
+ [Fact]
+ public async Task Join_NoTopic_SendsNoTopicReply()
+ {
+ _channelService.TopicResult = (null, true);
+
+ var lines = await RunAuthenticated(["JOIN #general"]);
+
+ Assert.Contains(lines, l => l.Contains("331") && l.Contains("No topic is set"));
+ }
+
+ [Fact]
+ public async Task Join_SendsNamesReply()
+ {
+ _chatService.OnlineUsersToReturn =
+ [
+ new("alice", null, null, UserStatus.Online, null, ServerRole.Member),
+ new("bob", null, null, UserStatus.Online, null, ServerRole.Member),
+ ];
+
+ var lines = await RunAuthenticated(["JOIN #general"]);
+
+ Assert.Contains(lines, l => l.Contains("353") && l.Contains("alice") && l.Contains("bob"));
+ Assert.Contains(lines, l => l.Contains("366") && l.Contains("End of /NAMES"));
+ }
+
+ [Fact]
+ public async Task Join_DecryptsHistoryForIrc()
+ {
+ // Simulate encrypted history (as ChatService returns it)
+ var encryptedContent = _encryption.Encrypt("Hello from history!");
+ _chatService.HistoryToReturn =
+ [
+ new(Guid.NewGuid(), encryptedContent, "bob", null, "general",
+ MessageType.Text, null, null, DateTimeOffset.UtcNow)
+ ];
+
+ var lines = await RunAuthenticated(["JOIN #general"]);
+
+ // Should contain the DECRYPTED text, not the encrypted version
+ Assert.Contains(lines, l => l.Contains("Hello from history!"));
+ Assert.DoesNotContain(lines, l => l.Contains("$ENC$Hello from history!"));
+ }
+
+ [Fact]
+ public async Task Join_NonexistentChannel_GetsError()
+ {
+ _chatService.JoinError = "Channel 'nope' does not exist.";
+
+ var lines = await RunAuthenticated(["JOIN #nope"]);
+
+ Assert.Contains(lines, l => l.Contains("403") && l.Contains("does not exist"));
+ }
+
+ [Fact]
+ public async Task Join_InvalidChannelName_GetsError()
+ {
+ var lines = await RunAuthenticated(["JOIN invalid"]);
+
+ Assert.Contains(lines, l => l.Contains("403") && l.Contains("Invalid channel name"));
+ }
+
+ [Fact]
+ public async Task Join_MultipleChannels_JoinsAll()
+ {
+ var lines = await RunAuthenticated(["JOIN #general,#random"]);
+
+ Assert.Equal(2, _chatService.JoinedChannels.Count);
+ Assert.Contains(_chatService.JoinedChannels, j => j.Channel == "general");
+ Assert.Contains(_chatService.JoinedChannels, j => j.Channel == "random");
+ }
+
+ [Fact]
+ public async Task Join_NoParams_GetsNeedMoreParamsError()
+ {
+ var lines = await RunAuthenticated(["JOIN"]);
+
+ Assert.Contains(lines, l => l.Contains("461") && l.Contains("Not enough parameters"));
+ }
+
+ // ── PART ─────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task Part_ValidChannel_ConfirmsPart()
+ {
+ var lines = await RunAuthenticated(["PART #general"]);
+
+ Assert.Contains(lines, l => l.Contains("PART #general"));
+ Assert.Single(_chatService.LeftChannels);
+ Assert.Equal("general", _chatService.LeftChannels[0].Channel);
+ }
+
+ [Fact]
+ public async Task Part_WithReason_IncludesReason()
+ {
+ var lines = await RunAuthenticated(["PART #general :Leaving for now"]);
+
+ Assert.Contains(lines, l => l.Contains("PART #general") && l.Contains("Leaving for now"));
+ }
+
+ // ── PRIVMSG ──────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task Privmsg_ChannelMessage_SendsViaService()
+ {
+ var lines = await RunAuthenticated(["PRIVMSG #general :Hello everyone!"]);
+
+ Assert.Single(_chatService.SentMessages);
+ Assert.Equal("general", _chatService.SentMessages[0].Channel);
+ Assert.Equal("Hello everyone!", _chatService.SentMessages[0].Content);
+ }
+
+ [Fact]
+ public async Task Privmsg_PrivateMessage_GetsError()
+ {
+ var lines = await RunAuthenticated(["PRIVMSG bob :Hey bob"]);
+
+ Assert.Contains(lines, l => l.Contains("401") && l.Contains("Private messages are not supported"));
+ Assert.Empty(_chatService.SentMessages);
+ }
+
+ [Fact]
+ public async Task Privmsg_ServiceError_ReturnsError()
+ {
+ _chatService.SendMessageError = "You are muted.";
+
+ var lines = await RunAuthenticated(["PRIVMSG #general :Hello"]);
+
+ Assert.Contains(lines, l => l.Contains("404") && l.Contains("muted"));
+ }
+
+ [Fact]
+ public async Task Privmsg_NoParams_GetsNeedMoreParamsError()
+ {
+ var lines = await RunAuthenticated(["PRIVMSG"]);
+
+ Assert.Contains(lines, l => l.Contains("461") && l.Contains("Not enough parameters"));
+ }
+
+ // ── QUIT ─────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task Quit_WithMessage_SendsClosingLink()
+ {
+ var lines = await RunAuthenticated(["QUIT :Goodbye!"]);
+
+ Assert.Contains(lines, l => l.Contains("ERROR") && l.Contains("Goodbye!"));
+ }
+
+ [Fact]
+ public async Task Quit_NoMessage_UsesDefault()
+ {
+ var lines = await RunAuthenticated(["QUIT"]);
+
+ Assert.Contains(lines, l => l.Contains("ERROR") && l.Contains("Client quit"));
+ }
+
+ // ── NAMES ────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task Names_ReturnsUserList()
+ {
+ _chatService.OnlineUsersToReturn =
+ [
+ new("alice", null, null, UserStatus.Online, null, ServerRole.Member),
+ new("bob", "Bob", null, UserStatus.Away, null, ServerRole.Mod),
+ ];
+
+ var lines = await RunAuthenticated(["NAMES #general"]);
+
+ Assert.Contains(lines, l => l.Contains("353") && l.Contains("alice") && l.Contains("bob"));
+ Assert.Contains(lines, l => l.Contains("366"));
+ }
+
+ // ── TOPIC ────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task Topic_Query_ReturnsTopic()
+ {
+ _channelService.TopicResult = ("Chat about everything", true);
+
+ var lines = await RunAuthenticated(["TOPIC #general"]);
+
+ Assert.Contains(lines, l => l.Contains("332") && l.Contains("Chat about everything"));
+ }
+
+ [Fact]
+ public async Task Topic_SetAttempt_GetsPermissionDenied()
+ {
+ var lines = await RunAuthenticated(["TOPIC #general :New topic"]);
+
+ Assert.Contains(lines, l => l.Contains("482") && l.Contains("channel creator"));
+ }
+
+ // ── WHO ──────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task Who_ReturnsUserListWithAwayFlags()
+ {
+ _chatService.OnlineUsersToReturn =
+ [
+ new("alice", "Alice", null, UserStatus.Online, null, ServerRole.Member),
+ new("bob", "Bob", null, UserStatus.Away, "brb", ServerRole.Member),
+ ];
+
+ var lines = await RunAuthenticated(["WHO #general"]);
+
+ Assert.Contains(lines, l => l.Contains("352") && l.Contains("alice") && l.Contains("H")); // Here
+ Assert.Contains(lines, l => l.Contains("352") && l.Contains("bob") && l.Contains("G")); // Gone
+ Assert.Contains(lines, l => l.Contains("315") && l.Contains("End of WHO"));
+ }
+
+ // ── WHOIS ────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task Whois_ExistingUser_ReturnsInfo()
+ {
+ _chatService.ProfileToReturn = new UserProfileDto(
+ Guid.NewGuid(), "bob", "Bob S.", "Hello!", null, null,
+ UserStatus.Online, null, ServerRole.Member,
+ DateTimeOffset.UtcNow.AddDays(-30), DateTimeOffset.UtcNow);
+ _chatService.ChannelsForUserToReturn = ["general", "random"];
+
+ var lines = await RunAuthenticated(["WHOIS bob"]);
+
+ Assert.Contains(lines, l => l.Contains("311") && l.Contains("bob") && l.Contains("Bob S."));
+ Assert.Contains(lines, l => l.Contains("312") && l.Contains("testserver"));
+ Assert.Contains(lines, l => l.Contains("319") && l.Contains("#general") && l.Contains("#random"));
+ Assert.Contains(lines, l => l.Contains("317")); // idle
+ Assert.Contains(lines, l => l.Contains("318") && l.Contains("End of WHOIS"));
+ }
+
+ [Fact]
+ public async Task Whois_NonexistentUser_GetsNoSuchNickError()
+ {
+ _chatService.ProfileToReturn = null;
+
+ var lines = await RunAuthenticated(["WHOIS ghost"]);
+
+ Assert.Contains(lines, l => l.Contains("401") && l.Contains("No such nick"));
+ }
+
+ [Fact]
+ public async Task Whois_AwayUser_ShowsAwayMessage()
+ {
+ _chatService.ProfileToReturn = new UserProfileDto(
+ Guid.NewGuid(), "bob", null, null, null, null,
+ UserStatus.Away, "Gone fishing", ServerRole.Member,
+ DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow);
+ _chatService.ChannelsForUserToReturn = [];
+
+ var lines = await RunAuthenticated(["WHOIS bob"]);
+
+ Assert.Contains(lines, l => l.Contains("301") && l.Contains("Gone fishing"));
+ }
+
+ // ── AWAY ─────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task Away_WithMessage_SetsAway()
+ {
+ var lines = await RunAuthenticated(["AWAY :Be right back"]);
+
+ Assert.Contains(lines, l => l.Contains("306") && l.Contains("marked as being away"));
+ Assert.Single(_chatService.StatusUpdates);
+ Assert.Equal(UserStatus.Away, _chatService.StatusUpdates[0].Status);
+ }
+
+ [Fact]
+ public async Task Away_NoMessage_ClearsAway()
+ {
+ var lines = await RunAuthenticated(["AWAY"]);
+
+ Assert.Contains(lines, l => l.Contains("305") && l.Contains("no longer marked"));
+ Assert.Single(_chatService.StatusUpdates);
+ Assert.Equal(UserStatus.Online, _chatService.StatusUpdates[0].Status);
+ }
+
+ // ── LIST ─────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task List_ReturnsChannels()
+ {
+ _channelService.ChannelListToReturn =
+ [
+ new("general", "General chat", 5),
+ new("random", null, 2),
+ ];
+
+ var lines = await RunAuthenticated(["LIST"]);
+
+ Assert.Contains(lines, l => l.Contains("322") && l.Contains("#general") && l.Contains("General chat"));
+ Assert.Contains(lines, l => l.Contains("322") && l.Contains("#random"));
+ Assert.Contains(lines, l => l.Contains("323") && l.Contains("End of LIST"));
+ }
+
+ // ── MODE ─────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task Mode_Channel_ReturnsChannelModes()
+ {
+ var lines = await RunAuthenticated(["MODE #general"]);
+
+ Assert.Contains(lines, l => l.Contains("324") && l.Contains("#general"));
+ }
+
+ [Fact]
+ public async Task Mode_User_ReturnsUserModes()
+ {
+ var lines = await RunAuthenticated(["MODE alice"]);
+
+ Assert.Contains(lines, l => l.Contains("221"));
+ }
+
+ // ── MOTD ─────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task Motd_NoMotdConfigured_GetsNoMotdError()
+ {
+ var lines = await RunAuthenticated(["MOTD"]);
+
+ Assert.Contains(lines, l => l.Contains("422") && l.Contains("MOTD File is missing"));
+ }
+
+ [Fact]
+ public async Task Motd_WithMotd_DisplaysMotd()
+ {
+ _options.Motd = "Welcome to EchoHub!\nEnjoy your stay.";
+
+ var lines = await RunAuthenticated(["MOTD"]);
+
+ Assert.Contains(lines, l => l.Contains("375")); // MOTDSTART
+ Assert.Contains(lines, l => l.Contains("372") && l.Contains("Welcome to EchoHub!"));
+ Assert.Contains(lines, l => l.Contains("372") && l.Contains("Enjoy your stay."));
+ Assert.Contains(lines, l => l.Contains("376")); // ENDOFMOTD
+ }
+
+ // ── Unknown command ──────────────────────────────────────────────────
+
+ [Fact]
+ public async Task UnknownCommand_GetsError()
+ {
+ var lines = await RunAuthenticated(["FOOBAR"]);
+
+ Assert.Contains(lines, l => l.Contains("421") && l.Contains("FOOBAR") && l.Contains("Unknown command"));
+ }
+
+ // ── Channel name conversion ──────────────────────────────────────────
+
+ [Fact]
+ public async Task Join_ChannelNameNormalized_ToLowerCase()
+ {
+ var lines = await RunAuthenticated(["JOIN #General"]);
+
+ Assert.Single(_chatService.JoinedChannels);
+ Assert.Equal("general", _chatService.JoinedChannels[0].Channel);
+ }
+}
diff --git a/src/EchoHub.Tests/Irc/IrcMessageFormatterTests.cs b/src/EchoHub.Tests/Irc/IrcMessageFormatterTests.cs
new file mode 100644
index 0000000..b845b50
--- /dev/null
+++ b/src/EchoHub.Tests/Irc/IrcMessageFormatterTests.cs
@@ -0,0 +1,294 @@
+using EchoHub.Core.DTOs;
+using EchoHub.Core.Models;
+using EchoHub.Server.Irc;
+using Xunit;
+
+namespace EchoHub.Tests.Irc;
+
+public class IrcMessageFormatterTests
+{
+ private static MessageDto CreateTextMessage(string content, string sender = "alice",
+ string channel = "general", List? embeds = null)
+ {
+ return new MessageDto(
+ Guid.NewGuid(), content, sender, null, channel,
+ MessageType.Text, null, null, DateTimeOffset.UtcNow, Embeds: embeds);
+ }
+
+ private static MessageDto CreateImageMessage(string asciiArt, string fileName = "image.png",
+ string url = "https://example.com/image.png", string sender = "alice", string channel = "general")
+ {
+ return new MessageDto(
+ Guid.NewGuid(), asciiArt, sender, null, channel,
+ MessageType.Image, url, fileName, DateTimeOffset.UtcNow);
+ }
+
+ private static MessageDto CreateFileMessage(string fileName = "doc.pdf",
+ string url = "https://example.com/doc.pdf", string sender = "alice", string channel = "general")
+ {
+ return new MessageDto(
+ Guid.NewGuid(), "", sender, null, channel,
+ MessageType.File, url, fileName, DateTimeOffset.UtcNow);
+ }
+
+ private static MessageDto CreateAudioMessage(string fileName = "song.mp3",
+ string url = "https://example.com/song.mp3", string sender = "alice", string channel = "general")
+ {
+ return new MessageDto(
+ Guid.NewGuid(), "", sender, null, channel,
+ MessageType.Audio, url, fileName, DateTimeOffset.UtcNow);
+ }
+
+ // ── FormatMessage ────────────────────────────────────────────────────
+
+ [Fact]
+ public void FormatMessage_TextMessage_FormatsAsPrivmsg()
+ {
+ var msg = CreateTextMessage("Hello world");
+ var lines = IrcMessageFormatter.FormatMessage(msg);
+
+ Assert.Single(lines);
+ Assert.Equal(":alice!alice@echohub PRIVMSG #general :Hello world", lines[0]);
+ }
+
+ [Fact]
+ public void FormatMessage_TextMessage_IncludesChannelHash()
+ {
+ var msg = CreateTextMessage("test", channel: "random");
+ var lines = IrcMessageFormatter.FormatMessage(msg);
+
+ Assert.Contains("#random", lines[0]);
+ }
+
+ [Fact]
+ public void FormatMessage_TextWithEmbeds_AppendsEmbedLines()
+ {
+ var embeds = new List
+ {
+ new("Example Site", "Page Title", "A description of the page", null, "https://example.com")
+ };
+ var msg = CreateTextMessage("Check this: https://example.com", embeds: embeds);
+ var lines = IrcMessageFormatter.FormatMessage(msg);
+
+ Assert.True(lines.Count >= 2);
+ Assert.Contains("Check this: https://example.com", lines[0]);
+ // Embed header
+ Assert.Contains("Example Site", lines[1]);
+ Assert.Contains("Page Title", lines[1]);
+ }
+
+ [Fact]
+ public void FormatMessage_EmbedWithDescription_IncludesDescription()
+ {
+ var embeds = new List
+ {
+ new("Site", "Title", "This is a description", null, "https://example.com")
+ };
+ var msg = CreateTextMessage("url", embeds: embeds);
+ var lines = IrcMessageFormatter.FormatMessage(msg);
+
+ Assert.True(lines.Count >= 3);
+ Assert.Contains("This is a description", lines[2]);
+ }
+
+ [Fact]
+ public void FormatMessage_EmbedWithLongDescription_Truncates()
+ {
+ var longDesc = new string('x', 300);
+ var embeds = new List
+ {
+ new("Site", "Title", longDesc, null, "https://example.com")
+ };
+ var msg = CreateTextMessage("url", embeds: embeds);
+ var lines = IrcMessageFormatter.FormatMessage(msg);
+
+ var descLine = lines.First(l => l.Contains("xxx"));
+ Assert.Contains("...", descLine);
+ // Should be truncated to ~200 chars
+ var descContent = descLine[(descLine.LastIndexOf(':') + 2)..]; // after ":│ "
+ Assert.True(descContent.Length <= 210);
+ }
+
+ [Fact]
+ public void FormatMessage_ImageMessage_IncludesFileNameAndUrl()
+ {
+ var msg = CreateImageMessage("##\n##", "photo.jpg", "https://example.com/photo.jpg");
+ var lines = IrcMessageFormatter.FormatMessage(msg);
+
+ Assert.Contains(lines, l => l.Contains("[Image: photo.jpg]"));
+ Assert.Contains(lines, l => l.Contains("Download: https://example.com/photo.jpg"));
+ }
+
+ [Fact]
+ public void FormatMessage_ImageMessage_IncludesAsciiArt()
+ {
+ var msg = CreateImageMessage("line1\nline2");
+ var lines = IrcMessageFormatter.FormatMessage(msg);
+
+ Assert.Contains(lines, l => l.Contains("line1"));
+ Assert.Contains(lines, l => l.Contains("line2"));
+ }
+
+ [Fact]
+ public void FormatMessage_ImageMessage_SkipsEmptyAsciiLines()
+ {
+ var msg = CreateImageMessage("line1\n\nline2");
+ var lines = IrcMessageFormatter.FormatMessage(msg);
+
+ // Empty lines should be skipped
+ var asciiLines = lines.Where(l => !l.Contains("[Image:") && !l.Contains("Download:")).ToList();
+ Assert.Equal(2, asciiLines.Count);
+ }
+
+ [Fact]
+ public void FormatMessage_FileMessage_FormatsCorrectly()
+ {
+ var msg = CreateFileMessage("report.pdf", "https://example.com/report.pdf");
+ var lines = IrcMessageFormatter.FormatMessage(msg);
+
+ Assert.Single(lines);
+ Assert.Contains("[File: report.pdf]", lines[0]);
+ Assert.Contains("https://example.com/report.pdf", lines[0]);
+ }
+
+ [Fact]
+ public void FormatMessage_AudioMessage_FormatsWithMusicNote()
+ {
+ var msg = CreateAudioMessage("track.mp3", "https://example.com/track.mp3");
+ var lines = IrcMessageFormatter.FormatMessage(msg);
+
+ Assert.Single(lines);
+ Assert.Contains("\u266a", lines[0]); // ♪
+ Assert.Contains("[Audio: track.mp3]", lines[0]);
+ Assert.Contains("https://example.com/track.mp3", lines[0]);
+ }
+
+ // ── SplitMessage ─────────────────────────────────────────────────────
+
+ [Fact]
+ public void SplitMessage_ShortMessage_ReturnsSingleChunk()
+ {
+ var chunks = IrcMessageFormatter.SplitMessage("Hello", 400);
+ Assert.Single(chunks);
+ Assert.Equal("Hello", chunks[0]);
+ }
+
+ [Fact]
+ public void SplitMessage_ExactlyAtLimit_ReturnsSingleChunk()
+ {
+ var msg = new string('a', 400);
+ var chunks = IrcMessageFormatter.SplitMessage(msg, 400);
+ Assert.Single(chunks);
+ }
+
+ [Fact]
+ public void SplitMessage_LongMessage_SplitsAtWordBoundary()
+ {
+ // Create a message that's longer than 50 bytes
+ var words = string.Join(" ", Enumerable.Repeat("hello", 20)); // 20 * 6 - 1 = 119 bytes
+ var chunks = IrcMessageFormatter.SplitMessage(words, 50);
+
+ Assert.True(chunks.Count > 1);
+ // Each chunk should be roughly <= 50 bytes
+ foreach (var chunk in chunks)
+ {
+ Assert.True(System.Text.Encoding.UTF8.GetByteCount(chunk) <= 55,
+ $"Chunk too long: {chunk.Length} chars");
+ }
+ // Reassembled content should match original
+ var reassembled = string.Join(" ", chunks);
+ Assert.Equal(words, reassembled);
+ }
+
+ [Fact]
+ public void SplitMessage_SingleLongWord_ForcedIntoOneChunk()
+ {
+ var longWord = new string('a', 500);
+ var chunks = IrcMessageFormatter.SplitMessage(longWord, 400);
+ // A single word can't be split at word boundaries, so it stays as one chunk
+ Assert.Single(chunks);
+ Assert.Equal(longWord, chunks[0]);
+ }
+
+ [Fact]
+ public void SplitMessage_EmptyString_ReturnsSingleEmpty()
+ {
+ var chunks = IrcMessageFormatter.SplitMessage("", 400);
+ Assert.Single(chunks);
+ Assert.Equal("", chunks[0]);
+ }
+
+ [Fact]
+ public void SplitMessage_UnicodeContent_CountsUtf8Bytes()
+ {
+ // Japanese text: each char is 3 bytes in UTF-8
+ var text = string.Join(" ", Enumerable.Repeat("\u3042\u3044\u3046", 50));
+ var chunks = IrcMessageFormatter.SplitMessage(text, 100);
+
+ Assert.True(chunks.Count > 1);
+ foreach (var chunk in chunks)
+ {
+ Assert.True(System.Text.Encoding.UTF8.GetByteCount(chunk) <= 110,
+ $"Chunk too long in bytes: {System.Text.Encoding.UTF8.GetByteCount(chunk)}");
+ }
+ }
+
+ // ── ColorTagsToAnsi ──────────────────────────────────────────────────
+
+ [Fact]
+ public void ColorTagsToAnsi_NoTags_ReturnsUnchanged()
+ {
+ Assert.Equal("Hello world", IrcMessageFormatter.ColorTagsToAnsi("Hello world"));
+ }
+
+ [Fact]
+ public void ColorTagsToAnsi_ForegroundTag_ConvertsToAnsi()
+ {
+ var result = IrcMessageFormatter.ColorTagsToAnsi("{F:FF0000}Red text");
+ Assert.Equal("\x1b[38;2;255;0;0mRed text", result);
+ }
+
+ [Fact]
+ public void ColorTagsToAnsi_BackgroundTag_ConvertsToAnsi()
+ {
+ var result = IrcMessageFormatter.ColorTagsToAnsi("{B:00FF00}Green bg");
+ Assert.Equal("\x1b[48;2;0;255;0mGreen bg", result);
+ }
+
+ [Fact]
+ public void ColorTagsToAnsi_ResetTag_ConvertsToReset()
+ {
+ var result = IrcMessageFormatter.ColorTagsToAnsi("{F:FF0000}Red{X} Normal");
+ Assert.Equal("\x1b[38;2;255;0;0mRed\x1b[0m Normal", result);
+ }
+
+ [Fact]
+ public void ColorTagsToAnsi_MultipleTags_ConvertsAll()
+ {
+ var result = IrcMessageFormatter.ColorTagsToAnsi("{F:FF0000}Red {F:0000FF}Blue{X}");
+ Assert.Contains("\x1b[38;2;255;0;0m", result);
+ Assert.Contains("\x1b[38;2;0;0;255m", result);
+ Assert.Contains("\x1b[0m", result);
+ }
+
+ [Fact]
+ public void ColorTagsToAnsi_LowercaseHex_ConvertsCorrectly()
+ {
+ var result = IrcMessageFormatter.ColorTagsToAnsi("{F:ff8800}text");
+ Assert.Equal("\x1b[38;2;255;136;0mtext", result);
+ }
+
+ [Fact]
+ public void ColorTagsToAnsi_NoBraces_SkipsProcessing()
+ {
+ var text = "plain text without braces";
+ Assert.Equal(text, IrcMessageFormatter.ColorTagsToAnsi(text));
+ }
+
+ [Fact]
+ public void ColorTagsToAnsi_ExistingAnsiCodes_PreservesUnchanged()
+ {
+ var text = "\x1b[31mAlready colored\x1b[0m";
+ Assert.Equal(text, IrcMessageFormatter.ColorTagsToAnsi(text));
+ }
+}
diff --git a/src/EchoHub.Tests/Irc/IrcMessageTests.cs b/src/EchoHub.Tests/Irc/IrcMessageTests.cs
new file mode 100644
index 0000000..566af96
--- /dev/null
+++ b/src/EchoHub.Tests/Irc/IrcMessageTests.cs
@@ -0,0 +1,183 @@
+using EchoHub.Server.Irc;
+using Xunit;
+
+namespace EchoHub.Tests.Irc;
+
+public class IrcMessageTests
+{
+ [Fact]
+ public void Parse_SimpleCommand_ExtractsCommand()
+ {
+ var msg = IrcMessage.Parse("PING");
+ Assert.Equal("PING", msg.Command);
+ Assert.Null(msg.Prefix);
+ Assert.Empty(msg.Parameters);
+ }
+
+ [Fact]
+ public void Parse_CommandWithOneParam_ExtractsParam()
+ {
+ var msg = IrcMessage.Parse("NICK alice");
+ Assert.Equal("NICK", msg.Command);
+ Assert.Single(msg.Parameters);
+ Assert.Equal("alice", msg.Parameters[0]);
+ }
+
+ [Fact]
+ public void Parse_CommandWithTrailing_ExtractsTrailingAsLastParam()
+ {
+ var msg = IrcMessage.Parse("PRIVMSG #general :Hello world!");
+ Assert.Equal("PRIVMSG", msg.Command);
+ Assert.Equal(2, msg.Parameters.Count);
+ Assert.Equal("#general", msg.Parameters[0]);
+ Assert.Equal("Hello world!", msg.Parameters[1]);
+ Assert.Equal("Hello world!", msg.Trailing);
+ }
+
+ [Fact]
+ public void Parse_MessageWithPrefix_ExtractsPrefix()
+ {
+ var msg = IrcMessage.Parse(":alice!alice@echohub PRIVMSG #general :hi");
+ Assert.Equal("alice!alice@echohub", msg.Prefix);
+ Assert.Equal("PRIVMSG", msg.Command);
+ Assert.Equal(2, msg.Parameters.Count);
+ Assert.Equal("#general", msg.Parameters[0]);
+ Assert.Equal("hi", msg.Parameters[1]);
+ }
+
+ [Fact]
+ public void Parse_MultipleParams_ExtractsAll()
+ {
+ var msg = IrcMessage.Parse("USER alice 0 * :Alice Smith");
+ Assert.Equal("USER", msg.Command);
+ Assert.Equal(4, msg.Parameters.Count);
+ Assert.Equal("alice", msg.Parameters[0]);
+ Assert.Equal("0", msg.Parameters[1]);
+ Assert.Equal("*", msg.Parameters[2]);
+ Assert.Equal("Alice Smith", msg.Parameters[3]);
+ }
+
+ [Fact]
+ public void Parse_PingWithToken_ExtractsToken()
+ {
+ var msg = IrcMessage.Parse("PING :server.example.com");
+ Assert.Equal("PING", msg.Command);
+ Assert.Single(msg.Parameters);
+ Assert.Equal("server.example.com", msg.Parameters[0]);
+ }
+
+ [Fact]
+ public void Parse_CapLs_ParsesSubcommand()
+ {
+ var msg = IrcMessage.Parse("CAP LS 302");
+ Assert.Equal("CAP", msg.Command);
+ Assert.Equal(2, msg.Parameters.Count);
+ Assert.Equal("LS", msg.Parameters[0]);
+ Assert.Equal("302", msg.Parameters[1]);
+ }
+
+ [Fact]
+ public void Parse_CapReqWithTrailing_ParsesSasl()
+ {
+ var msg = IrcMessage.Parse("CAP REQ :sasl");
+ Assert.Equal("CAP", msg.Command);
+ Assert.Equal(2, msg.Parameters.Count);
+ Assert.Equal("REQ", msg.Parameters[0]);
+ Assert.Equal("sasl", msg.Parameters[1]);
+ }
+
+ [Fact]
+ public void Parse_JoinMultipleChannels_ExtractsCsv()
+ {
+ var msg = IrcMessage.Parse("JOIN #general,#random");
+ Assert.Equal("JOIN", msg.Command);
+ Assert.Single(msg.Parameters);
+ Assert.Equal("#general,#random", msg.Parameters[0]);
+ }
+
+ [Fact]
+ public void Parse_PartWithReason_ExtractsReason()
+ {
+ var msg = IrcMessage.Parse("PART #general :Leaving for now");
+ Assert.Equal("PART", msg.Command);
+ Assert.Equal(2, msg.Parameters.Count);
+ Assert.Equal("#general", msg.Parameters[0]);
+ Assert.Equal("Leaving for now", msg.Parameters[1]);
+ }
+
+ [Fact]
+ public void Parse_EmptyTrailing_ExtractsEmptyString()
+ {
+ var msg = IrcMessage.Parse("PRIVMSG #general :");
+ Assert.Equal("PRIVMSG", msg.Command);
+ Assert.Equal(2, msg.Parameters.Count);
+ Assert.Equal("#general", msg.Parameters[0]);
+ Assert.Equal("", msg.Parameters[1]);
+ }
+
+ [Fact]
+ public void Parse_TrailingWithColons_PreservesColons()
+ {
+ var msg = IrcMessage.Parse("PRIVMSG #general :time is 12:30:00");
+ Assert.Equal("time is 12:30:00", msg.Parameters[1]);
+ }
+
+ [Fact]
+ public void Parse_CrLfTrimmed()
+ {
+ var msg = IrcMessage.Parse("PING\r\n");
+ Assert.Equal("PING", msg.Command);
+ Assert.Empty(msg.Parameters);
+ }
+
+ [Fact]
+ public void Parse_ExtraSpaces_Handled()
+ {
+ var msg = IrcMessage.Parse("NICK alice");
+ Assert.Equal("NICK", msg.Command);
+ Assert.Single(msg.Parameters);
+ Assert.Equal("alice", msg.Parameters[0]);
+ }
+
+ [Fact]
+ public void Parse_Authenticate_Base64Payload()
+ {
+ var payload = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("\0alice\0secret"));
+ var msg = IrcMessage.Parse($"AUTHENTICATE {payload}");
+ Assert.Equal("AUTHENTICATE", msg.Command);
+ Assert.Single(msg.Parameters);
+ Assert.Equal(payload, msg.Parameters[0]);
+ }
+
+ [Fact]
+ public void Parse_PassCommand_ExtractsPassword()
+ {
+ var msg = IrcMessage.Parse("PASS mysecretpassword");
+ Assert.Equal("PASS", msg.Command);
+ Assert.Single(msg.Parameters);
+ Assert.Equal("mysecretpassword", msg.Parameters[0]);
+ }
+
+ [Fact]
+ public void Parse_QuitWithMessage_ExtractsMessage()
+ {
+ var msg = IrcMessage.Parse("QUIT :Goodbye!");
+ Assert.Equal("QUIT", msg.Command);
+ Assert.Single(msg.Parameters);
+ Assert.Equal("Goodbye!", msg.Parameters[0]);
+ }
+
+ [Fact]
+ public void Trailing_NoParams_ReturnsNull()
+ {
+ var msg = IrcMessage.Parse("PING");
+ Assert.Null(msg.Trailing);
+ }
+
+ [Fact]
+ public void Trailing_WithParams_ReturnsLastParam()
+ {
+ var msg = IrcMessage.Parse("MODE #channel +o alice");
+ Assert.Equal("alice", msg.Trailing);
+ }
+}
diff --git a/src/EchoHub.Tests/Irc/TestHelpers.cs b/src/EchoHub.Tests/Irc/TestHelpers.cs
new file mode 100644
index 0000000..86001d2
--- /dev/null
+++ b/src/EchoHub.Tests/Irc/TestHelpers.cs
@@ -0,0 +1,260 @@
+using System.Net.Sockets;
+using System.Text;
+using EchoHub.Core.Contracts;
+using EchoHub.Core.DTOs;
+using EchoHub.Core.Models;
+using EchoHub.Server.Irc;
+
+namespace EchoHub.Tests.Irc;
+
+///
+/// A duplex stream that reads from one buffer and writes to another,
+/// allowing test code to inject input and capture output.
+///
+internal sealed class TestDuplexStream : Stream
+{
+ private readonly MemoryStream _readBuffer;
+ private readonly MemoryStream _writeBuffer = new();
+
+ public TestDuplexStream(string input = "")
+ {
+ _readBuffer = new MemoryStream(Encoding.UTF8.GetBytes(input));
+ }
+
+ public string GetOutput()
+ {
+ var raw = Encoding.UTF8.GetString(_writeBuffer.ToArray());
+ // Strip UTF-8 BOM emitted by StreamWriter
+ return raw.TrimStart('\uFEFF');
+ }
+
+ public List GetOutputLines() =>
+ GetOutput().Split("\r\n", StringSplitOptions.RemoveEmptyEntries).ToList();
+
+ // Read from the input buffer
+ public override int Read(byte[] buffer, int offset, int count) =>
+ _readBuffer.Read(buffer, offset, count);
+
+ public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct) =>
+ _readBuffer.ReadAsync(buffer, offset, count, ct);
+
+ public override ValueTask ReadAsync(Memory buffer, CancellationToken ct = default) =>
+ _readBuffer.ReadAsync(buffer, ct);
+
+ // Write to the output buffer
+ public override void Write(byte[] buffer, int offset, int count) =>
+ _writeBuffer.Write(buffer, offset, count);
+
+ public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken ct) =>
+ _writeBuffer.WriteAsync(buffer, offset, count, ct);
+
+ public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken ct = default) =>
+ _writeBuffer.WriteAsync(buffer, ct);
+
+ public override void Flush() => _writeBuffer.Flush();
+ public override Task FlushAsync(CancellationToken ct) => _writeBuffer.FlushAsync(ct);
+
+ public override bool CanRead => true;
+ public override bool CanWrite => true;
+ public override bool CanSeek => false;
+ public override long Length => throw new NotSupportedException();
+ public override long Position
+ {
+ get => throw new NotSupportedException();
+ set => throw new NotSupportedException();
+ }
+ public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
+ public override void SetLength(long value) => throw new NotSupportedException();
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ _readBuffer.Dispose();
+ _writeBuffer.Dispose();
+ }
+ base.Dispose(disposing);
+ }
+}
+
+///
+/// Creates IrcClientConnections backed by test streams for unit testing.
+///
+internal static class TestIrcConnectionFactory
+{
+ ///
+ /// Creates a test IRC connection with the given input lines.
+ /// Returns the connection and the test stream (for inspecting output).
+ ///
+ public static (IrcClientConnection Connection, TestDuplexStream Stream) Create(params string[] inputLines)
+ {
+ var input = string.Join("\r\n", inputLines);
+ if (inputLines.Length > 0) input += "\r\n";
+
+ var stream = new TestDuplexStream(input);
+ var tcpClient = new TcpClient();
+ var conn = new IrcClientConnection(tcpClient, stream);
+ return (conn, stream);
+ }
+
+ ///
+ /// Creates a pre-authenticated, registered IRC connection.
+ ///
+ public static (IrcClientConnection Connection, TestDuplexStream Stream) CreateAuthenticated(
+ string nickname = "alice", Guid? userId = null, params string[] inputLines)
+ {
+ var (conn, stream) = Create(inputLines);
+ conn.Nickname = nickname;
+ conn.Username = nickname;
+ conn.UserId = userId ?? Guid.NewGuid();
+ conn.IsRegistered = true;
+ conn.IsAuthenticated = true;
+ return (conn, stream);
+ }
+}
+
+///
+/// Fake encryption service that uses a simple reversible prefix-based scheme.
+/// Encrypt("hello") → "$ENC$hello", Decrypt("$ENC$hello") → "hello".
+///
+internal sealed class FakeEncryptionService : IMessageEncryptionService
+{
+ private const string Prefix = "$ENC$";
+
+ public bool EncryptDatabaseEnabled => true;
+
+ public string Encrypt(string plaintext) => $"{Prefix}{plaintext}";
+
+ public string Decrypt(string content)
+ {
+ if (content.StartsWith(Prefix))
+ return content[Prefix.Length..];
+ return content;
+ }
+
+ public string? EncryptNullable(string? value) =>
+ value is not null ? Encrypt(value) : null;
+
+ public string? DecryptNullable(string? value) =>
+ value is not null ? Decrypt(value) : null;
+}
+
+///
+/// Fake chat service that records method calls and returns pre-configured results.
+///
+internal sealed class FakeChatService : IChatService
+{
+ // Recorded calls
+ public List ConnectedUsers { get; } = [];
+ public List DisconnectedConnections { get; } = [];
+ public List<(string Channel, string Username)> JoinedChannels { get; } = [];
+ public List<(string Channel, string Username)> LeftChannels { get; } = [];
+ public List<(string Channel, string Content)> SentMessages { get; } = [];
+ public List<(string Username, UserStatus Status)> StatusUpdates { get; } = [];
+
+ // Configurable results
+ public List HistoryToReturn { get; set; } = [];
+ public string? JoinError { get; set; }
+ public string? SendMessageError { get; set; }
+ public (Guid UserId, string Username)? AuthResult { get; set; }
+ public UserProfileDto? ProfileToReturn { get; set; }
+ public List ChannelsForUserToReturn { get; set; } = [];
+ public List OnlineUsersToReturn { get; set; } = [];
+
+ public Task UserConnectedAsync(string connectionId, Guid userId, string username)
+ {
+ ConnectedUsers.Add(username);
+ return Task.CompletedTask;
+ }
+
+ public Task UserDisconnectedAsync(string connectionId)
+ {
+ DisconnectedConnections.Add(connectionId);
+ return Task.FromResult(null);
+ }
+
+ public Task<(List History, string? Error)> JoinChannelAsync(
+ string connectionId, Guid userId, string username, string channelName)
+ {
+ JoinedChannels.Add((channelName, username));
+ return Task.FromResult((HistoryToReturn, JoinError));
+ }
+
+ public Task LeaveChannelAsync(string connectionId, string username, string channelName)
+ {
+ LeftChannels.Add((channelName, username));
+ return Task.CompletedTask;
+ }
+
+ public Task SendMessageAsync(Guid userId, string username, string channelName, string content)
+ {
+ SentMessages.Add((channelName, content));
+ return Task.FromResult(SendMessageError);
+ }
+
+ public Task> GetChannelHistoryAsync(string channelName, int count) =>
+ Task.FromResult(HistoryToReturn);
+
+ public Task UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage)
+ {
+ StatusUpdates.Add((username, status));
+ return Task.FromResult(null);
+ }
+
+ public Task> GetOnlineUsersAsync(string channelName) =>
+ Task.FromResult(OnlineUsersToReturn);
+
+ public Task BroadcastMessageAsync(string channelName, MessageDto message) =>
+ Task.CompletedTask;
+
+ public Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null) =>
+ Task.CompletedTask;
+
+ public Task GetUserProfileAsync(string username) =>
+ Task.FromResult(ProfileToReturn);
+
+ public Task> GetChannelsForUserAsync(string username) =>
+ Task.FromResult(ChannelsForUserToReturn);
+
+ public Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password) =>
+ Task.FromResult(AuthResult);
+}
+
+///
+/// Fake channel service that records method calls and returns pre-configured results.
+///
+internal sealed class FakeChannelService : IChannelService
+{
+ // Configurable results
+ public (string? Topic, bool Exists) TopicResult { get; set; } = (null, true);
+ public List ChannelListToReturn { get; set; } = [];
+ public ChannelDto? ChannelByNameToReturn { get; set; }
+ public ChannelOperationResult? CreateResult { get; set; }
+ public ChannelOperationResult? UpdateTopicResult { get; set; }
+ public ChannelOperationResult? DeleteResult { get; set; }
+ public (bool Success, string? Error) MembershipResult { get; set; } = (true, null);
+
+ public Task> GetChannelsAsync(Guid userId, int offset, int limit) =>
+ Task.FromResult(new PaginatedResponse([], 0, offset, limit));
+
+ public Task CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic) =>
+ Task.FromResult(CreateResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured"));
+
+ public Task UpdateTopicAsync(Guid callerUserId, string channelName, string? topic) =>
+ Task.FromResult(UpdateTopicResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured"));
+
+ public Task DeleteChannelAsync(Guid callerUserId, string channelName) =>
+ Task.FromResult(DeleteResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured"));
+
+ public Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName) =>
+ Task.FromResult(TopicResult);
+
+ public Task> GetChannelListAsync() =>
+ Task.FromResult(ChannelListToReturn);
+
+ public Task GetChannelByNameAsync(string channelName) =>
+ Task.FromResult(ChannelByNameToReturn);
+
+ public Task<(bool Success, string? Error)> EnsureChannelMembershipAsync(Guid userId, string channelName) =>
+ Task.FromResult(MembershipResult);
+}
diff --git a/src/EchoHub.Tests/IrcMessageFormatterTests.cs b/src/EchoHub.Tests/IrcMessageFormatterTests.cs
new file mode 100644
index 0000000..5e407b5
--- /dev/null
+++ b/src/EchoHub.Tests/IrcMessageFormatterTests.cs
@@ -0,0 +1,186 @@
+using EchoHub.Core.DTOs;
+using EchoHub.Core.Models;
+using EchoHub.Server.Irc;
+using Xunit;
+
+namespace EchoHub.Tests;
+
+public class IrcMessageFormatterTests
+{
+ private static MessageDto CreateMessage(
+ MessageType type = MessageType.Text,
+ string content = "hello",
+ string sender = "alice",
+ string channel = "general",
+ string? attachmentUrl = null,
+ string? attachmentFileName = null,
+ List? embeds = null) => new(
+ Id: Guid.NewGuid(),
+ Content: content,
+ SenderUsername: sender,
+ SenderNicknameColor: null,
+ ChannelName: channel,
+ Type: type,
+ AttachmentUrl: attachmentUrl,
+ AttachmentFileName: attachmentFileName,
+ SentAt: DateTimeOffset.UtcNow,
+ Embeds: embeds);
+
+ // ── FormatMessage ─────────────────────────────────────────────────
+
+ [Fact]
+ public void FormatMessage_TextMessage_FormatsAsPRIVMSG()
+ {
+ var msg = CreateMessage(content: "Hello world");
+ var lines = IrcMessageFormatter.FormatMessage(msg);
+
+ Assert.Single(lines);
+ Assert.Contains("PRIVMSG #general :Hello world", lines[0]);
+ Assert.StartsWith(":alice!alice@echohub", lines[0]);
+ }
+
+ [Fact]
+ public void FormatMessage_TextMessage_WithEmbeds_AppendsEmbedLines()
+ {
+ var embeds = new List
+ {
+ new("GitHub", "Repo Title", "A description", null, "https://github.com/test")
+ };
+ var msg = CreateMessage(content: "check this out https://github.com/test", embeds: embeds);
+ var lines = IrcMessageFormatter.FormatMessage(msg);
+
+ Assert.True(lines.Count >= 2);
+ Assert.Contains("PRIVMSG #general :check this out", lines[0]);
+ // Embed lines contain the Unicode pipe char and site/title
+ Assert.Contains("GitHub", lines[1]);
+ Assert.Contains("Repo Title", lines[1]);
+ }
+
+ [Fact]
+ public void FormatMessage_ImageMessage_IncludesImageTagAndDownloadUrl()
+ {
+ var msg = CreateMessage(
+ type: MessageType.Image,
+ content: "{F:FF0000}\u2588{X}",
+ attachmentUrl: "/api/files/abc",
+ attachmentFileName: "photo.png");
+ var lines = IrcMessageFormatter.FormatMessage(msg);
+
+ Assert.True(lines.Count >= 2);
+ Assert.Contains("[Image: photo.png]", lines[0]);
+ Assert.Contains("Download: /api/files/abc", lines[1]);
+ }
+
+ [Fact]
+ public void FormatMessage_FileMessage_IncludesFileTag()
+ {
+ var msg = CreateMessage(
+ type: MessageType.File,
+ content: "report.pdf",
+ attachmentUrl: "/api/files/xyz",
+ attachmentFileName: "report.pdf");
+ var lines = IrcMessageFormatter.FormatMessage(msg);
+
+ Assert.Single(lines);
+ Assert.Contains("[File: report.pdf]", lines[0]);
+ Assert.Contains("/api/files/xyz", lines[0]);
+ }
+
+ [Fact]
+ public void FormatMessage_AudioMessage_IncludesMusicNoteAndAudioTag()
+ {
+ var msg = CreateMessage(
+ type: MessageType.Audio,
+ content: "song.mp3",
+ attachmentUrl: "/api/files/def",
+ attachmentFileName: "song.mp3");
+ var lines = IrcMessageFormatter.FormatMessage(msg);
+
+ Assert.Single(lines);
+ Assert.Contains("\u266a", lines[0]); // ♪
+ Assert.Contains("[Audio: song.mp3]", lines[0]);
+ Assert.Contains("/api/files/def", lines[0]);
+ }
+
+ // ── ColorTagsToAnsi ───────────────────────────────────────────────
+
+ [Fact]
+ public void ColorTagsToAnsi_ForegroundTag_ConvertsToAnsiEscape()
+ {
+ var result = IrcMessageFormatter.ColorTagsToAnsi("{F:FF0000}text");
+ Assert.Contains("\x1b[38;2;255;0;0m", result);
+ Assert.Contains("text", result);
+ }
+
+ [Fact]
+ public void ColorTagsToAnsi_BackgroundTag_ConvertsToAnsiEscape()
+ {
+ var result = IrcMessageFormatter.ColorTagsToAnsi("{B:00FF00}text");
+ Assert.Contains("\x1b[48;2;0;255;0m", result);
+ }
+
+ [Fact]
+ public void ColorTagsToAnsi_ResetTag_ConvertsToAnsiReset()
+ {
+ var result = IrcMessageFormatter.ColorTagsToAnsi("{X}");
+ Assert.Equal("\x1b[0m", result);
+ }
+
+ [Fact]
+ public void ColorTagsToAnsi_NoTags_ReturnsUnchanged()
+ {
+ var result = IrcMessageFormatter.ColorTagsToAnsi("plain text");
+ Assert.Equal("plain text", result);
+ }
+
+ [Fact]
+ public void ColorTagsToAnsi_MultipleTags_ConvertsAll()
+ {
+ var result = IrcMessageFormatter.ColorTagsToAnsi("{F:FF0000}red{F:0000FF}blue{X}");
+ Assert.Contains("\x1b[38;2;255;0;0m", result);
+ Assert.Contains("\x1b[38;2;0;0;255m", result);
+ Assert.Contains("\x1b[0m", result);
+ Assert.Contains("red", result);
+ Assert.Contains("blue", result);
+ }
+
+ // ── SplitMessage ──────────────────────────────────────────────────
+
+ [Fact]
+ public void SplitMessage_ShortMessage_ReturnsSingleChunk()
+ {
+ var result = IrcMessageFormatter.SplitMessage("Hello", 400);
+ Assert.Single(result);
+ Assert.Equal("Hello", result[0]);
+ }
+
+ [Fact]
+ public void SplitMessage_LongMessage_SplitsAtWordBoundary()
+ {
+ var words = string.Join(" ", Enumerable.Repeat("word", 200));
+ var result = IrcMessageFormatter.SplitMessage(words, 50);
+
+ Assert.True(result.Count > 1);
+ foreach (var chunk in result)
+ Assert.True(System.Text.Encoding.UTF8.GetByteCount(chunk) <= 50);
+ }
+
+ [Fact]
+ public void SplitMessage_EmptyMessage_ReturnsSingleEmptyChunk()
+ {
+ var result = IrcMessageFormatter.SplitMessage("", 400);
+ Assert.Single(result);
+ Assert.Equal("", result[0]);
+ }
+
+ [Fact]
+ public void SplitMessage_SingleLongWord_KeptAsOneChunk()
+ {
+ var longWord = new string('a', 500);
+ var result = IrcMessageFormatter.SplitMessage(longWord, 400);
+
+ // Single word can't be split at word boundary, so it stays as one chunk
+ Assert.Single(result);
+ Assert.Equal(longWord, result[0]);
+ }
+}
diff --git a/src/EchoHub.Tests/JwtTokenServiceTests.cs b/src/EchoHub.Tests/JwtTokenServiceTests.cs
new file mode 100644
index 0000000..a9367c8
--- /dev/null
+++ b/src/EchoHub.Tests/JwtTokenServiceTests.cs
@@ -0,0 +1,195 @@
+using System.IdentityModel.Tokens.Jwt;
+using EchoHub.Core.Models;
+using EchoHub.Server.Auth;
+using Microsoft.Extensions.Configuration;
+using Xunit;
+
+namespace EchoHub.Tests;
+
+public class JwtTokenServiceTests
+{
+ private const string TestSecret = "this_is_a_test_secret_key_that_is_long_enough_for_hmac_sha256";
+ private const string TestIssuer = "TestIssuer";
+ private const string TestAudience = "TestAudience";
+
+ private static JwtTokenService CreateService(
+ string? secret = null, string? issuer = null, string? audience = null)
+ {
+ var config = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ ["Jwt:Secret"] = secret ?? TestSecret,
+ ["Jwt:Issuer"] = issuer ?? TestIssuer,
+ ["Jwt:Audience"] = audience ?? TestAudience,
+ })
+ .Build();
+
+ return new JwtTokenService(config);
+ }
+
+ private static User CreateUser(
+ string username = "alice",
+ ServerRole role = ServerRole.Member,
+ string? displayName = null) => new()
+ {
+ Id = Guid.NewGuid(),
+ Username = username,
+ PasswordHash = "hash",
+ DisplayName = displayName,
+ Role = role,
+ };
+
+ // ── Constructor ───────────────────────────────────────────────────
+
+ [Fact]
+ public void Constructor_MissingSecret_Throws()
+ {
+ var config = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ ["Jwt:Issuer"] = TestIssuer,
+ ["Jwt:Audience"] = TestAudience,
+ })
+ .Build();
+
+ Assert.Throws(() => new JwtTokenService(config));
+ }
+
+ [Fact]
+ public void Constructor_MissingIssuer_Throws()
+ {
+ var config = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ ["Jwt:Secret"] = TestSecret,
+ ["Jwt:Audience"] = TestAudience,
+ })
+ .Build();
+
+ Assert.Throws(() => new JwtTokenService(config));
+ }
+
+ [Fact]
+ public void Constructor_MissingAudience_Throws()
+ {
+ var config = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ ["Jwt:Secret"] = TestSecret,
+ ["Jwt:Issuer"] = TestIssuer,
+ })
+ .Build();
+
+ Assert.Throws(() => new JwtTokenService(config));
+ }
+
+ // ── GenerateAccessToken ───────────────────────────────────────────
+
+ [Fact]
+ public void GenerateAccessToken_ContainsExpectedClaims()
+ {
+ var service = CreateService();
+ var user = CreateUser(username: "bob", role: ServerRole.Admin, displayName: "Bob Smith");
+
+ var (token, _) = service.GenerateAccessToken(user);
+ var handler = new JwtSecurityTokenHandler();
+ var jwt = handler.ReadJwtToken(token);
+
+ Assert.Equal(user.Id.ToString(), jwt.Claims.First(c => c.Type == "sub").Value);
+ Assert.Equal("bob", jwt.Claims.First(c => c.Type == "username").Value);
+ Assert.Equal("Bob Smith", jwt.Claims.First(c => c.Type == "display_name").Value);
+ Assert.Equal("Admin", jwt.Claims.First(c => c.Type == "role").Value);
+ Assert.NotNull(jwt.Claims.FirstOrDefault(c => c.Type == "jti"));
+ }
+
+ [Fact]
+ public void GenerateAccessToken_ExpiresIn15Minutes()
+ {
+ var service = CreateService();
+ var user = CreateUser();
+
+ var (_, expiresAt) = service.GenerateAccessToken(user);
+ var diff = expiresAt - DateTimeOffset.UtcNow;
+
+ // Should be approximately 15 minutes (allow 30s tolerance)
+ Assert.InRange(diff.TotalMinutes, 14.5, 15.5);
+ }
+
+ [Fact]
+ public void GenerateAccessToken_DifferentTokensForSameUser()
+ {
+ var service = CreateService();
+ var user = CreateUser();
+
+ var (token1, _) = service.GenerateAccessToken(user);
+ var (token2, _) = service.GenerateAccessToken(user);
+
+ Assert.NotEqual(token1, token2);
+ }
+
+ [Fact]
+ public void GenerateAccessToken_DisplayNameFallsBackToUsername()
+ {
+ var service = CreateService();
+ var user = CreateUser(username: "alice"); // DisplayName is null
+
+ var (token, _) = service.GenerateAccessToken(user);
+ var handler = new JwtSecurityTokenHandler();
+ var jwt = handler.ReadJwtToken(token);
+
+ Assert.Equal("alice", jwt.Claims.First(c => c.Type == "display_name").Value);
+ }
+
+ // ── GenerateRefreshToken ──────────────────────────────────────────
+
+ [Fact]
+ public void GenerateRefreshToken_Returns88CharBase64()
+ {
+ var token = JwtTokenService.GenerateRefreshToken();
+
+ // 64 bytes → 88 base64 characters
+ Assert.Equal(88, token.Length);
+ // Should be valid base64
+ var bytes = Convert.FromBase64String(token);
+ Assert.Equal(64, bytes.Length);
+ }
+
+ [Fact]
+ public void GenerateRefreshToken_UniqueBetweenCalls()
+ {
+ var token1 = JwtTokenService.GenerateRefreshToken();
+ var token2 = JwtTokenService.GenerateRefreshToken();
+
+ Assert.NotEqual(token1, token2);
+ }
+
+ // ── HashToken ─────────────────────────────────────────────────────
+
+ [Fact]
+ public void HashToken_DeterministicForSameInput()
+ {
+ var hash1 = JwtTokenService.HashToken("test-token");
+ var hash2 = JwtTokenService.HashToken("test-token");
+
+ Assert.Equal(hash1, hash2);
+ }
+
+ [Fact]
+ public void HashToken_DifferentForDifferentInput()
+ {
+ var hash1 = JwtTokenService.HashToken("token-a");
+ var hash2 = JwtTokenService.HashToken("token-b");
+
+ Assert.NotEqual(hash1, hash2);
+ }
+
+ [Fact]
+ public void HashToken_ReturnsBase64String()
+ {
+ var hash = JwtTokenService.HashToken("test-token");
+
+ // SHA256 → 32 bytes → 44 base64 characters
+ var bytes = Convert.FromBase64String(hash);
+ Assert.Equal(32, bytes.Length);
+ }
+}
diff --git a/src/EchoHub.Tests/LinkEmbedServiceTests.cs b/src/EchoHub.Tests/LinkEmbedServiceTests.cs
new file mode 100644
index 0000000..735d67a
--- /dev/null
+++ b/src/EchoHub.Tests/LinkEmbedServiceTests.cs
@@ -0,0 +1,192 @@
+using System.Reflection;
+using EchoHub.Server.Services;
+using Xunit;
+
+namespace EchoHub.Tests;
+
+public class LinkEmbedServiceTests
+{
+ private static readonly MethodInfo ExtractUrlsMethod = typeof(LinkEmbedService)
+ .GetMethod("ExtractUrls", BindingFlags.NonPublic | BindingFlags.Static)!;
+
+ private static readonly MethodInfo IsPrivateHostMethod = typeof(LinkEmbedService)
+ .GetMethod("IsPrivateHost", BindingFlags.NonPublic | BindingFlags.Static)!;
+
+ private static readonly MethodInfo ParseOgTagsMethod = typeof(LinkEmbedService)
+ .GetMethod("ParseOgTags", BindingFlags.NonPublic | BindingFlags.Static)!;
+
+ private static List ExtractUrls(string content) =>
+ (List)ExtractUrlsMethod.Invoke(null, [content])!;
+
+ private static bool IsPrivateHost(Uri uri) =>
+ (bool)IsPrivateHostMethod.Invoke(null, [uri])!;
+
+ private static Dictionary ParseOgTags(string html) =>
+ (Dictionary)ParseOgTagsMethod.Invoke(null, [html])!;
+
+ // ── ExtractUrls ───────────────────────────────────────────────────
+
+ [Fact]
+ public void ExtractUrls_SingleUrl_ReturnsIt()
+ {
+ var urls = ExtractUrls("Check this: https://example.com");
+ Assert.Single(urls);
+ Assert.Equal("https://example.com", urls[0]);
+ }
+
+ [Fact]
+ public void ExtractUrls_MultipleUrls_ReturnsAll()
+ {
+ var urls = ExtractUrls("See https://a.com and https://b.com");
+ Assert.Equal(2, urls.Count);
+ Assert.Contains("https://a.com", urls);
+ Assert.Contains("https://b.com", urls);
+ }
+
+ [Fact]
+ public void ExtractUrls_UrlWithTrailingPunctuation_Trimmed()
+ {
+ var urls = ExtractUrls("Visit https://example.com.");
+ Assert.Single(urls);
+ Assert.Equal("https://example.com", urls[0]);
+ }
+
+ [Fact]
+ public void ExtractUrls_NoUrls_ReturnsEmpty()
+ {
+ var urls = ExtractUrls("No links here");
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void ExtractUrls_MaxUrlsLimit_Respected()
+ {
+ // EmbedMaxUrlsPerMessage = 3
+ var text = "https://a.com https://b.com https://c.com https://d.com https://e.com";
+ var urls = ExtractUrls(text);
+ Assert.Equal(3, urls.Count);
+ }
+
+ [Fact]
+ public void ExtractUrls_DuplicateUrls_Deduped()
+ {
+ var urls = ExtractUrls("https://example.com and https://example.com again");
+ Assert.Single(urls);
+ }
+
+ [Fact]
+ public void ExtractUrls_HttpUrl_Extracted()
+ {
+ var urls = ExtractUrls("http://example.com");
+ Assert.Single(urls);
+ Assert.StartsWith("http://", urls[0]);
+ }
+
+ // ── IsPrivateHost ─────────────────────────────────────────────────
+
+ [Fact]
+ public void IsPrivateHost_Localhost_ReturnsTrue()
+ {
+ Assert.True(IsPrivateHost(new Uri("http://localhost/test")));
+ }
+
+ [Fact]
+ public void IsPrivateHost_LoopbackIP_ReturnsTrue()
+ {
+ Assert.True(IsPrivateHost(new Uri("http://127.0.0.1/test")));
+ }
+
+ [Fact]
+ public void IsPrivateHost_10Network_ReturnsTrue()
+ {
+ Assert.True(IsPrivateHost(new Uri("http://10.0.0.1/test")));
+ }
+
+ [Fact]
+ public void IsPrivateHost_172_16Network_ReturnsTrue()
+ {
+ Assert.True(IsPrivateHost(new Uri("http://172.16.0.1/test")));
+ }
+
+ [Fact]
+ public void IsPrivateHost_192_168Network_ReturnsTrue()
+ {
+ Assert.True(IsPrivateHost(new Uri("http://192.168.1.1/test")));
+ }
+
+ [Fact]
+ public void IsPrivateHost_PublicIP_ReturnsFalse()
+ {
+ Assert.False(IsPrivateHost(new Uri("http://8.8.8.8/test")));
+ }
+
+ [Fact]
+ public void IsPrivateHost_PublicDomain_ReturnsFalse()
+ {
+ Assert.False(IsPrivateHost(new Uri("https://example.com/test")));
+ }
+
+ [Fact]
+ public void IsPrivateHost_ZeroIP_ReturnsTrue()
+ {
+ Assert.True(IsPrivateHost(new Uri("http://0.0.0.0/test")));
+ }
+
+ // ── ParseOgTags ───────────────────────────────────────────────────
+
+ [Fact]
+ public void ParseOgTags_StandardOgTags_ParsedCorrectly()
+ {
+ var html = """
+
+
+
+
+
+ """;
+ var tags = ParseOgTags(html);
+
+ Assert.Equal("Test Title", tags["title"]);
+ Assert.Equal("A description", tags["description"]);
+ Assert.Equal("TestSite", tags["site_name"]);
+ }
+
+ [Fact]
+ public void ParseOgTags_ReversedOrder_ParsedCorrectly()
+ {
+ var html = """""";
+ var tags = ParseOgTags(html);
+
+ Assert.Equal("Reversed Title", tags["title"]);
+ }
+
+ [Fact]
+ public void ParseOgTags_NoOgTags_ReturnsEmptyDictionary()
+ {
+ var html = "Page";
+ var tags = ParseOgTags(html);
+
+ Assert.Empty(tags);
+ }
+
+ [Fact]
+ public void ParseOgTags_SingleQuotes_ParsedCorrectly()
+ {
+ var html = """""";
+ var tags = ParseOgTags(html);
+
+ Assert.Equal("Single Quoted", tags["title"]);
+ }
+
+ [Fact]
+ public void ParseOgTags_DuplicateKeys_FirstWins()
+ {
+ var html = """
+
+
+ """;
+ var tags = ParseOgTags(html);
+
+ Assert.Equal("First", tags["title"]);
+ }
+}
diff --git a/src/Terminal.Gui b/src/Terminal.Gui
new file mode 160000
index 0000000..0061d03
--- /dev/null
+++ b/src/Terminal.Gui
@@ -0,0 +1 @@
+Subproject commit 0061d03558264e0005d8c5ba53845e629ea2f8da