Compare commits

95 Commits
Author SHA1 Message Date
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
Hue ae06e7ae12 Merge pull request #7 from HueByte/dev_e2e_encrypt
feat: Implement message encryption and decryption support
2026-02-20 17:33:28 +01:00
HueByte f49148a85a fix: format table in encryption documentation for better readability 2026-02-20 17:29:52 +01:00
HueByte 575115cdca fix: update table of contents to include specific file paths for articles 2026-02-20 17:24:34 +01:00
HueByte ca9973b411 feat: Update table of contents for documentation and changelog with new sections 2026-02-20 17:20:49 +01:00
HueByte 4c850fc3c9 feat: Update encryption documentation with mermaid sequence diagram for clarity 2026-02-20 17:19:18 +01:00
HueByte 2efe54e417 feat: Implement message encryption and decryption support
- Added IMessageEncryptionService and its implementation MessageEncryptionService for handling message encryption.
- Updated ChannelsController and ChatService to encrypt messages before storing and sending.
- Introduced encryption key retrieval endpoint in ServerController.
- Modified EchoHubDbContext to accommodate increased message content and embed JSON lengths for encrypted data.
- Created migrations to support encryption-related database changes.
- Enhanced FirstRunSetup to ensure encryption key is generated if not present.
- Updated appsettings.example.json to include encryption configuration.
- Added comprehensive unit tests for encryption service and compatibility tests between client and server encryption.
2026-02-20 17:16:32 +01:00
Hue dfa1b21d32 Merge pull request #6 from HueByte/dev
Dev merge
2026-02-19 22:49:14 +01:00
HueByte 981875c495 fix: correct code block syntax for notification sound file path 2026-02-19 22:48:00 +01:00
HueByte c90ed27194 chore: update changelog for v0.2.3 with new features and improvements 2026-02-19 22:46:49 +01:00
HueByte 8f46ea0f2c Dotnet format 2026-02-19 22:45:47 +01:00
HueByte f20ce3d50f feat: add test sound command and enable notifications by default 2026-02-19 22:42:20 +01:00
HueByte 107152298e feat: add force disconnect handling and improve user ban notifications 2026-02-19 22:09:54 +01:00
HueByte 7a8b5f02d3 feat: implement notification sound functionality with user customization options 2026-02-19 22:03:16 +01:00
HueByte b508a5854a feat: Add notification sound functionality and embed support
- Introduced NotificationConfig to manage notification settings, including sound file.
- Added Notification.mp3 asset for notifications.
- Implemented NotificationSoundService to handle playing notification sounds.
- Enhanced ChatRenderer and MainWindow to support emoji rendering.
- Updated MessageDto to allow multiple embeds per message.
- Modified LinkEmbedService to fetch multiple embeds from message content.
- Added data migration for legacy embed JSON format to new array format.
- Adjusted embed handling in ChatService and IrcMessageFormatter to accommodate multiple embeds.
- Updated HubConstants for new embed dimensions and limits.
2026-02-19 21:52:18 +01:00
HueByte 7167b40013 feat: implement link embed functionality to enhance message previews with metadata and thumbnails 2026-02-19 21:21:51 +01:00
HueByte 6965ba573e fix: enhance chat message handling by implementing full Unicode support and collapsing excessive newlines 2026-02-19 21:11:06 +01:00
HueByte 9f303b5311 feat: enhance chat message handling by converting emojis to text shortcodes and improving newline sanitization 2026-02-19 20:41:36 +01:00
HueByte 8fc95e8189 fix: resolve crashes and command issues with emoji handling, file paths, and avatar rendering; implement newline spam protection 2026-02-19 20:01:57 +01:00
HueByte 5d51558c5c feat: implement newline sanitization in chat messages to prevent excessive newlines 2026-02-19 20:00:51 +01:00
HueByte 7181748d40 feat: enhance profile view dialog to render ASCII avatars with color tags 2026-02-19 19:48:48 +01:00
HueByte 0dbb02d12a feat: release v0.2.3 with moderation system, private channels, and UI enhancements 2026-02-19 19:37:45 +01:00
HueByte 38b60e5626 feat: implement channel membership management and ensure default channels are public 2026-02-19 19:21:27 +01:00
HueByte 246eb2b0bb feat: add @mention highlighting in chat lines with new SplitMentions method 2026-02-19 18:59:12 +01:00
HueByte d9a66749e7 feat: implement data migration service to convert ANSI escape codes to printable color tags 2026-02-19 18:57:57 +01:00
HueByte 8dfb1a4fb8 feat: add IsPublic property to channels and enhance channel creation with visibility options 2026-02-19 18:54:50 +01:00
HueByte 3a0ea0d321 feat: enhance file upload and URL sending with optional size parameter 2026-02-19 18:27:02 +01:00
HueByte baebc093f5 feat: implement background update check and add version notification in AppOrchestrator 2026-02-19 18:22:33 +01:00
HueByte b4c3ebd254 feat: enhance profile editing with avatar support and UI adjustments
- Added AvatarPath to ProfileEditResult for user profile updates.
- Updated ProfileEditDialog to include avatar selection with a browse button.
- Increased dialog height to accommodate new avatar input fields.
- Implemented avatar file path handling in the profile edit dialog.

feat: introduce moderation features and user roles

- Added ServerRole enum to define user roles (Member, Mod, Admin, Owner).
- Extended User model to include role, mute, and ban status.
- Created ModerationController for user role assignment, kicking, banning, and muting.
- Implemented methods in IChatBroadcaster and SignalRBroadcaster for user moderation actions.
- Updated database schema with new columns for user roles and moderation states.

fix: ensure muted users cannot send messages

- Added mute status checks in ChatService to prevent message sending for muted users.
- Updated user presence and status handling to reflect role changes and moderation actions.

chore: update constants for ASCII art rendering

- Introduced AsciiArtHeightHalfBlock constant for improved ASCII art rendering.
2026-02-19 17:47:10 +01:00
HueByte 257ac34224 feat: Add heartbeat handling to ServerDirectoryService for connection health checks 2026-02-19 15:17:19 +01:00
Hue 159a28e890 Merge pull request #5 from HueByte/dev
Dev merge
2026-02-19 14:52:04 +01:00
HueByte 328821cd14 refactor: Update changelog for v0.2.2 to reflect startup and shutdown fixes, and standardize constructor injection 2026-02-19 14:45:14 +01:00
HueByte 46a30c6b80 refactor: Remove diagnostic logging from IrcGatewayService, Program, and ServerDirectoryService 2026-02-19 14:22:03 +01:00
HueByte 8dfdc163bf refactor: Update IrcGatewayService to use IServiceProvider for dependency resolution
feat: Simplify IChatBroadcaster registration in IrcServiceExtensions
feat: Add diagnostic logging for IRC configuration and environment
2026-02-19 14:09:56 +01:00
HueByte 13297fd017 Refactor classes to use constructor injection for dependencies
- Updated IrcBroadcaster to use constructor injection for IrcGatewayService.
- Refactored JwtTokenService to initialize configuration values in the constructor.
- Modified AuthController to use constructor injection for EchoHubDbContext and JwtTokenService.
- Refactored ChannelsController to utilize constructor injection for dependencies.
- Updated FilesController to use constructor injection for FileStorageService.
- Refactored ServerController to initialize EchoHubDbContext and IConfiguration via constructor.
- Modified UsersController to use constructor injection for EchoHubDbContext and ImageToAsciiService.
- Updated EchoHubDbContext to use constructor for DbContextOptions.
- Refactored ChatHub to use constructor injection for IChatService and ILogger.
- Modified ChatService to utilize constructor injection for dependencies.
- Refactored ServerDirectoryService to use constructor injection for IConfiguration, PresenceTracker, and ILogger.
2026-02-19 14:04:19 +01:00
HueByte 0422066851 refactor: Update SignalRBroadcaster to use IServiceProvider for hub context retrieval 2026-02-19 13:54:42 +01:00
HueByte cf5ef80c08 feat: Enhance diagnostic logging for service resolution during startup 2026-02-19 13:48:26 +01:00
HueByte c8e33c96ce feat: Add diagnostic logging for hosted services resolution and startup timing 2026-02-19 13:37:51 +01:00
HueByte 2a6dbb4461 feat: Add diagnostic hooks and heartbeat logging for application lifecycle events 2026-02-19 13:30:36 +01:00
HueByte caba400f43 Temp diagnostics 2026-02-19 13:22:13 +01:00
HueByte 552fe6afa3 feat: Release v0.2.2 with shutdown improvements and connection handling fixes 2026-02-19 13:01:51 +01:00
Hue 87e8df4ec5 Merge pull request #4 from HueByte/dev
fix: Improve shutdown process for IRC Gateway and Server Directory se…
2026-02-19 12:31:38 +01:00
HueByte 4c192717de feat: Release v0.2.1 with shutdown and CI fixes 2026-02-19 12:29:45 +01:00
HueByte 1bf32450fd fix: Improve shutdown process for IRC Gateway and Server Directory services 2026-02-19 12:25:33 +01:00
Hue 963c6d3384 Merge pull request #3 from HueByte/dev
Dev merge
2026-02-19 11:39:54 +01:00
HueByte fbc75ec744 fix: Update CI workflow to trigger only on master branch for push events 2026-02-19 11:38:56 +01:00
HueByte 7bd99be41e feat: Add IRC gateway functionality and update documentation 2026-02-19 11:34:26 +01:00
HueByte cb6b9d1e55 feat: Implement IRC Gateway Service and related functionality
- Add IrcGatewayService to handle IRC connections and commands.
- Create IrcMessage class for parsing IRC protocol lines.
- Implement IrcMessageFormatter for formatting messages as IRC lines.
- Define IrcNumericReply constants for IRC numeric replies.
- Add IrcOptions class for configuration settings related to IRC.
- Create IrcServiceExtensions for adding IRC services to the application.
- Refactor ChannelsController to use IChatService for broadcasting messages and channel updates.
- Update ChatHub to utilize IChatService for user connection and message handling.
- Introduce ChatService to manage chat-related operations and interactions.
- Implement SignalRBroadcaster for broadcasting messages to SignalR clients.
- Update PresenceTracker to retrieve usernames for connections.
- Modify appsettings.example.json to include IRC configuration options.
- Update solution file to include the new IRC project.
2026-02-19 11:31:20 +01:00
Hue 441aa679f6 Merge pull request #2 from HueByte/dev
dev merge
2026-02-19 11:21:12 +01:00
HueByte da7c16d5d0 feat: Add CI and Release workflows for automated markdown linting, formatting checks, and deployment 2026-02-19 11:18:30 +01:00
HueByte defc5873fe fix: Update git log command to reference HEAD for more accurate commit history 2026-02-19 11:03:20 +01:00
HueByte 7952c13c2b feat: Allow PR checks on dev branch and standardize dotnet version formatting 2026-02-19 11:01:11 +01:00
Hue 844313a8c6 Merge pull request #1 from HueByte/dev
feat: Enhance server connection handling with exponential backoff and…
2026-02-19 10:52:05 +01:00
HueByte efc6aa8a02 feat: Update changelog for v0.1.1 release and increment version to 0.1.1 2026-02-19 10:46:45 +01:00
HueByte ff184da095 feat: Enhance server connection handling with exponential backoff and improved reconnection logic 2026-02-19 10:44:00 +01:00
159 changed files with 14662 additions and 1525 deletions
@@ -1,8 +1,11 @@
name: PR Check
name: CI
on:
pull_request:
push:
branches: [master]
pull_request:
branches: [master, dev]
workflow_dispatch:
jobs:
lint-markdown:
@@ -19,6 +22,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
# TEMPORARY: Terminal.Gui submodule's nuget.config breaks restore — remove until PR #4234 is merged
- name: Remove submodule NuGet config
run: rm -f src/Terminal.Gui/nuget.config
- name: Setup .NET 10
uses: actions/setup-dotnet@v4
@@ -26,27 +35,37 @@ jobs:
dotnet-version: '10.0.x'
- name: Check formatting
run: dotnet format src/EchoHub.slnx --verify-no-changes --verbosity diagnostic
run: dotnet format src/EchoHub.slnx --verify-no-changes --verbosity diagnostic --exclude src/Terminal.Gui/
test:
build-and-test:
name: Build & Test
needs: [lint-markdown, format-check]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: recursive
# TEMPORARY: Terminal.Gui submodule's nuget.config breaks restore — remove until PR #4234 is merged
- name: Remove submodule NuGet config
run: rm -f src/Terminal.Gui/nuget.config
- name: Check for src/ changes
id: changes
env:
EVENT: ${{ github.event_name }}
BEFORE: ${{ github.event.before }}
BASE_REF: ${{ github.base_ref }}
run: |
git fetch origin "$BASE_REF" --depth=1
CHANGED=$(git diff --name-only "origin/$BASE_REF"...HEAD -- 'src/' | wc -l)
if [ "$CHANGED" -gt 0 ]; then
echo "src_changed=true" >> "$GITHUB_OUTPUT"
if [ "$EVENT" = "pull_request" ]; then
git fetch origin "$BASE_REF" --depth=1
CHANGED=$(git diff --name-only "origin/$BASE_REF"...HEAD -- 'src/' | wc -l)
elif [ -z "$BEFORE" ] || [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
CHANGED=1
else
echo "src_changed=false" >> "$GITHUB_OUTPUT"
CHANGED=$(git diff --name-only "$BEFORE" HEAD -- 'src/' | wc -l)
fi
[ "$CHANGED" -gt 0 ] && echo "src_changed=true" >> "$GITHUB_OUTPUT" || echo "src_changed=false" >> "$GITHUB_OUTPUT"
- name: Setup .NET 10
if: steps.changes.outputs.src_changed == 'true'
+6
View File
@@ -16,6 +16,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
# TEMPORARY: Terminal.Gui submodule's nuget.config breaks restore — remove until PR #4234 is merged
- name: Remove submodule NuGet config
run: rm -f src/Terminal.Gui/nuget.config
- name: Setup .NET 10
uses: actions/setup-dotnet@v4
@@ -1,4 +1,4 @@
name: CI / Release
name: Release
on:
push:
@@ -9,96 +9,33 @@ permissions:
contents: write
jobs:
lint-markdown:
name: Markdown Lint
release:
name: Create Release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run markdownlint
run: bash scripts/lint-markdown.sh
format-check:
name: Format Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET 10
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Check formatting
run: dotnet format src/EchoHub.slnx --verify-no-changes --verbosity diagnostic
detect-changes:
name: Detect Changes
runs-on: ubuntu-latest
outputs:
src_changed: ${{ steps.check.outputs.src_changed }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: recursive
- name: Check for changes
id: check
# TEMPORARY: Terminal.Gui submodule's nuget.config breaks restore — remove until PR #4234 is merged
- name: Remove submodule NuGet config
run: rm -f src/Terminal.Gui/nuget.config
- name: Check for src/ changes
id: changes
env:
BEFORE: ${{ github.event.before }}
run: |
if [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
# Initial push — treat everything as changed
if [ -z "$BEFORE" ] || [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
echo "src_changed=true" >> "$GITHUB_OUTPUT"
else
SRC_CHANGED=$(git diff --name-only "$BEFORE" HEAD -- 'src/' | wc -l)
[ "$SRC_CHANGED" -gt 0 ] && echo "src_changed=true" >> "$GITHUB_OUTPUT" || echo "src_changed=false" >> "$GITHUB_OUTPUT"
CHANGED=$(git diff --name-only "$BEFORE" HEAD -- 'src/' | wc -l)
[ "$CHANGED" -gt 0 ] && echo "src_changed=true" >> "$GITHUB_OUTPUT" || echo "src_changed=false" >> "$GITHUB_OUTPUT"
fi
test:
name: Build & Test
needs: [lint-markdown, format-check, detect-changes]
if: needs.detect-changes.outputs.src_changed == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET 10
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('src/**/*.csproj') }}
restore-keys: nuget-${{ runner.os }}-
- name: Restore dependencies
run: dotnet restore src/EchoHub.slnx
- name: Build
run: dotnet build src/EchoHub.slnx --no-restore --configuration Release
- name: Test
run: dotnet test src/EchoHub.slnx --no-build --configuration Release --verbosity normal
release:
name: Create Release
needs: [lint-markdown, format-check, detect-changes, test]
if: needs.detect-changes.outputs.src_changed == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET 10
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Read version
if: steps.changes.outputs.src_changed == 'true'
id: version
run: |
VERSION=$(grep -oP '(?<=<Version>)[^<]+' src/Directory.Build.props)
@@ -106,6 +43,7 @@ jobs:
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
- name: Check if release exists
if: steps.changes.outputs.src_changed == 'true'
id: check_release
run: |
if gh release view "${{ steps.version.outputs.tag }}" &>/dev/null; then
@@ -116,40 +54,46 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Setup .NET 10
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Publish Server win-x64
if: steps.check_release.outputs.exists == 'false'
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r win-x64 --self-contained true -o publish/server-win-x64
- name: Publish Server linux-x64
if: steps.check_release.outputs.exists == 'false'
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r linux-x64 --self-contained true -o publish/server-linux-x64
- name: Publish Server osx-x64
if: steps.check_release.outputs.exists == 'false'
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r osx-x64 --self-contained true -o publish/server-osx-x64
- name: Publish Server osx-arm64
if: steps.check_release.outputs.exists == 'false'
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r osx-arm64 --self-contained true -o publish/server-osx-arm64
- name: Publish Client win-x64
if: steps.check_release.outputs.exists == 'false'
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r win-x64 --self-contained true -o publish/client-win-x64
- name: Publish Client linux-x64
if: steps.check_release.outputs.exists == 'false'
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r linux-x64 --self-contained true -o publish/client-linux-x64
- name: Publish Client osx-x64
if: steps.check_release.outputs.exists == 'false'
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r osx-x64 --self-contained true -o publish/client-osx-x64
- name: Publish Client osx-arm64
if: steps.check_release.outputs.exists == 'false'
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r osx-arm64 --self-contained true -o publish/client-osx-arm64
- name: Zip artifacts
if: steps.check_release.outputs.exists == 'false'
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
run: |
cd publish
zip -r ../EchoHub-Server-win-x64.zip server-win-x64/
@@ -161,12 +105,41 @@ jobs:
zip -r ../EchoHub-Client-osx-x64.zip client-osx-x64/
zip -r ../EchoHub-Client-osx-arm64.zip client-osx-arm64/
- name: Build release notes
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
run: |
TAG="${{ steps.version.outputs.tag }}"
CHANGELOG_URL="https://huebyte.github.io/EchoHub/changelog/${TAG}.html"
REPO="https://github.com/${{ github.repository }}"
PREV_TAG=$(git tag --sort=-v:refname | grep -v "^${TAG}$" | head -n 1)
{
echo "📋 **[Full Changelog](${CHANGELOG_URL})**"
echo ""
echo "---"
echo ""
echo "### Commits"
echo ""
if [ -n "$PREV_TAG" ]; then
git log "${PREV_TAG}..HEAD" --pretty=format:"- %s (%h)" --no-merges
else
git log HEAD --pretty=format:"- %s (%h)" --no-merges
fi
echo ""
echo ""
if [ -n "$PREV_TAG" ]; then
echo "*Version diff: [${PREV_TAG}...${TAG}](${REPO}/compare/${PREV_TAG}...${TAG})*"
else
echo "*Version diff: [${TAG}](${REPO}/commits/${TAG})*"
fi
} > release-notes.md
- name: Create GitHub Release
if: steps.check_release.outputs.exists == 'false'
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
run: |
gh release create "${{ steps.version.outputs.tag }}" \
--title "EchoHub ${{ steps.version.outputs.tag }}" \
--generate-notes \
--notes-file release-notes.md \
EchoHub-Server-win-x64.zip \
EchoHub-Server-linux-x64.zip \
EchoHub-Server-osx-x64.zip \
+3
View File
@@ -433,3 +433,6 @@ src/EchoHub.Server/uploads/*
# DocFx generated output
docs/_site/
docs/_api_meta/
# Diff files
*.diff
+3
View File
@@ -0,0 +1,3 @@
[submodule "src/Terminal.Gui"]
path = src/Terminal.Gui
url = https://github.com/HueByte/Terminal.Gui.git
+170 -25
View File
@@ -13,11 +13,19 @@
<p align="center">
<a href="#what-is-this">What</a> •
<a href="#getting-started">Setup</a> •
<a href="#irc-gateway">IRC</a> •
<a href="#deployment-with-nginx">Deploy</a> •
<a href="#client-commands">Commands</a> •
<a href="#configuration">Config</a> •
<a href="#license">License</a>
</p>
<p align="center">
<a href="https://echohub.voidcube.cloud/">Website</a> •
<a href="https://echohub.voidcube.cloud/servers">Public Servers</a> •
<a href="https://huebyte.github.io/EchoHub/">Documentation</a>
</p>
<p align="center">
<img alt=".NET 10" src="https://img.shields.io/badge/.NET-10-512BD4?style=flat-square&logo=dotnet&logoColor=white" />
<img alt="SignalR" src="https://img.shields.io/badge/SignalR-Real--time-0078D4?style=flat-square" />
@@ -40,21 +48,25 @@ Each server is fully independent — no central authority, no account federation
```mermaid
graph TD
subgraph Server["Server"]
ChatSvc["ChatService"]
Hub["SignalR ChatHub"]
IRC["IRC Gateway :6667"]
Auth["JWT Auth"]
DB["SQLite DB (EF Core)"]
Files["File Storage"]
end
subgraph Client["Client"]
subgraph Clients["Clients"]
TUI["Terminal GUI (TUI)"]
Theme["Theme Engine"]
API["API Client (auto-refresh)"]
IRCClient["IRC Client (irssi, WeeChat, ...)"]
end
API -- "WebSocket" --> Hub
API -- "REST" --> Auth
Hub --> DB
TUI -- "WebSocket" --> Hub
TUI -- "REST" --> Auth
IRCClient -- "TCP" --> IRC
Hub --> ChatSvc
IRC --> ChatSvc
ChatSvc --> DB
Auth --> DB
Files --> DB
```
@@ -65,6 +77,7 @@ graph TD
- **Self-hostable** — your server, your rules, your data
- **Real-time messaging** via SignalR WebSockets
- **IRC gateway** — native IRC clients connect alongside TUI users, full cross-protocol messaging
- **JWT auth** with short-lived access tokens and 30-day refresh tokens
- **Channels** — create, set topics, delete (no 47-step permission wizard required)
- **File & image uploads** with actual validation (magic bytes, not just trusting the extension)
@@ -122,6 +135,66 @@ Connect, register, chat. That's the whole onboarding flow.
dotnet build src/EchoHub.slnx
```
## IRC Gateway
EchoHub includes a built-in IRC protocol gateway. Any standard IRC client can connect to the same server and chat alongside TUI users — messages flow both ways in real time.
### Enable It
In the server's `appsettings.json`:
```json
{
"Irc": {
"Enabled": true,
"Port": 6667,
"ServerName": "echohub",
"Motd": "Welcome to EchoHub IRC Gateway!"
}
}
```
### Connect
```bash
# irssi
irssi -c your-server.com -p 6667 -w <password> -n <username>
# WeeChat
/server add echohub your-server.com/6667 -password=<password> -nicks=<username>
/connect echohub
```
IRC users must have an existing EchoHub account (no registration via IRC). Auth works via `PASS`/`NICK`/`USER` or SASL PLAIN.
### What Works
| Feature | How it maps to IRC |
| ------- | ------------------ |
| Text messages | Standard `PRIVMSG` (long messages split at ~400 byte chunks) |
| Images | `[Image: filename]` + download URL + ASCII art line-by-line |
| File uploads | `[File: filename] /api/files/{id}` |
| Channels | `JOIN`, `PART`, `NAMES`, `TOPIC`, `LIST` |
| Presence | `AWAY`, `WHO`, `WHOIS` |
| Status | Maps to IRC away/here |
### TLS
If running behind **nginx** (recommended), let nginx handle TLS -- see [Deployment with nginx](#deployment-with-nginx) below.
For direct TLS without a reverse proxy, the IRC gateway can terminate TLS itself:
```json
{
"Irc": {
"TlsEnabled": true,
"TlsPort": 6697,
"TlsCertPath": "/path/to/cert.pfx",
"TlsCertPassword": "your-password"
}
}
```
## Client Commands
| Command | Description |
@@ -172,35 +245,107 @@ dotnet build src/EchoHub.slnx
| `Jwt:Secret` | *(auto-generated)* | JWT signing key |
| `Server:Name` | `My EchoHub Server` | Server display name |
| `Server:Description` | `A self-hosted EchoHub chat server` | Server description |
| `Server:PublicServer` | `false` | List on the [public directory](https://echohub.voidcube.cloud/servers) |
| `Server:PublicHost` | *(empty)* | Public hostname for directory listing |
| `Irc:Enabled` | `false` | Enable the IRC gateway |
| `Irc:Port` | `6667` | IRC listen port |
| `Irc:TlsEnabled` | `false` | Enable TLS for IRC |
| `Irc:TlsPort` | `6697` | IRC TLS port |
| `Irc:ServerName` | `echohub` | IRC server name in protocol messages |
| `Irc:Motd` | `Welcome to EchoHub IRC Gateway!` | Message of the day |
| `Cors:AllowedOrigins` | *(all origins)* | CORS whitelist |
Logging uses Serilog — console + daily rolling files with 14-day retention. Configure it in the `Serilog` section of appsettings.
Logging uses Serilog — console + daily rolling files with 14-day retention. Configure in the `Serilog` section.
## Deployment with nginx
Most production deployments run behind nginx. Here's a config that handles both the HTTP/WebSocket server and the IRC gateway:
```nginx
# HTTP + WebSocket (EchoHub Server API + SignalR)
server {
listen 443 ssl;
server_name echohub.example.com;
ssl_certificate /etc/letsencrypt/live/echohub.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/echohub.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:5000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Required for SignalR WebSocket
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $http_connection;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
# Increase max upload size for file sharing
client_max_body_size 10m;
}
# HTTP → HTTPS redirect
server {
listen 80;
server_name echohub.example.com;
return 301 https://$host$request_uri;
}
# IRC TLS (port 6697 → plain IRC on 6667)
stream {
upstream irc_backend {
server 127.0.0.1:6667;
}
server {
listen 6697 ssl;
proxy_pass irc_backend;
ssl_certificate /etc/letsencrypt/live/echohub.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/echohub.example.com/privkey.pem;
}
}
```
With this setup, keep the EchoHub IRC gateway's `TlsEnabled` set to `false` — nginx terminates TLS. See the full example at [`examples/nginx.conf`](examples/nginx.conf).
## Project Structure
```text
src/
├── EchoHub.Core/ # Shared models, DTOs, contracts, validation
│ ├── Constants/ # ValidationConstants, HubConstants
│ ├── Contracts/ # IEchoHubClient (SignalR interface)
│ ├── DTOs/ # Record DTOs
│ └── Models/ # Entity models
├── EchoHub.Core/ # Shared models, DTOs, contracts, validation
│ ├── Constants/ # ValidationConstants, HubConstants
│ ├── Contracts/ # IChatService, IChatBroadcaster, IEchoHubClient
│ ├── DTOs/ # Record DTOs
│ └── Models/ # Entity models
├── EchoHub.Server/ # ASP.NET Core server
│ ├── Auth/ # JWT token service
│ ├── Controllers/ # REST API endpoints
│ ├── Data/ # EF Core DbContext + migrations
│ ├── Hubs/ # SignalR ChatHub
│ ├── Services/ # Presence, file storage, image processing
│ └── Setup/ # First-run setup, DB initialization
├── EchoHub.Server/ # ASP.NET Core server
│ ├── Auth/ # JWT token service
│ ├── Controllers/ # REST API endpoints
│ ├── Data/ # EF Core DbContext + migrations
│ ├── Hubs/ # SignalR ChatHub
│ ├── Services/ # ChatService, presence, file storage, image processing
│ └── Setup/ # First-run setup, DB initialization
├── EchoHub.Client/ # Terminal.Gui TUI client
│ ├── Config/ # Client configuration
│ ├── Services/ # API client, SignalR connection
│ ├── Themes/ # 6 built-in themes
│ └── UI/ # MainWindow, dialogs, chat renderer
├── EchoHub.Server.Irc/ # IRC protocol gateway
│ ├── IrcGatewayService # TCP listener (BackgroundService)
│ ├── IrcCommandHandler # IRC command dispatch (JOIN, PRIVMSG, etc.)
│ ├── IrcBroadcaster # Fans chat events to IRC connections
│ └── IrcMessageFormatter # MessageDto → IRC PRIVMSG lines
── EchoHub.slnx # Solution file
── EchoHub.Client/ # Terminal.Gui TUI client
│ ├── Config/ # Client configuration
│ ├── Services/ # API client, SignalR connection
│ ├── Themes/ # 13 built-in themes
│ └── UI/ # MainWindow, dialogs, chat renderer
└── EchoHub.slnx # Solution file
```
## License
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

+5 -2
View File
@@ -5,6 +5,9 @@ Articles related to the EchoHub TUI client built with Terminal.Gui v2.
## Topics
- Terminal.Gui v2 patterns and conventions
- Theme system and customization
- Theme system and customization (including transparent theme)
- Command system reference
- Configuration management
- Configuration and session persistence
- Audio playback and file downloads
- Automatic update checking
- [Notification sounds](../../articles/notification-sounds.md)
+1 -1
View File
@@ -5,5 +5,5 @@ Articles related to the EchoHub.Core shared library.
## Topics
- Data models and DTOs
- SignalR contract interface
- Contract interfaces (IChatService, IChannelService, IChatBroadcaster, IEchoHubClient)
- Validation constants and shared rules
+6 -2
View File
@@ -10,8 +10,12 @@ Terminal.Gui v2 TUI application -- UI components, services, themes, and configur
### Core
Shared library -- DTOs, models, constants, and the SignalR client contract.
Shared library -- DTOs, models, constants, and contracts (`IChatService`, `IChannelService`, `IChatBroadcaster`, `IEchoHubClient`).
### Server
ASP.NET Core server -- controllers, hubs, authentication, and data access.
ASP.NET Core server -- controllers, hubs, ChatService, ChannelService, SignalRBroadcaster, authentication, file cleanup, and data access.
### Server.Irc
IRC protocol gateway -- TCP listener, command handler, IrcBroadcaster, and message formatter.
+5 -2
View File
@@ -6,6 +6,9 @@ Articles related to the EchoHub server built with ASP.NET Core.
- Authentication and JWT tokens
- SignalR hub and real-time messaging
- File upload and validation
- IRC gateway and protocol bridging
- ChatService and broadcaster pattern
- ChannelService and channel CRUD
- File upload, validation, and cleanup
- Rate limiting configuration
- Database schema and migrations
- Database schema, migrations, and DataMigrationService
+7 -3
View File
@@ -3,16 +3,20 @@
- name: API Reference
href: ../_api_meta/client/toc.yml
- name: Articles
href: client-articles/
href: client-articles/toc.yml
- name: Core
items:
- name: API Reference
href: ../_api_meta/core/toc.yml
- name: Articles
href: core-articles/
href: core-articles/toc.yml
- name: Server
items:
- name: API Reference
href: ../_api_meta/server/toc.yml
- name: Articles
href: server-articles/
href: server-articles/toc.yml
- name: Server.Irc
items:
- name: API Reference
href: ../_api_meta/server-irc/toc.yml
+40 -8
View File
@@ -4,6 +4,16 @@
EchoHub follows a decentralized model where each server is fully independent. There is no central authority or account federation. Users create one account per server.
The server exposes two protocol interfaces to the same chat backend:
```text
IRC Client ──► TCP :6667 ──► IrcGateway ──┐
├──► ChatService ──► DB + PresenceTracker
TUI Client ──► WebSocket ──► ChatHub ─────┘
```
Both protocols call into a shared `IChatService` for business logic. Events fan out to all registered `IChatBroadcaster` implementations (SignalR and IRC).
## Components
### EchoHub.Core
@@ -12,7 +22,7 @@ Shared library containing:
- **Models**: `User`, `Channel`, `Message`, `RefreshToken`
- **DTOs**: Record types for API requests/responses
- **Contracts**: `IEchoHubClient` -- the strongly-typed SignalR client interface
- **Contracts**: `IChatService` (protocol-agnostic chat operations), `IChannelService` (channel CRUD and membership), `IChatBroadcaster` (event fan-out interface), `IEchoHubClient` (SignalR client interface)
- **Constants**: `ValidationConstants` (shared regex patterns), `HubConstants`
### EchoHub.Server
@@ -20,22 +30,44 @@ Shared library containing:
ASP.NET Core web application:
- **Controllers**: REST API endpoints for auth, channels, users, files, server info
- **Hubs**: SignalR `ChatHub` for real-time messaging
- **Hubs**: SignalR `ChatHub` -- thin adapter delegating to `IChatService`
- **Auth**: JWT token service (15-min access tokens, 30-day refresh tokens)
- **Data**: EF Core with SQLite
- **Services**: 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
IRC protocol gateway (separate project for clean separation of concerns):
- **IrcGatewayService**: `BackgroundService` with TCP listener on configured port(s), optional TLS
- **IrcCommandHandler**: Per-client IRC command dispatch -- handles `CAP`/`SASL`, `NICK`/`USER`/`PASS`, `JOIN`/`PART`/`PRIVMSG`/`QUIT`, `NAMES`/`TOPIC`/`WHO`/`WHOIS`/`AWAY`/`LIST`/`MODE`/`MOTD`
- **IrcBroadcaster**: `IChatBroadcaster` implementation that formats chat events as IRC protocol lines, with echo suppression (IRC convention)
- **IrcMessageFormatter**: Converts `MessageDto` to IRC `PRIVMSG` lines -- splits long text at word boundaries (~400 byte chunks), sends images as ASCII art line-by-line
IRC users authenticate with existing EchoHub accounts via `PASS`/`NICK`/`USER` or SASL PLAIN (BCrypt verification against the database).
### EchoHub.Client
Terminal.Gui v2 TUI application:
- **UI**: Main window, dialogs, chat renderer with ANSI color support
- **Services**: API client with automatic token refresh, SignalR connection wrapper
- **Themes**: 6 built-in color themes
- **Config**: Client configuration management
- **Services**: API client with automatic token refresh, SignalR connection wrapper, audio playback (NetCoreAudio), automatic update checker (AlwaysUpToDate)
- **Themes**: 13 built-in color themes (including transparent theme with true terminal transparency)
- **Config**: Client configuration management with session persistence ("Remember Me" refresh tokens)
## Communication
- REST API for authentication, profile management, channel CRUD, file uploads
- SignalR WebSocket for real-time messaging and presence updates
- **REST API** for authentication, profile management, channel CRUD, file uploads
- **SignalR WebSocket** for real-time messaging and presence updates (TUI client)
- **IRC TCP** for real-time messaging via standard IRC protocol (IRC clients)
- JWT tokens passed via query string for SignalR authentication
- IRC authentication via PASS/SASL PLAIN against BCrypt password hashes
## Broadcaster Pattern
The `IChatBroadcaster` interface allows multiple protocols to receive chat events:
- **SignalRBroadcaster**: Wraps `IHubContext<ChatHub>`, filters out IRC connections
- **IrcBroadcaster**: Iterates live IRC connections in a channel, formats events as IRC protocol lines
Both are registered in DI and called by `ChatService` when events occur. This means a message sent from an IRC client appears in the TUI client, and vice versa.
+141
View File
@@ -0,0 +1,141 @@
# Message Encryption
EchoHub uses application-layer AES-256-GCM encryption to protect message content in transit between clients and the server. This is an additional layer on top of TLS, protecting against ISPs, proxies, and any middleman that can inspect HTTPS traffic (e.g. corporate proxies with trusted root CA certificates).
## How It Works
```mermaid
sequenceDiagram
participant Sender as TUI Client (Sender)
participant Server
participant Receiver as TUI Client (Receiver)
participant IRC as IRC Client
Sender->>Sender: encrypt(plaintext)
Sender->>Server: $ENC$v1$... (SignalR)
Server->>Server: decrypt → validate/sanitize
Server->>Server: fetch embeds on plaintext
Server->>Server: (optional) encrypt for DB storage
Server->>Server: encrypt(plaintext) with fresh nonce
Server->>Receiver: $ENC$v1$... (SignalR broadcast)
Receiver->>Receiver: decrypt → display
Server->>Server: decrypt for IRC
Server->>IRC: plaintext (IRC PRIVMSG)
```
1. **Client encrypts** the message before sending it over SignalR
2. **Server decrypts** to validate content, sanitize newlines, and fetch link embeds
3. **Server re-encrypts** with a fresh nonce and broadcasts to all connected SignalR clients
4. **Clients decrypt** the broadcast and display the plaintext
5. **IRC clients** receive plaintext automatically (the IRC broadcaster decrypts before forwarding)
Each encryption uses a random 12-byte nonce, so the same message produces different ciphertext every time.
## Encryption Key
A 256-bit AES key is auto-generated on first server startup and saved to `appsettings.json`:
```json
{
"Encryption": {
"Key": "base64-encoded-32-byte-key",
"EncryptDatabase": false
}
}
```
The key is generated by `FirstRunSetup` using `RandomNumberGenerator.GetBytes(32)`. If the key is missing or empty when the server starts, a new one is created automatically.
Clients fetch the key after login via an authenticated endpoint (`GET /api/server/encryption-key`). No manual configuration is needed on the client side.
## Encrypted Content Format
Encrypted content uses a self-describing format:
```text
$ENC$v1${nonce_base64}${ciphertext+tag_base64}
```
- **`$ENC$v1$`** — version prefix (allows future algorithm changes)
- **Nonce** — 12 bytes, Base64-encoded, randomly generated per message
- **Ciphertext + Tag** — AES-256-GCM output with 16-byte authentication tag appended
The authentication tag ensures both confidentiality and integrity — any tampering with the ciphertext is detected during decryption.
## Database Encryption (Optional)
By default, messages are stored as **plaintext** in the database. Transport encryption still protects everything on the wire, but the SQLite file itself contains readable messages.
To encrypt messages at rest, enable the setting:
```json
{
"Encryption": {
"Key": "...",
"EncryptDatabase": true
}
}
```
### Behavior by Setting
| Setting | DB Storage | Transport | Key Rotation Risk |
| ----------------- | ---------- | --------- | ---------------------------------------------- |
| `false` (default) | Plaintext | Encrypted | None - stored data is unaffected |
| `true` | Encrypted | Encrypted | Changing the key makes old messages unreadable |
### Mixed Content
The server handles mixed encrypted/plaintext content in the database gracefully. When reading messages:
- Content starting with `$ENC$v1$` is decrypted
- Everything else is treated as plaintext
This means you can safely toggle `EncryptDatabase` at any time. Old messages remain readable regardless of the current setting.
### Enabling Encryption at Rest
When you set `EncryptDatabase: true`, only **new messages** are encrypted going forward. Existing plaintext messages in the database are not retroactively encrypted. This is intentional — it keeps key rotation safe and avoids irreversible bulk changes.
### Key Rotation
Changing the encryption key is safe:
- **Plaintext messages** — always readable regardless of key
- **Messages encrypted with the old key** — will show `[encrypted message — decryption failed]`
- **New messages** — encrypted with the new key going forward
If you need to recover old encrypted messages, restore the original key from a backup of `appsettings.json`. Since plaintext messages are never retroactively encrypted, you'll never lose access to your entire history from a key change.
## Security Considerations
### What This Protects Against
- **Passive network sniffing** — messages are encrypted even if captured off the wire
- **ISP/proxy inspection** — content is encrypted at the application layer, independent of TLS
- **Database theft** (when `EncryptDatabase: true`) — SQLite file contains only ciphertext
### Limitations
- **TLS-inspecting proxies** — if a corporate proxy terminates TLS with a trusted root CA, it can intercept the key exchange (`GET /api/server/encryption-key`) and read all traffic. A future upgrade to ECDH key exchange would address this.
- **Server has full access** — the server decrypts all messages for processing. This is not end-to-end encryption between users; it's transport encryption between client and server.
- **IRC clients receive plaintext** — IRC is an open protocol and third-party clients cannot participate in the encryption scheme.
## Troubleshooting
### Messages show `[encrypted message — decryption failed, try re-logging to fetch the latest key]`
The client's encryption key doesn't match the server's. This happens when:
- The server's encryption key was rotated while the client was connected
- The client cached a stale key
**Fix**: Disconnect and reconnect (re-login). The client fetches the current key on each login.
### Messages show `[encrypted message — decryption failed]` in channel history
The server cannot decrypt messages stored in the database. This happens when:
- `EncryptDatabase` was enabled, and the key was changed afterwards
**Fix**: Restore the original key from a backup. There is no way to recover messages encrypted with a lost key.
+33
View File
@@ -4,6 +4,8 @@
- [.NET 10 SDK](https://dotnet.microsoft.com/download)
Or grab a self-contained binary from [Releases](https://github.com/HueByte/EchoHub/releases) -- no runtime needed.
## Run the Server
```bash
@@ -24,6 +26,37 @@ dotnet run --project src/EchoHub.Client
Connect to a server, register an account, and start chatting.
## Connect via IRC
Enable the IRC gateway in the server's `appsettings.json`:
```json
{
"Irc": {
"Enabled": true,
"Port": 6667
}
}
```
Then connect with any standard IRC client:
```bash
irssi -c localhost -p 6667 -w <password> -n <username>
```
IRC users must have an existing EchoHub account. Authentication works via `PASS`/`NICK`/`USER` or SASL PLAIN. Messages flow bidirectionally between IRC and TUI clients.
For TLS, set `TlsEnabled: true`, `TlsPort: 6697`, and provide a PKCS#12 certificate path.
See the [Architecture](architecture.md) page for details on how the IRC gateway integrates with the chat service.
## Configuration
Server configuration is in `appsettings.json` (auto-generated on first run). See the [example config](https://github.com/HueByte/EchoHub/blob/master/src/EchoHub.Server/appsettings.example.json) for all available options.
To list your server on the [public directory](https://echohub.voidcube.cloud/servers), set `Server:PublicServer` to `true` and `Server:PublicHost` to your server's public address.
## Build from Source
```bash
+43
View File
@@ -0,0 +1,43 @@
# Notification Sounds
EchoHub can play a notification sound when someone @mentions you. This is **disabled by default** and must be enabled in your profile settings.
## Enabling Notifications
Open your profile (`/profile`) and check the **"Notification sound on @mention"** checkbox, then save. You can also adjust the **Volume** (0-100, default 30). All settings are persisted in `~/.echohub/config.json`.
## Customizing the Sound
The client ships with a default `Notification.mp3` in the `Assets` folder. To use your own notification sound, replace the file at:
```text
<app-directory>/Assets/Notification.mp3
```
The file must be a valid `.mp3` or `.wav` audio file. The replacement takes effect on the next app launch.
Alternatively, set a custom path in `~/.echohub/config.json`:
```json
{
"notifications": {
"enabled": true,
"volume": 30,
"soundFile": "/path/to/your/sound.mp3"
}
}
```
When `soundFile` is set, EchoHub uses that file instead of the bundled default.
## Disabling Notifications
Uncheck the option in your profile, or edit the config directly:
```json
{
"notifications": {
"enabled": false
}
}
```
+4
View File
@@ -2,3 +2,7 @@
href: getting-started.md
- name: Architecture
href: architecture.md
- name: Encryption
href: encryption.md
- name: Notification Sounds
href: notification-sounds.md
+7
View File
@@ -4,4 +4,11 @@ Release history for EchoHub.
## Releases
- [v0.2.5](v0.2.5.md) - Session Persistence, Auto-Updates, Audio & Transparent Theme
- [v0.2.4](v0.2.4.md) - E2E Message Encryption
- [v0.2.3](v0.2.3.md) - Moderation, Embeds & UI Overhaul
- [v0.2.2](v0.2.2.md) - Startup & Shutdown Fixes
- [v0.2.1](v0.2.1.md) - Shutdown & CI Fixes
- [v0.2.0](v0.2.0.md) - IRC Gateway
- [v0.1.1](v0.1.1.md) - Directory Connection Self-Healing
- [v0.1.0](v0.1.0.md) - Initial Release
+14
View File
@@ -1,4 +1,18 @@
- name: Overview
href: index.md
- name: v0.2.5
href: v0.2.5.md
- name: v0.2.4
href: v0.2.4.md
- name: v0.2.3
href: v0.2.3.md
- name: v0.2.2
href: v0.2.2.md
- name: v0.2.1
href: v0.2.1.md
- name: v0.2.0
href: v0.2.0.md
- name: v0.1.1
href: v0.1.1.md
- name: v0.1.0
href: v0.1.0.md
+7
View File
@@ -0,0 +1,7 @@
# v0.1.1 - Directory Connection Self-Healing
## Fixes
- Server now reconnects to the EchoHubSpace directory indefinitely when the API goes down, using exponential backoff (2s → 30s cap)
- If automatic reconnect is exhausted, the connection is rebuilt from scratch automatically
- Initial connection attempts also use exponential backoff instead of a fixed 30s delay
+21
View File
@@ -0,0 +1,21 @@
# v0.2.0 - IRC Gateway
## Features
- **IRC protocol gateway** -- native IRC clients (irssi, WeeChat, HexChat, etc.) can connect to EchoHub servers
- **Cross-protocol messaging** -- messages flow bidirectionally between IRC and TUI clients in real time
- **SASL PLAIN authentication** -- IRC clients can authenticate via SASL or traditional PASS/NICK/USER
- **Full IRC command support** -- JOIN, PART, PRIVMSG, QUIT, NAMES, TOPIC, WHO, WHOIS, AWAY, LIST, MODE, MOTD
- **TLS support** -- optional encrypted IRC connections on port 6697
- **Image-to-IRC formatting** -- images appear as ASCII art line-by-line with download URLs
- **Message splitting** -- long messages automatically split at word boundaries (~400 byte chunks)
- **Configurable MOTD** -- server message of the day for IRC clients
## Architecture Changes
- Extracted shared business logic from `ChatHub` into protocol-agnostic `IChatService`
- Introduced `IChatBroadcaster` pattern for multi-protocol event fan-out
- `ChatHub` refactored to thin adapter delegating to `IChatService`
- `ChannelsController` updated to use `IChatService` for broadcasts
- New `EchoHub.Server.Irc` project for clean separation of concerns
- `IChatService` and `IChatBroadcaster` interfaces live in `EchoHub.Core/Contracts`
+13
View File
@@ -0,0 +1,13 @@
# v0.2.1 - Shutdown & CI Fixes
## Fixes
- Fixed server hanging on Ctrl+C when the directory server is unreachable — `StopAsync` now cancels background services before disposing connections
- Fixed IRC gateway shutdown blocking indefinitely on unresponsive clients — send operations are now bounded to 2 seconds
- Fixed CI release workflow not having full git history for building release notes (`fetch-depth: 0`)
- Fixed `workflow_dispatch` trigger breaking change detection when `github.event.before` is empty
## Improvements
- GitHub releases now include a commit list and version diff link instead of generic auto-generated notes
- GitHub releases link to the full changelog on the docs site
+14
View File
@@ -0,0 +1,14 @@
# v0.2.2 - Startup & Shutdown Fixes
## Fixes
- Fixed server hanging on startup when IRC gateway is enabled — circular DI dependency between `IrcGatewayService``IChatService``IChatBroadcaster``IrcBroadcaster` caused the DI container to deadlock
- Fixed `SignalRBroadcaster` eagerly resolving `IHubContext<ChatHub>` during DI construction, which could deadlock on some platforms — now lazy-resolves via `IServiceProvider` on first use
- Simplified IRC service registration to use standard `AddSingleton<IChatBroadcaster, IrcBroadcaster>` instead of manual factory, breaking the circular resolution chain
- Fixed server hanging on Ctrl+C — replaced `await using` with explicit dispose bounded to 3 seconds, so a stuck `HubConnection` can no longer block shutdown
- Reduced host shutdown timeout from 30s (default) to 5s
- Caught `OperationCanceledException` in the directory service reconnect loop so cancellation exits immediately instead of propagating through dispose
## Refactoring
- Replaced primary constructors with standard constructor injection across all server classes for consistency
+95
View File
@@ -0,0 +1,95 @@
# v0.2.3 - Moderation, Embeds & UI Overhaul
## Features
### Moderation System
- Added server roles: Owner, Admin, Mod, Member — first registered user is automatically Owner
- New `/kick`, `/ban`, `/unban`, `/mute`, `/unmute`, `/role`, `/nuke` commands for moderators and admins
- `ModerationController` with full REST API for role assignment, kicks, bans, mutes, message deletion, and channel nuking
- Mutes support optional duration (auto-expire) and blocked users cannot log in
- Role claim included in JWT tokens; role badges shown in the online users panel
- Kicked and banned users are forcibly disconnected in real time — server cleans up presence, broadcasts departures, and signals client disconnect
- Works for both SignalR and IRC connections; client shows an error dialog with the reason
### Private Channels
- Channels can be created as public or private via a checkbox in the Create Channel dialog
- Public channels are visible to all users; private channels only appear for members who joined them
- Persistent channel membership tracked in the database (`ChannelMembership` table)
- `GET /api/channels` returns the combined list: public channels + user's joined private channels
- Channel creators are automatically added as members
### OpenGraph Link Embeds
- Messages containing URLs now show a rich preview below the message text
- Multiple URLs per message supported (up to 3) — each gets its own embed
- Server-side fetching: detects URLs in a message, fetches each page, and parses OpenGraph meta tags (`og:title`, `og:description`, `og:site_name`)
- Embeds are persisted in the database as a JSON array and included in channel history
- TUI client renders embeds with a `▏` left border bar — site name and border in blue, title in white, description in gray; text word-wraps at actual viewport width
- IRC gateway receives a text-only embed preview (site name, title, description)
- Falls back to `<title>` tag when no OG tags are present; gracefully skips if no useful metadata is found
- 5-second fetch timeout ensures message delivery is never significantly delayed
- SSRF protection rejects private/loopback IP addresses before fetching
### Notification Sounds
- Incoming messages play a notification sound when the terminal is not focused
- Embedded MP3 asset with cross-platform playback support
### Online Users Panel
- Collapsible right-side panel showing online users in the current channel (toggle with F2)
- Users displayed with status indicators, role badges, and their custom nickname colors
- Panel updates on join, leave, and status change events
### @mention Highlighting
- `@username` text rendered in orange accent color in all messages
- Messages mentioning the current user get a full-line amber background highlight
- Works across multi-line messages and continuation lines
### ASCII Art Improvements
- Half-block character rendering (`▀`/`█`) with separate foreground + background colors for 2x vertical resolution
- Switched from ANSI escape codes to printable color tags (`{F:RRGGBB}`, `{B:RRGGBB}`, `{X}`) — no control bytes in stored content
- Optional size parameter for `/send` command: `-s` (40x40), `-m` (80x80, default), `-l` (120x120)
- IRC gateway converts color tags back to ANSI for IRC client compatibility
### Client UI
- Version number shown in the status bar
- Custom colored rendering for channel list (active indicator, unread count badges)
- Avatar upload field added to the profile edit dialog (file path or URL)
- Profile avatar now renders with full color tag support in the profile view dialog
- Update check notification on connect — shows a system message if a newer GitHub release exists
- Chat messages no longer show selection/focus highlight
- Exit shortcut changed from Ctrl+C to Alt+Q — frees Ctrl+C for copy
- Default history increased from 50 to 100 messages on channel join
## Fixes
- Fixed `#general` channel not visible after adding the `IsPublic` column — migration default changed to `true` and startup service ensures it
- Fixed channels disappearing when creating a new channel — replaced full re-fetch with incremental updates
- Wired `OnChannelUpdated` SignalR event so new public channels appear for all connected users in real time
- Fixed color tag parser using wrong regex group numbers (6,7,8 instead of 1,2,3) — new ASCII art was rendering without colors
- Full Unicode/emoji support — renderers use Terminal.Gui v2 grapheme cluster API (`GraphemeHelper`, `AddStr`) for proper wide character handling
- Emoji-to-text shortcode conversion for consistent cross-platform rendering
- Fixed `/send` and `/avatar` commands not handling file paths with spaces correctly, even when quoted
- Server-side newline spam protection — consecutive blank/whitespace-only lines collapsed to 1 and total lines capped at 30
- Fixed OG tag regex truncating descriptions containing apostrophes (e.g. `"HueByte's portfolio"` was cut to `"HueByte"`) — switched to backreference-based quote pairing
## Infrastructure
- New `LinkEmbedService` on the server — URL detection, HTML fetching (first 64KB), OG tag parsing via compiled regex
- `EmbedDto` record added to shared Core DTOs; `MessageDto.Embeds` list for multiple embeds per message
- `EmbedJson` nullable column on the `Message` table stores serialized embed data as JSON array (max 8KB); `DataMigrationService` auto-migrates old single-object format
- Dedicated `"OgFetch"` named HttpClient with bot User-Agent header and 5-second timeout
- `PresenceTracker.ForceRemoveUser()` for atomic user cleanup on kick/ban
- `IChatBroadcaster.ForceDisconnectUserAsync()` and `IEchoHubClient.ForceDisconnect` for force-disconnect signaling
- `NotificationSoundService` for cross-platform audio playback of embedded notification sounds
- Startup `DataMigrationService` automatically converts old ANSI-format messages to the new color tag format on server boot, logging the count of migrated records
- `EmojiHelper` utility for emoji-to-shortcode conversion
- Heartbeat handling in `ServerDirectoryService` for connection health checks
- Four new EF Core migrations: `AddModerationRoles`, `AddChannelIsPublic`, `AddChannelMembership`, `AddMessageEmbed`
- `ChannelMembership` table with cascade delete on both channel and user removal
+32
View File
@@ -0,0 +1,32 @@
# v0.2.4 - E2E Message Encryption
## Features
### E2E Message Encryption
- Application-layer AES-256-GCM encryption for all message content between client and server
- Protects against ISPs, proxies, and any middleman reading chat messages — even if TLS is compromised
- 256-bit encryption key auto-generated on first server startup and saved to `appsettings.json`
- Client fetches the encryption key automatically after login via `GET /api/server/encryption-key`
- Client encrypts messages before sending via SignalR; server decrypts for validation and processing
- Server broadcasts encrypted content to SignalR clients; client decrypts transparently — no user action required
- Optional database encryption at rest via `Encryption:EncryptDatabase` setting (disabled by default)
- When enabled: new messages encrypted before DB storage (existing plaintext messages are not retroactively encrypted)
- When disabled: messages stored as plaintext, no risk of data loss from key rotation
- Reads handle mixed content (encrypted + plaintext) regardless of setting — safe to toggle at any time
- Key rotation is safe: old plaintext stays readable, new messages use the new key
- IRC gateway automatically decrypts messages before forwarding to IRC clients (plaintext over IRC)
- Encrypted content format: `$ENC$v1${nonce}${ciphertext+tag}` (Base64, 12-byte nonce, 16-byte auth tag)
- Server strips `$ENC$` prefix from user-typed messages to prevent format spoofing
- Graceful fallback: if decryption fails, shows `[encrypted message — decryption failed, try re-logging to fetch the latest key]`
## Infrastructure
- `IMessageEncryptionService` interface in Core; `MessageEncryptionService` server implementation and `ClientEncryptionService` client implementation
- `EncryptionKeyResponse` DTO and `GET /api/server/encryption-key` endpoint (authenticated, rate-limited)
- `FirstRunSetup.EnsureEncryptionKey()` auto-generates AES-256 key on first server run
- `Encryption:EncryptDatabase` server setting (default `false`) controls whether messages are encrypted at rest
- DB column max lengths increased for encrypted content: `Message.Content` 2000 → 16000, `Message.EmbedJson` 8000 → 32000
- EF Core migration: `AddEncryptionSupport`
- Encryption test suite: server-side, client-side, and cross-compatibility tests
- 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
+20
View File
@@ -0,0 +1,20 @@
# 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)
## Infrastructure
- Release workflow: use `--notes-file` instead of inline `--notes` interpolation to prevent shell expansion of special characters in commit messages
+16
View File
@@ -30,6 +30,16 @@
],
"dest": "_api_meta/client",
"filter": "filterConfig.yml"
},
{
"src": [
{
"src": "../src/EchoHub.Server.Irc/bin/Release/net10.0",
"files": ["EchoHub.Server.Irc.dll"]
}
],
"dest": "_api_meta/server-irc",
"filter": "filterConfig.yml"
}
],
"build": {
@@ -55,6 +65,12 @@
"dest": "api/client",
"files": ["*.yml"],
"exclude": ["toc.yml"]
},
{
"src": "_api_meta/server-irc",
"dest": "api/server-irc",
"files": ["*.yml"],
"exclude": ["toc.yml"]
}
],
"resource": [
+4 -2
View File
@@ -4,11 +4,13 @@ _layout: landing
# EchoHub Documentation
Welcome to the EchoHub documentation. EchoHub is a decentralized, IRC-like chat application built with .NET 10 and SignalR.
Welcome to the EchoHub documentation. EchoHub is a decentralized, IRC-style chat platform. Self-hosted, terminal-first, with a built-in IRC gateway so native IRC clients can connect alongside the TUI client.
**Website:** [echohub.voidcube.cloud](https://echohub.voidcube.cloud/) | **Public Servers:** [Server Directory](https://echohub.voidcube.cloud/servers)
## Quick Links
- [Getting Started](articles/getting-started.md) - Set up and run EchoHub
- [Architecture](articles/architecture.md) - Understand the system design
- [Architecture](articles/architecture.md) - System design and IRC gateway
- [API Reference](api/index.md) - Generated C# API documentation
- [Changelog](changelog/index.md) - Release history
+94
View File
@@ -0,0 +1,94 @@
# EchoHub nginx configuration example
#
# This config handles:
# - HTTPS reverse proxy for the HTTP API + SignalR WebSocket
# - TLS termination for IRC on port 6697
# - HTTP → HTTPS redirect
#
# Prerequisites:
# - EchoHub Server running on 127.0.0.1:5000
# - IRC gateway enabled on port 6667 (Irc:Enabled = true, Irc:TlsEnabled = false)
# - TLS certificate (e.g. from Let's Encrypt)
#
# Usage:
# 1. Copy this file to /etc/nginx/sites-available/echohub
# 2. Replace "echohub.example.com" with your domain
# 3. Update certificate paths
# 4. ln -s /etc/nginx/sites-available/echohub /etc/nginx/sites-enabled/
# 5. nginx -t && systemctl reload nginx
#
# Note: The "stream" block for IRC TLS must go in the main nginx.conf
# (outside the http block), not in sites-available. See the bottom of
# this file for the stream config.
# --- Place this in /etc/nginx/sites-available/echohub ---
# HTTP → HTTPS redirect
server {
listen 80;
listen [::]:80;
server_name echohub.example.com;
return 301 https://$host$request_uri;
}
# HTTPS — API + SignalR WebSocket
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name echohub.example.com;
ssl_certificate /etc/letsencrypt/live/echohub.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/echohub.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# File upload limit (match EchoHub's MaxFileSizeBytes)
client_max_body_size 10m;
location / {
proxy_pass http://127.0.0.1:5000;
proxy_http_version 1.1;
# Standard proxy headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Required for SignalR WebSocket upgrade
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $http_connection;
# Long timeout for WebSocket connections
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
# Disable buffering for real-time
proxy_buffering off;
}
}
# --- Place this in /etc/nginx/nginx.conf (outside the http block) ---
# IRC TLS termination (port 6697 → plain IRC on 6667)
# The stream module handles raw TCP, not HTTP.
#
# stream {
# upstream irc_backend {
# server 127.0.0.1:6667;
# }
#
# server {
# listen 6697 ssl;
# listen [::]:6697 ssl;
# proxy_pass irc_backend;
#
# ssl_certificate /etc/letsencrypt/live/echohub.example.com/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/echohub.example.com/privkey.pem;
# ssl_protocols TLSv1.2 TLSv1.3;
#
# # Timeout for idle IRC connections (24 hours)
# proxy_timeout 86400s;
# }
# }
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
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
</packageSources>
<packageSourceMapping>
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
</packageSourceMapping>
</configuration>
+1 -1
View File
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>0.1.0</Version>
<Version>0.2.6</Version>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup>
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+178 -8
View File
@@ -10,7 +10,7 @@ public class CommandHandler
public event Func<string, Task>? OnSetNick;
public event Func<string, Task>? OnSetColor;
public event Func<string, Task>? OnSetTheme;
public event Func<string, Task>? OnSendFile;
public event Func<string, string?, Task>? OnSendFile;
public event Func<string?, Task>? OnOpenProfile;
public event Func<Task>? OnOpenServers;
public event Func<string, Task>? OnJoinChannel;
@@ -18,6 +18,14 @@ public class CommandHandler
public event Func<string, Task>? OnSetTopic;
public event Func<Task>? OnListUsers;
public event Func<string, Task>? OnSetAvatar;
public event Func<string, string?, Task>? OnKickUser;
public event Func<string, string?, Task>? OnBanUser;
public event Func<string, Task>? OnUnbanUser;
public event Func<string, int?, Task>? OnMuteUser;
public event Func<string, Task>? OnUnmuteUser;
public event Func<string, string, Task>? OnAssignRole;
public event Func<Task>? OnNukeChannel;
public event Func<Task>? OnTestSound;
public event Func<Task>? OnQuit;
public event Func<Task>? OnHelp;
@@ -46,6 +54,14 @@ public class CommandHandler
"leave" => await HandleLeave(),
"topic" => await HandleTopic(args),
"users" => await HandleUsers(),
"kick" => await HandleKick(args),
"ban" => await HandleBan(args),
"unban" => await HandleUnban(args),
"mute" => await HandleMute(args),
"unmute" => await HandleUnmute(args),
"role" => await HandleRole(args),
"nuke" => await HandleNuke(),
"test-sound" => await HandleTestSound(),
"quit" or "exit" => await HandleQuit(),
"help" or "?" => await HandleHelp(),
_ => new CommandResult(true, $"Unknown command: /{command}. Type /help for available commands.", IsError: true),
@@ -120,15 +136,19 @@ public class CommandHandler
private async Task<CommandResult> HandleSend(string args)
{
if (string.IsNullOrWhiteSpace(args))
return new CommandResult(true, "Usage: /send <filepath or URL>", IsError: true);
return new CommandResult(true, "Usage: /send <filepath or URL> [-s|-m|-l]", IsError: true);
var target = args.Trim().Trim('"');
// Extract optional size flag from end or start, respecting quoted paths
var (target, size) = ParsePathAndSizeFlag(args);
if (string.IsNullOrWhiteSpace(target))
return new CommandResult(true, "Usage: /send <filepath or URL> [-s|-m|-l]", IsError: true);
if (Uri.TryCreate(target, UriKind.Absolute, out var uri)
&& (uri.Scheme == "http" || uri.Scheme == "https"))
{
if (OnSendFile is not null)
await OnSendFile(target);
await OnSendFile(target, size);
var fileName = Path.GetFileName(uri.LocalPath);
if (string.IsNullOrWhiteSpace(fileName))
fileName = "image";
@@ -139,7 +159,7 @@ public class CommandHandler
return new CommandResult(true, $"File not found: {target}", IsError: true);
if (OnSendFile is not null)
await OnSendFile(target);
await OnSendFile(target, size);
return new CommandResult(true, $"Uploading: {Path.GetFileName(target)}...");
}
@@ -156,7 +176,7 @@ public class CommandHandler
if (string.IsNullOrWhiteSpace(args))
return new CommandResult(true, "Usage: /avatar <URL or filepath>", IsError: true);
var target = args.Trim().Trim('"');
var target = StripQuotes(args.Trim());
if (OnSetAvatar is not null)
await OnSetAvatar(target);
@@ -212,6 +232,102 @@ public class CommandHandler
return new CommandResult(true);
}
private async Task<CommandResult> HandleKick(string args)
{
if (string.IsNullOrWhiteSpace(args))
return new CommandResult(true, "Usage: /kick <username> [reason]", IsError: true);
var parts = args.Split(' ', 2, StringSplitOptions.TrimEntries);
var username = parts[0];
var reason = parts.Length > 1 ? parts[1] : null;
if (OnKickUser is not null)
await OnKickUser(username, reason);
return new CommandResult(true, $"Kicking {username}...");
}
private async Task<CommandResult> HandleBan(string args)
{
if (string.IsNullOrWhiteSpace(args))
return new CommandResult(true, "Usage: /ban <username> [reason]", IsError: true);
var parts = args.Split(' ', 2, StringSplitOptions.TrimEntries);
var username = parts[0];
var reason = parts.Length > 1 ? parts[1] : null;
if (OnBanUser is not null)
await OnBanUser(username, reason);
return new CommandResult(true, $"Banning {username}...");
}
private async Task<CommandResult> HandleUnban(string args)
{
if (string.IsNullOrWhiteSpace(args))
return new CommandResult(true, "Usage: /unban <username>", IsError: true);
if (OnUnbanUser is not null)
await OnUnbanUser(args.Trim());
return new CommandResult(true, $"Unbanning {args.Trim()}...");
}
private async Task<CommandResult> HandleMute(string args)
{
if (string.IsNullOrWhiteSpace(args))
return new CommandResult(true, "Usage: /mute <username> [duration_minutes]", IsError: true);
var parts = args.Split(' ', 2, StringSplitOptions.TrimEntries);
var username = parts[0];
int? duration = parts.Length > 1 && int.TryParse(parts[1], out var d) ? d : null;
if (OnMuteUser is not null)
await OnMuteUser(username, duration);
return new CommandResult(true, $"Muting {username}...");
}
private async Task<CommandResult> HandleUnmute(string args)
{
if (string.IsNullOrWhiteSpace(args))
return new CommandResult(true, "Usage: /unmute <username>", IsError: true);
if (OnUnmuteUser is not null)
await OnUnmuteUser(args.Trim());
return new CommandResult(true, $"Unmuting {args.Trim()}...");
}
private async Task<CommandResult> HandleRole(string args)
{
if (string.IsNullOrWhiteSpace(args))
return new CommandResult(true, "Usage: /role <username> <admin|mod|member>", IsError: true);
var parts = args.Split(' ', 2, StringSplitOptions.TrimEntries);
if (parts.Length < 2)
return new CommandResult(true, "Usage: /role <username> <admin|mod|member>", IsError: true);
var username = parts[0];
var role = parts[1].ToLowerInvariant();
if (role is not ("admin" or "mod" or "member"))
return new CommandResult(true, "Invalid role. Use: admin, mod, or member", IsError: true);
if (OnAssignRole is not null)
await OnAssignRole(username, role);
return new CommandResult(true, $"Setting {username} to {role}...");
}
private async Task<CommandResult> HandleNuke()
{
if (OnNukeChannel is not null)
await OnNukeChannel();
return new CommandResult(true, "Nuking channel history...");
}
private async Task<CommandResult> HandleTestSound()
{
if (OnTestSound is not null)
await OnTestSound();
return new CommandResult(true, "Playing notification sound...");
}
private async Task<CommandResult> HandleHelp()
{
if (OnHelp is not null)
@@ -223,18 +339,72 @@ public class CommandHandler
/nick <name> - Set display name
/color <#hex> - Set nickname color
/theme <name> - Switch theme
/send <filepath or URL> - Send a file or image
/send <filepath or URL> [-s|-m|-l] - Send file/image/audio (size flag for images)
/avatar <URL or filepath> - Set your avatar
/profile [username] - View a profile (yours if no name given)
/profile [username] - View a profile
/servers - Open saved servers
/join <channel> - Join a channel
/leave - Leave current channel
/topic <text> - Set channel topic
/users - List online users
Moderation:
/kick <user> [reason] - Kick a user (Mod+)
/ban <user> [reason] - Ban a user (Admin+)
/unban <user> - Unban a user (Admin+)
/mute <user> [minutes] - Mute a user (Mod+)
/unmute <user> - Unmute a user (Mod+)
/role <user> <admin|mod|member> - Assign role (Admin+)
/nuke - Clear channel history (Mod+)
/test-sound - Play notification sound
/quit - Exit the app
""");
}
/// <summary>
/// Extract a file path (possibly quoted) and an optional size flag (-s, -m, -l).
/// The flag can appear before or after the path.
/// </summary>
private static (string Path, string? Size) ParsePathAndSizeFlag(string args)
{
var trimmed = args.Trim();
string? size = null;
// Check for flag at the end: "path" -m or path -m
if (trimmed.Length > 3)
{
var suffix = trimmed[^2..];
if (suffix is "-s" or "-m" or "-l" && trimmed[^3] == ' ')
{
size = suffix[1..];
trimmed = trimmed[..^3].TrimEnd();
}
}
// Check for flag at the start: -m "path" or -m path
if (size is null && trimmed.Length > 3)
{
var prefix = trimmed[..2];
if (prefix is "-s" or "-m" or "-l" && trimmed[2] == ' ')
{
size = prefix[1..];
trimmed = trimmed[3..].TrimStart();
}
}
return (StripQuotes(trimmed), size);
}
/// <summary>
/// Remove matching surrounding quotes (double or single) from a string.
/// </summary>
private static string StripQuotes(string s)
{
if (s.Length >= 2 &&
((s[0] == '"' && s[^1] == '"') || (s[0] == '\'' && s[^1] == '\'')))
return s[1..^1];
return s;
}
private static bool IsValidHex(string s) =>
s.All(c => char.IsAsciiHexDigit(c));
}
+10 -1
View File
@@ -5,6 +5,14 @@ public class ClientConfig
public List<SavedServer> SavedServers { get; set; } = [];
public AccountPreset DefaultPreset { get; set; } = new();
public string ActiveTheme { get; set; } = "Default";
public NotificationConfig Notifications { get; set; } = new();
}
public class NotificationConfig
{
public bool Enabled { get; set; } = true;
public byte Volume { get; set; } = 30;
public string? SoundFile { get; set; }
}
public class SavedServer
@@ -12,7 +20,8 @@ public class SavedServer
public required string Name { get; set; }
public required string Url { get; set; }
public string? Username { get; set; }
public string? Token { get; set; }
public string? RefreshToken { get; set; }
public bool RememberMe { get; set; }
public DateTimeOffset LastConnected { get; set; }
}
+9 -1
View File
@@ -5,18 +5,24 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="AlwaysUpToDate" Version="2.0.1" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.3" />
<PackageReference Include="NetCoreAudio" Version="2.0.1" />
<PackageReference Include="Serilog" Version="4.3.1" />
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Terminal.Gui" Version="2.0.0-develop.5027" />
<!-- Using local fork until transparent color support is merged upstream (gui-cs/Terminal.Gui#4234) -->
<ProjectReference Include="..\Terminal.Gui\Terminal.Gui\Terminal.Gui.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="appsettings.json" Condition="Exists('appsettings.json')">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Assets\**">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<EmbeddedResource Include="appsettings.example.json">
<LogicalName>EchoHub.Client.appsettings.example.json</LogicalName>
</EmbeddedResource>
@@ -27,6 +33,8 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PackageIcon></PackageIcon>
<ApplicationIcon>Assets\hue_icon.ico</ApplicationIcon>
</PropertyGroup>
</Project>
+2
View File
@@ -4,6 +4,8 @@ using EchoHub.Client.Themes;
using Microsoft.Extensions.Configuration;
using Serilog;
using Terminal.Gui.App;
using Terminal.Gui.Drawing;
var appSettingsPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
if (!File.Exists(appSettingsPath))
+118 -6
View File
@@ -3,6 +3,7 @@ using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
namespace EchoHub.Client.Services;
@@ -17,6 +18,8 @@ public sealed class ApiClient : IDisposable
public string? RefreshToken => _refreshToken;
public string BaseUrl { get; }
public event Action? OnTokensRefreshed;
public ApiClient(string baseUrl)
{
BaseUrl = baseUrl.TrimEnd('/');
@@ -67,6 +70,19 @@ public sealed class ApiClient : IDisposable
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()
{
if (!string.IsNullOrEmpty(_refreshToken))
@@ -127,6 +143,16 @@ public sealed class ApiClient : IDisposable
return info;
}
public async Task<string> GetEncryptionKeyAsync()
{
EnsureAuthenticated();
var response = await AuthenticatedGetAsync("/api/server/encryption-key");
await EnsureSuccessAsync(response);
var result = await response.Content.ReadFromJsonAsync<EncryptionKeyResponse>()
?? throw new InvalidOperationException("Server returned empty encryption key response.");
return result.Key;
}
public async Task<UserProfileDto?> GetUserProfileAsync(string username)
{
EnsureAuthenticated();
@@ -159,7 +185,7 @@ public sealed class ApiClient : IDisposable
return result?.AvatarAscii;
}
public async Task<MessageDto?> UploadFileAsync(string channelName, Stream fileStream, string fileName)
public async Task<MessageDto?> UploadFileAsync(string channelName, Stream fileStream, string fileName, string? size = null)
{
EnsureAuthenticated();
using var content = new MultipartFormDataContent();
@@ -167,26 +193,45 @@ public sealed class ApiClient : IDisposable
streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(fileName));
content.Add(streamContent, "file", fileName);
var sizeQuery = size is not null ? $"?size={size}" : "";
var response = await AuthenticatedRequestAsync(() =>
_http.PostAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/upload", content));
_http.PostAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/upload{sizeQuery}", content));
await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<MessageDto>();
}
public async Task<MessageDto?> SendUrlAsync(string channelName, string url)
public async Task<MessageDto?> SendUrlAsync(string channelName, string url, string? size = null)
{
EnsureAuthenticated();
var request = new SendUrlRequest(url);
var sizeQuery = size is not null ? $"?size={size}" : "";
var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/send-url", request));
_http.PostAsJsonAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/send-url{sizeQuery}", request));
await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<MessageDto>();
}
public async Task<ChannelDto?> CreateChannelAsync(string name, string? topic = null)
public async Task<string> DownloadFileToTempAsync(string relativeUrl, string fileName)
{
EnsureAuthenticated();
var request = new CreateChannelRequest(name, topic);
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)
{
EnsureAuthenticated();
var request = new CreateChannelRequest(name, topic, isPublic);
var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync("/api/channels", request));
await EnsureSuccessAsync(response);
@@ -211,12 +256,79 @@ public sealed class ApiClient : IDisposable
await EnsureSuccessAsync(response);
}
// ── Moderation ────────────────────────────────────────────────────────
public async Task AssignRoleAsync(string username, ServerRole role)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync("/api/moderation/role", new AssignRoleRequest(username, role)));
await EnsureSuccessAsync(response);
}
public async Task KickUserAsync(string username, string? reason = null)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync($"/api/moderation/kick/{Uri.EscapeDataString(username)}", new KickRequest(reason)));
await EnsureSuccessAsync(response);
}
public async Task BanUserAsync(string username, string? reason = null)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync($"/api/moderation/ban/{Uri.EscapeDataString(username)}", new BanRequest(reason)));
await EnsureSuccessAsync(response);
}
public async Task UnbanUserAsync(string username)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync($"/api/moderation/unban/{Uri.EscapeDataString(username)}", new { }));
await EnsureSuccessAsync(response);
}
public async Task MuteUserAsync(string username, int? durationMinutes = null, string? reason = null)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync($"/api/moderation/mute/{Uri.EscapeDataString(username)}", new MuteRequest(reason, durationMinutes)));
await EnsureSuccessAsync(response);
}
public async Task UnmuteUserAsync(string username)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync($"/api/moderation/unmute/{Uri.EscapeDataString(username)}", new { }));
await EnsureSuccessAsync(response);
}
public async Task DeleteMessageAsync(Guid messageId)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
_http.DeleteAsync($"/api/moderation/messages/{messageId}"));
await EnsureSuccessAsync(response);
}
public async Task NukeChannelAsync(string channelName)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
_http.DeleteAsync($"/api/moderation/channels/{Uri.EscapeDataString(channelName)}/nuke"));
await EnsureSuccessAsync(response);
}
private void SetTokens(LoginResponse result)
{
_accessToken = result.Token;
_refreshToken = result.RefreshToken;
_expiresAt = result.ExpiresAt;
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
OnTokensRefreshed?.Invoke();
}
/// <summary>
@@ -1,7 +1,7 @@
using Serilog;
using Terminal.Gui.App;
namespace EchoHub.Client;
namespace EchoHub.Client.Services;
/// <summary>
/// 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,95 @@
using System.Security.Cryptography;
using System.Text;
using EchoHub.Core.Contracts;
namespace EchoHub.Client.Services;
/// <summary>
/// Client-side encryption service. Uses the same AES-256-GCM format as the server
/// so messages are encrypted end-to-end between client and server.
/// </summary>
public sealed class ClientEncryptionService : IMessageEncryptionService
{
private const string EncryptionPrefix = "$ENC$v1$";
private const int NonceSizeBytes = 12;
private const int TagSizeBytes = 16;
private byte[]? _key;
public bool IsInitialized => _key is not null;
public bool EncryptDatabaseEnabled => false; // Not relevant for client
/// <summary>
/// Initialize with the server's encryption key (fetched after login).
/// </summary>
public void SetKey(string base64Key)
{
_key = Convert.FromBase64String(base64Key);
if (_key.Length != 32)
throw new InvalidOperationException($"Encryption key must be exactly 32 bytes (256-bit). Got {_key.Length} bytes.");
}
public string Encrypt(string plaintext)
{
if (_key is null)
return plaintext; // Not initialized — pass through
var plaintextBytes = Encoding.UTF8.GetBytes(plaintext);
var nonce = RandomNumberGenerator.GetBytes(NonceSizeBytes);
var ciphertext = new byte[plaintextBytes.Length];
var tag = new byte[TagSizeBytes];
using var aes = new AesGcm(_key, TagSizeBytes);
aes.Encrypt(nonce, plaintextBytes, ciphertext, tag);
var combined = new byte[ciphertext.Length + tag.Length];
Buffer.BlockCopy(ciphertext, 0, combined, 0, ciphertext.Length);
Buffer.BlockCopy(tag, 0, combined, ciphertext.Length, tag.Length);
return $"{EncryptionPrefix}{Convert.ToBase64String(nonce)}${Convert.ToBase64String(combined)}";
}
public string Decrypt(string content)
{
if (_key is null || !content.StartsWith(EncryptionPrefix))
return content; // Not initialized or legacy plaintext
try
{
var payload = content[EncryptionPrefix.Length..];
var separatorIndex = payload.IndexOf('$');
if (separatorIndex < 0)
return content;
var nonceBase64 = payload[..separatorIndex];
var combinedBase64 = payload[(separatorIndex + 1)..];
var nonce = Convert.FromBase64String(nonceBase64);
var combined = Convert.FromBase64String(combinedBase64);
if (combined.Length < TagSizeBytes)
return content;
var ciphertextLength = combined.Length - TagSizeBytes;
var ciphertext = combined.AsSpan(0, ciphertextLength);
var tag = combined.AsSpan(ciphertextLength, TagSizeBytes);
var plaintext = new byte[ciphertextLength];
using var aes = new AesGcm(_key, TagSizeBytes);
aes.Decrypt(nonce, ciphertext, tag, plaintext);
return Encoding.UTF8.GetString(plaintext);
}
catch
{
return "[encrypted message — decryption failed, try re-logging to fetch the latest key]";
}
}
public string? EncryptNullable(string? value)
=> value is null ? null : Encrypt(value);
public string? DecryptNullable(string? value)
=> value is null ? null : Decrypt(value);
}
@@ -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();
}
}
@@ -8,20 +8,27 @@ namespace EchoHub.Client.Services;
public sealed class EchoHubConnection : IAsyncDisposable
{
private readonly HubConnection _connection;
private readonly ClientEncryptionService _encryption;
public event Action<MessageDto>? OnMessageReceived;
public event Action<string, string>? OnUserJoined;
public event Action<string, string>? OnUserLeft;
public event Action<ChannelDto>? OnChannelUpdated;
public event Action<UserPresenceDto>? OnUserStatusChanged;
public event Action<string, string, string?>? OnUserKicked;
public event Action<string, string?>? OnUserBanned;
public event Action<string, Guid>? OnMessageDeleted;
public event Action<string>? OnChannelNuked;
public event Action<string>? OnForceDisconnect;
public event Action<string>? OnError;
public event Action<string>? OnConnectionStateChanged;
public event Action? OnReconnected;
public bool IsConnected => _connection.State == HubConnectionState.Connected;
public EchoHubConnection(string serverUrl, ApiClient apiClient)
public EchoHubConnection(string serverUrl, ApiClient apiClient, ClientEncryptionService encryption)
{
_encryption = encryption;
var hubUrl = serverUrl.TrimEnd('/') + HubConstants.ChatHubPath;
_connection = new HubConnectionBuilder()
@@ -58,7 +65,9 @@ public sealed class EchoHubConnection : IAsyncDisposable
{
_connection.On<MessageDto>(nameof(Core.Contracts.IEchoHubClient.ReceiveMessage), message =>
{
OnMessageReceived?.Invoke(message);
// Decrypt message content received from server
var decrypted = message with { Content = _encryption.Decrypt(message.Content) };
OnMessageReceived?.Invoke(decrypted);
});
_connection.On<string, string>(nameof(Core.Contracts.IEchoHubClient.UserJoined), (channelName, username) =>
@@ -81,6 +90,31 @@ public sealed class EchoHubConnection : IAsyncDisposable
OnUserStatusChanged?.Invoke(presence);
});
_connection.On<string, string, string?>(nameof(Core.Contracts.IEchoHubClient.UserKicked), (channelName, username, reason) =>
{
OnUserKicked?.Invoke(channelName, username, reason);
});
_connection.On<string, string?>(nameof(Core.Contracts.IEchoHubClient.UserBanned), (username, reason) =>
{
OnUserBanned?.Invoke(username, reason);
});
_connection.On<string, Guid>(nameof(Core.Contracts.IEchoHubClient.MessageDeleted), (channelName, messageId) =>
{
OnMessageDeleted?.Invoke(channelName, messageId);
});
_connection.On<string>(nameof(Core.Contracts.IEchoHubClient.ChannelNuked), channelName =>
{
OnChannelNuked?.Invoke(channelName);
});
_connection.On<string>(nameof(Core.Contracts.IEchoHubClient.ForceDisconnect), reason =>
{
OnForceDisconnect?.Invoke(reason);
});
_connection.On<string>(nameof(Core.Contracts.IEchoHubClient.Error), message =>
{
OnError?.Invoke(message);
@@ -102,7 +136,8 @@ public sealed class EchoHubConnection : IAsyncDisposable
public async Task<List<MessageDto>> JoinChannelAsync(string channelName)
{
return await _connection.InvokeAsync<List<MessageDto>>("JoinChannel", channelName);
var messages = await _connection.InvokeAsync<List<MessageDto>>("JoinChannel", channelName);
return DecryptMessages(messages);
}
public async Task LeaveChannelAsync(string channelName)
@@ -112,12 +147,15 @@ public sealed class EchoHubConnection : IAsyncDisposable
public async Task SendMessageAsync(string channelName, string content)
{
await _connection.InvokeAsync("SendMessage", channelName, content);
// Encrypt content before sending to server
var encrypted = _encryption.Encrypt(content);
await _connection.InvokeAsync("SendMessage", channelName, encrypted);
}
public async Task<List<MessageDto>> GetHistoryAsync(string channelName, int count = HubConstants.DefaultHistoryCount)
{
return await _connection.InvokeAsync<List<MessageDto>>("GetChannelHistory", channelName, count);
var messages = await _connection.InvokeAsync<List<MessageDto>>("GetChannelHistory", channelName, count);
return DecryptMessages(messages);
}
public async Task UpdateStatusAsync(UserStatus status, string? statusMessage = null)
@@ -130,6 +168,11 @@ public sealed class EchoHubConnection : IAsyncDisposable
return await _connection.InvokeAsync<List<UserPresenceDto>>("GetOnlineUsers", channelName);
}
private List<MessageDto> DecryptMessages(List<MessageDto> messages)
{
return messages.Select(m => m with { Content = _encryption.Decrypt(m.Content) }).ToList();
}
public async ValueTask DisposeAsync()
{
await _connection.DisposeAsync();
@@ -0,0 +1,85 @@
using EchoHub.Client.Config;
using NetCoreAudio;
using Serilog;
namespace EchoHub.Client.Services;
public class NotificationSoundService
{
private readonly Player _player = new();
private readonly NotificationConfig _config;
private string? _resolvedSoundPath;
public NotificationSoundService(NotificationConfig config)
{
_config = config;
ResolveSoundPath();
}
public void SetEnabled(bool enabled) => _config.Enabled = enabled;
public void SetVolume(byte volume) => _config.Volume = Math.Min(volume, (byte)100);
public async Task PlayAsync()
{
if (!_config.Enabled || _resolvedSoundPath is null)
return;
await PlayInternal();
}
/// <summary>
/// Plays the notification sound regardless of the Enabled setting (for /test-sound).
/// </summary>
public async Task PlayTestAsync()
{
if (_resolvedSoundPath is null)
return;
await PlayInternal();
}
private async Task PlayInternal()
{
try
{
if (_player.Playing)
await _player.Stop();
await _player.SetVolume(_config.Volume);
await _player.Play(_resolvedSoundPath!);
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to play notification sound");
}
}
private void ResolveSoundPath()
{
// 1. Explicit path from config (~/.echohub/config.json)
if (!string.IsNullOrWhiteSpace(_config.SoundFile))
{
if (File.Exists(_config.SoundFile))
{
_resolvedSoundPath = Path.GetFullPath(_config.SoundFile);
Log.Debug("Notification sound: {Path} (from config)", _resolvedSoundPath);
return;
}
Log.Warning("Configured sound file not found: {Path}", _config.SoundFile);
}
// 2. Default: Notification.mp3 bundled next to the executable
var defaultPath = Path.Combine(AppContext.BaseDirectory, "Assets", "Notification.mp3");
if (File.Exists(defaultPath))
{
_resolvedSoundPath = defaultPath;
Log.Debug("Notification sound: {Path} (default)", _resolvedSoundPath);
return;
}
Log.Information("No notification sound file found — notifications will be silent");
}
}
@@ -0,0 +1,106 @@
using AlwaysUpToDate;
using EchoHub.Client.UI.Dialogs;
using Serilog;
using Terminal.Gui.App;
namespace EchoHub.Client.Services;
public sealed class UpdateChecker : IDisposable
{
private const string ManifestUrl = "https://echohub.voidcube.cloud/api/app/version";
private readonly Updater _updater;
private readonly IApplication _app;
private UpdateProgressDialog? _progressDialog;
public static string CurrentVersion => typeof(UpdateChecker).Assembly.GetName().Version?.ToString(3) ?? "0.0.0";
public UpdateChecker(IApplication app)
{
_app = app;
_updater = new Updater(TimeSpan.FromHours(1), ManifestUrl, false);
_updater.UpdateAvailable += OnUpdateAvailable;
_updater.ProgressChanged += OnProgressChanged;
_updater.UpdateStarted += OnUpdateStarted;
_updater.NoUpdateAvailable += OnNoUpdateAvailable;
_updater.OnException += OnException;
}
public void Start()
{
#if RELEASE
_updater.Start();
#endif
}
private async void OnUpdateAvailable(string version, string changelogUrl)
{
Log.Information("Update available: v{Version}", version);
var confirmed = false;
_app.Invoke(() =>
{
confirmed = UpdateConfirmDialog.Show(_app, CurrentVersion, version);
if (confirmed)
{
_progressDialog = new UpdateProgressDialog(_app, version);
// Start the update; progress is reported via OnProgressChanged
_ = Task.Run(async () =>
{
await _updater.UpdateAsync();
});
_progressDialog?.Show();
}
});
}
private void OnProgressChanged(UpdateStep step, long itemsProcessed, long? totalItems, double? progressPercentage)
{
var fraction = progressPercentage.HasValue ? (float)(progressPercentage.Value / 100.0) : 0f;
var statusText = $"{step}: {itemsProcessed}/{totalItems ?? 0} ({progressPercentage ?? 0:F0}%)";
if (!progressPercentage.HasValue)
{
statusText = $"{step}...";
}
_progressDialog?.UpdateProgress(fraction, statusText);
}
private void OnUpdateStarted(string version)
{
Log.Information("Update started: v{Version}", version);
}
private void OnNoUpdateAvailable()
{
Log.Debug("No update available");
}
private void OnException(Exception exception)
{
Log.Error(exception, "Update check failed");
_app.Invoke(() =>
{
_progressDialog?.Close();
_progressDialog = null;
});
}
public void Dispose()
{
_updater.UpdateAvailable -= OnUpdateAvailable;
_updater.ProgressChanged -= OnProgressChanged;
_updater.UpdateStarted -= OnUpdateStarted;
_updater.NoUpdateAvailable -= OnNoUpdateAvailable;
_updater.OnException -= OnException;
}
}
@@ -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
{
Foreground = "White",
Background = "Black",
Background = "None",
FocusForeground = "BrightCyan",
FocusBackground = "Black"
FocusBackground = "None"
},
Menu = new ThemeColors
{
Foreground = "White",
Background = "Black",
Background = "None",
FocusForeground = "BrightCyan",
FocusBackground = "Black"
FocusBackground = "None"
},
Dialog = new ThemeColors
{
Foreground = "White",
Background = "Black",
Background = "DarkGray",
FocusForeground = "BrightCyan",
FocusBackground = "Black"
},
Status = new ThemeColors
{
Foreground = "Gray",
Background = "Black",
Background = "None",
FocusForeground = "Gray",
FocusBackground = "Black"
FocusBackground = "None"
}
};
+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;
}
}
-280
View File
@@ -1,280 +0,0 @@
using System.Collections;
using System.Collections.Specialized;
using System.Text;
using System.Text.RegularExpressions;
using Terminal.Gui.Drawing;
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 ChatLine(string plainText)
{
Segments = [new ChatSegment(plainText, null)];
TextLength = plainText.Length;
}
public ChatLine(List<ChatSegment> segments)
{
Segments = segments;
TextLength = segments.Sum(s => s.Text.Length);
}
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)
{
int segPos = 0;
while (segPos < segment.Text.Length)
{
int remaining = width - col;
if (remaining <= 0)
{
// Emit current line and start a new one
results.Add(new ChatLine(currentSegments));
currentSegments = [];
// Add indent for continuation
if (continuationIndent > 0)
{
currentSegments.Add(new ChatSegment(new string(' ', continuationIndent), null));
col = continuationIndent;
}
else
{
col = 0;
}
remaining = width - col;
}
int take = Math.Min(segment.Text.Length - segPos, remaining);
currentSegments.Add(new ChatSegment(segment.Text.Substring(segPos, take), segment.Color));
col += take;
segPos += take;
}
}
if (currentSegments.Count > 0)
results.Add(new ChatLine(currentSegments));
return results;
}
/// <summary>
/// Parse a string containing ANSI 24-bit color escape codes into colored segments.
/// Format: \x1b[38;2;R;G;Bm (foreground color), \x1b[0m (reset)
/// </summary>
public static ChatLine FromAnsi(string ansiText, Attribute? defaultAttr = null)
{
var segments = new List<ChatSegment>();
var regex = AnsiColorRegex();
int lastIndex = 0;
Attribute? currentColor = defaultAttr;
foreach (Match match in regex.Matches(ansiText))
{
// Add any text before this escape sequence
if (match.Index > lastIndex)
{
var text = ansiText[lastIndex..match.Index];
if (text.Length > 0)
segments.Add(new ChatSegment(text, currentColor));
}
// Parse the escape sequence
if (match.Groups[1].Value == "0")
{
// Reset
currentColor = defaultAttr;
}
else if (match.Groups[2].Success)
{
// 38;2;R;G;B — 24-bit foreground color
var r = int.Parse(match.Groups[3].Value);
var g = int.Parse(match.Groups[4].Value);
var b = int.Parse(match.Groups[5].Value);
currentColor = new Attribute(new Color(r, g, b), Color.Black);
}
lastIndex = match.Index + match.Length;
}
// Add remaining text
if (lastIndex < ansiText.Length)
{
var text = ansiText[lastIndex..];
if (text.Length > 0)
segments.Add(new ChatSegment(text, currentColor));
}
return segments.Count > 0 ? new ChatLine(segments) : new ChatLine("");
}
// Matches: \x1b[0m (reset) or \x1b[38;2;R;G;Bm (24-bit foreground)
[GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")]
private static partial Regex AnsiColorRegex();
}
/// <summary>
/// Custom list data source for chat messages with per-character 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(selected ? VisualRole.Focus : VisualRole.Normal);
int charPos = 0;
int drawnChars = 0;
foreach (var segment in chatLine.Segments)
{
var attr = segment.Color ?? normalAttr;
listView.SetAttribute(attr);
foreach (var ch in segment.Text)
{
if (charPos >= viewportX && drawnChars < width)
{
listView.AddRune(new Rune(ch));
drawnChars++;
}
charPos++;
}
}
// Fill remaining width with spaces using default colors
listView.SetAttribute(normalAttr);
while (drawnChars < width)
{
listView.AddRune(new Rune(' '));
drawnChars++;
}
}
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>
/// Shared color attributes for chat rendering (timestamps, system messages).
/// </summary>
public static 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);
}
/// <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.ViewBase;
namespace EchoHub.Client.UI;
namespace EchoHub.Client.UI.Dialogs;
/// <summary>
/// Result returned from the connect dialog.
/// </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>
/// A Terminal.Gui dialog for entering server connection and authentication details.
@@ -27,11 +29,12 @@ public sealed class ConnectDialog
savedServers ??= [];
var hasSavedServers = savedServers.Count > 0;
var dialogHeight = hasSavedServers ? 20 : 16;
var dialogHeight = hasSavedServers ? 22 : 18;
var dialog = new Dialog { Title = "Connect to Server", Width = 60, Height = dialogHeight };
int yOffset = 0;
SavedServer? selectedSavedServer = null;
// -- Saved Servers section (if any) -----------------------------------
ListView? savedServerList = null;
@@ -46,7 +49,11 @@ public sealed class ConnectDialog
dialog.Add(savedLabel);
var serverDisplayNames = savedServers
.Select(s => $"{s.Name} ({s.Username ?? "?"})")
.Select(s =>
{
var session = !string.IsNullOrEmpty(s.RefreshToken) ? " [session]" : "";
return $"{s.Name} ({s.Username ?? "?"}){session}";
})
.ToList();
savedServerList = new ListView
@@ -115,17 +122,34 @@ public sealed class ConnectDialog
Secret = true
};
var tokenHintLabel = new Label
{
Text = "Session saved \u2014 password optional",
X = 15,
Y = yOffset + 6,
Width = Dim.Fill(2),
Visible = false
};
var rememberMeCheckbox = new CheckBox
{
Text = "Remember me",
X = 15,
Y = yOffset + 7,
Value = CheckState.UnChecked
};
var displayLabel = new Label
{
Text = "Display Name:",
X = 1,
Y = yOffset + 7
Y = yOffset + 9
};
var displayField = new TextField
{
Text = "",
X = 15,
Y = yOffset + 7,
Y = yOffset + 9,
Width = Dim.Fill(2)
};
@@ -134,21 +158,21 @@ public sealed class ConnectDialog
Text = "Login",
IsDefault = true,
X = Pos.Center() - 20,
Y = yOffset + 9
Y = yOffset + 11
};
var registerButton = new Button
{
Text = "Register",
X = Pos.Center() - 5,
Y = yOffset + 9
Y = yOffset + 11
};
var cancelButton = new Button
{
Text = "Cancel",
X = Pos.Center() + 10,
Y = yOffset + 9
Y = yOffset + 11
};
// Wire saved server selection to auto-fill fields
@@ -159,15 +183,32 @@ public sealed class ConnectDialog
var index = e.NewValue;
if (index.HasValue && index.Value >= 0 && index.Value < savedServers.Count)
{
var server = savedServers[index.Value];
urlField.Text = server.Url;
userField.Text = server.Username ?? "";
selectedSavedServer = savedServers[index.Value];
urlField.Text = selectedSavedServer.Url;
userField.Text = selectedSavedServer.Username ?? "";
rememberMeCheckbox.Value = selectedSavedServer.RememberMe
? CheckState.Checked : CheckState.UnChecked;
if (!string.IsNullOrEmpty(selectedSavedServer.RefreshToken))
{
passField.Text = "";
tokenHintLabel.Visible = true;
}
else
{
tokenHintLabel.Visible = false;
}
}
};
// Pre-fill with the first saved server
selectedSavedServer = savedServers[0];
urlField.Text = savedServers[0].Url;
userField.Text = savedServers[0].Username ?? "";
rememberMeCheckbox.Value = savedServers[0].RememberMe
? CheckState.Checked : CheckState.UnChecked;
if (!string.IsNullOrEmpty(savedServers[0].RefreshToken))
tokenHintLabel.Visible = true;
}
loginButton.Accepting += (s, e) =>
@@ -175,15 +216,34 @@ public sealed class ConnectDialog
var url = urlField.Text?.Trim() ?? string.Empty;
var user = userField.Text?.Trim() ?? string.Empty;
var pass = passField.Text ?? string.Empty;
var rememberMe = rememberMeCheckbox.Value == CheckState.Checked;
if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user) || string.IsNullOrEmpty(pass))
if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user))
{
MessageBox.ErrorQuery(app, "Validation", "Server URL, username, and password are required.", "OK");
MessageBox.ErrorQuery(app, "Validation", "Server URL and username are required.", "OK");
e.Handled = true;
return;
}
result = new ConnectDialogResult(url, user, pass, IsRegister: false);
// Determine if we can use a saved token
string? savedRefreshToken = null;
if (string.IsNullOrEmpty(pass)
&& selectedSavedServer is not null
&& !string.IsNullOrEmpty(selectedSavedServer.RefreshToken)
&& string.Equals(selectedSavedServer.Url, url, StringComparison.OrdinalIgnoreCase)
&& string.Equals(selectedSavedServer.Username, user, StringComparison.OrdinalIgnoreCase))
{
savedRefreshToken = selectedSavedServer.RefreshToken;
}
if (string.IsNullOrEmpty(pass) && savedRefreshToken is null)
{
MessageBox.ErrorQuery(app, "Validation", "Password is required.", "OK");
e.Handled = true;
return;
}
result = new ConnectDialogResult(url, user, pass, IsRegister: false, rememberMe, savedRefreshToken);
e.Handled = true;
app.RequestStop();
};
@@ -193,6 +253,7 @@ public sealed class ConnectDialog
var url = urlField.Text?.Trim() ?? string.Empty;
var user = userField.Text?.Trim() ?? string.Empty;
var pass = passField.Text ?? string.Empty;
var rememberMe = rememberMeCheckbox.Value == CheckState.Checked;
if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user) || string.IsNullOrEmpty(pass))
{
@@ -201,7 +262,7 @@ public sealed class ConnectDialog
return;
}
result = new ConnectDialogResult(url, user, pass, IsRegister: true);
result = new ConnectDialogResult(url, user, pass, IsRegister: true, rememberMe, SavedRefreshToken: null);
e.Handled = true;
app.RequestStop();
};
@@ -214,7 +275,8 @@ public sealed class ConnectDialog
};
dialog.Add(urlLabel, urlField, userLabel, userField, passLabel, passField,
displayLabel, displayField, loginButton, registerButton, cancelButton);
tokenHintLabel, rememberMeCheckbox, displayLabel, displayField,
loginButton, registerButton, cancelButton);
if (hasSavedServers && savedServerList is not null)
savedServerList.SetFocus();
@@ -2,9 +2,9 @@ using Terminal.Gui.App;
using Terminal.Gui.Views;
using Terminal.Gui.ViewBase;
namespace EchoHub.Client.UI;
namespace EchoHub.Client.UI.Dialogs;
public record CreateChannelResult(string Name, string? Topic);
public record CreateChannelResult(string Name, string? Topic, bool IsPublic);
public sealed class CreateChannelDialog
{
@@ -12,7 +12,7 @@ public sealed class CreateChannelDialog
{
CreateChannelResult? result = null;
var dialog = new Dialog { Title = "Create Channel", Width = 50, Height = 12 };
var dialog = new Dialog { Title = "Create Channel", Width = 50, Height = 14 };
var nameLabel = new Label { Text = "Name:", X = 1, Y = 1 };
var nameField = new TextField { X = 10, Y = 1, Width = Dim.Fill(2) };
@@ -20,11 +20,19 @@ public sealed class CreateChannelDialog
var topicLabel = new Label { Text = "Topic:", X = 1, Y = 3 };
var topicField = new TextField { X = 10, Y = 3, Width = Dim.Fill(2) };
var publicCheckbox = new CheckBox
{
Text = "Public (visible to all users)",
X = 1,
Y = 5,
Value = CheckState.Checked
};
var hintLabel = new Label
{
Text = "Lowercase letters, digits, hyphens, underscores (2-100 chars)",
X = 1,
Y = 5,
Y = 7,
};
var createButton = new Button
@@ -32,14 +40,14 @@ public sealed class CreateChannelDialog
Text = "Create",
IsDefault = true,
X = Pos.Center() - 10,
Y = 7
Y = 9
};
var cancelButton = new Button
{
Text = "Cancel",
X = Pos.Center() + 5,
Y = 7
Y = 9
};
createButton.Accepting += (s, e) =>
@@ -55,7 +63,8 @@ public sealed class CreateChannelDialog
if (string.IsNullOrWhiteSpace(topic))
topic = null;
result = new CreateChannelResult(name, topic);
var isPublic = publicCheckbox.Value == CheckState.Checked;
result = new CreateChannelResult(name, topic, isPublic);
e.Handled = true;
app.RequestStop();
};
@@ -67,7 +76,7 @@ public sealed class CreateChannelDialog
app.RequestStop();
};
dialog.Add(nameLabel, nameField, topicLabel, topicField, hintLabel, createButton, cancelButton);
dialog.Add(nameLabel, nameField, topicLabel, topicField, publicCheckbox, hintLabel, createButton, cancelButton);
nameField.SetFocus();
app.Run(dialog);
@@ -1,15 +1,16 @@
using EchoHub.Client.UI.Helpers;
using Terminal.Gui.App;
using Terminal.Gui.Views;
using Terminal.Gui.ViewBase;
using Terminal.Gui.Drawing;
using Terminal.Gui.ViewBase;
using Terminal.Gui.Views;
using Attribute = Terminal.Gui.Drawing.Attribute;
namespace EchoHub.Client.UI;
namespace EchoHub.Client.UI.Dialogs;
/// <summary>
/// Result returned from the profile edit dialog.
/// </summary>
public record ProfileEditResult(string? DisplayName, string? Bio, string? NicknameColor);
public record ProfileEditResult(string? DisplayName, string? Bio, string? NicknameColor, string? AvatarPath, bool? NotificationSoundEnabled, byte? NotificationVolume);
/// <summary>
/// A Terminal.Gui dialog for editing the user's profile (display name, bio, nickname color).
@@ -19,11 +20,11 @@ public sealed class ProfileEditDialog
/// <summary>
/// Shows the profile edit dialog and returns the result, or null if cancelled.
/// </summary>
public static ProfileEditResult? Show(IApplication app, string? currentDisplayName, string? currentBio, string? currentColor)
public static ProfileEditResult? Show(IApplication app, string? currentDisplayName, string? currentBio, string? currentColor, bool notificationSoundEnabled = false, byte notificationVolume = 30)
{
ProfileEditResult? result = null;
var dialog = new Dialog { Title = "Edit Profile", Width = 60, Height = 18 };
var dialog = new Dialog { Title = "Edit Profile", Width = 60, Height = 26 };
// Display Name
var nameLabel = new Label
@@ -102,20 +103,99 @@ public sealed class ProfileEditDialog
UpdateColorPreview(colorPreview, colorField.Text);
};
// Avatar
var avatarLabel = new Label
{
Text = "Avatar:",
X = 1,
Y = 10
};
var avatarField = new TextField
{
Text = "",
X = 17,
Y = 10,
Width = Dim.Fill(12)
};
var browseButton = new Button
{
Text = "Browse",
X = Pos.AnchorEnd(10),
Y = 10
};
var avatarHintLabel = new Label
{
Text = "(file path or URL)",
X = 17,
Y = 11
};
avatarHintLabel.SetScheme(new Scheme
{
Normal = new Attribute(Color.DarkGray, Color.Blue)
});
browseButton.Accepting += (s, e) =>
{
e.Handled = true;
var openDialog = new OpenDialog
{
Title = "Select Avatar Image",
OpenMode = OpenMode.File,
};
app.Run(openDialog);
if (openDialog.FilePaths.Count > 0)
{
avatarField.Text = openDialog.FilePaths[0];
}
};
// Notification Sound
var notifCheckbox = new CheckBox
{
Text = "Notification sound on @mention",
X = 1,
Y = 13,
Value = notificationSoundEnabled ? CheckState.Checked : CheckState.UnChecked
};
var volumeLabel = new Label
{
Text = "Volume:",
X = 1,
Y = 15
};
var volumeField = new TextField
{
Text = notificationVolume.ToString(),
X = 17,
Y = 15,
Width = 6
};
var volumeHintLabel = new Label
{
Text = "(0-100)",
X = 24,
Y = 15
};
volumeHintLabel.SetScheme(new Scheme
{
Normal = new Attribute(Color.DarkGray, Color.Blue)
});
// Buttons
var saveButton = new Button
{
Text = "Save",
IsDefault = true,
X = Pos.Center() - 10,
Y = 10
Y = 18
};
var cancelButton = new Button
{
Text = "Cancel",
X = Pos.Center() + 5,
Y = 10
Y = 18
};
saveButton.Accepting += (s, e) =>
@@ -123,8 +203,10 @@ public sealed class ProfileEditDialog
var displayName = NullIfEmpty(nameField.Text?.Trim());
var bio = NullIfEmpty(bioField.Text?.Trim());
var nicknameColor = NullIfEmpty(colorField.Text?.Trim());
var avatarPath = NullIfEmpty(avatarField.Text?.Trim());
result = new ProfileEditResult(displayName, bio, nicknameColor);
byte? volume = byte.TryParse(volumeField.Text, out var v) ? Math.Min(v, (byte)100) : null;
result = new ProfileEditResult(displayName, bio, nicknameColor, avatarPath, notifCheckbox.Value == CheckState.Checked, volume);
e.Handled = true;
app.RequestStop();
};
@@ -137,7 +219,10 @@ public sealed class ProfileEditDialog
};
dialog.Add(nameLabel, nameField, bioLabel, bioField, colorLabel, colorField,
colorHintLabel, previewLabel, colorPreview, saveButton, cancelButton);
colorHintLabel, previewLabel, colorPreview,
avatarLabel, avatarField, browseButton, avatarHintLabel,
notifCheckbox, volumeLabel, volumeField, volumeHintLabel,
saveButton, cancelButton);
nameField.SetFocus();
app.Run(dialog);
@@ -159,32 +244,13 @@ public sealed class ProfileEditDialog
return;
}
var color = ParseHexToTrueColor(hexColor.Trim());
var color = HexColorHelper.ParseHexToColor(hexColor.Trim(), Color.White);
preview.SetScheme(new Scheme
{
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) =>
string.IsNullOrWhiteSpace(value) ? null : value;
}
@@ -1,12 +1,14 @@
using Terminal.Gui.App;
using Terminal.Gui.Views;
using Terminal.Gui.ViewBase;
using Terminal.Gui.Drawing;
using EchoHub.Client.UI.Chat;
using EchoHub.Client.UI.Helpers;
using EchoHub.Core.DTOs;
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;
namespace EchoHub.Client.UI;
namespace EchoHub.Client.UI.Dialogs;
/// <summary>
/// Action selected by the user in their own profile dialog.
@@ -111,7 +113,7 @@ public sealed class ProfileViewDialog
// Color
var colorLabel = new Label { Text = "Color:", X = 1, 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 });
dialog.Add(colorLabel, colorValue);
row++;
@@ -140,12 +142,20 @@ public sealed class ProfileViewDialog
dialog.Add(bioView);
row += 3;
// ASCII Avatar
// ASCII Avatar — render with color tags
if (!string.IsNullOrWhiteSpace(profile.AvatarAscii))
{
row++;
var avatarLines = profile.AvatarAscii.Split('\n').Length;
var avatarHeight = Math.Min(avatarLines + 2, 6);
var rawLines = profile.AvatarAscii.Split('\n');
var avatarSource = new ChatListSource();
foreach (var line in rawLines)
{
avatarSource.Add(ChatLine.HasColorTags(line)
? ChatLine.FromColoredText(line)
: new ChatLine(line));
}
var avatarHeight = Math.Min(rawLines.Length + 2, 24);
var avatarFrame = new FrameView
{
Title = "Avatar",
@@ -154,9 +164,22 @@ public sealed class ProfileViewDialog
Width = Dim.Fill(2),
Height = avatarHeight
};
avatarFrame.Add(new Label { Text = profile.AvatarAscii, X = 0, Y = 0 });
var avatarList = new ListView
{
X = 0,
Y = 0,
Width = Dim.Fill(),
Height = Dim.Fill(),
Source = avatarSource
};
avatarFrame.Add(avatarList);
dialog.Add(avatarFrame);
// Grow dialog to fit avatar
// Grow dialog to fit avatar + widen for art
var artWidth = rawLines.Max(l => ChatLine.HasColorTags(l)
? ChatLine.FromColoredText(l).TextLength
: l.Length);
dialog.Width = Math.Max(50, artWidth + 6);
dialog.Height = row + avatarHeight + 4;
}
@@ -3,7 +3,7 @@ using Terminal.Gui.Views;
using Terminal.Gui.ViewBase;
using EchoHub.Core.Models;
namespace EchoHub.Client.UI;
namespace EchoHub.Client.UI.Dialogs;
/// <summary>
/// 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();
}
}
@@ -0,0 +1,333 @@
using System.Globalization;
using System.Text;
namespace EchoHub.Client.UI.Helpers;
/// <summary>
/// Converts emoji grapheme clusters to text shortcodes for safe TUI rendering.
/// Terminal width calculations for emoji are unreliable across different terminals,
/// so we replace them with fixed-width ASCII shortcodes for display only.
/// </summary>
public static class EmojiHelper
{
/// <summary>
/// Replace emoji graphemes in the text with :shortcode: equivalents.
/// Non-emoji text passes through unchanged.
/// </summary>
public static string ReplaceEmoji(string text)
{
if (string.IsNullOrEmpty(text))
return text;
// Quick check: if no characters above BMP or supplementary emoji ranges, skip processing
bool hasEmoji = false;
foreach (var rune in text.EnumerateRunes())
{
if (IsEmojiRune(rune))
{
hasEmoji = true;
break;
}
}
if (!hasEmoji)
return text;
var sb = new StringBuilder(text.Length);
var enumerator = StringInfo.GetTextElementEnumerator(text);
while (enumerator.MoveNext())
{
var grapheme = enumerator.GetTextElement();
// Check if this grapheme contains emoji runes
bool graphemeHasEmoji = false;
foreach (var rune in grapheme.EnumerateRunes())
{
if (IsEmojiRune(rune))
{
graphemeHasEmoji = true;
break;
}
}
if (graphemeHasEmoji)
{
// Try to find a shortcode for the whole grapheme first
if (EmojiShortcodes.TryGetValue(grapheme, out var shortcode))
{
sb.Append(shortcode);
}
else
{
// Try the base emoji (first rune only, stripping modifiers/ZWJ)
var baseRune = GetBaseEmoji(grapheme);
if (baseRune is not null && EmojiShortcodes.TryGetValue(baseRune, out shortcode))
{
sb.Append(shortcode);
}
else
{
// Unknown emoji — use generic placeholder
sb.Append("[emoji]");
}
}
}
else
{
sb.Append(grapheme);
}
}
return sb.ToString();
}
private static bool IsEmojiRune(Rune rune)
{
var value = rune.Value;
// Common emoji ranges
if (value >= 0x1F600 && value <= 0x1F64F) return true; // Emoticons
if (value >= 0x1F300 && value <= 0x1F5FF) return true; // Misc Symbols & Pictographs
if (value >= 0x1F680 && value <= 0x1F6FF) return true; // Transport & Map
if (value >= 0x1F900 && value <= 0x1F9FF) return true; // Supplemental Symbols
if (value >= 0x1FA00 && value <= 0x1FA6F) return true; // Chess Symbols
if (value >= 0x1FA70 && value <= 0x1FAFF) return true; // Symbols Extended-A
if (value >= 0x2600 && value <= 0x26FF) return true; // Misc Symbols
if (value >= 0x2700 && value <= 0x27BF) return true; // Dingbats
if (value >= 0xFE00 && value <= 0xFE0F) return true; // Variation Selectors
if (value >= 0x200D && value <= 0x200D) return true; // ZWJ
if (value >= 0x1F1E0 && value <= 0x1F1FF) return true; // Regional Indicators (flags)
if (value >= 0x231A && value <= 0x23F3) return true; // Misc Technical (watch, hourglass)
if (value >= 0x2934 && value <= 0x2935) return true; // Arrows
if (value >= 0x25AA && value <= 0x25FE) return true; // Geometric Shapes
if (value >= 0x2B05 && value <= 0x2B55) return true; // Misc Symbols & Arrows
if (value >= 0x3030 && value <= 0x303D) return true; // CJK Symbols
if (value == 0x00A9 || value == 0x00AE) return true; // © ®
if (value == 0x2122) return true; // ™
if (value >= 0x1F000 && value <= 0x1F02F) return true; // Mahjong & Dominos
return false;
}
/// <summary>
/// Extract the base emoji string (first non-modifier, non-ZWJ rune) for lookup.
/// </summary>
private static string? GetBaseEmoji(string grapheme)
{
foreach (var rune in grapheme.EnumerateRunes())
{
// Skip ZWJ, variation selectors, skin tone modifiers
if (rune.Value == 0x200D) continue;
if (rune.Value >= 0xFE00 && rune.Value <= 0xFE0F) continue;
if (rune.Value >= 0x1F3FB && rune.Value <= 0x1F3FF) continue;
if (IsEmojiRune(rune))
return rune.ToString();
}
return null;
}
// Common emoji → shortcode mapping (display-only, covers most frequently used emoji)
private static readonly Dictionary<string, string> EmojiShortcodes = new()
{
// Smileys & Emotion
["\U0001F600"] = ":grinning:",
["\U0001F601"] = ":grin:",
["\U0001F602"] = ":joy:",
["\U0001F603"] = ":smiley:",
["\U0001F604"] = ":smile:",
["\U0001F605"] = ":sweat_smile:",
["\U0001F606"] = ":laughing:",
["\U0001F607"] = ":innocent:",
["\U0001F608"] = ":smiling_imp:",
["\U0001F609"] = ":wink:",
["\U0001F60A"] = ":blush:",
["\U0001F60B"] = ":yum:",
["\U0001F60C"] = ":relieved:",
["\U0001F60D"] = ":heart_eyes:",
["\U0001F60E"] = ":sunglasses:",
["\U0001F60F"] = ":smirk:",
["\U0001F610"] = ":neutral_face:",
["\U0001F611"] = ":expressionless:",
["\U0001F612"] = ":unamused:",
["\U0001F613"] = ":sweat:",
["\U0001F614"] = ":pensive:",
["\U0001F615"] = ":confused:",
["\U0001F616"] = ":confounded:",
["\U0001F617"] = ":kissing:",
["\U0001F618"] = ":kissing_heart:",
["\U0001F619"] = ":kissing_smiling_eyes:",
["\U0001F61A"] = ":kissing_closed_eyes:",
["\U0001F61B"] = ":stuck_out_tongue:",
["\U0001F61C"] = ":stuck_out_tongue_winking_eye:",
["\U0001F61D"] = ":stuck_out_tongue_closed_eyes:",
["\U0001F61E"] = ":disappointed:",
["\U0001F61F"] = ":worried:",
["\U0001F620"] = ":angry:",
["\U0001F621"] = ":rage:",
["\U0001F622"] = ":cry:",
["\U0001F623"] = ":persevere:",
["\U0001F624"] = ":triumph:",
["\U0001F625"] = ":disappointed_relieved:",
["\U0001F626"] = ":frowning:",
["\U0001F627"] = ":anguished:",
["\U0001F628"] = ":fearful:",
["\U0001F629"] = ":weary:",
["\U0001F62A"] = ":sleepy:",
["\U0001F62B"] = ":tired_face:",
["\U0001F62C"] = ":grimacing:",
["\U0001F62D"] = ":sob:",
["\U0001F62E"] = ":open_mouth:",
["\U0001F62F"] = ":hushed:",
["\U0001F630"] = ":cold_sweat:",
["\U0001F631"] = ":scream:",
["\U0001F632"] = ":astonished:",
["\U0001F633"] = ":flushed:",
["\U0001F634"] = ":sleeping:",
["\U0001F635"] = ":dizzy_face:",
["\U0001F636"] = ":no_mouth:",
["\U0001F637"] = ":mask:",
["\U0001F641"] = ":slightly_frowning_face:",
["\U0001F642"] = ":slightly_smiling_face:",
["\U0001F643"] = ":upside_down_face:",
["\U0001F644"] = ":roll_eyes:",
["\U0001F910"] = ":zipper_mouth:",
["\U0001F911"] = ":money_mouth:",
["\U0001F912"] = ":thermometer_face:",
["\U0001F913"] = ":nerd:",
["\U0001F914"] = ":thinking:",
["\U0001F915"] = ":head_bandage:",
["\U0001F920"] = ":cowboy:",
["\U0001F921"] = ":clown:",
["\U0001F922"] = ":nauseated:",
["\U0001F923"] = ":rofl:",
["\U0001F924"] = ":drooling:",
["\U0001F925"] = ":lying:",
["\U0001F929"] = ":star_struck:",
["\U0001F92A"] = ":zany:",
["\U0001F92B"] = ":shushing:",
["\U0001F92C"] = ":cursing:",
["\U0001F92D"] = ":hand_over_mouth:",
["\U0001F92E"] = ":vomiting:",
["\U0001F92F"] = ":exploding_head:",
["\U0001F970"] = ":smiling_face_with_hearts:",
["\U0001F971"] = ":yawning:",
["\U0001F972"] = ":smiling_with_tear:",
["\U0001F973"] = ":partying:",
["\U0001F974"] = ":woozy:",
["\U0001F975"] = ":hot_face:",
["\U0001F976"] = ":cold_face:",
["\U0001F979"] = ":holding_back_tears:",
["\U0001F97A"] = ":pleading:",
["\U0001FAE0"] = ":melting:",
["\U0001FAE1"] = ":saluting:",
["\U0001FAE2"] = ":face_with_open_eyes_hand_over_mouth:",
["\U0001FAE3"] = ":face_with_peeking_eye:",
["\U0001FAE4"] = ":face_with_diagonal_mouth:",
// Gestures
["\U0001F44D"] = ":+1:",
["\U0001F44E"] = ":-1:",
["\U0001F44B"] = ":wave:",
["\U0001F44C"] = ":ok_hand:",
["\U0001F44F"] = ":clap:",
["\U0001F44A"] = ":fist:",
["\U0001F91D"] = ":handshake:",
["\U0001F91E"] = ":crossed_fingers:",
["\U0001F91F"] = ":love_you:",
["\U0001F918"] = ":metal:",
["\U0001F919"] = ":call_me:",
["\U0001F590"] = ":raised_hand:",
["\U0001F4AA"] = ":muscle:",
["\U0001F926"] = ":facepalm:",
["\U0001F937"] = ":shrug:",
["\U0001F64F"] = ":pray:",
["\U0001F64C"] = ":raised_hands:",
["\U0001F64B"] = ":raising_hand:",
// Hearts & Symbols
["\u2764"] = "<3",
["\U0001F494"] = "</3",
["\U0001F495"] = ":two_hearts:",
["\U0001F496"] = ":sparkling_heart:",
["\U0001F497"] = ":heartpulse:",
["\U0001F498"] = ":cupid:",
["\U0001F499"] = ":blue_heart:",
["\U0001F49A"] = ":green_heart:",
["\U0001F49B"] = ":yellow_heart:",
["\U0001F49C"] = ":purple_heart:",
["\U0001F49D"] = ":gift_heart:",
["\U0001F49E"] = ":revolving_hearts:",
["\U0001F49F"] = ":heart_decoration:",
["\U0001F90D"] = ":white_heart:",
["\U0001F90E"] = ":brown_heart:",
["\U0001F5A4"] = ":black_heart:",
["\U0001F9E1"] = ":orange_heart:",
// Objects & Nature
["\U0001F525"] = ":fire:",
["\U0001F4A9"] = ":poop:",
["\U0001F480"] = ":skull:",
["\U0001F47B"] = ":ghost:",
["\U0001F47D"] = ":alien:",
["\U0001F916"] = ":robot:",
["\U0001F4AF"] = ":100:",
["\U0001F4A5"] = ":boom:",
["\U0001F4A4"] = ":zzz:",
["\U0001F4A2"] = ":anger:",
["\U0001F4AC"] = ":speech_balloon:",
["\U0001F440"] = ":eyes:",
["\U0001F3B5"] = ":musical_note:",
["\U0001F3B6"] = ":notes:",
["\U0001F389"] = ":tada:",
["\U0001F38A"] = ":confetti:",
["\U0001F381"] = ":gift:",
["\U0001F3C6"] = ":trophy:",
["\U0001F4B0"] = ":money_bag:",
["\U0001F4BB"] = ":computer:",
["\U0001F4F1"] = ":phone:",
["\U0001F4E7"] = ":email:",
["\U0001F511"] = ":key:",
["\U0001F512"] = ":lock:",
["\U0001F513"] = ":unlock:",
["\U0001F6A8"] = ":rotating_light:",
["\U0001F6AB"] = ":no_entry:",
// Animals
["\U0001F436"] = ":dog:",
["\U0001F431"] = ":cat:",
["\U0001F42D"] = ":mouse:",
["\U0001F430"] = ":rabbit:",
["\U0001F43B"] = ":bear:",
["\U0001F427"] = ":penguin:",
["\U0001F41D"] = ":bee:",
["\U0001F40D"] = ":snake:",
["\U0001F422"] = ":turtle:",
// Food & Drink
["\U0001F355"] = ":pizza:",
["\U0001F354"] = ":hamburger:",
["\U0001F37A"] = ":beer:",
["\U0001F377"] = ":wine:",
["\U0001F370"] = ":cake:",
["\u2615"] = ":coffee:",
["\U0001F382"] = ":birthday:",
// Misc symbols (BMP)
["\u2705"] = ":white_check_mark:",
["\u274C"] = ":x:",
["\u274E"] = ":negative_squared_cross_mark:",
["\u2714"] = ":heavy_check_mark:",
["\u2716"] = ":heavy_multiplication_x:",
["\u26A0"] = ":warning:",
["\u2B50"] = ":star:",
["\u2728"] = ":sparkles:",
["\u267B"] = ":recycle:",
["\u2611"] = ":ballot_box_with_check:",
["\u23F0"] = ":alarm_clock:",
["\u231A"] = ":watch:",
["\u231B"] = ":hourglass:",
};
}
@@ -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() { }
}
+323 -205
View File
@@ -1,10 +1,14 @@
using System.Collections.ObjectModel;
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.Models;
using Terminal.Gui.App;
using Terminal.Gui.Configuration;
using Terminal.Gui.Drawing;
using Terminal.Gui.Input;
using Terminal.Gui.Text;
using Terminal.Gui.ViewBase;
using Terminal.Gui.Views;
using Attribute = Terminal.Gui.Drawing.Attribute;
@@ -21,14 +25,26 @@ public sealed class MainWindow : Runnable
private readonly ListView _messageList;
private readonly TextView _inputField;
private readonly FrameView _chatFrame;
private readonly FrameView _inputFrame;
private readonly Label _statusLabel;
private readonly Label _topicLabel;
private MenuBar _menuBar;
// Online users panel
private readonly FrameView _usersFrame;
private readonly ListView _usersList;
private readonly UserListSource _usersListSource;
private bool _usersPanelVisible = true;
private const int UsersPanelWidth = 22;
private static readonly Key F2Key = Key.F2;
internal static readonly string AppVersion =
typeof(MainWindow).Assembly.GetName().Version?.ToString(3) ?? "?";
// Cached Key constants — compare via .KeyCode to avoid Key.Equals (which also checks Handled)
private static readonly Key EnterKey = Key.Enter;
private static readonly Key NewlineKey = Key.N.WithCtrl;
private static readonly Key CtrlCKey = Key.C.WithCtrl;
private static readonly Key AltQKey = Key.Q.WithAlt;
private static readonly Key TabKey = Key.Tab;
// Available slash commands for Tab autocomplete
@@ -36,15 +52,16 @@ public sealed class MainWindow : Runnable
[
"/status", "/nick", "/color", "/theme", "/send",
"/avatar", "/profile", "/servers", "/join", "/leave",
"/topic", "/users", "/quit", "/help"
"/topic", "/users", "/kick", "/ban", "/unban",
"/mute", "/unmute", "/role", "/nuke", "/test-sound", "/quit", "/help"
];
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 string _currentChannel = string.Empty;
private string _currentUser = string.Empty;
private readonly Dictionary<string, bool> _channelPublic = [];
private readonly ChannelListSource _channelListSource;
private readonly ChatMessageManager _messageManager;
private string _connectionStatus = "Disconnected";
private int _lastChatWidth;
/// <summary>
@@ -67,6 +84,11 @@ public sealed class MainWindow : Runnable
/// </summary>
public event Action? OnDisconnectRequested;
/// <summary>
/// Fired when the user requests to logout (disconnect + revoke session).
/// </summary>
public event Action? OnLogoutRequested;
/// <summary>
/// Fired when the user requests to open their profile panel.
/// </summary>
@@ -92,9 +114,26 @@ public sealed class MainWindow : Runnable
/// </summary>
public event Action? OnCreateChannelRequested;
public MainWindow(IApplication app)
/// <summary>
/// Fired when the user requests to delete the current channel.
/// </summary>
public event Action? OnDeleteChannelRequested;
/// <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;
_messageManager = messageManager;
_messageManager.MessagesChanged += OnMessagesChanged;
Arrangement = ViewArrangement.Fixed;
// Menu bar at the top
@@ -107,7 +146,7 @@ public sealed class MainWindow : Runnable
Title = "Channels",
X = 0,
Y = 1, // below menu bar
Width = 25,
Width = 22,
Height = Dim.Fill(1) // leave room for status bar
};
@@ -118,7 +157,8 @@ public sealed class MainWindow : Runnable
Width = Dim.Fill(),
Height = Dim.Fill()
};
_channelList.SetSource(new ObservableCollection<string>(_channelNames));
_channelListSource = new ChannelListSource();
_channelList.Source = _channelListSource;
_channelList.ValueChanged += OnChannelListSelectionChanged;
channelsFrame.Add(_channelList);
Add(channelsFrame);
@@ -127,9 +167,9 @@ public sealed class MainWindow : Runnable
_topicLabel = new Label
{
Text = "",
X = 25,
X = 22,
Y = 1,
Width = Dim.Fill(),
Width = Dim.Fill(UsersPanelWidth),
Height = 1,
Visible = false
};
@@ -139,9 +179,9 @@ public sealed class MainWindow : Runnable
_chatFrame = new FrameView
{
Title = "Chat",
X = 25,
X = 22,
Y = 1, // below menu bar (shifts to 2 when topic is visible)
Width = Dim.Fill(),
Width = Dim.Fill(UsersPanelWidth),
Height = Dim.Fill(6) // leave room for input area and status bar
};
@@ -153,16 +193,17 @@ public sealed class MainWindow : Runnable
Height = Dim.Fill()
};
_messageList.Source = new ChatListSource();
_messageList.Accepting += OnMessageListAccepting;
_chatFrame.Add(_messageList);
Add(_chatFrame);
// Bottom input area
var inputFrame = new FrameView
_inputFrame = new FrameView
{
Title = "Message (Enter=send, Ctrl+N=newline, Tab=autocomplete)",
X = 25,
Title = "Message \u2502 Enter=send \u2502 Ctrl+N=newline \u2502 Tab=complete",
X = 22,
Y = Pos.Bottom(_chatFrame),
Width = Dim.Fill(),
Width = Dim.Fill(UsersPanelWidth),
Height = 5
};
@@ -175,19 +216,43 @@ public sealed class MainWindow : Runnable
WordWrap = true
};
_inputField.KeyDown += OnInputKeyDown;
inputFrame.Add(_inputField);
Add(inputFrame);
_inputField.ContentsChanged += OnInputContentsChanged;
_inputFrame.Add(_inputField);
Add(_inputFrame);
// Status bar at the very bottom
// Right panel - online users
_usersFrame = new FrameView
{
Title = "Users",
X = Pos.AnchorEnd(UsersPanelWidth),
Y = 1,
Width = UsersPanelWidth,
Height = Dim.Fill(1)
};
_usersList = new ListView
{
X = 0,
Y = 0,
Width = Dim.Fill(),
Height = Dim.Fill()
};
_usersListSource = new UserListSource();
_usersList.Source = _usersListSource;
_usersFrame.Add(_usersList);
Add(_usersFrame);
// Status bar at the very bottom — custom drawing for colored connection state
_statusLabel = new Label
{
Text = "Disconnected",
Text = "",
X = 0,
Y = Pos.AnchorEnd(1),
Width = Dim.Fill(),
Height = 1
};
_statusLabel.SetScheme(SchemeManager.GetScheme("Menu"));
_statusLabel.DrawingContent += OnStatusBarDrawContent;
Add(_statusLabel);
// Apply our custom color schemes to all views
@@ -198,7 +263,7 @@ public sealed class MainWindow : Runnable
_messageList.ViewportChanged += (_, _) => OnChatViewportChanged();
_chatFrame.ViewportChanged += (_, _) => OnChatViewportChanged();
// Window-level key handling for Ctrl+C (quit)
// Window-level key handling for Alt+Q (quit), F2 (toggle users panel)
KeyDown += OnWindowKeyDown;
}
@@ -263,9 +328,14 @@ public sealed class MainWindow : Runnable
{
new MenuItem("_Connect...", "Connect to a server", () => OnConnectRequested?.Invoke(), Key.Empty),
new MenuItem("_Disconnect", "Disconnect from server", () => OnDisconnectRequested?.Invoke(), Key.Empty),
new MenuItem("_Logout", "Logout and clear session", () => OnLogoutRequested?.Invoke(), Key.Empty),
new Line(),
new MenuItem("New C_hannel...", "Create a new channel", () => OnCreateChannelRequested?.Invoke(), Key.Empty),
new MenuItem("_Saved Servers...", "View saved servers", () => OnSavedServersRequested?.Invoke(), Key.Empty)
new MenuItem("_Delete Channel", "Delete the current channel", () => OnDeleteChannelRequested?.Invoke(), Key.Empty),
new Line(),
new MenuItem("_Saved Servers...", "View saved servers", () => OnSavedServersRequested?.Invoke(), Key.Empty),
new Line(),
new MenuItem("Toggle _Users Panel", "Toggle online users (F2)", () => ToggleUsersPanel(), Key.Empty)
}),
new MenuBarItem("_User", allUserItems)
]);
@@ -303,7 +373,7 @@ public sealed class MainWindow : Runnable
if (index.HasValue && index.Value >= 0 && index.Value < _channelNames.Count)
{
var channelName = _channelNames[index.Value];
if (channelName != _currentChannel)
if (channelName != _messageManager.CurrentChannel)
{
SwitchToChannel(channelName);
OnChannelSelected?.Invoke(channelName);
@@ -311,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)
{
if (e.KeyCode == TabKey.KeyCode)
@@ -326,20 +421,45 @@ public sealed class MainWindow : Runnable
else if (e.KeyCode == EnterKey.KeyCode)
{
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;
}
e.Handled = true;
}
else if (e.KeyCode == CtrlCKey.KeyCode)
else if (e.KeyCode == AltQKey.KeyCode)
{
_app.RequestStop();
e.Handled = true;
}
}
private bool _suppressEmojiReplace;
private void OnInputContentsChanged(object? sender, ContentsChangedEventArgs e)
{
if (_suppressEmojiReplace)
return;
var text = _inputField.Text;
if (string.IsNullOrEmpty(text))
return;
var replaced = EmojiHelper.ReplaceEmoji(text);
if (replaced == text)
return;
// Calculate where cursor should land after replacement
var lengthDelta = replaced.Length - text.Length;
var newCol = Math.Max(0, _inputField.CurrentColumn + lengthDelta);
_suppressEmojiReplace = true;
_inputField.Text = replaced;
_inputField.InsertionPoint = new System.Drawing.Point(newCol, _inputField.CurrentRow);
_suppressEmojiReplace = false;
}
/// <summary>
/// Tab-complete slash commands in the input field.
/// </summary>
@@ -380,99 +500,31 @@ public sealed class MainWindow : Runnable
if (newWidth > 0 && newWidth != _lastChatWidth)
{
_lastChatWidth = newWidth;
_messageManager.SetChatWidth(newWidth);
RefreshMessages();
}
}
private void OnWindowKeyDown(object? sender, Key e)
{
// Ctrl+C quits from anywhere
if (e.KeyCode == CtrlCKey.KeyCode)
if (e.KeyCode == AltQKey.KeyCode)
{
_app.RequestStop();
e.Handled = true;
}
else if (e.KeyCode == F2Key.KeyCode)
{
ToggleUsersPanel();
e.Handled = true;
}
}
/// <summary>
/// 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)
private void OnMessagesChanged(string channelName)
{
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();
}
else
{
// Increment unread count for non-active channels
_channelUnread.TryGetValue(message.ChannelName, out var count);
_channelUnread[message.ChannelName] = count + 1;
RefreshChannelList();
}
}
/// <summary>
/// Add a system/informational message to a channel with colored styling.
/// </summary>
public void AddSystemMessage(string channelName, string text)
{
var time = DateTimeOffset.Now.ToString("HH:mm");
var segments = new List<ChatSegment>
{
new($"[{time}] ", ChatColors.TimestampAttr),
new($"** {text}", ChatColors.SystemAttr)
};
if (!_channelMessages.TryGetValue(channelName, out var messages))
{
messages = [];
_channelMessages[channelName] = messages;
}
messages.Add(new ChatLine(segments));
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>
@@ -482,23 +534,49 @@ public sealed class MainWindow : Runnable
{
_channelNames.Clear();
_channelTopics.Clear();
_channelPublic.Clear();
foreach (var ch in channels)
{
_channelNames.Add(ch.Name);
_channelTopics[ch.Name] = ch.Topic;
if (!_channelMessages.ContainsKey(ch.Name))
_channelMessages[ch.Name] = [];
_channelPublic[ch.Name] = ch.IsPublic;
}
RefreshChannelList();
}
/// <summary>
/// Ensure a channel exists in the left panel list (used for private channels joined via /join).
/// </summary>
public void EnsureChannelInList(string channelName, bool? isPublic = null)
{
if (isPublic.HasValue)
_channelPublic[channelName] = isPublic.Value;
if (_channelNames.Contains(channelName))
return;
_channelNames.Add(channelName);
RefreshChannelList();
}
/// <summary>
/// Remove a channel from the left panel list.
/// </summary>
public void RemoveChannel(string channelName)
{
_channelNames.Remove(channelName);
_channelTopics.Remove(channelName);
_channelPublic.Remove(channelName);
RefreshChannelList();
}
/// <summary>
/// Update the topic for a specific channel.
/// </summary>
public void SetChannelTopic(string channelName, string? topic)
{
_channelTopics[channelName] = topic;
if (channelName == _currentChannel)
if (channelName == _messageManager.CurrentChannel)
UpdateTopicBar();
}
@@ -515,23 +593,92 @@ public sealed class MainWindow : Runnable
/// </summary>
public void UpdateStatusBar(string status)
{
var userPart = string.IsNullOrEmpty(_currentUser) ? "" : $" | User: {_currentUser}";
var channelPart = string.IsNullOrEmpty(_currentChannel) ? "" : $" | #{_currentChannel}";
_statusLabel.Text = $" {status}{userPart}{channelPart}";
_connectionStatus = status;
_statusLabel.SetNeedsDraw();
}
private static readonly Attribute StatusConnectedAttr = new(new Color(0, 200, 0), Color.None);
private static readonly Attribute StatusDisconnectedAttr = new(new Color(220, 50, 50), Color.None);
private static readonly Attribute StatusTransitionalAttr = new(new Color(220, 180, 0), Color.None);
private static readonly Attribute StatusBrandAttr = new(new Color(218, 165, 32), Color.None);
private void OnStatusBarDrawContent(object? sender, DrawEventArgs e)
{
var menuScheme = SchemeManager.GetScheme("Menu");
var normalAttr = menuScheme?.Normal ?? _statusLabel.GetAttributeForRole(VisualRole.Normal);
var width = _statusLabel.Viewport.Width;
if (width <= 0) return;
// Resolve None background for colored segments
var bg = normalAttr.Background;
Attribute Resolve(Attribute a) => a.Background == Color.None ? a with { Background = bg } : a;
int col = 0;
void Write(string text, Attribute attr)
{
_statusLabel.SetAttribute(Resolve(attr));
foreach (var g in GraphemeHelper.GetGraphemes(text))
{
var cols = Math.Max(g.GetColumns(), 1);
if (col + cols > width) return;
_statusLabel.Move(col, 0);
_statusLabel.AddStr(g);
col += cols;
}
}
// EchoHub branding
Write(" EchoHub", Resolve(StatusBrandAttr));
Write($" \u2502 v{AppVersion} \u2502 ", normalAttr);
// Connection state with color
var statusAttr = _connectionStatus switch
{
"Connected" => StatusConnectedAttr,
"Disconnected" => StatusDisconnectedAttr,
_ => StatusTransitionalAttr // Connecting, Reconnecting, Authenticating, etc.
};
Write(_connectionStatus, Resolve(statusAttr));
// User
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>
/// Set the current user name for display in the status bar.
/// Set the current user name (delegates to message manager for @mention detection).
/// </summary>
public void SetCurrentUser(string username)
{
_currentUser = username;
_messageManager.SetCurrentUser(username);
}
/// <summary>
/// Get the current channel name.
/// </summary>
public string CurrentChannel => _currentChannel;
public string CurrentChannel => _messageManager.CurrentChannel;
/// <summary>
/// Get all channel names that have message buffers (for broadcasting status changes).
@@ -543,15 +690,15 @@ public sealed class MainWindow : Runnable
/// </summary>
public void SwitchToChannel(string channelName)
{
_currentChannel = channelName;
_messageManager.CurrentChannel = channelName;
_chatFrame.Title = $"#{channelName}";
// Reset unread count for this channel
_channelUnread[channelName] = 0;
_messageManager.ClearUnread(channelName);
RefreshChannelList();
RefreshMessages();
UpdateTopicBar();
_statusLabel.SetNeedsDraw();
// Update channel list selection
var idx = _channelNames.IndexOf(channelName);
@@ -559,36 +706,23 @@ public sealed class MainWindow : Runnable
_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>
/// Clear all messages and channels (used on disconnect).
/// </summary>
public void ClearAll()
{
_channelNames.Clear();
_channelMessages.Clear();
_channelUnread.Clear();
_messageManager.ClearAll();
_channelTopics.Clear();
_currentChannel = string.Empty;
_currentUser = string.Empty;
_channelList.SetSource(new ObservableCollection<string>(_channelNames));
_channelPublic.Clear();
_channelListSource.Update([], [], string.Empty);
_channelList.Source = _channelListSource;
_chatFrame.Title = "Chat";
_topicLabel.Visible = false;
_chatFrame.Y = 1;
_usersListSource.Update([]);
_usersList.Source = _usersListSource;
_usersFrame.Title = "Users";
RefreshMessages();
}
@@ -602,7 +736,8 @@ public sealed class MainWindow : Runnable
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;
@@ -640,16 +775,11 @@ public sealed class MainWindow : Runnable
/// </summary>
private void RefreshChannelList()
{
var displayNames = _channelNames.Select(name =>
{
_channelUnread.TryGetValue(name, out var unread);
return unread > 0 ? $"#{name} ({unread})" : $"#{name}";
}).ToList();
_channelList.SetSource(new ObservableCollection<string>(displayNames));
_channelListSource.Update(_channelNames, _messageManager.GetUnreadCounts(), _messageManager.CurrentChannel);
_channelList.Source = _channelListSource;
// Restore selection to current channel
var idx = _channelNames.IndexOf(_currentChannel);
var idx = _channelNames.IndexOf(_messageManager.CurrentChannel);
if (idx >= 0)
_channelList.SelectedItem = idx;
}
@@ -659,7 +789,7 @@ public sealed class MainWindow : Runnable
/// </summary>
private void UpdateTopicBar()
{
_channelTopics.TryGetValue(_currentChannel, out var topic);
_channelTopics.TryGetValue(_messageManager.CurrentChannel, out var topic);
if (!string.IsNullOrWhiteSpace(topic))
{
_topicLabel.Text = $" Topic: {topic}";
@@ -674,70 +804,58 @@ public sealed class MainWindow : Runnable
}
/// <summary>
/// Format a message DTO into one or more display lines based on its MessageType.
/// Timestamps are dimmed and sender names are colored.
/// Adjusts widths of chat, topic, and input frames based on users panel visibility.
/// </summary>
private static List<ChatLine> FormatMessage(MessageDto message)
private void UpdateLayout()
{
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 ANSI color codes from colored ASCII art
var trimmed = artLine.TrimEnd('\r');
if (trimmed.Contains('\x1b'))
lines.Add(ChatLine.FromAnsi(" " + 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 contentLines = message.Content.Split('\n');
var firstLine = contentLines[0].TrimEnd('\r');
lines.Add(BuildChatLine(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++)
{
lines.Add(new ChatLine($"{indent}{contentLines[i].TrimEnd('\r')}"));
}
break;
}
return lines;
var rightMargin = _usersPanelVisible ? UsersPanelWidth : 0;
_chatFrame.Width = Dim.Fill(rightMargin);
_topicLabel.Width = Dim.Fill(rightMargin);
_inputFrame.Width = Dim.Fill(rightMargin);
_usersFrame.Visible = _usersPanelVisible;
SetNeedsDraw();
}
/// <summary>
/// Build a chat line with a dimmed timestamp and optionally colored sender name.
/// Toggle the online users panel visibility (F2).
/// </summary>
private static ChatLine BuildChatLine(string time, string senderName, Attribute? senderColor, string suffix)
public void ToggleUsersPanel()
{
var segments = new List<ChatSegment>
{
new($"[{time}] ", ChatColors.TimestampAttr),
new(senderName, senderColor),
new(suffix, null)
};
return new ChatLine(segments);
_usersPanelVisible = !_usersPanelVisible;
UpdateLayout();
}
/// <summary>
/// Update the online users list display.
/// </summary>
public void UpdateOnlineUsers(List<UserPresenceDto> users)
{
var displayItems = users.Select(u =>
{
var statusIcon = u.Status switch
{
UserStatus.Online => "\u25cf", // ●
UserStatus.Away => "\u25cb", // ○
UserStatus.DoNotDisturb => "\u25d0", // ◐
UserStatus.Invisible => "\u25cc", // ◌
_ => " "
};
var name = u.DisplayName ?? u.Username;
var roleTag = u.Role switch
{
ServerRole.Owner => "\u2605", // ★
ServerRole.Admin => "\u2666", // ♦
ServerRole.Mod => "\u2740", // ❀
_ => ""
};
var text = $"{statusIcon} {roleTag}{name}";
var nameColor = HexColorHelper.ParseHexColor(u.NicknameColor);
return (text, nameColor);
}).ToList();
_usersListSource.Update(displayItems);
_usersList.Source = _usersListSource;
_usersFrame.Title = $"Users ({users.Count})";
}
}
+13 -2
View File
@@ -4,10 +4,21 @@ public static class HubConstants
{
public const string ChatHubPath = "/hubs/chat";
public const string DefaultChannel = "general";
public const int DefaultHistoryCount = 50;
public const int DefaultHistoryCount = 100;
public const int MaxMessageLength = 2000;
public const int MaxFileSizeBytes = 10 * 1024 * 1024; // 10 MB
public const int MaxImageSizeBytes = 10 * 1024 * 1024; // 10 MB
public const int MaxAudioFileSizeBytes = 10 * 1024 * 1024; // 10 MB
public const int MaxFileSizeBytes = 100 * 1024 * 1024; // 100 MB
public const int MaxAvatarSizeBytes = 2 * 1024 * 1024; // 2 MB
public const int MaxMessageNewlines = 30;
public const int MaxConsecutiveNewlines = 1;
public const int AsciiArtWidth = 80;
public const int AsciiArtHeight = 40;
public const int AsciiArtHeightHalfBlock = 80;
// Link embed constants
public const int EmbedMaxDescriptionLength = 500;
public const int EmbedMaxHtmlBytes = 64 * 1024; // 64 KB
public const int EmbedFetchTimeoutSeconds = 5;
public const int EmbedMaxUrlsPerMessage = 3;
}
@@ -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);
@@ -0,0 +1,18 @@
using EchoHub.Core.DTOs;
namespace EchoHub.Core.Contracts;
public interface IChatBroadcaster
{
Task SendMessageToChannelAsync(string channelName, MessageDto message);
Task SendUserJoinedAsync(string channelName, string username, string? excludeConnectionId = null);
Task SendUserLeftAsync(string channelName, string username);
Task SendChannelUpdatedAsync(ChannelDto channel, string? channelName = null);
Task SendUserStatusChangedAsync(List<string> channelNames, UserPresenceDto presence);
Task SendUserKickedAsync(string channelName, string username, string? reason);
Task SendUserBannedAsync(string username, string? reason);
Task SendMessageDeletedAsync(string channelName, Guid messageId);
Task SendChannelNukedAsync(string channelName);
Task SendErrorAsync(string connectionId, string message);
Task ForceDisconnectUserAsync(List<string> connectionIds, string reason);
}
@@ -0,0 +1,32 @@
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
namespace EchoHub.Core.Contracts;
public interface IChatService
{
// Connection lifecycle
Task UserConnectedAsync(string connectionId, Guid userId, string username);
Task<string?> UserDisconnectedAsync(string connectionId);
// Channel operations
Task<(List<MessageDto> History, string? Error)> JoinChannelAsync(string connectionId, Guid userId, string username, string channelName);
Task LeaveChannelAsync(string connectionId, string username, string channelName);
// Messaging
Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content);
Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count);
// Presence
Task<string?> UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage);
Task<List<UserPresenceDto>> GetOnlineUsersAsync(string channelName);
// Broadcasting (used by controllers and IRC gateway)
Task BroadcastMessageAsync(string channelName, MessageDto message);
Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null);
// Query operations (used by IRC gateway for WHOIS, AUTH)
Task<UserProfileDto?> GetUserProfileAsync(string username);
Task<List<string>> GetChannelsForUserAsync(string username);
Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password);
}
@@ -12,5 +12,10 @@ public interface IEchoHubClient
Task UserLeft(string channelName, string username);
Task ChannelUpdated(ChannelDto channel);
Task UserStatusChanged(UserPresenceDto presence);
Task UserKicked(string channelName, string username, string? reason);
Task UserBanned(string username, string? reason);
Task MessageDeleted(string channelName, Guid messageId);
Task ChannelNuked(string channelName);
Task ForceDisconnect(string reason);
Task Error(string message);
}
@@ -0,0 +1,14 @@
namespace EchoHub.Core.Contracts;
public interface IMessageEncryptionService
{
/// <summary>
/// Whether database content should be encrypted at rest (server setting).
/// </summary>
bool EncryptDatabaseEnabled { get; }
string Encrypt(string plaintext);
string Decrypt(string content);
string? EncryptNullable(string? value);
string? DecryptNullable(string? value);
}
+12 -2
View File
@@ -11,12 +11,15 @@ public record MessageDto(
MessageType Type,
string? AttachmentUrl,
string? AttachmentFileName,
DateTimeOffset SentAt);
DateTimeOffset SentAt,
long? AttachmentFileSize = null,
List<EmbedDto>? Embeds = null);
public record ChannelDto(
Guid Id,
string Name,
string? Topic,
bool IsPublic,
int MessageCount,
DateTimeOffset CreatedAt);
@@ -30,8 +33,15 @@ public record UserDto(
public record SendMessageRequest(string ChannelName, string Content);
public record CreateChannelRequest(string Name, string? Topic = null);
public record CreateChannelRequest(string Name, string? Topic = null, bool IsPublic = true);
public record UpdateTopicRequest(string? Topic);
public record SendUrlRequest(string Url);
public record EmbedDto(
string? SiteName,
string? Title,
string? Description,
string? ImageAscii,
string Url);
+21
View File
@@ -1,5 +1,26 @@
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 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);
}
+8
View File
@@ -0,0 +1,8 @@
using EchoHub.Core.Models;
namespace EchoHub.Core.DTOs;
public record AssignRoleRequest(string Username, ServerRole Role);
public record MuteRequest(string? Reason = null, int? DurationMinutes = null);
public record BanRequest(string? Reason = null);
public record KickRequest(string? Reason = null);
+3 -1
View File
@@ -11,6 +11,7 @@ public record UserProfileDto(
string? AvatarAscii,
UserStatus Status,
string? StatusMessage,
ServerRole Role,
DateTimeOffset CreatedAt,
DateTimeOffset LastSeenAt);
@@ -28,6 +29,7 @@ public record UserPresenceDto(
string? DisplayName,
string? NicknameColor,
UserStatus Status,
string? StatusMessage);
string? StatusMessage,
ServerRole Role);
public record AvatarUploadResponse(string AvatarAscii);
+2
View File
@@ -1,3 +1,5 @@
namespace EchoHub.Core.DTOs;
public record ServerStatusDto(string Name, string? Description, int OnlineUsers, int TotalChannels);
public record EncryptionKeyResponse(string Key);
+1
View File
@@ -5,6 +5,7 @@ public class Channel
public Guid Id { get; set; }
public required string Name { get; set; }
public string? Topic { get; set; }
public bool IsPublic { get; set; } = true;
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public Guid CreatedByUserId { get; set; }
@@ -0,0 +1,8 @@
namespace EchoHub.Core.Models;
public class ChannelMembership
{
public Guid UserId { get; set; }
public Guid ChannelId { get; set; }
public DateTimeOffset JoinedAt { get; set; } = DateTimeOffset.UtcNow;
}
+2
View File
@@ -7,6 +7,8 @@ public class Message
public MessageType Type { get; set; } = MessageType.Text;
public string? AttachmentUrl { get; set; }
public string? AttachmentFileName { get; set; }
public long? AttachmentFileSize { get; set; }
public string? EmbedJson { get; set; }
public DateTimeOffset SentAt { get; set; } = DateTimeOffset.UtcNow;
public Guid ChannelId { get; set; }
+2 -1
View File
@@ -4,5 +4,6 @@ public enum MessageType
{
Text,
Image,
File
File,
Audio
}
+9
View File
@@ -0,0 +1,9 @@
namespace EchoHub.Core.Models;
public enum ServerRole
{
Member = 0,
Mod = 1,
Admin = 2,
Owner = 3
}
+4
View File
@@ -11,6 +11,10 @@ public class User
public string? AvatarAscii { get; set; }
public UserStatus Status { get; set; } = UserStatus.Online;
public string? StatusMessage { get; set; }
public ServerRole Role { get; set; } = ServerRole.Member;
public bool IsMuted { get; set; }
public DateTimeOffset? MutedUntil { get; set; }
public bool IsBanned { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset LastSeenAt { get; set; } = DateTimeOffset.UtcNow;
}
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.1.0" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
+131
View File
@@ -0,0 +1,131 @@
using EchoHub.Core.Contracts;
using EchoHub.Core.DTOs;
namespace EchoHub.Server.Irc;
public class IrcBroadcaster : IChatBroadcaster
{
private readonly IrcGatewayService _gateway;
private readonly IMessageEncryptionService _encryption;
public IrcBroadcaster(IrcGatewayService gateway, IMessageEncryptionService encryption)
{
_gateway = gateway;
_encryption = encryption;
}
public async Task SendMessageToChannelAsync(string channelName, MessageDto message)
{
// Decrypt content for IRC clients (they can't handle app-layer encryption)
var decryptedMessage = message with { Content = _encryption.Decrypt(message.Content) };
var lines = IrcMessageFormatter.FormatMessage(decryptedMessage);
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
{
// IRC convention: don't echo sender's own message
if (conn.Nickname == message.SenderUsername)
continue;
foreach (var line in lines)
await conn.SendAsync(line);
}
}
public async Task SendUserJoinedAsync(string channelName, string username, string? excludeConnectionId = null)
{
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
{
if (conn.ConnectionId == excludeConnectionId) continue;
await conn.SendAsync($":{username}!{username}@echohub JOIN #{channelName}");
}
}
public async Task SendUserLeftAsync(string channelName, string username)
{
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
{
if (conn.Nickname == username) continue;
await conn.SendAsync($":{username}!{username}@echohub PART #{channelName}");
}
}
public async Task SendChannelUpdatedAsync(ChannelDto channel, string? channelName = null)
{
var target = channelName ?? channel.Name;
if (channel.Topic is null) return;
foreach (var conn in _gateway.GetConnectionsInChannel(target))
{
await conn.SendAsync($":{_gateway.Options.ServerName} TOPIC #{channel.Name} :{channel.Topic}");
}
}
public Task SendUserStatusChangedAsync(List<string> channelNames, UserPresenceDto presence)
{
// IRC has no active status broadcast. Clients discover away via WHOIS/WHO.
return Task.CompletedTask;
}
public async Task SendUserKickedAsync(string channelName, string username, string? reason)
{
var reasonText = reason is not null ? $" :{reason}" : "";
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
{
await conn.SendAsync($":{_gateway.Options.ServerName} KICK #{channelName} {username}{reasonText}");
}
}
public async Task SendUserBannedAsync(string username, string? reason)
{
var reasonText = reason ?? "You have been banned.";
foreach (var conn in _gateway.GetAllConnections())
{
if (conn.Nickname == username)
await conn.SendAsync($":{_gateway.Options.ServerName} NOTICE {username} :You have been banned: {reasonText}");
}
}
public async Task SendMessageDeletedAsync(string channelName, Guid messageId)
{
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
{
await conn.SendAsync($":{_gateway.Options.ServerName} NOTICE {conn.Nickname ?? "*"} :Message {messageId} was deleted in #{channelName}");
}
}
public async Task SendChannelNukedAsync(string channelName)
{
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
{
await conn.SendAsync($":{_gateway.Options.ServerName} NOTICE {conn.Nickname ?? "*"} :All messages in #{channelName} have been cleared");
}
}
public async Task SendErrorAsync(string connectionId, string message)
{
if (!connectionId.StartsWith("irc-")) return;
if (_gateway.Connections.TryGetValue(connectionId, out var conn))
{
await conn.SendAsync($":{_gateway.Options.ServerName} NOTICE {conn.Nickname ?? "*"} :{message}");
}
}
public async Task ForceDisconnectUserAsync(List<string> connectionIds, string reason)
{
foreach (var connId in connectionIds)
{
if (!connId.StartsWith("irc-")) continue;
if (_gateway.Connections.TryGetValue(connId, out var conn))
{
try
{
await conn.SendAsync($"ERROR :Closing Link: {reason}");
await conn.DisposeAsync();
}
catch { /* connection may already be closed */ }
}
}
}
}
@@ -0,0 +1,93 @@
using System.Net.Sockets;
using System.Text;
namespace EchoHub.Server.Irc;
/// <summary>
/// Manages a single IRC client TCP connection.
/// </summary>
public sealed class IrcClientConnection : IAsyncDisposable
{
private readonly TcpClient _tcpClient;
private readonly StreamReader _reader;
private readonly StreamWriter _writer;
private readonly SemaphoreSlim _writeLock = new(1, 1);
// Connection identity
public string ConnectionId { get; } = $"irc-{Guid.NewGuid()}";
// Registration state
public string? Nickname { get; set; }
public string? Username { get; set; }
public string? RealName { get; set; }
public string? Password { get; set; }
public Guid? UserId { get; set; }
public bool IsRegistered { get; set; }
public bool IsAuthenticated { get; set; }
public bool IsSasl { get; set; }
public bool CapNegotiating { get; set; }
// Channel state — thread-safe: written by command handler, read by broadcaster threads
private readonly HashSet<string> _joinedChannels = new(StringComparer.OrdinalIgnoreCase);
private readonly object _channelLock = new();
// Away state
public string? AwayMessage { get; set; }
public string Hostmask => $"{Nickname}!{Username ?? Nickname}@echohub";
public void JoinChannel(string channel) { lock (_channelLock) _joinedChannels.Add(channel); }
public void LeaveChannel(string channel) { lock (_channelLock) _joinedChannels.Remove(channel); }
public bool IsInChannel(string channel) { lock (_channelLock) return _joinedChannels.Contains(channel); }
public List<string> GetJoinedChannels() { lock (_channelLock) return [.. _joinedChannels]; }
public IrcClientConnection(TcpClient tcpClient, Stream stream)
{
_tcpClient = tcpClient;
_reader = new StreamReader(stream, Encoding.UTF8);
_writer = new StreamWriter(stream, Encoding.UTF8) { AutoFlush = true, NewLine = "\r\n" };
}
public async Task<string?> ReadLineAsync(CancellationToken ct)
{
try
{
return await _reader.ReadLineAsync(ct);
}
catch
{
return null;
}
}
public async Task SendAsync(string line)
{
await _writeLock.WaitAsync();
try
{
await _writer.WriteLineAsync(line);
}
catch
{
// Connection lost — swallow
}
finally
{
_writeLock.Release();
}
}
public Task SendNumericAsync(string serverName, string numeric, string target, string text)
=> SendAsync($":{serverName} {numeric} {target} {text}");
public Task SendNumericAsync(string serverName, string numeric, string text)
=> SendNumericAsync(serverName, numeric, Nickname ?? "*", text);
public async ValueTask DisposeAsync()
{
try { _tcpClient.Close(); } catch { }
_reader.Dispose();
_writer.Dispose();
_writeLock.Dispose();
}
}
+648
View File
@@ -0,0 +1,648 @@
using System.Text;
using EchoHub.Core.Constants;
using EchoHub.Core.Contracts;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using Microsoft.Extensions.Logging;
namespace EchoHub.Server.Irc;
public sealed class IrcCommandHandler
{
private readonly IrcClientConnection _conn;
private readonly IrcOptions _options;
private readonly IChatService _chatService;
private readonly IChannelService _channelService;
private readonly IMessageEncryptionService _encryption;
private readonly ILogger _logger;
private string ServerName => _options.ServerName;
public IrcCommandHandler(
IrcClientConnection conn,
IrcOptions options,
IChatService chatService,
IChannelService channelService,
IMessageEncryptionService encryption,
ILogger logger)
{
_conn = conn;
_options = options;
_chatService = chatService;
_channelService = channelService;
_encryption = encryption;
_logger = logger;
}
public async Task RunAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
var line = await _conn.ReadLineAsync(ct);
if (line is null) break;
line = line.TrimEnd('\r', '\n');
if (string.IsNullOrWhiteSpace(line)) continue;
var msg = IrcMessage.Parse(line);
try
{
await HandleCommandAsync(msg);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error handling IRC command {Command} for {Nick}",
msg.Command, _conn.Nickname ?? "unregistered");
}
}
}
private Task HandleCommandAsync(IrcMessage msg)
{
var command = msg.Command.ToUpperInvariant();
return command switch
{
// Pre-registration
"CAP" => HandleCapAsync(msg),
"AUTHENTICATE" => HandleAuthenticateAsync(msg),
"PASS" => HandlePassAsync(msg),
"NICK" => HandleNickAsync(msg),
"USER" => HandleUserAsync(msg),
// Post-registration
"PING" => HandlePingAsync(msg),
"PONG" => Task.CompletedTask,
"JOIN" => HandleJoinAsync(msg),
"PART" => HandlePartAsync(msg),
"PRIVMSG" => HandlePrivmsgAsync(msg),
"QUIT" => HandleQuitAsync(msg),
"NAMES" => HandleNamesAsync(msg),
"TOPIC" => HandleTopicAsync(msg),
"WHO" => HandleWhoAsync(msg),
"WHOIS" => HandleWhoisAsync(msg),
"AWAY" => HandleAwayAsync(msg),
"LIST" => HandleListAsync(msg),
"MODE" => HandleModeAsync(msg),
"MOTD" => SendMotdAsync(),
"USERHOST" or "LUSERS" => Task.CompletedTask,
_ => _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_UNKNOWNCOMMAND,
$"{command} :Unknown command"),
};
}
// ── Authentication ──────────────────────────────────────────────────────
private async Task HandleCapAsync(IrcMessage msg)
{
if (msg.Parameters.Count < 1) return;
switch (msg.Parameters[0].ToUpperInvariant())
{
case "LS":
await _conn.SendAsync($":{ServerName} CAP * LS :sasl");
_conn.CapNegotiating = true;
break;
case "REQ":
if (msg.Parameters.Count >= 2 &&
msg.Parameters[1].Trim().Equals("sasl", StringComparison.OrdinalIgnoreCase))
{
await _conn.SendAsync($":{ServerName} CAP * ACK :sasl");
_conn.IsSasl = true;
}
else
{
var requested = msg.Parameters.ElementAtOrDefault(1) ?? "";
await _conn.SendAsync($":{ServerName} CAP * NAK :{requested}");
}
break;
case "END":
_conn.CapNegotiating = false;
if (_conn.Nickname is not null && _conn.Username is not null && !_conn.IsRegistered)
await TryCompleteRegistrationAsync();
break;
}
}
private async Task HandleAuthenticateAsync(IrcMessage msg)
{
if (msg.Parameters.Count < 1) return;
if (msg.Parameters[0].Equals("PLAIN", StringComparison.OrdinalIgnoreCase))
{
await _conn.SendAsync("AUTHENTICATE +");
return;
}
try
{
var decoded = Convert.FromBase64String(msg.Parameters[0]);
var text = Encoding.UTF8.GetString(decoded);
var parts = text.Split('\0');
if (parts.Length < 3)
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_SASLFAIL,
":SASL authentication failed (malformed payload)");
return;
}
var username = (parts[1].Length > 0 ? parts[1] : parts[0]).ToLowerInvariant();
var password = parts[2];
var result = await _chatService.AuthenticateUserAsync(username, password);
if (result is null)
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_SASLFAIL,
":SASL authentication failed");
return;
}
_conn.Nickname = result.Value.Username;
_conn.UserId = result.Value.UserId;
_conn.IsAuthenticated = true;
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_LOGGEDIN,
$"{_conn.Hostmask} {username} :You are now logged in as {username}");
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_SASLSUCCESS,
":SASL authentication successful");
}
catch
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_SASLFAIL,
":SASL authentication failed");
}
}
private Task HandlePassAsync(IrcMessage msg)
{
if (_conn.IsRegistered)
return _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_ALREADYREGISTERED,
":You may not reregister");
if (msg.Parameters.Count >= 1)
_conn.Password = msg.Parameters[0];
return Task.CompletedTask;
}
private async Task HandleNickAsync(IrcMessage msg)
{
if (msg.Parameters.Count < 1)
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NONICKNAMEGIVEN,
":No nickname given");
return;
}
var nick = msg.Parameters[0];
if (!ValidationConstants.UsernameRegex().IsMatch(nick))
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_ERRONEUSNICKNAME,
$"{nick} :Erroneous nickname (must be 3-50 chars: a-z, 0-9, _, -)");
return;
}
_conn.Nickname = nick.ToLowerInvariant();
if (!_conn.IsRegistered && _conn.Username is not null)
await TryCompleteRegistrationAsync();
}
private async Task HandleUserAsync(IrcMessage msg)
{
if (_conn.IsRegistered)
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_ALREADYREGISTERED,
":You may not reregister");
return;
}
if (msg.Parameters.Count < 4)
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NEEDMOREPARAMS,
"USER :Not enough parameters");
return;
}
_conn.Username = msg.Parameters[0];
_conn.RealName = msg.Parameters[3];
if (_conn.Nickname is not null)
await TryCompleteRegistrationAsync();
}
private async Task TryCompleteRegistrationAsync()
{
if (_conn.CapNegotiating || _conn.IsRegistered) return;
// SASL already authenticated
if (_conn.IsAuthenticated && _conn.UserId is not null)
{
_conn.IsRegistered = true;
await _chatService.UserConnectedAsync(_conn.ConnectionId, _conn.UserId.Value, _conn.Nickname!);
await SendWelcomeBurstAsync();
return;
}
// PASS-based authentication
if (string.IsNullOrEmpty(_conn.Password))
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_PASSWDMISMATCH,
":Password required. Use PASS command or SASL PLAIN.");
await _conn.SendAsync("ERROR :Authentication failed - no password provided");
return;
}
var result = await _chatService.AuthenticateUserAsync(_conn.Nickname!, _conn.Password);
if (result is null)
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_PASSWDMISMATCH,
":Password incorrect or account not found. Register via the EchoHub client first.");
await _conn.SendAsync("ERROR :Authentication failed");
return;
}
_conn.UserId = result.Value.UserId;
_conn.Nickname = result.Value.Username;
_conn.IsAuthenticated = true;
_conn.IsRegistered = true;
await _chatService.UserConnectedAsync(_conn.ConnectionId, result.Value.UserId, result.Value.Username);
await SendWelcomeBurstAsync();
}
// ── Welcome / MOTD ──────────────────────────────────────────────────────
private async Task SendWelcomeBurstAsync()
{
var nick = _conn.Nickname!;
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_WELCOME,
$":Welcome to the EchoHub IRC Gateway, {nick}!");
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_YOURHOST,
$":Your host is {ServerName}, running EchoHub IRC Gateway");
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_CREATED,
$":This server was created {DateTimeOffset.UtcNow:yyyy-MM-dd}");
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_MYINFO,
$"{ServerName} EchoHub-IRC o o");
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_ISUPPORT,
"CHANTYPES=# NICKLEN=50 CHANNELLEN=100 :are supported by this server");
await SendMotdAsync();
}
private async Task SendMotdAsync()
{
if (string.IsNullOrWhiteSpace(_options.Motd))
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOMOTD,
":MOTD File is missing");
return;
}
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_MOTDSTART,
$":- {ServerName} Message of the day - ");
foreach (var line in _options.Motd.Split('\n'))
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_MOTD,
$":- {line.TrimEnd('\r')}");
}
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_ENDOFMOTD,
":End of MOTD command");
}
// ── Channel Operations ──────────────────────────────────────────────────
private async Task HandleJoinAsync(IrcMessage msg)
{
if (!await RequireRegisteredAsync()) return;
if (msg.Parameters.Count < 1)
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NEEDMOREPARAMS,
"JOIN :Not enough parameters");
return;
}
var channels = msg.Parameters[0].Split(',', StringSplitOptions.RemoveEmptyEntries);
foreach (var rawChannel in channels)
{
var channelName = IrcToEchoHubChannel(rawChannel);
if (channelName is null)
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOSUCHCHANNEL,
$"{rawChannel} :Invalid channel name");
continue;
}
var (history, error) = await _chatService.JoinChannelAsync(
_conn.ConnectionId, _conn.UserId!.Value, _conn.Nickname!, channelName);
if (error is not null)
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOSUCHCHANNEL,
$"#{channelName} :{error}");
continue;
}
_conn.JoinChannel(channelName);
// Confirm JOIN to the client
await _conn.SendAsync($":{_conn.Hostmask} JOIN #{channelName}");
// Send topic
await SendChannelTopicAsync(channelName);
// Send NAMES list
await SendNamesReplyAsync(channelName);
// Replay history (decrypt — history is encrypted for SignalR transport)
foreach (var m in history)
{
var decrypted = m with { Content = _encryption.Decrypt(m.Content) };
var lines = IrcMessageFormatter.FormatMessage(decrypted);
foreach (var line in lines)
await _conn.SendAsync(line);
}
}
}
private async Task HandlePartAsync(IrcMessage msg)
{
if (!await RequireRegisteredAsync()) return;
if (msg.Parameters.Count < 1) return;
var channels = msg.Parameters[0].Split(',', StringSplitOptions.RemoveEmptyEntries);
var partMessage = msg.Parameters.Count > 1 ? msg.Parameters[1] : null;
foreach (var rawChannel in channels)
{
var channelName = IrcToEchoHubChannel(rawChannel);
if (channelName is null) continue;
await _chatService.LeaveChannelAsync(_conn.ConnectionId, _conn.Nickname!, channelName);
_conn.LeaveChannel(channelName);
await _conn.SendAsync($":{_conn.Hostmask} PART #{channelName}" +
(partMessage is not null ? $" :{partMessage}" : ""));
}
}
private async Task HandlePrivmsgAsync(IrcMessage msg)
{
if (!await RequireRegisteredAsync()) return;
if (msg.Parameters.Count < 2)
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NEEDMOREPARAMS,
"PRIVMSG :Not enough parameters");
return;
}
var target = msg.Parameters[0];
var content = msg.Parameters[1];
if (!target.StartsWith('#'))
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOSUCHNICK,
$"{target} :Private messages are not supported. Use channels.");
return;
}
var channelName = IrcToEchoHubChannel(target);
if (channelName is null) return;
var error = await _chatService.SendMessageAsync(
_conn.UserId!.Value, _conn.Nickname!, channelName, content);
if (error is not null)
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_CANNOTSENDTOCHAN,
$"#{channelName} :{error}");
}
}
private async Task HandleQuitAsync(IrcMessage msg)
{
var quitMessage = msg.Parameters.Count > 0 ? msg.Parameters[0] : "Client quit";
await _conn.SendAsync($"ERROR :Closing Link: {_conn.Nickname} ({quitMessage})");
}
// ── Query Commands ──────────────────────────────────────────────────────
private async Task HandleNamesAsync(IrcMessage msg)
{
if (!await RequireRegisteredAsync()) return;
if (msg.Parameters.Count < 1) return;
var channelName = IrcToEchoHubChannel(msg.Parameters[0]);
if (channelName is null) return;
await SendNamesReplyAsync(channelName);
}
private async Task SendNamesReplyAsync(string channelName)
{
var users = await _chatService.GetOnlineUsersAsync(channelName);
var nicks = string.Join(" ", users.Select(u => u.Username));
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_NAMREPLY,
$"= #{channelName} :{nicks}");
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_ENDOFNAMES,
$"#{channelName} :End of /NAMES list");
}
private async Task HandleTopicAsync(IrcMessage msg)
{
if (!await RequireRegisteredAsync()) return;
if (msg.Parameters.Count < 1) return;
var channelName = IrcToEchoHubChannel(msg.Parameters[0]);
if (channelName is null) return;
if (msg.Parameters.Count == 1)
{
await SendChannelTopicAsync(channelName);
}
else
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_CHANOPRIVSNEEDED,
$"#{channelName} :Topic can only be changed by the channel creator via the API");
}
}
private async Task SendChannelTopicAsync(string channelName)
{
var (topic, exists) = await _channelService.GetChannelTopicAsync(channelName);
if (!exists) return;
if (topic is not null)
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_TOPIC,
$"#{channelName} :{topic}");
}
else
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_NOTOPIC,
$"#{channelName} :No topic is set");
}
}
private async Task HandleWhoAsync(IrcMessage msg)
{
if (!await RequireRegisteredAsync()) return;
if (msg.Parameters.Count < 1) return;
var channelName = IrcToEchoHubChannel(msg.Parameters[0]);
if (channelName is null) return;
var users = await _chatService.GetOnlineUsersAsync(channelName);
foreach (var u in users)
{
var awayFlag = u.Status == UserStatus.Away ? "G" : "H";
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_WHOREPLY,
$"#{channelName} {u.Username} echohub {ServerName} {u.Username} {awayFlag} :0 {u.DisplayName ?? u.Username}");
}
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_ENDOFWHO,
$"#{channelName} :End of WHO list");
}
private async Task HandleWhoisAsync(IrcMessage msg)
{
if (!await RequireRegisteredAsync()) return;
if (msg.Parameters.Count < 1) return;
var nick = msg.Parameters[^1].ToLowerInvariant();
var profile = await _chatService.GetUserProfileAsync(nick);
if (profile is null)
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOSUCHNICK,
$"{nick} :No such nick/channel");
return;
}
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_WHOISUSER,
$"{nick} {nick} echohub * :{profile.DisplayName ?? nick}");
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_WHOISSERVER,
$"{nick} {ServerName} :EchoHub IRC Gateway");
var channels = await _chatService.GetChannelsForUserAsync(nick);
if (channels.Count > 0)
{
var chanList = string.Join(" ", channels.Select(c => $"#{c}"));
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_WHOISCHANNELS,
$"{nick} :{chanList}");
}
if (profile.Status == UserStatus.Away && profile.StatusMessage is not null)
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_AWAY,
$"{nick} :{profile.StatusMessage}");
}
var idleSeconds = (long)(DateTimeOffset.UtcNow - profile.LastSeenAt).TotalSeconds;
var signonUnix = profile.CreatedAt.ToUnixTimeSeconds();
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_WHOISIDLE,
$"{nick} {idleSeconds} {signonUnix} :seconds idle, signon time");
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_ENDOFWHOIS,
$"{nick} :End of WHOIS list");
}
private async Task HandleAwayAsync(IrcMessage msg)
{
if (!await RequireRegisteredAsync()) return;
if (msg.Parameters.Count > 0 && !string.IsNullOrWhiteSpace(msg.Parameters[0]))
{
_conn.AwayMessage = msg.Parameters[0];
await _chatService.UpdateStatusAsync(
_conn.UserId!.Value, _conn.Nickname!, UserStatus.Away, _conn.AwayMessage);
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_NOWAWAY,
":You have been marked as being away");
}
else
{
_conn.AwayMessage = null;
await _chatService.UpdateStatusAsync(
_conn.UserId!.Value, _conn.Nickname!, UserStatus.Online, null);
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_UNAWAY,
":You are no longer marked as being away");
}
}
private async Task HandleListAsync(IrcMessage msg)
{
if (!await RequireRegisteredAsync()) return;
var channels = await _channelService.GetChannelListAsync();
foreach (var ch in channels)
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_LIST,
$"#{ch.Name} {ch.OnlineCount} :{ch.Topic ?? ""}");
}
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_LISTEND,
":End of LIST");
}
private async Task HandleModeAsync(IrcMessage msg)
{
if (!await RequireRegisteredAsync()) return;
if (msg.Parameters.Count < 1) return;
var target = msg.Parameters[0];
if (target.StartsWith('#'))
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_CHANNELMODEIS,
$"{target} +");
}
else
{
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_UMODEIS, "+");
}
}
private async Task HandlePingAsync(IrcMessage msg)
{
var token = msg.Parameters.Count > 0 ? msg.Parameters[0] : ServerName;
await _conn.SendAsync($":{ServerName} PONG {ServerName} :{token}");
}
// ── Helpers ──────────────────────────────────────────────────────────────
private async Task<bool> RequireRegisteredAsync()
{
if (_conn.IsRegistered) return true;
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOTREGISTERED,
":You have not registered");
return false;
}
private static string? IrcToEchoHubChannel(string ircChannel)
{
if (!ircChannel.StartsWith('#') || ircChannel.Length < 2)
return null;
var name = ircChannel[1..].ToLowerInvariant().Trim();
return ValidationConstants.ChannelNameRegex().IsMatch(name) ? name : null;
}
}
+177
View File
@@ -0,0 +1,177 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Cryptography.X509Certificates;
using EchoHub.Core.Contracts;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace EchoHub.Server.Irc;
public sealed class IrcGatewayService : BackgroundService
{
private readonly IrcOptions _options;
private readonly IServiceProvider _services;
private readonly ILogger<IrcGatewayService> _logger;
private readonly ConcurrentDictionary<string, IrcClientConnection> _connections = new();
public IrcOptions Options => _options;
public IReadOnlyDictionary<string, IrcClientConnection> Connections => _connections;
public IrcGatewayService(
IOptions<IrcOptions> options,
IServiceProvider services,
ILogger<IrcGatewayService> logger)
{
_options = options.Value;
_services = services;
_logger = logger;
}
public IEnumerable<IrcClientConnection> GetConnectionsInChannel(string channelName)
{
return _connections.Values
.Where(c => c.IsAuthenticated && c.IsInChannel(channelName));
}
public IEnumerable<IrcClientConnection> GetAllConnections()
{
return _connections.Values.Where(c => c.IsAuthenticated);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await Task.Yield();
if (!_options.Enabled)
{
_logger.LogInformation("IRC gateway is disabled");
return;
}
var listeners = new List<Task>();
listeners.Add(RunListenerAsync(_options.Port, useTls: false, stoppingToken));
if (_options.TlsEnabled && !string.IsNullOrWhiteSpace(_options.TlsCertPath))
{
listeners.Add(RunListenerAsync(_options.TlsPort, useTls: true, stoppingToken));
}
await Task.WhenAll(listeners);
}
private async Task RunListenerAsync(int port, bool useTls, CancellationToken ct)
{
var listener = new TcpListener(IPAddress.Any, port);
listener.Start();
_logger.LogInformation("IRC gateway listening on port {Port} ({Mode})",
port, useTls ? "TLS" : "plain");
ct.Register(() => listener.Stop());
try
{
while (!ct.IsCancellationRequested)
{
var tcpClient = await listener.AcceptTcpClientAsync(ct);
_ = HandleClientAsync(tcpClient, useTls, ct);
}
}
catch (OperationCanceledException) { }
catch (ObjectDisposedException) { }
finally
{
listener.Stop();
}
}
private async Task HandleClientAsync(TcpClient tcpClient, bool useTls, CancellationToken ct)
{
Stream stream = tcpClient.GetStream();
if (useTls)
{
try
{
var cert = X509CertificateLoader.LoadPkcs12FromFile(_options.TlsCertPath!, _options.TlsCertPassword);
var sslStream = new SslStream(stream, leaveInnerStreamOpen: false);
await sslStream.AuthenticateAsServerAsync(cert);
stream = sslStream;
}
catch (Exception ex)
{
_logger.LogError(ex, "TLS handshake failed");
tcpClient.Close();
return;
}
}
var connection = new IrcClientConnection(tcpClient, stream);
_connections[connection.ConnectionId] = connection;
_logger.LogInformation("IRC client connected: {Id}", connection.ConnectionId);
IChatService? chatService = null;
try
{
chatService = _services.GetRequiredService<IChatService>();
var channelService = _services.GetRequiredService<IChannelService>();
var encryption = _services.GetRequiredService<IMessageEncryptionService>();
var handler = new IrcCommandHandler(
connection, _options, chatService, channelService, encryption, _logger);
await handler.RunAsync(ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "IRC client {Id} error", connection.ConnectionId);
}
finally
{
if (connection.IsAuthenticated)
{
foreach (var ch in connection.GetJoinedChannels())
{
if (chatService is null) break;
await chatService.LeaveChannelAsync(
connection.ConnectionId, connection.Nickname!, ch);
}
if (chatService is not null)
await chatService.UserDisconnectedAsync(connection.ConnectionId);
}
_connections.TryRemove(connection.ConnectionId, out _);
await connection.DisposeAsync();
_logger.LogInformation("IRC client {Id} ({Nick}) disconnected",
connection.ConnectionId, connection.Nickname ?? "unregistered");
}
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
// Cancel ExecuteAsync first so listeners stop accepting
await base.StopAsync(cancellationToken);
// Force-close any remaining client connections
foreach (var (_, conn) in _connections)
{
try
{
await conn.SendAsync("ERROR :Server shutting down")
.WaitAsync(TimeSpan.FromSeconds(2));
}
catch { }
finally
{
try { await conn.DisposeAsync(); }
catch { }
}
}
_connections.Clear();
}
}
+69
View File
@@ -0,0 +1,69 @@
namespace EchoHub.Server.Irc;
/// <summary>
/// Parsed representation of an IRC protocol line.
/// Format: [:prefix] COMMAND [params...] [:trailing]
/// </summary>
public sealed class IrcMessage
{
public string? Prefix { get; init; }
public string Command { get; init; } = "";
public List<string> Parameters { get; init; } = [];
public string? Trailing => Parameters.Count > 0 ? Parameters[^1] : null;
/// <summary>
/// Parse a raw IRC line: [:prefix SPACE] command [SPACE params] CRLF
/// </summary>
public static IrcMessage Parse(string line)
{
var span = line.AsSpan().TrimEnd("\r\n");
string? prefix = null;
var pos = 0;
// Parse optional prefix
if (span.Length > 0 && span[0] == ':')
{
var spaceIdx = span.IndexOf(' ');
if (spaceIdx == -1)
return new IrcMessage { Prefix = span[1..].ToString() };
prefix = span[1..spaceIdx].ToString();
pos = spaceIdx + 1;
}
// Skip whitespace
while (pos < span.Length && span[pos] == ' ') pos++;
// Parse command
var cmdStart = pos;
while (pos < span.Length && span[pos] != ' ') pos++;
var command = span[cmdStart..pos].ToString();
// Parse parameters
var parameters = new List<string>();
while (pos < span.Length)
{
while (pos < span.Length && span[pos] == ' ') pos++;
if (pos >= span.Length) break;
if (span[pos] == ':')
{
// Trailing parameter (rest of line)
parameters.Add(span[(pos + 1)..].ToString());
break;
}
var paramStart = pos;
while (pos < span.Length && span[pos] != ' ') pos++;
parameters.Add(span[paramStart..pos].ToString());
}
return new IrcMessage
{
Prefix = prefix,
Command = command,
Parameters = parameters,
};
}
}
@@ -0,0 +1,148 @@
using System.Text;
using System.Text.RegularExpressions;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
namespace EchoHub.Server.Irc;
public static partial class IrcMessageFormatter
{
private const int MaxIrcLineContentBytes = 400;
/// <summary>
/// Format a MessageDto as one or more IRC PRIVMSG lines.
/// </summary>
public static List<string> FormatMessage(MessageDto message)
{
var lines = new List<string>();
var ircChannel = $"#{message.ChannelName}";
var prefix = $":{message.SenderUsername}!{message.SenderUsername}@echohub";
switch (message.Type)
{
case MessageType.Text:
foreach (var chunk in SplitMessage(message.Content, MaxIrcLineContentBytes))
lines.Add($"{prefix} PRIVMSG {ircChannel} :{chunk}");
// Append embed previews if present
if (message.Embeds is { Count: > 0 })
{
foreach (var embed in message.Embeds)
lines.AddRange(FormatEmbed(prefix, ircChannel, embed));
}
break;
case MessageType.Image:
lines.Add($"{prefix} PRIVMSG {ircChannel} :[Image: {message.AttachmentFileName}]");
if (message.AttachmentUrl is not null)
lines.Add($"{prefix} PRIVMSG {ircChannel} :Download: {message.AttachmentUrl}");
foreach (var line in message.Content.Split('\n'))
{
var trimmed = line.TrimEnd('\r');
if (trimmed.Length > 0)
lines.Add($"{prefix} PRIVMSG {ircChannel} :{ColorTagsToAnsi(trimmed)}");
}
break;
case MessageType.File:
lines.Add($"{prefix} PRIVMSG {ircChannel} :[File: {message.AttachmentFileName}] {message.AttachmentUrl}");
break;
case MessageType.Audio:
lines.Add($"{prefix} PRIVMSG {ircChannel} :\u266a [Audio: {message.AttachmentFileName}] {message.AttachmentUrl}");
break;
}
return lines;
}
/// <summary>
/// Format a link embed as IRC PRIVMSG lines (text-only, no ASCII thumbnail).
/// </summary>
private static List<string> FormatEmbed(string prefix, string ircChannel, EmbedDto embed)
{
var lines = new List<string>();
var header = new List<string>();
if (!string.IsNullOrWhiteSpace(embed.SiteName))
header.Add(embed.SiteName);
if (!string.IsNullOrWhiteSpace(embed.Title))
header.Add(embed.Title);
if (header.Count > 0)
lines.Add($"{prefix} PRIVMSG {ircChannel} :\u2502 {string.Join(" \u2014 ", header)}");
if (!string.IsNullOrWhiteSpace(embed.Description))
{
var desc = embed.Description.Length > 200
? embed.Description[..197] + "..."
: embed.Description;
lines.Add($"{prefix} PRIVMSG {ircChannel} :\u2502 {desc}");
}
return lines;
}
/// <summary>
/// Convert printable color tags ({F:RRGGBB}, {B:RRGGBB}, {X}) to ANSI escape codes for IRC clients.
/// Also passes through content that already uses ANSI codes unchanged.
/// </summary>
public static string ColorTagsToAnsi(string text)
{
if (!text.Contains('{'))
return text;
return ColorTagRegex().Replace(text, match =>
{
if (match.Groups[1].Success) // {X} reset
return "\x1b[0m";
if (match.Groups[2].Success) // {F:RRGGBB} or {B:RRGGBB}
{
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);
var code = match.Groups[2].Value == "F" ? "38" : "48";
return $"\x1b[{code};2;{r};{g};{b}m";
}
return match.Value;
});
}
[GeneratedRegex(@"\{(?:(X)|(?:(F|B):([0-9A-Fa-f]{6})))\}")]
private static partial Regex ColorTagRegex();
/// <summary>
/// Split a message into chunks of approximately maxBytes (UTF-8), at word boundaries.
/// </summary>
public static List<string> SplitMessage(string content, int maxBytes)
{
if (Encoding.UTF8.GetByteCount(content) <= maxBytes)
return [content];
var chunks = new List<string>();
var current = new StringBuilder();
var currentBytes = 0;
foreach (var word in content.Split(' '))
{
var wordBytes = Encoding.UTF8.GetByteCount(word) + 1; // +1 for space
if (currentBytes + wordBytes > maxBytes && current.Length > 0)
{
chunks.Add(current.ToString().TrimEnd());
current.Clear();
currentBytes = 0;
}
current.Append(word).Append(' ');
currentBytes += wordBytes;
}
if (current.Length > 0)
chunks.Add(current.ToString().TrimEnd());
return chunks;
}
}
+65
View File
@@ -0,0 +1,65 @@
namespace EchoHub.Server.Irc;
public static class IrcNumericReply
{
// Connection registration
public const string RPL_WELCOME = "001";
public const string RPL_YOURHOST = "002";
public const string RPL_CREATED = "003";
public const string RPL_MYINFO = "004";
public const string RPL_ISUPPORT = "005";
// MOTD
public const string RPL_MOTDSTART = "375";
public const string RPL_MOTD = "372";
public const string RPL_ENDOFMOTD = "376";
public const string ERR_NOMOTD = "422";
// Channel operations
public const string RPL_NOTOPIC = "331";
public const string RPL_TOPIC = "332";
public const string RPL_NAMREPLY = "353";
public const string RPL_ENDOFNAMES = "366";
// LIST
public const string RPL_LIST = "322";
public const string RPL_LISTEND = "323";
// WHO / WHOIS
public const string RPL_WHOREPLY = "352";
public const string RPL_ENDOFWHO = "315";
public const string RPL_WHOISUSER = "311";
public const string RPL_WHOISSERVER = "312";
public const string RPL_WHOISIDLE = "317";
public const string RPL_ENDOFWHOIS = "318";
public const string RPL_WHOISCHANNELS = "319";
// AWAY
public const string RPL_UNAWAY = "305";
public const string RPL_NOWAWAY = "306";
public const string RPL_AWAY = "301";
// MODE
public const string RPL_CHANNELMODEIS = "324";
public const string RPL_UMODEIS = "221";
// Errors
public const string ERR_NOSUCHNICK = "401";
public const string ERR_NOSUCHCHANNEL = "403";
public const string ERR_CANNOTSENDTOCHAN = "404";
public const string ERR_UNKNOWNCOMMAND = "421";
public const string ERR_NONICKNAMEGIVEN = "431";
public const string ERR_ERRONEUSNICKNAME = "432";
public const string ERR_NICKNAMEINUSE = "433";
public const string ERR_NOTONCHANNEL = "442";
public const string ERR_NOTREGISTERED = "451";
public const string ERR_NEEDMOREPARAMS = "461";
public const string ERR_ALREADYREGISTERED = "462";
public const string ERR_PASSWDMISMATCH = "464";
public const string ERR_CHANOPRIVSNEEDED = "482";
// SASL
public const string RPL_LOGGEDIN = "900";
public const string RPL_SASLSUCCESS = "903";
public const string ERR_SASLFAIL = "904";
}
+15
View File
@@ -0,0 +1,15 @@
namespace EchoHub.Server.Irc;
public sealed class IrcOptions
{
public const string SectionName = "Irc";
public bool Enabled { get; set; }
public int Port { get; set; } = 6667;
public bool TlsEnabled { get; set; }
public int TlsPort { get; set; } = 6697;
public string? TlsCertPath { get; set; }
public string? TlsCertPassword { get; set; }
public string ServerName { get; set; } = "echohub";
public string? Motd { get; set; }
}

Some files were not shown because too many files have changed in this diff Show More