Compare commits

45 Commits
Author SHA1 Message Date
Hue 94f968ccf9 Merge pull request #21 from HueByte/dev
Dev
2026-02-22 16:31:37 +01:00
HueByte 4f96b8d986 refactor: consolidate and reorganize code structure; extract classes and improve readability 2026-02-22 16:25:06 +01:00
HueByte fbb2958fdd refactor: remove Terminal.Gui submodule and switch to NuGet package; update version to 0.2.7 2026-02-22 16:23:19 +01:00
HueByte 62e9d7fff5 fix: optimize user fetching after connection failure handling 2026-02-22 16:02:15 +01:00
Hue be4d686d4d Merge pull request #19 from HueByte/dev_cleanups
Dev cleanups
2026-02-22 15:32:03 +01:00
HueByte c8ea124198 Add hex color parsing helper and implement custom list sources for channels and users
- Introduced HexColorHelper for parsing hex color strings to Terminal.Gui Attributes and Colors.
- Created ChannelListSource for managing and rendering a list of channels with unread counts and active indicators.
- Developed UserListSource for displaying online users with per-user nickname colors.
- Refactored MainWindow to utilize ChatMessageManager for handling messages and channel state.
- Updated project references and removed unused content from the server project.
- Adjusted tests to reflect changes in namespaces and structure.
2026-02-22 08:08:21 +01:00
HueByte c5d8e25a86 Add dialogs for connection, channel creation, profile editing, and status management
- Implemented ConnectDialog for server connection and authentication.
- Added CreateChannelDialog for creating new channels with name and topic.
- Developed ProfileEditDialog for editing user profiles including display name, bio, and avatar.
- Created ProfileViewDialog for viewing user profiles with options to edit or set status.
- Introduced StatusDialog for setting user status and status message.
- Added UpdateConfirmDialog for confirming updates and UpdateProgressDialog for showing update progress.
- Removed obsolete hue_icon.ico file from the server.
2026-02-22 08:07:53 +01:00
HueByte 03f4685ae6 fix: update release workflow to use --notes-file for release notes to prevent shell expansion issues 2026-02-22 06:03:08 +01:00
Hue 17e64293b5 Merge pull request #18 from HueByte/dev
Dev merge
2026-02-22 05:56:01 +01:00
HueByte ce375c7619 fix: remove invisible character from using directive in migration file 2026-02-22 05:51:17 +01:00
HueByte d12f249222 fix: update transparent color handling to use 'None' instead of 'Transparent' across UI components 2026-02-22 05:50:28 +01:00
HueByte bc8e2fd7da feat: add attachment file size support for messages and update upload limits 2026-02-21 20:37:13 +01:00
HueByte 35a162d013 refactor: streamline audio service stop logic in AudioPlayerDialog 2026-02-21 20:36:57 +01:00
HueByte de34fb43b9 feat: enhance audio playback features and UI, including audio player dialog and improved file size handling 2026-02-21 19:50:09 +01:00
Hue 3e03a77aeb Merge pull request #17 from HueByte/dev_api_update
feat: add ApiResponse and ApiResponse<T> records for improved API res…
2026-02-21 18:08:09 +01:00
Hue 5ef338bbf7 Merge branch 'dev' into dev_api_update 2026-02-21 18:01:25 +01:00
HueByte f96f70f09a feat: add ApiResponse and ApiResponse<T> records for improved API response handling 2026-02-21 18:00:10 +01:00
Hue b7d58dc10d Merge pull request #16 from HueByte/dev_changelog_docs
Dev changelog docs
2026-02-21 17:59:04 +01:00
HueByte 7fadd08702 docs: update API documentation and changelog for v0.2.5 release 2026-02-21 17:55:08 +01:00
Hue 576dd4bc94 Merge pull request #14 from HueByte/dev_session_persistence
Dev session persistence
2026-02-21 17:31:19 +01:00
HueByte d4cf35e04e feat: implement session persistence with "Remember Me" functionality and logout support 2026-02-21 17:29:16 +01:00
Hue 23abb9b0bb Merge pull request #13 from HueByte/dev_channel_logic_update
feat: extract channel management to IChannelService and implement CRU…
2026-02-21 17:28:53 +01:00
HueByte dcb8bbc05f fix: exclude Terminal.Gui from formatting checks in CI workflow 2026-02-21 17:26:30 +01:00
HueByte 4930aee615 chore: update CI workflows to temporarily remove Terminal.Gui submodule's nuget.config to fix restore issues 2026-02-21 17:23:14 +01:00
HueByte 2cd3bbb570 chore: remove submodule NuGet config from CI workflows 2026-02-21 17:22:39 +01:00
HueByte 0d959e317e feat: extract channel management to IChannelService and implement CRUD operations 2026-02-21 17:16:41 +01:00
HueByte 75491d65e2 feat: add nuget.config for package source management and update CI workflows to restore dependencies correctly 2026-02-21 17:16:05 +01:00
Hue 1861fd00f9 Merge pull request #12 from Stone-Red-Code/dev
feat: installer & auto update system
2026-02-21 17:12:47 +01:00
Stone_Red 9996a6be97 chore: Add icon files and set application icon for installer and projects 2026-02-21 17:11:47 +01:00
Stone_Red cf9c647c03 feat: add automatic update checking with confirmation and progress dialogs 2026-02-21 17:11:47 +01:00
Stone_Red 1fa4348700 feat: add EchoHub installer Inno Setup script 2026-02-21 17:11:47 +01:00
HueByte 0c7c620f24 feat: enable recursive submodule fetching in CI workflows 2026-02-21 17:09:04 +01:00
HueByte 56a962c1af feat: add hue icon SVG asset for improved UI representation 2026-02-21 16:17:39 +01:00
HueByte a65b41fb7f feat: enhance channel management and status bar functionality 2026-02-21 16:02:49 +01:00
HueByte ab9e09a609 feat: add support for ignoring diff files in .gitignore 2026-02-21 15:44:13 +01:00
HueByte 1bdc6ec99e feat: update chat rendering to support transparent backgrounds 2026-02-21 13:59:32 +01:00
HueByte fb12848e9a feat: add true transparent background support via Terminal.Gui fork
Add Terminal.Gui fork as submodule with transparent color support
(Color.Transparent emitting CSI 39m/49m default bg/fg sequences).
Switch EchoHub.Client from NuGet to project reference.
Update Transparent theme to use real terminal transparency.
2026-02-21 12:53:46 +01:00
HueByte 7e78c57665 chore: update changelog for v0.2.5 release with new features and fixes 2026-02-21 11:31:55 +01:00
Hue 9d4979d4a7 Merge pull request #10 from HueByte/dev_irc_abstraction_fixes
Dev irc abstraction fixes
2026-02-21 11:26:34 +01:00
HueByte c0d38015ff Add comprehensive unit tests for IRC command handling and message formatting
- Implemented IrcCommandHandlerTests to cover various IRC commands including PING, JOIN, PART, and authentication scenarios.
- Added IrcMessageFormatterTests to validate message formatting for different message types such as text, images, files, and audio.
- Created IrcMessageTests to ensure correct parsing of IRC messages and handling of various command formats.
- Introduced TestHelpers to facilitate testing with mock connections and streams, including a FakeChatService and FakeEncryptionService for simulating chat behavior.
2026-02-21 11:25:34 +01:00
Hue 3bc1446d1c Merge pull request #9 from HueByte/dev_more_tests
Dev more tests
2026-02-21 11:18:07 +01:00
HueByte 411042c397 fix: correct formatting in JwtTokenServiceTests for improved readability 2026-02-20 23:02:51 +01:00
HueByte 50817ce6e9 Add unit tests for various services and functionalities
- Implement tests for ChatLine utility methods including color tag detection and stripping.
- Create comprehensive tests for CommandHandler to validate command handling and status updates.
- Add tests for DataMigrationService to ensure correct ANSI to color tag conversion.
- Enhance FileValidationHelperTests with audio file validation tests.
- Introduce ImageToAsciiServiceTests to verify image dimension retrieval.
- Develop IrcMessageFormatterTests for message formatting and color tag conversion.
- Add JwtTokenServiceTests to validate JWT token generation and hashing.
- Implement LinkEmbedServiceTests for URL extraction and Open Graph tag parsing.
- Update EchoHub.Tests.csproj to include necessary project references.
2026-02-20 23:02:51 +01:00
Hue 9265a9f312 Merge pull request #8 from HueByte/dev_send_rework
feat: add audio playback and file download features with correspondin…
2026-02-20 23:01:03 +01:00
HueByte a860a8fbc3 feat: add audio playback and file download features with corresponding UI updates 2026-02-20 20:47:28 +01:00
91 changed files with 7168 additions and 1706 deletions
+2 -5
View File
@@ -102,7 +102,6 @@ jobs:
- name: Build release notes - name: Build release notes
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false' if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
id: notes
run: | run: |
TAG="${{ steps.version.outputs.tag }}" TAG="${{ steps.version.outputs.tag }}"
CHANGELOG_URL="https://huebyte.github.io/EchoHub/changelog/${TAG}.html" CHANGELOG_URL="https://huebyte.github.io/EchoHub/changelog/${TAG}.html"
@@ -110,7 +109,6 @@ jobs:
PREV_TAG=$(git tag --sort=-v:refname | grep -v "^${TAG}$" | head -n 1) PREV_TAG=$(git tag --sort=-v:refname | grep -v "^${TAG}$" | head -n 1)
{ {
echo "body<<RELEASE_EOF"
echo "📋 **[Full Changelog](${CHANGELOG_URL})**" echo "📋 **[Full Changelog](${CHANGELOG_URL})**"
echo "" echo ""
echo "---" echo "---"
@@ -129,15 +127,14 @@ jobs:
else else
echo "*Version diff: [${TAG}](${REPO}/commits/${TAG})*" echo "*Version diff: [${TAG}](${REPO}/commits/${TAG})*"
fi fi
echo "RELEASE_EOF" } > release-notes.md
} >> "$GITHUB_OUTPUT"
- name: Create GitHub Release - name: Create GitHub Release
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false' if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
run: | run: |
gh release create "${{ steps.version.outputs.tag }}" \ gh release create "${{ steps.version.outputs.tag }}" \
--title "EchoHub ${{ steps.version.outputs.tag }}" \ --title "EchoHub ${{ steps.version.outputs.tag }}" \
--notes "${{ steps.notes.outputs.body }}" \ --notes-file release-notes.md \
EchoHub-Server-win-x64.zip \ EchoHub-Server-win-x64.zip \
EchoHub-Server-linux-x64.zip \ EchoHub-Server-linux-x64.zip \
EchoHub-Server-osx-x64.zip \ EchoHub-Server-osx-x64.zip \
+3
View File
@@ -433,3 +433,6 @@ src/EchoHub.Server/uploads/*
# DocFx generated output # DocFx generated output
docs/_site/ docs/_site/
docs/_api_meta/ docs/_api_meta/
# Diff files
*.diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

+11
View File
@@ -0,0 +1,11 @@
<svg width="32" height="32" viewBox="0 0 1000 1000" xmlns="http://www.w3.org/2000/svg">
<g stroke="#E6C06E" stroke-width="28" fill="none" stroke-linecap="round" stroke-linejoin="round">
<circle cx="500" cy="500" r="420" />
<circle cx="500" cy="500" r="180" />
<polygon points="500,440 550,532 450,532" fill="#E6C06E" />
<line x1="500" y1="80" x2="500" y2="320" />
<circle cx="285" cy="390" r="8" fill="#E6C06E" />
<circle cx="715" cy="390" r="8" fill="#E6C06E" />
<circle cx="500" cy="750" r="8" fill="#E6C06E" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 590 B

+4 -2
View File
@@ -5,7 +5,9 @@ Articles related to the EchoHub TUI client built with Terminal.Gui v2.
## Topics ## Topics
- Terminal.Gui v2 patterns and conventions - Terminal.Gui v2 patterns and conventions
- Theme system and customization - Theme system and customization (including transparent theme)
- Command system reference - Command system reference
- Configuration management - Configuration and session persistence
- Audio playback and file downloads
- Automatic update checking
- [Notification sounds](../../articles/notification-sounds.md) - [Notification sounds](../../articles/notification-sounds.md)
+1 -1
View File
@@ -5,5 +5,5 @@ Articles related to the EchoHub.Core shared library.
## Topics ## Topics
- Data models and DTOs - Data models and DTOs
- Contract interfaces (IChatService, IChatBroadcaster, IEchoHubClient) - Contract interfaces (IChatService, IChannelService, IChatBroadcaster, IEchoHubClient)
- Validation constants and shared rules - Validation constants and shared rules
+2 -2
View File
@@ -10,11 +10,11 @@ Terminal.Gui v2 TUI application -- UI components, services, themes, and configur
### Core ### Core
Shared library -- DTOs, models, constants, and contracts (`IChatService`, `IChatBroadcaster`, `IEchoHubClient`). Shared library -- DTOs, models, constants, and contracts (`IChatService`, `IChannelService`, `IChatBroadcaster`, `IEchoHubClient`).
### Server ### 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 ### Server.Irc
+3 -2
View File
@@ -8,6 +8,7 @@ Articles related to the EchoHub server built with ASP.NET Core.
- SignalR hub and real-time messaging - SignalR hub and real-time messaging
- IRC gateway and protocol bridging - IRC gateway and protocol bridging
- ChatService and broadcaster pattern - ChatService and broadcaster pattern
- File upload and validation - ChannelService and channel CRUD
- File upload, validation, and cleanup
- Rate limiting configuration - Rate limiting configuration
- Database schema and migrations - Database schema, migrations, and DataMigrationService
+5 -5
View File
@@ -22,7 +22,7 @@ Shared library containing:
- **Models**: `User`, `Channel`, `Message`, `RefreshToken` - **Models**: `User`, `Channel`, `Message`, `RefreshToken`
- **DTOs**: Record types for API requests/responses - **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` - **Constants**: `ValidationConstants` (shared regex patterns), `HubConstants`
### EchoHub.Server ### EchoHub.Server
@@ -33,7 +33,7 @@ ASP.NET Core web application:
- **Hubs**: SignalR `ChatHub` -- thin adapter delegating to `IChatService` - **Hubs**: SignalR `ChatHub` -- thin adapter delegating to `IChatService`
- **Auth**: JWT token service (15-min access tokens, 30-day refresh tokens) - **Auth**: JWT token service (15-min access tokens, 30-day refresh tokens)
- **Data**: EF Core with SQLite - **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 ### EchoHub.Server.Irc
@@ -51,9 +51,9 @@ IRC users authenticate with existing EchoHub accounts via `PASS`/`NICK`/`USER` o
Terminal.Gui v2 TUI application: Terminal.Gui v2 TUI application:
- **UI**: Main window, dialogs, chat renderer with ANSI color support - **UI**: Main window, dialogs, chat renderer with ANSI color support
- **Services**: API client with automatic token refresh, SignalR connection wrapper - **Services**: API client with automatic token refresh, SignalR connection wrapper, audio playback (NetCoreAudio), automatic update checker (AlwaysUpToDate)
- **Themes**: 13 built-in color themes - **Themes**: 13 built-in color themes (including transparent theme with true terminal transparency)
- **Config**: Client configuration management - **Config**: Client configuration management with session persistence ("Remember Me" refresh tokens)
## Communication ## Communication
+2
View File
@@ -4,6 +4,8 @@ Release history for EchoHub.
## Releases ## 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.3](v0.2.3.md) - Moderation, Embeds & UI Overhaul
- [v0.2.2](v0.2.2.md) - Startup & Shutdown Fixes - [v0.2.2](v0.2.2.md) - Startup & Shutdown Fixes
- [v0.2.1](v0.2.1.md) - Shutdown & CI Fixes - [v0.2.1](v0.2.1.md) - Shutdown & CI Fixes
+6
View File
@@ -1,5 +1,11 @@
- name: Overview - name: Overview
href: index.md href: index.md
- name: v0.2.7
href: v0.2.7.md
- name: v0.2.6
href: v0.2.6.md
- name: v0.2.5
href: v0.2.5.md
- name: v0.2.4 - name: v0.2.4
href: v0.2.4.md href: v0.2.4.md
- name: v0.2.3 - name: v0.2.3
+1 -1
View File
@@ -28,5 +28,5 @@
- `Encryption:EncryptDatabase` server setting (default `false`) controls whether messages are encrypted at rest - `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 - DB column max lengths increased for encrypted content: `Message.Content` 2000 → 16000, `Message.EmbedJson` 8000 → 32000
- EF Core migration: `AddEncryptionSupport` - 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` - Documentation article: `docs/articles/encryption.md`
+121
View File
@@ -0,0 +1,121 @@
# 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<bool>` (`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)
- Extracted `ChatMessageManager` from `MainWindow` — message storage, formatting, and mutation logic now in a dedicated class, reducing MainWindow complexity
## 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
- Release workflow: use `--notes-file` instead of inline `--notes` interpolation to prevent shell expansion of special characters in commit messages
- `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
+22
View File
@@ -0,0 +1,22 @@
# v0.2.6
## Refactoring
- Extracted `ChatMessageManager` from `MainWindow` — message storage, formatting, and mutation logic now in a dedicated class, reducing MainWindow complexity
- Split `ChatRenderer.cs` (8 classes, 548 lines) into 7 individual files: `ChatSegment`, `ChatLine`, `ChatListSource`, `ChannelListSource`, `UserListSource`, `ChatColors`, `ColorHelper`, `RenderHelpers`
- Extracted `ConnectionManager` from `AppOrchestrator` — connection lifecycle, authentication, SignalR event wiring, and channel tracking now in a dedicated service
- Extracted `AvatarHelper` — deduplicated avatar upload logic previously duplicated in `/avatar` command and profile edit dialog
- Consolidated `ProfileEditDialog.ParseHexToTrueColor` into shared `ColorHelper.ParseHexToColor`
- Extracted `UserSession` — session state (`Username`, `Status`, `StatusMessage`) now in a dedicated class instead of scattered fields
- Replaced 21 inline command handler lambdas with named `HandleCmd*` methods for improved readability
- Extracted shared `CleanupConnectionAsync` to deduplicate disconnect/logout cleanup logic
- Reorganized flat `UI/` folder (19 files) into subfolders: `Chat/`, `Dialogs/`, `ListSources/`, `Helpers/` with matching namespaces
- Moved `AsyncRunner` from project root to `Services/` with updated namespace
- Renamed `ColorHelper``HexColorHelper` to avoid namespace collision with Terminal.Gui's `ColorHelper` NuGet dependency
- Moved `hue_icon.ico` to `Client/Assets/`, removed duplicate from Server (Server now references shared icon via relative path)
- Add hex color parsing helper and implement custom list sources for channels and users
- Add dialogs for connection, channel creation, profile editing, and status management
## Infrastructure
- Release workflow: use `--notes-file` instead of inline `--notes` interpolation to prevent shell expansion of special characters in commit messages
+12
View File
@@ -0,0 +1,12 @@
# v0.2.7
## Bug Fixes
- Fix user list empty on initial connect — `FetchAndUpdateOnlineUsers` was called before `InvokeUI` set the current channel, causing an early return
## Infrastructure
- Switch Terminal.Gui from local fork submodule back to NuGet package (`2.0.0-develop.5039`) — transparent color PR merged upstream
- Remove Terminal.Gui submodule, `.gitmodules`, and root `nuget.config` workaround
- Remove `rm -f` submodule nuget.config steps and `submodules: recursive` from all 3 CI workflows (ci, docs, release)
- Remove `--exclude src/Terminal.Gui/` from format check
Binary file not shown.
+52
View File
@@ -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
+1 -1
View File
@@ -1,6 +1,6 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>0.2.4</Version> <Version>0.2.7</Version>
<GenerateDocumentationFile>true</GenerateDocumentationFile> <GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn> <NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup> </PropertyGroup>
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

@@ -339,7 +339,7 @@ public class CommandHandler
/nick <name> - Set display name /nick <name> - Set display name
/color <#hex> - Set nickname color /color <#hex> - Set nickname color
/theme <name> - Switch theme /theme <name> - Switch theme
/send <filepath or URL> [-s|-m|-l] - Send a file or image (size: small/medium/large) /send <filepath or URL> [-s|-m|-l] - Send file/image/audio (size flag for images)
/avatar <URL or filepath> - Set your avatar /avatar <URL or filepath> - Set your avatar
/profile [username] - View a profile /profile [username] - View a profile
/servers - Open saved servers /servers - Open saved servers
+2 -1
View File
@@ -20,7 +20,8 @@ public class SavedServer
public required string Name { get; set; } public required string Name { get; set; }
public required string Url { get; set; } public required string Url { get; set; }
public string? Username { 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; } public DateTimeOffset LastConnected { get; set; }
} }
+4 -1
View File
@@ -5,13 +5,14 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AlwaysUpToDate" Version="2.0.1" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.3" /> <PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.3" /> <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.3" />
<PackageReference Include="NetCoreAudio" Version="2.0.1" /> <PackageReference Include="NetCoreAudio" Version="2.0.1" />
<PackageReference Include="Serilog" Version="4.3.1" /> <PackageReference Include="Serilog" Version="4.3.1" />
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" /> <PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" /> <PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Terminal.Gui" Version="2.0.0-develop.5027" /> <PackageReference Include="Terminal.Gui" Version="2.0.0-develop.5039" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -31,6 +32,8 @@
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<PackageIcon></PackageIcon>
<ApplicationIcon>Assets\hue_icon.ico</ApplicationIcon>
</PropertyGroup> </PropertyGroup>
</Project> </Project>
+33
View File
@@ -18,6 +18,8 @@ public sealed class ApiClient : IDisposable
public string? RefreshToken => _refreshToken; public string? RefreshToken => _refreshToken;
public string BaseUrl { get; } public string BaseUrl { get; }
public event Action? OnTokensRefreshed;
public ApiClient(string baseUrl) public ApiClient(string baseUrl)
{ {
BaseUrl = baseUrl.TrimEnd('/'); BaseUrl = baseUrl.TrimEnd('/');
@@ -68,6 +70,19 @@ public sealed class ApiClient : IDisposable
SetTokens(result); SetTokens(result);
} }
public async Task<LoginResponse> 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<LoginResponse>()
?? throw new InvalidOperationException("Token refresh returned empty response.");
SetTokens(result);
return result;
}
public async Task LogoutAsync() public async Task LogoutAsync()
{ {
if (!string.IsNullOrEmpty(_refreshToken)) if (!string.IsNullOrEmpty(_refreshToken))
@@ -196,6 +211,23 @@ public sealed class ApiClient : IDisposable
return await response.Content.ReadFromJsonAsync<MessageDto>(); return await response.Content.ReadFromJsonAsync<MessageDto>();
} }
public async Task<string> 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<ChannelDto?> CreateChannelAsync(string name, string? topic = null, bool isPublic = true) public async Task<ChannelDto?> CreateChannelAsync(string name, string? topic = null, bool isPublic = true)
{ {
EnsureAuthenticated(); EnsureAuthenticated();
@@ -296,6 +328,7 @@ public sealed class ApiClient : IDisposable
_refreshToken = result.RefreshToken; _refreshToken = result.RefreshToken;
_expiresAt = result.ExpiresAt; _expiresAt = result.ExpiresAt;
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken); _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
OnTokensRefreshed?.Invoke();
} }
/// <summary> /// <summary>
@@ -1,7 +1,7 @@
using Serilog; using Serilog;
using Terminal.Gui.App; using Terminal.Gui.App;
namespace EchoHub.Client; namespace EchoHub.Client.Services;
/// <summary> /// <summary>
/// Eliminates repeated Task.Run/try/catch/app.Invoke(ShowError) boilerplate. /// Eliminates repeated Task.Run/try/catch/app.Invoke(ShowError) boilerplate.
@@ -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");
}
}
}
@@ -0,0 +1,44 @@
using Serilog;
namespace EchoHub.Client.Services;
/// <summary>
/// Shared avatar upload logic — resolves a file path or URL to a stream
/// and uploads it via ApiClient.
/// </summary>
internal static class AvatarHelper
{
/// <summary>
/// Upload an avatar from a local file path or HTTP(S) URL.
/// Returns the ASCII art response from the server.
/// </summary>
public static async Task<string?> UploadAsync(ApiClient apiClient, string target)
{
Stream stream;
string fileName;
if (Uri.TryCreate(target, UriKind.Absolute, out var uri)
&& (uri.Scheme == "http" || uri.Scheme == "https"))
{
using var http = new HttpClient();
var bytes = await http.GetByteArrayAsync(uri);
stream = new MemoryStream(bytes);
fileName = Path.GetFileName(uri.LocalPath);
if (string.IsNullOrWhiteSpace(fileName) || !fileName.Contains('.'))
fileName = "avatar.png";
}
else
{
if (!File.Exists(target))
throw new FileNotFoundException($"File not found: {target}");
stream = File.OpenRead(target);
fileName = Path.GetFileName(target);
}
await using (stream)
{
return await apiClient.UploadAvatarAsync(stream, fileName);
}
}
}
@@ -0,0 +1,268 @@
using EchoHub.Client.Config;
using EchoHub.Client.UI.Dialogs;
using EchoHub.Core.Constants;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using Serilog;
namespace EchoHub.Client.Services;
/// <summary>
/// Result of a successful connection, returned to AppOrchestrator for UI updates.
/// </summary>
internal record ConnectResult(
LoginResponse Login,
List<ChannelDto> Channels,
List<MessageDto> DefaultHistory);
/// <summary>
/// Owns connection lifecycle, authentication, SignalR event wiring, and channel tracking.
/// Fires events so AppOrchestrator can update the UI without managing connection internals.
/// </summary>
internal sealed class ConnectionManager : IAsyncDisposable
{
private EchoHubConnection? _connection;
private ApiClient? _apiClient;
private readonly ClientEncryptionService _encryption = new();
private readonly HashSet<string> _joinedChannels = [];
// ── Properties ────────────────────────────────────────────────────────
public bool IsConnected => _connection?.IsConnected == true;
public bool IsAuthenticated => _apiClient is not null;
public ApiClient? Api => _apiClient;
// ── Events (forwarded from SignalR) ───────────────────────────────────
public event Action<MessageDto>? MessageReceived;
public event Action<string, string>? UserJoined;
public event Action<string, string>? UserLeft;
public event Action<UserPresenceDto>? UserStatusChanged;
public event Action<string, string, string?>? UserKicked;
public event Action<string, string?>? UserBanned;
public event Action<string>? ForceDisconnected;
public event Action<string, Guid>? MessageDeleted;
public event Action<string>? ChannelNuked;
public event Action<ChannelDto>? ChannelUpdated;
public event Action<string>? Error;
public event Action<string>? ConnectionStatusChanged;
public event Action? Reconnected;
// ── Connect ───────────────────────────────────────────────────────────
/// <summary>
/// Full connection flow: authenticate → encryption → SignalR → join default channel.
/// Calls <paramref name="onStatus"/> with progress messages for UI updates.
/// Throws on auth failure (caller handles saved-session expiry, etc.).
/// </summary>
public async Task<ConnectResult> ConnectAsync(ConnectDialogResult info, Action<string> onStatus)
{
_apiClient?.Dispose();
_apiClient = new ApiClient(info.ServerUrl);
onStatus("Authenticating...");
LoginResponse loginResponse;
if (info.SavedRefreshToken is not null)
{
try
{
loginResponse = await _apiClient.LoginWithRefreshTokenAsync(info.SavedRefreshToken);
Log.Information("Authenticated via saved session for {User}", loginResponse.Username);
}
catch
{
_apiClient.Dispose();
_apiClient = null;
throw; // Caller handles saved-session expiry
}
}
else if (info.IsRegister)
{
loginResponse = await _apiClient.RegisterAsync(info.Username, info.Password);
}
else
{
loginResponse = await _apiClient.LoginAsync(info.Username, info.Password);
}
// Auto-persist rotated refresh tokens for Remember Me
_apiClient.OnTokensRefreshed += HandleTokensRefreshed;
// E2E encryption key
onStatus("Fetching encryption key...");
try
{
var encryptionKey = await _apiClient.GetEncryptionKeyAsync();
_encryption.SetKey(encryptionKey);
Log.Information("E2E encryption key established");
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to fetch encryption key — messages will not be encrypted");
}
onStatus("Authenticated, connecting...");
if (_connection is not null)
await _connection.DisposeAsync();
_connection = new EchoHubConnection(info.ServerUrl, _apiClient, _encryption);
WireConnectionEvents(_connection);
await _connection.ConnectAsync();
var channels = await _apiClient.GetChannelsAsync();
onStatus("Connected");
// Join default channel + fetch history
_joinedChannels.Clear();
_joinedChannels.Add(HubConstants.DefaultChannel);
await _connection.JoinChannelAsync(HubConstants.DefaultChannel);
List<MessageDto> history = [];
try
{
history = await _connection.GetHistoryAsync(HubConstants.DefaultChannel);
}
catch
{
// History might not be available
}
return new ConnectResult(loginResponse, channels, history);
}
// ── Cleanup ───────────────────────────────────────────────────────────
/// <summary>
/// Disconnect and dispose connection + API client, clear channel tracking.
/// </summary>
public async Task CleanupAsync()
{
if (_connection is not null)
{
await _connection.DisconnectAsync();
await _connection.DisposeAsync();
_connection = null;
}
_apiClient?.Dispose();
_apiClient = null;
_joinedChannels.Clear();
}
/// <summary>
/// Revoke refresh token on the server. Call <see cref="CleanupAsync"/> afterwards.
/// </summary>
public async Task LogoutAsync()
{
if (_apiClient is not null)
await _apiClient.LogoutAsync();
}
// ── Channel Operations ────────────────────────────────────────────────
public async Task<List<MessageDto>> JoinChannelAsync(string channelName)
{
if (_connection is null) throw new InvalidOperationException("Not connected");
_joinedChannels.Add(channelName);
return await _connection.JoinChannelAsync(channelName);
}
public async Task LeaveChannelAsync(string channelName)
{
if (_connection is null) throw new InvalidOperationException("Not connected");
await _connection.LeaveChannelAsync(channelName);
_joinedChannels.Remove(channelName);
}
/// <summary>
/// Track a channel as joined (returns true if newly added).
/// </summary>
public bool TrackChannel(string channelName) => _joinedChannels.Add(channelName);
public void UntrackChannel(string channelName) => _joinedChannels.Remove(channelName);
// ── Delegate Operations ───────────────────────────────────────────────
public Task SendMessageAsync(string channel, string content) =>
_connection?.SendMessageAsync(channel, content)
?? throw new InvalidOperationException("Not connected");
public Task<List<MessageDto>> GetHistoryAsync(string channel) =>
_connection?.GetHistoryAsync(channel)
?? throw new InvalidOperationException("Not connected");
public Task<List<UserPresenceDto>> GetOnlineUsersAsync(string channel) =>
_connection?.GetOnlineUsersAsync(channel)
?? throw new InvalidOperationException("Not connected");
public Task UpdateStatusAsync(UserStatus status, string? message) =>
_connection?.UpdateStatusAsync(status, message)
?? throw new InvalidOperationException("Not connected");
// ── Reconnect ─────────────────────────────────────────────────────────
/// <summary>
/// Rejoin all previously tracked channels after a reconnect.
/// </summary>
public async Task RejoinChannelsAsync()
{
var channels = _joinedChannels.ToList();
if (channels.Count == 0 || _connection is null) return;
_joinedChannels.Clear();
foreach (var channel in channels)
{
_joinedChannels.Add(channel);
await _connection.JoinChannelAsync(channel);
}
Log.Information("Rejoined {Count} channel(s) after reconnect", channels.Count);
}
// ── SignalR Event Wiring ──────────────────────────────────────────────
private void WireConnectionEvents(EchoHubConnection connection)
{
connection.OnMessageReceived += msg => MessageReceived?.Invoke(msg);
connection.OnUserJoined += (ch, user) => UserJoined?.Invoke(ch, user);
connection.OnUserLeft += (ch, user) => UserLeft?.Invoke(ch, user);
connection.OnUserStatusChanged += p => UserStatusChanged?.Invoke(p);
connection.OnUserKicked += (ch, user, reason) => UserKicked?.Invoke(ch, user, reason);
connection.OnUserBanned += (user, reason) => UserBanned?.Invoke(user, reason);
connection.OnForceDisconnect += reason => ForceDisconnected?.Invoke(reason);
connection.OnMessageDeleted += (ch, id) => MessageDeleted?.Invoke(ch, id);
connection.OnChannelNuked += ch => ChannelNuked?.Invoke(ch);
connection.OnChannelUpdated += ch => ChannelUpdated?.Invoke(ch);
connection.OnError += msg => Error?.Invoke(msg);
connection.OnConnectionStateChanged += status => ConnectionStatusChanged?.Invoke(status);
connection.OnReconnected += () => Reconnected?.Invoke();
}
// ── Token Persistence ─────────────────────────────────────────────────
private void HandleTokensRefreshed()
{
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);
}
}
// ── Dispose ───────────────────────────────────────────────────────────
public async ValueTask DisposeAsync()
{
_apiClient?.Dispose();
if (_connection is not null)
await _connection.DisposeAsync();
}
}
+94 -35
View File
@@ -1,47 +1,106 @@
using System.Net.Http.Json; using AlwaysUpToDate;
using System.Text.Json.Serialization;
using EchoHub.Client.UI.Dialogs;
using Serilog;
using Terminal.Gui.App;
namespace EchoHub.Client.Services; namespace EchoHub.Client.Services;
public static class UpdateChecker public sealed class UpdateChecker : IDisposable
{ {
private static readonly Uri ReleaseUrl = private const string ManifestUrl = "https://echohub.voidcube.cloud/api/app/version";
new("https://api.github.com/repos/HueByte/EchoHub/releases/latest");
/// <summary> private readonly Updater _updater;
/// Checks GitHub for a newer release. Returns the new version string if one exists, or null. private readonly IApplication _app;
/// Never throws — all errors are silently swallowed. private UpdateProgressDialog? _progressDialog;
/// </summary>
public static async Task<string?> CheckForUpdateAsync() public static string CurrentVersion => typeof(UpdateChecker).Assembly.GetName().Version?.ToString(3) ?? "0.0.0";
public UpdateChecker(IApplication app)
{ {
try _app = app;
{ _updater = new Updater(TimeSpan.FromHours(1), ManifestUrl, false);
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
http.DefaultRequestHeaders.UserAgent.ParseAdd("EchoHub-Client");
var release = await http.GetFromJsonAsync<GitHubRelease>(ReleaseUrl); _updater.UpdateAvailable += OnUpdateAvailable;
if (release?.TagName is null) _updater.ProgressChanged += OnProgressChanged;
return null; _updater.UpdateStarted += OnUpdateStarted;
_updater.NoUpdateAvailable += OnNoUpdateAvailable;
var tag = release.TagName.TrimStart('v', 'V'); _updater.OnException += OnException;
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;
}
} }
private sealed class GitHubRelease public void Start()
{ {
[JsonPropertyName("tag_name")] #if RELEASE
public string? TagName { get; set; } _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;
} }
} }
@@ -0,0 +1,20 @@
using EchoHub.Core.Models;
namespace EchoHub.Client.Services;
/// <summary>
/// Holds the current user's session state (username, status, status message).
/// </summary>
internal sealed class UserSession
{
public string Username { get; set; } = string.Empty;
public UserStatus Status { get; set; } = UserStatus.Online;
public string? StatusMessage { get; set; }
public void Reset()
{
Username = string.Empty;
Status = UserStatus.Online;
StatusMessage = null;
}
}
+7 -7
View File
@@ -418,30 +418,30 @@ public static class ThemeManager
Base = new ThemeColors Base = new ThemeColors
{ {
Foreground = "White", Foreground = "White",
Background = "Black", Background = "None",
FocusForeground = "BrightCyan", FocusForeground = "BrightCyan",
FocusBackground = "Black" FocusBackground = "None"
}, },
Menu = new ThemeColors Menu = new ThemeColors
{ {
Foreground = "White", Foreground = "White",
Background = "Black", Background = "None",
FocusForeground = "BrightCyan", FocusForeground = "BrightCyan",
FocusBackground = "Black" FocusBackground = "None"
}, },
Dialog = new ThemeColors Dialog = new ThemeColors
{ {
Foreground = "White", Foreground = "White",
Background = "Black", Background = "DarkGray",
FocusForeground = "BrightCyan", FocusForeground = "BrightCyan",
FocusBackground = "Black" FocusBackground = "Black"
}, },
Status = new ThemeColors Status = new ThemeColors
{ {
Foreground = "Gray", Foreground = "Gray",
Background = "Black", Background = "None",
FocusForeground = "Gray", FocusForeground = "Gray",
FocusBackground = "Black" FocusBackground = "None"
} }
}; };
+49
View File
@@ -0,0 +1,49 @@
using System.Text.RegularExpressions;
using Terminal.Gui.Drawing;
using Attribute = Terminal.Gui.Drawing.Attribute;
namespace EchoHub.Client.UI.Chat;
/// <summary>
/// Shared color attributes for chat rendering (timestamps, system messages).
/// </summary>
public static partial class ChatColors
{
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.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);
/// <summary>
/// Split text around @mentions, giving each @word the MentionTextAttr accent color.
/// Non-mention text uses the provided default color.
/// </summary>
public static List<ChatSegment> SplitMentions(string text, Attribute? defaultColor = null)
{
var segments = new List<ChatSegment>();
int lastIndex = 0;
foreach (Match match in MentionRegex().Matches(text))
{
if (match.Index > lastIndex)
segments.Add(new ChatSegment(text[lastIndex..match.Index], defaultColor));
segments.Add(new ChatSegment(match.Value, MentionTextAttr));
lastIndex = match.Index + match.Length;
}
if (lastIndex < text.Length)
segments.Add(new ChatSegment(text[lastIndex..], defaultColor));
return segments;
}
[GeneratedRegex(@"@[\w-]+")]
private static partial Regex MentionRegex();
}
+177
View File
@@ -0,0 +1,177 @@
using System.Text.RegularExpressions;
using EchoHub.Core.Models;
using Terminal.Gui.Drawing;
using Terminal.Gui.Text;
using Attribute = Terminal.Gui.Drawing.Attribute;
namespace EchoHub.Client.UI.Chat;
/// <summary>
/// A single line in the chat, composed of colored segments.
/// </summary>
public partial class ChatLine
{
public List<ChatSegment> Segments { get; }
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)
{
Segments = [new ChatSegment(plainText, null)];
TextLength = plainText.GetColumns();
}
public ChatLine(List<ChatSegment> segments)
{
Segments = segments;
TextLength = segments.Sum(s => s.Text.GetColumns());
}
public override string ToString() => string.Concat(Segments.Select(s => s.Text));
/// <summary>
/// Wrap this line into multiple lines that fit within the given width.
/// Continuation lines are indented with the specified number of spaces.
/// </summary>
public List<ChatLine> Wrap(int width, int continuationIndent = 0)
{
if (width <= 0 || TextLength <= width)
return [this];
var results = new List<ChatLine>();
var currentSegments = new List<ChatSegment>();
int col = 0;
foreach (var segment in Segments)
{
var text = segment.Text;
int chunkStart = 0;
int charPos = 0;
foreach (var grapheme in GraphemeHelper.GetGraphemes(text))
{
var graphemeCols = Math.Max(grapheme.GetColumns(), 1);
if (col + graphemeCols > width)
{
if (charPos > chunkStart)
currentSegments.Add(new ChatSegment(text[chunkStart..charPos], segment.Color));
results.Add(new ChatLine(currentSegments));
currentSegments = [];
if (continuationIndent > 0)
{
currentSegments.Add(new ChatSegment(new string(' ', continuationIndent), null));
col = continuationIndent;
}
else
{
col = 0;
}
chunkStart = charPos;
}
col += graphemeCols;
charPos += grapheme.Length;
}
if (chunkStart < text.Length)
currentSegments.Add(new ChatSegment(text[chunkStart..], segment.Color));
}
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;
}
/// <summary>
/// Returns true if a line contains printable color tags.
/// </summary>
public static bool HasColorTags(string text) =>
text.Contains("{F:") || text.Contains("{B:") || text.Contains("{X}");
/// <summary>
/// Remove all color tags from text, returning only the visible characters.
/// </summary>
public static string StripColorTags(string text) =>
ColorTagRegex().Replace(text, "");
/// <summary>
/// Parse a string containing printable color tags into colored segments.
/// Format: {F:RRGGBB} (foreground), {B:RRGGBB} (background), {X} (reset).
/// </summary>
public static ChatLine FromColoredText(string text, Attribute? defaultAttr = null)
{
var segments = new List<ChatSegment>();
int lastIndex = 0;
Color? currentFg = null;
Color? currentBg = null;
var defaultFg = defaultAttr?.Foreground;
var defaultBg = defaultAttr?.Background ?? Color.None;
Attribute? BuildAttr()
{
if (currentFg is null && currentBg is null) return defaultAttr;
var fg = currentFg ?? defaultFg ?? Color.White;
var bg = currentBg ?? defaultBg;
return new Attribute(fg, bg);
}
foreach (Match match in ColorTagRegex().Matches(text))
{
if (match.Index > lastIndex)
{
var t = text[lastIndex..match.Index];
if (t.Length > 0)
segments.Add(new ChatSegment(t, BuildAttr()));
}
if (match.Groups[1].Success)
{
currentFg = null;
currentBg = null;
}
else if (match.Groups[2].Success)
{
var hex = match.Groups[3].Value;
var r = Convert.ToInt32(hex[..2], 16);
var g = Convert.ToInt32(hex[2..4], 16);
var b = Convert.ToInt32(hex[4..6], 16);
if (match.Groups[2].Value == "F")
currentFg = new Color(r, g, b);
else
currentBg = new Color(r, g, b);
}
lastIndex = match.Index + match.Length;
}
if (lastIndex < text.Length)
{
var t = text[lastIndex..];
if (t.Length > 0)
segments.Add(new ChatSegment(t, BuildAttr()));
}
return segments.Count > 0 ? new ChatLine(segments) : new ChatLine("");
}
[GeneratedRegex(@"\{(?:(X)|(?:(F|B):([0-9A-Fa-f]{6})))\}")]
private static partial Regex ColorTagRegex();
}
@@ -0,0 +1,113 @@
using System.Collections;
using System.Collections.Specialized;
using Terminal.Gui.Drawing;
using Terminal.Gui.Text;
using Terminal.Gui.Views;
using Attribute = Terminal.Gui.Drawing.Attribute;
namespace EchoHub.Client.UI.Chat;
/// <summary>
/// Custom list data source for chat messages with per-segment coloring.
/// </summary>
public class ChatListSource : IListDataSource
{
private readonly List<ChatLine> _lines = [];
public event NotifyCollectionChangedEventHandler? CollectionChanged;
public int Count => _lines.Count;
public int MaxItemLength { get; private set; }
public bool SuspendCollectionChangedEvent { get; set; }
public void Add(ChatLine line)
{
_lines.Add(line);
UpdateMaxLength(line);
RaiseCollectionChanged();
}
public void AddRange(IEnumerable<ChatLine> lines)
{
foreach (var line in lines)
{
_lines.Add(line);
UpdateMaxLength(line);
}
RaiseCollectionChanged();
}
public void InsertRange(int index, IEnumerable<ChatLine> lines)
{
var items = lines.ToList();
_lines.InsertRange(index, items);
foreach (var line in items)
UpdateMaxLength(line);
RaiseCollectionChanged();
}
public void Clear()
{
_lines.Clear();
MaxItemLength = 0;
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();
public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0)
{
listView.Move(Math.Max(col - viewportX, 0), row);
var chatLine = _lines[item];
var normalAttr = listView.GetAttributeForRole(VisualRole.Normal);
var mentionBg = chatLine.IsMention ? ChatColors.MentionHighlightAttr.Background : (Color?)null;
int charPos = 0;
int drawnChars = 0;
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 = attr with { Background = mentionBg.Value };
listView.SetAttribute(attr);
foreach (var grapheme in GraphemeHelper.GetGraphemes(segment.Text))
{
var cols = Math.Max(grapheme.GetColumns(), 1);
if (charPos >= viewportX && drawnChars + cols <= width)
{
listView.AddStr(grapheme);
drawnChars += cols;
}
charPos += cols;
}
}
var fillAttr = mentionBg.HasValue ? new Attribute(normalAttr.Foreground, mentionBg.Value) : normalAttr;
listView.SetAttribute(fillAttr);
for (int i = drawnChars; i < width; i++)
listView.AddStr(" ");
}
private void UpdateMaxLength(ChatLine line)
{
if (line.TextLength > MaxItemLength)
MaxItemLength = line.TextLength;
}
private void RaiseCollectionChanged()
{
if (!SuspendCollectionChangedEvent)
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
}
public void Dispose() { }
}
@@ -0,0 +1,400 @@
using System.Text.RegularExpressions;
using EchoHub.Client.UI.Helpers;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using Terminal.Gui.Drawing;
using Terminal.Gui.Text;
using Attribute = Terminal.Gui.Drawing.Attribute;
namespace EchoHub.Client.UI.Chat;
/// <summary>
/// Owns chat message storage, formatting, and mutation.
/// Fires <see cref="MessagesChanged"/> when a channel's message list is modified
/// so the UI layer can refresh.
/// </summary>
public sealed class ChatMessageManager
{
private readonly Dictionary<string, List<ChatLine>> _channelMessages = [];
private readonly Dictionary<string, int> _channelUnread = [];
private string _currentUser = string.Empty;
private string _currentChannel = string.Empty;
private int _chatWidth;
/// <summary>
/// Fired after any mutation to a channel's messages. Parameter is the channel name.
/// </summary>
public event Action<string>? MessagesChanged;
/// <summary>
/// The currently active channel (used for unread tracking and @mention detection).
/// </summary>
public string CurrentChannel
{
get => _currentChannel;
set => _currentChannel = value;
}
public string CurrentUser => _currentUser;
public void SetCurrentUser(string username) => _currentUser = username;
public void SetChatWidth(int width) => _chatWidth = width;
// ── Queries ──────────────────────────────────────────────────────
public List<ChatLine>? GetMessages(string channelName)
{
return _channelMessages.TryGetValue(channelName, out var messages) ? messages : null;
}
public int GetUnreadCount(string channelName)
{
return _channelUnread.TryGetValue(channelName, out var count) ? count : 0;
}
public void ClearUnread(string channelName)
{
_channelUnread[channelName] = 0;
}
internal Dictionary<string, int> GetUnreadCounts() => _channelUnread;
// ── Mutations ────────────────────────────────────────────────────
/// <summary>
/// Format and store a received message. Increments unread count if not the active channel.
/// </summary>
public void AddMessage(MessageDto message)
{
var lines = FormatMessage(message);
if (!_channelMessages.TryGetValue(message.ChannelName, out var messages))
{
messages = [];
_channelMessages[message.ChannelName] = messages;
}
foreach (var line in lines)
messages.Add(line);
if (message.ChannelName == _currentChannel)
{
MessagesChanged?.Invoke(message.ChannelName);
}
else
{
_channelUnread.TryGetValue(message.ChannelName, out var count);
_channelUnread[message.ChannelName] = count + 1;
MessagesChanged?.Invoke(message.ChannelName);
}
}
/// <summary>
/// Add a system/informational message to a channel with colored styling.
/// </summary>
public void AddSystemMessage(string channelName, string text)
{
if (!_channelMessages.TryGetValue(channelName, out var messages))
{
messages = [];
_channelMessages[channelName] = messages;
}
var time = DateTimeOffset.Now.ToString("HH:mm");
var textLines = text.Split('\n');
messages.Add(new ChatLine(
[
new($"[{time}] ", ChatColors.TimestampAttr),
new($"** {textLines[0].TrimEnd('\r')}", ChatColors.SystemAttr)
]));
var indent = new string(' ', $"[{time}] ** ".Length);
for (int i = 1; i < textLines.Length; i++)
{
var line = textLines[i].TrimEnd('\r');
if (string.IsNullOrWhiteSpace(line)) continue;
messages.Add(new ChatLine(
[
new($"{indent}{line}", ChatColors.SystemAttr)
]));
}
if (channelName == _currentChannel)
MessagesChanged?.Invoke(channelName);
}
/// <summary>
/// Add a status change message to a channel with colored styling.
/// </summary>
public void AddStatusMessage(string channelName, string username, string status)
{
var time = DateTimeOffset.Now.ToString("HH:mm");
var segments = new List<ChatSegment>
{
new($"[{time}] ", ChatColors.TimestampAttr),
new($"** {username} is now {status}", ChatColors.SystemAttr)
};
if (!_channelMessages.TryGetValue(channelName, out var messages))
{
messages = [];
_channelMessages[channelName] = messages;
}
messages.Add(new ChatLine(segments));
if (channelName == _currentChannel)
MessagesChanged?.Invoke(channelName);
}
/// <summary>
/// Remove all lines associated with a specific message ID.
/// </summary>
public void RemoveMessage(string channelName, Guid messageId)
{
if (_channelMessages.TryGetValue(channelName, out var messages))
{
messages.RemoveAll(l => l.MessageId == messageId);
if (channelName == _currentChannel)
MessagesChanged?.Invoke(channelName);
}
}
/// <summary>
/// Clear all messages from a specific channel.
/// </summary>
public void ClearChannelMessages(string channelName)
{
if (_channelMessages.TryGetValue(channelName, out var messages))
{
messages.Clear();
if (channelName == _currentChannel)
MessagesChanged?.Invoke(channelName);
}
}
/// <summary>
/// Load historical messages into a channel, replacing any existing messages.
/// </summary>
public void LoadHistory(string channelName, List<MessageDto> messages)
{
var formatted = messages.SelectMany(FormatMessage).ToList();
_channelMessages[channelName] = formatted;
if (channelName == _currentChannel)
MessagesChanged?.Invoke(channelName);
}
/// <summary>
/// Reset all message state (used on disconnect).
/// </summary>
public void ClearAll()
{
_channelMessages.Clear();
_channelUnread.Clear();
_currentChannel = string.Empty;
_currentUser = string.Empty;
}
// ── Formatting ───────────────────────────────────────────────────
private List<ChatLine> FormatMessage(MessageDto message)
{
var time = message.SentAt.ToLocalTime().ToString("HH:mm");
var senderName = message.SenderUsername + ":";
var senderColor = HexColorHelper.ParseHexColor(message.SenderNicknameColor);
var lines = new List<ChatLine>();
switch (message.Type)
{
case MessageType.Image:
lines.Add(BuildChatLine(time, senderName, senderColor, " [Image]"));
if (!string.IsNullOrWhiteSpace(message.Content))
{
foreach (var artLine in message.Content.Split('\n'))
{
var trimmed = artLine.TrimEnd('\r');
if (ChatLine.HasColorTags(trimmed))
lines.Add(ChatLine.FromColoredText(" " + trimmed));
else
lines.Add(new ChatLine($" {trimmed}"));
}
}
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 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:
default:
var displayContent = EmojiHelper.ReplaceEmoji(message.Content);
var contentLines = displayContent.Split('\n');
var firstLine = contentLines[0].TrimEnd('\r');
lines.Add(BuildChatLineWithMentions(time, senderName, senderColor, $" {firstLine}"));
var indent = new string(' ', $"[{time}] {senderName} ".Length);
for (int i = 1; i < contentLines.Length; i++)
{
var contText = $"{indent}{contentLines[i].TrimEnd('\r')}";
lines.Add(new ChatLine(ChatColors.SplitMentions(contText)));
}
if (message.Embeds is { Count: > 0 })
{
var chatWidth = _chatWidth > 0 ? _chatWidth : 80;
foreach (var embed in message.Embeds)
lines.AddRange(FormatEmbed(embed, indent, chatWidth));
}
break;
}
foreach (var line in lines)
line.MessageId = message.Id;
if (!string.IsNullOrEmpty(_currentUser) && message.Type == MessageType.Text)
{
var pattern = $@"@{Regex.Escape(_currentUser)}\b";
if (Regex.IsMatch(message.Content, pattern, RegexOptions.IgnoreCase))
{
foreach (var line in lines)
line.IsMention = true;
}
}
return lines;
}
private static ChatLine BuildChatLine(string time, string senderName, Attribute? senderColor, string suffix)
{
var segments = new List<ChatSegment>
{
new($"[{time}] ", ChatColors.TimestampAttr),
new(senderName, senderColor),
new(suffix, null)
};
return new ChatLine(segments);
}
private static ChatLine BuildChatLineColored(string time, string senderName, Attribute? senderColor, string suffix, Attribute suffixColor)
{
var segments = new List<ChatSegment>
{
new($"[{time}] ", ChatColors.TimestampAttr),
new(senderName, senderColor),
new(suffix, suffixColor)
};
return new ChatLine(segments);
}
private static ChatLine BuildChatLineWithMentions(string time, string senderName, Attribute? senderColor, string suffix)
{
var segments = new List<ChatSegment>
{
new($"[{time}] ", ChatColors.TimestampAttr),
new(senderName, senderColor),
};
segments.AddRange(ChatColors.SplitMentions(suffix));
return new ChatLine(segments);
}
private static List<ChatLine> FormatEmbed(EmbedDto embed, string indent, int chatWidth)
{
var lines = new List<ChatLine>();
const string border = "\u258f "; // ▏ + space
const int borderCols = 2;
int indentCols = indent.GetColumns();
int textWidth = chatWidth - indentCols - borderCols;
if (textWidth < 20) textWidth = 20;
void AddTextLine(string text, Attribute? color)
{
lines.Add(new ChatLine(
[
new ChatSegment(indent, null),
new ChatSegment(border, ChatColors.EmbedBorderAttr),
new ChatSegment(text, color)
]));
}
if (!string.IsNullOrWhiteSpace(embed.SiteName))
AddTextLine(embed.SiteName, ChatColors.EmbedBorderAttr);
if (!string.IsNullOrWhiteSpace(embed.Title))
{
foreach (var wrapped in WordWrap(embed.Title, textWidth))
AddTextLine(wrapped, ChatColors.EmbedTitleAttr);
}
if (!string.IsNullOrWhiteSpace(embed.Description))
{
foreach (var wrapped in WordWrap(embed.Description, textWidth))
AddTextLine(wrapped, ChatColors.EmbedDescAttr);
}
return lines;
}
private static List<string> WordWrap(string text, int maxCols)
{
if (maxCols <= 0)
return [text];
var result = new List<string>();
var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var currentLine = "";
foreach (var word in words)
{
var candidate = currentLine.Length == 0 ? word : currentLine + " " + word;
if (candidate.GetColumns() <= maxCols)
{
currentLine = candidate;
}
else
{
if (currentLine.Length > 0)
result.Add(currentLine);
currentLine = word;
}
}
if (currentLine.Length > 0)
result.Add(currentLine);
return result;
}
internal 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"
};
}
}
@@ -0,0 +1,8 @@
using Attribute = Terminal.Gui.Drawing.Attribute;
namespace EchoHub.Client.UI.Chat;
/// <summary>
/// A colored text segment within a chat line.
/// </summary>
public record ChatSegment(string Text, Attribute? Color);
@@ -0,0 +1,27 @@
using Terminal.Gui.Drawing;
using Terminal.Gui.Text;
using Terminal.Gui.Views;
namespace EchoHub.Client.UI.Chat;
/// <summary>
/// Shared rendering helpers for IListDataSource implementations.
/// </summary>
static class RenderHelpers
{
/// <summary>
/// Write text grapheme-by-grapheme to a ListView, respecting a width limit.
/// Returns the updated drawn-columns count.
/// </summary>
public static int WriteText(ListView lv, string text, int drawn, int maxWidth)
{
foreach (var grapheme in GraphemeHelper.GetGraphemes(text))
{
var cols = Math.Max(grapheme.GetColumns(), 1);
if (drawn + cols > maxWidth) break;
lv.AddStr(grapheme);
drawn += cols;
}
return drawn;
}
}
-523
View File
@@ -1,523 +0,0 @@
using System.Collections;
using System.Collections.Specialized;
using System.Text.RegularExpressions;
using Terminal.Gui.Drawing;
using Terminal.Gui.Text;
using Terminal.Gui.Views;
using Attribute = Terminal.Gui.Drawing.Attribute;
namespace EchoHub.Client.UI;
/// <summary>
/// A colored text segment within a chat line.
/// </summary>
public record ChatSegment(string Text, Attribute? Color);
/// <summary>
/// A single line in the chat, composed of colored segments.
/// </summary>
public partial class ChatLine
{
public List<ChatSegment> Segments { get; }
public int TextLength { get; }
public Guid? MessageId { get; set; }
public bool IsMention { get; set; }
public ChatLine(string plainText)
{
Segments = [new ChatSegment(plainText, null)];
TextLength = plainText.GetColumns();
}
public ChatLine(List<ChatSegment> segments)
{
Segments = segments;
TextLength = segments.Sum(s => s.Text.GetColumns());
}
public override string ToString() => string.Concat(Segments.Select(s => s.Text));
/// <summary>
/// Wrap this line into multiple lines that fit within the given width.
/// Continuation lines are indented with the specified number of spaces.
/// </summary>
public List<ChatLine> Wrap(int width, int continuationIndent = 0)
{
if (width <= 0 || TextLength <= width)
return [this];
var results = new List<ChatLine>();
var currentSegments = new List<ChatSegment>();
int col = 0;
foreach (var segment in Segments)
{
var text = segment.Text;
int chunkStart = 0;
int charPos = 0;
foreach (var grapheme in GraphemeHelper.GetGraphemes(text))
{
var graphemeCols = Math.Max(grapheme.GetColumns(), 1);
if (col + graphemeCols > width)
{
if (charPos > chunkStart)
currentSegments.Add(new ChatSegment(text[chunkStart..charPos], segment.Color));
results.Add(new ChatLine(currentSegments));
currentSegments = [];
if (continuationIndent > 0)
{
currentSegments.Add(new ChatSegment(new string(' ', continuationIndent), null));
col = continuationIndent;
}
else
{
col = 0;
}
chunkStart = charPos;
}
col += graphemeCols;
charPos += grapheme.Length;
}
if (chunkStart < text.Length)
currentSegments.Add(new ChatSegment(text[chunkStart..], segment.Color));
}
if (currentSegments.Count > 0)
results.Add(new ChatLine(currentSegments));
return results;
}
/// <summary>
/// Returns true if a line contains printable color tags.
/// </summary>
public static bool HasColorTags(string text) =>
text.Contains("{F:") || text.Contains("{B:") || text.Contains("{X}");
/// <summary>
/// Remove all color tags from text, returning only the visible characters.
/// </summary>
public static string StripColorTags(string text) =>
ColorTagRegex().Replace(text, "");
/// <summary>
/// Parse a string containing printable color tags into colored segments.
/// Format: {F:RRGGBB} (foreground), {B:RRGGBB} (background), {X} (reset).
/// </summary>
public static ChatLine FromColoredText(string text, Attribute? defaultAttr = null)
{
var segments = new List<ChatSegment>();
int lastIndex = 0;
Color? currentFg = null;
Color? currentBg = null;
var defaultFg = defaultAttr?.Foreground;
var defaultBg = defaultAttr?.Background ?? Color.Black;
Attribute? BuildAttr()
{
if (currentFg is null && currentBg is null) return defaultAttr;
var fg = currentFg ?? defaultFg ?? Color.White;
var bg = currentBg ?? defaultBg;
return new Attribute(fg, bg);
}
foreach (Match match in ColorTagRegex().Matches(text))
{
if (match.Index > lastIndex)
{
var t = text[lastIndex..match.Index];
if (t.Length > 0)
segments.Add(new ChatSegment(t, BuildAttr()));
}
if (match.Groups[1].Success)
{
currentFg = null;
currentBg = null;
}
else if (match.Groups[2].Success)
{
var hex = match.Groups[3].Value;
var r = Convert.ToInt32(hex[..2], 16);
var g = Convert.ToInt32(hex[2..4], 16);
var b = Convert.ToInt32(hex[4..6], 16);
if (match.Groups[2].Value == "F")
currentFg = new Color(r, g, b);
else
currentBg = new Color(r, g, b);
}
lastIndex = match.Index + match.Length;
}
if (lastIndex < text.Length)
{
var t = text[lastIndex..];
if (t.Length > 0)
segments.Add(new ChatSegment(t, BuildAttr()));
}
return segments.Count > 0 ? new ChatLine(segments) : new ChatLine("");
}
[GeneratedRegex(@"\{(?:(X)|(?:(F|B):([0-9A-Fa-f]{6})))\}")]
private static partial Regex ColorTagRegex();
}
/// <summary>
/// Custom list data source for chat messages with per-segment coloring.
/// </summary>
public class ChatListSource : IListDataSource
{
private readonly List<ChatLine> _lines = [];
public event NotifyCollectionChangedEventHandler? CollectionChanged;
public int Count => _lines.Count;
public int MaxItemLength { get; private set; }
public bool SuspendCollectionChangedEvent { get; set; }
public void Add(ChatLine line)
{
_lines.Add(line);
UpdateMaxLength(line);
RaiseCollectionChanged();
}
public void AddRange(IEnumerable<ChatLine> lines)
{
foreach (var line in lines)
{
_lines.Add(line);
UpdateMaxLength(line);
}
RaiseCollectionChanged();
}
public void InsertRange(int index, IEnumerable<ChatLine> lines)
{
var items = lines.ToList();
_lines.InsertRange(index, items);
foreach (var line in items)
UpdateMaxLength(line);
RaiseCollectionChanged();
}
public void Clear()
{
_lines.Clear();
MaxItemLength = 0;
RaiseCollectionChanged();
}
public bool IsMarked(int item) => false;
public void SetMark(int item, bool value) { }
public IList ToList() => _lines.Select(l => l.ToString()).ToList();
public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0)
{
listView.Move(Math.Max(col - viewportX, 0), row);
var chatLine = _lines[item];
var normalAttr = listView.GetAttributeForRole(VisualRole.Normal);
var mentionBg = chatLine.IsMention ? ChatColors.MentionHighlightAttr.Background : (Color?)null;
int charPos = 0;
int drawnChars = 0;
foreach (var segment in chatLine.Segments)
{
var attr = segment.Color ?? normalAttr;
if (mentionBg.HasValue)
attr = new Attribute(attr.Foreground, mentionBg.Value);
listView.SetAttribute(attr);
foreach (var grapheme in GraphemeHelper.GetGraphemes(segment.Text))
{
var cols = Math.Max(grapheme.GetColumns(), 1);
if (charPos >= viewportX && drawnChars + cols <= width)
{
listView.AddStr(grapheme);
drawnChars += cols;
}
charPos += cols;
}
}
var fillAttr = mentionBg.HasValue ? new Attribute(normalAttr.Foreground, mentionBg.Value) : normalAttr;
listView.SetAttribute(fillAttr);
for (int i = drawnChars; i < width; i++)
listView.AddStr(" ");
}
private void UpdateMaxLength(ChatLine line)
{
if (line.TextLength > MaxItemLength)
MaxItemLength = line.TextLength;
}
private void RaiseCollectionChanged()
{
if (!SuspendCollectionChangedEvent)
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
}
public void Dispose() { }
}
/// <summary>
/// Custom list data source for colored channel list rendering.
/// Active channel gets a > indicator, unread channels are bright with a count badge.
/// </summary>
public class ChannelListSource : IListDataSource
{
private readonly List<string> _channelNames = [];
private readonly Dictionary<string, int> _unreadCounts = [];
private string _activeChannel = string.Empty;
public event NotifyCollectionChangedEventHandler? CollectionChanged;
public int Count => _channelNames.Count;
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);
public void Update(List<string> channels, Dictionary<string, int> unread, string activeChannel)
{
_channelNames.Clear();
_channelNames.AddRange(channels);
_unreadCounts.Clear();
foreach (var kv in unread)
_unreadCounts[kv.Key] = kv.Value;
_activeChannel = activeChannel;
MaxItemLength = channels.Count > 0 ? channels.Max(c => c.Length + 6) : 0;
if (!SuspendCollectionChangedEvent)
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
}
public bool IsMarked(int item) => false;
public void SetMark(int item, bool value) { }
public IList ToList() => _channelNames.Select(n => $"#{n}").ToList();
public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0)
{
listView.Move(Math.Max(col - viewportX, 0), row);
var name = _channelNames[item];
var isActive = name == _activeChannel;
_unreadCounts.TryGetValue(name, out var unread);
var hasUnread = unread > 0;
var focusAttr = listView.GetAttributeForRole(VisualRole.Focus);
var prefix = isActive ? "> " : " ";
var channelText = $"#{name}";
var badge = hasUnread ? $" ({unread})" : "";
int drawnChars = 0;
if (selected)
{
listView.SetAttribute(focusAttr);
drawnChars = RenderHelpers.WriteText(listView, prefix + channelText + badge, drawnChars, width);
}
else
{
listView.SetAttribute(isActive ? ActiveAttr : NormalAttr);
drawnChars = RenderHelpers.WriteText(listView, prefix, drawnChars, width);
listView.SetAttribute(isActive ? ActiveAttr : hasUnread ? UnreadAttr : NormalAttr);
drawnChars = RenderHelpers.WriteText(listView, channelText, drawnChars, width);
if (hasUnread)
{
listView.SetAttribute(BadgeAttr);
drawnChars = RenderHelpers.WriteText(listView, badge, drawnChars, width);
}
}
var fillAttr = selected ? focusAttr : listView.GetAttributeForRole(VisualRole.Normal);
listView.SetAttribute(fillAttr);
for (int i = drawnChars; i < width; i++)
listView.AddStr(" ");
}
public void Dispose() { }
}
/// <summary>
/// Custom list data source for the online users panel with per-user nickname colors.
/// </summary>
public class UserListSource : IListDataSource
{
private readonly List<(string Text, Attribute? NameColor)> _users = [];
public event NotifyCollectionChangedEventHandler? CollectionChanged;
public int Count => _users.Count;
public int MaxItemLength { get; private set; }
public bool SuspendCollectionChangedEvent { get; set; }
public void Update(List<(string Text, Attribute? NameColor)> users)
{
_users.Clear();
_users.AddRange(users);
MaxItemLength = users.Count > 0 ? users.Max(u => u.Text.GetColumns()) : 0;
if (!SuspendCollectionChangedEvent)
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
}
public bool IsMarked(int item) => false;
public void SetMark(int item, bool value) { }
public IList ToList() => _users.Select(u => u.Text).ToList();
public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0)
{
listView.Move(Math.Max(col - viewportX, 0), row);
var (text, nameColor) = _users[item];
var normalAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal);
// Find where the name starts (after status icon + space + optional role badge)
// Format: "● ★Username" or "● Username"
var graphemes = GraphemeHelper.GetGraphemes(text).ToList();
int nameStart = 0;
while (nameStart < graphemes.Count)
{
var g = graphemes[nameStart];
if (g.Length > 0 && (char.IsLetterOrDigit(g[0]) || g[0] == '_'))
break;
nameStart++;
}
int drawnChars = 0;
// Draw prefix (status icon + role badge) in normal color
listView.SetAttribute(normalAttr);
for (int i = 0; i < nameStart; i++)
{
var cols = Math.Max(graphemes[i].GetColumns(), 1);
if (drawnChars + cols > width) break;
listView.AddStr(graphemes[i]);
drawnChars += cols;
}
// Draw name in nickname color
var userAttr = selected ? normalAttr : nameColor ?? normalAttr;
listView.SetAttribute(userAttr);
for (int i = nameStart; i < graphemes.Count; i++)
{
var cols = Math.Max(graphemes[i].GetColumns(), 1);
if (drawnChars + cols > width) break;
listView.AddStr(graphemes[i]);
drawnChars += cols;
}
// Fill rest
listView.SetAttribute(normalAttr);
for (int i = drawnChars; i < width; i++)
listView.AddStr(" ");
}
public void Dispose() { }
}
/// <summary>
/// Shared rendering helpers for IListDataSource implementations.
/// </summary>
static class RenderHelpers
{
/// <summary>
/// Write text grapheme-by-grapheme to a ListView, respecting a width limit.
/// Returns the updated drawn-columns count.
/// </summary>
public static int WriteText(ListView lv, string text, int drawn, int maxWidth)
{
foreach (var grapheme in GraphemeHelper.GetGraphemes(text))
{
var cols = Math.Max(grapheme.GetColumns(), 1);
if (drawn + cols > maxWidth) break;
lv.AddStr(grapheme);
drawn += cols;
}
return drawn;
}
}
/// <summary>
/// Shared color attributes for chat rendering (timestamps, system messages).
/// </summary>
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 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);
/// <summary>
/// Split text around @mentions, giving each @word the MentionTextAttr accent color.
/// Non-mention text uses the provided default color.
/// </summary>
public static List<ChatSegment> SplitMentions(string text, Attribute? defaultColor = null)
{
var segments = new List<ChatSegment>();
int lastIndex = 0;
foreach (Match match in MentionRegex().Matches(text))
{
if (match.Index > lastIndex)
segments.Add(new ChatSegment(text[lastIndex..match.Index], defaultColor));
segments.Add(new ChatSegment(match.Value, MentionTextAttr));
lastIndex = match.Index + match.Length;
}
if (lastIndex < text.Length)
segments.Add(new ChatSegment(text[lastIndex..], defaultColor));
return segments;
}
[GeneratedRegex(@"@[\w-]+")]
private static partial Regex MentionRegex();
}
/// <summary>
/// Helper to parse hex colors to Terminal.Gui Attributes.
/// </summary>
public static class ColorHelper
{
public static Attribute? ParseHexColor(string? hex)
{
if (string.IsNullOrWhiteSpace(hex))
return null;
hex = hex.TrimStart('#');
if (hex.Length != 6)
return null;
try
{
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);
}
catch
{
return null;
}
}
}
@@ -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.Dialogs;
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;
}
}
@@ -4,12 +4,14 @@ using Terminal.Gui.App;
using Terminal.Gui.Views; using Terminal.Gui.Views;
using Terminal.Gui.ViewBase; using Terminal.Gui.ViewBase;
namespace EchoHub.Client.UI; namespace EchoHub.Client.UI.Dialogs;
/// <summary> /// <summary>
/// Result returned from the connect dialog. /// Result returned from the connect dialog.
/// </summary> /// </summary>
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);
/// <summary> /// <summary>
/// A Terminal.Gui dialog for entering server connection and authentication details. /// A Terminal.Gui dialog for entering server connection and authentication details.
@@ -27,11 +29,12 @@ public sealed class ConnectDialog
savedServers ??= []; savedServers ??= [];
var hasSavedServers = savedServers.Count > 0; 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 }; var dialog = new Dialog { Title = "Connect to Server", Width = 60, Height = dialogHeight };
int yOffset = 0; int yOffset = 0;
SavedServer? selectedSavedServer = null;
// -- Saved Servers section (if any) ----------------------------------- // -- Saved Servers section (if any) -----------------------------------
ListView? savedServerList = null; ListView? savedServerList = null;
@@ -46,7 +49,11 @@ public sealed class ConnectDialog
dialog.Add(savedLabel); dialog.Add(savedLabel);
var serverDisplayNames = savedServers 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(); .ToList();
savedServerList = new ListView savedServerList = new ListView
@@ -115,17 +122,34 @@ public sealed class ConnectDialog
Secret = true 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 var displayLabel = new Label
{ {
Text = "Display Name:", Text = "Display Name:",
X = 1, X = 1,
Y = yOffset + 7 Y = yOffset + 9
}; };
var displayField = new TextField var displayField = new TextField
{ {
Text = "", Text = "",
X = 15, X = 15,
Y = yOffset + 7, Y = yOffset + 9,
Width = Dim.Fill(2) Width = Dim.Fill(2)
}; };
@@ -134,21 +158,21 @@ public sealed class ConnectDialog
Text = "Login", Text = "Login",
IsDefault = true, IsDefault = true,
X = Pos.Center() - 20, X = Pos.Center() - 20,
Y = yOffset + 9 Y = yOffset + 11
}; };
var registerButton = new Button var registerButton = new Button
{ {
Text = "Register", Text = "Register",
X = Pos.Center() - 5, X = Pos.Center() - 5,
Y = yOffset + 9 Y = yOffset + 11
}; };
var cancelButton = new Button var cancelButton = new Button
{ {
Text = "Cancel", Text = "Cancel",
X = Pos.Center() + 10, X = Pos.Center() + 10,
Y = yOffset + 9 Y = yOffset + 11
}; };
// Wire saved server selection to auto-fill fields // Wire saved server selection to auto-fill fields
@@ -159,15 +183,32 @@ public sealed class ConnectDialog
var index = e.NewValue; var index = e.NewValue;
if (index.HasValue && index.Value >= 0 && index.Value < savedServers.Count) if (index.HasValue && index.Value >= 0 && index.Value < savedServers.Count)
{ {
var server = savedServers[index.Value]; selectedSavedServer = savedServers[index.Value];
urlField.Text = server.Url; urlField.Text = selectedSavedServer.Url;
userField.Text = server.Username ?? ""; 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 // Pre-fill with the first saved server
selectedSavedServer = savedServers[0];
urlField.Text = savedServers[0].Url; urlField.Text = savedServers[0].Url;
userField.Text = savedServers[0].Username ?? ""; 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) => loginButton.Accepting += (s, e) =>
@@ -175,15 +216,34 @@ public sealed class ConnectDialog
var url = urlField.Text?.Trim() ?? string.Empty; var url = urlField.Text?.Trim() ?? string.Empty;
var user = userField.Text?.Trim() ?? string.Empty; var user = userField.Text?.Trim() ?? string.Empty;
var pass = passField.Text ?? 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; e.Handled = true;
return; 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; e.Handled = true;
app.RequestStop(); app.RequestStop();
}; };
@@ -193,6 +253,7 @@ public sealed class ConnectDialog
var url = urlField.Text?.Trim() ?? string.Empty; var url = urlField.Text?.Trim() ?? string.Empty;
var user = userField.Text?.Trim() ?? string.Empty; var user = userField.Text?.Trim() ?? string.Empty;
var pass = passField.Text ?? 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) || string.IsNullOrEmpty(pass))
{ {
@@ -201,7 +262,7 @@ public sealed class ConnectDialog
return; return;
} }
result = new ConnectDialogResult(url, user, pass, IsRegister: true); result = new ConnectDialogResult(url, user, pass, IsRegister: true, rememberMe, SavedRefreshToken: null);
e.Handled = true; e.Handled = true;
app.RequestStop(); app.RequestStop();
}; };
@@ -214,7 +275,8 @@ public sealed class ConnectDialog
}; };
dialog.Add(urlLabel, urlField, userLabel, userField, passLabel, passField, 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) if (hasSavedServers && savedServerList is not null)
savedServerList.SetFocus(); savedServerList.SetFocus();
@@ -2,7 +2,7 @@ using Terminal.Gui.App;
using Terminal.Gui.Views; using Terminal.Gui.Views;
using Terminal.Gui.ViewBase; using Terminal.Gui.ViewBase;
namespace EchoHub.Client.UI; namespace EchoHub.Client.UI.Dialogs;
public record CreateChannelResult(string Name, string? Topic, bool IsPublic); public record CreateChannelResult(string Name, string? Topic, bool IsPublic);
@@ -1,10 +1,11 @@
using EchoHub.Client.UI.Helpers;
using Terminal.Gui.App; using Terminal.Gui.App;
using Terminal.Gui.Views;
using Terminal.Gui.ViewBase;
using Terminal.Gui.Drawing; using Terminal.Gui.Drawing;
using Terminal.Gui.ViewBase;
using Terminal.Gui.Views;
using Attribute = Terminal.Gui.Drawing.Attribute; using Attribute = Terminal.Gui.Drawing.Attribute;
namespace EchoHub.Client.UI; namespace EchoHub.Client.UI.Dialogs;
/// <summary> /// <summary>
/// Result returned from the profile edit dialog. /// Result returned from the profile edit dialog.
@@ -243,32 +244,13 @@ public sealed class ProfileEditDialog
return; return;
} }
var color = ParseHexToTrueColor(hexColor.Trim()); var color = HexColorHelper.ParseHexToColor(hexColor.Trim(), Color.White);
preview.SetScheme(new Scheme preview.SetScheme(new Scheme
{ {
Normal = new Attribute(color, Color.Blue) Normal = new Attribute(color, Color.Blue)
}); });
} }
/// <summary>
/// Parses a hex color string to a Terminal.Gui TrueColor Color.
/// V2 supports TrueColor via new Color(r, g, b).
/// </summary>
private static Color ParseHexToTrueColor(string hex)
{
if (hex.StartsWith('#'))
hex = hex[1..];
if (hex.Length != 6 || !int.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null, out var rgb))
return Color.White;
int r = (rgb >> 16) & 0xFF;
int g = (rgb >> 8) & 0xFF;
int b = rgb & 0xFF;
return new Color(r, g, b);
}
private static string? NullIfEmpty(string? value) => private static string? NullIfEmpty(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value; string.IsNullOrWhiteSpace(value) ? null : value;
} }
@@ -1,12 +1,14 @@
using Terminal.Gui.App; using EchoHub.Client.UI.Chat;
using Terminal.Gui.Views; using EchoHub.Client.UI.Helpers;
using Terminal.Gui.ViewBase;
using Terminal.Gui.Drawing;
using EchoHub.Core.DTOs; using EchoHub.Core.DTOs;
using EchoHub.Core.Models; using EchoHub.Core.Models;
using Terminal.Gui.App;
using Terminal.Gui.Drawing;
using Terminal.Gui.ViewBase;
using Terminal.Gui.Views;
using Attribute = Terminal.Gui.Drawing.Attribute; using Attribute = Terminal.Gui.Drawing.Attribute;
namespace EchoHub.Client.UI; namespace EchoHub.Client.UI.Dialogs;
/// <summary> /// <summary>
/// Action selected by the user in their own profile dialog. /// Action selected by the user in their own profile dialog.
@@ -111,7 +113,7 @@ public sealed class ProfileViewDialog
// Color // Color
var colorLabel = new Label { Text = "Color:", X = 1, Y = row }; var colorLabel = new Label { Text = "Color:", X = 1, Y = row };
var colorValue = new Label { Text = profile.NicknameColor ?? "-", X = 14, Y = row }; var colorValue = new Label { Text = profile.NicknameColor ?? "-", X = 14, Y = row };
if (ColorHelper.ParseHexColor(profile.NicknameColor) is { } colorAttr) if (HexColorHelper.ParseHexColor(profile.NicknameColor) is { } colorAttr)
colorValue.SetScheme(new Scheme { Normal = colorAttr }); colorValue.SetScheme(new Scheme { Normal = colorAttr });
dialog.Add(colorLabel, colorValue); dialog.Add(colorLabel, colorValue);
row++; row++;
@@ -3,7 +3,7 @@ using Terminal.Gui.Views;
using Terminal.Gui.ViewBase; using Terminal.Gui.ViewBase;
using EchoHub.Core.Models; using EchoHub.Core.Models;
namespace EchoHub.Client.UI; namespace EchoHub.Client.UI.Dialogs;
/// <summary> /// <summary>
/// Result returned from the status dialog. /// Result returned from the status dialog.
@@ -0,0 +1,58 @@
using Terminal.Gui.App;
using Terminal.Gui.Views;
using Terminal.Gui.ViewBase;
namespace EchoHub.Client.UI.Dialogs;
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;
}
}
@@ -0,0 +1,61 @@
using Terminal.Gui.App;
using Terminal.Gui.Views;
using Terminal.Gui.ViewBase;
namespace EchoHub.Client.UI.Dialogs;
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();
}
}
@@ -1,7 +1,7 @@
using System.Globalization; using System.Globalization;
using System.Text; using System.Text;
namespace EchoHub.Client.UI; namespace EchoHub.Client.UI.Helpers;
/// <summary> /// <summary>
/// Converts emoji grapheme clusters to text shortcodes for safe TUI rendering. /// Converts emoji grapheme clusters to text shortcodes for safe TUI rendering.
@@ -0,0 +1,58 @@
using Terminal.Gui.Drawing;
using Attribute = Terminal.Gui.Drawing.Attribute;
namespace EchoHub.Client.UI.Helpers;
/// <summary>
/// Helper to parse hex colors to Terminal.Gui Attributes.
/// </summary>
public static class HexColorHelper
{
public static Attribute? ParseHexColor(string? hex)
{
if (string.IsNullOrWhiteSpace(hex))
return null;
hex = hex.TrimStart('#');
if (hex.Length != 6)
return null;
try
{
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.None);
}
catch
{
return null;
}
}
/// <summary>
/// Parse a hex color string (with or without #) to a Terminal.Gui Color.
/// Returns <paramref name="fallback"/> if parsing fails.
/// </summary>
public static Color ParseHexToColor(string? hex, Color fallback = default)
{
if (string.IsNullOrWhiteSpace(hex))
return fallback;
var trimmed = hex.TrimStart('#');
if (trimmed.Length != 6)
return fallback;
try
{
var r = Convert.ToInt32(trimmed[..2], 16);
var g = Convert.ToInt32(trimmed[2..4], 16);
var b = Convert.ToInt32(trimmed[4..6], 16);
return new Color(r, g, b);
}
catch
{
return fallback;
}
}
}
@@ -0,0 +1,96 @@
using System.Collections;
using System.Collections.Specialized;
using EchoHub.Client.UI.Chat;
using Terminal.Gui.Drawing;
using Terminal.Gui.Text;
using Terminal.Gui.Views;
using Attribute = Terminal.Gui.Drawing.Attribute;
namespace EchoHub.Client.UI.ListSources;
/// <summary>
/// Custom list data source for colored channel list rendering.
/// Active channel gets a > indicator, unread channels are bright with a count badge.
/// </summary>
public class ChannelListSource : IListDataSource
{
private readonly List<string> _channelNames = [];
private readonly Dictionary<string, int> _unreadCounts = [];
private string _activeChannel = string.Empty;
public event NotifyCollectionChangedEventHandler? CollectionChanged;
public int Count => _channelNames.Count;
public int MaxItemLength { get; private set; }
public bool SuspendCollectionChangedEvent { get; set; }
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<string> channels, Dictionary<string, int> unread, string activeChannel)
{
_channelNames.Clear();
_channelNames.AddRange(channels);
_unreadCounts.Clear();
foreach (var kv in unread)
_unreadCounts[kv.Key] = kv.Value;
_activeChannel = activeChannel;
MaxItemLength = channels.Count > 0 ? channels.Max(c => c.Length + 6) : 0;
if (!SuspendCollectionChangedEvent)
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
}
public bool IsMarked(int item) => false;
public void SetMark(int item, bool value) { }
public IList ToList() => _channelNames.Select(n => $"#{n}").ToList();
public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0)
{
listView.Move(Math.Max(col - viewportX, 0), row);
var name = _channelNames[item];
var isActive = name == _activeChannel;
_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)
{
listView.SetAttribute(focusAttr);
drawnChars = RenderHelpers.WriteText(listView, prefix + channelText + badge, drawnChars, width);
}
else
{
listView.SetAttribute(Resolve(isActive ? ActiveAttr : NormalAttr));
drawnChars = RenderHelpers.WriteText(listView, prefix, drawnChars, width);
listView.SetAttribute(Resolve(isActive ? ActiveAttr : hasUnread ? UnreadAttr : NormalAttr));
drawnChars = RenderHelpers.WriteText(listView, channelText, drawnChars, width);
if (hasUnread)
{
listView.SetAttribute(Resolve(BadgeAttr));
drawnChars = RenderHelpers.WriteText(listView, badge, drawnChars, width);
}
}
var fillAttr = selected ? focusAttr : normalAttr;
listView.SetAttribute(fillAttr);
for (int i = drawnChars; i < width; i++)
listView.AddStr(" ");
}
public void Dispose() { }
}
@@ -0,0 +1,84 @@
using System.Collections;
using System.Collections.Specialized;
using Terminal.Gui.Drawing;
using Terminal.Gui.Text;
using Terminal.Gui.Views;
using Attribute = Terminal.Gui.Drawing.Attribute;
namespace EchoHub.Client.UI.ListSources;
/// <summary>
/// Custom list data source for the online users panel with per-user nickname colors.
/// </summary>
public class UserListSource : IListDataSource
{
private readonly List<(string Text, Attribute? NameColor)> _users = [];
public event NotifyCollectionChangedEventHandler? CollectionChanged;
public int Count => _users.Count;
public int MaxItemLength { get; private set; }
public bool SuspendCollectionChangedEvent { get; set; }
public void Update(List<(string Text, Attribute? NameColor)> users)
{
_users.Clear();
_users.AddRange(users);
MaxItemLength = users.Count > 0 ? users.Max(u => u.Text.GetColumns()) : 0;
if (!SuspendCollectionChangedEvent)
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
}
public bool IsMarked(int item) => false;
public void SetMark(int item, bool value) { }
public IList ToList() => _users.Select(u => u.Text).ToList();
public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0)
{
listView.Move(Math.Max(col - viewportX, 0), row);
var (text, nameColor) = _users[item];
var normalAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal);
// Find where the name starts (after status icon + space + optional role badge)
// Format: "● ★Username" or "● Username"
var graphemes = GraphemeHelper.GetGraphemes(text).ToList();
int nameStart = 0;
while (nameStart < graphemes.Count)
{
var g = graphemes[nameStart];
if (g.Length > 0 && (char.IsLetterOrDigit(g[0]) || g[0] == '_'))
break;
nameStart++;
}
int drawnChars = 0;
// Draw prefix (status icon + role badge) in normal color
listView.SetAttribute(normalAttr);
for (int i = 0; i < nameStart; i++)
{
var cols = Math.Max(graphemes[i].GetColumns(), 1);
if (drawnChars + cols > width) break;
listView.AddStr(graphemes[i]);
drawnChars += cols;
}
// Draw name in nickname color
var userAttr = selected ? normalAttr : nameColor ?? normalAttr;
listView.SetAttribute(userAttr);
for (int i = nameStart; i < graphemes.Count; i++)
{
var cols = Math.Max(graphemes[i].GetColumns(), 1);
if (drawnChars + cols > width) break;
listView.AddStr(graphemes[i]);
drawnChars += cols;
}
// Fill rest
listView.SetAttribute(normalAttr);
for (int i = drawnChars; i < width; i++)
listView.AddStr(" ");
}
public void Dispose() { }
}
+154 -351
View File
@@ -1,5 +1,7 @@
using System.Text.RegularExpressions;
using EchoHub.Client.Themes; using EchoHub.Client.Themes;
using EchoHub.Client.UI.Chat;
using EchoHub.Client.UI.Helpers;
using EchoHub.Client.UI.ListSources;
using EchoHub.Core.DTOs; using EchoHub.Core.DTOs;
using EchoHub.Core.Models; using EchoHub.Core.Models;
using Terminal.Gui.App; using Terminal.Gui.App;
@@ -55,12 +57,11 @@ public sealed class MainWindow : Runnable
]; ];
private readonly List<string> _channelNames = []; private readonly List<string> _channelNames = [];
private readonly Dictionary<string, List<ChatLine>> _channelMessages = [];
private readonly Dictionary<string, int> _channelUnread = [];
private readonly Dictionary<string, string?> _channelTopics = []; private readonly Dictionary<string, string?> _channelTopics = [];
private readonly Dictionary<string, bool> _channelPublic = [];
private readonly ChannelListSource _channelListSource; private readonly ChannelListSource _channelListSource;
private string _currentChannel = string.Empty; private readonly ChatMessageManager _messageManager;
private string _currentUser = string.Empty; private string _connectionStatus = "Disconnected";
private int _lastChatWidth; private int _lastChatWidth;
/// <summary> /// <summary>
@@ -83,6 +84,11 @@ public sealed class MainWindow : Runnable
/// </summary> /// </summary>
public event Action? OnDisconnectRequested; public event Action? OnDisconnectRequested;
/// <summary>
/// Fired when the user requests to logout (disconnect + revoke session).
/// </summary>
public event Action? OnLogoutRequested;
/// <summary> /// <summary>
/// Fired when the user requests to open their profile panel. /// Fired when the user requests to open their profile panel.
/// </summary> /// </summary>
@@ -113,9 +119,21 @@ public sealed class MainWindow : Runnable
/// </summary> /// </summary>
public event Action? OnDeleteChannelRequested; public event Action? OnDeleteChannelRequested;
public MainWindow(IApplication app) /// <summary>
/// Fired when the user activates (Enter/click) an audio message. Parameters: attachmentUrl, fileName.
/// </summary>
public event Action<string, string>? OnAudioPlayRequested;
/// <summary>
/// Fired when the user activates (Enter/click) a file message. Parameters: attachmentUrl, fileName.
/// </summary>
public event Action<string, string>? OnFileDownloadRequested;
public MainWindow(IApplication app, ChatMessageManager messageManager)
{ {
_app = app; _app = app;
_messageManager = messageManager;
_messageManager.MessagesChanged += OnMessagesChanged;
Arrangement = ViewArrangement.Fixed; Arrangement = ViewArrangement.Fixed;
// Menu bar at the top // Menu bar at the top
@@ -175,6 +193,7 @@ public sealed class MainWindow : Runnable
Height = Dim.Fill() Height = Dim.Fill()
}; };
_messageList.Source = new ChatListSource(); _messageList.Source = new ChatListSource();
_messageList.Accepting += OnMessageListAccepting;
_chatFrame.Add(_messageList); _chatFrame.Add(_messageList);
Add(_chatFrame); Add(_chatFrame);
@@ -223,16 +242,17 @@ public sealed class MainWindow : Runnable
_usersFrame.Add(_usersList); _usersFrame.Add(_usersList);
Add(_usersFrame); Add(_usersFrame);
// Status bar at the very bottom // Status bar at the very bottom — custom drawing for colored connection state
_statusLabel = new Label _statusLabel = new Label
{ {
Text = "Disconnected", Text = "",
X = 0, X = 0,
Y = Pos.AnchorEnd(1), Y = Pos.AnchorEnd(1),
Width = Dim.Fill(), Width = Dim.Fill(),
Height = 1 Height = 1
}; };
_statusLabel.SetScheme(SchemeManager.GetScheme("Menu")); _statusLabel.SetScheme(SchemeManager.GetScheme("Menu"));
_statusLabel.DrawingContent += OnStatusBarDrawContent;
Add(_statusLabel); Add(_statusLabel);
// Apply our custom color schemes to all views // Apply our custom color schemes to all views
@@ -308,6 +328,7 @@ public sealed class MainWindow : Runnable
{ {
new MenuItem("_Connect...", "Connect to a server", () => OnConnectRequested?.Invoke(), Key.Empty), new MenuItem("_Connect...", "Connect to a server", () => OnConnectRequested?.Invoke(), Key.Empty),
new MenuItem("_Disconnect", "Disconnect from server", () => OnDisconnectRequested?.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 Line(),
new MenuItem("New C_hannel...", "Create a new channel", () => OnCreateChannelRequested?.Invoke(), Key.Empty), 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), new MenuItem("_Delete Channel", "Delete the current channel", () => OnDeleteChannelRequested?.Invoke(), Key.Empty),
@@ -352,7 +373,7 @@ public sealed class MainWindow : Runnable
if (index.HasValue && index.Value >= 0 && index.Value < _channelNames.Count) if (index.HasValue && index.Value >= 0 && index.Value < _channelNames.Count)
{ {
var channelName = _channelNames[index.Value]; var channelName = _channelNames[index.Value];
if (channelName != _currentChannel) if (channelName != _messageManager.CurrentChannel)
{ {
SwitchToChannel(channelName); SwitchToChannel(channelName);
OnChannelSelected?.Invoke(channelName); OnChannelSelected?.Invoke(channelName);
@@ -360,6 +381,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) private void OnInputKeyDown(object? sender, Key e)
{ {
if (e.KeyCode == TabKey.KeyCode) if (e.KeyCode == TabKey.KeyCode)
@@ -375,9 +421,9 @@ public sealed class MainWindow : Runnable
else if (e.KeyCode == EnterKey.KeyCode) else if (e.KeyCode == EnterKey.KeyCode)
{ {
var text = _inputField.Text?.Trim() ?? string.Empty; var text = _inputField.Text?.Trim() ?? string.Empty;
if (!string.IsNullOrEmpty(text) && !string.IsNullOrEmpty(_currentChannel)) if (!string.IsNullOrEmpty(text) && !string.IsNullOrEmpty(_messageManager.CurrentChannel))
{ {
OnMessageSubmitted?.Invoke(_currentChannel, text); OnMessageSubmitted?.Invoke(_messageManager.CurrentChannel, text);
_inputField.Text = string.Empty; _inputField.Text = string.Empty;
} }
e.Handled = true; e.Handled = true;
@@ -454,6 +500,7 @@ public sealed class MainWindow : Runnable
if (newWidth > 0 && newWidth != _lastChatWidth) if (newWidth > 0 && newWidth != _lastChatWidth)
{ {
_lastChatWidth = newWidth; _lastChatWidth = newWidth;
_messageManager.SetChatWidth(newWidth);
RefreshMessages(); RefreshMessages();
} }
} }
@@ -472,126 +519,13 @@ public sealed class MainWindow : Runnable
} }
} }
/// <summary> private void OnMessagesChanged(string channelName)
/// Add a message to the specified channel's message list and refresh if it is the current channel.
/// Tracks unread count for non-active channels.
/// </summary>
public void AddMessage(MessageDto message)
{
var lines = FormatMessage(message);
if (!_channelMessages.TryGetValue(message.ChannelName, out var messages))
{
messages = [];
_channelMessages[message.ChannelName] = messages;
}
foreach (var line in lines)
{
messages.Add(line);
}
if (message.ChannelName == _currentChannel)
{ {
if (channelName == _messageManager.CurrentChannel)
RefreshMessages(); RefreshMessages();
}
else else
{
// Increment unread count for non-active channels
_channelUnread.TryGetValue(message.ChannelName, out var count);
_channelUnread[message.ChannelName] = count + 1;
RefreshChannelList(); RefreshChannelList();
} }
}
/// <summary>
/// Add a system/informational message to a channel with colored styling.
/// </summary>
public void AddSystemMessage(string channelName, string text)
{
if (!_channelMessages.TryGetValue(channelName, out var messages))
{
messages = [];
_channelMessages[channelName] = messages;
}
var time = DateTimeOffset.Now.ToString("HH:mm");
var textLines = text.Split('\n');
// First line gets timestamp prefix
messages.Add(new ChatLine(
[
new($"[{time}] ", ChatColors.TimestampAttr),
new($"** {textLines[0].TrimEnd('\r')}", ChatColors.SystemAttr)
]));
// Continuation lines are indented to align
var indent = new string(' ', $"[{time}] ** ".Length);
for (int i = 1; i < textLines.Length; i++)
{
var line = textLines[i].TrimEnd('\r');
if (string.IsNullOrWhiteSpace(line)) continue;
messages.Add(new ChatLine(
[
new($"{indent}{line}", ChatColors.SystemAttr)
]));
}
if (channelName == _currentChannel)
{
RefreshMessages();
}
}
/// <summary>
/// Add a status change message to a channel with colored styling.
/// </summary>
public void AddStatusMessage(string channelName, string username, string status)
{
var time = DateTimeOffset.Now.ToString("HH:mm");
var segments = new List<ChatSegment>
{
new($"[{time}] ", ChatColors.TimestampAttr),
new($"** {username} is now {status}", ChatColors.SystemAttr)
};
if (!_channelMessages.TryGetValue(channelName, out var messages))
{
messages = [];
_channelMessages[channelName] = messages;
}
messages.Add(new ChatLine(segments));
if (channelName == _currentChannel)
{
RefreshMessages();
}
}
/// <summary>
/// Remove all lines associated with a specific message ID.
/// </summary>
public void RemoveMessage(string channelName, Guid messageId)
{
if (_channelMessages.TryGetValue(channelName, out var messages))
{
messages.RemoveAll(l => l.MessageId == messageId);
if (channelName == _currentChannel)
RefreshMessages();
}
}
/// <summary>
/// Clear all messages from a specific channel.
/// </summary>
public void ClearChannelMessages(string channelName)
{
if (_channelMessages.TryGetValue(channelName, out var messages))
{
messages.Clear();
if (channelName == _currentChannel)
RefreshMessages();
}
}
/// <summary> /// <summary>
/// Set the list of available channels, storing topics, and refresh the channel list view. /// Set the list of available channels, storing topics, and refresh the channel list view.
@@ -600,12 +534,12 @@ public sealed class MainWindow : Runnable
{ {
_channelNames.Clear(); _channelNames.Clear();
_channelTopics.Clear(); _channelTopics.Clear();
_channelPublic.Clear();
foreach (var ch in channels) foreach (var ch in channels)
{ {
_channelNames.Add(ch.Name); _channelNames.Add(ch.Name);
_channelTopics[ch.Name] = ch.Topic; _channelTopics[ch.Name] = ch.Topic;
if (!_channelMessages.ContainsKey(ch.Name)) _channelPublic[ch.Name] = ch.IsPublic;
_channelMessages[ch.Name] = [];
} }
RefreshChannelList(); RefreshChannelList();
} }
@@ -613,14 +547,15 @@ public sealed class MainWindow : Runnable
/// <summary> /// <summary>
/// Ensure a channel exists in the left panel list (used for private channels joined via /join). /// Ensure a channel exists in the left panel list (used for private channels joined via /join).
/// </summary> /// </summary>
public void EnsureChannelInList(string channelName) public void EnsureChannelInList(string channelName, bool? isPublic = null)
{ {
if (isPublic.HasValue)
_channelPublic[channelName] = isPublic.Value;
if (_channelNames.Contains(channelName)) if (_channelNames.Contains(channelName))
return; return;
_channelNames.Add(channelName); _channelNames.Add(channelName);
if (!_channelMessages.ContainsKey(channelName))
_channelMessages[channelName] = [];
RefreshChannelList(); RefreshChannelList();
} }
@@ -631,6 +566,7 @@ public sealed class MainWindow : Runnable
{ {
_channelNames.Remove(channelName); _channelNames.Remove(channelName);
_channelTopics.Remove(channelName); _channelTopics.Remove(channelName);
_channelPublic.Remove(channelName);
RefreshChannelList(); RefreshChannelList();
} }
@@ -640,7 +576,7 @@ public sealed class MainWindow : Runnable
public void SetChannelTopic(string channelName, string? topic) public void SetChannelTopic(string channelName, string? topic)
{ {
_channelTopics[channelName] = topic; _channelTopics[channelName] = topic;
if (channelName == _currentChannel) if (channelName == _messageManager.CurrentChannel)
UpdateTopicBar(); UpdateTopicBar();
} }
@@ -657,23 +593,92 @@ public sealed class MainWindow : Runnable
/// </summary> /// </summary>
public void UpdateStatusBar(string status) public void UpdateStatusBar(string status)
{ {
var userPart = string.IsNullOrEmpty(_currentUser) ? "" : $" \u2502 User: {_currentUser}"; _connectionStatus = status;
var channelPart = string.IsNullOrEmpty(_currentChannel) ? "" : $" \u2502 #{_currentChannel}"; _statusLabel.SetNeedsDraw();
_statusLabel.Text = $" v{AppVersion} \u2502 {status}{userPart}{channelPart}"; }
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
var currentUser = _messageManager.CurrentUser;
if (!string.IsNullOrEmpty(currentUser))
Write($" \u2502 User: {currentUser}", normalAttr);
// Channel + type
var currentChannel = _messageManager.CurrentChannel;
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;
} }
/// <summary> /// <summary>
/// Set the current user name for display in the status bar. /// Set the current user name (delegates to message manager for @mention detection).
/// </summary> /// </summary>
public void SetCurrentUser(string username) public void SetCurrentUser(string username)
{ {
_currentUser = username; _messageManager.SetCurrentUser(username);
} }
/// <summary> /// <summary>
/// Get the current channel name. /// Get the current channel name.
/// </summary> /// </summary>
public string CurrentChannel => _currentChannel; public string CurrentChannel => _messageManager.CurrentChannel;
/// <summary> /// <summary>
/// Get all channel names that have message buffers (for broadcasting status changes). /// Get all channel names that have message buffers (for broadcasting status changes).
@@ -685,15 +690,15 @@ public sealed class MainWindow : Runnable
/// </summary> /// </summary>
public void SwitchToChannel(string channelName) public void SwitchToChannel(string channelName)
{ {
_currentChannel = channelName; _messageManager.CurrentChannel = channelName;
_chatFrame.Title = $"#{channelName}"; _chatFrame.Title = $"#{channelName}";
// Reset unread count for this channel _messageManager.ClearUnread(channelName);
_channelUnread[channelName] = 0;
RefreshChannelList(); RefreshChannelList();
RefreshMessages(); RefreshMessages();
UpdateTopicBar(); UpdateTopicBar();
_statusLabel.SetNeedsDraw();
// Update channel list selection // Update channel list selection
var idx = _channelNames.IndexOf(channelName); var idx = _channelNames.IndexOf(channelName);
@@ -701,32 +706,15 @@ public sealed class MainWindow : Runnable
_channelList.SelectedItem = idx; _channelList.SelectedItem = idx;
} }
/// <summary>
/// Load historical messages into a channel, replacing any existing messages.
/// </summary>
public void LoadHistory(string channelName, List<MessageDto> messages)
{
var formatted = messages.SelectMany(FormatMessage).ToList();
_channelMessages[channelName] = formatted;
if (channelName == _currentChannel)
{
RefreshMessages();
}
}
/// <summary> /// <summary>
/// Clear all messages and channels (used on disconnect). /// Clear all messages and channels (used on disconnect).
/// </summary> /// </summary>
public void ClearAll() public void ClearAll()
{ {
_channelNames.Clear(); _channelNames.Clear();
_channelMessages.Clear(); _messageManager.ClearAll();
_channelUnread.Clear();
_channelTopics.Clear(); _channelTopics.Clear();
_currentChannel = string.Empty; _channelPublic.Clear();
_currentUser = string.Empty;
_channelListSource.Update([], [], string.Empty); _channelListSource.Update([], [], string.Empty);
_channelList.Source = _channelListSource; _channelList.Source = _channelListSource;
_chatFrame.Title = "Chat"; _chatFrame.Title = "Chat";
@@ -748,7 +736,8 @@ public sealed class MainWindow : Runnable
private void RefreshMessages() private void RefreshMessages()
{ {
if (_channelMessages.TryGetValue(_currentChannel, out var messages)) var messages = _messageManager.GetMessages(_messageManager.CurrentChannel);
if (messages is not null)
{ {
var width = _messageList.Viewport.Width; var width = _messageList.Viewport.Width;
@@ -786,11 +775,11 @@ public sealed class MainWindow : Runnable
/// </summary> /// </summary>
private void RefreshChannelList() private void RefreshChannelList()
{ {
_channelListSource.Update(_channelNames, _channelUnread, _currentChannel); _channelListSource.Update(_channelNames, _messageManager.GetUnreadCounts(), _messageManager.CurrentChannel);
_channelList.Source = _channelListSource; _channelList.Source = _channelListSource;
// Restore selection to current channel // Restore selection to current channel
var idx = _channelNames.IndexOf(_currentChannel); var idx = _channelNames.IndexOf(_messageManager.CurrentChannel);
if (idx >= 0) if (idx >= 0)
_channelList.SelectedItem = idx; _channelList.SelectedItem = idx;
} }
@@ -800,7 +789,7 @@ public sealed class MainWindow : Runnable
/// </summary> /// </summary>
private void UpdateTopicBar() private void UpdateTopicBar()
{ {
_channelTopics.TryGetValue(_currentChannel, out var topic); _channelTopics.TryGetValue(_messageManager.CurrentChannel, out var topic);
if (!string.IsNullOrWhiteSpace(topic)) if (!string.IsNullOrWhiteSpace(topic))
{ {
_topicLabel.Text = $" Topic: {topic}"; _topicLabel.Text = $" Topic: {topic}";
@@ -860,7 +849,7 @@ public sealed class MainWindow : Runnable
_ => "" _ => ""
}; };
var text = $"{statusIcon} {roleTag}{name}"; var text = $"{statusIcon} {roleTag}{name}";
var nameColor = ColorHelper.ParseHexColor(u.NicknameColor); var nameColor = HexColorHelper.ParseHexColor(u.NicknameColor);
return (text, nameColor); return (text, nameColor);
}).ToList(); }).ToList();
@@ -869,190 +858,4 @@ public sealed class MainWindow : Runnable
_usersFrame.Title = $"Users ({users.Count})"; _usersFrame.Title = $"Users ({users.Count})";
} }
/// <summary>
/// Format a message DTO into one or more display lines based on its MessageType.
/// Timestamps are dimmed and sender names are colored.
/// </summary>
private List<ChatLine> FormatMessage(MessageDto message)
{
var time = message.SentAt.ToLocalTime().ToString("HH:mm");
var senderName = message.SenderUsername + ":";
var senderColor = ColorHelper.ParseHexColor(message.SenderNicknameColor);
var lines = new List<ChatLine>();
switch (message.Type)
{
case MessageType.Image:
lines.Add(BuildChatLine(time, senderName, senderColor, " [Image]"));
// Content IS the ASCII art — add each line as a separate list item
if (!string.IsNullOrWhiteSpace(message.Content))
{
foreach (var artLine in message.Content.Split('\n'))
{
// Parse color tags from colored ASCII art
var trimmed = artLine.TrimEnd('\r');
if (ChatLine.HasColorTags(trimmed))
lines.Add(ChatLine.FromColoredText(" " + trimmed));
else
lines.Add(new ChatLine($" {trimmed}"));
}
}
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}"));
break;
case MessageType.Text:
default:
var displayContent = EmojiHelper.ReplaceEmoji(message.Content);
var contentLines = displayContent.Split('\n');
var firstLine = contentLines[0].TrimEnd('\r');
lines.Add(BuildChatLineWithMentions(time, senderName, senderColor, $" {firstLine}"));
// Continuation lines indented to align with first line's content
var indent = new string(' ', $"[{time}] {senderName} ".Length);
for (int i = 1; i < contentLines.Length; i++)
{
var contText = $"{indent}{contentLines[i].TrimEnd('\r')}";
lines.Add(new ChatLine(ChatColors.SplitMentions(contText)));
}
// Render link embeds if present
if (message.Embeds is { Count: > 0 })
{
var chatWidth = _lastChatWidth > 0 ? _lastChatWidth : 80;
foreach (var embed in message.Embeds)
lines.AddRange(FormatEmbed(embed, indent, chatWidth));
}
break;
}
// Tag all lines with the message ID for deletion support
foreach (var line in lines)
line.MessageId = message.Id;
// Check for @mention of current user
if (!string.IsNullOrEmpty(_currentUser) && message.Type == MessageType.Text)
{
var pattern = $@"@{Regex.Escape(_currentUser)}\b";
if (Regex.IsMatch(message.Content, pattern, RegexOptions.IgnoreCase))
{
foreach (var line in lines)
line.IsMention = true;
}
}
return lines;
}
/// <summary>
/// Build a chat line with a dimmed timestamp and optionally colored sender name.
/// </summary>
private static ChatLine BuildChatLine(string time, string senderName, Attribute? senderColor, string suffix)
{
var segments = new List<ChatSegment>
{
new($"[{time}] ", ChatColors.TimestampAttr),
new(senderName, senderColor),
new(suffix, null)
};
return new ChatLine(segments);
}
/// <summary>
/// Build a chat line with @mention highlighting in the suffix text.
/// </summary>
private static ChatLine BuildChatLineWithMentions(string time, string senderName, Attribute? senderColor, string suffix)
{
var segments = new List<ChatSegment>
{
new($"[{time}] ", ChatColors.TimestampAttr),
new(senderName, senderColor),
};
segments.AddRange(ChatColors.SplitMentions(suffix));
return new ChatLine(segments);
}
/// <summary>
/// Format a link embed as indented chat lines with a left border bar,
/// text at full width, and optional icon below (preview image).
/// Each line is pre-wrapped to fit chatWidth so ChatLine.Wrap won't break layout.
/// </summary>
private static List<ChatLine> FormatEmbed(EmbedDto embed, string indent, int chatWidth)
{
var lines = new List<ChatLine>();
const string border = "\u258f "; // ▏ + space
const int borderCols = 2;
int indentCols = indent.GetColumns();
int textWidth = chatWidth - indentCols - borderCols;
if (textWidth < 20) textWidth = 20;
// Helper: create a bordered text line
void AddTextLine(string text, Attribute? color)
{
lines.Add(new ChatLine(
[
new ChatSegment(indent, null),
new ChatSegment(border, ChatColors.EmbedBorderAttr),
new ChatSegment(text, color)
]));
}
// Site name
if (!string.IsNullOrWhiteSpace(embed.SiteName))
AddTextLine(embed.SiteName, ChatColors.EmbedBorderAttr);
// Title
if (!string.IsNullOrWhiteSpace(embed.Title))
{
foreach (var wrapped in WordWrap(embed.Title, textWidth))
AddTextLine(wrapped, ChatColors.EmbedTitleAttr);
}
// Description (word-wrapped at full available width)
if (!string.IsNullOrWhiteSpace(embed.Description))
{
foreach (var wrapped in WordWrap(embed.Description, textWidth))
AddTextLine(wrapped, ChatColors.EmbedDescAttr);
}
return lines;
}
/// <summary>
/// Simple word-wrap: splits text into lines that fit within maxCols display columns.
/// </summary>
private static List<string> WordWrap(string text, int maxCols)
{
if (maxCols <= 0)
return [text];
var result = new List<string>();
var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var currentLine = "";
foreach (var word in words)
{
var candidate = currentLine.Length == 0 ? word : currentLine + " " + word;
if (candidate.GetColumns() <= maxCols)
{
currentLine = candidate;
}
else
{
if (currentLine.Length > 0)
result.Add(currentLine);
// If a single word exceeds maxCols, just add it as-is
currentLine = word;
}
}
if (currentLine.Length > 0)
result.Add(currentLine);
return result;
}
} }
+3 -1
View File
@@ -6,7 +6,9 @@ public static class HubConstants
public const string DefaultChannel = "general"; public const string DefaultChannel = "general";
public const int DefaultHistoryCount = 100; public const int DefaultHistoryCount = 100;
public const int MaxMessageLength = 2000; 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 MaxAvatarSizeBytes = 2 * 1024 * 1024; // 2 MB
public const int MaxMessageNewlines = 30; public const int MaxMessageNewlines = 30;
public const int MaxConsecutiveNewlines = 1; public const int MaxConsecutiveNewlines = 1;
@@ -0,0 +1,22 @@
using EchoHub.Core.DTOs;
namespace EchoHub.Core.Contracts;
public interface IChannelService
{
// Channel CRUD
Task<PaginatedResponse<ChannelDto>> GetChannelsAsync(Guid userId, int offset, int limit);
Task<ChannelOperationResult> CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic);
Task<ChannelOperationResult> UpdateTopicAsync(Guid callerUserId, string channelName, string? topic);
Task<ChannelOperationResult> DeleteChannelAsync(Guid callerUserId, string channelName);
// Channel queries
Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName);
Task<List<ChannelListItem>> GetChannelListAsync();
Task<ChannelDto?> GetChannelByNameAsync(string channelName);
// Membership
Task<(bool Success, string? Error)> EnsureChannelMembershipAsync(Guid userId, string channelName);
}
public record ChannelListItem(string Name, string? Topic, int OnlineCount);
+1 -5
View File
@@ -25,12 +25,8 @@ public interface IChatService
Task BroadcastMessageAsync(string channelName, MessageDto message); Task BroadcastMessageAsync(string channelName, MessageDto message);
Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null); 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<UserProfileDto?> GetUserProfileAsync(string username); Task<UserProfileDto?> GetUserProfileAsync(string username);
Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName);
Task<List<ChannelListItem>> GetChannelListAsync();
Task<List<string>> GetChannelsForUserAsync(string username); Task<List<string>> GetChannelsForUserAsync(string username);
Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password); Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password);
} }
public record ChannelListItem(string Name, string? Topic, int OnlineCount);
+1
View File
@@ -12,6 +12,7 @@ public record MessageDto(
string? AttachmentUrl, string? AttachmentUrl,
string? AttachmentFileName, string? AttachmentFileName,
DateTimeOffset SentAt, DateTimeOffset SentAt,
long? AttachmentFileSize = null,
List<EmbedDto>? Embeds = null); List<EmbedDto>? Embeds = null);
public record ChannelDto( public record ChannelDto(
+21
View File
@@ -1,5 +1,26 @@
namespace EchoHub.Core.DTOs; namespace EchoHub.Core.DTOs;
public record ApiResponse(bool Success, string? Message = null, List<string>? Errors = null);
public record ApiResponse<T>(bool Success, string? Message = null, List<string>? Errors = null, T? Data = default);
public record ErrorResponse(string Error, string? Detail = null); public record ErrorResponse(string Error, string? Detail = null);
public record PaginatedResponse<T>(List<T> Items, int Total, int Offset, int Limit); public record PaginatedResponse<T>(List<T> 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);
}
+1
View File
@@ -7,6 +7,7 @@ public class Message
public MessageType Type { get; set; } = MessageType.Text; public MessageType Type { get; set; } = MessageType.Text;
public string? AttachmentUrl { get; set; } public string? AttachmentUrl { get; set; }
public string? AttachmentFileName { get; set; } public string? AttachmentFileName { get; set; }
public long? AttachmentFileSize { get; set; }
public string? EmbedJson { get; set; } public string? EmbedJson { get; set; }
public DateTimeOffset SentAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset SentAt { get; set; } = DateTimeOffset.UtcNow;
+2 -1
View File
@@ -4,5 +4,6 @@ public enum MessageType
{ {
Text, Text,
Image, Image,
File File,
Audio
} }
@@ -27,14 +27,20 @@ public sealed class IrcClientConnection : IAsyncDisposable
public bool IsSasl { get; set; } public bool IsSasl { get; set; }
public bool CapNegotiating { get; set; } public bool CapNegotiating { get; set; }
// Channel state // Channel state — thread-safe: written by command handler, read by broadcaster threads
public HashSet<string> JoinedChannels { get; } = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet<string> _joinedChannels = new(StringComparer.OrdinalIgnoreCase);
private readonly object _channelLock = new();
// Away state // Away state
public string? AwayMessage { get; set; } public string? AwayMessage { get; set; }
public string Hostmask => $"{Nickname}!{Username ?? Nickname}@echohub"; 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<string> GetJoinedChannels() { lock (_channelLock) return [.. _joinedChannels]; }
public IrcClientConnection(TcpClient tcpClient, Stream stream) public IrcClientConnection(TcpClient tcpClient, Stream stream)
{ {
_tcpClient = tcpClient; _tcpClient = tcpClient;
+25 -18
View File
@@ -12,6 +12,8 @@ public sealed class IrcCommandHandler
private readonly IrcClientConnection _conn; private readonly IrcClientConnection _conn;
private readonly IrcOptions _options; private readonly IrcOptions _options;
private readonly IChatService _chatService; private readonly IChatService _chatService;
private readonly IChannelService _channelService;
private readonly IMessageEncryptionService _encryption;
private readonly ILogger _logger; private readonly ILogger _logger;
private string ServerName => _options.ServerName; private string ServerName => _options.ServerName;
@@ -20,11 +22,15 @@ public sealed class IrcCommandHandler
IrcClientConnection conn, IrcClientConnection conn,
IrcOptions options, IrcOptions options,
IChatService chatService, IChatService chatService,
IChannelService channelService,
IMessageEncryptionService encryption,
ILogger logger) ILogger logger)
{ {
_conn = conn; _conn = conn;
_options = options; _options = options;
_chatService = chatService; _chatService = chatService;
_channelService = channelService;
_encryption = encryption;
_logger = logger; _logger = logger;
} }
@@ -319,7 +325,7 @@ public sealed class IrcCommandHandler
private async Task HandleJoinAsync(IrcMessage msg) private async Task HandleJoinAsync(IrcMessage msg)
{ {
if (!RequireRegistered()) return; if (!await RequireRegisteredAsync()) return;
if (msg.Parameters.Count < 1) if (msg.Parameters.Count < 1)
{ {
@@ -350,7 +356,7 @@ public sealed class IrcCommandHandler
continue; continue;
} }
_conn.JoinedChannels.Add(channelName); _conn.JoinChannel(channelName);
// Confirm JOIN to the client // Confirm JOIN to the client
await _conn.SendAsync($":{_conn.Hostmask} JOIN #{channelName}"); await _conn.SendAsync($":{_conn.Hostmask} JOIN #{channelName}");
@@ -361,10 +367,11 @@ public sealed class IrcCommandHandler
// Send NAMES list // Send NAMES list
await SendNamesReplyAsync(channelName); await SendNamesReplyAsync(channelName);
// Replay history // Replay history (decrypt — history is encrypted for SignalR transport)
foreach (var m in history) 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) foreach (var line in lines)
await _conn.SendAsync(line); await _conn.SendAsync(line);
} }
@@ -373,7 +380,7 @@ public sealed class IrcCommandHandler
private async Task HandlePartAsync(IrcMessage msg) private async Task HandlePartAsync(IrcMessage msg)
{ {
if (!RequireRegistered()) return; if (!await RequireRegisteredAsync()) return;
if (msg.Parameters.Count < 1) return; if (msg.Parameters.Count < 1) return;
var channels = msg.Parameters[0].Split(',', StringSplitOptions.RemoveEmptyEntries); var channels = msg.Parameters[0].Split(',', StringSplitOptions.RemoveEmptyEntries);
@@ -385,7 +392,7 @@ public sealed class IrcCommandHandler
if (channelName is null) continue; if (channelName is null) continue;
await _chatService.LeaveChannelAsync(_conn.ConnectionId, _conn.Nickname!, channelName); await _chatService.LeaveChannelAsync(_conn.ConnectionId, _conn.Nickname!, channelName);
_conn.JoinedChannels.Remove(channelName); _conn.LeaveChannel(channelName);
await _conn.SendAsync($":{_conn.Hostmask} PART #{channelName}" + await _conn.SendAsync($":{_conn.Hostmask} PART #{channelName}" +
(partMessage is not null ? $" :{partMessage}" : "")); (partMessage is not null ? $" :{partMessage}" : ""));
@@ -394,7 +401,7 @@ public sealed class IrcCommandHandler
private async Task HandlePrivmsgAsync(IrcMessage msg) private async Task HandlePrivmsgAsync(IrcMessage msg)
{ {
if (!RequireRegistered()) return; if (!await RequireRegisteredAsync()) return;
if (msg.Parameters.Count < 2) if (msg.Parameters.Count < 2)
{ {
@@ -436,7 +443,7 @@ public sealed class IrcCommandHandler
private async Task HandleNamesAsync(IrcMessage msg) private async Task HandleNamesAsync(IrcMessage msg)
{ {
if (!RequireRegistered()) return; if (!await RequireRegisteredAsync()) return;
if (msg.Parameters.Count < 1) return; if (msg.Parameters.Count < 1) return;
var channelName = IrcToEchoHubChannel(msg.Parameters[0]); var channelName = IrcToEchoHubChannel(msg.Parameters[0]);
@@ -458,7 +465,7 @@ public sealed class IrcCommandHandler
private async Task HandleTopicAsync(IrcMessage msg) private async Task HandleTopicAsync(IrcMessage msg)
{ {
if (!RequireRegistered()) return; if (!await RequireRegisteredAsync()) return;
if (msg.Parameters.Count < 1) return; if (msg.Parameters.Count < 1) return;
var channelName = IrcToEchoHubChannel(msg.Parameters[0]); var channelName = IrcToEchoHubChannel(msg.Parameters[0]);
@@ -477,7 +484,7 @@ public sealed class IrcCommandHandler
private async Task SendChannelTopicAsync(string channelName) private async Task SendChannelTopicAsync(string channelName)
{ {
var (topic, exists) = await _chatService.GetChannelTopicAsync(channelName); var (topic, exists) = await _channelService.GetChannelTopicAsync(channelName);
if (!exists) return; if (!exists) return;
@@ -495,7 +502,7 @@ public sealed class IrcCommandHandler
private async Task HandleWhoAsync(IrcMessage msg) private async Task HandleWhoAsync(IrcMessage msg)
{ {
if (!RequireRegistered()) return; if (!await RequireRegisteredAsync()) return;
if (msg.Parameters.Count < 1) return; if (msg.Parameters.Count < 1) return;
var channelName = IrcToEchoHubChannel(msg.Parameters[0]); var channelName = IrcToEchoHubChannel(msg.Parameters[0]);
@@ -516,7 +523,7 @@ public sealed class IrcCommandHandler
private async Task HandleWhoisAsync(IrcMessage msg) private async Task HandleWhoisAsync(IrcMessage msg)
{ {
if (!RequireRegistered()) return; if (!await RequireRegisteredAsync()) return;
if (msg.Parameters.Count < 1) return; if (msg.Parameters.Count < 1) return;
var nick = msg.Parameters[^1].ToLowerInvariant(); var nick = msg.Parameters[^1].ToLowerInvariant();
@@ -559,7 +566,7 @@ public sealed class IrcCommandHandler
private async Task HandleAwayAsync(IrcMessage msg) private async Task HandleAwayAsync(IrcMessage msg)
{ {
if (!RequireRegistered()) return; if (!await RequireRegisteredAsync()) return;
if (msg.Parameters.Count > 0 && !string.IsNullOrWhiteSpace(msg.Parameters[0])) if (msg.Parameters.Count > 0 && !string.IsNullOrWhiteSpace(msg.Parameters[0]))
{ {
@@ -581,9 +588,9 @@ public sealed class IrcCommandHandler
private async Task HandleListAsync(IrcMessage msg) 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) foreach (var ch in channels)
{ {
@@ -597,7 +604,7 @@ public sealed class IrcCommandHandler
private async Task HandleModeAsync(IrcMessage msg) private async Task HandleModeAsync(IrcMessage msg)
{ {
if (!RequireRegistered()) return; if (!await RequireRegisteredAsync()) return;
if (msg.Parameters.Count < 1) return; if (msg.Parameters.Count < 1) return;
var target = msg.Parameters[0]; var target = msg.Parameters[0];
@@ -621,11 +628,11 @@ public sealed class IrcCommandHandler
// ── Helpers ────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────
private bool RequireRegistered() private async Task<bool> RequireRegisteredAsync()
{ {
if (_conn.IsRegistered) return true; if (_conn.IsRegistered) return true;
_ = _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOTREGISTERED, await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOTREGISTERED,
":You have not registered"); ":You have not registered");
return false; return false;
} }
+5 -3
View File
@@ -34,7 +34,7 @@ public sealed class IrcGatewayService : BackgroundService
public IEnumerable<IrcClientConnection> GetConnectionsInChannel(string channelName) public IEnumerable<IrcClientConnection> GetConnectionsInChannel(string channelName)
{ {
return _connections.Values return _connections.Values
.Where(c => c.IsAuthenticated && c.JoinedChannels.Contains(channelName)); .Where(c => c.IsAuthenticated && c.IsInChannel(channelName));
} }
public IEnumerable<IrcClientConnection> GetAllConnections() public IEnumerable<IrcClientConnection> GetAllConnections()
@@ -120,8 +120,10 @@ public sealed class IrcGatewayService : BackgroundService
try try
{ {
chatService = _services.GetRequiredService<IChatService>(); chatService = _services.GetRequiredService<IChatService>();
var channelService = _services.GetRequiredService<IChannelService>();
var encryption = _services.GetRequiredService<IMessageEncryptionService>();
var handler = new IrcCommandHandler( var handler = new IrcCommandHandler(
connection, _options, chatService, _logger); connection, _options, chatService, channelService, encryption, _logger);
await handler.RunAsync(ct); await handler.RunAsync(ct);
} }
@@ -133,7 +135,7 @@ public sealed class IrcGatewayService : BackgroundService
{ {
if (connection.IsAuthenticated) if (connection.IsAuthenticated)
{ {
foreach (var ch in connection.JoinedChannels.ToList()) foreach (var ch in connection.GetJoinedChannels())
{ {
if (chatService is null) break; if (chatService is null) break;
await chatService.LeaveChannelAsync( await chatService.LeaveChannelAsync(
@@ -48,6 +48,10 @@ public static partial class IrcMessageFormatter
case MessageType.File: case MessageType.File:
lines.Add($"{prefix} PRIVMSG {ircChannel} :[File: {message.AttachmentFileName}] {message.AttachmentUrl}"); lines.Add($"{prefix} PRIVMSG {ircChannel} :[File: {message.AttachmentFileName}] {message.AttachmentUrl}");
break; break;
case MessageType.Audio:
lines.Add($"{prefix} PRIVMSG {ircChannel} :\u266a [Audio: {message.AttachmentFileName}] {message.AttachmentUrl}");
break;
} }
return lines; return lines;
@@ -8,7 +8,6 @@ using EchoHub.Server.Services;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
namespace EchoHub.Server.Controllers; namespace EchoHub.Server.Controllers;
@@ -18,6 +17,7 @@ namespace EchoHub.Server.Controllers;
[EnableRateLimiting("general")] [EnableRateLimiting("general")]
public class ChannelsController : ControllerBase public class ChannelsController : ControllerBase
{ {
private readonly IChannelService _channelService;
private readonly EchoHubDbContext _db; private readonly EchoHubDbContext _db;
private readonly FileStorageService _fileStorage; private readonly FileStorageService _fileStorage;
private readonly ImageToAsciiService _asciiService; private readonly ImageToAsciiService _asciiService;
@@ -26,6 +26,7 @@ public class ChannelsController : ControllerBase
private readonly IMessageEncryptionService _encryption; private readonly IMessageEncryptionService _encryption;
public ChannelsController( public ChannelsController(
IChannelService channelService,
EchoHubDbContext db, EchoHubDbContext db,
FileStorageService fileStorage, FileStorageService fileStorage,
ImageToAsciiService asciiService, ImageToAsciiService asciiService,
@@ -33,6 +34,7 @@ public class ChannelsController : ControllerBase
IChatService chatService, IChatService chatService,
IMessageEncryptionService encryption) IMessageEncryptionService encryption)
{ {
_channelService = channelService;
_db = db; _db = db;
_fileStorage = fileStorage; _fileStorage = fileStorage;
_asciiService = asciiService; _asciiService = asciiService;
@@ -48,74 +50,29 @@ public class ChannelsController : ControllerBase
if (userIdClaim is null) if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required.")); return Unauthorized(new ErrorResponse("Authentication required."));
var userId = Guid.Parse(userIdClaim);
offset = Math.Max(0, offset); offset = Math.Max(0, offset);
limit = Math.Clamp(limit, 1, 100); limit = Math.Clamp(limit, 1, 100);
// Public channels + private channels the user has joined var result = await _channelService.GetChannelsAsync(Guid.Parse(userIdClaim), offset, limit);
var query = _db.Channels.Where(c => return Ok(result);
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<ChannelDto>(channels, total, offset, limit));
} }
[HttpPost] [HttpPost]
public async Task<IActionResult> CreateChannel([FromBody] CreateChannelRequest request) public async Task<IActionResult> 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); var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userIdClaim is null) if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required.")); return Unauthorized(new ErrorResponse("Authentication required."));
var channel = new Channel var result = await _channelService.CreateChannelAsync(
{ Guid.Parse(userIdClaim), request.Name, request.Topic, request.IsPublic);
Id = Guid.NewGuid(), if (!result.IsSuccess)
Name = channelName, return MapChannelError(result);
Topic = request.Topic?.Trim(),
IsPublic = request.IsPublic,
CreatedByUserId = Guid.Parse(userIdClaim),
};
_db.Channels.Add(channel); if (result.Channel!.IsPublic)
await _chatService.BroadcastChannelUpdatedAsync(result.Channel);
// Creator automatically becomes a member return Created($"/api/channels/{result.Channel.Name}", result.Channel);
_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);
} }
[HttpPut("{channel}/topic")] [HttpPut("{channel}/topic")]
@@ -125,26 +82,13 @@ public class ChannelsController : ControllerBase
if (userIdClaim is null) if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required.")); return Unauthorized(new ErrorResponse("Authentication required."));
var channelName = channel.ToLowerInvariant().Trim(); var result = await _channelService.UpdateTopicAsync(
var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); Guid.Parse(userIdClaim), channel, request.Topic);
if (!result.IsSuccess)
return MapChannelError(result);
if (dbChannel is null) await _chatService.BroadcastChannelUpdatedAsync(result.Channel!, channel.ToLowerInvariant().Trim());
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist.")); return Ok(result.Channel);
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);
} }
[HttpDelete("{channel}")] [HttpDelete("{channel}")]
@@ -154,29 +98,17 @@ public class ChannelsController : ControllerBase
if (userIdClaim is null) if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required.")); return Unauthorized(new ErrorResponse("Authentication required."));
var channelName = channel.ToLowerInvariant().Trim(); var result = await _channelService.DeleteChannelAsync(Guid.Parse(userIdClaim), channel);
if (!result.IsSuccess)
if (channelName == HubConstants.DefaultChannel) return MapChannelError(result);
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();
return NoContent(); return NoContent();
} }
[HttpPost("{channel}/upload")] [HttpPost("{channel}/upload")]
[EnableRateLimiting("upload")] [EnableRateLimiting("upload")]
[RequestSizeLimit(HubConstants.MaxFileSizeBytes)]
[RequestFormLimits(MultipartBodyLengthLimit = HubConstants.MaxFileSizeBytes)]
public async Task<IActionResult> Upload(string channel, [FromQuery] string? size = null) public async Task<IActionResult> Upload(string channel, [FromQuery] string? size = null)
{ {
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
@@ -190,8 +122,8 @@ public class ChannelsController : ControllerBase
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
return BadRequest(new ErrorResponse("Invalid channel name format.")); return BadRequest(new ErrorResponse("Invalid channel name format."));
var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); var channelDto = await _channelService.GetChannelByNameAsync(channelName);
if (dbChannel is null) if (channelDto is null)
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist.")); return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
if (!Request.HasFormContentType || Request.Form.Files.Count == 0) if (!Request.HasFormContentType || Request.Form.Files.Count == 0)
@@ -199,16 +131,23 @@ public class ChannelsController : ControllerBase
var file = Request.Form.Files[0]; var file = Request.Form.Files[0];
if (file.Length > HubConstants.MaxFileSizeBytes) // Detect file type early so we can apply the correct size limit
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB."));
// Detect if file is an image by checking magic bytes
using var stream = file.OpenReadStream(); using var stream = file.OpenReadStream();
var isImage = FileValidationHelper.IsValidImage(stream); 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 (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; string content;
if (isImage) if (isImage)
@@ -233,8 +172,9 @@ public class ChannelsController : ControllerBase
Type = messageType, Type = messageType,
AttachmentUrl = attachmentUrl, AttachmentUrl = attachmentUrl,
AttachmentFileName = file.FileName, AttachmentFileName = file.FileName,
AttachmentFileSize = file.Length,
SentAt = DateTimeOffset.UtcNow, SentAt = DateTimeOffset.UtcNow,
ChannelId = dbChannel.Id, ChannelId = channelDto.Id,
SenderUserId = userId, SenderUserId = userId,
SenderUsername = usernameClaim, SenderUsername = usernameClaim,
}; };
@@ -252,7 +192,8 @@ public class ChannelsController : ControllerBase
messageType, messageType,
attachmentUrl, attachmentUrl,
file.FileName, file.FileName,
message.SentAt); message.SentAt,
file.Length);
await _chatService.BroadcastMessageAsync(channelName, messageDto); await _chatService.BroadcastMessageAsync(channelName, messageDto);
@@ -274,8 +215,8 @@ public class ChannelsController : ControllerBase
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
return BadRequest(new ErrorResponse("Invalid channel name format.")); return BadRequest(new ErrorResponse("Invalid channel name format."));
var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); var channelDto = await _channelService.GetChannelByNameAsync(channelName);
if (dbChannel is null) if (channelDto is null)
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist.")); return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
if (string.IsNullOrWhiteSpace(request.Url)) if (string.IsNullOrWhiteSpace(request.Url))
@@ -295,13 +236,13 @@ public class ChannelsController : ControllerBase
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
var contentLength = response.Content.Headers.ContentLength; var contentLength = response.Content.Headers.ContentLength;
if (contentLength > HubConstants.MaxFileSizeBytes) if (contentLength > HubConstants.MaxImageSizeBytes)
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB.")); return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxImageSizeBytes / (1024 * 1024)} MB."));
imageBytes = await response.Content.ReadAsByteArrayAsync(); imageBytes = await response.Content.ReadAsByteArrayAsync();
if (imageBytes.Length > HubConstants.MaxFileSizeBytes) if (imageBytes.Length > HubConstants.MaxImageSizeBytes)
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB.")); return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxImageSizeBytes / (1024 * 1024)} MB."));
fileName = Path.GetFileName(uri.LocalPath); fileName = Path.GetFileName(uri.LocalPath);
if (string.IsNullOrWhiteSpace(fileName) || !fileName.Contains('.')) if (string.IsNullOrWhiteSpace(fileName) || !fileName.Contains('.'))
@@ -353,8 +294,9 @@ public class ChannelsController : ControllerBase
Type = MessageType.Image, Type = MessageType.Image,
AttachmentUrl = attachmentUrl, AttachmentUrl = attachmentUrl,
AttachmentFileName = fileName, AttachmentFileName = fileName,
AttachmentFileSize = imageBytes.Length,
SentAt = DateTimeOffset.UtcNow, SentAt = DateTimeOffset.UtcNow,
ChannelId = dbChannel.Id, ChannelId = channelDto.Id,
SenderUserId = userId, SenderUserId = userId,
SenderUsername = usernameClaim, SenderUsername = usernameClaim,
}; };
@@ -372,10 +314,21 @@ public class ChannelsController : ControllerBase
MessageType.Image, MessageType.Image,
attachmentUrl, attachmentUrl,
fileName, fileName,
message.SentAt); message.SentAt,
imageBytes.Length);
await _chatService.BroadcastMessageAsync(channelName, messageDto); await _chatService.BroadcastMessageAsync(channelName, messageDto);
return Ok(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.")),
};
} }
@@ -36,6 +36,13 @@ public class FilesController : ControllerBase
".png" => "image/png", ".png" => "image/png",
".gif" => "image/gif", ".gif" => "image/gif",
".webp" => "image/webp", ".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", ".pdf" => "application/pdf",
".txt" => "text/plain", ".txt" => "text/plain",
_ => "application/octet-stream" _ => "application/octet-stream"
@@ -0,0 +1,267 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("CreatedByUserId")
.HasColumnType("TEXT");
b.Property<bool>("IsPublic")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Topic")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Channels");
});
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.Property<Guid>("ChannelId")
.HasColumnType("TEXT");
b.Property<long>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("AttachmentFileName")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<long?>("AttachmentFileSize")
.HasColumnType("INTEGER");
b.Property<string>("AttachmentUrl")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<Guid>("ChannelId")
.HasColumnType("TEXT");
b.Property<string>("Content")
.IsRequired()
.HasMaxLength(16000)
.HasColumnType("TEXT");
b.Property<string>("EmbedJson")
.HasMaxLength(32000)
.HasColumnType("TEXT");
b.Property<Guid>("SenderUserId")
.HasColumnType("TEXT");
b.Property<string>("SenderUsername")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<long>("SentAt")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ChannelId");
b.HasIndex("SentAt");
b.ToTable("Messages");
});
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<long>("ExpiresAt")
.HasColumnType("INTEGER");
b.Property<long?>("RevokedAt")
.HasColumnType("INTEGER");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TokenHash");
b.HasIndex("UserId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("EchoHub.Core.Models.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("AvatarAscii")
.HasMaxLength(10000)
.HasColumnType("TEXT");
b.Property<string>("Bio")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DisplayName")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<bool>("IsBanned")
.HasColumnType("INTEGER");
b.Property<bool>("IsMuted")
.HasColumnType("INTEGER");
b.Property<long>("LastSeenAt")
.HasColumnType("INTEGER");
b.Property<long?>("MutedUntil")
.HasColumnType("INTEGER");
b.Property<string>("NicknameColor")
.HasMaxLength(7)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<string>("StatusMessage")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("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
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EchoHub.Server.Data.Migrations
{
/// <inheritdoc />
public partial class AddAttachmentFileSize : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<long>(
name: "AttachmentFileSize",
table: "Messages",
type: "INTEGER",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AttachmentFileSize",
table: "Messages");
}
}
}
@@ -79,6 +79,9 @@ namespace EchoHub.Server.Data.Migrations
.HasMaxLength(255) .HasMaxLength(255)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<long?>("AttachmentFileSize")
.HasColumnType("INTEGER");
b.Property<string>("AttachmentUrl") b.Property<string>("AttachmentUrl")
.HasMaxLength(500) .HasMaxLength(500)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
+2
View File
@@ -22,6 +22,8 @@
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<PackageIcon></PackageIcon>
<ApplicationIcon>..\EchoHub.Client\Assets\hue_icon.ico</ApplicationIcon>
</PropertyGroup> </PropertyGroup>
</Project> </Project>
+2
View File
@@ -108,12 +108,14 @@ while (true)
builder.Services.AddSingleton<FileStorageService>(); builder.Services.AddSingleton<FileStorageService>();
builder.Services.AddSingleton<LinkEmbedService>(); builder.Services.AddSingleton<LinkEmbedService>();
builder.Services.AddHostedService<ServerDirectoryService>(); builder.Services.AddHostedService<ServerDirectoryService>();
builder.Services.AddHostedService<FileCleanupService>();
// ── Encryption ───────────────────────────────────────────────────── // ── Encryption ─────────────────────────────────────────────────────
builder.Services.AddSingleton<IMessageEncryptionService, MessageEncryptionService>(); builder.Services.AddSingleton<IMessageEncryptionService, MessageEncryptionService>();
// ── Chat Service + Broadcasters ───────────────────────────────────── // ── Chat Service + Broadcasters ─────────────────────────────────────
builder.Services.AddSingleton<IChatBroadcaster, SignalRBroadcaster>(); builder.Services.AddSingleton<IChatBroadcaster, SignalRBroadcaster>();
builder.Services.AddSingleton<IChannelService, ChannelService>();
builder.Services.AddSingleton<IChatService, ChatService>(); builder.Services.AddSingleton<IChatService, ChatService>();
// ── IRC Gateway (optional) ────────────────────────────────────────── // ── IRC Gateway (optional) ──────────────────────────────────────────
@@ -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<ChannelService> _logger;
public ChannelService(
IServiceScopeFactory scopeFactory,
PresenceTracker presenceTracker,
ILogger<ChannelService> logger)
{
_scopeFactory = scopeFactory;
_presenceTracker = presenceTracker;
_logger = logger;
}
public async Task<PaginatedResponse<ChannelDto>> GetChannelsAsync(Guid userId, int offset, int limit)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
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<ChannelDto>(channels, total, offset, limit);
}
public async Task<ChannelOperationResult> 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<EchoHubDbContext>();
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<ChannelOperationResult> 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<EchoHubDbContext>();
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<ChannelOperationResult> 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<EchoHubDbContext>();
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<EchoHubDbContext>();
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (channel is null) return (null, false);
return (channel.Topic, true);
}
public async Task<List<ChannelListItem>> GetChannelListAsync()
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
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<ChannelDto?> GetChannelByNameAsync(string channelName)
{
channelName = channelName.ToLowerInvariant().Trim();
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
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<EchoHubDbContext>();
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();
}
}
}
+10 -50
View File
@@ -17,6 +17,7 @@ public class ChatService : IChatService
private readonly IEnumerable<IChatBroadcaster> _broadcasters; private readonly IEnumerable<IChatBroadcaster> _broadcasters;
private readonly LinkEmbedService _embedService; private readonly LinkEmbedService _embedService;
private readonly IMessageEncryptionService _encryption; private readonly IMessageEncryptionService _encryption;
private readonly IChannelService _channelService;
private readonly ILogger<ChatService> _logger; private readonly ILogger<ChatService> _logger;
public ChatService( public ChatService(
@@ -25,6 +26,7 @@ public class ChatService : IChatService
IEnumerable<IChatBroadcaster> broadcasters, IEnumerable<IChatBroadcaster> broadcasters,
LinkEmbedService embedService, LinkEmbedService embedService,
IMessageEncryptionService encryption, IMessageEncryptionService encryption,
IChannelService channelService,
ILogger<ChatService> logger) ILogger<ChatService> logger)
{ {
_scopeFactory = scopeFactory; _scopeFactory = scopeFactory;
@@ -32,6 +34,7 @@ public class ChatService : IChatService
_broadcasters = broadcasters; _broadcasters = broadcasters;
_embedService = embedService; _embedService = embedService;
_encryption = encryption; _encryption = encryption;
_channelService = channelService;
_logger = logger; _logger = logger;
} }
@@ -95,28 +98,10 @@ public class ChatService : IChatService
{ {
channelName = channelName.ToLowerInvariant().Trim(); channelName = channelName.ToLowerInvariant().Trim();
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) // Delegate channel validation + membership to ChannelService
return ([], "Invalid channel name. Use 2-100 characters: letters, digits, underscores, or hyphens."); var (success, error) = await _channelService.EnsureChannelMembershipAsync(userId, channelName);
if (!success)
using var scope = _scopeFactory.CreateScope(); return ([], error);
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
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();
}
var isNewJoin = _presenceTracker.JoinChannel(username, channelName); var isNewJoin = _presenceTracker.JoinChannel(username, channelName);
@@ -126,7 +111,7 @@ public class ChatService : IChatService
_logger.LogInformation("{User} joined channel '{Channel}'", username, channelName); _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); return (history, null);
} }
@@ -228,7 +213,7 @@ public class ChatService : IChatService
null, null,
null, null,
message.SentAt, message.SentAt,
embeds); Embeds: embeds);
await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto)); await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto));
@@ -335,32 +320,6 @@ public class ChatService : IChatService
user.StatusMessage, user.Role, user.CreatedAt, user.LastSeenAt); 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<EchoHubDbContext>();
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (channel is null) return (null, false);
return (channel.Topic, true);
}
public async Task<List<ChannelListItem>> GetChannelListAsync()
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
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<List<string>> GetChannelsForUserAsync(string username) public Task<List<string>> GetChannelsForUserAsync(string username)
=> Task.FromResult(_presenceTracker.GetChannelsForUser(username)); => Task.FromResult(_presenceTracker.GetChannelsForUser(username));
@@ -457,6 +416,7 @@ public class ChatService : IChatService
x.m.AttachmentUrl, x.m.AttachmentUrl,
x.m.AttachmentFileName, x.m.AttachmentFileName,
x.m.SentAt, x.m.SentAt,
x.m.AttachmentFileSize,
embeds); embeds);
}).ToList(); }).ToList();
} }
@@ -0,0 +1,69 @@
namespace EchoHub.Server.Services;
public sealed class FileCleanupService : BackgroundService
{
private readonly IConfiguration _configuration;
private readonly ILogger<FileCleanupService> _logger;
public FileCleanupService(IConfiguration configuration, ILogger<FileCleanupService> 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);
}
}
@@ -52,6 +52,20 @@ public static class FileValidationHelper
} }
} }
private static readonly HashSet<string> AudioExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".mp3", ".wav", ".ogg", ".flac", ".aac", ".m4a", ".wma"
};
/// <summary>
/// Checks whether the file name has a recognized audio extension.
/// </summary>
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) private static bool StartsWith(byte[] buffer, int length, byte[] magic)
{ {
if (length < magic.Length) if (length < magic.Length)
@@ -1,6 +1,7 @@
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using EchoHub.Core.Constants; using EchoHub.Core.Constants;
using EchoHub.Core.DTOs; using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Server.Data; using EchoHub.Server.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -12,12 +13,14 @@ public static partial class DataMigrationService
{ {
using var scope = services.CreateScope(); using var scope = services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>(); var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var config = scope.ServiceProvider.GetRequiredService<IConfiguration>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>() var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>()
.CreateLogger("EchoHub.Server.Setup.DataMigration"); .CreateLogger("EchoHub.Server.Setup.DataMigration");
await EnsureDefaultChannelsPublicAsync(db, logger); await EnsureDefaultChannelsPublicAsync(db, logger);
await MigrateAnsiMessagesAsync(db, logger); await MigrateAnsiMessagesAsync(db, logger);
await MigrateEmbedJsonToArrayAsync(db, logger); await MigrateEmbedJsonToArrayAsync(db, logger);
await EnsureConfiguredAdminsAsync(db, config, logger);
} }
/// <summary> /// <summary>
@@ -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")] [GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2|48;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")]
private static partial Regex AnsiColorRegex(); private static partial Regex AnsiColorRegex();
/// <summary>
/// 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.
/// </summary>
private static async Task EnsureConfiguredAdminsAsync(EchoHubDbContext db, IConfiguration config, ILogger logger)
{
var adminUsernames = config.GetSection("Server:Admins").Get<string[]>();
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();
}
/// <summary> /// <summary>
/// Migrate old single-object EmbedJson ("{...}") to array format ("[{...}]"). /// Migrate old single-object EmbedJson ("{...}") to array format ("[{...}]").
/// </summary> /// </summary>
+6 -1
View File
@@ -12,7 +12,12 @@
"Name": "My EchoHub Server", "Name": "My EchoHub Server",
"Description": "A self-hosted EchoHub chat server", "Description": "A self-hosted EchoHub chat server",
"PublicServer": false, "PublicServer": false,
"PublicHost": "" "PublicHost": "",
"Admins": []
},
"Storage": {
"CleanupIntervalHours": 1,
"RetentionDays": 30
}, },
"Encryption": { "Encryption": {
"Key": "", "Key": "",
+107
View File
@@ -0,0 +1,107 @@
using EchoHub.Client.UI.Chat;
using Xunit;
namespace EchoHub.Tests;
/// <summary>
/// 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.
/// </summary>
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);
}
}
+462
View File
@@ -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);
}
}
@@ -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);
}
}
+1
View File
@@ -21,6 +21,7 @@
<ProjectReference Include="..\EchoHub.Client\EchoHub.Client.csproj" /> <ProjectReference Include="..\EchoHub.Client\EchoHub.Client.csproj" />
<ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" /> <ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" />
<ProjectReference Include="..\EchoHub.Server\EchoHub.Server.csproj" /> <ProjectReference Include="..\EchoHub.Server\EchoHub.Server.csproj" />
<ProjectReference Include="..\EchoHub.Server.Irc\EchoHub.Server.Irc.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -53,4 +53,48 @@ public class FileValidationHelperTests
FileValidationHelper.IsValidImage(stream); FileValidationHelper.IsValidImage(stream);
Assert.Equal(0, stream.Position); 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));
}
} }
@@ -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);
}
}
@@ -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<IOptions<IrcOptions>>(Options.Create(_options))
.BuildServiceProvider();
_gateway = new IrcGatewayService(
Options.Create(_options), services, NullLogger<IrcGatewayService>.Instance);
_broadcaster = new IrcBroadcaster(_gateway, _encryption);
}
/// <summary>
/// Injects a test connection into the gateway's internal connection map.
/// </summary>
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<string, IrcClientConnection>)_gateway.Connections;
connections[conn.ConnectionId] = conn;
return conn;
}
private static List<string> 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");
}
/// <summary>
/// Creates a connection that can capture output and injects it into the gateway.
/// </summary>
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<string, IrcClientConnection>)_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());
}
}
@@ -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);
}
}
@@ -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<List<string>> RunAndCapture(string[] inputLines,
Action<IrcClientConnection>? 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<List<string>> RunAuthenticated(string[] inputLines,
string nickname = "alice", Guid? userId = null,
Action<IrcClientConnection>? 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);
}
}
@@ -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<EmbedDto>? 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<EmbedDto>
{
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<EmbedDto>
{
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<EmbedDto>
{
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));
}
}
+183
View File
@@ -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);
}
}
+260
View File
@@ -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;
/// <summary>
/// A duplex stream that reads from one buffer and writes to another,
/// allowing test code to inject input and capture output.
/// </summary>
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<string> 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<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct) =>
_readBuffer.ReadAsync(buffer, offset, count, ct);
public override ValueTask<int> ReadAsync(Memory<byte> 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<byte> 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);
}
}
/// <summary>
/// Creates IrcClientConnections backed by test streams for unit testing.
/// </summary>
internal static class TestIrcConnectionFactory
{
/// <summary>
/// Creates a test IRC connection with the given input lines.
/// Returns the connection and the test stream (for inspecting output).
/// </summary>
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);
}
/// <summary>
/// Creates a pre-authenticated, registered IRC connection.
/// </summary>
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);
}
}
/// <summary>
/// Fake encryption service that uses a simple reversible prefix-based scheme.
/// Encrypt("hello") → "$ENC$hello", Decrypt("$ENC$hello") → "hello".
/// </summary>
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;
}
/// <summary>
/// Fake chat service that records method calls and returns pre-configured results.
/// </summary>
internal sealed class FakeChatService : IChatService
{
// Recorded calls
public List<string> ConnectedUsers { get; } = [];
public List<string> 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<MessageDto> 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<string> ChannelsForUserToReturn { get; set; } = [];
public List<UserPresenceDto> OnlineUsersToReturn { get; set; } = [];
public Task UserConnectedAsync(string connectionId, Guid userId, string username)
{
ConnectedUsers.Add(username);
return Task.CompletedTask;
}
public Task<string?> UserDisconnectedAsync(string connectionId)
{
DisconnectedConnections.Add(connectionId);
return Task.FromResult<string?>(null);
}
public Task<(List<MessageDto> 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<string?> SendMessageAsync(Guid userId, string username, string channelName, string content)
{
SentMessages.Add((channelName, content));
return Task.FromResult(SendMessageError);
}
public Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count) =>
Task.FromResult(HistoryToReturn);
public Task<string?> UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage)
{
StatusUpdates.Add((username, status));
return Task.FromResult<string?>(null);
}
public Task<List<UserPresenceDto>> 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<UserProfileDto?> GetUserProfileAsync(string username) =>
Task.FromResult(ProfileToReturn);
public Task<List<string>> GetChannelsForUserAsync(string username) =>
Task.FromResult(ChannelsForUserToReturn);
public Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password) =>
Task.FromResult(AuthResult);
}
/// <summary>
/// Fake channel service that records method calls and returns pre-configured results.
/// </summary>
internal sealed class FakeChannelService : IChannelService
{
// Configurable results
public (string? Topic, bool Exists) TopicResult { get; set; } = (null, true);
public List<ChannelListItem> 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<PaginatedResponse<ChannelDto>> GetChannelsAsync(Guid userId, int offset, int limit) =>
Task.FromResult(new PaginatedResponse<ChannelDto>([], 0, offset, limit));
public Task<ChannelOperationResult> CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic) =>
Task.FromResult(CreateResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured"));
public Task<ChannelOperationResult> UpdateTopicAsync(Guid callerUserId, string channelName, string? topic) =>
Task.FromResult(UpdateTopicResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured"));
public Task<ChannelOperationResult> 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<List<ChannelListItem>> GetChannelListAsync() =>
Task.FromResult(ChannelListToReturn);
public Task<ChannelDto?> GetChannelByNameAsync(string channelName) =>
Task.FromResult(ChannelByNameToReturn);
public Task<(bool Success, string? Error)> EnsureChannelMembershipAsync(Guid userId, string channelName) =>
Task.FromResult(MembershipResult);
}
@@ -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<EmbedDto>? 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<EmbedDto>
{
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]);
}
}
+195
View File
@@ -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<string, string?>
{
["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<string, string?>
{
["Jwt:Issuer"] = TestIssuer,
["Jwt:Audience"] = TestAudience,
})
.Build();
Assert.Throws<InvalidOperationException>(() => new JwtTokenService(config));
}
[Fact]
public void Constructor_MissingIssuer_Throws()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Jwt:Secret"] = TestSecret,
["Jwt:Audience"] = TestAudience,
})
.Build();
Assert.Throws<InvalidOperationException>(() => new JwtTokenService(config));
}
[Fact]
public void Constructor_MissingAudience_Throws()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Jwt:Secret"] = TestSecret,
["Jwt:Issuer"] = TestIssuer,
})
.Build();
Assert.Throws<InvalidOperationException>(() => 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);
}
}
+192
View File
@@ -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<string> ExtractUrls(string content) =>
(List<string>)ExtractUrlsMethod.Invoke(null, [content])!;
private static bool IsPrivateHost(Uri uri) =>
(bool)IsPrivateHostMethod.Invoke(null, [uri])!;
private static Dictionary<string, string> ParseOgTags(string html) =>
(Dictionary<string, string>)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 = """
<html><head>
<meta property="og:title" content="Test Title" />
<meta property="og:description" content="A description" />
<meta property="og:site_name" content="TestSite" />
</head></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 = """<meta content="Reversed Title" property="og:title" />""";
var tags = ParseOgTags(html);
Assert.Equal("Reversed Title", tags["title"]);
}
[Fact]
public void ParseOgTags_NoOgTags_ReturnsEmptyDictionary()
{
var html = "<html><head><title>Page</title></head></html>";
var tags = ParseOgTags(html);
Assert.Empty(tags);
}
[Fact]
public void ParseOgTags_SingleQuotes_ParsedCorrectly()
{
var html = """<meta property='og:title' content='Single Quoted' />""";
var tags = ParseOgTags(html);
Assert.Equal("Single Quoted", tags["title"]);
}
[Fact]
public void ParseOgTags_DuplicateKeys_FirstWins()
{
var html = """
<meta property="og:title" content="First" />
<meta property="og:title" content="Second" />
""";
var tags = ParseOgTags(html);
Assert.Equal("First", tags["title"]);
}
}