Compare commits

194 Commits
Author SHA1 Message Date
Hue 0c2e8eae87 Merge pull request #62 from HueByte/dev_logs_room
Dev logs room
2026-07-17 21:08:48 +02:00
HueByte 75ff10c5fd fix: remove invisible character from migration file 2026-07-17 21:00:34 +02:00
HueByte 46afd2a195 feat: add unit tests for ChannelService, FileStorageService, and UserService 2026-07-17 20:59:59 +02:00
HueByte 38eca99fb1 feat: add server logs functionality with role-based access and system channel management
- Introduced a new migration to add the IsSystem column to the Channels table.
- Updated the DbContext model snapshot to reflect the new IsSystem property.
- Enhanced the ChannelService to manage system channels, including creation, visibility control, and protection against deletion.
- Implemented ServerLogsService to handle live server logging, including reading from log files and managing access based on user roles.
- Created ServerLogsSink to queue log events for streaming to the live log room.
- Developed ServerLogsStreamService to stream log events to clients in real-time.
- Added configuration options for server logs in appsettings.
- Implemented comprehensive unit tests for channel service system channel behavior and server logs functionality.
2026-07-17 20:44:47 +02:00
HueByte df1bd0119c feat: Update configuration files for storage path and spam protection settings 2026-07-17 19:54:30 +02:00
HueByte 71d82b45fb fix: Remove invisible character from using directive 2026-07-17 19:49:38 +02:00
HueByte 53d0d326cb feat: Implement spam protection with configurable limits and auto-mute escalation 2026-07-17 19:47:57 +02:00
HueByte 3281064720 feat: Add invite codes and message replies functionality
- Introduced a new migration to add InviteCodes table and ReplyToMessageId column in Messages.
- Updated ChatHub to support replying to messages.
- Enhanced ChatService to handle message replies and validate reply targets.
- Modified UserService to implement invite-only registration mode with invite code consumption.
- Added configuration options for registration modes in appsettings.
- Created unit tests for new features including invite code registration and message reply formatting.
2026-07-17 19:47:57 +02:00
Hue bb987dda82 Merge pull request #60 from HueByte/dev_irc_fixes_adjustments
Dev irc fixes adjustments
2026-07-17 18:01:00 +02:00
HueByte e71575d920 feat: update changelog for v0.2.15 release with new features and bug fixes 2026-07-17 17:11:06 +02:00
HueByte 96736d69df feat: enhance message broadcasting to exclude sender's connection and improve IRC compliance 2026-07-17 17:05:03 +02:00
HueByte 953e081123 feat: add image handling actions and improve IRC message formatting 2026-07-17 16:52:15 +02:00
Stone_Red b9d099dd73 chore: bump version and add changelog 2026-07-17 16:36:10 +02:00
Stone_Red a9519a52cc feat: add support for broadcasting and handling channel deletions across clients 2026-07-17 16:35:59 +02:00
Hue 4d25458717 Merge pull request #58 from HueByte/dev
Dev
2026-07-16 20:33:43 +02:00
Hue 584ce45979 Merge branch 'master' into dev 2026-07-16 20:26:16 +02:00
HueByte 53927b130c Add markdown linting tools and scripts
- Create package.json to manage markdownlint-cli2 as a dev dependency.
- Add PowerShell script for linting Markdown files with options for fixing issues.
- Update shell script to prefer locally installed markdownlint-cli2 or fallback to npx.
2026-07-16 20:22:57 +02:00
HueByte ca3c1ec2b5 feat: add documentation for TUI, messages & attachments, and IRC gateway; update index and TOC 2026-07-16 20:02:18 +02:00
Hue 35a4eec749 Merge pull request #57 from HueByte/dev_copy_paste_qol
Dev copy paste qol
2026-07-16 19:44:32 +02:00
HueByte 78f18a36ba docs: update changelog for v0.2.14 to include clipboard image and multi-file paste features 2026-07-16 19:41:15 +02:00
HueByte 2157884e61 refactor: replace Key constants with KeyCode for improved clarity and performance 2026-07-16 19:40:18 +02:00
HueByte 4f2cffa372 feat: add clipboard image handling for pasting and staging attachments 2026-07-16 19:40:18 +02:00
Hue 31a0bb8c9f Merge pull request #56 from HueByte/dev_fixes_features_and_nightmares
feat: enhance IRC support with display name handling, private channel…
2026-07-16 19:32:15 +02:00
HueByte e439c8ae72 feat: implement end-to-end encryption for room keys with secure storage and migration support 2026-07-16 18:47:28 +02:00
HueByte b6c01dab15 feat: enhance IRC support with display name handling, private channel tracking, and connection identification 2026-07-16 18:22:34 +02:00
Hue 1f4af9a543 Merge pull request #55 from HueByte/dev
Dev
2026-07-16 16:52:23 +02:00
HueByte b62729dc95 feat: enhance message decryption for IRC clients and improve attachment handling 2026-07-16 16:35:34 +02:00
HueByte aae788028e feat: enhance chat functionality with auto-join, persistent read positions, and theme border customization 2026-07-16 08:19:43 +02:00
HueByte c048b39e42 feat: release v0.2.13 with chat visual overhaul and new features 2026-07-16 07:54:17 +02:00
HueByte 3886e7148c feat(chat): enhance chat UI with new features and visual improvements
- Added new attributes for chat colors including rail, date rules, and unread markers.
- Enhanced ChatLine class to support colored segments for continuation lines and horizontal separator rules.
- Introduced a WelcomeBanner class to display a splash screen when no channel is selected.
- Implemented NickColorHelper for deterministic per-nick colors, ensuring consistent user color representation.
- Updated ChannelListSource to highlight channels with unread messages and mentions.
- Improved MainWindow to include a spinner for transitional connection states and display activity in the status bar.
- Added unit tests for NickColorHelper to ensure stable and predictable color indexing.
2026-07-16 07:48:47 +02:00
Hue b9a80806e1 Merge pull request #54 from HueByte/dev_update_fixes
Dev update fixes
2026-07-16 07:10:50 +02:00
HueByte e797ec2542 feat: add /meta command for channel metadata retrieval
- Implemented the `/meta` command to fetch and display channel metadata including room ID, topic, message count, unique user count, estimated size, and protection level.
- Added `ChannelMetaDto` to encapsulate channel metadata.
- Updated `ChannelsController` to handle the new `/meta` endpoint.
- Introduced `UploadLimits` configuration for admin-defined upload size limits for files, images, audio, and avatars.
- Enhanced error handling and user feedback for metadata retrieval.
- Updated documentation to reflect changes in encryption and room metadata.
- Added tests for the new functionality and upload limits.
2026-07-16 07:04:58 +02:00
HueByte dbf6565d18 feat: Implement update confirmation handling and streamline update process 2026-07-16 07:04:26 +02:00
HueByte 494dcb46cf feat: Update CI workflows to trigger on successful completion of previous CI runs 2026-07-16 05:59:27 +02:00
Hue ce41ef9c0a Merge pull request #53 from HueByte/dev_QOL_And_Gateway
Dev qol and gateway
2026-07-16 05:53:33 +02:00
HueByte 9aea6ecfc3 refactor: Update DroppedFileParserTests to use absolute path helper for improved readability and maintainability 2026-07-16 05:52:06 +02:00
HueByte 5040c5c201 feat: Enhance ThemeManager to ensure transparent themes render correctly for editable fields 2026-07-16 05:46:02 +02:00
HueByte a3a413f0b1 feat: Add "Copy message ID" option to message context menu 2026-07-16 05:38:41 +02:00
HueByte 6292e82cec feat: Add ASCII art size selection for image attachments and enhance message context menu 2026-07-16 05:35:11 +02:00
HueByte 2d33773c24 feat: Add support for message attachments
- Introduced Attachment model to handle file attachments associated with messages.
- Updated ModerationController to manage message deletions and attachment cleanup.
- Enhanced ChatService to include attachments in message retrieval.
- Implemented migration for legacy single-attachment messages to the new Attachments model.
- Added unit tests for attachment handling in message formatting and parsing.
- Updated database context and migrations to support new Attachments table.
2026-07-16 05:02:12 +02:00
HueByte e05b420ce9 feat: Implement end-to-end encryption for channels
- Added EncryptionSalt and WrappedRoomKey properties to Channel model.
- Introduced RoomCrypto class for client-side encryption and decryption.
- Updated ChannelService to handle encrypted channels, including creation and rekeying.
- Modified ChannelsController to expose crypto metadata and rekey functionality.
- Enhanced IrcCommandHandler to block joining encrypted channels over IRC.
- Updated database schema with migration for new encryption fields.
- Refactored file validation and image processing services to accommodate encrypted channels.
- Added unit tests for RoomCrypto functionality and updated existing tests for channel services.
2026-07-16 03:50:23 +02:00
HueByte ea8e583ee5 feat: add image validation and ASCII conversion services 2026-07-16 03:50:08 +02:00
HueByte 3ca9dbfd91 feat: add password protection for channels
- Updated IChatService to include password parameter in JoinChannelAsync method.
- Modified ChannelDto and related models to support password functionality.
- Implemented password handling in ChannelService for channel creation and membership validation.
- Enhanced IrcCommandHandler to manage channel join requests with passwords.
- Added ChannelPasswordDialog for user input when joining protected channels.
- Created database migration to add PasswordHash column to Channels table.
- Updated tests to cover new password functionality in channel joining and management.
2026-07-16 03:13:03 +02:00
Stone_Red 15187c4665 chore: bump version and add changelog 2026-07-16 01:05:21 +02:00
Stone_Red 64bca51619 feat: show date on messages older than a day and improve time formatting 2026-07-16 01:04:53 +02:00
Hue 3538ec8005 Merge pull request #49 from HueByte/dev
feat: update authors and project URL in nuspec file
2026-07-01 23:03:07 +02:00
HueByte 1b234e39e1 feat: update authors and project URL in nuspec file 2026-07-01 23:00:04 +02:00
Hue 19bf123c8c Merge pull request #48 from HueByte/dev
Dev
2026-07-01 22:40:16 +02:00
Stone_Red 6cf284a475 chore: remove LICENSE.txt and VERIFICATION.txt from choco tools directory 2026-07-01 22:35:44 +02:00
HueByte 3b45bb5661 feat: update DirectoryHubUrl to point to the production server 2026-04-24 16:39:16 +02:00
Hue 6235565480 Merge pull request #46 from HueByte/dev
Dev merge
2026-04-24 16:13:39 +02:00
HueByte ecb20c4c52 feat: update .env.example with additional server configuration comments and add release checklist workflow 2026-04-24 15:51:01 +02:00
HueByte a85d16fff8 feat: update installation script and documentation for version 0.2.11 release 2026-04-24 15:51:01 +02:00
HueByte 6db93ecfea feat: enhance server registration handling with response envelope and error management 2026-04-24 15:51:01 +02:00
HueByte 67587dafc2 feat: implement DirectoryClaimStore for managing directory claims and enhance ServerDirectoryService with registration error handling 2026-04-24 15:51:01 +02:00
HueByte 1bbe099835 feat: enhance presence tracking and server registration with user count updates and multi-host support 2026-04-24 15:51:01 +02:00
Hue 769aa5b468 Merge pull request #44 from HueByte/dev
fix: resolve client startup crash by explicitly passing Configuration…
2026-04-20 18:18:01 +02:00
Hue 6660944588 Merge branch 'master' into dev 2026-04-20 18:16:20 +02:00
HueByte 4b41438af0 fix: resolve client startup crash by explicitly passing ConfigurationReaderOptions for Serilog 2026-04-20 18:14:54 +02:00
Hue a4fe432992 Merge pull request #43 from HueByte/dev
Dev v0.2.10
2026-04-20 17:45:39 +02:00
HueByte 9279e8be06 fix: update release notes for v0.2.10 to reflect Command Palette and Infinite History Scroll features 2026-04-20 17:42:06 +02:00
Hue 7f9fcfe3cc Merge pull request #36 from HueByte/dev_fix_notification_sound
fix: error when playing notification sounds in quick succession
2026-04-20 17:42:06 +02:00
Hue 62c5ab27c5 Merge pull request #39 from HueByte/dev_load_more_messages_on_scroll
feat: load more messages on scroll
2026-04-20 17:42:05 +02:00
Stone_Red 240892495b chore: add notification bug fix to changelog 2026-04-20 17:42:05 +02:00
Stone_Red 6e7cbf39f0 chore: add channel history loading and refactor GetChannelHistory with offset to changelog 2026-04-20 17:42:05 +02:00
Stone_Red 83d257591e fix: error when playing notification sounds in quick succession 2026-04-20 17:42:05 +02:00
Stone_Red e42f1a0965 feat: load more message history when scrolling to top 2026-04-20 17:42:05 +02:00
Hue fe6dfd3d4d Merge pull request #40 from HueByte/dev_search_dialog
feat: add search dialog for quick navigation
2026-04-20 17:42:04 +02:00
Stone_Red d6282885e3 refactor: move search dialog handling to AppOrchestrator 2026-04-20 17:42:04 +02:00
Stone_Red bd8b88add2 chore: add command palette with Ctrl+K for quick navigation and actions to changelog 2026-04-20 17:42:04 +02:00
Stone_Red ff9a9e3dd0 chore: mark search bar/modal as completed in the todo list 2026-04-20 17:42:04 +02:00
Stone_Red aa6599a4e0 feat: search dialog to navigate app 2026-04-20 17:42:04 +02:00
Hue 3091a146eb Merge pull request #41 from HueByte/dev_move_cursor_to_end_on_autocomplete
fix: set input field insertion point to end after autocomplete
2026-04-20 17:42:03 +02:00
HueByte 000764fdb4 chore: update version to 0.2.10 and enhance installation scripts and changelog 2026-04-20 17:42:03 +02:00
Stone_Red 56fccf5cfb chore: add cursor position fix to changelog 2026-04-20 17:42:03 +02:00
Stone_Red b98673f8c5 fix: set input field insertion point to end after autocomplete 2026-04-20 17:42:03 +02:00
Stone_Red 335cfcc28a Merge pull request #42 from HueByte/dev_fix_update_service
fix: update service
2026-04-20 17:41:23 +02:00
HueByte 6aef6890cf fix: ensure progress dialog updates on the UI thread 2026-04-20 17:41:22 +02:00
Stone_Red 5b8df9d505 fix: remove unnecessary _app.Invoke calls around update progress dialog updates 2026-04-20 17:41:22 +02:00
Stone_Red 6dbc29818c fix: error when trying to zip open log file 2026-04-20 17:41:22 +02:00
HueByte 6758aceb5d chore: add v0.2.9 release notes to changelog and table of contents 2026-04-07 15:41:18 +02:00
Hue 06aba16303 Merge pull request #35 from HueByte/dev
Merge dev branch
2026-04-07 14:51:14 +02:00
Hue 89b028cf06 Merge branch 'master' into dev 2026-04-07 14:46:58 +02:00
Stone_Red eb4aac9861 Merge pull request #37 from HueByte/dev_fix_create_dialog_checkbox
fix: when users clicks public -> private -> public checkbox in the channel creation, it ends up creating the channel on 3rd check switch
2026-02-25 22:30:13 +01:00
Stone_Red 38d63d844d chore: mark checkbox issue in channel creation as completed in todo list 2026-02-25 22:29:06 +01:00
Stone_Red 3f2211f42a fix: double click on public checkbox creates channel by setting set CommandsToBubbleUp in dialog 2026-02-25 22:29:06 +01:00
Stone_Red 0606394af2 Merge pull request #38 from HueByte/dev_fix_choco_path
fix: choco install path by separating directory and file joins
2026-02-25 18:07:20 +01:00
Stone_Red ee18743721 fix: choco install path by separating directory and file joins 2026-02-25 18:04:13 +01:00
HueByte 0aaa371488 chore: update release process for single-file publish and enhance installer script 2026-02-25 10:08:50 +01:00
HueByte 8689dc2a01 fix: add TODO for incorrect embed color display 2026-02-24 23:08:32 +01:00
HueByte 5d61266fd7 docs: add configuration guide and update related documentation 2026-02-24 23:07:41 +01:00
HueByte ae342381e0 fix: update release workflow and README badges; reorganize table of contents 2026-02-24 22:50:29 +01:00
Stone_Red ba536c6f9c Merge pull request #33 from HueByte/dev_fix_docs_urls
fix: documentation URLs in echohub.nuspecch
2026-02-24 22:47:27 +01:00
Stone_Red 92b89fa8a0 fix: github repo URLs in echohub.nuspecch 2026-02-24 22:46:35 +01:00
Stone_Red 9029d63e54 fix: documentation URLs in echohub.nuspecch 2026-02-24 22:42:07 +01:00
HueByte 023e62dea6 fix: update release workflow to correctly handle version checks and Chocolatey publishing conditions 2026-02-24 22:27:30 +01:00
HueByte 36e1ea0dfa fix: update Chocolatey publish condition to check for source changes 2026-02-24 22:18:39 +01:00
Hue be18cf88df Merge pull request #32 from HueByte/dev
chore: update release workflow to include Chocolatey package publishing
2026-02-24 22:14:25 +01:00
Hue 595475c436 Merge branch 'master' into dev 2026-02-24 22:13:31 +01:00
HueByte 1b6c27247f chore: update release workflow to include Chocolatey package publishing 2026-02-24 22:12:26 +01:00
Hue 4a598aa8a8 Merge pull request #31 from HueByte/dev
Dev merge
2026-02-24 22:01:59 +01:00
Hue ed7d0a73a3 Merge branch 'master' into dev 2026-02-24 22:00:48 +01:00
HueByte 6af85559cb chore: update todo list with completed tasks 2026-02-24 21:59:42 +01:00
Stone_Red 43f1b97244 Merge pull request #26 from HueByte/todo_list
Add  todo list
2026-02-24 21:51:42 +01:00
Stone_Red 9baa3ff566 chore: fix lint errors in todo.md 2026-02-24 21:50:44 +01:00
Stone_Red 0330f24e45 Add todo list 2026-02-24 21:48:12 +01:00
Stone_Red ac98845c69 Merge pull request #28 from HueByte/dev_fix_ghost_channel_on_join
fix: ghost channel when trying to join a channel that doesn't exist
2026-02-24 21:47:53 +01:00
Stone_Red d4f540285b chore: add ghost channel fixes to changelog 2026-02-24 21:45:33 +01:00
Stone_Red 3896640ac7 refactor: Move JoinChannelResult to the correct position in ChatDtos.cs 2026-02-24 21:45:33 +01:00
Stone_Red 29bfd87d8b fix: ghost channel when trying to join a channel that doesn't exist 2026-02-24 21:45:33 +01:00
Stone_Red b0adb55b8f refactor: Move JoinChannelResult to the correct position in ChatDtos.cs 2026-02-24 21:45:33 +01:00
Stone_Red 24a5bdb9a1 fix: ghost channel when trying to join a channel that doesn't exist 2026-02-24 21:45:33 +01:00
HueByte 5391d2cb1c fix: improve role tag formatting and handle empty role cases in user status display 2026-02-24 21:44:43 +01:00
HueByte 62996f85e9 fix: add space between role tag and username in status display 2026-02-24 21:15:41 +01:00
Hue ac5348dbb0 Merge pull request #30 from HueByte/dev_bugs_attack
Dev bugs attack
2026-02-24 21:05:11 +01:00
Hue ad5627a4bb Merge branch 'dev' into dev_bugs_attack 2026-02-24 21:03:23 +01:00
Hue c7a5829fac Merge pull request #29 from HueByte/dev_pipelines
Dev pipelines
2026-02-24 21:02:00 +01:00
HueByte a0d9d86956 fix: update markdownlint configuration to allow compact table pipe style 2026-02-24 21:00:51 +01:00
HueByte 0c16f44db6 feat: enhance user presence and channel interaction features
- Fix userlist not refreshing after creating a new channel.
- Implement unmute timer with a background job to automatically unmute users.
- Improve userlist display by filtering invisible users and ensuring proper transitions between statuses.
- Add clickable usernames, @mentions, and #channels for easier navigation.
- Embed theme colors from source sites for a more cohesive UI.
- Introduce a stateful userlist that updates incrementally via SignalR events.
- Restrict auto-opening of files to safe types only, enhancing security.
- Refactor user management into a dedicated service to reduce code duplication.
- Add a MuteExpirationService to handle timed mutes.
- Update documentation with Mermaid diagrams for major flows.
2026-02-24 20:56:40 +01:00
HueByte fb4f6c34ed feat: add detailed documentation for authentication, connection, messaging, channels, moderation, and media flows 2026-02-24 20:50:20 +01:00
Hue 8647b05c12 Merge pull request #27 from HueByte/dev_distribution_update
Dev distribution update
2026-02-24 19:51:11 +01:00
Hue e2bab3d0d9 Merge branch 'dev' into dev_distribution_update 2026-02-24 19:50:11 +01:00
HueByte 6295831045 feat: update nuspec metadata and add PATH setup functionality for terminal access 2026-02-24 19:44:55 +01:00
HueByte 045515369c feat: add Chocolatey package and Linux/macOS install script with automated publishing 2026-02-24 19:44:46 +01:00
Stone_Red fe0e31f9a0 Merge pull request #25 from HueByte/dev_improve_word_wrapping
feat: improve word wrapping in ChatLine
2026-02-24 13:54:36 +01:00
Stone_Red 3273e62b37 feat: improve word wrapping in ChatLine 2026-02-24 13:53:40 +01:00
Hue cf5fca5772 Merge pull request #24 from HueByte/dev_update_adjustments
Dev update adjustments
2026-02-23 19:46:03 +01:00
HueByte 38df05df41 feat: implement rollback functionality with pre-update backup and recovery options | Rebase 2026-02-23 18:15:16 +01:00
Stone_Red 760693befe Merge pull request #22 from HueByte/dev_manual_update_entry
feat: Add "Check for Updates" menu item
2026-02-23 17:50:12 +01:00
Stone_Red 241fee67e8 chore: update AlwaysUpToDate package to version 2.0.2.20250223 2026-02-23 17:20:38 +01:00
Stone_Red 0e3dd932af feat: Add "Check for Updates" menu item 2026-02-23 17:20:38 +01:00
Hue 81b09b1af7 Merge pull request #23 from HueByte/dev_user_irc_improvements
Dev user irc improvements
2026-02-23 15:58:37 +01:00
HueByte 94b31b6056 refactor: replace FakeChatService with FakeUserService for user authentication and profile handling in tests 2026-02-23 15:52:18 +01:00
HueByte 6e76065dcb fix: correct markdown table formatting and specify code block type in Docker documentation 2026-02-23 14:53:58 +01:00
HueByte bdcff74ad5 Refactor user management: Extract IUserService and UserService, consolidate user registration, authentication, and profile management. Fix memory leaks in ApiClient, enhance connection management, and improve error handling in AuthController and UsersController. Update IRC command handling to utilize IUserService for user operations. 2026-02-23 14:51:24 +01:00
HueByte 27a25b1b43 feat: implement user registration and update SASL authentication handling 2026-02-23 14:51:04 +01:00
HueByte 28c4b2993f feat: handle SASL authentication abort scenario in IrcCommandHandler 2026-02-23 10:57:12 +01:00
HueByte 3c760b0bd8 feat: improve IRC command handling with debug logging for incoming messages 2026-02-23 10:54:02 +01:00
HueByte c2a8e9cfc8 feat: enhance SASL authentication logging for better traceability 2026-02-23 10:40:56 +01:00
HueByte 45fd382b13 feat: add Docker support with environment configuration and entrypoint script 2026-02-22 18:12:06 +01:00
HueByte 039390c8b0 feat: add Docker workflow for building and pushing multi-arch server image to GHCR 2026-02-22 18:03:25 +01:00
HueByte c5be63db25 feat: add Docker support for EchoHub.Server with Dockerfile and docker-compose.yml 2026-02-22 17:58:18 +01:00
HueByte 993bb1f973 refactor: enhance audio playback service with semaphore locking for thread safety 2026-02-22 16:43:05 +01:00
Hue 94f968ccf9 Merge pull request #21 from HueByte/dev
Dev
2026-02-22 16:31:37 +01:00
HueByte 4f96b8d986 refactor: consolidate and reorganize code structure; extract classes and improve readability 2026-02-22 16:25:06 +01:00
HueByte fbb2958fdd refactor: remove Terminal.Gui submodule and switch to NuGet package; update version to 0.2.7 2026-02-22 16:23:19 +01:00
HueByte 62e9d7fff5 fix: optimize user fetching after connection failure handling 2026-02-22 16:02:15 +01:00
Hue be4d686d4d Merge pull request #19 from HueByte/dev_cleanups
Dev cleanups
2026-02-22 15:32:03 +01:00
HueByte c8ea124198 Add hex color parsing helper and implement custom list sources for channels and users
- Introduced HexColorHelper for parsing hex color strings to Terminal.Gui Attributes and Colors.
- Created ChannelListSource for managing and rendering a list of channels with unread counts and active indicators.
- Developed UserListSource for displaying online users with per-user nickname colors.
- Refactored MainWindow to utilize ChatMessageManager for handling messages and channel state.
- Updated project references and removed unused content from the server project.
- Adjusted tests to reflect changes in namespaces and structure.
2026-02-22 08:08:21 +01:00
HueByte c5d8e25a86 Add dialogs for connection, channel creation, profile editing, and status management
- Implemented ConnectDialog for server connection and authentication.
- Added CreateChannelDialog for creating new channels with name and topic.
- Developed ProfileEditDialog for editing user profiles including display name, bio, and avatar.
- Created ProfileViewDialog for viewing user profiles with options to edit or set status.
- Introduced StatusDialog for setting user status and status message.
- Added UpdateConfirmDialog for confirming updates and UpdateProgressDialog for showing update progress.
- Removed obsolete hue_icon.ico file from the server.
2026-02-22 08:07:53 +01:00
HueByte 03f4685ae6 fix: update release workflow to use --notes-file for release notes to prevent shell expansion issues 2026-02-22 06:03:08 +01:00
Hue 17e64293b5 Merge pull request #18 from HueByte/dev
Dev merge
2026-02-22 05:56:01 +01:00
HueByte ce375c7619 fix: remove invisible character from using directive in migration file 2026-02-22 05:51:17 +01:00
HueByte d12f249222 fix: update transparent color handling to use 'None' instead of 'Transparent' across UI components 2026-02-22 05:50:28 +01:00
HueByte bc8e2fd7da feat: add attachment file size support for messages and update upload limits 2026-02-21 20:37:13 +01:00
HueByte 35a162d013 refactor: streamline audio service stop logic in AudioPlayerDialog 2026-02-21 20:36:57 +01:00
HueByte de34fb43b9 feat: enhance audio playback features and UI, including audio player dialog and improved file size handling 2026-02-21 19:50:09 +01:00
Hue 3e03a77aeb Merge pull request #17 from HueByte/dev_api_update
feat: add ApiResponse and ApiResponse<T> records for improved API res…
2026-02-21 18:08:09 +01:00
Hue 5ef338bbf7 Merge branch 'dev' into dev_api_update 2026-02-21 18:01:25 +01:00
HueByte f96f70f09a feat: add ApiResponse and ApiResponse<T> records for improved API response handling 2026-02-21 18:00:10 +01:00
Hue b7d58dc10d Merge pull request #16 from HueByte/dev_changelog_docs
Dev changelog docs
2026-02-21 17:59:04 +01:00
HueByte 7fadd08702 docs: update API documentation and changelog for v0.2.5 release 2026-02-21 17:55:08 +01:00
Hue 576dd4bc94 Merge pull request #14 from HueByte/dev_session_persistence
Dev session persistence
2026-02-21 17:31:19 +01:00
HueByte d4cf35e04e feat: implement session persistence with "Remember Me" functionality and logout support 2026-02-21 17:29:16 +01:00
Hue 23abb9b0bb Merge pull request #13 from HueByte/dev_channel_logic_update
feat: extract channel management to IChannelService and implement CRU…
2026-02-21 17:28:53 +01:00
HueByte dcb8bbc05f fix: exclude Terminal.Gui from formatting checks in CI workflow 2026-02-21 17:26:30 +01:00
HueByte 4930aee615 chore: update CI workflows to temporarily remove Terminal.Gui submodule's nuget.config to fix restore issues 2026-02-21 17:23:14 +01:00
HueByte 2cd3bbb570 chore: remove submodule NuGet config from CI workflows 2026-02-21 17:22:39 +01:00
HueByte 0d959e317e feat: extract channel management to IChannelService and implement CRUD operations 2026-02-21 17:16:41 +01:00
HueByte 75491d65e2 feat: add nuget.config for package source management and update CI workflows to restore dependencies correctly 2026-02-21 17:16:05 +01:00
Hue 1861fd00f9 Merge pull request #12 from Stone-Red-Code/dev
feat: installer & auto update system
2026-02-21 17:12:47 +01:00
Stone_Red 9996a6be97 chore: Add icon files and set application icon for installer and projects 2026-02-21 17:11:47 +01:00
Stone_Red cf9c647c03 feat: add automatic update checking with confirmation and progress dialogs 2026-02-21 17:11:47 +01:00
Stone_Red 1fa4348700 feat: add EchoHub installer Inno Setup script 2026-02-21 17:11:47 +01:00
HueByte 0c7c620f24 feat: enable recursive submodule fetching in CI workflows 2026-02-21 17:09:04 +01:00
HueByte 56a962c1af feat: add hue icon SVG asset for improved UI representation 2026-02-21 16:17:39 +01:00
HueByte a65b41fb7f feat: enhance channel management and status bar functionality 2026-02-21 16:02:49 +01:00
HueByte ab9e09a609 feat: add support for ignoring diff files in .gitignore 2026-02-21 15:44:13 +01:00
HueByte 1bdc6ec99e feat: update chat rendering to support transparent backgrounds 2026-02-21 13:59:32 +01:00
HueByte fb12848e9a feat: add true transparent background support via Terminal.Gui fork
Add Terminal.Gui fork as submodule with transparent color support
(Color.Transparent emitting CSI 39m/49m default bg/fg sequences).
Switch EchoHub.Client from NuGet to project reference.
Update Transparent theme to use real terminal transparency.
2026-02-21 12:53:46 +01:00
HueByte 7e78c57665 chore: update changelog for v0.2.5 release with new features and fixes 2026-02-21 11:31:55 +01:00
Hue 9d4979d4a7 Merge pull request #10 from HueByte/dev_irc_abstraction_fixes
Dev irc abstraction fixes
2026-02-21 11:26:34 +01:00
HueByte c0d38015ff Add comprehensive unit tests for IRC command handling and message formatting
- Implemented IrcCommandHandlerTests to cover various IRC commands including PING, JOIN, PART, and authentication scenarios.
- Added IrcMessageFormatterTests to validate message formatting for different message types such as text, images, files, and audio.
- Created IrcMessageTests to ensure correct parsing of IRC messages and handling of various command formats.
- Introduced TestHelpers to facilitate testing with mock connections and streams, including a FakeChatService and FakeEncryptionService for simulating chat behavior.
2026-02-21 11:25:34 +01:00
Hue 3bc1446d1c Merge pull request #9 from HueByte/dev_more_tests
Dev more tests
2026-02-21 11:18:07 +01:00
HueByte 411042c397 fix: correct formatting in JwtTokenServiceTests for improved readability 2026-02-20 23:02:51 +01:00
HueByte 50817ce6e9 Add unit tests for various services and functionalities
- Implement tests for ChatLine utility methods including color tag detection and stripping.
- Create comprehensive tests for CommandHandler to validate command handling and status updates.
- Add tests for DataMigrationService to ensure correct ANSI to color tag conversion.
- Enhance FileValidationHelperTests with audio file validation tests.
- Introduce ImageToAsciiServiceTests to verify image dimension retrieval.
- Develop IrcMessageFormatterTests for message formatting and color tag conversion.
- Add JwtTokenServiceTests to validate JWT token generation and hashing.
- Implement LinkEmbedServiceTests for URL extraction and Open Graph tag parsing.
- Update EchoHub.Tests.csproj to include necessary project references.
2026-02-20 23:02:51 +01:00
Hue 9265a9f312 Merge pull request #8 from HueByte/dev_send_rework
feat: add audio playback and file download features with correspondin…
2026-02-20 23:01:03 +01:00
HueByte a860a8fbc3 feat: add audio playback and file download features with corresponding UI updates 2026-02-20 20:47:28 +01:00
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
240 changed files with 26989 additions and 2305 deletions
+77
View File
@@ -0,0 +1,77 @@
# EchoHub Server Configuration
# Copy this file to .env and customize as needed: cp .env.example .env
# These override appsettings.json via ASP.NET Core's configuration hierarchy.
# ── Server ───────────────────────────────────────────────────────────
Server__Name=My EchoHub Server
Server__Description=A self-hosted EchoHub chat server
Server__PublicServer=false
# Registration mode: open (default), invite (codes via /invite, Admin+), or closed.
# Server__Registration=open
# Hostnames advertised to the EchoHubSpace directory. Index per entry.
# Server__PublicHosts__0=echohub.example.com
# Server__PublicHosts__1=alias.example.com
# Topic tags surfaced in the EchoHubSpace browser. Index per entry.
# Server__Tags__0=community
# Server__Tags__1=gaming
# Server__Admins__0=adminUsername
# Override where the EchoHubSpace claim token file is stored (defaults next to the DB).
# Server__DirectoryClaimPath=
# ── JWT ──────────────────────────────────────────────────────────────
# Auto-generated on first run if left empty. Only set if you need a stable secret across containers.
# Jwt__Secret=
# Jwt__Issuer=EchoHub.Server
# Jwt__Audience=EchoHub.Client
# ── Encryption ───────────────────────────────────────────────────────
# Auto-generated on first run if left empty.
# Encryption__Key=
# Encryption__EncryptDatabase=false
# ── Storage ──────────────────────────────────────────────────────────
# Defaults are set in the Dockerfile to use /app/data for persistence.
# Storage__Path=
# Storage__CleanupIntervalHours=1
# Storage__RetentionDays=30
# ── Uploads ──────────────────────────────────────────────────────────
# Uploads__MaxFileSizeMB=100
# Uploads__MaxImageSizeMB=10
# Uploads__MaxAudioSizeMB=10
# Uploads__MaxAvatarSizeMB=2
# Uploads__MaxAttachmentsPerMessage=10
# ── Spam protection ──────────────────────────────────────────────────
# Per-user flood/duplicate limits with auto-mute escalation; Mods and above are exempt.
# Spam__Enabled=true
# Spam__MaxMessagesPerWindow=8
# Spam__WindowSeconds=5
# Spam__MaxDuplicateMessages=3
# Spam__AutoMuteMinutes=5
# Spam__ViolationThreshold=5
# Spam__ViolationWindowMinutes=5
# Only first-time channel joins count — keep this above your public channel count.
# Spam__MaxJoinsPerWindow=25
# Spam__JoinWindowSeconds=30
# Spam__MaxChannelCreatesPerWindow=3
# Spam__ChannelCreateWindowMinutes=10
# ── IRC Gateway ──────────────────────────────────────────────────────
Irc__Enabled=false
# Irc__Port=6667
# Irc__TlsEnabled=false
# Irc__TlsPort=6697
# Irc__TlsCertPath=
# Irc__TlsCertPassword=
# Irc__ServerName=echohub
# Irc__Motd=Welcome to EchoHub IRC Gateway!
# Public base URL used to make attachment links absolute for IRC clients.
# Irc__PublicBaseUrl=https://chat.example.com
# ── CORS ─────────────────────────────────────────────────────────────
# Leave unset to allow all origins. Index per entry to whitelist.
# Cors__AllowedOrigins__0=https://chat.example.com
# ── Logging ──────────────────────────────────────────────────────────
# Serilog__MinimumLevel__Default=Information
+11
View File
@@ -14,6 +14,15 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- name: Install pinned linter
run: npm ci
- name: Run markdownlint
run: bash scripts/lint-markdown.sh
@@ -33,6 +42,8 @@ jobs:
build-and-test:
name: Build & Test
# Run the cheap, fast checks first; only spend build/test compute if they pass.
needs: [format-check, lint-markdown]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+102
View File
@@ -0,0 +1,102 @@
name: Docker
# Runs only after the CI workflow completes, so an image is never pushed for a commit
# whose formatting, lint, build, or tests failed.
on:
workflow_run:
workflows: ["CI"]
types: [completed]
branches: [master]
workflow_dispatch:
permissions:
contents: read
packages: write
env:
IMAGE: ghcr.io/huebyte/echohub-server
jobs:
docker:
name: Build & Push Docker Image
# Proceed only for a successful CI run on a master push, or a manual dispatch.
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == 'master')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# The exact commit CI tested (workflow_run), or the current tip (manual dispatch).
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- name: Check for src/ changes
id: changes
run: |
if git rev-parse HEAD~1 >/dev/null 2>&1; then
CHANGED=$(git diff --name-only HEAD~1 HEAD -- 'src/' | wc -l)
else
CHANGED=1
fi
[ "$CHANGED" -gt 0 ] && echo "src_changed=true" >> "$GITHUB_OUTPUT" || echo "src_changed=false" >> "$GITHUB_OUTPUT"
- name: Read version
if: steps.changes.outputs.src_changed == 'true'
id: version
run: |
VERSION=$(grep -oP '(?<=<Version>)[^<]+' src/Directory.Build.props)
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
- name: Check if image tag exists
if: steps.changes.outputs.src_changed == 'true'
id: check_image
run: |
TAG="${{ steps.version.outputs.tag }}"
if docker manifest inspect "${{ env.IMAGE }}:${TAG}" &>/dev/null; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
if: steps.changes.outputs.src_changed == 'true' && steps.check_image.outputs.exists == 'false'
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
if: steps.changes.outputs.src_changed == 'true' && steps.check_image.outputs.exists == 'false'
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
if: steps.changes.outputs.src_changed == 'true' && steps.check_image.outputs.exists == 'false'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
if: steps.changes.outputs.src_changed == 'true' && steps.check_image.outputs.exists == 'false'
uses: docker/build-push-action@v6
with:
context: ./src
file: ./src/EchoHub.Server/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ env.IMAGE }}:latest
${{ env.IMAGE }}:${{ steps.version.outputs.tag }}
labels: |
org.opencontainers.image.title=EchoHub Server
org.opencontainers.image.description=Self-hosted IRC-style chat server
org.opencontainers.image.version=${{ steps.version.outputs.version }}
org.opencontainers.image.source=https://github.com/${{ github.repository }}
- name: Skip notice
if: steps.changes.outputs.src_changed != 'true' || steps.check_image.outputs.exists == 'true'
run: echo "⏭️ Skipped — no src/ changes or image tag already exists."
+60
View File
@@ -0,0 +1,60 @@
name: Release Checklist
on:
pull_request:
branches: [master]
workflow_dispatch:
jobs:
release-checklist:
name: Release Checklist
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Extract version
id: version
run: |
VERSION=$(grep -oP '(?<=<Version>)[^<]+' src/Directory.Build.props)
if [ -z "$VERSION" ]; then
echo "::error file=src/Directory.Build.props::Could not read version from Directory.Build.props"
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Version: $VERSION"
- name: Check version was bumped from master
run: |
BRANCH_VERSION="${{ steps.version.outputs.version }}"
git fetch origin master --depth=1
MASTER_VERSION=$(git show origin/master:src/Directory.Build.props | grep -oP '(?<=<Version>)[^<]+')
echo "Branch: $BRANCH_VERSION | Master: $MASTER_VERSION"
if [ "$BRANCH_VERSION" = "$MASTER_VERSION" ]; then
echo "::error file=src/Directory.Build.props::Version $BRANCH_VERSION was not bumped from master. Update <Version> in src/Directory.Build.props."
exit 1
fi
- name: Check changelog file exists
run: |
VERSION="${{ steps.version.outputs.version }}"
FILE="docs/changelog/v${VERSION}.md"
if [ ! -f "$FILE" ]; then
echo "::error::Missing changelog file: $FILE"
exit 1
fi
echo "Found: $FILE"
- name: Check changelog TOC
run: |
VERSION="${{ steps.version.outputs.version }}"
if ! grep -q "v${VERSION}.md" docs/changelog/toc.yml; then
echo "::error file=docs/changelog/toc.yml::v${VERSION} not found in changelog TOC. Add it to docs/changelog/toc.yml."
exit 1
fi
if ! grep -q "v${VERSION}" docs/changelog/index.md; then
echo "::error file=docs/changelog/index.md::v${VERSION} not found in changelog index. Add it to docs/changelog/index.md."
exit 1
fi
echo "toc.yml and index.md: OK"
+88 -20
View File
@@ -1,7 +1,11 @@
name: Release
# Runs only after the CI workflow completes, so a release is never published on a commit
# whose formatting, lint, build, or tests failed.
on:
push:
workflow_run:
workflows: ["CI"]
types: [completed]
branches: [master]
workflow_dispatch:
@@ -11,26 +15,36 @@ permissions:
jobs:
release:
name: Create Release
# Proceed only for a successful CI run on a master push, or a manual dispatch.
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == 'master')
runs-on: ubuntu-latest
outputs:
should_release: ${{ steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false' }}
release_exists: ${{ steps.check_release.outputs.exists }}
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# The exact commit CI tested (workflow_run), or the current tip (manual dispatch).
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- name: Check for src/ changes
id: changes
env:
BEFORE: ${{ github.event.before }}
run: |
if [ -z "$BEFORE" ] || [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
echo "src_changed=true" >> "$GITHUB_OUTPUT"
if git rev-parse HEAD~1 >/dev/null 2>&1; then
CHANGED=$(git diff --name-only HEAD~1 HEAD -- 'src/' | wc -l)
else
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"
CHANGED=1
fi
[ "$CHANGED" -gt 0 ] && echo "src_changed=true" >> "$GITHUB_OUTPUT" || echo "src_changed=false" >> "$GITHUB_OUTPUT"
- name: Read version
if: steps.changes.outputs.src_changed == 'true'
id: version
run: |
VERSION=$(grep -oP '(?<=<Version>)[^<]+' src/Directory.Build.props)
@@ -38,7 +52,6 @@ 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
@@ -53,7 +66,7 @@ jobs:
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
dotnet-version: "10.0.x"
- name: Publish Server win-x64
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
@@ -71,21 +84,29 @@ jobs:
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 Server linux-arm64
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-arm64 --self-contained true -o publish/server-linux-arm64
- name: Publish Client win-x64
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
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -o publish/client-win-x64
- name: Publish Client linux-x64
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
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r linux-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -o publish/client-linux-x64
- name: Publish Client osx-x64
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
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r osx-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -o publish/client-osx-x64
- name: Publish Client osx-arm64
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
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r osx-arm64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -o publish/client-osx-arm64
- name: Publish Client linux-arm64
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-arm64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -o publish/client-linux-arm64
- name: Zip artifacts
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
@@ -95,14 +116,15 @@ jobs:
zip -r ../EchoHub-Server-linux-x64.zip server-linux-x64/
zip -r ../EchoHub-Server-osx-x64.zip server-osx-x64/
zip -r ../EchoHub-Server-osx-arm64.zip server-osx-arm64/
zip -r ../EchoHub-Server-linux-arm64.zip server-linux-arm64/
zip -r ../EchoHub-Client-win-x64.zip client-win-x64/
zip -r ../EchoHub-Client-linux-x64.zip client-linux-x64/
zip -r ../EchoHub-Client-osx-x64.zip client-osx-x64/
zip -r ../EchoHub-Client-osx-arm64.zip client-osx-arm64/
zip -r ../EchoHub-Client-linux-arm64.zip client-linux-arm64/
- name: Build release notes
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
id: notes
run: |
TAG="${{ steps.version.outputs.tag }}"
CHANGELOG_URL="https://huebyte.github.io/EchoHub/changelog/${TAG}.html"
@@ -110,7 +132,6 @@ jobs:
PREV_TAG=$(git tag --sort=-v:refname | grep -v "^${TAG}$" | head -n 1)
{
echo "body<<RELEASE_EOF"
echo "📋 **[Full Changelog](${CHANGELOG_URL})**"
echo ""
echo "---"
@@ -129,22 +150,69 @@ jobs:
else
echo "*Version diff: [${TAG}](${REPO}/commits/${TAG})*"
fi
echo "RELEASE_EOF"
} >> "$GITHUB_OUTPUT"
} > release-notes.md
- name: Create GitHub Release
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 }}" \
--notes "${{ steps.notes.outputs.body }}" \
--notes-file release-notes.md \
EchoHub-Server-win-x64.zip \
EchoHub-Server-linux-x64.zip \
EchoHub-Server-osx-x64.zip \
EchoHub-Server-osx-arm64.zip \
EchoHub-Server-linux-arm64.zip \
EchoHub-Client-win-x64.zip \
EchoHub-Client-linux-x64.zip \
EchoHub-Client-osx-x64.zip \
EchoHub-Client-osx-arm64.zip
EchoHub-Client-osx-arm64.zip \
EchoHub-Client-linux-arm64.zip
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
choco:
name: Publish to Chocolatey
needs: release
if: needs.release.outputs.release_exists == 'true' || needs.release.outputs.should_release == 'true'
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Check if version already published
id: choco_check
shell: pwsh
run: |
$version = "${{ needs.release.outputs.version }}"
$result = choco search echohub --version $version --exact --source https://community.chocolatey.org/api/v2/ 2>&1
if ($result -match "echohub $version") {
echo "exists=true" >> $env:GITHUB_OUTPUT
Write-Host "Chocolatey package echohub $version already published — skipping."
} else {
echo "exists=false" >> $env:GITHUB_OUTPUT
}
- name: Download release asset
if: steps.choco_check.outputs.exists == 'false'
run: gh release download "v${{ needs.release.outputs.version }}" --pattern "EchoHub-Client-win-x64.zip"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Pack and push
if: steps.choco_check.outputs.exists == 'false'
shell: pwsh
run: |
$version = "${{ needs.release.outputs.version }}"
$checksum = (Get-FileHash EchoHub-Client-win-x64.zip -Algorithm SHA256).Hash.ToLower()
# Stamp version and checksum into package templates
(Get-Content packaging/choco/echohub.nuspec) -replace '__VERSION__', $version | Set-Content packaging/choco/echohub.nuspec
(Get-Content packaging/choco/tools/chocolateyInstall.ps1) -replace '__VERSION__', $version | Set-Content packaging/choco/tools/chocolateyInstall.ps1
(Get-Content packaging/choco/tools/chocolateyInstall.ps1) -replace '__CHECKSUM64__', $checksum | Set-Content packaging/choco/tools/chocolateyInstall.ps1
cd packaging/choco
choco pack echohub.nuspec --output-directory $env:TEMP
$pkg = Get-ChildItem "$env:TEMP\echohub.*.nupkg" | Select-Object -First 1
choco push $pkg.FullName --source https://push.chocolatey.org/ --api-key $env:CHOCO_API_KEY
env:
CHOCO_API_KEY: ${{ secrets.CHOCOLATEY_API_KEY }}
+3
View File
@@ -433,3 +433,6 @@ src/EchoHub.Server/uploads/*
# DocFx generated output
docs/_site/
docs/_api_meta/
# Diff files
*.diff
+3 -1
View File
@@ -9,7 +9,9 @@
// Allow inline HTML (docfx uses it)
"MD033": false,
// Allow bare URLs
"MD034": false
"MD034": false,
// Allow compact table pipe style (flow docs use compact tables)
"MD060": false
},
"globs": ["**/*.md"],
+60 -16
View File
@@ -27,12 +27,14 @@
</p>
<p align="center">
<a href="https://github.com/HueByte/EchoHub/actions/workflows/ci.yml"><img alt="Build" src="https://img.shields.io/github/actions/workflow/status/HueByte/EchoHub/ci.yml?branch=master&style=flat-square&logo=github&label=Build" /></a>
<a href="https://github.com/HueByte/EchoHub/releases/latest"><img alt="Release" src="https://img.shields.io/github/v/release/HueByte/EchoHub?style=flat-square&logo=github&label=Release" /></a>
<a href="https://community.chocolatey.org/packages/echohub"><img alt="Chocolatey" src="https://img.shields.io/chocolatey/v/echohub?style=flat-square&logo=chocolatey&label=Chocolatey" /></a>
<a href="https://github.com/HueByte/EchoHub/pkgs/container/echohub-server"><img alt="Docker" src="https://img.shields.io/badge/Docker-GHCR-2496ED?style=flat-square&logo=docker&logoColor=white" /></a>
<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" />
<img alt="SQLite" src="https://img.shields.io/badge/SQLite-EF%20Core-003B57?style=flat-square&logo=sqlite&logoColor=white" />
<img alt="License" src="https://img.shields.io/badge/License-MIT-green?style=flat-square" />
<img alt="Terminal.Gui" src="https://img.shields.io/badge/TUI-Terminal.Gui%20v2-yellow?style=flat-square" />
<img alt="Electron" src="https://img.shields.io/badge/Electron-None-red?style=flat-square" />
<a href="LICENSE"><img alt="License" src="https://img.shields.io/github/license/HueByte/EchoHub?style=flat-square" /></a>
<img alt="Repo size" src="https://img.shields.io/github/repo-size/HueByte/EchoHub?style=flat-square&label=Size" />
</p>
---
@@ -76,14 +78,18 @@ graph TD
### Server
- **Self-hostable** — your server, your rules, your data
- **Docker ready** — `docker compose up -d` and you're done
- **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)
- **Moderation** — ban, mute (timed or permanent), kick, role assignment
- **Registration control** — open, invite-code-gated (`/invite`), or closed; codes live in your own database
- **File & image uploads** with actual validation (magic bytes, not just trusting the extension)
- **Image-to-ASCII** — because images in a terminal is objectively cool
- **Presence tracking** — online/away/DND/invisible with custom status messages
- **Rate limiting** — in case someone gets too excited
- **Spam protection** — per-user flood/duplicate limits with auto-mute escalation, covering TUI and IRC alike; configurable under `Spam`, Mods exempt
- **Auto-restart** on crash with exponential backoff — it picks itself back up
- **Serilog logging** — console + rolling file, because `Console.WriteLine` isn't a logging strategy
- **Zero config first run** — generates its own JWT secret and config on launch
@@ -91,37 +97,61 @@ graph TD
### Client
- **Runs in your terminal** — no browser, no Electron, no 500MB of bundled Chromium
- **13 built-in themes** — including `hacker` for when you want to feel like you're in a movie
- **14 built-in themes** — including `hacker` for when you want to feel like you're in a movie
- **Slash commands** — `/join`, `/send`, `/status`, `/theme`, etc.
- **Colored nicknames** — pick your hex color, express yourself
- **File/image sharing** — local files or URLs
- **Clickable everything** — usernames, @mentions, #channels — just press Enter
- **File/image sharing** — local files or URLs; drag & drop a file onto the terminal to send it; save the original behind any ASCII-art image
- **End-to-end encrypted rooms** — password-protected channels are encrypted with a passphrase-derived key that never reaches the server, so not even the server owner can read messages or files (they can still see counts and storage size)
- **Replies** — quote a message, jump back to the original; being replied to pings like a mention
- **Your data is yours** — `/export` everything the server holds about you; `/deleteaccount` removes it
- **Multi-server** — save and switch between servers
- **Auto-reconnect** — drops happen, it rejoins your channels automatically
- **Auto-updater** — updates in-place with automatic rollback if something goes wrong
- **Message history** on join — you won't miss context
## Getting Started
### Download
### Install the Client
Grab a self-contained binary from [Releases](../../releases) — no runtime needed, just run it.
**Windows (Chocolatey):**
### Prerequisites (for development)
```bash
choco install echohub
```
- [.NET 10 SDK](https://dotnet.microsoft.com/download)
**Linux / macOS:**
### Run the Server
```bash
curl -sSfL https://raw.githubusercontent.com/HueByte/EchoHub/master/scripts/install.sh | sh
```
**Manual download:** grab a self-contained binary from [Releases](../../releases) — no runtime needed, just run it.
### Host a Server
**Docker (recommended):**
```bash
cp .env.example .env
docker compose up -d
```
Pre-built images on [GHCR](https://github.com/HueByte/EchoHub/pkgs/container/echohub-server) — `linux/amd64` and `linux/arm64`.
**From source:**
```bash
dotnet run --project src/EchoHub.Server
```
First run does everything for you:
Requires [.NET 10 SDK](https://dotnet.microsoft.com/download). First run does everything for you:
1. Creates `appsettings.json` from the example config
2. Generates a secure JWT secret
3. Creates the database with a `#general` channel
### Run the Client
### Run the Client (from source)
```bash
dotnet run --project src/EchoHub.Client
@@ -165,13 +195,15 @@ irssi -c your-server.com -p 6667 -w <password> -n <username>
/connect echohub
```
IRC users must have an existing EchoHub account (no registration via IRC). Auth works via `PASS`/`NICK`/`USER` or SASL PLAIN.
Auth works via `PASS`/`NICK`/`USER` or SASL PLAIN. New usernames are auto-registered on first connect — no separate signup needed.
### What Works
| Feature | How it maps to IRC |
| ------- | ------------------ |
| Text messages | Standard `PRIVMSG` (long messages split at ~400 byte chunks) |
| `/me` actions | Native CTCP ACTION in both directions |
| Replies | TUI replies arrive as `> nick: snippet \| text` |
| Images | `[Image: filename]` + download URL + ASCII art line-by-line |
| File uploads | `[File: filename] /api/files/{id}` |
| Channels | `JOIN`, `PART`, `NAMES`, `TOPIC`, `LIST` |
@@ -199,21 +231,31 @@ For direct TLS without a reverse proxy, the IRC gateway can terminate TLS itself
| Command | Description |
| ------- | ----------- |
| `/join <channel>` | Join a channel |
| `/join <channel> [password]` | Join a channel (passphrase for encrypted channels) |
| `/me <action>` | Action message — `* nick waves` (native CTCP ACTION over IRC) |
| `/banner <text>` | Render short text as a big ASCII banner |
| `/passwd <old> <new>` | Change the current encrypted channel's passphrase (creator only) |
| `/size [s\|m\|l]` | ASCII-art size for attached images (no arg = picker) |
| `/downloadpath [path]` | Set the download folder (no path = native folder picker) |
| `/leave` | Leave current channel |
| `/topic <text>` | Set channel topic (creator only) |
| `/send <file or URL>` | Upload a file or image |
| `/status <online\|away\|dnd\|invisible>` | Set your status |
| `/status <message>` | Set a status message |
| `/status msg <text>` | Set a status message (keeps your status; empty text clears it) |
| `/nick <name>` | Set display name |
| `/color <#hex>` | Set nickname color |
| `/theme <name>` | Switch theme |
| `/profile` | Open profile editor |
| `/users` | List online users in channel |
| `/servers` | Manage saved servers |
| `/invite [uses] [hours]` | Create a registration invite code (Admin+); also `list` / `revoke <code>` |
| `/export` | Download everything the server stores about you as JSON |
| `/deleteaccount` | Permanently delete your account (password re-confirmed) |
| `/help` | Show help |
| `/quit` | Exit |
**Message actions:** **right-click a message** for a context menu — reply (quotes the message; Esc cancels a pending reply), delete, save/download/play its attachment, mention the sender, view their profile, or copy the text. (Keyboard alternative: press <kbd>F6</kbd> to focus the message list, select with the arrow keys, and press <kbd>Delete</kbd>; <kbd>F6</kbd> again returns to the input.) You can always delete your own messages; moderators and above can delete others' messages, but only from users below their own role.
## Themes
`/theme <name>` to switch:
@@ -222,6 +264,7 @@ For direct TLS without a reverse proxy, the IRC gateway can terminate TLS itself
| ----- | ---- |
| `default` | Gray on black — clean and quiet |
| `transparent` | White on black — for fancy transparent terminals |
| `transparentlight` | Black on transparent — dark characters for light transparent terminals |
| `classic` | White on blue — IRC nostalgia |
| `light` | Black on white — for the brave |
| `hacker` | Green on black — *I'm in* |
@@ -245,6 +288,7 @@ For direct TLS without a reverse proxy, the IRC gateway can terminate TLS itself
| `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:Registration` | `open` | `open`, `invite` (codes via `/invite`, Admin+), or `closed` |
| `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 |
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

+19
View File
@@ -0,0 +1,19 @@
services:
echohub-server:
build:
context: ./src
dockerfile: EchoHub.Server/Dockerfile
# image: ghcr.io/huebyte/echohub-server:latest # use this instead of build for pre-built images
container_name: echohub-server
restart: unless-stopped
ports:
- "5000:5000"
# - "6667:6667" # IRC (plain, no encryption)
# - "6697:6697" # IRC (TLS encrypted, preferred)
volumes:
- echohub-data:/app/data
env_file:
- .env
volumes:
echohub-data:
+4 -2
View File
@@ -5,7 +5,9 @@ Articles related to the EchoHub TUI client built with Terminal.Gui v2.
## Topics
- Terminal.Gui v2 patterns and conventions
- Theme system and customization
- Theme system and customization (including transparent theme)
- Command system reference
- Configuration management
- Configuration and session persistence
- Audio playback and file downloads
- Automatic update checking
- [Notification sounds](../../articles/notification-sounds.md)
+1 -1
View File
@@ -5,5 +5,5 @@ Articles related to the EchoHub.Core shared library.
## Topics
- Data models and DTOs
- Contract interfaces (IChatService, IChatBroadcaster, IEchoHubClient)
- Contract interfaces (IChatService, IChannelService, IChatBroadcaster, IEchoHubClient)
- Validation constants and shared rules
+2 -2
View File
@@ -10,11 +10,11 @@ Terminal.Gui v2 TUI application -- UI components, services, themes, and configur
### Core
Shared library -- DTOs, models, constants, and contracts (`IChatService`, `IChatBroadcaster`, `IEchoHubClient`).
Shared library -- DTOs, models, constants, and contracts (`IChatService`, `IChannelService`, `IChatBroadcaster`, `IEchoHubClient`).
### Server
ASP.NET Core server -- controllers, hubs, ChatService, SignalRBroadcaster, authentication, and data access.
ASP.NET Core server -- controllers, hubs, ChatService, ChannelService, SignalRBroadcaster, authentication, file cleanup, and data access.
### Server.Irc
+3 -2
View File
@@ -8,6 +8,7 @@ Articles related to the EchoHub server built with ASP.NET Core.
- SignalR hub and real-time messaging
- IRC gateway and protocol bridging
- ChatService and broadcaster pattern
- File upload and validation
- ChannelService and channel CRUD
- File upload, validation, and cleanup
- Rate limiting configuration
- Database schema and migrations
- Database schema, migrations, and DataMigrationService
+3 -3
View File
@@ -3,19 +3,19 @@
- 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
+5 -5
View File
@@ -22,7 +22,7 @@ Shared library containing:
- **Models**: `User`, `Channel`, `Message`, `RefreshToken`
- **DTOs**: Record types for API requests/responses
- **Contracts**: `IChatService` (protocol-agnostic chat operations), `IChatBroadcaster` (event fan-out interface), `IEchoHubClient` (SignalR client interface)
- **Contracts**: `IChatService` (protocol-agnostic chat operations), `IChannelService` (channel CRUD and membership), `IChatBroadcaster` (event fan-out interface), `IEchoHubClient` (SignalR client interface)
- **Constants**: `ValidationConstants` (shared regex patterns), `HubConstants`
### EchoHub.Server
@@ -33,7 +33,7 @@ ASP.NET Core web application:
- **Hubs**: SignalR `ChatHub` -- thin adapter delegating to `IChatService`
- **Auth**: JWT token service (15-min access tokens, 30-day refresh tokens)
- **Data**: EF Core with SQLite
- **Services**: `ChatService` (core business logic), `SignalRBroadcaster`, presence tracking, file storage, image-to-ASCII conversion
- **Services**: `ChatService` (core business logic), `ChannelService` (channel CRUD and membership), `SignalRBroadcaster`, presence tracking, file storage, image-to-ASCII conversion, `FileCleanupService` (periodic removal of expired uploads), `DataMigrationService` (startup schema/data evolution)
### EchoHub.Server.Irc
@@ -51,9 +51,9 @@ IRC users authenticate with existing EchoHub accounts via `PASS`/`NICK`/`USER` o
Terminal.Gui v2 TUI application:
- **UI**: Main window, dialogs, chat renderer with ANSI color support
- **Services**: API client with automatic token refresh, SignalR connection wrapper
- **Themes**: 13 built-in color themes
- **Config**: Client configuration management
- **Services**: API client with automatic token refresh, SignalR connection wrapper, audio playback (NetCoreAudio), automatic update checker (AlwaysUpToDate)
- **Themes**: 14 built-in color themes (including transparent dark/light themes with true terminal transparency)
- **Config**: Client configuration management with session persistence ("Remember Me" refresh tokens)
## Communication
+210
View File
@@ -0,0 +1,210 @@
# Configuration
EchoHub Server generates an `appsettings.json` with sensible defaults on first run (including a random JWT secret), so you can launch and start chatting immediately. Tweak things later when you feel like it.
> [!NOTE]
> Under the hood, EchoHub Server is built on ASP.NET Core, so it inherits the standard .NET configuration system. If you're familiar with that, everything works exactly as you'd expect. If not — no worries, this page covers everything you need.
## How It Works
EchoHub Server loads settings from multiple sources. Each source **overrides** the previous one, so you can layer defaults with environment-specific values:
```text
1. appsettings.json (base defaults)
2. appsettings.{Environment}.json (e.g. appsettings.Production.json)
3. Environment variables (great for Docker / CI)
4. Command-line arguments (highest priority)
```
The last one wins. If `appsettings.json` says `"Irc:Port": 6667` but you pass `--Irc:Port=7000` on the command line, port 7000 is what you get.
In practice this means you can leave `appsettings.json` alone and override just the settings you care about using environment variables or CLI flags — no need to edit JSON files if that's not your thing.
### Environment Variable Mapping
Environment variables use **double underscores** (`__`) in place of the JSON nesting. The rule is simple — replace every `:` (or each level of JSON nesting) with `__`:
| appsettings.json path | Environment variable |
| --- | --- |
| `Server:Name` | `Server__Name` |
| `Irc:Enabled` | `Irc__Enabled` |
| `Jwt:Secret` | `Jwt__Secret` |
| `Serilog:MinimumLevel:Default` | `Serilog__MinimumLevel__Default` |
| `ConnectionStrings:DefaultConnection` | `ConnectionStrings__DefaultConnection` |
Arrays use numeric indices: `Server:Admins:0` becomes `Server__Admins__0`, `Server:Admins:1` becomes `Server__Admins__1`, and so on.
This is why the Docker `.env` file uses `Server__Name=My Server` instead of JSON — Docker passes these as environment variables, and the server picks them up automatically.
### Examples
All three of these achieve the same thing — use whichever fits your setup.
**appsettings.json** (direct editing):
```json
{
"Server": {
"Name": "My EchoHub Server",
"PublicServer": true
}
}
```
**Environment variables** (Docker, systemd, shell export):
```bash
export Server__Name="My EchoHub Server"
export Server__PublicServer=true
```
**Command-line arguments** (quick overrides, highest priority):
```bash
./EchoHub.Server --Server:Name="My EchoHub Server" --Irc:Enabled=true
```
## Configuration Reference
The full `appsettings.json` is auto-generated on first run from the [example config](https://github.com/HueByte/EchoHub/blob/master/src/EchoHub.Server/appsettings.example.json). Here's every option:
### General
| Key | Default | Description |
| --- | --- | --- |
| `Urls` | `http://0.0.0.0:5000` | Listen address and port |
| `AllowedHosts` | `*` | Allowed host headers (leave `*` unless you need host filtering) |
### Database
| Key | Default | Description |
| --- | --- | --- |
| `ConnectionStrings:DefaultConnection` | *(empty)* | SQLite connection string. Empty = `echohub.db` in the app directory |
### Authentication
| Key | Default | Description |
| --- | --- | --- |
| `Jwt:Secret` | *(auto-generated)* | Signing key (min 32 chars). Auto-generated on first run |
| `Jwt:Issuer` | `EchoHub.Server` | JWT issuer claim |
| `Jwt:Audience` | `EchoHub.Client` | JWT audience claim |
Access tokens expire after 15 minutes, refresh tokens after 30 days with rotation on each use.
### Server Identity
| Key | Default | Description |
| --- | --- | --- |
| `Server:Name` | `My EchoHub Server` | Display name shown to clients |
| `Server:Description` | `A self-hosted EchoHub chat server` | Server description |
| `Server:PublicServer` | `false` | Register on the [public directory](https://echohub.voidcube.cloud/servers) |
| `Server:PublicHosts` | `[]` | Hostnames advertised to the directory (array, e.g. `["chat.example.com"]`). Required when `PublicServer` is `true` |
| `Server:Tags` | `[]` | Topic tags surfaced in the directory browser (array, e.g. `["community", "gaming"]`) |
| `Server:Admins` | `[]` | Array of admin usernames (e.g. `["alice", "bob"]`) |
| `Server:Registration` | `open` | Registration mode: `open`, `invite` (codes via `/invite`, Admin+), or `closed` |
| `Server:DirectoryClaimPath` | *(empty)* | Overrides where the directory claim token file is stored. Empty = `directory-claim.json` next to the SQLite database. Treat it as a secret and back it up with the database |
### Uploads
Per-attachment size limits by kind (in megabytes) and the per-message attachment cap. An
absent or partial `Uploads` section keeps the built-in defaults. See
[Messages & Attachments](messages-and-attachments.md) for how kinds are detected.
| Key | Default | Description |
| --- | --- | --- |
| `Uploads:MaxImageSizeMB` | `10` | Max size for one image attachment |
| `Uploads:MaxAudioSizeMB` | `10` | Max size for one audio attachment |
| `Uploads:MaxFileSizeMB` | `100` | Max size for any other attachment |
| `Uploads:MaxAvatarSizeMB` | `2` | Max avatar upload size |
| `Uploads:MaxAttachmentsPerMessage` | `10` | Attachments allowed on a single message |
The server sizes its request-body limits from these values, so raising a limit here is all
that's needed — no separate Kestrel tuning.
### Spam Protection
Per-user flood, duplicate, join, and channel-create limits with auto-mute escalation. Mods and
above are always exempt, and the defaults are lenient enough that a fast typist never trips them.
An absent or partial `Spam` section keeps these defaults.
| Key | Default | Description |
| --- | --- | --- |
| `Spam:Enabled` | `true` | Master switch for all spam protection |
| `Spam:MaxMessagesPerWindow` | `8` | Max messages per `WindowSeconds` before a send is rejected |
| `Spam:WindowSeconds` | `5` | Sliding window (seconds) for the message-rate check |
| `Spam:MaxDuplicateMessages` | `3` | Identical messages in a row tolerated before rejection (E2E rooms are exempt — ciphertext differs each time) |
| `Spam:AutoMuteMinutes` | `5` | Auto-mute duration once a user hits the violation threshold. `0` disables auto-mute (rejections still apply) |
| `Spam:ViolationThreshold` | `5` | Rejected sends within `ViolationWindowMinutes` that trigger an auto-mute |
| `Spam:ViolationWindowMinutes` | `5` | Window (minutes) over which violations accumulate |
| `Spam:MaxJoinsPerWindow` | `25` | Max first-time channel joins per `JoinWindowSeconds` — keep this above your public channel count |
| `Spam:JoinWindowSeconds` | `30` | Sliding window (seconds) for the join-rate check |
| `Spam:MaxChannelCreatesPerWindow` | `3` | Max channel creations per `ChannelCreateWindowMinutes` |
| `Spam:ChannelCreateWindowMinutes` | `10` | Window (minutes) for the channel-create check |
### Encryption
| Key | Default | Description |
| --- | --- | --- |
| `Encryption:Key` | *(auto-generated)* | AES key for message encryption in transit |
| `Encryption:EncryptDatabase` | `false` | Also encrypt message content at rest in SQLite |
### Storage
| Key | Default | Description |
| --- | --- | --- |
| `Storage:Path` | *(empty)* | Directory for uploaded file blobs (attachments, avatars). Empty = `uploads/` in the app directory; the Docker image sets it to `/app/data/uploads` on the persistent volume |
| `Storage:CleanupIntervalHours` | `1` | How often the cleanup job runs (hours) |
| `Storage:RetentionDays` | `30` | Days to keep uploaded files before cleanup |
### IRC Gateway
| Key | Default | Description |
| --- | --- | --- |
| `Irc:Enabled` | `false` | Enable the IRC protocol gateway |
| `Irc:Port` | `6667` | IRC plain-text listen port |
| `Irc:TlsEnabled` | `false` | Enable TLS termination for IRC |
| `Irc:TlsPort` | `6697` | IRC TLS listen port |
| `Irc:TlsCertPath` | *(empty)* | Path to a PKCS#12 (`.pfx`) certificate |
| `Irc:TlsCertPassword` | *(empty)* | Password for the certificate file |
| `Irc:ServerName` | `echohub` | IRC server name in protocol messages |
| `Irc:Motd` | `Welcome to EchoHub IRC Gateway!` | Message of the day |
### Server Logs Room
When enabled, EchoHub auto-creates a read-only system channel and streams log events to it live,
so operators can watch the server from inside the app. Log lines are **never stored as messages**
the rolling Serilog files remain the only persistence, and the room replays recent lines from those
files when someone opens it.
| Key | Default | Description |
| --- | --- | --- |
| `ServerLogs:Enabled` | `true` | Master switch for the live log room |
| `ServerLogs:RoomName` | `server-logs` | Name of the auto-created channel (reserved — users can't create a channel with this name) |
| `ServerLogs:MinRole` | `Mod` | Minimum server role that can see and join the room (`Member`, `Mod`, `Admin`, `Owner`) |
| `ServerLogs:MinLevel` | `Information` | Minimum log level streamed to the room (`Verbose`, `Debug`, `Information`, `Warning`, `Error`, `Fatal`) — affects only the room, not the file/console sinks |
| `ServerLogs:BacklogLines` | `100` | Recent log lines replayed from file when someone opens the room |
| `ServerLogs:LogDirectory` | `logs` | Directory holding the rolling log files (must match the Serilog file sink path below) |
| `ServerLogs:LogFilePattern` | `echohub-server-*.log` | Filename glob for the rolling log files inside `LogDirectory` |
### Logging
EchoHub uses [Serilog](https://serilog.net/) for structured logging — console output + daily rolling files with 14-day retention by default.
| Key | Default | Description |
| --- | --- | --- |
| `Serilog:MinimumLevel:Default` | `Information` | Global log level (`Debug`, `Information`, `Warning`, `Error`) |
| `Serilog:MinimumLevel:Override:Microsoft` | `Warning` | Suppress noisy framework logs |
| `Serilog:MinimumLevel:Override:Microsoft.AspNetCore` | `Warning` | Suppress request pipeline logs |
| `Serilog:MinimumLevel:Override:Microsoft.EntityFrameworkCore` | `Warning` | Suppress database query logs |
Log files are written to `logs/echohub-server-YYYY-MM-DD.log`. To change the path or retention, edit the `Serilog:WriteTo` section in `appsettings.json`.
Want more verbose output for debugging? Set the minimum level to `Debug`:
```bash
# via environment variable
export Serilog__MinimumLevel__Default=Debug
# or command line
./EchoHub.Server --Serilog:MinimumLevel:Default=Debug
```
+121
View File
@@ -0,0 +1,121 @@
# Docker
## Quick Start
```bash
cp .env.example .env # create your config
docker compose up -d # start the server
```
On first run the server automatically generates JWT and encryption keys, creates the database, and seeds a `#general` channel. Connect with the EchoHub client to `http://localhost:5000`.
### Using a Pre-built Image
Instead of building locally, you can pull from GHCR. In `docker-compose.yml`, replace the `build` block:
```yaml
services:
echohub-server:
image: ghcr.io/huebyte/echohub-server:latest
# build:
# context: ./src
# dockerfile: EchoHub.Server/Dockerfile
```
## Configuration
All settings are configured through the `.env` file. These are environment variables that override `appsettings.json` — the `__` (double underscore) maps to JSON nesting levels. For example, `Server__Name` overrides the `Server:Name` key in `appsettings.json`.
See the [Configuration](configuration.md) guide for the full reference of all available settings and how the override hierarchy works.
Common Docker-relevant variables:
| Variable | Default | Description |
| --- | --- | --- |
| `Server__Name` | My EchoHub Server | Display name for your server |
| `Server__Admins__0` | *(empty)* | Admin username (use `__1`, `__2` for more) |
| `Irc__Enabled` | `false` | Enable the IRC gateway |
| `Serilog__MinimumLevel__Default` | `Information` | Log level (`Debug`, `Warning`, etc.) |
## Persistent Data
All server state lives in a single Docker volume mounted at `/app/data`:
```text
/app/data/
├── appsettings.json # generated config with JWT/encryption keys
├── echohub.db # SQLite database
├── uploads/ # uploaded files and avatars
└── logs/ # rolling log files (14-day retention)
```
### Backup
```bash
# stop the server to ensure a consistent snapshot
docker compose stop
# copy the data volume to a local directory
docker cp echohub-server:/app/data ./backup
docker compose start
```
## IRC Gateway
To enable IRC, set these in your `.env`:
```env
Irc__Enabled=true
```
Then uncomment the port in `docker-compose.yml`:
```yaml
ports:
- "5000:5000"
- "6697:6697" # IRC (TLS encrypted, preferred)
```
For TLS, also set:
```env
Irc__TlsEnabled=true
Irc__TlsCertPath=/app/data/cert.pfx
Irc__TlsCertPassword=your_password
```
Mount your certificate into the data volume or bind-mount it directly.
IRC users must have an existing EchoHub account. See [Getting Started](getting-started.md#connect-via-irc) for client connection examples.
## Updating
```bash
# if using pre-built images
docker compose pull
docker compose up -d
# if building locally
docker compose build
docker compose up -d
```
Data persists across updates since it lives in the named volume.
## Troubleshooting
**Port already in use** -- Another process is using port 5000. Change the host port in `docker-compose.yml`:
```yaml
ports:
- "8080:5000" # access via http://localhost:8080
```
**Permission denied on volume** -- The container runs as a non-root `echohub` user (UID 999). If using bind mounts instead of named volumes, ensure the directory is writable.
**View logs** -- Check the container output:
```bash
docker compose logs -f echohub-server
```
File-based logs are also available inside the volume at `/app/data/logs/`.
+160
View File
@@ -0,0 +1,160 @@
# Encrypted Rooms (Password-Protected Channels)
An **encrypted room** is a channel whose entire content — every message and every file — is
end-to-end encrypted with a key derived from a shared passphrase. Only people who know the
passphrase can read the room. **Not even the server owner can read the content**, yet the server
can still gate who joins, and it can count and measure what's stored (message count, file sizes,
timestamps) without ever seeing the plaintext.
This is a stronger guarantee than the [transport and at-rest encryption](encryption.md) described
elsewhere, where the server decrypts every message to process it. Here the server is treated as
*untrusted* for content: it holds only ciphertext and wrapped keys.
> **The passphrase is the only key.** There is no recovery. If everyone who knows a room's
> passphrase forgets it, that room's history is permanently unreadable — by design.
## What the server can and cannot see
| The server **can** see | The server **cannot** see |
| --- | --- |
| That the channel is encrypted | Message text |
| Message count and timestamps | File contents |
| Who sent each message (sender identity) | Image previews (ASCII art) |
| Each attachment's **file name** and byte size | The passphrase, the room key, or the key-encryption key |
| The estimated total size (via `/meta`) | Anything that would let it decrypt the above |
File **names are stored in plaintext** so the file list stays usable — treat a file name itself as
non-secret. Everything *inside* the file is encrypted.
## Key hierarchy
Three keys are derived from one passphrase. The passphrase, the key-encryption key, and the room
content key **never leave the client**.
```mermaid
flowchart TD
P[Passphrase] -->|PBKDF2-SHA256, 210k iterations, per-room salt| OKM[64-byte output]
OKM --> AK["Auth key (first 32 bytes)"]
OKM --> KEK["Key-encryption key / KEK (last 32 bytes)"]
AK -->|sent as lowercase hex| SRV1["Server: BCrypt-hash as the join gate"]
RCK["Room Content Key (random 256-bit)"] -->|encrypts all content| CONTENT[Messages + files + previews]
RCK -->|AES-256-GCM wrap under KEK| WRAP["Wrapped room key"]
WRAP -->|stored| SRV2["Server: stores wrapped key + salt only"]
```
- **Auth key** — the join credential. Derived from the passphrase, sent to the server as hex, and
stored only as a **BCrypt hash**. Proving knowledge of it is what lets you join; it reveals
nothing about the content key.
- **Key-encryption key (KEK)** — never sent. Used locally to *wrap* (encrypt) and *unwrap* the room
content key.
- **Room Content Key (RCK)** — a random 256-bit key generated once, at room creation. It encrypts
every message and file. The server stores it only in wrapped form, so it can hand the wrapped key
to a joiner but can never unwrap it itself.
All content encryption is **AES-256-GCM** with a random 12-byte nonce and a 16-byte authentication
tag per item, so identical inputs never produce identical ciphertext, and any tampering is detected.
Room-encrypted text carries a self-describing prefix so clients and the server can tell it apart
from plaintext:
```text
$RC1$base64(nonce || tag || ciphertext)
```
## Creating a room
The client does all the cryptography locally, then hands the server only what it needs to gate joins
and store (but not read) the content.
```mermaid
sequenceDiagram
participant Client
participant Server
Client->>Client: generate salt + random Room Content Key (RCK)
Client->>Client: DeriveKeys(passphrase, salt) → auth key + KEK
Client->>Client: wrap RCK under KEK
Client->>Server: create channel { authKey(hex), salt, wrappedRoomKey }
Server->>Server: BCrypt-hash auth key, store salt + wrapped key
Note over Server: Server never receives passphrase, KEK, or RCK
```
## Joining a room
```mermaid
sequenceDiagram
participant Client
participant Server
Client->>Server: GET /crypto → { isEncrypted, salt }
Client->>Client: DeriveKeys(passphrase, salt) → auth key + KEK
Client->>Server: join { authKey(hex) }
Server->>Server: BCrypt-verify against stored hash
alt correct passphrase
Server->>Client: history (ciphertext) + wrapped room key
Client->>Client: unwrap RCK with KEK, then decrypt everything locally
else wrong passphrase
Server->>Client: rejected (join gate fails)
end
```
A wrong passphrase fails the BCrypt gate, so the server never even hands out the wrapped key. Even if
it did, an attacker without the KEK cannot unwrap it.
## What gets encrypted
When you send a message or attach files to an encrypted room, the client encrypts each part with the
room content key **before** uploading:
- **Message text** → `$RC1$…` ciphertext.
- **Files** (any kind) → the whole blob is AES-256-GCM encrypted client-side; the server stores an
opaque ciphertext blob.
- **Image ASCII previews** → rendered on the client, then room-encrypted. The server never sees the
rendered art.
The server records each attachment's **kind**, **file name**, and **byte size** (of the ciphertext
blob) as metadata, and broadcasts the ciphertext to other members, who decrypt locally.
## Changing the passphrase
`/passwd <old> <new>` rotates the passphrase. Because only the *wrapping* of the room content key
changes — not the RCK itself — **all existing history stays readable**:
1. The client proves knowledge of the old passphrase (old auth key).
2. It unwraps the RCK with the old KEK, then re-wraps it under the new KEK (new salt).
3. It uploads the new auth key + salt + re-wrapped key. The content is never re-encrypted.
## Inspecting a room
Use `/meta` in any channel to see what the server knows about it, including encrypted rooms:
```text
Room info for #private-room:
Room ID 3f2a…-…-…
Created 7/16/2026 2:31 PM
Messages 128
Unique users 4
Est. size 42.5 MB
Protection end-to-end encrypted
```
`Est. size` is the sum of stored attachment blob sizes plus message text length — an estimate of the
room's footprint, computed entirely from metadata the server holds without reading any content.
## Limitations & security notes
- **No recovery.** A lost passphrase means unrecoverable history. Keep it safe; there is no reset.
- **File names are plaintext.** They stay readable so the file list works — don't put secrets in a
file name.
- **IRC is disabled for encrypted rooms.** The IRC gateway forwards plaintext and cannot participate
in the room's key scheme, so encrypted channels are not bridged to IRC.
- **Metadata is visible.** Message counts, timestamps, sender identities, file names, and sizes are
intentionally readable so the server can moderate at the metadata level and report `/meta`.
- **Endpoint trust.** End-to-end encryption protects content from the server and the network, not
from a compromised client device that already holds the passphrase.
## Related
- [Message Encryption](encryption.md) — transport (`$ENC$v1$`) and optional at-rest database
encryption, where the server *does* decrypt content for processing. Encrypted rooms are a separate,
stronger layer that sits on top.
+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. For true end-to-end encryption where the server cannot read content, use [encrypted rooms](encrypted-rooms.md).
- **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.
+44 -13
View File
@@ -1,29 +1,60 @@
# Getting Started
## Prerequisites
## Install the Client
- [.NET 10 SDK](https://dotnet.microsoft.com/download)
### Windows (Chocolatey)
Or grab a self-contained binary from [Releases](https://github.com/HueByte/EchoHub/releases) -- no runtime needed.
```bash
choco install echohub
```
## Run the Server
### Linux / macOS
```bash
curl -sSfL https://raw.githubusercontent.com/HueByte/EchoHub/master/scripts/install.sh | sh
```
To install a specific version or to a custom directory:
```bash
curl -sSfL .../install.sh | sh -s -- --version 0.2.14
curl -sSfL .../install.sh | sh -s -- --install-dir /opt/echohub
```
### Manual Download
Grab a self-contained binary from [Releases](https://github.com/HueByte/EchoHub/releases) -- no runtime needed.
## Host a Server
### Docker
The quickest way to host a server:
```bash
cp .env.example .env
docker compose up -d
```
See the [Docker guide](docker.md) for configuration, pre-built images, and more.
### From Source
```bash
dotnet run --project src/EchoHub.Server
```
Requires [.NET 10 SDK](https://dotnet.microsoft.com/download).
On first run, the server automatically:
1. Creates `appsettings.json` from the example config
2. Generates a secure JWT secret
3. Creates the SQLite database with a `#general` channel
## Run the Client
```bash
dotnet run --project src/EchoHub.Client
```
## Usage
After installing the client, run `echohub` (or `dotnet run --project src/EchoHub.Client` from source).
Connect to a server, register an account, and start chatting.
## Connect via IRC
@@ -45,17 +76,17 @@ Then connect with any standard IRC client:
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.
Your nick is your EchoHub username and the server password is your account password (`PASS`/`NICK`/`USER` or SASL PLAIN). Connecting with a new username registers the account. 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.
See the [IRC Gateway guide](irc-gateway.md) for command mapping, attachment rendering, and limitations, or [Architecture](architecture.md) for how the 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.
Server configuration is in `appsettings.json` (auto-generated on first run). You can also use environment variables or command-line arguments to override settings.
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.
See the [Configuration](configuration.md) guide for the full reference and how it all works.
## Build from Source
+103
View File
@@ -0,0 +1,103 @@
# IRC Gateway
Every EchoHub server can expose a second door: a built-in **IRC gateway** that speaks the
classic IRC protocol on port 6667. Any standard IRC client — irssi, WeeChat, HexChat,
Halloy — can join the same channels as TUI users, see the same messages, and chat with the
same accounts. Under the hood both protocols call the same chat service, so a message sent
from IRC appears instantly in the TUI and vice versa (see [Architecture](architecture.md)).
## Enabling the gateway
The gateway is off by default. Enable it in `appsettings.json` (or `Irc__Enabled=true` as an
environment variable):
```json
{
"Irc": {
"Enabled": true,
"Port": 6667,
"TlsEnabled": false,
"TlsPort": 6697,
"TlsCertPath": "",
"ServerName": "echohub",
"Motd": "Welcome to EchoHub IRC Gateway!"
}
}
```
The plaintext listener always starts on `Port`. The TLS listener on `TlsPort` starts only
when `TlsEnabled` is `true` **and** `TlsCertPath` points to a PKCS#12 (`.pfx`) certificate.
See the [configuration reference](configuration.md#irc-gateway) for every option.
## Connecting & authentication
Your IRC **nick is your EchoHub username** and your server password is your **account
password**. Two flows are supported:
```bash
# classic PASS/NICK/USER — most clients call this the "server password"
irssi -c chat.example.com -p 6667 -w <password> -n <username>
```
or **SASL PLAIN** (advertised via `CAP LS`), where the SASL username/password are the account
credentials.
A few things worth knowing:
- **Connecting auto-registers.** If the username doesn't exist yet, the gateway creates the
account with that password (usernames: 350 chars of `a-z 0-9 _ -`; passwords: 6+ chars).
The very first account ever created on a server becomes the **Owner**.
- Because of that, a typo'd password for an *existing* account fails with
`Username is already taken` — the gateway tried to log in, couldn't, then tried to register
the name. If you see that error, re-check your password.
- Connecting without a password is rejected: `Password required. Use PASS command or SASL PLAIN.`
## What maps to what
| IRC | EchoHub |
| --- | --- |
| `JOIN #room` | Join a channel (history is replayed on join) |
| `JOIN #room <key>` | Join a password-protected (`+k`) channel |
| `PART` / `QUIT` | Leave channel / disconnect |
| `LIST` | Public channels only (password-protected ones show a `[+k]` hint) |
| `TOPIC` | Read or set the channel topic (permission-checked) |
| `NAMES` / `WHO` | Online users in the channel |
| `WHOIS` | Profile: display name, channels, idle time, away status |
| `AWAY [message]` | Sets your EchoHub status to Away / back to Online |
| `MODE #room +k <key>` / `-k` | Set / clear the channel password |
Private (unlisted) channels don't appear in `LIST`, but members who know the exact name can
still `JOIN` them. Channels are not auto-created from IRC — create them from the TUI first.
## How messages look
- **Attachments** arrive as labeled link lines — `[Image: photo.png] https://…`,
`♪ [Audio: song.mp3] https://…`, `[File: report.pdf] https://…` — and image attachments
additionally render their **ASCII-art preview** using truecolor ANSI escapes, so a modern
terminal IRC client shows actual picture previews.
- **Link embeds** are appended as `│`-prefixed text lines.
- Long messages are split at word boundaries into IRC-safe lines (~400 bytes each);
incoming messages may be up to 2,000 characters like any EchoHub message.
- Your own messages aren't echoed back (standard IRC convention).
- Moderation actions surface natively: kicks arrive as `KICK`, bans and channel nukes as
server `NOTICE`s.
## Limitations
The gateway bridges what IRC can express — and deliberately refuses what it can't:
- **No end-to-end encrypted rooms.** Joining an [encrypted room](encrypted-rooms.md) fails
with *"Cannot join channel — end-to-end encrypted, use the EchoHub client."* Bridging one
would require the server to hold the room key, breaking the zero-knowledge design.
- **No private messages.** `PRIVMSG` to a nick is rejected; EchoHub is channel-based.
- **Usernames, not display names.** Messages are attributed to the account username;
a user's display name is visible via `WHOIS`/`WHO` (realname field).
- **No client features.** Uploading attachments, profiles, themes, and reactions to status
changes are TUI-client features. Other users' status changes aren't pushed to IRC —
discover them with `WHOIS`/`WHO`.
## How IRC users appear to TUI users
Users connected *only* through the gateway are tagged `[irc]` in the users panel — a hint
that they can't receive encrypted content or use client-side features. Someone connected
with both an IRC client and the TUI shows untagged.
+109
View File
@@ -0,0 +1,109 @@
# Messages & Attachments
An EchoHub message is **text content plus up to 10 attachments**, Discord-style. A plain chat
line is just a message with no attachments; a photo dump is one message with several files and
an optional caption. This page explains how to attach files, what happens to them on the way to
the server, and how other clients receive them.
## Message basics
| Limit | Value |
| --- | --- |
| Max message length | 2,000 characters |
| Max newlines per message | 30 (no blank-line runs) |
| Max attachments per message | 10 |
| Link embeds per message | first 3 URLs |
Multiline messages are written with `Ctrl+N` for a newline; `Enter` sends. URLs in a message
get link embeds (title, description, theme color) fetched by the server.
## Attaching files
All of these end up in the same place — the **staging tray** — and are sent together as one
message the next time you press `Enter`, with whatever you've typed as the caption:
- **Paste a copied file** — copy one *or several* files in your file manager and press
`Ctrl+V` in the input. All of them are staged at once.
- **Paste an image from the clipboard** — copy an image in a browser (right-click → *Copy
image*), take a screenshot (`Win+Shift+S`), or copy from an image editor, then `Ctrl+V`.
The image is attached directly as a PNG named `image.png` — no saving to disk first.
On Linux this uses `wl-paste` or `xclip`; on macOS it requires
[`pngpaste`](https://github.com/jcsalterego/pngpaste) (`brew install pngpaste`).
- **Drag & drop** — drop a file onto the terminal window; the client recognizes the dropped
path and stages the file.
- **`/send <filepath>`** — stage a file by path (quote paths containing spaces).
The input frame's title shows what's currently staged. `/clear` drops all staged attachments
without sending. Sending with an empty input is fine — the message is just the attachments.
```text
┌ Message (2 attached: report.pdf, image.png) ──────────────┐
│ here's the summary and a screenshot_ │
└────────────────────────────────────────────────────────────┘
```
**URL sends are different:** `/send <https://…>` sends an image URL immediately as its own
message — nothing is staged, and it isn't available in end-to-end encrypted rooms (the server
would have to fetch the image, which would defeat the encryption).
## Attachment kinds
The kind is detected per attachment, not per message:
| Kind | Detected by | Renders as | Default size limit |
| --- | --- | --- | --- |
| **Image** | Magic bytes: JPEG, PNG, GIF, WebP | ASCII-art preview in chat | 10 MB |
| **Audio** | Extension: `.mp3` `.wav` `.ogg` `.flac` `.aac` `.m4a` `.wma` | Playable row (▶) | 10 MB |
| **File** | Everything else | Downloadable row | 100 MB |
Limits are per file and server-configurable — see the `Uploads` section in the
[configuration guide](configuration.md) (`MaxImageSizeMB`, `MaxAudioSizeMB`, `MaxFileSizeMB`,
`MaxAttachmentsPerMessage`).
## Image previews (ASCII art)
Images are rendered in chat as half-block ASCII art. You pick the rendering size:
| Flag | Size | Feel |
| --- | --- | --- |
| `-s` / `/size s` | 40 × 40 | compact |
| `-m` / `/size m` | 80 × 80 | default |
| `-l` / `/size l` | 120 × 120 | detailed |
`/size` with no argument opens a picker; the choice persists as your default. A one-off
`-s|-m|-l` flag on `/send` applies to that message.
## Receiving attachments
Right-click a message (or press `F6` to select one with the arrow keys) for actions:
- **Images** → save to disk
- **Audio** → play (in-client playback)
- **Files** → download
Downloads go to your configured download folder — set it with `/downloadpath` (no argument
opens a native folder picker, or pass a path directly).
## Attachments in encrypted rooms
In an [end-to-end encrypted room](encrypted-rooms.md) every attachment is encrypted
client-side **before** upload:
```mermaid
flowchart LR
F[File bytes] -->|AES-256-GCM with room key| B[Ciphertext blob]
F -->|if image: render ASCII locally| A[ASCII preview]
A -->|room-encrypt| AP["$RC1$… preview"]
B --> S[Server stores blob + name + size]
AP --> S
```
The server never sees the file contents or the rendered preview — it stores an opaque blob and
broadcasts it to members, who decrypt locally. File **names and sizes remain visible** to the
server so the file list stays usable; don't put secrets in a file name. Pasted clipboard
images go through exactly the same pipeline.
## Deleting messages with attachments
Deleting a message also removes its uploaded attachment files from the server. You can always
delete your own messages; moderators can delete others' — see [Moderation & Roles](moderation.md).
+71
View File
@@ -0,0 +1,71 @@
# Moderation & Roles
Every EchoHub server has a four-tier role hierarchy. Moderation is **strictly hierarchical**:
acting on another user requires outranking them — equal rank is never enough — and a few
invariants protect the server owner from lockouts.
## Roles
| Role | Rank | Users panel glyph | How it's granted |
| --- | --- | --- | --- |
| **Owner** | 3 | ★ | The first account ever registered on the server |
| **Admin** | 2 | ♦ | Assigned by the Owner |
| **Mod** | 1 | ❀ | Assigned by an Admin or the Owner |
| **Member** | 0 | — | Everyone else |
Assign roles with `/role <user> <admin|mod|member>`. Two rules apply:
- You can only assign roles **strictly below your own** — an Admin can promote to Mod but
cannot create another Admin; only the Owner can.
- **Owner is not assignable and not demotable.** There is exactly one Owner (the first
account), nobody can be promoted to it, and the Owner's role can't be changed.
## Actions
| Command | Minimum role | Effect |
| --- | --- | --- |
| `/kick <user> [reason]` | Mod | Disconnects the user. Not persistent — they can reconnect immediately. |
| `/ban <user> [reason]` | Admin | Persistent: flags the account banned and disconnects it. Banned accounts are rejected at login. |
| `/unban <user>` | Admin | Lifts a ban. |
| `/mute <user> [minutes]` | Mod | Blocks the user from sending messages or uploading files. Without a duration the mute is **indefinite**; with one it auto-expires (checked every ~15 seconds). |
| `/unmute <user>` | Mod | Lifts a mute early. |
| `/role <user> <role>` | Admin | Assign a role (see rules above). |
| `/nuke` | Mod | Deletes the **entire history of the current channel**, including all attachment files on disk. Channel-wide — no per-user check. |
Kick, ban, and mute all enforce the hierarchy: the target's role must be **strictly lower**
than yours. A Mod cannot kick another Mod; nobody can kick, ban, mute, or demote the Owner.
## Deleting messages
Deletion has its own, slightly different rule set:
- **Your own messages** — always deletable, whatever your role. Right-click a message →
*Delete message*, or press `F6`, pick the message, and hit `Delete`.
- **Someone else's messages** — requires **Mod or higher** *and* strictly outranking the
author. A Mod can delete a Member's message, but not another Mod's.
Deleting a message also purges its uploaded attachment blobs from the server's disk, and the
removal is broadcast live — the message disappears from everyone's chat immediately.
## How actions surface
Everyone in the channel sees moderation happen:
- **TUI clients** show system messages — *"alice was kicked (reason)"*, *"bob was banned"*,
*"Channel history has been cleared by a moderator."* The kicked or banned user themselves
gets a dialog with the reason, then the client disconnects.
- **IRC clients** get native protocol events: kicks arrive as a real `KICK` command, bans as
a server `NOTICE`. (See the [IRC Gateway guide](irc-gateway.md).)
Muted users aren't announced; they simply receive *"You are muted and cannot send messages."*
when they try to speak.
## Design notes
- All checks run server-side in the moderation API — the client commands are conveniences,
and the same rules bind IRC users and any direct API caller.
- Bans are account-level, not IP-level. A banned person can register a fresh account; pair
bans with registration hygiene on public servers.
- In [end-to-end encrypted rooms](encrypted-rooms.md) moderation still works at the metadata
level — messages can be deleted and users muted/kicked by identity — but no moderator can
*read* the content, including the Owner.
+26 -4
View File
@@ -1,4 +1,26 @@
- name: Getting Started
href: getting-started.md
- name: Architecture
href: architecture.md
- name: Guides
items:
- name: Getting Started
href: getting-started.md
- name: TUI Guide
href: tui-guide.md
- name: Messages & Attachments
href: messages-and-attachments.md
- name: Moderation & Roles
href: moderation.md
- name: IRC Gateway
href: irc-gateway.md
- name: Docker
href: docker.md
- name: Architecture
href: architecture.md
- name: Configuration
href: configuration.md
- name: Encryption
href: encryption.md
- name: Encrypted Rooms
href: encrypted-rooms.md
- name: Notification Sounds
href: notification-sounds.md
- name: Flows
href: ../flows/toc.yml
+116
View File
@@ -0,0 +1,116 @@
# TUI Guide
Everything you can do in the EchoHub terminal client: keyboard shortcuts, mouse actions,
slash commands, themes, and the everyday behaviors (unread markers, auto-join, scrollback)
that make it feel like a proper IRC-era client with modern comforts.
## Layout
```text
┌ Menu bar ──────────────────────────────────────────────────┐
│ ┌ Channels ─┐ ┌ Messages ────────────────────┐ ┌ Users ──┐ │
│ │ #general 3│ │ 12:01 <alice> hi │ │ ★ alice │ │
│ │ #dev │ │ ── new messages ── │ │ ❀ bob │ │
│ │ #random*~ │ │ 12:04 <bob> anyone around? │ │ carol │ │
│ └───────────┘ └──────────────────────────────┘ │ d [irc] │ │
│ ┌ Message │ Enter=send │ Tab=complete │ … ────┐ └─────────┘ │
│ │ _ │ │
│ └─────────────────────────────────────────────┘ │
│ Status: Connected │ v0.2.14 │ alice │ Act: #dev │
└─────────────────────────────────────────────────────────────┘
```
Channel list markers: `*` = password-protected, `~` = private (unlisted), plus unread counts
(orange when you were @mentioned). Users panel glyphs: `★` Owner, `♦` Admin, `❀` Mod,
`[irc]` for IRC-gateway-only users; status icons `●`/`○`/`◐`/`◌` for online/offline/away/dnd.
## Keyboard shortcuts
### In the message input
| Key | Action |
| --- | --- |
| `Enter` | Send the message (also sends staged attachments with the text as caption) |
| `Ctrl+N` | Insert a newline (multiline message) |
| `Tab` | Autocomplete a slash command (`/th``/theme`) |
| `Ctrl+V` (or `Ctrl+Y`) | Paste — copied files and images become attachments, text pastes normally ([details](messages-and-attachments.md)) |
| `Ctrl+C` / `Ctrl+X` | Copy / cut in the input |
| `Ctrl+W` | Delete the word left of the cursor |
| `Ctrl+K` | Open the search palette |
| `F6` | Move focus into the message list |
### In the message list (after `F6`)
| Key | Action |
| --- | --- |
| `↑` / `↓` | Select a message |
| `Enter` | Activate: play/download/save an attachment, open an `@mention`'s profile, join a `#channel`, or open the sender's profile |
| `Delete` / `Backspace` | Delete the selected message (with confirmation; [permission rules](moderation.md)) |
| `F6` | Return focus to the input |
### Anywhere
| Key | Action |
| --- | --- |
| `F2` | Toggle the users panel |
| `Ctrl+K` | Search palette |
| `Alt+Q` | Quit |
## The search palette (`Ctrl+K`)
A command-palette that searches **channels and app actions** — type to filter, `↓` to
navigate, `Enter` to jump. Actions include Connect, Disconnect, Logout, My Profile,
Set Status, Create/Delete Channel, Saved Servers, Toggle Users Panel, Check for Updates,
and Quit. `Ctrl+K` again closes it.
## Mouse
- **Right-click a message** for the context menu: save image / play audio / download file
(depending on the attachment), *Mention @user*, *View profile*, *Copy text*,
*Copy message ID*, *Delete message*.
- **Left-click a message** does the most useful thing for that line: attachments
play/download/save, `@mentions` and the sender open profiles, `#channel` references join
that channel.
- **Click a user** in the users panel to open their profile; **click a channel** to switch.
## Slash commands
Type `/help` in any channel for the full list. The highlights:
| Command | What it does |
| --- | --- |
| `/status <online\|away\|dnd\|invisible>` or `/status <message>` | Presence / status message |
| `/nick <name>`, `/color <#hex>`, `/avatar <url or path>` | Display name, nick color, avatar |
| `/theme <name>` | Switch theme |
| `/send`, `/clear`, `/size`, `/downloadpath` | Attachments — see [Messages & Attachments](messages-and-attachments.md) |
| `/join <channel> [password]`, `/leave`, `/topic <text>` | Channel membership and topic |
| `/passwd <old> <new>` | Rotate an encrypted room's passphrase |
| `/profile [user]`, `/users`, `/meta` | Profiles, online users, room info |
| `/kick`, `/ban`, `/mute`, `/role`, `/nuke`, … | [Moderation](moderation.md) |
| `/servers`, `/quit` | Saved servers, exit |
Emoji shortcodes (`:smile:` style) are replaced live as you type.
## Themes
14 built-in themes: **Default, Transparent, TransparentLight, Classic, Light, Hacker,
Solarized, Dracula, Monokai, Nord, Gruvbox, Ocean, HighContrast, RosePine** — switch from
the User menu or `/theme <name>`. The two *Transparent* themes use no background color at
all, so your terminal's own background (and any blur/acrylic) shows through.
You can add your own: drop a theme JSON into `~/.echohub/themes/` and it appears in the list
(names that collide with a built-in are skipped).
## Everyday behaviors
- **Unread markers** — a `── new messages ──` rule marks where you left off in each channel,
irssi-style. Read positions are **persisted per server**, so the marker survives
reconnects and restarts. The status bar's `Act:` segment lists channels with activity
(orange when you were @mentioned), and day boundaries draw a date rule.
- **Auto-join** — connecting joins `#general` plus every channel you're a member of, so
unread counts and mentions accumulate everywhere. Channels you `/leave` stay left, and
password-protected or [encrypted rooms](encrypted-rooms.md) are never auto-prompted —
join those explicitly. `#general` is the home channel and can't be left or deleted.
- **Scrollback** — history loads 100 messages at a time; scrolling to the top of a channel
fetches the next page and keeps your position (no jump).
- **Drag & drop** — dropping a file onto the window stages it as an attachment.
+20
View File
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AurionDocs</title>
<style>
body { font-family: system-ui, sans-serif; display: grid; place-items: center; min-height: 100vh; margin: 0; background: #1a1a1e; color: #ddd; }
main { text-align: center; }
a { color: #7aa2f7; }
</style>
</head>
<body>
<main>
<h1>AurionDocs</h1>
<p>Supplementary documentation for EchoHub.</p>
<p><a href="../index.html">&larr; Back to EchoHub documentation</a></p>
</main>
</body>
</html>
+12
View File
@@ -4,6 +4,18 @@ Release history for EchoHub.
## Releases
- [v0.2.15](v0.2.15.md) - Invite Codes, Data Export & Deletion, /me, /banner, Replies, Open Images In Browser & IRC Image Links
- [v0.2.14](v0.2.14.md) - Clipboard Image & Multi-File Paste, E2E Room Unlock Fixes, Encrypted Key Cache & IRC Gateway Polish
- [v0.2.13](v0.2.13.md) - Chat Visual Overhaul, Auto-Join All Channels & Persistent Read Positions
- [v0.2.12](v0.2.12.md) - End-to-End Encrypted Channels, IRC Channel Keys, Image Save & Ctrl+W Crash Fix
- [v0.2.11](v0.2.11.md) - EchoHubSpace Auth, Live Directory Updates & Server Browser Metadata
- [v0.2.10](v0.2.10.md) - Command Palette, Infinite History Scroll & Auto-Updater Fixes
- [v0.2.9](v0.2.9.md) - Install Script & Chocolatey Fixes
- [v0.2.8](v0.2.8.md) - Docker Support, IRC Account Creation & BOM Fix
- [v0.2.7](v0.2.7.md) - User List Fix & Terminal.Gui NuGet Migration
- [v0.2.6](v0.2.6.md) - Major Refactoring & Code Organization
- [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
+24
View File
@@ -1,5 +1,29 @@
- name: Overview
href: index.md
- name: v0.2.15
href: v0.2.15.md
- name: v0.2.14
href: v0.2.14.md
- name: v0.2.13
href: v0.2.13.md
- name: v0.2.12
href: v0.2.12.md
- name: v0.2.11
href: v0.2.11.md
- name: v0.2.10
href: v0.2.10.md
- name: v0.2.9
href: v0.2.9.md
- name: v0.2.8
href: v0.2.8.md
- name: v0.2.7
href: v0.2.7.md
- name: v0.2.6
href: v0.2.6.md
- name: v0.2.5
href: v0.2.5.md
- name: v0.2.4
href: v0.2.4.md
- name: v0.2.3
href: v0.2.3.md
- name: v0.2.2
+23
View File
@@ -0,0 +1,23 @@
# v0.2.10
Follow-up patch release for v0.2.9 addressing auto-updater regressions, adding a command palette, infinite-scroll message history, and input polish.
## New Features
- Command palette — press Ctrl+K from the message input (or anywhere in the main window) to open a searchable dialog for navigating channels and triggering app actions (connect, disconnect, logout, profile, status, create/delete channel, saved servers, toggle users panel, check for updates, quit). Fuzzy matches against both the label and the underlying key so typing `ch` surfaces channel actions alongside `#channel` entries
- Scroll-to-load message history — scrolling to the top of a channel now fetches the next batch of older messages in the background (previously only the most recent 100 messages were available). Duplicate messages are filtered by ID, a per-channel guard prevents concurrent fetches, and the scroll position is preserved after the prepend so your reading position doesn't jump
## Bug Fixes
- Fix update progress dialog freezing / not repainting — progress callbacks now run on the UI thread so the download and extraction percentage actually updates while an update is in progress
- Fix pre-update backup failing when a Serilog-held log file is locked — `UpdateBackupService` now enumerates files manually, skips the `logs/` directory and `.log` files, and logs-and-continues on `IOException`/`UnauthorizedAccessException` instead of aborting the whole backup
- Simplify update progress dispatch — remove redundant `Application.Invoke` wrappers around progress updates that are already called from the UI thread (introduced while fixing the freeze above)
- Fix cursor position being reset to the start of the line when auto-completing commands in the CLI app — insertion point is now moved to the end of the completed text
- Fix notification sounds crashing or being silently dropped when several arrive in quick succession — playback is now serialized through a semaphore that's held for the duration of each sound (using `PlaybackFinished` with a 10s safety timeout) and always released in `finally`, so back-to-back notifications queue up and play in order instead of racing the underlying audio player (fixes #20)
- Fix client crashing on startup with `No Serilog:Using configuration section is defined` under single-file publish — pass `ConfigurationReaderOptions` with the `Serilog.Sinks.File` assembly explicitly so Serilog can resolve sinks without scanning the filesystem for `.dll`s (which don't exist in a bundled exe)
## Refactoring
- Move search-dialog dispatch out of `MainWindow` into `AppOrchestrator``MainWindow` now just raises `OnSearchRequested`, keeping the view dumb and letting the orchestrator own navigation/action routing
- `ChatHub.GetChannelHistory` and `IChatService.GetChannelHistoryAsync` gain an additional `offset` parameter for paginated history loading (defaults to `0` — existing callers are unaffected)
- `ValidationConstants.MaxHistoryCount` raised from `100` to `200` so power users and paginated fetches can request larger batches; `DefaultHistoryCount` stays at `100`
+23
View File
@@ -0,0 +1,23 @@
# v0.2.11
EchoHubSpace directory protocol overhaul: authenticated server registration with persistent claim tokens, near-real-time user-count updates, and richer server metadata (tags, multi-host, version). Coordinated cutover with the EchoHubSpace directory deploy.
## New Features
- EchoHubSpace claim-token authentication — the directory issues a per-server claim token on first registration, persisted atomically alongside the SQLite database (chmod 0600 on Unix). Subsequent reconnects authenticate with the token instead of relying on raw hostname-squatting protection. Token survives both client and directory restarts; lost tokens require an admin-side `DELETE /api/servers/{id}` on the directory to recover
- Server tags — public servers can advertise topic tags via the new `Server:Tags` config array, surfacing as filter facets in the EchoHubSpace browser
- Multi-host advertisement — a single server can register multiple hostnames (e.g. apex domain, IPv6, alias domains) by listing them in `Server:PublicHosts`. All hosts route to the same directory row
- Server version sent to directory — the EchoHubSpace browser shows what version each public server is running, pulled from the server's assembly informational version
- Operator-facing `GET /api/server/directory` endpoint (Admin role required) — returns `ServerId`, `IsRegistered`, `LastRegisteredAt`, `LastError`, and any `ConflictingHosts` for support tickets. Never exposes the claim token itself, only a `HasClaimToken` boolean
## Refactoring
- Replace 30s polling with event-driven directory updates — `PresenceTracker` now raises `UserCountChanged` only when the distinct user count actually changes (multi-tab/multi-connection users no longer trigger). `ServerDirectoryService` consumes via a single-slot `Channel<int>` (latest-wins coalesces bursts) with a 1-second min-interval throttle. Directory reflects user-count changes within ~1s instead of up to 30s stale
- Wrap directory hub responses in a `Response<T>` envelope with `IsSuccess`/`Data`/`Errors`/`Version` shape — protocol version is pinned client-side (currently `1.0`); mismatches trigger a permanent-failure stop with operator-facing log
- Stop attempting re-registration after permanent failures (`HostAlreadyClaimed`, `InvalidToken`, `HostConflict`, `InvalidInput`) — the directory no longer terminates the connection on these errors, so the client suppresses re-register on `Reconnected` to avoid tight retry loops. Operator must restart the server after fixing config
## Configuration
- **Breaking**: `Server:PublicHost` (string) renamed to `Server:PublicHosts` (string array). Public servers must update `appsettings.json` — single-host deployments use a one-element array
- New `Server:Tags` (string array) — defaults to empty
- New optional `Server:DirectoryClaimPath` — overrides the path of the persisted claim file. Defaults to a `directory-claim.json` next to the SQLite database. Treat the file as a secret; back it up alongside the database
+56
View File
@@ -0,0 +1,56 @@
# v0.2.12
Private channels are now genuinely private: password-protected channels are end-to-end encrypted, so the server (and its operators) can gate joins and measure storage but cannot read message or file contents. The IRC gateway grows real MODE/TOPIC support and channel keys, and the client gets image "save original", a transparent-light theme, drag-and-drop file sending, Ctrl+V paste, and a fix for the intermittent Ctrl+W crash.
## New Features
- **End-to-end encrypted channels** — creating a channel with a password now provisions a zero-knowledge room:
- The passphrase never leaves the client. It derives (PBKDF2-SHA256, 210k iterations) two keys: an *auth key* sent to the server as the join credential, and a *key-encryption key* that never leaves the machine.
- A random room content key encrypts every message and file with AES-256-GCM. The server only ever stores the room key *wrapped* under the passphrase, so it can gate joins and report a channel's message count, storage size, and attachments — but cannot decrypt any of it. Even the server owner cannot read a private room's contents.
- Members' clients cache the derived room key locally (in the per-server config, like saved sessions) so the passphrase isn't retyped every launch; joining on a new device prompts for it once.
- Change the passphrase with `/passwd <old> <new>` (channel creator only). The room key is re-wrapped, not rotated, so **existing history stays readable** and members who join later with the new passphrase can still read older messages.
- Files and images are encrypted client-side before upload; for images the ASCII-art preview is rendered on the client and stored room-encrypted too. Sending images by URL is disabled in encrypted channels (the server can't fetch-and-render without the key).
- End-to-end encrypted channels cannot be joined over the IRC gateway (that would require the server to hold the room key) — IRC `JOIN` returns `475` directing users to the EchoHub client.
- Password-protected channels — set an optional password when creating a channel (masked field in the Create Channel dialog, `password` on `POST /api/channels`). Passwords are BCrypt-hashed server-side; the join gate applies on first join only (existing members and the creator are unaffected). Protected channels show a `*` marker in the channel list and `+k` in the status bar
- Save original images — image messages now show a clickable "[↓ save original]" line under the ASCII-art preview that downloads the full-resolution original to your Downloads folder (decrypting locally in encrypted channels)
- **Messages with attachments (Discord-style)** — a message is now text **plus** a list of attachments instead of being either text or a single file. One message can carry a caption and several files (images, audio, docs) together:
- Compose with a **staging tray**: `/send <file>` or dropping files onto the terminal stages them (shown on the input bar); the next Enter sends your typed caption and all staged files as one message. `/clear` drops staged files. `/send <URL>` still posts an image immediately.
- Each image attachment renders its own ASCII preview with its own "save original" action; audio/file attachments each get their own play/download line.
- In encrypted channels every attachment is encrypted individually (blob + ASCII preview), and the caption is room-encrypted — the server still stores only ciphertext and can report count/size but not contents.
- Up to 10 attachments per message.
- **Right-click message menu** — right-click any message for a context menu: save/download/play its attachment, mention the sender, view their profile, copy the text, copy the message ID (for linking or command arguments), or delete the message. (Keyboard: F6 focuses the message list for arrow-key selection + Delete.) The selected message is now highlighted while the list is focused.
- **Message deletion** — press Delete on a selected message to remove it. You can always delete your own messages; moderators and above can delete others' messages, but only from users **below their own role** (a mod can't delete an admin's or owner's message). Deleting a message also removes its attachment blobs from server storage.
- **Customizable download folder** — `/downloadpath` opens your OS-native folder picker (Windows Explorer / macOS Finder / Linux GTK or KDE) to choose where downloaded attachments and saved images go; `/downloadpath <path>` sets it directly (the fallback when no native picker is available). Downloaded files now land in that folder (with automatic `(n)` de-duplication) instead of a temp directory.
- `/join <channel> [password]` — join protected channels inline, or let the client prompt: joining a protected channel without a password opens a masked prompt that re-prompts on a wrong password
- IRC channel keys — `JOIN #room <key>` works against room passwords (RFC 1459 comma-paired key lists supported); keyless or wrong-key joins get `475 ERR_BADCHANNELKEY`
- IRC `MODE` implemented — `MODE #chan` reports `+k`/`+`, `MODE #chan +k <key>` sets and `-k` clears the room password (channel creator or admin only), ban-list probes get a clean empty reply, and `CHANMODES` is advertised in ISUPPORT
- IRC `TOPIC` set support — the channel creator can change the topic from IRC; the change broadcasts to connected TUI clients (previously topic changes were rejected with a stub error)
- Attach a file by drag & drop or by pasting — drop a file onto the terminal, or **copy a file in your file manager and press Ctrl+V**, to stage it as an attachment (the next Enter sends it with your caption). Multiple files at once are supported. Ctrl+V still pastes text when the clipboard holds text; Ctrl+Y is a paste alias. On Windows the copied-file paste reads the clipboard's file list directly (Windows Terminal never pastes copied files as text), with `xclip`/`wl-paste` used on Linux
- Pick ASCII-art size for attached images — `/size` opens a Small/Medium/Large picker (40×40 / 80×80 / 120×120) with descriptions, `/size <s|m|l>` sets it directly, and `/send <file> -l` sets it for that message. The choice is a saved preference and applies to copy-paste/drag-drop images (which have no per-file flag); the current size is shown in the staging tray
- New `TransparentLight` theme — dark characters on a transparent background, for light terminal color schemes (`/theme transparentlight`)
- **`/meta` command** — shows a summary of the current room: room id, created date, message count, unique participant count, estimated storage size, and protection level (open / password-protected / end-to-end encrypted). Works for encrypted rooms too, since these are all metadata the server tracks without reading content. (`/info` is an alias.)
- **Configurable upload limits** — a new `Uploads` section in the server settings sets the maximum size per file, image, audio clip, and avatar, plus the maximum attachments per message. Absent values fall back to the previous built-in limits, so existing servers are unaffected until they opt in.
- **Updater progress bar** — the self-updater now draws a real progress bar on the console (`Downloading [████████░░] 62%`) for each step, replacing the plain status lines, so a large download shows visible progress.
- Timestamps in messages are culture-aware and now **always include the date**: today's messages show a compact date + short time, and older messages show the general short date/time.
## Bug Fixes
- Transparent themes no longer draw an opaque box behind the message input. The input `TextView` renders with the `Editable` visual role, which Terminal.Gui derives as an opaque color when a theme leaves it unset; the themes now pin `Editable`/`ReadOnly` to their base colors so the input matches its (transparent) background.
- Attachments whose files have been pruned (retention cleanup deletes blobs older than `Storage:RetentionDays` but left the message rows) no longer render a dead download/preview. When channel history loads, the server checks which attachment blobs still exist: missing ones are dropped from the message, and an attachment-only message whose files are all gone is removed from the database.
- Fixed intermittent crash on Ctrl+W — Terminal.Gui binds Ctrl+W to clipboard-cut, and Windows clipboard contention (another app holding the clipboard) threw an unhandled `Win32Exception` that took the app down. Ctrl+W now deletes the previous word (readline behavior, no clipboard), and all clipboard shortcuts (Ctrl+X/C/V/Y) are guarded so transient clipboard failures log a warning instead of crashing
- Fixed emoji shortcode replacement permanently disabling itself if a cursor update threw mid-replacement
- Fixed new messages showing no date — today's messages rendered time-only, and the "is it today?" check compared the server's UTC date against the local date, so the classification could also be wrong near midnight. Timestamps are now converted to local time first and always include the date.
- Fixed the self-updater hanging at "extracting" — the update ran while the Terminal.Gui main loop still owned the console, so the old and new processes deadlocked over it. The update now runs after the TUI shuts down, on a clean console
- IRC `LIST` no longer leaks private channels; protected channels are marked `[+k]`
## API Changes
- `ChannelDto` gains `isProtected` and `isEncrypted`; `CreateChannelRequest` gains optional `password`, `encryptionSalt`, and `wrappedRoomKey`; SignalR `JoinChannel` takes an optional second `password` argument and `JoinChannelResult` gains `passwordRequired`, `encryptionSalt`, and `wrappedRoomKey` (older clients must update to join over SignalR)
- New endpoints: `GET /api/channels/{channel}/crypto` (public crypto metadata — salt only, never the wrapped key) and `POST /api/channels/{channel}/rekey` (creator-only passphrase change)
- New endpoint `GET /api/channels/{channel}/meta` returns a `ChannelMetaDto` (room id, name, topic, encrypted/protected flags, message count, unique user count, estimated size, created date) backing the `/meta` command
- New server `Uploads` configuration section (`MaxFileSizeMB`, `MaxImageSizeMB`, `MaxAudioSizeMB`, `MaxAvatarSizeMB`, `MaxAttachmentsPerMessage`); the message-upload endpoint's request-body ceiling is now derived from these values at runtime rather than from compile-time constants
- The upload endpoint accepts `type` and `content` form fields for encrypted channels, where the client supplies the declared message type and room-encrypted content
- `ImageToAsciiService` and `FileValidationHelper` moved from `EchoHub.Server` to `EchoHub.Core` so the client can render ASCII art and detect file types for encrypted uploads
- **Message shape change**: `MessageDto` drops `Type`/`AttachmentUrl`/`AttachmentFileName`/`AttachmentFileSize` and gains `Attachments` (a list of `AttachmentDto { Kind, Url, FileName, FileSize, AsciiPreview }`, null/empty for plain text). New `Attachment` entity + table with a cascade FK to `Message`
- New endpoint `POST /api/channels/{channel}/messages` (multipart: `content` + N `files`, plus `kind`/`preview` per file for encrypted channels) replaces the single-file `upload` endpoint; `DELETE /api/moderation/messages/{id}` now enforces the own-or-higher-role rule
- New EF migrations `AddChannelPasswordHash`, `AddChannelEncryptionEnvelope`, and `AddMessageAttachments` (applied automatically on server start); a one-time startup data migration folds legacy single-attachment messages into the new model
+26
View File
@@ -0,0 +1,26 @@
# v0.2.13
A visual overhaul of the chat client — WeeChat-style column layout, per-user nick colors, date rules, unread markers, an activity status segment, and an ASCII welcome banner — plus reliable cross-channel notifications: the client now joins all your channels at connect so unread counts and @mentions light up everywhere, and read positions persist across restarts so activity that happened while you were offline still shows. Modern polish, old IRC soul.
## New Features
- **Aligned message layout (WeeChat-style)** — every message renders as `HH:mm nickname │ text`: a dim timestamp, the nick right-aligned in a fixed 12-column gutter, and a dim `│` rail separating names from content. Word-wrapped continuation lines, multi-line messages, attachments, ASCII-art image previews, and link embeds all align under the text column and extend the rail, so conversations read as a clean two-column grid. Over-long nicks are truncated with `…` in the gutter only — profiles, mentions, and the users panel always use the full name. System and status lines (`joined`, `is now Away`, …) show `--` in the nick column, replacing the old `**` prefix.
- **Deterministic nickname colors** — users who haven't picked a `/color` now get an automatic, stable color derived from their nick (12-entry palette chosen to stay readable on dark and light backgrounds). The same user is the same color in message headers and the online-users panel, on every client, every session. An explicitly chosen nickname color still takes priority.
- **Date separator rules** — a full-width `── Wed, Jul 16 2026 ─────` rule is inserted whenever the calendar day changes (live, in loaded history, and when scrolling back through older messages). Timestamps are now a compact `HH:mm` — the date lives in the rules instead of being repeated on every line.
- **"New messages" marker** — the first message that arrives in a channel you're not viewing gets an orange `── new messages ──` rule above it (irssi-style). Switch to the channel and the marker shows exactly where you left off; it survives the history reload on channel switch and is consumed when you move away again.
- **Status bar activity segment** — channels with unread messages appear in the status bar as `│ Act: #dev,#random` (up to 4, `+n` overflow). Channels where you were @mentioned show in orange, others in cyan.
- **Connecting spinner** — transitional connection states (Connecting, Reconnecting, Authenticating…) now animate a braille spinner in the status bar instead of sitting on static text.
- **Mention-aware channel list** — a channel where you were @mentioned turns orange in the channel list (name and unread badge), escalating above the cyan plain-unread highlight. The highlight clears when you view the channel.
- **Welcome banner** — with no channel selected (fresh start, or after disconnecting) the chat pane shows a gold-gradient ASCII "ECHOHUB" logo with the version and key hints, instead of an empty box. Narrow panes get a compact variant.
- **Rounded frame borders** — the channels, chat, input, and users panels draw with rounded corners.
- **Auto-join all channels at connect** — the client now joins every channel it lists for you (public channels and prior memberships) as part of connecting, so message events flow for all of them: unread badges, @mention highlights, and the status-bar activity segment work without having to open each channel first. Protected channels you've never entered are skipped silently (joining them stays a prompted, manual action), and channels you `/leave` are remembered and excluded until you join them again.
- **Read positions survive restarts** — the client persists the last message you read per channel (locally, per server). On the next connect it compares that against fetched history, so messages that arrived while you were offline still produce unread counts, mention highlights, and a correctly placed "new messages" marker.
- **Theme-tinted frame borders** — themes can now color the window frame borders independently of text via a new optional `border` section (hex values supported). The Transparent and TransparentLight themes use it to draw dim gray borders instead of stark white ones, for a subtler, glassier look. Custom theme JSON files without a `border` section keep their base colors.
## Bug Fixes
- The @mention highlight no longer drops off the second and later lines of a long mention message — wrapped continuation lines now inherit the highlight.
- The unread-position marker is re-anchored after a channel switch reloads history, instead of being wiped by the reload before you could see it.
- Cached room keys for end-to-end encrypted channels no longer disappear from the config on reconnect. Saving the server entry after a successful connect replaced it wholesale, wiping the cached keys (and forcing a passphrase re-entry on the next launch); the entry is now updated in place.
- A failed channel rejoin after a reconnect (e.g. a channel deleted while you were away) no longer aborts rejoining the remaining channels.
- Client config file access is now serialized across threads (token refresh, room-key cache, and read-position checkpoints all write it), preventing rare config corruption.
+22
View File
@@ -0,0 +1,22 @@
# v0.2.14
A reliability and security pass over end-to-end encrypted rooms: locked channels now offer the passphrase prompt instead of dead-ending on the "rejoin to unlock" placeholder, the client can no longer be tricked into sending plaintext into an encrypted room, and cached room keys are encrypted at rest instead of sitting in the config as base64. Ctrl+V grows up too — images copied from a browser or screenshot tool paste straight into the chat as attachments, and copying several files pastes them all into one message. Plus a set of IRC gateway fixes — decrypted image previews, display-name plumbing, and user/channel indicators.
## New Features
- **Paste images straight from the clipboard** — copy an image from a browser, a screenshot tool (Win+Shift+S), or an image editor and Ctrl+V it into the input: it's attached as a PNG (`image.png`), no saving to disk first, Discord-style. Transparency is preserved when the source provides PNG data; plain clipboard bitmaps are converted automatically. On Linux this uses `wl-paste`/`xclip`; on macOS it requires `pngpaste`. In end-to-end encrypted rooms pasted images go through the same client-side encryption as any other attachment.
- **Multi-file paste** — copying several files in your file manager and pasting attaches them all to a single message (up to the 10-attachment cap), staged as one batch alongside anything you type as the caption. Previously each pasted file was routed through its own `/send`, which could misbehave on large batches.
- **Room keys encrypted at rest** — the per-channel room keys cached so you don't retype a passphrase every launch are no longer stored as plain base64 in `config.json`. On Windows they're protected with DPAPI (current-user scope); on Linux/macOS with AES-GCM under a per-user key file created with `0600` permissions next to the config. Existing plain entries migrate to the encrypted format automatically on first load. The passphrase itself is never stored in any form.
- **`[irc]` tag in the users panel** — users online only through the IRC gateway are tagged `[irc]`, useful context since IRC clients lack encryption, attachments, and profiles. Someone also running the TUI shows untagged.
- **`~` marker for private channels** — the channel list now marks private (unlisted) channels with a trailing `~`, alongside the existing `*` for password-protected ones (`#room*~` when both apply).
## Bug Fixes
- **Locked encrypted channels now prompt for the passphrase.** Auto-joining your channels at connect silently entered end-to-end encrypted rooms you're a member of without running the unlock flow — on a new device (or after a cancelled prompt) the room showed only `[encrypted — rejoin this channel with its passphrase to unlock]` placeholders, and only a manual `/join` recovered it. Selecting the channel now offers the passphrase prompt; entering it unlocks history and live messages in place. Cancelling is remembered for the session so reselecting the channel doesn't nag — `/join` or trying to send always re-offers the prompt.
- **A stale cached room key no longer beats a fresh one.** If an encrypted channel was deleted and recreated under the same name, a client that still had the old key cached kept encrypting messages nobody else could read. Typing the passphrase on join now always adopts the key from the server's current envelope, replacing the stale cache.
- **IRC clients no longer get flooded with ciphertext for image messages.** The gateway forwarded image ASCII previews without stripping transport encryption, spamming IRC clients with one enormous `$ENC$v1$…` line per image. Previews are now decrypted before formatting, and any that still can't be read (e.g. end-to-end room ciphertext the server cannot decrypt) are skipped in favor of the plain `[Image: name] url` line.
- **Display names now show on chat messages.** Messages only carried the sender's username, so a configured display name appeared in the user list but not on the messages themselves. Live messages, history, and attachment messages now all carry it; mention and profile lookups stay keyed to the username.
## Security
- **No plaintext can leak into an encrypted room.** Previously, a client without the room key silently sent unencrypted text into an end-to-end encrypted channel (and other members saw it as a normal message, none the wiser it went over the wire readable by the server). All send paths — typed messages, staged file attachments, and URL sends — are now blocked while a room is locked: the client offers the unlock prompt, keeps staged files in the tray, and refuses to transmit until the key is present, with a hard guard at the connection layer as backstop.
+78
View File
@@ -0,0 +1,78 @@
# v0.2.15
A big one. Server owners can gate registration behind invite codes, users can export or delete
everything the server holds about them, and chat gets three classics — `/me` actions that
interop natively with IRC, `/banner` ASCII text, and message replies with quote rendering and
jump-to-original. Images become properly shareable: every image gets an **[open]** action that
views it in your browser (or, in end-to-end encrypted rooms, decrypts and opens it locally).
The IRC gateway stops painting ANSI art nobody asked for and posts plain image links any IRC
client can open or auto-preview. And a long-standing dual-session annoyance is fixed: messages
you send from the TUI now reach your own connected IRC client instantly.
## New Features
- **Invite-gated registration** — set `Server:Registration` to `"invite"` (or `"closed"`) in
`appsettings.json` and new accounts require a valid invite code; the default `"open"` keeps
today's behavior. Admins mint codes with `/invite [uses] [hours]` (single-use, never-expiring
by default), list them with `/invite list`, and revoke with `/invite revoke <code>`. Codes are
unguessable (`K7QM-3XPF` style), stored only in your server's own database, and consumed
atomically so a code's last use can't be taken twice. The IRC gateway respects the gate too:
unknown nicknames connecting to an invite-only server get a clear error instead of a silent
auto-registered account. The very first account (server owner bootstrap) is always allowed.
- **Data export** — `/export` downloads everything the server stores about you (profile, your
messages, attachment metadata) as JSON into your download folder. Content from end-to-end
encrypted rooms appears as ciphertext, because the server never had the plaintext — "you own
the data", demonstrably.
- **Account deletion** — `/deleteaccount` permanently removes your account after a typed
confirmation and password re-check: profile, sessions, and every file you uploaded are
deleted. Your messages remain (deleting them would gut other people's conversations) but are
re-attributed to a reserved `deleted-user` name. The last remaining Owner can't self-delete.
- **`/me` actions** — `/me waves` renders as `* nick waves`. On the wire it's a real CTCP
ACTION, so irssi/WeeChat users see a native action line, and their `/me` renders properly in
the TUI. Works in encrypted rooms (the marker encrypts with the text).
- **`/banner <text>`** — renders short text (up to 20 characters) as a 5-row block-letter
banner. Rendered locally with a built-in font — no network, no dependencies — and sent as
plain message content, so it works over IRC and in encrypted rooms.
- **Message replies** — right-click a message (or F6-select) and pick **Reply**: the input
shows a "Replying to" strip (Esc cancels), and the sent message renders with a dim
`┌ nick: snippet` quote line. Activating the quote line jumps to the original message.
Being replied to lights up the channel like an @mention. IRC clients see the familiar
`> nick: snippet | text` convention. In encrypted rooms the quoted snippet is decrypted
client-side with the room key — the server still never reads room content.
- **`[open]` images without saving them** — every image attachment now shows `[open] [↓ save original]` beneath its preview. Open views the image in your default browser straight from the server; in end-to-end encrypted rooms (where a browser would only see ciphertext) the client downloads, decrypts with the room key, and opens the image in your OS viewer from a temp file instead. Both actions are individually clickable, Enter on the line opens, and the right-click menu carries both.
- **Attachment links work in a browser** — `GET /api/files/{id}` is now a capability URL: the unguessable GUID in the link is the access token (Discord-CDN style), so attachment links can be opened directly in a browser or shared to IRC without a login token. Images and audio are served inline so the browser displays them instead of forcing a download. Blobs from encrypted rooms remain ciphertext, so their links reveal nothing.
- **IRC gets image links instead of terminal art** — the gateway no longer floods IRC clients with truecolor-ANSI ASCII art for images. Each attachment is now a single line — `[Image: photo.png] https://your-server/api/files/…` — the convention every IRC client understands, and ones like TheLounge or IRCCloud auto-preview. Set the new `Irc:PublicBaseUrl` option (e.g. `"https://chat.example.com"`) so those links come out absolute; unset, they fall back to relative paths as before.
- **Spam protection** — per-user message flood and duplicate-message limits, join and
channel-creation throttles, and automatic escalation: enough rejected sends in a few minutes
earns a timed auto-mute through the normal mute system (moderators see it, and it expires on
its own). One guard covers every ingress — TUI and IRC clients hit the same limits. Mods and
above are exempt, everything is configurable under the new `Spam` section
(`Spam:Enabled` master switch, lenient defaults a fast typist won't trip), and the guard only
ever sees stored content — encrypted-room messages stay ciphertext.
## Improvements
- **Strict `/status`** — `/status garbage` is now an error instead of silently becoming your
status message (and resetting you to Online). Status messages moved to `/status msg <text>`,
which preserves your current status; `/status msg` with no text clears it. The server also
rejects out-of-range status values sent by misbehaving clients.
- **Registration dialog** — gained Invite Code support, and the Display Name field you could
already type into is now actually sent with registration.
## Bug Fixes
- **Messages sent from the TUI now reach your own IRC session.** With the same account online via both the TUI and an IRC client, messages sent from the TUI never appeared in the IRC client until it reconnected (which replayed history). The gateway suppressed the sender's echo by *nickname*, which swallowed the message for every IRC connection on that account — it now excludes only the exact connection a message originated from, so all your other sessions (a second IRC client included) receive it immediately.
- **Channel deleted event** — when a channel is deleted, all connected clients are now immediately notified via the new `ChannelDeleted` SignalR event. The channel is removed from the channel list. Previously only the deleting client was able to delete the channel. Other clients would see the channel linger until the next manual refresh.
- **Deleted accounts don't break history** — channel history now left-joins users, so messages
from deleted accounts render (as `deleted-user`) instead of silently disappearing.
## Notes for server operators
- New config key: `Server:Registration``"open"` (default), `"invite"`, or `"closed"`.
- New config section: `Spam` — flood/duplicate/join/create thresholds and auto-mute duration;
see `appsettings.example.json`. On by default with lenient limits; `Spam:Enabled: false`
turns it all off. Note `Spam:MaxJoinsPerWindow` counts only *first-time* channel joins —
keep it above your public channel count so a new member's first connect isn't throttled.
- New REST endpoints: `POST/GET/DELETE /api/invites` (Admin+), `GET /api/users/me/export`,
`DELETE /api/users/me`.
- One new database migration (`AddInvitesAndReplies`) applies automatically on startup.
+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
+22
View File
@@ -0,0 +1,22 @@
# v0.2.6
## Refactoring
- Extracted `ChatMessageManager` from `MainWindow` — message storage, formatting, and mutation logic now in a dedicated class, reducing MainWindow complexity
- Split `ChatRenderer.cs` (8 classes, 548 lines) into 7 individual files: `ChatSegment`, `ChatLine`, `ChatListSource`, `ChannelListSource`, `UserListSource`, `ChatColors`, `ColorHelper`, `RenderHelpers`
- Extracted `ConnectionManager` from `AppOrchestrator` — connection lifecycle, authentication, SignalR event wiring, and channel tracking now in a dedicated service
- Extracted `AvatarHelper` — deduplicated avatar upload logic previously duplicated in `/avatar` command and profile edit dialog
- Consolidated `ProfileEditDialog.ParseHexToTrueColor` into shared `ColorHelper.ParseHexToColor`
- Extracted `UserSession` — session state (`Username`, `Status`, `StatusMessage`) now in a dedicated class instead of scattered fields
- Replaced 21 inline command handler lambdas with named `HandleCmd*` methods for improved readability
- Extracted shared `CleanupConnectionAsync` to deduplicate disconnect/logout cleanup logic
- Reorganized flat `UI/` folder (19 files) into subfolders: `Chat/`, `Dialogs/`, `ListSources/`, `Helpers/` with matching namespaces
- Moved `AsyncRunner` from project root to `Services/` with updated namespace
- Renamed `ColorHelper``HexColorHelper` to avoid namespace collision with Terminal.Gui's `ColorHelper` NuGet dependency
- Moved `hue_icon.ico` to `Client/Assets/`, removed duplicate from Server (Server now references shared icon via relative path)
- Add hex color parsing helper and implement custom list sources for channels and users
- Add dialogs for connection, channel creation, profile editing, and status management
## Infrastructure
- Release workflow: use `--notes-file` instead of inline `--notes` interpolation to prevent shell expansion of special characters in commit messages
+12
View File
@@ -0,0 +1,12 @@
# v0.2.7
## Bug Fixes
- Fix user list empty on initial connect — `FetchAndUpdateOnlineUsers` was called before `InvokeUI` set the current channel, causing an early return
## Infrastructure
- Switch Terminal.Gui from local fork submodule back to NuGet package (`2.0.0-develop.5039`) — transparent color PR merged upstream
- Remove Terminal.Gui submodule, `.gitmodules`, and root `nuget.config` workaround
- Remove `rm -f` submodule nuget.config steps and `submodules: recursive` from all 3 CI workflows (ci, docs, release)
- Remove `--exclude src/Terminal.Gui/` from format check
+57
View File
@@ -0,0 +1,57 @@
# v0.2.8
## Bug Fixes
- Fix memory leak — `HttpResponseMessage` objects never disposed in `ApiClient`, leaking TCP connections and content buffers on every API call (especially on failed connection attempts)
- Fix 401 retry leak — `AuthenticatedGetAsync`/`AuthenticatedRequestAsync` leaked the original response when retrying after token refresh
- Fix connection failure cleanup — `ConnectionManager.ConnectAsync` now properly disposes `ApiClient` and `EchoHubConnection` on any failure path (previously only cleaned up on saved-token auth failures)
- Fix IRC gateway sending UTF-8 BOM on first message, breaking CAP negotiation and SASL auth for all clients
- Handle `AUTHENTICATE *` (SASL abort) instead of crashing on invalid base64
- Fix userlist not refreshing when creating a new channel — now fetches online users after channel creation
- Fix unmute timer not working — add `MuteExpirationService` background job that proactively unmutes users when their timed mute expires (previously only checked on message send)
- Fix missing space between mod/admin role icon and username in the userlist
- Fix invisible users still visible in the userlist — `GetOnlineUsersAsync` now filters invisible users; server skips `UserJoined` broadcast for invisible users
- Fix invisible→online transition — user reappears in cached userlist when switching from invisible back to a visible status
- Fix thread safety — `_channelUsers` presence cache now protected by `Lock` to prevent races between SignalR events and background fetches
- Fix `@mention` regex matching email addresses and `#channel` regex matching hex colors / issue numbers — both now use lookbehind and letter-requirement guards
- Fix `ParseThemeColor` accepting non-hex characters — now validates `[0-9a-fA-F]` digits
- Fix ghost channel when trying to join a channel that doesn't exist
## New Features
- Add Docker support for EchoHub.Server — `docker compose up -d` for easy self-hosting with persistent volume for database, uploads, and logs
- IRC account creation — connecting with a new username auto-registers the account (PASS and SASL PLAIN)
- Auto-updater rollback — pre-update backup created automatically before each update; restore via File > Rollback menu or `--rollback` CLI flag
- Update failure recovery — if an update fails mid-extraction, offers to restore from the backup immediately
- Defensive Unix permission check — verify execute permission on startup after auto-update (defense-in-depth)
- Clickable usernames — press Enter on a username in the userlist or message sender to view their profile
- Clickable @mentions — press Enter on a message containing `@username` to open that user's profile
- Clickable #channels — press Enter on a message containing `#channel` to join/switch to that channel; `#channel` references are now highlighted in chat
- Embed theme colors — embed vertical border line now uses the source site's `theme-color` meta tag when available
- Stateful userlist — user presence is cached per channel and updated incrementally via SignalR events instead of re-fetching the full list on every join/leave/status change
## Security
- Restrict file auto-open — only safe file types (video, PDF, text) are opened with the system default app; all other files are downloaded to temp without executing (prevents script execution via `.bat`, `.exe`, etc.)
## Refactoring
- Extract `IUserService`/`UserService` — consolidate user registration, authentication, and profile management into a dedicated service, eliminating duplicated logic between `AuthController` and `ChatService`
- IRC gateway now checks ban status during authentication (previously skipped)
- EchoHubSpace directory updates — server now only sends user count when it actually changes instead of every 30 seconds
- `ChatHub.JoinChannel` now returns `JoinChannelResult` instead of `List<MessageDto>` to allow for better error handling
## Distribution
- Add Chocolatey package — `choco install echohub` for Windows users, auto-published from CI on each release
- Add Linux/macOS install script — `curl -sSfL .../install.sh | sh` with automatic OS/arch detection
## Documentation
- Add Flows section — Mermaid sequence diagrams documenting all major request/event flows (auth, connection, messaging, channels, moderation, file upload, link embeds, server directory) with inline code references
## CI
- Add Docker workflow — builds and pushes multi-arch (`amd64`/`arm64`) server image to GHCR on release
- Add `linux-arm64` builds to release pipeline — server and client binaries for ARM Linux (Raspberry Pi, cloud ARM instances)
- Automate Chocolatey package publishing in release workflow (checksum calculation, pack, push)
+23
View File
@@ -0,0 +1,23 @@
# v0.2.9
## Bug Fixes
- Fix Linux/macOS client install — enable single-file publish so the install script copies one self-contained binary instead of just the native host (which failed with "does not exist: EchoHub.Client.dll")
- Fix Chocolatey install path on Windows — `chocolateyInstall.ps1` was joining the install directory and executable name into a single segment, producing an invalid target path
- Fix Chocolatey package metadata — corrected GitHub repository URLs and documentation URLs in `echohub.nuspec` that pointed at the wrong location
- Fix double-click on "Public" checkbox in the Create Channel dialog accidentally submitting the dialog — checkbox toggle commands no longer bubble up to the dialog's default button
## Documentation
- Add a dedicated configuration guide (`docs/articles/configuration.md`) covering server settings, client settings, and environment overrides
- Refresh README badges and reorganize the articles table of contents for better discoverability
- Polish Docker, getting-started, and flow docs to match the current configuration surface
## Dependencies
- Bump `Terminal.Gui` to `2.0.0-develop.5043` (from `5039`)
## CI
- Release workflow now publishes a single-file self-contained client binary for Linux and macOS so the install script works out-of-the-box
- Chocolatey publishing step now triggers only when the package source actually changes and performs a proper version check against the feed before pushing
+3 -3
View File
@@ -46,7 +46,7 @@
"content": [
{
"files": ["**/*.{md,yml}"],
"exclude": ["_site/**", "_api_meta/**"]
"exclude": ["_site/**", "_api_meta/**", "auriondocs/**"]
},
{
"src": "_api_meta/core",
@@ -75,7 +75,7 @@
],
"resource": [
{
"files": ["images/**"]
"files": ["images/**", "auriondocs/**"]
}
],
"output": "_site",
@@ -85,7 +85,7 @@
"_appTitle": "EchoHub Documentation",
"_appLogoPath": "images/hue_icon.svg",
"_appFaviconPath": "images/hue_icon.svg",
"_appFooter": "<div class='footer-custom'><div class='footer-inner'><span class='footer-brand'>EchoHub</span><span class='footer-sep'>&middot;</span><a href='https://github.com/HueByte/EchoHub'>GitHub</a><span class='footer-sep'>&middot;</span><a href='https://echohub.voidcube.cloud'>Website</a><span class='footer-sep'>&middot;</span><span class='footer-credit'>Built with <a href='https://dotnet.github.io/docfx'>DocFX</a></span></div></div>",
"_appFooter": "<div class='footer-custom'><div class='footer-inner'><span class='footer-brand'>EchoHub</span><span class='footer-sep'>&middot;</span><a href='https://github.com/HueByte/EchoHub'>GitHub</a><span class='footer-sep'>&middot;</span><a href='https://echohub.voidcube.cloud'>Website</a></div></div>",
"_enableSearch": true,
"_disableContribution": false,
"_gitContribute": {
+105
View File
@@ -0,0 +1,105 @@
# Authentication
## User Registration
A new user creates an account on a server. The client sends credentials via REST,
the server hashes the password, issues JWT tokens, and the client stores the
refresh token for "Remember Me" sessions.
```mermaid
sequenceDiagram
participant UI as ConnectDialog
participant AO as AppOrchestrator
participant CM as ConnectionManager
participant API as ApiClient
participant Auth as AuthController
participant US as UserService
participant JWT as JwtTokenService
participant DB as SQLite
UI->>AO: ConnectDialogResult(IsRegister: true)
AO->>CM: ConnectAsync(dialogResult)
CM->>API: RegisterAsync(username, password)
API->>Auth: POST /api/auth/register
Auth->>US: RegisterUserAsync(username, password, displayName)
US->>US: Validate (regex, length, uniqueness)
US->>DB: INSERT User (BCrypt hash)
US-->>Auth: UserOperationResult.Success
Auth->>JWT: GenerateAccessToken(user)
JWT-->>Auth: (token, expiresAt) [15 min]
Auth->>JWT: GenerateRefreshToken()
JWT-->>Auth: Base64 random (64 bytes)
Auth->>DB: INSERT RefreshToken (SHA256 hash)
Auth-->>API: LoginResponse
API->>API: SetTokens() — store in memory + set Bearer header
API-->>CM: LoginResponse
CM->>CM: Wire OnTokensRefreshed for config persistence
CM->>CM: Continue to connection setup (see Connection Flow)
```
---
## User Login
Returning user authenticates with username/password or a saved refresh token.
```mermaid
sequenceDiagram
participant UI as ConnectDialog
participant CM as ConnectionManager
participant API as ApiClient
participant Auth as AuthController
participant US as UserService
participant DB as SQLite
alt Saved refresh token (Remember Me)
UI->>CM: ConnectDialogResult(SavedRefreshToken: "...")
CM->>API: LoginWithRefreshTokenAsync()
API->>Auth: POST /api/auth/refresh
Auth->>DB: Lookup token by SHA256 hash
Auth->>DB: Revoke old token, issue new pair
Auth-->>API: LoginResponse (rotated tokens)
else Username + Password
UI->>CM: ConnectDialogResult(IsRegister: false)
CM->>API: LoginAsync(username, password)
API->>Auth: POST /api/auth/login
Auth->>US: AuthenticateUserAsync(username, password)
US->>DB: Fetch user, BCrypt.Verify(password, hash)
US->>DB: Update LastSeenAt
US-->>Auth: UserOperationResult.Success
Auth-->>API: LoginResponse
end
API->>API: SetTokens()
```
---
## Token Refresh
Access tokens expire after 15 minutes. The client auto-refreshes transparently
before requests and on 401 responses. Refresh tokens are rotated on each use.
```mermaid
sequenceDiagram
participant SR as SignalR / HTTP Request
participant API as ApiClient
participant Auth as AuthController
participant DB as SQLite
participant Config as config.json
SR->>API: GetValidTokenAsync() or HTTP 401
API->>API: Token expires within 60s?
alt Proactive refresh (SignalR token provider)
API->>Auth: POST /api/auth/refresh (old refresh token)
else Reactive refresh (HTTP 401 retry)
API->>Auth: POST /api/auth/refresh (old refresh token)
end
Auth->>DB: Lookup by SHA256 hash
Auth->>DB: Revoke old refresh token
Auth->>DB: INSERT new RefreshToken
Auth-->>API: LoginResponse (new token pair)
API->>API: SetTokens() — update Bearer header
API-->>API: Fire OnTokensRefreshed event
API-->>Config: Persist new refresh token (if Remember Me)
API->>SR: Retry original request with new token
```
+124
View File
@@ -0,0 +1,124 @@
# Channels
## Channel Creation
Channels are created via the REST API. Public channels are broadcast to all
connected clients.
```mermaid
sequenceDiagram
participant Client as Client (TUI/API)
participant CC as ChannelsController
participant ChS as ChannelService
participant CS as ChatService
participant DB as SQLite
participant SRB as SignalRBroadcaster
participant IRCB as IrcBroadcaster
Client->>CC: POST /api/channels {name, topic, isPublic}
CC->>ChS: CreateChannelAsync(creatorId, name, topic, isPublic)
ChS->>ChS: Normalize name (lowercase, trim)
ChS->>ChS: Validate format (2-100 chars, regex)
ChS->>DB: Check duplicate
ChS->>DB: INSERT Channel + ChannelMembership (creator auto-added)
ChS-->>CC: ChannelDto
alt Channel is public
CC->>CS: BroadcastChannelUpdatedAsync(channel)
CS->>SRB: SendChannelUpdatedAsync(channelDto)
SRB->>SRB: Notify all SignalR clients
CS->>IRCB: SendChannelUpdatedAsync(channelDto)
end
CC-->>Client: 201 Created (ChannelDto)
```
---
## Channel Deletion
Only the channel creator (or admin) can delete a channel. The default channel
is protected.
```mermaid
sequenceDiagram
participant Client as Client
participant CC as ChannelsController
participant ChS as ChannelService
participant DB as SQLite
Client->>CC: DELETE /api/channels/{channel}
CC->>ChS: DeleteChannelAsync(channelName, callerId)
ChS->>ChS: Reject if default channel
ChS->>DB: Lookup channel
ChS->>ChS: Verify caller is creator or admin
ChS->>DB: DELETE Channel (cascade: messages, memberships)
ChS-->>CC: Success
CC-->>Client: 204 No Content
```
---
## Joining a Channel
Both SignalR and IRC clients join channels through `ChatService`. The presence
tracker determines if this is a genuinely new join (vs. a second connection) and
broadcasts accordingly.
```mermaid
sequenceDiagram
participant Client as Client (SignalR or IRC)
participant Entry as ChatHub / IrcCommandHandler
participant CS as ChatService
participant ChS as ChannelService
participant PT as PresenceTracker
participant DB as SQLite
participant SRB as SignalRBroadcaster
participant IRCB as IrcBroadcaster
Client->>Entry: JoinChannel / JOIN #channel
Entry->>CS: JoinChannelAsync(connectionId, userId, username, channel)
CS->>ChS: EnsureChannelMembershipAsync(userId, channel)
ChS->>DB: INSERT ChannelMembership (if not exists)
CS->>PT: JoinChannel(username, channel)
PT-->>CS: isNewJoin?
alt First connection in this channel
CS->>DB: Fetch UserPresenceDto
CS->>CS: BroadcastToAllAsync(SendUserJoinedAsync)
par
CS->>SRB: SendUserJoinedAsync(channel, user, excludeConn)
and
CS->>IRCB: SendUserJoinedAsync(channel, user)
end
end
CS->>DB: Fetch message history
CS-->>Entry: (history, error)
Entry-->>Client: History messages
```
---
## Leaving a Channel
```mermaid
sequenceDiagram
participant Client as Client
participant Entry as ChatHub / IrcCommandHandler
participant CS as ChatService
participant PT as PresenceTracker
participant SRB as SignalRBroadcaster
participant IRCB as IrcBroadcaster
Client->>Entry: LeaveChannel / PART #channel
Entry->>CS: LeaveChannelAsync(connectionId, username, channel)
CS->>PT: LeaveChannel(username, channel)
CS->>CS: BroadcastToAllAsync(SendUserLeftAsync)
par
CS->>SRB: SendUserLeftAsync(channel, username)
and
CS->>IRCB: SendUserLeftAsync(channel, username)
end
```
+116
View File
@@ -0,0 +1,116 @@
# Connection
## SignalR Client Connection
After authentication, the TUI client establishes a SignalR WebSocket, registers
event handlers, joins the default channel, and loads history.
```mermaid
sequenceDiagram
participant CM as ConnectionManager
participant EHC as EchoHubConnection
participant Hub as ChatHub
participant CS as ChatService
participant PT as PresenceTracker
participant DB as SQLite
CM->>CM: Fetch encryption key (GET /api/server/encryption-key)
CM->>EHC: new EchoHubConnection(apiClient, encryption)
EHC->>EHC: Build HubConnection (URL + JWT token provider + auto-reconnect)
EHC->>EHC: RegisterHandlers() — wire ReceiveMessage, UserJoined, etc.
EHC->>Hub: ConnectAsync() → WebSocket handshake
Hub->>CS: UserConnectedAsync(connectionId, userId, username)
CS->>PT: UserConnected(connectionId, userId, username)
CS->>DB: Update user: Status=Online, LastSeenAt=now
CM->>CM: Fetch channel list (GET /api/channels)
CM->>EHC: JoinChannelAsync("general")
EHC->>Hub: InvokeAsync("JoinChannel", "general")
Hub->>CS: JoinChannelAsync(connectionId, userId, username, "general")
CS->>DB: EnsureChannelMembership
CS->>PT: JoinChannel(username, "general")
CS->>CS: BroadcastToAllAsync → UserJoined
CS->>DB: Fetch message history
Hub-->>EHC: List<MessageDto> (encrypted)
EHC-->>CM: Decrypted history
```
---
## IRC Client Connection
IRC clients connect via TCP, authenticate with PASS/NICK/USER or SASL PLAIN,
and auto-join channels. New usernames are auto-registered.
```mermaid
sequenceDiagram
participant IRC as IRC Client
participant GW as IrcGatewayService
participant CH as IrcCommandHandler
participant US as UserService
participant CS as ChatService
participant PT as PresenceTracker
IRC->>GW: TCP connect (:6667 or :6697 TLS)
GW->>GW: Accept + create IrcClientConnection
GW->>CH: new IrcCommandHandler(connection, services)
GW->>CH: RunAsync() — start read loop
alt SASL PLAIN
IRC->>CH: CAP REQ :sasl
CH-->>IRC: CAP ACK :sasl
IRC->>CH: AUTHENTICATE PLAIN
CH-->>IRC: AUTHENTICATE +
IRC->>CH: AUTHENTICATE <base64(\0user\0pass)>
CH->>US: AuthenticateUserAsync(user, pass)
alt Auth fails → auto-register
CH->>US: RegisterUserAsync(user, pass)
end
CH-->>IRC: 903 :SASL authentication successful
else PASS/NICK/USER
IRC->>CH: PASS <password>
IRC->>CH: NICK <nickname>
IRC->>CH: USER <username> 0 * :<realname>
CH->>US: AuthenticateUserAsync(nick, pass)
alt Auth fails → auto-register
CH->>US: RegisterUserAsync(nick, pass)
end
end
CH->>CS: UserConnectedAsync(irc-{guid}, userId, username)
CS->>PT: UserConnected(irc-{guid}, userId, username)
CH-->>IRC: 001-004 RPL_WELCOME burst + MOTD
Note over IRC,CH: Client is now ready for JOIN/PART/PRIVMSG
```
---
## User Disconnect & Presence
When a client disconnects, the presence tracker determines if the user has any
remaining connections. If not, status is set to Invisible and all channels are
notified.
```mermaid
sequenceDiagram
participant Client as Client
participant Entry as ChatHub / IrcGatewayService
participant CS as ChatService
participant PT as PresenceTracker
participant DB as SQLite
participant SRB as SignalRBroadcaster
Client->>Entry: Disconnect / TCP close
Entry->>CS: UserDisconnectedAsync(connectionId)
CS->>PT: Get username + channels (before removal)
CS->>PT: UserDisconnected(connectionId)
PT->>PT: Remove connection from tracking
alt No remaining connections for user
CS->>DB: Update user: Status=Invisible, LastSeenAt=now
loop For each channel user was in
CS->>CS: BroadcastToAllAsync(SendUserStatusChangedAsync)
CS->>SRB: Notify channel members
end
end
```
+3
View File
@@ -0,0 +1,3 @@
# Flows
This section documents the major request/event flows in EchoHub, showing how data moves between the TUI client, IRC client, server, and database. Each diagram includes code references so you can jump straight to the implementation.
+89
View File
@@ -0,0 +1,89 @@
# Media & Services
## File Upload
Files are uploaded via REST, validated by magic bytes, stored with GUID filenames,
and broadcast as a message with a download link.
```mermaid
sequenceDiagram
participant Client as Client
participant CC as ChannelsController
participant FV as FileValidationHelper
participant FS as FileStorageService
participant CS as ChatService
participant DB as SQLite
Client->>CC: POST /api/channels/{channel}/upload (multipart)
CC->>CC: Check file size limits
CC->>FV: IsValidImage(stream) — magic byte check
FV->>FV: Read header: JPEG(FFD8FF) / PNG(89504E47) / GIF / WebP(RIFF+WEBP)
FV-->>CC: true/false
CC->>FS: SaveFileAsync(stream, extension)
FS->>FS: Generate GUID filename, write to uploads/
FS-->>CC: fileId (GUID)
CC->>CC: Determine MessageType (Image/Audio/File)
CC->>CS: SendMessageAsync (with file URL + optional ASCII art)
CS->>DB: INSERT Message
CS->>CS: BroadcastToAllAsync → fan out to clients
```
---
## Link Embed Resolution
When a message contains URLs, the server fetches OpenGraph metadata for preview
embeds.
```mermaid
sequenceDiagram
participant CS as ChatService
participant LE as LinkEmbedService
participant Web as External Website
CS->>LE: TryGetEmbedsAsync(messageContent)
LE->>LE: Extract URLs via regex
LE->>LE: Filter: max URLs per message, skip duplicates
loop For each URL
LE->>LE: Validate: http(s) only, block private IPs
LE->>Web: GET URL (timeout + size limit)
Web-->>LE: HTML response
LE->>LE: Parse og:title, og:description, og:site_name
LE->>LE: Parse theme-color meta tag
LE->>LE: Fallback to <title> if no og:title
end
LE-->>CS: List<EmbedDto> (or null)
Note over CS: Attached to MessageDto before broadcast
```
---
## Server Directory Registration
Public servers register with the EchoHubSpace directory for discoverability.
```mermaid
sequenceDiagram
participant SDS as ServerDirectoryService
participant Dir as EchoHubSpace Directory
participant PT as PresenceTracker
SDS->>SDS: Check Server:PublicServer config
SDS->>Dir: SignalR connect (echohub.voidcube.cloud/hubs/servers)
SDS->>Dir: RegisterServer(name, description, host, userCount)
Dir-->>SDS: Registered
loop Every 30 seconds
SDS->>PT: Get online user count
alt Count changed
SDS->>Dir: UpdateUserCount(count)
end
end
Dir->>SDS: Ping
SDS->>Dir: Heartbeat
Note over SDS,Dir: Exponential backoff on disconnect (2s → 30s max)
```
+168
View File
@@ -0,0 +1,168 @@
# Messaging
## Sending a Message (SignalR)
A message typed in the TUI travels through encryption, the SignalR hub,
`ChatService` validation, database storage, and fan-out to both SignalR and IRC
clients.
```mermaid
sequenceDiagram
participant UI as MainWindow (TUI)
participant AO as AppOrchestrator
participant EHC as EchoHubConnection
participant Hub as ChatHub
participant CS as ChatService
participant LE as LinkEmbedService
participant DB as SQLite
participant SRB as SignalRBroadcaster
participant IRCB as IrcBroadcaster
participant Clients as Other Clients
UI->>AO: OnMessageSubmitted(channel, text)
AO->>AO: IsCommand(text)? → No
AO->>EHC: SendMessageAsync(channel, text)
EHC->>EHC: Encrypt(text) → ciphertext
EHC->>Hub: InvokeAsync("SendMessage", channel, ciphertext)
Hub->>CS: SendMessageAsync(userId, username, channel, ciphertext)
CS->>CS: Decrypt(ciphertext) → plaintext
CS->>CS: Validate (length, newlines, channel exists)
CS->>DB: Check mute status
CS->>LE: TryGetEmbedsAsync(plaintext)
LE->>LE: Extract URLs, fetch OG tags
LE-->>CS: List<EmbedDto> (or null)
CS->>DB: INSERT Message (encrypted at rest)
CS->>CS: Re-encrypt plaintext for transport
CS->>CS: Build MessageDto with embeds
par Fan-out to all broadcasters
CS->>SRB: SendMessageToChannelAsync(channel, dto)
SRB->>Clients: HubContext.Group(channel).ReceiveMessage(dto)
and
CS->>IRCB: SendMessageToChannelAsync(channel, dto)
IRCB->>IRCB: Decrypt → format as PRIVMSG lines
IRCB->>Clients: Send to each IRC conn (skip sender)
end
```
---
## Sending a Message (IRC)
Messages from IRC clients follow the same `ChatService` path but enter as
plaintext (no app-layer encryption).
```mermaid
sequenceDiagram
participant IRC as IRC Client
participant CH as IrcCommandHandler
participant CS as ChatService
participant DB as SQLite
participant SRB as SignalRBroadcaster
participant IRCB as IrcBroadcaster
IRC->>CH: PRIVMSG #channel :Hello world
CH->>CH: Parse target + content
CH->>CH: IrcToEchoHubChannel("#channel") → "channel"
CH->>CS: SendMessageAsync(userId, username, "channel", "Hello world")
CS->>CS: Decrypt("Hello world") → passthrough (no $ENC$ prefix)
CS->>CS: Validate, check mute, fetch embeds
CS->>DB: INSERT Message
CS->>CS: Encrypt plaintext for SignalR transport
par Fan-out
CS->>SRB: SendMessageToChannelAsync (encrypted for SignalR)
and
CS->>IRCB: SendMessageToChannelAsync (decrypt → PRIVMSG)
IRCB->>IRCB: Skip sender (IRC echo suppression)
end
```
---
## Receiving a Message (TUI Client)
When a message arrives via SignalR, the client decrypts it, adds it to the chat
view, and optionally plays a notification sound for @mentions.
```mermaid
sequenceDiagram
participant SRB as SignalRBroadcaster
participant EHC as EchoHubConnection
participant AO as AppOrchestrator
participant MM as ChatMessageManager
participant UI as MainWindow
SRB->>EHC: ReceiveMessage(MessageDto)
EHC->>EHC: Decrypt(message.Content)
EHC-->>AO: OnMessageReceived(decrypted dto)
AO->>AO: InvokeUI (thread-safe)
AO->>MM: AddMessage(message)
MM->>UI: Render in chat ListView
alt Message contains @username
AO->>AO: PlayAsync() notification sound
end
```
---
## Command Execution
Slash commands (`/status`, `/nick`, `/kick`, etc.) are parsed client-side and
dispatched to appropriate handlers, which call REST APIs or SignalR methods.
```mermaid
sequenceDiagram
participant UI as MainWindow
participant AO as AppOrchestrator
participant CMD as CommandHandler
participant API as ApiClient
participant EHC as EchoHubConnection
participant Server as Server (API/Hub)
UI->>AO: OnMessageSubmitted(channel, "/kick baduser")
AO->>CMD: IsCommand("/kick baduser")? → true
AO->>CMD: HandleAsync("/kick baduser")
CMD->>CMD: Parse → command="kick", args="baduser"
alt API command (kick, ban, mute, nick, etc.)
CMD-->>AO: Fire OnKickRequested("baduser")
AO->>API: KickUserAsync("baduser")
API->>Server: POST /api/moderation/kick/baduser
else Hub command (status, join, leave, etc.)
CMD-->>AO: Fire OnSetStatus / OnJoinChannel / etc.
AO->>EHC: UpdateStatusAsync() / JoinChannelAsync() / etc.
EHC->>Server: SignalR Invoke
else Local command (theme, help, quit)
CMD-->>AO: Fire OnThemeChanged / etc.
AO->>UI: Apply locally (no server call)
end
AO->>UI: AddSystemMessage(result)
```
**Available commands:**
| Command | Type | Handler |
|---------|------|---------|
| `/status <status> [message]` | Hub | `UpdateStatusAsync` |
| `/nick <name>` | API | `UpdateProfileAsync` |
| `/color <hex>` | API | `UpdateProfileAsync` |
| `/join <channel>` | Hub | `JoinChannelAsync` |
| `/leave` | Hub | `LeaveChannelAsync` |
| `/topic <text>` | API | `UpdateChannelTopicAsync` |
| `/kick <user>` | API | `POST /api/moderation/kick/{user}` |
| `/ban <user>` | API | `POST /api/moderation/ban/{user}` |
| `/unban <user>` | API | `POST /api/moderation/unban/{user}` |
| `/mute <user> [mins]` | API | `POST /api/moderation/mute/{user}` |
| `/unmute <user>` | API | `POST /api/moderation/unmute/{user}` |
| `/role <user> <role>` | API | `PUT /api/moderation/role/{user}` |
| `/nuke` | API | `DELETE /api/channels/{channel}/messages` |
| `/send <file>` | API | `POST /api/channels/{channel}/upload` |
| `/profile [user]` | Local | Show profile dialog |
| `/avatar` | API | `POST /api/users/avatar` |
| `/theme <name>` | Local | `ThemeManager.SetTheme()` |
| `/servers` | API | `GET /api/serverdir/servers` |
| `/users` | Local | Show userlist |
| `/help` | Local | Show help text |
| `/quit` | Local | Exit application |
+38
View File
@@ -0,0 +1,38 @@
# Moderation
## Kick / Ban / Mute
Moderators and admins can kick, ban, or mute users via REST API or slash commands.
These actions force-disconnect the target and broadcast the event.
```mermaid
sequenceDiagram
participant Mod as Moderator
participant MC as ModerationController
participant CS as ChatService
participant PT as PresenceTracker
participant DB as SQLite
participant SRB as SignalRBroadcaster
participant IRCB as IrcBroadcaster
Mod->>MC: POST /api/moderation/kick/{user}
alt Kick
MC->>CS: Get user's channels
MC->>CS: BroadcastToAllAsync(SendUserKickedAsync)
MC->>CS: ForceDisconnectAndCleanupAsync(user)
else Ban
MC->>DB: Set user.IsBanned = true
MC->>CS: BroadcastToAllAsync(SendUserBannedAsync)
MC->>CS: ForceDisconnectAndCleanupAsync(user)
else Mute
MC->>DB: Set user.IsMuted = true, MutedUntil = now + duration
Note over DB: MuteExpirationService auto-unmutes when timer expires
end
CS->>PT: ForceRemoveUser(username)
PT->>PT: Remove from all connections + channels
CS->>SRB: ForceDisconnectUserAsync(connectionIds, reason)
CS->>IRCB: ForceDisconnectUserAsync(connectionIds, reason)
CS->>DB: Set Status=Invisible, LastSeenAt=now
```
+12
View File
@@ -0,0 +1,12 @@
- name: Authentication
href: authentication.md
- name: Connection
href: connection.md
- name: Messaging
href: messaging.md
- name: Channels
href: channels.md
- name: Moderation
href: moderation.md
- name: Media & Services
href: media.md
+4
View File
@@ -11,6 +11,10 @@ Welcome to the EchoHub documentation. EchoHub is a decentralized, IRC-style chat
## Quick Links
- [Getting Started](articles/getting-started.md) - Set up and run EchoHub
- [TUI Guide](articles/tui-guide.md) - Keyboard shortcuts, slash commands, and everyday usage
- [Messages & Attachments](articles/messages-and-attachments.md) - Attaching, pasting, and receiving files
- [Encrypted Rooms](articles/encrypted-rooms.md) - End-to-end encrypted channels
- [IRC Gateway](articles/irc-gateway.md) - Connect with any IRC client
- [Architecture](articles/architecture.md) - System design and IRC gateway
- [API Reference](api/index.md) - Generated C# API documentation
- [Changelog](changelog/index.md) - Release history
+1 -13
View File
@@ -49,6 +49,7 @@ header.top-navbar {
.navbar .navbar-brand svg {
height: 28px;
width: auto;
margin-right: 0.5em;
}
.navbar .nav-link {
@@ -320,19 +321,6 @@ footer {
color: #E6C06E;
}
.footer-credit {
color: #484f58;
font-size: 0.85em;
}
.footer-credit a {
color: #6e7681;
}
.footer-credit a:hover {
color: #E6C06E;
}
/* --- Scrollbar --- */
::-webkit-scrollbar {
width: 8px;
+2
View File
@@ -7,3 +7,5 @@
- name: API
href: api/
homepage: api/index.md
- name: AurionDocs
href: auriondocs/index.html
+25
View File
@@ -0,0 +1,25 @@
# TODO
- [ ] fix the chat trailing; when user scrolls up, and somebody sends a message the chat instantly "teleports" to the very bottom
- [x] disable the autorun of files (maybe keep for mp4? gotta do some sec research on it)
- [x] when user creates a new channel, he gets moved to that channel; but the userlist does not refresh the state on that it refreshes when user re-enters the channel again
- [x] password protected rooms (end-to-end encrypted — server cannot read contents)
- [ ] better audio lib, current one (NetCoreAudio) does not support seek or other audio actions
- [ ] Use options pattern for both client & server
- ref: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/configuration/options?view=aspnetcore-10.0
- [x] The vertical line of embeds should be the same colour as theme-color meta tag of the source
- [x] change text wrapping to honor the offset of the user. (Preferably wrap whole words if they fit in on line. Basically how the CSS "text-wrap-mode: wrap;" works)
- [x] make usernames in messages, mentions and in the user list clickable to open the user profile
- [ ] Add tags support for public servers & add filters on echohubspace for those tags in the server "browser"
- [ ] when trying to /join a channel that doesn't exist a client side-ghost channel gets created that does not work
- [x] unmute timer does not seem to work
- [ ] add IRC network like support
- that means basically multiple servers linked, so users can chat cross-server in this network
- [x] when users clicks public -> private -> public checkbox in the channel creation, it ends up creating the channel on 3rd check switch
- [ ] add keyboard only controls | at least for most important parts and the rest might be accessible with: (down)
- [x] add search bar / search modal that will allow users to instantly navigate to room / focus on app element & etc
- [ ] Actually smart data management cache messages, lazy load messages on scroll (currently hardcoded 100msgs fetched + new ones)
- [x] Another thing would be stateful userlist basically fetch once and listen for userlist updates
- [x] Send to EchohubSpace only state changes, currently we send user count periodically, instead of updating it on update
- [x] space between mod|admin "icon" and username
- [ ] Embeds still incorrectly display colors
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
+1365
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
{
"name": "echohub",
"private": true,
"description": "Repo tooling — markdown linting. Not an npm package.",
"scripts": {
"lint:md": "markdownlint-cli2",
"lint:md:fix": "markdownlint-cli2 --fix"
},
"devDependencies": {
"markdownlint-cli2": "^0.23.0"
}
}
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd">
<metadata>
<id>echohub</id>
<version>__VERSION__</version>
<title>EchoHub</title>
<authors>HueByte, StoneRed</authors>
<owners>HueByte</owners>
<requireLicenseAcceptance>false</requireLicenseAcceptance>
<licenseUrl>https://github.com/HueByte/EchoHub/blob/master/LICENSE</licenseUrl>
<projectUrl>https://echohub.voidcube.cloud/</projectUrl>
<projectSourceUrl>https://github.com/HueByte/EchoHub</projectSourceUrl>
<docsUrl>https://huebyte.github.io/EchoHub</docsUrl>
<bugTrackerUrl>https://github.com/HueByte/EchoHub/issues</bugTrackerUrl>
<packageSourceUrl>https://github.com/HueByte/EchoHub/tree/master/packaging/choco</packageSourceUrl>
<iconUrl>https://raw.githubusercontent.com/HueByte/EchoHub/refs/heads/master/assets/hue_icon.png</iconUrl>
<description>EchoHub is a decentralized IRC-style chat application with a terminal user interface (TUI). Connect to any EchoHub server, join channels, and chat — all from your terminal. Features include SignalR real-time messaging, file sharing, custom themes, and IRC gateway compatibility.</description>
<summary>Decentralized terminal chat client with IRC gateway support</summary>
<tags>chat irc decentralized tui terminal signalr echohub</tags>
<releaseNotes>https://huebyte.github.io/EchoHub/changelog/v__VERSION__.html</releaseNotes>
<copyright>Copyright (c) 2026 HueByte</copyright>
</metadata>
<files>
<file src="tools\**" target="tools" />
</files>
</package>
@@ -0,0 +1,19 @@
$ErrorActionPreference = 'Stop'
$toolsDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
$version = $env:chocolateyPackageVersion
$packageArgs = @{
packageName = $env:chocolateyPackageName
unzipLocation = $toolsDir
url64bit = "https://github.com/HueByte/EchoHub/releases/download/v$version/EchoHub-Client-win-x64.zip"
checksum64 = '__CHECKSUM64__'
checksumType64 = 'sha256'
}
Install-ChocolateyZipPackage @packageArgs
# Create a shim so 'echohub' is available on PATH
$exeDir = Join-Path $toolsDir 'client-win-x64'
$exePath = Join-Path $exeDir 'EchoHub.Client.exe'
Install-BinFile -Name 'echohub' -Path $exePath
@@ -0,0 +1,3 @@
$ErrorActionPreference = 'Stop'
Uninstall-BinFile -Name 'echohub'
+230
View File
@@ -0,0 +1,230 @@
#!/bin/sh
# EchoHub Client Installer
# Usage: curl -sSfL https://raw.githubusercontent.com/HueByte/EchoHub/master/scripts/install.sh | sh
#
# Options (pass as arguments or environment variables):
# --version X.Y.Z Install a specific version (default: latest)
# --install-dir DIR Install to a custom directory
# --help Show this help message
set -eu
REPO="HueByte/EchoHub"
BINARY_NAME="echohub"
INSTALL_DIR=""
VERSION=""
# ── Argument parsing ──────────────────────────────────────────────────────
while [ $# -gt 0 ]; do
case "$1" in
--version)
VERSION="$2"
shift 2
;;
--install-dir)
INSTALL_DIR="$2"
shift 2
;;
--help)
sed -n '2,8p' "$0" 2>/dev/null || true
echo ""
echo " curl -sSfL https://raw.githubusercontent.com/$REPO/master/scripts/install.sh | sh"
echo " curl ... | sh -s -- --version 0.2.11"
echo " curl ... | sh -s -- --install-dir /opt/echohub"
exit 0
;;
*)
echo "Unknown option: $1" >&2
exit 1
;;
esac
done
# ── Platform detection ────────────────────────────────────────────────────
detect_os() {
case "$(uname -s)" in
Linux*) echo "linux" ;;
Darwin*) echo "osx" ;;
*)
echo "Error: Unsupported operating system: $(uname -s)" >&2
echo "EchoHub supports Linux and macOS. For Windows, use: choco install echohub" >&2
exit 1
;;
esac
}
detect_arch() {
case "$(uname -m)" in
x86_64|amd64) echo "x64" ;;
aarch64|arm64) echo "arm64" ;;
*)
echo "Error: Unsupported architecture: $(uname -m)" >&2
echo "EchoHub supports x64 and arm64." >&2
exit 1
;;
esac
}
# ── Version resolution ────────────────────────────────────────────────────
resolve_version() {
if [ -n "$VERSION" ]; then
echo "$VERSION"
return
fi
# Fetch latest release tag from GitHub API
if command -v curl >/dev/null 2>&1; then
tag=$(curl -sSf "https://api.github.com/repos/$REPO/releases/latest" \
| grep '"tag_name"' | head -1 | sed 's/.*"tag_name"[[:space:]]*:[[:space:]]*"v\?\([^"]*\)".*/\1/')
elif command -v wget >/dev/null 2>&1; then
tag=$(wget -qO- "https://api.github.com/repos/$REPO/releases/latest" \
| grep '"tag_name"' | head -1 | sed 's/.*"tag_name"[[:space:]]*:[[:space:]]*"v\?\([^"]*\)".*/\1/')
else
echo "Error: curl or wget is required to detect the latest version." >&2
echo "Install curl/wget or specify a version with --version X.Y.Z" >&2
exit 1
fi
if [ -z "$tag" ]; then
echo "Error: Could not determine latest version from GitHub." >&2
exit 1
fi
echo "$tag"
}
# ── Install directory resolution ──────────────────────────────────────────
resolve_install_dir() {
if [ -n "$INSTALL_DIR" ]; then
echo "$INSTALL_DIR"
return
fi
# Prefer /usr/local/bin if writable, otherwise ~/.local/bin
if [ -w "/usr/local/bin" ]; then
echo "/usr/local/bin"
else
local_bin="$HOME/.local/bin"
mkdir -p "$local_bin"
echo "$local_bin"
fi
}
# ── Download helper ───────────────────────────────────────────────────────
download() {
url="$1"
output="$2"
if command -v curl >/dev/null 2>&1; then
curl -sSfL "$url" -o "$output"
elif command -v wget >/dev/null 2>&1; then
wget -qO "$output" "$url"
else
echo "Error: curl or wget is required." >&2
exit 1
fi
}
# ── PATH setup ────────────────────────────────────────────────────────────
add_to_path() {
dir="$1"
export_line="export PATH=\"${dir}:\$PATH\" # Added by EchoHub"
added=0
for profile in "$HOME/.profile" "$HOME/.bashrc" "$HOME/.zshrc"; do
if [ -f "$profile" ]; then
if grep -q "$dir" "$profile" 2>/dev/null; then
continue # Already present
fi
printf '\n%s\n' "$export_line" >> "$profile"
echo " Added to PATH in $(basename "$profile")"
added=1
fi
done
# If no profile existed, create .profile
if [ "$added" -eq 0 ]; then
printf '\n%s\n' "$export_line" >> "$HOME/.profile"
echo " Added to PATH in .profile"
fi
}
# ── Main ──────────────────────────────────────────────────────────────────
main() {
os=$(detect_os)
arch=$(detect_arch)
version=$(resolve_version)
install_dir=$(resolve_install_dir)
artifact="EchoHub-Client-${os}-${arch}.zip"
url="https://github.com/$REPO/releases/download/v${version}/${artifact}"
echo "EchoHub Installer"
echo " Version: v${version}"
echo " Platform: ${os}-${arch}"
echo " Install to: ${install_dir}"
echo ""
# Create temp directory with cleanup trap
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
echo "Downloading ${artifact}..."
download "$url" "$tmpdir/echohub.zip"
echo "Extracting..."
if command -v unzip >/dev/null 2>&1; then
unzip -qo "$tmpdir/echohub.zip" -d "$tmpdir/extract"
else
echo "Error: unzip is required to extract the archive." >&2
echo "Install it with: apt install unzip / brew install unzip" >&2
exit 1
fi
# The ZIP contains a client-{os}-{arch}/ subdirectory
src_dir="$tmpdir/extract/client-${os}-${arch}"
if [ ! -d "$src_dir" ]; then
# Fallback: look for any directory containing the binary
src_dir=$(find "$tmpdir/extract" -name "EchoHub.Client" -type f -printf '%h' -quit 2>/dev/null || true)
if [ -z "$src_dir" ]; then
echo "Error: Could not find EchoHub.Client binary in the archive." >&2
exit 1
fi
fi
# Install full directory to ~/.local/share/echohub and symlink the binary.
# Even single-file publishes may have content files (appsettings, assets)
# that the app expects next to the binary via AppContext.BaseDirectory.
app_dir="$HOME/.local/share/echohub"
rm -rf "$app_dir"
mkdir -p "$app_dir"
cp -r "$src_dir"/. "$app_dir/"
chmod +x "$app_dir/EchoHub.Client"
mkdir -p "$install_dir"
ln -sf "$app_dir/EchoHub.Client" "$install_dir/$BINARY_NAME"
echo ""
# Ensure install directory is on PATH
if ! command -v "$BINARY_NAME" >/dev/null 2>&1; then
add_to_path "$install_dir"
fi
echo "Installed successfully! Run 'echohub' to start."
echo ""
if ! echo "$PATH" | tr ':' '\n' | grep -qx "$install_dir"; then
echo "NOTE: Restart your shell or run the following to use echohub now:"
echo ""
echo " export PATH=\"${install_dir}:\$PATH\""
fi
}
main
+61
View File
@@ -0,0 +1,61 @@
# EchoHub - Markdown Lint
#
# Runs markdownlint-cli2 over the repo. Rules, globs, and ignores all live in
# .markdownlint-cli2.jsonc; the linter version is pinned in package.json.
#
# Usage:
# .\scripts\lint-markdown.ps1 # lint-only, exits non-zero on violations
# .\scripts\lint-markdown.ps1 -Fix # auto-fix what markdownlint can
#
# Requires: Node + npx on PATH. Prefers the locally installed linter
# (`npm install` once); otherwise npx fetches the same pinned version.
param(
[switch]$Fix
)
$ErrorActionPreference = "Stop"
if (-not (Get-Command npx -ErrorAction SilentlyContinue)) {
Write-Host " ERROR: npx not found on PATH. Install Node.js (https://nodejs.org) and retry." -ForegroundColor Red
exit 1
}
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$RootDir = Split-Path -Parent $ScriptDir
Push-Location $RootDir
try {
# Local install is version-pinned via package.json/package-lock.json; the
# npx fallback pins the same version so CI and local can never drift.
if (Test-Path "node_modules/.bin/markdownlint-cli2") {
$npxArgs = @('--no-install', 'markdownlint-cli2')
} else {
$npxArgs = @('--yes', '[email protected]')
}
if ($Fix) {
Write-Host " Auto-fix mode (--fix): markdownlint will rewrite files in place." -ForegroundColor Yellow
$npxArgs += '--fix'
}
Write-Host " > npx $($npxArgs -join ' ')" -ForegroundColor Gray
& npx @npxArgs
$exit = $LASTEXITCODE
if ($exit -eq 0) {
Write-Host " Markdown lint clean." -ForegroundColor Green
} elseif ($Fix) {
Write-Host ""
Write-Host " Some issues could not be auto-fixed. Review the output above and fix manually." -ForegroundColor Yellow
} else {
Write-Host ""
Write-Host " Markdown lint failed. Re-run with -Fix to auto-correct the fixable rules:" -ForegroundColor Red
Write-Host " .\scripts\lint-markdown.ps1 -Fix" -ForegroundColor Yellow
}
exit $exit
}
finally {
Pop-Location
}
+7 -4
View File
@@ -2,6 +2,8 @@
#
# Lint all Markdown files in the repository.
# Config, globs, and ignores are defined in .markdownlint-cli2.jsonc.
# The linter version is pinned in package.json — run `npm install` once,
# or just use `npm run lint:md` / `npm run lint:md:fix` directly.
#
# Usage:
# ./scripts/lint-markdown.sh # check
@@ -12,11 +14,12 @@ set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$REPO_ROOT"
# Resolve markdownlint-cli2 binary
if command -v markdownlint-cli2 &>/dev/null; then
LINT_CMD="markdownlint-cli2"
# Prefer the locally installed (version-pinned) linter; fall back to a one-off
# npx install of the same version pinned in package.json.
if [ -x "node_modules/.bin/markdownlint-cli2" ]; then
LINT_CMD="npx --no-install markdownlint-cli2"
else
LINT_CMD="npx --yes markdownlint-cli2"
LINT_CMD="npx --yes markdownlint-cli2@0.23.0"
fi
echo "Linting Markdown files..."
+2
View File
@@ -0,0 +1,2 @@
**/obj/
**/bin/
+1 -1
View File
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>0.2.3</Version>
<Version>0.2.15</Version>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup>
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+200 -19
View File
@@ -6,17 +6,34 @@ public record CommandResult(bool Handled, string? Message = null, bool IsError =
public class CommandHandler
{
public event Func<UserStatus, string?, Task>? OnSetStatus;
/// <summary>
/// Status update. A null status means "keep the current status"; a null message means
/// "keep the current message" and an empty message means "clear it". The orchestrator
/// resolves both against the session state.
/// </summary>
public event Func<UserStatus?, string?, Task>? OnSetStatus;
public event Func<string, Task>? OnSendAction;
public event Func<string, Task>? OnSendBanner;
public event Func<int?, int?, Task>? OnCreateInvite;
public event Func<Task>? OnListInvites;
public event Func<string, Task>? OnRevokeInvite;
public event Func<Task>? OnExportData;
public event Func<Task>? OnDeleteAccount;
public event Func<string, Task>? OnSetNick;
public event Func<string, Task>? OnSetColor;
public event Func<string, Task>? OnSetTheme;
public event Func<string, string?, Task>? OnSendFile;
public event Func<string?, Task>? OnOpenProfile;
public event Func<Task>? OnOpenServers;
public event Func<string, Task>? OnJoinChannel;
public event Func<string, string?, Task>? OnJoinChannel;
public event Func<string, string, Task>? OnChangeRoomPassword;
public event Func<Task>? OnClearAttachments;
public event Func<string, Task>? OnSetDownloadPath;
public event Func<string, Task>? OnSetAsciiSize;
public event Func<Task>? OnLeaveChannel;
public event Func<string, Task>? OnSetTopic;
public event Func<Task>? OnListUsers;
public event Func<Task>? OnRoomInfo;
public event Func<string, Task>? OnSetAvatar;
public event Func<string, string?, Task>? OnKickUser;
public event Func<string, string?, Task>? OnBanUser;
@@ -43,17 +60,27 @@ public class CommandHandler
return command switch
{
"status" => await HandleStatus(args),
"me" => await HandleMe(args),
"banner" => await HandleBanner(args),
"invite" => await HandleInvite(args),
"export" => await HandleExport(),
"deleteaccount" => await HandleDeleteAccount(),
"nick" => await HandleNick(args),
"color" => await HandleColor(args),
"theme" => await HandleTheme(args),
"send" => await HandleSend(args),
"clear" => await HandleClear(),
"size" or "asciisize" => await HandleAsciiSize(args),
"downloadpath" or "downloads" => await HandleDownloadPath(args),
"profile" => await HandleProfile(args),
"avatar" => await HandleAvatar(args),
"servers" => await HandleServers(),
"join" => await HandleJoin(args),
"passwd" => await HandlePasswd(args),
"leave" => await HandleLeave(),
"topic" => await HandleTopic(args),
"users" => await HandleUsers(),
"meta" or "info" => await HandleMeta(),
"kick" => await HandleKick(args),
"ban" => await HandleBan(args),
"unban" => await HandleUnban(args),
@@ -68,12 +95,28 @@ public class CommandHandler
};
}
private const string StatusUsage =
"Usage: /status <online|away|dnd|invisible> or /status msg <text> (empty text clears it)";
private async Task<CommandResult> HandleStatus(string args)
{
if (string.IsNullOrWhiteSpace(args))
return new CommandResult(true, "Usage: /status <online|away|dnd|invisible> or /status <message>", IsError: true);
return new CommandResult(true, StatusUsage, IsError: true);
var parts = args.Trim().Split(' ', 2, StringSplitOptions.TrimEntries);
var statusArg = parts[0].ToLowerInvariant();
// /status msg <text> — set/clear the message, keep the current status
if (statusArg is "msg" or "message")
{
var message = parts.Length > 1 ? parts[1] : string.Empty;
if (OnSetStatus is not null)
await OnSetStatus(null, message);
return new CommandResult(true, message.Length > 0
? $"Status message set: {message}"
: "Status message cleared.");
}
var statusArg = args.ToLowerInvariant().Trim();
UserStatus? status = statusArg switch
{
"online" => UserStatus.Online,
@@ -83,17 +126,92 @@ public class CommandHandler
_ => null,
};
if (status.HasValue)
// Strict: anything else is an error — no silent "it became your status message"
if (!status.HasValue)
return new CommandResult(true, $"Unknown status '{parts[0]}'. {StatusUsage}", IsError: true);
if (parts.Length > 1)
return new CommandResult(true, StatusUsage, IsError: true);
if (OnSetStatus is not null)
await OnSetStatus(status.Value, null);
return new CommandResult(true, $"Status set to {status.Value}");
}
private async Task<CommandResult> HandleMe(string args)
{
if (string.IsNullOrWhiteSpace(args))
return new CommandResult(true, "Usage: /me <action> (e.g. /me waves)", IsError: true);
if (OnSendAction is not null)
await OnSendAction(args.Trim());
return new CommandResult(true);
}
private async Task<CommandResult> HandleBanner(string args)
{
if (string.IsNullOrWhiteSpace(args))
return new CommandResult(true, "Usage: /banner <text> (letters, digits, basic punctuation)", IsError: true);
if (OnSendBanner is not null)
await OnSendBanner(args.Trim());
return new CommandResult(true);
}
private async Task<CommandResult> HandleInvite(string args)
{
var parts = args.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
// /invite list
if (parts.Length > 0 && parts[0].Equals("list", StringComparison.OrdinalIgnoreCase))
{
if (OnSetStatus is not null)
await OnSetStatus(status.Value, null);
return new CommandResult(true, $"Status set to {status.Value}");
if (OnListInvites is not null)
await OnListInvites();
return new CommandResult(true);
}
// Treat as custom status message (keep current status)
if (OnSetStatus is not null)
await OnSetStatus(UserStatus.Online, args);
return new CommandResult(true, $"Status message set: {args}");
// /invite revoke <code>
if (parts.Length > 0 && parts[0].Equals("revoke", StringComparison.OrdinalIgnoreCase))
{
if (parts.Length < 2)
return new CommandResult(true, "Usage: /invite revoke <code>", IsError: true);
if (OnRevokeInvite is not null)
await OnRevokeInvite(parts[1]);
return new CommandResult(true);
}
// /invite [maxUses] [expiresHours] — defaults to a single-use, never-expiring code
int? maxUses = null, expiresHours = null;
if (parts.Length > 0)
{
if (!int.TryParse(parts[0], out var uses) || uses < 1)
return new CommandResult(true, "Usage: /invite [maxUses] [expiresHours] | /invite list | /invite revoke <code>", IsError: true);
maxUses = uses;
}
if (parts.Length > 1)
{
if (!int.TryParse(parts[1], out var hours) || hours < 1)
return new CommandResult(true, "Usage: /invite [maxUses] [expiresHours]", IsError: true);
expiresHours = hours;
}
if (OnCreateInvite is not null)
await OnCreateInvite(maxUses, expiresHours);
return new CommandResult(true);
}
private async Task<CommandResult> HandleExport()
{
if (OnExportData is not null)
await OnExportData();
return new CommandResult(true);
}
private async Task<CommandResult> HandleDeleteAccount()
{
if (OnDeleteAccount is not null)
await OnDeleteAccount();
return new CommandResult(true);
}
private async Task<CommandResult> HandleNick(string args)
@@ -126,7 +244,7 @@ public class CommandHandler
private async Task<CommandResult> HandleTheme(string args)
{
if (string.IsNullOrWhiteSpace(args))
return new CommandResult(true, "Usage: /theme <name> (Default, Dark, Light, Hacker, Solarized)", IsError: true);
return new CommandResult(true, "Usage: /theme <name> — pick one from the User menu's theme list (e.g. Default, Transparent, TransparentLight, Hacker)", IsError: true);
if (OnSetTheme is not null)
await OnSetTheme(args.Trim());
@@ -163,6 +281,29 @@ public class CommandHandler
return new CommandResult(true, $"Uploading: {Path.GetFileName(target)}...");
}
private async Task<CommandResult> HandleClear()
{
if (OnClearAttachments is not null)
await OnClearAttachments();
return new CommandResult(true, "Cleared staged attachments.");
}
private async Task<CommandResult> HandleAsciiSize(string args)
{
// No argument → open the size picker; an argument (s/m/l or small/medium/large) sets it.
if (OnSetAsciiSize is not null)
await OnSetAsciiSize(args.Trim());
return new CommandResult(true);
}
private async Task<CommandResult> HandleDownloadPath(string args)
{
// No argument → open the native folder picker; an argument sets the path directly.
if (OnSetDownloadPath is not null)
await OnSetDownloadPath(args.Trim());
return new CommandResult(true);
}
private async Task<CommandResult> HandleProfile(string args)
{
var username = string.IsNullOrWhiteSpace(args) ? null : args.Trim();
@@ -193,11 +334,28 @@ public class CommandHandler
private async Task<CommandResult> HandleJoin(string args)
{
if (string.IsNullOrWhiteSpace(args))
return new CommandResult(true, "Usage: /join <channel>", IsError: true);
return new CommandResult(true, "Usage: /join <channel> [password]", IsError: true);
var parts = args.Trim().Split(' ', 2, StringSplitOptions.TrimEntries);
var channel = parts[0].TrimStart('#');
var password = parts.Length > 1 && !string.IsNullOrWhiteSpace(parts[1]) ? parts[1] : null;
var channel = args.Trim().TrimStart('#');
if (OnJoinChannel is not null)
await OnJoinChannel(channel);
await OnJoinChannel(channel, password);
return new CommandResult(true);
}
private async Task<CommandResult> HandlePasswd(string args)
{
var parts = args.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length != 2)
return new CommandResult(true, "Usage: /passwd <old passphrase> <new passphrase> — changes the current encrypted channel's passphrase", IsError: true);
if (parts[1].Length < 3)
return new CommandResult(true, "New passphrase must be at least 3 characters.", IsError: true);
if (OnChangeRoomPassword is not null)
await OnChangeRoomPassword(parts[0], parts[1]);
return new CommandResult(true);
}
@@ -225,6 +383,13 @@ public class CommandHandler
return new CommandResult(true);
}
private async Task<CommandResult> HandleMeta()
{
if (OnRoomInfo is not null)
await OnRoomInfo();
return new CommandResult(true);
}
private async Task<CommandResult> HandleQuit()
{
if (OnQuit is not null)
@@ -335,18 +500,32 @@ public class CommandHandler
return new CommandResult(true, """
Available commands:
/status <online|away|dnd|invisible> - Set your status
/status <message> - Set status message
/status msg <text> - Set status message (empty = clear)
/me <action> - Action message (* nick waves)
/banner <text> - Send text as an ASCII banner
/nick <name> - Set display name
/color <#hex> - Set nickname color
/theme <name> - Switch theme
/send <filepath or URL> [-s|-m|-l] - Send a file or image (size: small/medium/large)
/send <filepath> [-s|-m|-l] - Stage a file to attach (Enter sends with your text)
/send <URL> [-s|-m|-l] - Send an image URL immediately
/clear - Drop all staged attachments
/size [s|m|l] - ASCII art size for attached images (no arg = picker)
(Tip: copy a file and press Ctrl+V, or drag a file onto the window, to attach it.)
(Tip: right-click a message for actions delete, save/download/play attachment,
mention, view profile, copy. Or press F6 to pick a message, then Delete.)
/downloadpath [path] - Set download folder (no path = native folder picker)
/avatar <URL or filepath> - Set your avatar
/profile [username] - View a profile
/servers - Open saved servers
/join <channel> - Join a channel
/join <channel> [password] - Join a channel (password if protected)
/passwd <old> <new> - Change current encrypted channel's passphrase
/leave - Leave current channel
/topic <text> - Set channel topic
/users - List online users
/meta - Show room info (size, messages, users, created, id)
/export - Download everything the server stores about you
/deleteaccount - Permanently delete your account
(Tip: right-click a message and pick Reply to quote it; Esc cancels a pending reply.)
Moderation:
/kick <user> [reason] - Kick a user (Mod+)
/ban <user> [reason] - Ban a user (Admin+)
@@ -354,6 +533,8 @@ public class CommandHandler
/mute <user> [minutes] - Mute a user (Mod+)
/unmute <user> - Unmute a user (Mod+)
/role <user> <admin|mod|member> - Assign role (Admin+)
/invite [uses] [hours] - Create a registration invite code (Admin+)
/invite list | revoke <code> - Manage invite codes (Admin+)
/nuke - Clear channel history (Mod+)
/test-sound - Play notification sound
/quit - Exit the app
+34 -1
View File
@@ -6,6 +6,18 @@ public class ClientConfig
public AccountPreset DefaultPreset { get; set; } = new();
public string ActiveTheme { get; set; } = "Default";
public NotificationConfig Notifications { get; set; } = new();
/// <summary>
/// Folder where downloaded attachments and saved images are written. When null, the
/// OS Downloads folder is used. Set via the native folder picker or <c>/downloadpath</c>.
/// </summary>
public string? DownloadPath { get; set; }
/// <summary>
/// ASCII-art rendering size for images you attach: "s" (40×40), "m" (80×80), or "l" (120×120).
/// Applies to copy-paste/drag-drop attachments, which have no per-file size flag.
/// </summary>
public string DefaultAsciiSize { get; set; } = "m";
}
public class NotificationConfig
@@ -20,8 +32,29 @@ 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; }
/// <summary>
/// Cached room content keys for end-to-end encrypted channels on this server,
/// keyed by channel name and encrypted at rest (see RoomKeyProtector; legacy
/// entries were plain base64). Like RefreshToken, these live only on the
/// user's machine — the server never sees them.
/// </summary>
public Dictionary<string, string> ChannelKeys { get; set; } = [];
/// <summary>
/// Channels the user explicitly left with /leave. Excluded from the automatic
/// join-all-channels pass at connect until the user joins them again.
/// </summary>
public List<string> LeftChannels { get; set; } = [];
/// <summary>
/// Last message the user has read per channel (message id as string), persisted so
/// unread counts, @mention highlights, and the "new messages" marker survive restarts.
/// </summary>
public Dictionary<string, string> LastReadMessages { get; set; } = [];
}
public class AccountPreset
+48 -29
View File
@@ -9,6 +9,13 @@ public static class ConfigManager
private static readonly string ConfigPath = Path.Combine(ConfigDir, "config.json");
/// <summary>Directory holding the client config and local key material.</summary>
public static string ConfigDirectory => ConfigDir;
// Load-mutate-save cycles run from both the UI thread and background tasks
// (token refresh, room keys, last-read checkpoints) — serialize file access.
private static readonly Lock FileLock = new();
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
@@ -17,54 +24,66 @@ public static class ConfigManager
public static ClientConfig Load()
{
try
lock (FileLock)
{
if (!File.Exists(ConfigPath))
return new ClientConfig();
try
{
if (!File.Exists(ConfigPath))
return new ClientConfig();
var json = File.ReadAllText(ConfigPath);
return JsonSerializer.Deserialize<ClientConfig>(json, JsonOptions) ?? new ClientConfig();
}
catch
{
return new ClientConfig();
var json = File.ReadAllText(ConfigPath);
return JsonSerializer.Deserialize<ClientConfig>(json, JsonOptions) ?? new ClientConfig();
}
catch
{
return new ClientConfig();
}
}
}
public static void Save(ClientConfig config)
{
try
lock (FileLock)
{
Directory.CreateDirectory(ConfigDir);
var json = JsonSerializer.Serialize(config, JsonOptions);
File.WriteAllText(ConfigPath, json);
}
catch
{
// Silently fail — config save is best-effort
try
{
Directory.CreateDirectory(ConfigDir);
var json = JsonSerializer.Serialize(config, JsonOptions);
File.WriteAllText(ConfigPath, json);
}
catch
{
// Silently fail — config save is best-effort
}
}
}
public static void SaveServer(SavedServer server)
{
var config = Load();
var existing = config.SavedServers.FindIndex(s =>
string.Equals(s.Url, server.Url, StringComparison.OrdinalIgnoreCase));
lock (FileLock)
{
var config = Load();
var existing = config.SavedServers.FindIndex(s =>
string.Equals(s.Url, server.Url, StringComparison.OrdinalIgnoreCase));
if (existing >= 0)
config.SavedServers[existing] = server;
else
config.SavedServers.Add(server);
if (existing >= 0)
config.SavedServers[existing] = server;
else
config.SavedServers.Add(server);
Save(config);
Save(config);
}
}
public static void RemoveServer(string url)
{
var config = Load();
config.SavedServers.RemoveAll(s =>
string.Equals(s.Url, url, StringComparison.OrdinalIgnoreCase));
lock (FileLock)
{
var config = Load();
config.SavedServers.RemoveAll(s =>
string.Equals(s.Url, url, StringComparison.OrdinalIgnoreCase));
Save(config);
Save(config);
}
}
}
+5 -1
View File
@@ -5,13 +5,15 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="AlwaysUpToDate" Version="2.0.2.20250223" />
<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" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.3" />
<PackageReference Include="Terminal.Gui" Version="2.0.0-develop.5043" />
</ItemGroup>
<ItemGroup>
@@ -31,6 +33,8 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PackageIcon></PackageIcon>
<ApplicationIcon>Assets\hue_icon.ico</ApplicationIcon>
</PropertyGroup>
</Project>
+102 -3
View File
@@ -1,12 +1,59 @@
using EchoHub.Client;
using EchoHub.Client.Config;
using EchoHub.Client.Services;
using EchoHub.Client.Themes;
using Microsoft.Extensions.Configuration;
using Serilog;
using Serilog.Settings.Configuration;
using Terminal.Gui.App;
using Terminal.Gui.Drawing;
// == CLI rollback: works without TUI, before anything else ================
if (args.Contains("--rollback"))
{
if (UpdateBackupService.BackupExists())
{
var info = UpdateBackupService.GetBackupInfo();
Console.WriteLine($"Rolling back to version {info?.Version ?? "unknown"}...");
try
{
UpdateBackupService.RestoreBackup();
// RestoreBackup calls Environment.Exit(0)
}
catch (Exception ex)
{
Console.Error.WriteLine($"Rollback failed: {ex.Message}");
Environment.Exit(1);
}
}
else
{
Console.Error.WriteLine("No backup available to restore.");
Environment.Exit(1);
}
}
// == Unix permission self-check (defense-in-depth after auto-update) ==
if (!OperatingSystem.IsWindows())
{
var exePath = Environment.ProcessPath;
if (!string.IsNullOrEmpty(exePath))
{
try
{
var mode = File.GetUnixFileMode(exePath);
if ((mode & UnixFileMode.UserExecute) == 0)
{
File.SetUnixFileMode(exePath, mode | UnixFileMode.UserExecute);
}
}
catch
{
// Best-effort; if we're running, we already have execute permission
}
}
}
// == Normal startup ==
var appSettingsPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
if (!File.Exists(appSettingsPath))
{
@@ -25,12 +72,50 @@ var configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: false)
.Build();
// Explicit sink-assembly reference is required under PublishSingleFile — the default
// AssemblyFinder scans for Serilog.Sinks.*.dll on disk, which don't exist in a bundled exe.
var serilogOptions = new ConfigurationReaderOptions(typeof(FileLoggerConfigurationExtensions).Assembly);
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(configuration)
.ReadFrom.Configuration(configuration, serilogOptions)
.CreateLogger();
Log.Information("EchoHub client starting");
// == Ensure echohub is on PATH for convenient terminal access ============
PathSetup.EnsureOnPath();
// == Post-update detection: stale backup cleanup or flag for rollback menu ================
if (UpdateBackupService.BackupExists())
{
var backupInfo = UpdateBackupService.GetBackupInfo();
if (backupInfo is not null && DateTimeOffset.UtcNow - backupInfo.CreatedAt > TimeSpan.FromDays(7))
{
Log.Information("Deleting stale update backup from {Date}", backupInfo.CreatedAt);
UpdateBackupService.DeleteBackup();
}
else
{
Log.Information("Post-update: backup of v{OldVersion} available for rollback",
backupInfo?.Version ?? "unknown");
UpdateBackupService.IsPostUpdate = true;
}
}
// == Windows: clean up .old executable left by rollback restore ===========
if (OperatingSystem.IsWindows())
{
var currentExe = Environment.ProcessPath;
if (!string.IsNullOrEmpty(currentExe))
{
var oldExe = currentExe + ".old";
if (File.Exists(oldExe))
{
try { File.Delete(oldExe); }
catch { /* locked or permission issue — will be cleaned next launch */ }
}
}
}
try
{
var config = ConfigManager.Load();
@@ -41,10 +126,24 @@ try
var theme = ThemeManager.GetTheme(config.ActiveTheme);
ThemeManager.ApplyTheme(theme);
using var orchestrator = new AppOrchestrator(app, config);
var orchestrator = new AppOrchestrator(app, config);
app.Run(orchestrator.MainWindow);
// Capture any confirmed update before tearing anything down, then restore the console.
var pendingUpdate = orchestrator.PendingUpdate;
app.Dispose();
// Apply the update on a clean console: the TUI has released it, so the updater can extract
// and restart the process without deadlocking against the alternate-screen buffer. This call
// ends by starting the new version and calling Environment.Exit, so it does not return.
if (pendingUpdate is not null)
{
Log.Information("Applying confirmed update after shutdown");
await pendingUpdate();
}
orchestrator.Dispose();
}
catch (Exception ex)
{
+186 -33
View File
@@ -18,6 +18,8 @@ public sealed class ApiClient : IDisposable
public string? RefreshToken => _refreshToken;
public string BaseUrl { get; }
public event Action? OnTokensRefreshed;
public ApiClient(string baseUrl)
{
BaseUrl = baseUrl.TrimEnd('/');
@@ -27,10 +29,10 @@ public sealed class ApiClient : IDisposable
};
}
public async Task<LoginResponse> RegisterAsync(string username, string password, string? displayName = null)
public async Task<LoginResponse> RegisterAsync(string username, string password, string? displayName = null, string? inviteCode = null)
{
var request = new RegisterRequest(username, password, displayName);
var response = await _http.PostAsJsonAsync("/api/auth/register", request);
var request = new RegisterRequest(username, password, displayName, inviteCode);
using var response = await _http.PostAsJsonAsync("/api/auth/register", request);
await EnsureSuccessAsync(response);
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
@@ -43,7 +45,7 @@ public sealed class ApiClient : IDisposable
public async Task<LoginResponse> LoginAsync(string username, string password)
{
var request = new LoginRequest(username, password);
var response = await _http.PostAsJsonAsync("/api/auth/login", request);
using var response = await _http.PostAsJsonAsync("/api/auth/login", request);
await EnsureSuccessAsync(response);
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
@@ -59,7 +61,7 @@ public sealed class ApiClient : IDisposable
throw new InvalidOperationException("No refresh token available.");
var request = new RefreshRequest(_refreshToken);
var response = await _http.PostAsJsonAsync("/api/auth/refresh", request);
using var response = await _http.PostAsJsonAsync("/api/auth/refresh", request);
await EnsureSuccessAsync(response);
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
@@ -68,6 +70,19 @@ public sealed class ApiClient : IDisposable
SetTokens(result);
}
public async Task<LoginResponse> LoginWithRefreshTokenAsync(string refreshToken)
{
var request = new RefreshRequest(refreshToken);
using 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))
@@ -75,7 +90,7 @@ public sealed class ApiClient : IDisposable
try
{
var request = new RefreshRequest(_refreshToken);
await _http.PostAsJsonAsync("/api/auth/logout", request);
using var response = await _http.PostAsJsonAsync("/api/auth/logout", request);
}
catch
{
@@ -116,7 +131,7 @@ public sealed class ApiClient : IDisposable
public async Task<List<ChannelDto>> GetChannelsAsync()
{
EnsureAuthenticated();
var response = await AuthenticatedGetAsync("/api/channels");
using var response = await AuthenticatedGetAsync("/api/channels");
await EnsureSuccessAsync(response);
var paginated = await response.Content.ReadFromJsonAsync<PaginatedResponse<ChannelDto>>();
return paginated?.Items ?? [];
@@ -128,10 +143,20 @@ public sealed class ApiClient : IDisposable
return info;
}
public async Task<string> GetEncryptionKeyAsync()
{
EnsureAuthenticated();
using 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();
var response = await AuthenticatedGetAsync($"/api/users/{Uri.EscapeDataString(username)}/profile");
using var response = await AuthenticatedGetAsync($"/api/users/{Uri.EscapeDataString(username)}/profile");
await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<UserProfileDto>();
}
@@ -139,7 +164,7 @@ public sealed class ApiClient : IDisposable
public async Task<UserProfileDto?> UpdateProfileAsync(UpdateProfileRequest request)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
using var response = await AuthenticatedRequestAsync(() =>
_http.PutAsJsonAsync("/api/users/profile", request));
await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<UserProfileDto>();
@@ -153,24 +178,42 @@ public sealed class ApiClient : IDisposable
streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(fileName));
content.Add(streamContent, "file", fileName);
var response = await AuthenticatedRequestAsync(() =>
using var response = await AuthenticatedRequestAsync(() =>
_http.PostAsync("/api/users/avatar", content));
await EnsureSuccessAsync(response);
var result = await response.Content.ReadFromJsonAsync<AvatarUploadResponse>();
return result?.AvatarAscii;
}
public async Task<MessageDto?> UploadFileAsync(string channelName, Stream fileStream, string fileName, string? size = null)
/// <summary>
/// Sends one message with optional text and one or more file attachments.
/// For end-to-end encrypted channels each attachment carries a declared kind and a
/// room-encrypted preview (empty when none); the caption is likewise room-encrypted.
/// </summary>
public async Task<MessageDto?> SendMessageWithAttachmentsAsync(
string channelName, string content, IReadOnlyList<OutgoingAttachment> attachments, string? size = null)
{
EnsureAuthenticated();
using var content = new MultipartFormDataContent();
using var streamContent = new StreamContent(fileStream);
streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(fileName));
content.Add(streamContent, "file", fileName);
using var form = new MultipartFormDataContent { { new StringContent(content), "content" } };
foreach (var att in attachments)
{
var streamContent = new StreamContent(att.Stream);
streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(att.FileName));
form.Add(streamContent, "file", att.FileName);
// Encrypted channels: one kind + preview per file, in the same order, to keep
// the server's index alignment (empty preview string for non-images).
if (att.DeclaredKind is not null)
{
form.Add(new StringContent(att.DeclaredKind), "kind");
form.Add(new StringContent(att.EncryptedPreview ?? string.Empty), "preview");
}
}
var sizeQuery = size is not null ? $"?size={size}" : "";
var response = await AuthenticatedRequestAsync(() =>
_http.PostAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/upload{sizeQuery}", content));
using var response = await AuthenticatedRequestAsync(() =>
_http.PostAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/messages{sizeQuery}", form));
await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<MessageDto>();
}
@@ -180,27 +223,82 @@ public sealed class ApiClient : IDisposable
EnsureAuthenticated();
var request = new SendUrlRequest(url);
var sizeQuery = size is not null ? $"?size={size}" : "";
var response = await AuthenticatedRequestAsync(() =>
using var response = await AuthenticatedRequestAsync(() =>
_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, bool isPublic = true)
public async Task<string> DownloadFileToTempAsync(string relativeUrl, string fileName)
{
EnsureAuthenticated();
var request = new CreateChannelRequest(name, topic, isPublic);
var response = await AuthenticatedRequestAsync(() =>
using 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,
string? password = null, string? encryptionSalt = null, string? wrappedRoomKey = null)
{
EnsureAuthenticated();
var request = new CreateChannelRequest(name, topic, isPublic, password, encryptionSalt, wrappedRoomKey);
using var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync("/api/channels", request));
await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<ChannelDto>();
}
/// <summary>
/// Fetches a channel's public crypto metadata (whether it's E2E-encrypted and its
/// key-derivation salt). Returns null when the channel doesn't exist.
/// </summary>
public async Task<ChannelCryptoDto?> GetChannelCryptoAsync(string channelName)
{
EnsureAuthenticated();
using var response = await AuthenticatedGetAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/crypto");
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return null;
await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<ChannelCryptoDto>();
}
/// <summary>
/// Fetches a channel's human-facing metadata (message count, unique posters, estimated
/// size, created date, room id) for the <c>/meta</c> command. Returns null if it doesn't exist.
/// </summary>
public async Task<ChannelMetaDto?> GetChannelMetaAsync(string channelName)
{
EnsureAuthenticated();
using var response = await AuthenticatedGetAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/meta");
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return null;
await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<ChannelMetaDto>();
}
public async Task<ChannelDto?> RekeyChannelAsync(string channelName, RekeyChannelRequest request)
{
EnsureAuthenticated();
using var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/rekey", request));
await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<ChannelDto>();
}
public async Task<ChannelDto?> UpdateChannelTopicAsync(string channelName, string? topic)
{
EnsureAuthenticated();
var request = new UpdateTopicRequest(topic);
var response = await AuthenticatedRequestAsync(() =>
using var response = await AuthenticatedRequestAsync(() =>
_http.PutAsJsonAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/topic", request));
await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<ChannelDto>();
@@ -209,17 +307,65 @@ public sealed class ApiClient : IDisposable
public async Task DeleteChannelAsync(string channelName)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
using var response = await AuthenticatedRequestAsync(() =>
_http.DeleteAsync($"/api/channels/{Uri.EscapeDataString(channelName)}"));
await EnsureSuccessAsync(response);
}
// ── Invites / Account ─────────────────────────────────────────────────
public async Task<InviteDto?> CreateInviteAsync(int? maxUses = null, int? expiresInHours = null)
{
EnsureAuthenticated();
using var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync("/api/invites", new CreateInviteRequest(maxUses, expiresInHours)));
await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<InviteDto>();
}
public async Task<List<InviteDto>> GetInvitesAsync()
{
EnsureAuthenticated();
using var response = await AuthenticatedGetAsync("/api/invites");
await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<List<InviteDto>>() ?? [];
}
public async Task RevokeInviteAsync(string code)
{
EnsureAuthenticated();
using var response = await AuthenticatedRequestAsync(() =>
_http.DeleteAsync($"/api/invites/{Uri.EscapeDataString(code)}"));
await EnsureSuccessAsync(response);
}
/// <summary>Downloads the caller's full data export as raw JSON text.</summary>
public async Task<string> ExportMyDataAsync()
{
EnsureAuthenticated();
using var response = await AuthenticatedGetAsync("/api/users/me/export");
await EnsureSuccessAsync(response);
return await response.Content.ReadAsStringAsync();
}
/// <summary>Deletes the caller's account. The password re-confirms intent.</summary>
public async Task DeleteMyAccountAsync(string password)
{
EnsureAuthenticated();
using var response = await AuthenticatedRequestAsync(() =>
_http.SendAsync(new HttpRequestMessage(HttpMethod.Delete, "/api/users/me")
{
Content = JsonContent.Create(new DeleteAccountRequest(password)),
}));
await EnsureSuccessAsync(response);
}
// ── Moderation ────────────────────────────────────────────────────────
public async Task AssignRoleAsync(string username, ServerRole role)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
using var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync("/api/moderation/role", new AssignRoleRequest(username, role)));
await EnsureSuccessAsync(response);
}
@@ -227,7 +373,7 @@ public sealed class ApiClient : IDisposable
public async Task KickUserAsync(string username, string? reason = null)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
using var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync($"/api/moderation/kick/{Uri.EscapeDataString(username)}", new KickRequest(reason)));
await EnsureSuccessAsync(response);
}
@@ -235,7 +381,7 @@ public sealed class ApiClient : IDisposable
public async Task BanUserAsync(string username, string? reason = null)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
using var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync($"/api/moderation/ban/{Uri.EscapeDataString(username)}", new BanRequest(reason)));
await EnsureSuccessAsync(response);
}
@@ -243,7 +389,7 @@ public sealed class ApiClient : IDisposable
public async Task UnbanUserAsync(string username)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
using var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync($"/api/moderation/unban/{Uri.EscapeDataString(username)}", new { }));
await EnsureSuccessAsync(response);
}
@@ -251,7 +397,7 @@ public sealed class ApiClient : IDisposable
public async Task MuteUserAsync(string username, int? durationMinutes = null, string? reason = null)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
using var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync($"/api/moderation/mute/{Uri.EscapeDataString(username)}", new MuteRequest(reason, durationMinutes)));
await EnsureSuccessAsync(response);
}
@@ -259,7 +405,7 @@ public sealed class ApiClient : IDisposable
public async Task UnmuteUserAsync(string username)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
using var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync($"/api/moderation/unmute/{Uri.EscapeDataString(username)}", new { }));
await EnsureSuccessAsync(response);
}
@@ -267,7 +413,7 @@ public sealed class ApiClient : IDisposable
public async Task DeleteMessageAsync(Guid messageId)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
using var response = await AuthenticatedRequestAsync(() =>
_http.DeleteAsync($"/api/moderation/messages/{messageId}"));
await EnsureSuccessAsync(response);
}
@@ -275,7 +421,7 @@ public sealed class ApiClient : IDisposable
public async Task NukeChannelAsync(string channelName)
{
EnsureAuthenticated();
var response = await AuthenticatedRequestAsync(() =>
using var response = await AuthenticatedRequestAsync(() =>
_http.DeleteAsync($"/api/moderation/channels/{Uri.EscapeDataString(channelName)}/nuke"));
await EnsureSuccessAsync(response);
}
@@ -286,10 +432,12 @@ public sealed class ApiClient : IDisposable
_refreshToken = result.RefreshToken;
_expiresAt = result.ExpiresAt;
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
OnTokensRefreshed?.Invoke();
}
/// <summary>
/// Performs a GET request with automatic token refresh on 401.
/// Caller is responsible for disposing the returned response.
/// </summary>
private async Task<HttpResponseMessage> AuthenticatedGetAsync(string url)
{
@@ -300,7 +448,9 @@ public sealed class ApiClient : IDisposable
try
{
await RefreshTokenAsync();
response = await _http.GetAsync(url);
var retryResponse = await _http.GetAsync(url);
response.Dispose();
response = retryResponse;
}
catch
{
@@ -313,6 +463,7 @@ public sealed class ApiClient : IDisposable
/// <summary>
/// Performs a request with automatic token refresh on 401.
/// Caller is responsible for disposing the returned response.
/// </summary>
private async Task<HttpResponseMessage> AuthenticatedRequestAsync(Func<Task<HttpResponseMessage>> requestFactory)
{
@@ -323,7 +474,9 @@ public sealed class ApiClient : IDisposable
try
{
await RefreshTokenAsync();
response = await requestFactory();
var retryResponse = await requestFactory();
response.Dispose();
response = retryResponse;
}
catch
{
@@ -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,111 @@
using NetCoreAudio;
using Serilog;
namespace EchoHub.Client.Services;
public class AudioPlaybackService
{
private readonly Player _player = new();
private readonly SemaphoreSlim _lock = new(1, 1);
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)
{
await _lock.WaitAsync();
try
{
if (_player.Playing)
await _player.Stop();
await _player.Play(filePath);
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to play audio file: {Path}", filePath);
}
finally
{
_lock.Release();
}
}
public async Task PauseAsync()
{
await _lock.WaitAsync();
try
{
if (_player.Playing && !_player.Paused)
await _player.Pause();
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to pause audio playback");
}
finally
{
_lock.Release();
}
}
public async Task ResumeAsync()
{
await _lock.WaitAsync();
try
{
if (_player.Paused)
await _player.Resume();
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to resume audio playback");
}
finally
{
_lock.Release();
}
}
public async Task StopAsync()
{
await _lock.WaitAsync();
try
{
if (_player.Playing || _player.Paused)
await _player.Stop();
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to stop audio playback");
}
finally
{
_lock.Release();
}
}
public async Task SetVolumeAsync(byte volume)
{
await _lock.WaitAsync();
try
{
await _player.SetVolume(Math.Min(volume, (byte)100));
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to set audio volume");
}
finally
{
_lock.Release();
}
}
}
@@ -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,154 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Text;
using Serilog;
namespace EchoHub.Client.Services;
/// <summary>
/// Reads file paths that live on the OS clipboard as a *file list* (e.g. after copying a file in
/// Explorer/Finder/Nautilus), which terminals do not paste as text. Lets Ctrl+V attach a copied
/// file directly instead of requiring the user to paste a raw path.
/// </summary>
public static class ClipboardFiles
{
public static bool TryGetFiles(out List<string> files)
{
files = [];
try
{
if (OperatingSystem.IsWindows())
return TryGetWindows(out files);
if (OperatingSystem.IsLinux())
return TryGetLinux(out files);
}
catch (Exception ex)
{
Log.Warning(ex, "Reading files from the clipboard failed");
}
// macOS and everything else: no file-list clipboard support (text paste still works).
return false;
}
// ── Windows: CF_HDROP via the Win32 clipboard ────────────────────────────
private const uint CfHdrop = 15;
[SupportedOSPlatform("windows")]
private static bool TryGetWindows(out List<string> files)
{
files = [];
if (!IsClipboardFormatAvailable(CfHdrop))
return false;
// The clipboard may briefly be held by another process; a few quick retries cover that.
var opened = false;
for (var attempt = 0; attempt < 5 && !opened; attempt++)
opened = OpenClipboard(IntPtr.Zero);
if (!opened)
return false;
try
{
var hDrop = GetClipboardData(CfHdrop);
if (hDrop == IntPtr.Zero)
return false;
var count = DragQueryFileW(hDrop, 0xFFFFFFFF, null, 0);
for (uint i = 0; i < count; i++)
{
var len = DragQueryFileW(hDrop, i, null, 0);
if (len == 0)
continue;
var sb = new StringBuilder((int)len + 1);
DragQueryFileW(hDrop, i, sb, (uint)sb.Capacity);
var path = sb.ToString();
if (File.Exists(path))
files.Add(path);
}
return files.Count > 0;
}
finally
{
CloseClipboard();
}
}
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool OpenClipboard(IntPtr hWndNewOwner);
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CloseClipboard();
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool IsClipboardFormatAvailable(uint format);
[DllImport("user32.dll", SetLastError = true)]
private static extern IntPtr GetClipboardData(uint uFormat);
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
private static extern uint DragQueryFileW(IntPtr hDrop, uint iFile, StringBuilder? lpszFile, uint cch);
// ── Linux: text/uri-list from the clipboard via xclip or wl-paste ─────────
[SupportedOSPlatform("linux")]
private static bool TryGetLinux(out List<string> files)
{
files = [];
var output = RunForOutput("wl-paste", ["--type", "text/uri-list", "--no-newline"])
?? RunForOutput("xclip", ["-selection", "clipboard", "-t", "text/uri-list", "-o"]);
if (string.IsNullOrWhiteSpace(output))
return false;
foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (!line.StartsWith("file://", StringComparison.Ordinal))
continue;
try
{
var path = new Uri(line).LocalPath;
if (File.Exists(path))
files.Add(path);
}
catch (UriFormatException) { /* skip malformed entry */ }
}
return files.Count > 0;
}
private static string? RunForOutput(string fileName, IEnumerable<string> args)
{
var psi = new ProcessStartInfo(fileName)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
foreach (var arg in args)
psi.ArgumentList.Add(arg);
try
{
using var process = Process.Start(psi);
if (process is null)
return null;
var output = process.StandardOutput.ReadToEnd();
process.WaitForExit(2000);
return process.ExitCode == 0 ? output : null;
}
catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or FileNotFoundException)
{
return null; // tool not installed
}
}
}
@@ -0,0 +1,257 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using Serilog;
using SixLabors.ImageSharp;
namespace EchoHub.Client.Services;
/// <summary>
/// Reads raw image data from the OS clipboard (e.g. an image copied from a browser, or a
/// Win+Shift+S screenshot), which terminals cannot paste as text. Always returns PNG bytes:
/// clipboard PNG data is passed through, clipboard bitmaps (CF_DIB) are re-encoded.
/// </summary>
public static class ClipboardImage
{
private static readonly byte[] PngMagic = [0x89, 0x50, 0x4E, 0x47];
public static bool TryGetPng(out byte[] png)
{
png = [];
try
{
if (OperatingSystem.IsWindows())
return TryGetWindows(out png);
if (OperatingSystem.IsLinux())
return TryGetLinux(out png);
if (OperatingSystem.IsMacOS())
return TryGetMacOS(out png);
}
catch (Exception ex)
{
Log.Warning(ex, "Reading an image from the clipboard failed");
}
return false;
}
private static bool IsPng(byte[] data) =>
data.Length > PngMagic.Length && data.AsSpan(0, PngMagic.Length).SequenceEqual(PngMagic);
/// <summary>
/// Converts clipboard DIB bytes (a BITMAPINFOHEADER/V4/V5 + optional palette/masks + pixel
/// data, i.e. a .bmp file without its 14-byte file header) to PNG. Returns null when the
/// data is malformed or not decodable as a bitmap.
/// </summary>
public static byte[]? DibToPng(byte[] dib)
{
if (dib.Length < 40)
return null;
var headerSize = BitConverter.ToInt32(dib, 0);
if (headerSize < 40 || headerSize > dib.Length)
return null;
var bitCount = BitConverter.ToUInt16(dib, 14);
var compression = BitConverter.ToUInt32(dib, 16);
var clrUsed = BitConverter.ToUInt32(dib, 32);
// Pixel data offset: file header + info header + color masks + palette.
// BI_BITFIELDS masks follow a plain 40-byte header; larger headers embed them.
var maskBytes = headerSize == 40 && compression == 3 ? 12
: headerSize == 40 && compression == 6 ? 16
: 0;
var paletteEntries = clrUsed != 0 ? clrUsed
: bitCount <= 8 ? 1u << bitCount
: 0u;
var pixelOffset = (uint)(14 + headerSize + maskBytes) + paletteEntries * 4;
var bmp = new byte[14 + dib.Length];
bmp[0] = (byte)'B';
bmp[1] = (byte)'M';
BitConverter.TryWriteBytes(bmp.AsSpan(2), (uint)bmp.Length);
BitConverter.TryWriteBytes(bmp.AsSpan(10), pixelOffset);
dib.CopyTo(bmp, 14);
try
{
using var image = Image.Load(bmp);
using var ms = new MemoryStream();
image.SaveAsPng(ms);
return ms.ToArray();
}
catch (Exception ex) when (ex is ImageFormatException or InvalidOperationException)
{
Log.Warning(ex, "Clipboard DIB could not be decoded as a bitmap");
return null;
}
}
// ── Windows: "PNG" / "image/png" registered formats, then CF_DIB ─────────
private const uint CfDib = 8;
[SupportedOSPlatform("windows")]
private static bool TryGetWindows(out byte[] png)
{
png = [];
// Browsers register a "PNG" (Chromium) or "image/png" (some apps) clipboard format
// preserving transparency; CF_DIB is synthesized by Windows for everything else
// (screenshots, image editors), so together these cover all image sources.
var pngFormat = RegisterClipboardFormatW("PNG");
var mimeFormat = RegisterClipboardFormatW("image/png");
var hasAny = (pngFormat != 0 && IsClipboardFormatAvailable(pngFormat))
|| (mimeFormat != 0 && IsClipboardFormatAvailable(mimeFormat))
|| IsClipboardFormatAvailable(CfDib);
if (!hasAny)
return false;
var opened = false;
for (var attempt = 0; attempt < 5 && !opened; attempt++)
opened = OpenClipboard(IntPtr.Zero);
if (!opened)
return false;
try
{
foreach (var format in new[] { pngFormat, mimeFormat })
{
if (format == 0 || !IsClipboardFormatAvailable(format))
continue;
var data = ReadHGlobal(GetClipboardData(format));
if (data is not null && IsPng(data))
{
png = data;
return true;
}
}
if (IsClipboardFormatAvailable(CfDib)
&& ReadHGlobal(GetClipboardData(CfDib)) is { } dib
&& DibToPng(dib) is { } converted)
{
png = converted;
return true;
}
return false;
}
finally
{
CloseClipboard();
}
}
[SupportedOSPlatform("windows")]
private static byte[]? ReadHGlobal(IntPtr handle)
{
if (handle == IntPtr.Zero)
return null;
var ptr = GlobalLock(handle);
if (ptr == IntPtr.Zero)
return null;
try
{
var size = (int)GlobalSize(handle);
if (size <= 0)
return null;
var data = new byte[size];
Marshal.Copy(ptr, data, 0, size);
return data;
}
finally
{
GlobalUnlock(handle);
}
}
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool OpenClipboard(IntPtr hWndNewOwner);
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CloseClipboard();
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool IsClipboardFormatAvailable(uint format);
[DllImport("user32.dll", SetLastError = true)]
private static extern IntPtr GetClipboardData(uint uFormat);
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern uint RegisterClipboardFormatW(string lpszFormat);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern IntPtr GlobalLock(IntPtr hMem);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GlobalUnlock(IntPtr hMem);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern nuint GlobalSize(IntPtr hMem);
// ── Linux: image/png via wl-paste or xclip ────────────────────────────────
[SupportedOSPlatform("linux")]
private static bool TryGetLinux(out byte[] png)
{
png = [];
var data = RunForBytes("wl-paste", ["--type", "image/png"])
?? RunForBytes("xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]);
if (data is null || !IsPng(data))
return false;
png = data;
return true;
}
// ── macOS: pngpaste (brew install pngpaste), when present ────────────────
[SupportedOSPlatform("macos")]
private static bool TryGetMacOS(out byte[] png)
{
png = [];
var data = RunForBytes("pngpaste", ["-"]);
if (data is null || !IsPng(data))
return false;
png = data;
return true;
}
private static byte[]? RunForBytes(string fileName, IEnumerable<string> args)
{
var psi = new ProcessStartInfo(fileName)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
foreach (var arg in args)
psi.ArgumentList.Add(arg);
try
{
using var process = Process.Start(psi);
if (process is null)
return null;
using var ms = new MemoryStream();
process.StandardOutput.BaseStream.CopyTo(ms);
process.WaitForExit(2000);
return process.ExitCode == 0 && ms.Length > 0 ? ms.ToArray() : null;
}
catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or FileNotFoundException)
{
return null; // tool not installed
}
}
}
@@ -0,0 +1,343 @@
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.
/// <paramref name="Histories"/> holds the initial history of every auto-joined channel
/// (keyed by channel name, always including the default channel).
/// </summary>
internal record ConnectResult(
LoginResponse Login,
List<ChannelDto> Channels,
Dictionary<string, List<MessageDto>> Histories);
/// <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 RoomKeyStore _roomKeys = new();
private readonly HashSet<string> _joinedChannels = [];
// ── Properties ────────────────────────────────────────────────────────
public bool IsConnected => _connection?.IsConnected == true;
public bool IsAuthenticated => _apiClient is not null;
public ApiClient? Api => _apiClient;
public RoomKeyStore RoomKeys => _roomKeys;
// ── Events (forwarded from SignalR) ───────────────────────────────────
public event Action<MessageDto>? MessageReceived;
public event Action<string, string, UserPresenceDto?>? 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>? ChannelDeleted;
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);
try
{
onStatus("Authenticating...");
LoginResponse loginResponse;
if (info.SavedRefreshToken is not null)
{
loginResponse = await _apiClient.LoginWithRefreshTokenAsync(info.SavedRefreshToken);
Log.Information("Authenticated via saved session for {User}", loginResponse.Username);
}
else if (info.IsRegister)
{
loginResponse = await _apiClient.RegisterAsync(
info.Username, info.Password, info.DisplayName, info.InviteCode);
}
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();
_roomKeys.LoadForServer(info.ServerUrl);
_connection = new EchoHubConnection(info.ServerUrl, _apiClient, _encryption, _roomKeys);
WireConnectionEvents(_connection);
await _connection.ConnectAsync();
var channels = await _apiClient.GetChannelsAsync();
// Known E2E channels — senders consult this so a client without the room
// key never emits plaintext into an encrypted room
foreach (var channel in channels)
_roomKeys.MarkChannelEncrypted(channel.Name, channel.IsEncrypted);
// Join default channel + fetch history
onStatus("Joining channels...");
_joinedChannels.Clear();
_joinedChannels.Add(HubConstants.DefaultChannel);
await _connection.JoinChannelAsync(HubConstants.DefaultChannel);
var histories = new Dictionary<string, List<MessageDto>>(StringComparer.OrdinalIgnoreCase);
try
{
histories[HubConstants.DefaultChannel] = await _connection.GetHistoryAsync(HubConstants.DefaultChannel);
}
catch
{
// History might not be available
}
// Auto-join every other channel the server lists for this user (public +
// prior memberships) so message events — unread counts, @mentions — flow for
// all of them, not just channels opened this session. Channels the user left
// with /leave stay out until rejoined; protected channels we can't enter
// silently (no cached membership) are skipped, never prompted for.
var leftChannels = FindServer(ConfigManager.Load(), info.ServerUrl)?.LeftChannels ?? [];
foreach (var channel in channels)
{
if (channel.Name.Equals(HubConstants.DefaultChannel, StringComparison.OrdinalIgnoreCase))
continue;
if (leftChannels.Contains(channel.Name, StringComparer.OrdinalIgnoreCase))
continue;
try
{
var outcome = await _connection.JoinChannelAsync(channel.Name);
_joinedChannels.Add(channel.Name);
histories[channel.Name] = outcome.History;
}
catch (ChannelPasswordRequiredException)
{
// First-time protected channel — joining stays a manual, prompted action
}
catch (Exception ex)
{
Log.Warning(ex, "Auto-join failed for #{Channel}", channel.Name);
}
}
onStatus("Connected");
return new ConnectResult(loginResponse, channels, histories);
}
catch
{
if (_connection is not null)
{
await _connection.DisposeAsync();
_connection = null;
}
_apiClient.Dispose();
_apiClient = null;
throw;
}
}
// ── 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();
_roomKeys.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<JoinOutcome> JoinChannelAsync(string channelName, string? password = null)
{
if (_connection is null) throw new InvalidOperationException("Not connected");
try
{
var outcome = await _connection.JoinChannelAsync(channelName, password);
_joinedChannels.Add(channelName);
return outcome;
}
catch (ChannelPasswordRequiredException)
{
// Not actually joined — don't track, or reconnects would retry a doomed join
_joinedChannels.Remove(channelName);
throw;
}
}
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, Guid? replyToMessageId = null) =>
_connection?.SendMessageAsync(channel, content, replyToMessageId)
?? throw new InvalidOperationException("Not connected");
public Task<List<MessageDto>> GetHistoryAsync(string channel, int count = HubConstants.DefaultHistoryCount, int offset = 0) =>
_connection?.GetHistoryAsync(channel, count, offset)
?? 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)
{
// One channel gone bad (deleted, membership revoked) must not stop the rest
try
{
await _connection.JoinChannelAsync(channel);
_joinedChannels.Add(channel);
}
catch (Exception ex)
{
Log.Warning(ex, "Rejoin failed for #{Channel}", channel);
}
}
Log.Information("Rejoined {Count} channel(s) after reconnect", _joinedChannels.Count);
}
// ── SignalR Event Wiring ──────────────────────────────────────────────
private void WireConnectionEvents(EchoHubConnection connection)
{
connection.OnMessageReceived += msg => MessageReceived?.Invoke(msg);
connection.OnUserJoined += (ch, user, presence) => UserJoined?.Invoke(ch, user, presence);
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.OnChannelDeleted += ch => ChannelDeleted?.Invoke(ch);
connection.OnChannelNuked += ch => ChannelNuked?.Invoke(ch);
connection.OnChannelUpdated += ch =>
{
_roomKeys.MarkChannelEncrypted(ch.Name, ch.IsEncrypted);
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 = FindServer(config, _apiClient.BaseUrl);
if (server is not null && server.RememberMe)
{
server.RefreshToken = _apiClient.RefreshToken;
ConfigManager.Save(config);
}
}
private static SavedServer? FindServer(ClientConfig config, string url) =>
config.SavedServers.FirstOrDefault(s =>
string.Equals(s.Url, url, StringComparison.OrdinalIgnoreCase));
// ── Dispose ───────────────────────────────────────────────────────────
public async ValueTask DisposeAsync()
{
_apiClient?.Dispose();
if (_connection is not null)
await _connection.DisposeAsync();
}
}
+125 -11
View File
@@ -1,22 +1,64 @@
using EchoHub.Core.Constants;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Core.Security;
using Microsoft.AspNetCore.SignalR.Client;
namespace EchoHub.Client.Services;
/// <summary>
/// Result of joining a channel: decrypted history plus, for end-to-end encrypted
/// channels, the key envelope needed to unlock the room content key.
/// </summary>
public sealed record JoinOutcome(List<MessageDto> History, string? EncryptionSalt, string? WrappedRoomKey);
/// <summary>
/// Thrown when joining a channel fails because a password is required or incorrect.
/// The UI catches this to prompt the user and retry.
/// </summary>
public sealed class ChannelPasswordRequiredException : Exception
{
public string ChannelName { get; }
public ChannelPasswordRequiredException(string channelName, string message) : base(message)
{
ChannelName = channelName;
}
}
/// <summary>
/// Thrown when sending into an end-to-end encrypted channel whose room key isn't cached:
/// without the key the message would leave the client as plaintext, which must never happen.
/// </summary>
public sealed class RoomLockedException : Exception
{
public string ChannelName { get; }
public RoomLockedException(string channelName)
: base($"#{channelName} is end-to-end encrypted and locked — enter its passphrase to unlock it before sending.")
{
ChannelName = channelName;
}
}
public sealed class EchoHubConnection : IAsyncDisposable
{
public const string LockedMessagePlaceholder =
"[encrypted — rejoin this channel with its passphrase to unlock]";
private readonly HubConnection _connection;
private readonly ClientEncryptionService _encryption;
private readonly RoomKeyStore _roomKeys;
public event Action<MessageDto>? OnMessageReceived;
public event Action<string, string>? OnUserJoined;
public event Action<string, string, UserPresenceDto?>? 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>? OnChannelDeleted;
public event Action<string>? OnChannelNuked;
public event Action<string>? OnForceDisconnect;
public event Action<string>? OnError;
@@ -25,8 +67,10 @@ public sealed class EchoHubConnection : IAsyncDisposable
public bool IsConnected => _connection.State == HubConnectionState.Connected;
public EchoHubConnection(string serverUrl, ApiClient apiClient)
public EchoHubConnection(string serverUrl, ApiClient apiClient, ClientEncryptionService encryption, RoomKeyStore roomKeys)
{
_encryption = encryption;
_roomKeys = roomKeys;
var hubUrl = serverUrl.TrimEnd('/') + HubConstants.ChatHubPath;
_connection = new HubConnectionBuilder()
@@ -63,12 +107,12 @@ public sealed class EchoHubConnection : IAsyncDisposable
{
_connection.On<MessageDto>(nameof(Core.Contracts.IEchoHubClient.ReceiveMessage), message =>
{
OnMessageReceived?.Invoke(message);
OnMessageReceived?.Invoke(DecryptMessage(message));
});
_connection.On<string, string>(nameof(Core.Contracts.IEchoHubClient.UserJoined), (channelName, username) =>
_connection.On<string, string, UserPresenceDto?>(nameof(Core.Contracts.IEchoHubClient.UserJoined), (channelName, username, presence) =>
{
OnUserJoined?.Invoke(channelName, username);
OnUserJoined?.Invoke(channelName, username, presence);
});
_connection.On<string, string>(nameof(Core.Contracts.IEchoHubClient.UserLeft), (channelName, username) =>
@@ -101,6 +145,11 @@ public sealed class EchoHubConnection : IAsyncDisposable
OnMessageDeleted?.Invoke(channelName, messageId);
});
_connection.On<string>(nameof(Core.Contracts.IEchoHubClient.ChannelDeleted), channelName =>
{
OnChannelDeleted?.Invoke(channelName);
});
_connection.On<string>(nameof(Core.Contracts.IEchoHubClient.ChannelNuked), channelName =>
{
OnChannelNuked?.Invoke(channelName);
@@ -130,9 +179,18 @@ public sealed class EchoHubConnection : IAsyncDisposable
OnConnectionStateChanged?.Invoke("Disconnected");
}
public async Task<List<MessageDto>> JoinChannelAsync(string channelName)
public async Task<JoinOutcome> JoinChannelAsync(string channelName, string? password = null)
{
return await _connection.InvokeAsync<List<MessageDto>>("JoinChannel", channelName);
var result = await _connection.InvokeAsync<JoinChannelResult>("JoinChannel", channelName, password);
if (!result.Success)
{
if (result.PasswordRequired)
throw new ChannelPasswordRequiredException(channelName, result.Error ?? "Channel is password protected.");
throw new InvalidOperationException(result.Error ?? "Failed to join channel.");
}
if (result.WrappedRoomKey is not null)
_roomKeys.MarkChannelEncrypted(channelName, true);
return new JoinOutcome(DecryptMessages(result.History), result.EncryptionSalt, result.WrappedRoomKey);
}
public async Task LeaveChannelAsync(string channelName)
@@ -140,14 +198,22 @@ public sealed class EchoHubConnection : IAsyncDisposable
await _connection.InvokeAsync("LeaveChannel", channelName);
}
public async Task SendMessageAsync(string channelName, string content)
public async Task SendMessageAsync(string channelName, string content, Guid? replyToMessageId = null)
{
await _connection.InvokeAsync("SendMessage", channelName, content);
// Room layer first (end-to-end, server can't read), then transport encryption
if (_roomKeys.TryGetKey(channelName, out var roomKey))
content = RoomCrypto.EncryptText(content, roomKey);
else if (_roomKeys.IsChannelEncrypted(channelName))
throw new RoomLockedException(channelName); // never fall through to plaintext
var encrypted = _encryption.Encrypt(content);
await _connection.InvokeAsync("SendMessage", channelName, encrypted, replyToMessageId);
}
public async Task<List<MessageDto>> GetHistoryAsync(string channelName, int count = HubConstants.DefaultHistoryCount)
public async Task<List<MessageDto>> GetHistoryAsync(string channelName, int count = HubConstants.DefaultHistoryCount, int offset = 0)
{
return await _connection.InvokeAsync<List<MessageDto>>("GetChannelHistory", channelName, count);
var messages = await _connection.InvokeAsync<List<MessageDto>>("GetChannelHistory", channelName, count, offset);
return DecryptMessages(messages);
}
public async Task UpdateStatusAsync(UserStatus status, string? statusMessage = null)
@@ -160,6 +226,54 @@ public sealed class EchoHubConnection : IAsyncDisposable
return await _connection.InvokeAsync<List<UserPresenceDto>>("GetOnlineUsers", channelName);
}
private List<MessageDto> DecryptMessages(List<MessageDto> messages)
{
return messages.Select(DecryptMessage).ToList();
}
/// <summary>
/// Strips the transport encryption, then the room layer for E2E channels, from the
/// message content and every attachment preview. Without the room key the content is
/// replaced by a locked placeholder — re-fetch history after unlocking to render it.
/// </summary>
private MessageDto DecryptMessage(MessageDto message)
{
_roomKeys.TryGetKey(message.ChannelName, out var roomKey);
var content = DecryptField(message.Content, roomKey) ?? LockedMessagePlaceholder;
List<AttachmentDto>? attachments = null;
if (message.Attachments is { Count: > 0 })
{
attachments = message.Attachments
.Select(a => a with { AsciiPreview = a.AsciiPreview is null ? null : DecryptField(a.AsciiPreview, roomKey) })
.ToList();
}
// Reply snippets are encrypted exactly like message content
var replyTo = message.ReplyTo is { } reply
? reply with { Content = DecryptField(reply.Content, roomKey) ?? LockedMessagePlaceholder }
: null;
return message with { Content = content, Attachments = attachments, ReplyTo = replyTo };
}
/// <summary>
/// Decrypts one field: strips transport encryption, then the room layer if it is room
/// ciphertext. Returns null when it is room ciphertext but the room key is missing/wrong.
/// </summary>
private string? DecryptField(string value, byte[]? roomKey)
{
var plain = _encryption.Decrypt(value);
if (!RoomCrypto.IsRoomCiphertext(plain))
return plain;
if (roomKey is not null && RoomCrypto.TryDecryptText(plain, roomKey, out var decrypted))
return decrypted;
return null;
}
public async ValueTask DisposeAsync()
{
await _connection.DisposeAsync();
@@ -0,0 +1,141 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text;
using Serilog;
namespace EchoHub.Client.Services;
public enum PickerOutcome
{
/// <summary>The user picked a folder (<see cref="FolderPickResult.Path"/> is set).</summary>
Chosen,
/// <summary>The native dialog ran but the user cancelled it.</summary>
Cancelled,
/// <summary>No native picker is available on this machine (headless, missing tool, etc.).</summary>
Unavailable,
}
public sealed record FolderPickResult(PickerOutcome Outcome, string? Path);
/// <summary>
/// Opens the OS-native folder chooser (Windows Explorer, macOS Finder, Linux GTK/KDE) by shelling
/// out, so the TUI doesn't need a GUI toolkit reference. Returns <see cref="PickerOutcome.Unavailable"/>
/// when no native dialog can run, so callers can fall back to a configured path.
/// </summary>
public static class NativeFolderPicker
{
private const string Title = "Choose your EchoHub download folder";
public static async Task<FolderPickResult> PickFolderAsync(string? initialDir)
{
try
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
return await PickWindowsAsync(initialDir);
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
return await PickMacAsync();
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
return await PickLinuxAsync(initialDir);
}
catch (Exception ex)
{
Log.Warning(ex, "Native folder picker failed");
}
return new FolderPickResult(PickerOutcome.Unavailable, null);
}
private static async Task<FolderPickResult> PickWindowsAsync(string? initialDir)
{
var safeInit = (initialDir ?? string.Empty).Replace("'", "''");
var script = $$"""
Add-Type -AssemblyName System.Windows.Forms
$d = New-Object System.Windows.Forms.FolderBrowserDialog
$d.Description = '{{Title}}'
$d.ShowNewFolderButton = $true
$d.SelectedPath = '{{safeInit}}'
if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write($d.SelectedPath) }
""";
// -EncodedCommand avoids all quoting issues; FolderBrowserDialog needs an STA thread.
var encoded = Convert.ToBase64String(Encoding.Unicode.GetBytes(script));
var (started, _, stdout) = await RunAsync("powershell.exe",
["-STA", "-NoProfile", "-NonInteractive", "-EncodedCommand", encoded]);
if (!started)
return new FolderPickResult(PickerOutcome.Unavailable, null);
return string.IsNullOrWhiteSpace(stdout)
? new FolderPickResult(PickerOutcome.Cancelled, null)
: new FolderPickResult(PickerOutcome.Chosen, stdout);
}
private static async Task<FolderPickResult> PickMacAsync()
{
var (started, exit, stdout) = await RunAsync("osascript",
["-e", $"POSIX path of (choose folder with prompt \"{Title}\")"]);
if (!started)
return new FolderPickResult(PickerOutcome.Unavailable, null);
return exit == 0 && !string.IsNullOrWhiteSpace(stdout)
? new FolderPickResult(PickerOutcome.Chosen, stdout)
: new FolderPickResult(PickerOutcome.Cancelled, null);
}
private static async Task<FolderPickResult> PickLinuxAsync(string? initialDir)
{
// No graphical session → no native picker.
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("DISPLAY"))
&& string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WAYLAND_DISPLAY")))
return new FolderPickResult(PickerOutcome.Unavailable, null);
var zenityArgs = new List<string> { "--file-selection", "--directory", $"--title={Title}" };
if (!string.IsNullOrWhiteSpace(initialDir))
zenityArgs.Add($"--filename={initialDir!.TrimEnd('/')}/");
var (zStarted, zExit, zOut) = await RunAsync("zenity", zenityArgs);
if (zStarted)
return zExit == 0 && !string.IsNullOrWhiteSpace(zOut)
? new FolderPickResult(PickerOutcome.Chosen, zOut)
: new FolderPickResult(PickerOutcome.Cancelled, null);
var (kStarted, kExit, kOut) = await RunAsync("kdialog",
["--getexistingdirectory", string.IsNullOrWhiteSpace(initialDir) ? "." : initialDir!]);
if (kStarted)
return kExit == 0 && !string.IsNullOrWhiteSpace(kOut)
? new FolderPickResult(PickerOutcome.Chosen, kOut)
: new FolderPickResult(PickerOutcome.Cancelled, null);
return new FolderPickResult(PickerOutcome.Unavailable, null);
}
private static async Task<(bool Started, int ExitCode, string StdOut)> RunAsync(string fileName, IEnumerable<string> args)
{
var psi = new ProcessStartInfo(fileName)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
foreach (var arg in args)
psi.ArgumentList.Add(arg);
try
{
using var process = Process.Start(psi);
if (process is null)
return (false, -1, string.Empty);
var stdout = await process.StandardOutput.ReadToEndAsync();
await process.WaitForExitAsync();
return (true, process.ExitCode, stdout.Trim());
}
catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or FileNotFoundException)
{
// Executable not found on PATH → treat as "no native picker".
return (false, -1, string.Empty);
}
}
}
@@ -6,7 +6,11 @@ namespace EchoHub.Client.Services;
public class NotificationSoundService
{
// Safety net: if PlaybackFinished never fires we don't want to block future notifications forever.
private static readonly TimeSpan PlaybackTimeout = TimeSpan.FromSeconds(10);
private readonly Player _player = new();
private readonly SemaphoreSlim _lock = new(1, 1);
private readonly NotificationConfig _config;
private string? _resolvedSoundPath;
@@ -41,18 +45,31 @@ public class NotificationSoundService
private async Task PlayInternal()
{
await _lock.WaitAsync();
// _player.Play returns as soon as playback starts, so we wait on PlaybackFinished
// to hold the lock for the duration of the sound. A one-shot handler + timeout
// keeps the finally release robust: never-fires → timeout; fires twice → ignored
// (TrySetResult); handler throws → caller's catch still runs finally.
var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
void OnFinished(object? s, EventArgs e) => completion.TrySetResult();
_player.PlaybackFinished += OnFinished;
try
{
if (_player.Playing)
await _player.Stop();
await _player.SetVolume(_config.Volume);
await _player.Play(_resolvedSoundPath!);
await Task.WhenAny(completion.Task, Task.Delay(PlaybackTimeout));
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to play notification sound");
}
finally
{
_player.PlaybackFinished -= OnFinished;
_lock.Release();
}
}
private void ResolveSoundPath()
@@ -0,0 +1,13 @@
namespace EchoHub.Client.Services;
/// <summary>
/// One file to upload as part of a message. For end-to-end encrypted channels the stream
/// is already ciphertext, <see cref="DeclaredKind"/> is set (image/audio/file), and
/// <see cref="EncryptedPreview"/> holds the room-encrypted ASCII art for images.
/// For normal channels only <see cref="Stream"/> and <see cref="FileName"/> are set.
/// </summary>
public sealed record OutgoingAttachment(
Stream Stream,
string FileName,
string? DeclaredKind = null,
string? EncryptedPreview = null);
+102
View File
@@ -0,0 +1,102 @@
using Serilog;
namespace EchoHub.Client.Services;
/// <summary>
/// Ensures the application's directory is on the system PATH so users
/// can run 'echohub' from any terminal session.
/// </summary>
public static class PathSetup
{
private const string PathMarker = "# Added by EchoHub";
/// <summary>
/// Checks if the app directory is on PATH; if not, adds it persistently.
/// On Windows: modifies user-level PATH environment variable.
/// On Linux/macOS: appends an export line to shell profile files.
/// </summary>
public static void EnsureOnPath()
{
try
{
var appDir = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
if (IsOnPath(appDir))
return;
if (OperatingSystem.IsWindows())
AddToWindowsPath(appDir);
else
AddToUnixPath(appDir);
}
catch (Exception ex)
{
Log.Debug(ex, "Could not add app directory to PATH");
}
}
private static bool IsOnPath(string directory)
{
var pathVar = Environment.GetEnvironmentVariable("PATH") ?? "";
var separator = OperatingSystem.IsWindows() ? ';' : ':';
return pathVar
.Split(separator, StringSplitOptions.RemoveEmptyEntries)
.Any(p => string.Equals(
p.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
directory,
OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal));
}
private static void AddToWindowsPath(string directory)
{
var userPath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.User) ?? "";
// Double-check against user PATH specifically (process PATH includes system + user)
if (userPath.Split(';', StringSplitOptions.RemoveEmptyEntries)
.Any(p => string.Equals(p.TrimEnd('\\', '/'), directory, StringComparison.OrdinalIgnoreCase)))
return;
var newPath = string.IsNullOrEmpty(userPath) ? directory : userPath + ";" + directory;
Environment.SetEnvironmentVariable("PATH", newPath, EnvironmentVariableTarget.User);
Log.Information("Added {Directory} to user PATH", directory);
}
private static void AddToUnixPath(string directory)
{
var exportLine = $"export PATH=\"{directory}:$PATH\" {PathMarker}";
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
// Target the most common shell profiles
string[] profiles = [
Path.Combine(home, ".profile"),
Path.Combine(home, ".bashrc"),
Path.Combine(home, ".zshrc")
];
var added = false;
foreach (var profile in profiles)
{
if (!File.Exists(profile))
continue;
var content = File.ReadAllText(profile);
if (content.Contains(directory))
continue; // Already present (manual or previous run)
File.AppendAllText(profile, $"\n{exportLine}\n");
added = true;
Log.Information("Added PATH export to {Profile}", profile);
}
// If no profile existed, create .profile
if (!added)
{
var fallback = Path.Combine(home, ".profile");
File.AppendAllText(fallback, $"\n{exportLine}\n");
Log.Information("Created PATH export in {Profile}", fallback);
}
}
}
@@ -0,0 +1,120 @@
using System.Security.Cryptography;
using EchoHub.Core.Security;
using Serilog;
namespace EchoHub.Client.Services;
/// <summary>
/// Encrypts cached room content keys at rest so the client config never holds them as
/// plain base64. Windows uses DPAPI (current-user scope, format prefix "dp1:"). On other
/// platforms the keys are AES-GCM encrypted with a per-user master key file stored next
/// to the config with 0600 permissions (prefix "k1:") — without an OS keychain that is
/// file-permission-level protection, not zero-knowledge: anyone who can read both the
/// config and the key file can recover the room keys. Values with no recognized prefix
/// are legacy plain-base64 keys from older clients; they load once and are re-encrypted.
/// The room passphrase itself is never stored in any form.
/// </summary>
public sealed class RoomKeyProtector
{
public const string DpapiPrefix = "dp1:";
public const string KeyFilePrefix = "k1:";
private const string KeyFileName = "roomkeys.key";
private const int MasterKeySizeBytes = 32;
private const int RoomKeySizeBytes = 32;
private readonly string _keyFilePath;
private readonly bool _useDpapi;
private readonly Lock _lock = new();
private byte[]? _masterKey;
/// <param name="keyDirectory">Directory holding the master key file (the client config dir).</param>
/// <param name="useDpapi">Overrides the platform default (DPAPI on Windows) — for tests.</param>
public RoomKeyProtector(string keyDirectory, bool? useDpapi = null)
{
_keyFilePath = Path.Combine(keyDirectory, KeyFileName);
_useDpapi = useDpapi ?? OperatingSystem.IsWindows();
}
/// <summary>Encrypts a room key for storage in the config file.</summary>
public string Protect(byte[] roomKey)
{
if (_useDpapi && OperatingSystem.IsWindows())
return DpapiPrefix + Convert.ToBase64String(
ProtectedData.Protect(roomKey, null, DataProtectionScope.CurrentUser));
return KeyFilePrefix + Convert.ToBase64String(RoomCrypto.EncryptBytes(roomKey, GetMasterKey()));
}
/// <summary>
/// Decrypts a stored value back into a room key. <paramref name="wasLegacy"/> is true when
/// the value was an unencrypted legacy entry that should be re-persisted via
/// <see cref="Protect"/>. Returns false for unreadable values (wrong user/machine, missing
/// or regenerated key file, malformed data) — the caller drops the entry and the user can
/// recover it by re-entering the passphrase.
/// </summary>
public bool TryUnprotect(string stored, out byte[] roomKey, out bool wasLegacy)
{
roomKey = [];
wasLegacy = false;
try
{
if (stored.StartsWith(DpapiPrefix, StringComparison.Ordinal))
{
if (!OperatingSystem.IsWindows())
return false; // config copied from a Windows machine
roomKey = ProtectedData.Unprotect(
Convert.FromBase64String(stored[DpapiPrefix.Length..]), null, DataProtectionScope.CurrentUser);
return roomKey.Length > 0;
}
if (stored.StartsWith(KeyFilePrefix, StringComparison.Ordinal))
{
if (!File.Exists(_keyFilePath))
return false;
roomKey = RoomCrypto.DecryptBytes(
Convert.FromBase64String(stored[KeyFilePrefix.Length..]), GetMasterKey());
return roomKey.Length > 0;
}
// No recognized prefix — legacy plain-base64 room key from a pre-encryption client
roomKey = Convert.FromBase64String(stored);
wasLegacy = true;
return roomKey.Length == RoomKeySizeBytes;
}
catch (Exception ex) when (ex is FormatException or CryptographicException
or IOException or UnauthorizedAccessException)
{
return false;
}
}
private byte[] GetMasterKey()
{
lock (_lock)
{
if (_masterKey is not null)
return _masterKey;
if (File.Exists(_keyFilePath))
{
var existing = File.ReadAllBytes(_keyFilePath);
if (existing.Length == MasterKeySizeBytes)
return _masterKey = existing;
Log.Warning("Room-key master key file has unexpected size — regenerating (previously cached keys become unreadable)");
}
var key = RandomNumberGenerator.GetBytes(MasterKeySizeBytes);
Directory.CreateDirectory(Path.GetDirectoryName(_keyFilePath)!);
File.WriteAllBytes(_keyFilePath, key);
if (!OperatingSystem.IsWindows())
File.SetUnixFileMode(_keyFilePath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
return _masterKey = key;
}
}
}
+178
View File
@@ -0,0 +1,178 @@
using EchoHub.Client.Config;
using EchoHub.Core.Security;
using Serilog;
namespace EchoHub.Client.Services;
/// <summary>
/// Holds room content keys for end-to-end encrypted channels: in-memory for the
/// active session, persisted per-server in the client config (like saved sessions)
/// so users don't retype the passphrase every launch. Keys never leave this machine
/// and are encrypted at rest by <see cref="RoomKeyProtector"/>. Also tracks which
/// channels are known to be end-to-end encrypted, so senders can refuse to emit
/// plaintext into a room whose key isn't cached yet.
/// </summary>
public sealed class RoomKeyStore
{
private readonly Dictionary<string, byte[]> _keys = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _encryptedChannels = new(StringComparer.OrdinalIgnoreCase);
private readonly RoomKeyProtector _protector;
private readonly Lock _lock = new();
private string? _serverUrl;
public RoomKeyStore() : this(new RoomKeyProtector(ConfigManager.ConfigDirectory))
{
}
public RoomKeyStore(RoomKeyProtector protector)
{
_protector = protector;
}
/// <summary>Binds the store to a server and loads that server's cached keys from config.</summary>
public void LoadForServer(string serverUrl)
{
lock (_lock)
{
_serverUrl = serverUrl;
_keys.Clear();
_encryptedChannels.Clear();
var server = FindServer(ConfigManager.Load(), serverUrl);
if (server is null) return;
var legacyFound = false;
foreach (var (channel, stored) in server.ChannelKeys)
{
if (_protector.TryUnprotect(stored, out var key, out var wasLegacy))
{
_keys[channel] = key;
legacyFound |= wasLegacy;
}
else
{
Log.Warning("Ignoring unreadable cached room key for #{Channel}", channel);
}
}
// One-way upgrade: legacy plain-base64 entries get re-persisted encrypted
// (unreadable entries drop out — the unlock prompt recovers those rooms).
if (legacyFound)
Persist(s =>
{
s.ChannelKeys.Clear();
foreach (var (channel, key) in _keys)
s.ChannelKeys[channel] = _protector.Protect(key);
});
}
}
public bool TryGetKey(string channelName, out byte[] key)
{
lock (_lock)
{
if (_keys.TryGetValue(channelName, out var k))
{
key = k;
return true;
}
}
key = [];
return false;
}
public bool HasKey(string channelName) => TryGetKey(channelName, out _);
/// <summary>Stores a key for the session and persists it to the server's config entry.</summary>
public void StoreKey(string channelName, byte[] key)
{
lock (_lock)
{
_keys[channelName] = key;
_encryptedChannels.Add(channelName);
Persist(server => server.ChannelKeys[channelName] = _protector.Protect(key));
}
}
/// <summary>
/// Unwraps a fresh key envelope and caches the key, overwriting any stale cached key
/// (e.g. the channel was deleted and recreated under the same name, so the old key
/// would encrypt messages nobody else can read). Returns false when the KEK doesn't
/// open the envelope — the cache is left untouched.
/// </summary>
public bool TryStoreFromEnvelope(string channelName, string wrappedRoomKey, byte[] kek)
{
if (!RoomCrypto.TryUnwrapRoomKey(wrappedRoomKey, kek, out var roomKey))
return false;
StoreKey(channelName, roomKey);
return true;
}
public void RemoveKey(string channelName)
{
lock (_lock)
{
_keys.Remove(channelName);
Persist(server => server.ChannelKeys.Remove(channelName));
}
}
/// <summary>
/// Records whether a channel is end-to-end encrypted (from channel listings, crypto
/// metadata, or join outcomes). Senders consult this to block plaintext into rooms
/// whose key isn't cached.
/// </summary>
public void MarkChannelEncrypted(string channelName, bool isEncrypted)
{
lock (_lock)
{
if (isEncrypted)
_encryptedChannels.Add(channelName);
else
_encryptedChannels.Remove(channelName);
}
}
public bool IsChannelEncrypted(string channelName)
{
lock (_lock)
{
return _encryptedChannels.Contains(channelName);
}
}
public void Clear()
{
lock (_lock)
{
_keys.Clear();
_encryptedChannels.Clear();
_serverUrl = null;
}
}
private void Persist(Action<SavedServer> mutate)
{
if (_serverUrl is null) return;
try
{
var config = ConfigManager.Load();
var server = FindServer(config, _serverUrl);
if (server is null) return; // server not saved yet — key stays in-memory only
mutate(server);
ConfigManager.Save(config);
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to persist room key cache");
}
}
private static SavedServer? FindServer(ClientConfig config, string url) =>
config.SavedServers.FirstOrDefault(s =>
string.Equals(s.Url, url, StringComparison.OrdinalIgnoreCase));
}
@@ -0,0 +1,183 @@
using System.Diagnostics;
using System.IO.Compression;
using System.Text.Json;
using Serilog;
namespace EchoHub.Client.Services;
/// <summary>
/// Manages pre-update backups and rollback restoration for the auto-updater.
/// Backup location: ~/.echohub/update-backup/
/// </summary>
public static class UpdateBackupService
{
private static readonly string BackupDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".echohub", "update-backup");
private static readonly string BackupZipPath = Path.Combine(BackupDir, "backup.zip");
private static readonly string BackupInfoPath = Path.Combine(BackupDir, "backup-info.json");
/// <summary>
/// True if a backup exists from a recent update (set at startup).
/// </summary>
public static bool IsPostUpdate { get; set; }
/// <summary>
/// Creates a ZIP backup of the current app directory before an update.
/// Deletes any previous backup first. Uses fastest compression for speed.
/// </summary>
public static void CreateBackup()
{
var appDir = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var version = UpdateChecker.CurrentVersion;
if (Directory.Exists(BackupDir))
Directory.Delete(BackupDir, true);
Directory.CreateDirectory(BackupDir);
Log.Information("Creating pre-update backup of {AppDir} (v{Version})", appDir, version);
using (var archive = ZipFile.Open(BackupZipPath, ZipArchiveMode.Create))
{
foreach (var file in Directory.EnumerateFiles(appDir, "*", SearchOption.AllDirectories))
{
var relativePath = Path.GetRelativePath(appDir, file);
// Skip log files to prevent locking errors with Serilog while zipping
if (relativePath.StartsWith("logs" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ||
relativePath.StartsWith("logs" + Path.AltDirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ||
relativePath.EndsWith(".log", StringComparison.OrdinalIgnoreCase))
{
continue;
}
// Normalize path separators for the zip archive format
var entryName = relativePath.Replace(Path.DirectorySeparatorChar, '/').Replace(Path.AltDirectorySeparatorChar, '/');
try
{
archive.CreateEntryFromFile(file, entryName, CompressionLevel.Fastest);
}
catch (IOException ex)
{
Log.Warning(ex, "Skipped locked file {FileName} during backup calculation", relativePath);
}
catch (UnauthorizedAccessException ex)
{
Log.Warning(ex, "Skipped inaccessible file {FileName} during backup calculation", relativePath);
}
}
}
var info = new BackupInfo(version, appDir, DateTimeOffset.UtcNow);
var json = JsonSerializer.Serialize(info, BackupJsonContext.Default.BackupInfo);
File.WriteAllText(BackupInfoPath, json);
Log.Information("Backup created at {BackupPath}", BackupZipPath);
}
/// <summary>
/// Returns true if a valid backup exists (both ZIP and metadata file present).
/// </summary>
public static bool BackupExists()
=> File.Exists(BackupZipPath) && File.Exists(BackupInfoPath);
/// <summary>
/// Reads backup metadata. Returns null if no backup exists or metadata is unreadable.
/// </summary>
public static BackupInfo? GetBackupInfo()
{
if (!File.Exists(BackupInfoPath))
return null;
try
{
var json = File.ReadAllText(BackupInfoPath);
return JsonSerializer.Deserialize(json, BackupJsonContext.Default.BackupInfo);
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to read backup metadata");
return null;
}
}
/// <summary>
/// Restores the backup ZIP to the app directory, then restarts the process.
/// This method does not return — it calls Environment.Exit(0).
/// </summary>
public static void RestoreBackup()
{
var info = GetBackupInfo()
?? throw new InvalidOperationException("No backup metadata found");
var appDir = info.AppDirectory;
Log.Information("Restoring backup v{Version} to {AppDir}", info.Version, appDir);
// On Windows, rename the running executable so extraction can overwrite it
if (OperatingSystem.IsWindows())
{
var currentExe = Environment.ProcessPath;
if (!string.IsNullOrEmpty(currentExe) && File.Exists(currentExe))
{
var oldExe = currentExe + ".old";
if (File.Exists(oldExe))
File.Delete(oldExe);
File.Move(currentExe, oldExe);
}
}
ZipFile.ExtractToDirectory(BackupZipPath, appDir, overwriteFiles: true);
// Restore execute permission on Unix
if (!OperatingSystem.IsWindows())
{
var exePath = Environment.ProcessPath
?? Path.Combine(appDir, "EchoHub.Client");
if (File.Exists(exePath))
{
var mode = File.GetUnixFileMode(exePath);
File.SetUnixFileMode(exePath, mode | UnixFileMode.UserExecute);
}
}
// Start the restored version and exit
var processPath = Environment.ProcessPath
?? Path.Combine(appDir, "EchoHub.Client");
Log.Information("Launching restored version v{Version}: {Path}", info.Version, processPath);
Process.Start(new ProcessStartInfo(processPath) { UseShellExecute = false });
Environment.Exit(0);
}
/// <summary>
/// Deletes the backup directory and all contents.
/// </summary>
public static void DeleteBackup()
{
if (!Directory.Exists(BackupDir))
return;
try
{
Directory.Delete(BackupDir, true);
Log.Information("Update backup deleted");
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to delete update backup");
}
}
}
public record BackupInfo(
string Version,
string AppDirectory,
DateTimeOffset CreatedAt);
[System.Text.Json.Serialization.JsonSerializable(typeof(BackupInfo))]
internal partial class BackupJsonContext : System.Text.Json.Serialization.JsonSerializerContext;
+206 -29
View File
@@ -1,47 +1,224 @@
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using System.Text;
using AlwaysUpToDate;
using EchoHub.Client.UI.Dialogs;
using Serilog;
using Terminal.Gui.App;
using Terminal.Gui.Views;
namespace EchoHub.Client.Services;
public static class UpdateChecker
public sealed class UpdateChecker : IDisposable
{
private static readonly Uri ReleaseUrl =
new("https://api.github.com/repos/HueByte/EchoHub/releases/latest");
private const string ManifestUrl = "https://echohub.voidcube.cloud/api/app/version";
private readonly Updater _updater;
private readonly IApplication _app;
private string? _pendingVersion;
private bool _applying;
private UpdateStep _lastStep = (UpdateStep)(-1);
private bool _manualCheck;
/// <summary>
/// Checks GitHub for a newer release. Returns the new version string if one exists, or null.
/// Never throws — all errors are silently swallowed.
/// Set when the user confirms an update. The host runs this <b>after</b> the Terminal.Gui
/// main loop has exited and the console is restored, so the library's in-place restart doesn't
/// deadlock against a TUI that still owns the console.
/// </summary>
public static async Task<string?> CheckForUpdateAsync()
public Func<Task>? PendingUpdate { get; private set; }
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
}
public async Task CheckNowAsync()
{
_manualCheck = true;
try
{
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
http.DefaultRequestHeaders.UserAgent.ParseAdd("EchoHub-Client");
var release = await http.GetFromJsonAsync<GitHubRelease>(ReleaseUrl);
if (release?.TagName is null)
return null;
var tag = release.TagName.TrimStart('v', 'V');
if (!Version.TryParse(tag, out var latest))
return null;
var currentStr = typeof(UpdateChecker).Assembly.GetName().Version?.ToString(3);
if (currentStr is null || !Version.TryParse(currentStr, out var current))
return null;
return latest > current ? tag : null;
await _updater.CheckForUpdateAsync();
}
catch
finally
{
return null;
_manualCheck = false;
}
}
private sealed class GitHubRelease
private void OnUpdateAvailable(string version, string changelogUrl)
{
[JsonPropertyName("tag_name")]
public string? TagName { get; set; }
Log.Information("Update available: v{Version}", version);
_app.Invoke(() =>
{
var confirmed = UpdateConfirmDialog.Show(_app, CurrentVersion, version);
if (!confirmed)
return;
// Defer the actual download/extract/restart to after the TUI is torn down.
// Running it under the live main loop lets the library restart the process while
// this one still holds the console in raw/alternate-screen mode — the two processes
// then deadlock over the console (the "stuck at N/N extracting" hang).
_pendingVersion = version;
PendingUpdate = ApplyUpdateAsync;
_app.RequestStop();
});
}
/// <summary>
/// Runs the update on a plain console (invoked by the host after the main loop exits).
/// Ends by restarting the app and exiting the process, or restoring the backup on failure.
/// </summary>
private async Task ApplyUpdateAsync()
{
_applying = true;
// The TUI restored the console on shutdown; make sure the block-glyph bar renders.
try { Console.OutputEncoding = Encoding.UTF8; } catch { /* redirected/non-interactive */ }
Console.WriteLine();
Console.WriteLine($"Updating EchoHub to v{_pendingVersion}...");
try
{
Console.WriteLine("Creating backup...");
UpdateBackupService.CreateBackup();
}
catch (Exception ex)
{
Log.Error(ex, "Failed to create pre-update backup");
Console.WriteLine($"Warning: could not create a backup ({ex.Message}). Continuing without one.");
}
Console.WriteLine("Downloading update...");
await _updater.UpdateAsync(); // download → extract → restart → Environment.Exit(0)
}
private const int BarWidth = 28;
private void OnProgressChanged(UpdateStep step, long itemsProcessed, long? totalItems, double? progressPercentage)
{
// Before the TUI is torn down (i.e. during a check) there is no progress surface; the
// real work happens headless after shutdown, so draw a progress bar on the console.
if (!_applying)
return;
// Finish the previous step's line so each step keeps its completed bar.
if (step != _lastStep)
{
if (_lastStep != (UpdateStep)(-1))
Console.WriteLine();
_lastStep = step;
}
var label = Humanize(step);
if (progressPercentage is { } percent)
{
var pct = (int)Math.Clamp(Math.Round(percent), 0, 100);
var filled = pct * BarWidth / 100;
var bar = new string('█', filled) + new string('░', BarWidth - filled); // █ / ░
Console.Write($"\r {label,-13} [{bar}] {pct,3}% ");
}
else
{
// Steps with no measurable total (verifying, restarting): show an indeterminate marker.
Console.Write($"\r {label,-13} working... ");
}
}
private static string Humanize(UpdateStep step) => step switch
{
UpdateStep.Downloading => "Downloading",
UpdateStep.VerifyingChecksum => "Verifying",
UpdateStep.Extracting => "Extracting",
UpdateStep.CleaningUp => "Cleaning up",
UpdateStep.Restarting => "Restarting",
_ => step.ToString(),
};
private void OnUpdateStarted(string version)
{
Log.Information("Update started: v{Version}", version);
}
private void OnNoUpdateAvailable()
{
Log.Debug("No update available");
if (_manualCheck)
{
_app.Invoke(() =>
{
MessageBox.Query(_app, "Check for Updates", $"You are already on the latest version (v{CurrentVersion}).", "OK");
});
}
}
private void OnException(Exception exception)
{
Log.Error(exception, "Update failed");
// Headless failure (post-shutdown): report and offer rollback on the console.
if (_applying)
{
Console.Error.WriteLine();
Console.Error.WriteLine($"Update failed: {exception.Message}");
if (UpdateBackupService.BackupExists())
{
Console.WriteLine("Restoring the previous version...");
try
{
UpdateBackupService.RestoreBackup(); // calls Environment.Exit(0)
}
catch (Exception restoreEx)
{
Log.Error(restoreEx, "Backup restoration failed");
Console.Error.WriteLine($"Restore failed: {restoreEx.Message}. Re-download EchoHub to recover.");
Environment.Exit(1);
}
}
else
{
Console.Error.WriteLine("No backup available. Re-download EchoHub if it no longer starts.");
Environment.Exit(1);
}
return;
}
// Failure during a check while the TUI is still running.
_app.Invoke(() =>
{
MessageBox.ErrorQuery(_app, "Update Check Failed",
$"Could not check for updates: {exception.Message}", "OK");
});
}
public void Dispose()
{
_updater.UpdateAvailable -= OnUpdateAvailable;
_updater.ProgressChanged -= OnProgressChanged;
_updater.UpdateStarted -= OnUpdateStarted;
_updater.NoUpdateAvailable -= OnNoUpdateAvailable;
_updater.OnException -= OnException;
_updater.Dispose();
}
}
@@ -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;
}
}
+8
View File
@@ -7,6 +7,14 @@ public class Theme
public ThemeColors Menu { get; set; } = new();
public ThemeColors Dialog { get; set; } = new();
public ThemeColors Status { get; set; } = new();
/// <summary>
/// Colors for the main-window frame borders (and their titles). Null falls back
/// to <see cref="Base"/>. Lets themes tone borders down independently of text —
/// e.g. the transparent themes use a dim gray for a subtler, glassy look.
/// Supports hex values ("#6E6E6E") as well as named colors.
/// </summary>
public ThemeColors? Border { get; set; }
}
public class ThemeColors
+67 -8
View File
@@ -418,30 +418,79 @@ 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"
},
// Dim, grayish borders — bright white frames fight the glassy transparent look
Border = new ThemeColors
{
Foreground = "#6E6E6E",
Background = "None",
FocusForeground = "#8A8A8A",
FocusBackground = "None"
}
};
private static readonly Theme TransparentLightTheme = new()
{
Name = "TransparentLight",
Base = new ThemeColors
{
Foreground = "Black",
Background = "None",
FocusForeground = "Blue",
FocusBackground = "None"
},
Menu = new ThemeColors
{
Foreground = "Black",
Background = "None",
FocusForeground = "Blue",
FocusBackground = "None"
},
Dialog = new ThemeColors
{
Foreground = "Black",
Background = "Gray",
FocusForeground = "Blue",
FocusBackground = "White"
},
Status = new ThemeColors
{
Foreground = "DarkGray",
Background = "None",
FocusForeground = "DarkGray",
FocusBackground = "None"
},
// Softer gray borders against light terminal backgrounds
Border = new ThemeColors
{
Foreground = "#8F8F8F",
Background = "None",
FocusForeground = "#6E6E6E",
FocusBackground = "None"
}
};
@@ -449,6 +498,7 @@ public static class ThemeManager
[
DefaultTheme,
TransparentTheme,
TransparentLightTheme,
ClassicTheme,
LightTheme,
HackerTheme,
@@ -512,6 +562,8 @@ public static class ThemeManager
SchemeManager.AddScheme("Base", BuildColorScheme(theme.Base));
SchemeManager.AddScheme("Menu", BuildColorScheme(theme.Menu));
SchemeManager.AddScheme("Dialog", BuildColorScheme(theme.Dialog));
// Frame borders/titles; themes without an explicit Border section keep Base
SchemeManager.AddScheme("Border", BuildColorScheme(theme.Border ?? theme.Base));
}
public static void SaveTheme(Theme theme)
@@ -541,7 +593,14 @@ public static class ThemeManager
Focus = focus,
HotNormal = normal,
HotFocus = focus,
Disabled = normal
Disabled = normal,
// TextView/TextField draw their editable area with the Editable/ReadOnly roles. If
// left unset, Terminal.Gui derives an opaque background from Normal — which renders
// as a solid box behind the input under transparent themes. Pin them to the theme's
// own colors so the input matches its background (transparent stays transparent).
Editable = normal,
ReadOnly = normal
};
}
+91
View File
@@ -0,0 +1,91 @@
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 ChannelRefAttr = new(new Color(100, 200, 255), 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>The dim vertical rail (│) separating the nick column from message text.</summary>
public static readonly Attribute RailAttr = new(new Color(95, 95, 95), Color.None);
/// <summary>Horizontal date-separator rules (── Wed, Jul 16 ──…).</summary>
public static readonly Attribute DateRuleAttr = new(new Color(120, 120, 120), Color.None);
/// <summary>The irssi-style "new messages" unread marker rule.</summary>
public static readonly Attribute UnreadMarkerAttr = new(new Color(230, 140, 60), Color.None);
/// <summary>
/// Split text around @mentions and #channels, giving each the appropriate accent color.
/// Non-special 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;
}
// Add remaining text after the last mention (or all text if no mentions found)
if (lastIndex < text.Length)
segments.Add(new ChatSegment(text[lastIndex..], defaultColor));
// Second pass: highlight #channels in non-mention segments
var mentionSegments = segments;
segments = [];
foreach (var seg in mentionSegments)
{
if (seg.Color != null && seg.Color != defaultColor)
{
// Already colored (mention) — keep as-is
segments.Add(seg);
continue;
}
int segLast = 0;
foreach (Match match in ChannelRefRegex().Matches(seg.Text))
{
if (match.Index > segLast)
segments.Add(new ChatSegment(seg.Text[segLast..match.Index], defaultColor));
segments.Add(new ChatSegment(match.Value, ChannelRefAttr));
segLast = match.Index + match.Length;
}
if (segLast < seg.Text.Length)
segments.Add(new ChatSegment(seg.Text[segLast..], defaultColor));
}
return segments;
}
// @mention — not preceded by a word char (avoids emails)
[GeneratedRegex(@"(?<!\w)@[\w-]+")]
private static partial Regex MentionRegex();
// #channel — not preceded by a word char, must contain at least one letter (avoids hex colors / issue numbers)
[GeneratedRegex(@"(?<!\w)#(?=.*[a-zA-Z])[\w-]+")]
private static partial Regex ChannelRefRegex();
}
+269
View File
@@ -0,0 +1,269 @@
using System.Text;
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>An action a click on an attachment line can trigger.</summary>
public enum AttachmentAction
{
OpenImage,
SaveImage,
}
/// <summary>Inclusive column range on a chat line that triggers an attachment action when clicked.</summary>
public readonly record struct AttachmentActionSpan(int StartCol, int EndCol, AttachmentAction Action);
/// <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 AttachmentKind? AttachmentKind { get; set; }
public string? SenderUsername { get; set; }
/// <summary>
/// Set on a reply's quote line: activating the line jumps to this message
/// if it is in the loaded history.
/// </summary>
public Guid? JumpToMessageId { get; set; }
/// <summary>
/// Clickable sub-line targets (e.g. the "[open]" and "[save original]" brackets under an
/// image). Columns are relative to the unwrapped line, so only the first wrapped line
/// keeps them. Null means the whole line uses the kind's default action.
/// </summary>
public List<AttachmentActionSpan>? ActionSpans { get; set; }
/// <summary>Number of spaces to prepend on continuation lines when this line is word-wrapped.</summary>
public int ContinuationIndent { get; set; }
/// <summary>
/// Colored segments to prepend on continuation lines instead of plain spaces
/// (e.g. the nick-column rail " │ "). When set, takes
/// precedence over <see cref="ContinuationIndent"/>.
/// </summary>
public List<ChatSegment>? ContinuationPrefixSegments { get; set; }
/// <summary>
/// When set, this line is a horizontal separator rule (date change, unread marker).
/// The view regenerates it to the current viewport width instead of word-wrapping.
/// </summary>
public string? RuleLabel { get; set; }
/// <summary>Color for a rule line; null falls back to <see cref="ChatColors.DateRuleAttr"/>.</summary>
public Attribute? RuleAttr { get; set; }
/// <summary>True for the "new messages" unread-marker rule so it can be removed on channel switch.</summary>
public bool IsUnreadMarker { 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)
{
// Rules are regenerated to viewport width by the view; never word-wrap them.
if (RuleLabel is not null || width <= 0 || TextLength <= width)
return [this];
var prefixSegments = ContinuationPrefixSegments;
if (prefixSegments is not null)
continuationIndent = prefixSegments.Sum(s => s.Text.GetColumns());
var tokens = new List<(string grapheme, Attribute? color)>();
foreach (var segment in Segments)
foreach (var g in GraphemeHelper.GetGraphemes(segment.Text))
tokens.Add((g, segment.Color));
var results = new List<ChatLine>();
int pos = 0;
bool firstLine = true;
while (pos < tokens.Count)
{
// First line uses the full width
int col = firstLine ? 0 : continuationIndent;
int lastSpaceIdx = -1;
int i = pos;
for (; i < tokens.Count; i++)
{
var graphemeCols = Math.Max(tokens[i].grapheme.GetColumns(), 1);
if (col + graphemeCols > width) break;
if (tokens[i].grapheme == " ") lastSpaceIdx = i;
col += graphemeCols;
}
int lineEnd, nextPos;
if (i == tokens.Count)
{
// All remaining tokens fit on this line.
lineEnd = tokens.Count;
nextPos = tokens.Count;
}
else if (lastSpaceIdx >= pos)
{
// Break at the last space that fit, skip the space itself.
lineEnd = lastSpaceIdx;
nextPos = lastSpaceIdx + 1;
}
else
{
// No space found, break word.
lineEnd = Math.Max(i, pos + 1);
nextPos = lineEnd;
}
var segments = new List<ChatSegment>();
if (!firstLine)
{
if (prefixSegments is not null)
segments.AddRange(prefixSegments);
else if (continuationIndent > 0)
segments.Add(new ChatSegment(new string(' ', continuationIndent), null));
}
// Rebuild segments by grouping consecutive same-color tokens.
var sb = new StringBuilder();
int groupStart = pos;
while (groupStart < lineEnd)
{
var color = tokens[groupStart].color;
sb.Clear();
int groupEnd = groupStart;
while (groupEnd < lineEnd && tokens[groupEnd].color == color)
{
sb.Append(tokens[groupEnd].grapheme);
groupEnd++;
}
segments.Add(new ChatSegment(sb.ToString(), color));
groupStart = groupEnd;
}
results.Add(new ChatLine(segments));
pos = nextPos;
firstLine = false;
}
if (results.Count == 0)
return [this];
// Propagate metadata to all wrapped lines so they remain clickable
// and keep the mention highlight across continuation lines
foreach (var wrapped in results)
{
wrapped.AttachmentUrl = AttachmentUrl;
wrapped.AttachmentFileName = AttachmentFileName;
wrapped.AttachmentKind = AttachmentKind;
wrapped.MessageId = MessageId;
wrapped.SenderUsername = SenderUsername;
wrapped.IsMention = IsMention;
wrapped.JumpToMessageId = JumpToMessageId;
}
// Span columns only line up with the first wrapped line; later lines fall
// back to the kind's default action.
results[0].ActionSpans = ActionSpans;
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,129 @@
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);
// Highlight the selected row (whole width) while the list has focus — used by the
// F6 selection flow and right-click. The custom source must draw this itself.
var focusAttr = selected && listView.HasFocus
? listView.GetAttributeForRole(VisualRole.Focus)
: (Attribute?)null;
var mentionBg = chatLine.IsMention ? ChatColors.MentionHighlightAttr.Background : (Color?)null;
int charPos = 0;
int drawnChars = 0;
foreach (var segment in chatLine.Segments)
{
Attribute attr;
if (focusAttr is { } focus)
{
attr = focus;
}
else
{
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 = focusAttr
?? (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,817 @@
using System.Text;
using System.Text.RegularExpressions;
using EchoHub.Client.UI.Helpers;
using EchoHub.Core.Constants;
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
{
/// <summary>Columns reserved for the right-aligned nick column (WeeChat-style).</summary>
public const int NickColWidth = 12;
/// <summary>Columns before message text starts: "HH:mm " + nick column + " │ ".</summary>
public const int ContentIndentCols = 6 + NickColWidth + 3;
private readonly Dictionary<string, List<ChatLine>> _channelMessages = [];
private readonly Dictionary<string, int> _channelUnread = [];
private readonly Dictionary<string, DateTime> _channelLastDate = [];
private readonly HashSet<string> _markedChannels = [];
private readonly Dictionary<string, Guid> _markerAnchor = [];
private readonly HashSet<string> _mentionChannels = [];
private readonly Dictionary<string, Guid> _lastRead = [];
private readonly Dictionary<string, Guid> _channelNewestId = [];
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
{
if (_currentChannel == value)
return;
// Leaving a channel consumes its "new messages" marker so the next
// unread burst gets a fresh one (irssi behavior), and everything
// visible up to now counts as read.
RemoveUnreadMarker(_currentChannel);
MarkRead(_currentChannel);
_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;
_mentionChannels.Remove(channelName);
MarkRead(channelName);
}
/// <summary>
/// Last message the user has read per channel — persisted by the orchestrator so
/// unread/mention state can be seeded from history on the next connect.
/// </summary>
public IReadOnlyDictionary<string, Guid> LastReadIds => _lastRead;
private void MarkRead(string channelName)
{
if (!string.IsNullOrEmpty(channelName) && _channelNewestId.TryGetValue(channelName, out var newest))
_lastRead[channelName] = newest;
}
internal Dictionary<string, int> GetUnreadCounts() => _channelUnread;
/// <summary>Channels with an unread @mention of the current user (cleared by <see cref="ClearUnread"/>).</summary>
public IReadOnlySet<string> MentionChannels => _mentionChannels;
// ── 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;
}
// Day boundary → horizontal date rule
var msgDate = message.SentAt.ToLocalTime().Date;
if (!_channelLastDate.TryGetValue(message.ChannelName, out var lastDate) || lastDate != msgDate)
{
messages.Add(DateRule(msgDate));
_channelLastDate[message.ChannelName] = msgDate;
}
var isCurrent = message.ChannelName == _currentChannel;
_channelNewestId[message.ChannelName] = message.Id;
if (isCurrent)
_lastRead[message.ChannelName] = message.Id;
// First unread message in an inactive channel → "new messages" marker,
// anchored to this message so a history reload can re-place it
if (!isCurrent && _markedChannels.Add(message.ChannelName))
{
messages.Add(UnreadMarkerRule());
_markerAnchor[message.ChannelName] = message.Id;
}
foreach (var line in lines)
messages.Add(line);
if (!isCurrent)
{
_channelUnread.TryGetValue(message.ChannelName, out var count);
_channelUnread[message.ChannelName] = count + 1;
if (lines.Any(l => l.IsMention))
_mentionChannels.Add(message.ChannelName);
}
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 = FormatTime(DateTimeOffset.Now);
var textLines = text.Split('\n');
var header = SystemHeaderSegments(time);
header.Add(new(textLines[0].TrimEnd('\r'), ChatColors.SystemAttr));
messages.Add(new ChatLine(header) { ContinuationPrefixSegments = RailPrefix() });
for (int i = 1; i < textLines.Length; i++)
{
var line = textLines[i].TrimEnd('\r');
if (string.IsNullOrWhiteSpace(line)) continue;
var segments = RailPrefix();
segments.Add(new(line, ChatColors.SystemAttr));
messages.Add(new ChatLine(segments) { ContinuationPrefixSegments = RailPrefix() });
}
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 = FormatTime(DateTimeOffset.Now);
var segments = SystemHeaderSegments(time);
segments.Add(new($"{username} is now {status}", ChatColors.SystemAttr));
if (!_channelMessages.TryGetValue(channelName, out var messages))
{
messages = [];
_channelMessages[channelName] = messages;
}
messages.Add(new ChatLine(segments) { ContinuationPrefixSegments = RailPrefix() });
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();
_channelLastDate.Remove(channelName);
_markedChannels.Remove(channelName);
_markerAnchor.Remove(channelName);
if (channelName == _currentChannel)
MessagesChanged?.Invoke(channelName);
}
}
/// <summary>
/// Load historical messages into a channel, replacing any existing messages.
/// When <paramref name="lastReadId"/> is given (persisted from a previous session),
/// messages after it seed the unread count, @mention highlight, and the
/// "new messages" marker — so activity that happened while offline still lights up.
/// </summary>
public void LoadHistory(string channelName, List<MessageDto> messages, Guid? lastReadId = null)
{
var formatted = FormatWithDateRules(messages, out var lastDate);
if (messages.Count > 0)
_channelNewestId[channelName] = messages[^1].Id;
// Re-place the "new messages" marker at its anchor — channel selection
// reloads history, which would otherwise wipe the marker right when the
// user switches in to read the unread backlog.
if (_markedChannels.Contains(channelName))
{
var anchorIdx = _markerAnchor.TryGetValue(channelName, out var anchorId)
? formatted.FindIndex(l => l.MessageId == anchorId)
: -1;
if (anchorIdx >= 0)
{
formatted.Insert(anchorIdx, UnreadMarkerRule());
}
else
{
// Anchor fell outside the fetched history window — drop the marker
_markedChannels.Remove(channelName);
_markerAnchor.Remove(channelName);
}
}
else if (lastReadId is { } lastRead && messages.Count > 0)
{
SeedUnreadFromHistory(channelName, messages, formatted, lastRead);
}
_channelMessages[channelName] = formatted;
if (lastDate is { } date)
_channelLastDate[channelName] = date;
else
_channelLastDate.Remove(channelName);
MessagesChanged?.Invoke(channelName);
}
/// <summary>
/// Reconstructs unread state from a persisted last-read message id: places the
/// "new messages" marker before the first unread message and, for inactive
/// channels, seeds the unread count and @mention highlight. A last-read id that
/// is no longer inside the fetched window treats the whole window as unread.
/// </summary>
private void SeedUnreadFromHistory(string channelName, List<MessageDto> messages,
List<ChatLine> formatted, Guid lastReadId)
{
// FindIndex miss (-1 → 0) means the last-read message is older than the fetched
// window: everything in the window counts as unread.
var firstUnread = messages.FindIndex(m => m.Id == lastReadId) + 1;
if (firstUnread >= messages.Count)
return; // everything read
var anchor = messages[firstUnread];
var lineIdx = formatted.FindIndex(l => l.MessageId == anchor.Id);
if (lineIdx < 0)
return;
formatted.Insert(lineIdx, UnreadMarkerRule());
_markedChannels.Add(channelName);
_markerAnchor[channelName] = anchor.Id;
// The active channel shows the marker but is being read right now —
// badges and mention highlights are only for background channels.
if (channelName == _currentChannel)
return;
_channelUnread[channelName] = messages.Count - firstUnread;
if (!string.IsNullOrEmpty(_currentUser))
{
var pattern = $@"@{Regex.Escape(_currentUser)}\b";
if (messages.Skip(firstUnread).Any(m =>
Regex.IsMatch(m.Content, pattern, RegexOptions.IgnoreCase)
|| (m.ReplyTo is { } reply && reply.SenderUsername.Equals(_currentUser, StringComparison.OrdinalIgnoreCase))))
_mentionChannels.Add(channelName);
}
}
/// <summary>
/// Prepend older messages at the front of a channel's buffer, skipping any that are already present.
/// Fires <see cref="HistoryPrepended"/> when new lines are actually inserted.
/// </summary>
public void PrependHistory(string channelName, List<MessageDto> olderMessages)
{
if (!_channelMessages.TryGetValue(channelName, out var existing))
return;
var existingIds = existing
.Where(l => l.MessageId.HasValue)
.Select(l => l.MessageId!.Value)
.ToHashSet();
var fresh = olderMessages.Where(m => !existingIds.Contains(m.Id)).ToList();
var newLines = FormatWithDateRules(fresh, out var lastBatchDate);
if (newLines.Count == 0)
return;
// The buffer's leading date rule is redundant when the prepended batch
// ends on the same day — the batch already carries that day's rule.
if (lastBatchDate is { } batchDate
&& existing.Count > 0
&& existing[0].RuleLabel is { } label
&& !existing[0].IsUnreadMarker
&& label == DateRuleLabel(batchDate))
{
existing.RemoveAt(0);
}
existing.InsertRange(0, newLines);
if (channelName == _currentChannel)
HistoryPrepended?.Invoke(channelName);
}
/// <summary>
/// Fired after older messages are prepended to a channel's buffer. Parameter is the channel name.
/// </summary>
public event Action<string>? HistoryPrepended;
/// <summary>
/// Reset all message state (used on disconnect).
/// </summary>
public void ClearAll()
{
_channelMessages.Clear();
_channelUnread.Clear();
_channelLastDate.Clear();
_markedChannels.Clear();
_markerAnchor.Clear();
_mentionChannels.Clear();
_lastRead.Clear();
_channelNewestId.Clear();
_currentChannel = string.Empty;
_currentUser = string.Empty;
}
// ── Formatting ───────────────────────────────────────────────────
private List<ChatLine> FormatMessage(MessageDto message)
{
var time = FormatTime(message.SentAt);
// Show the display name, but keep color + click identity keyed to the username
// so they stay consistent with the user list and profile lookups.
var senderName = message.SenderDisplayName ?? message.SenderUsername;
var senderColor = HexColorHelper.ParseHexColor(message.SenderNicknameColor)
?? NickColorHelper.GetAttribute(message.SenderUsername);
var lines = new List<ChatLine>();
var hasContent = !string.IsNullOrWhiteSpace(message.Content);
var attachments = message.Attachments ?? [];
// Reply → dim quote line above the message; activating it jumps to the original
if (message.ReplyTo is { } replyTo)
lines.Add(ReplyQuoteLine(replyTo));
// /me action → "* nick waves" (CTCP ACTION content)
string? actionText = null;
var isAction = hasContent && MessageConventions.TryParseAction(message.Content, out actionText);
if (isAction)
{
var actionLines = EmojiHelper.ReplaceEmoji(actionText!).Split('\n');
var header = ActionHeaderSegments(time);
header.Add(new(senderName, senderColor));
header.Add(new(" ", null));
header.AddRange(ChatColors.SplitMentions(actionLines[0].TrimEnd('\r')));
lines.Add(new ChatLine(header));
for (int i = 1; i < actionLines.Length; i++)
{
var segments = RailPrefix();
segments.AddRange(ChatColors.SplitMentions(actionLines[i].TrimEnd('\r')));
lines.Add(new ChatLine(segments));
}
}
// Header line: caption text, or a summary when the message is attachments-only
if (isAction)
{
// already rendered above
}
else if (hasContent)
{
var displayContent = EmojiHelper.ReplaceEmoji(message.Content);
var contentLines = displayContent.Split('\n');
var header = HeaderSegments(time, senderName, senderColor);
header.AddRange(ChatColors.SplitMentions(contentLines[0].TrimEnd('\r')));
lines.Add(new ChatLine(header));
for (int i = 1; i < contentLines.Length; i++)
{
var segments = RailPrefix();
segments.AddRange(ChatColors.SplitMentions(contentLines[i].TrimEnd('\r')));
lines.Add(new ChatLine(segments));
}
}
else
{
var summary = attachments.Count switch
{
0 => " ",
1 => $"[{attachments[0].Kind.ToString().ToLowerInvariant()}]",
_ => $"[{attachments.Count} attachments]",
};
var header = HeaderSegments(time, senderName, senderColor);
header.Add(new(summary, null));
lines.Add(new ChatLine(header));
}
foreach (var l in lines)
l.ContinuationPrefixSegments = RailPrefix();
// One block per attachment — every block hangs off the nick-column rail
foreach (var attachment in attachments)
{
switch (attachment.Kind)
{
case Core.Models.AttachmentKind.Image:
if (!string.IsNullOrWhiteSpace(attachment.AsciiPreview))
{
foreach (var artLine in attachment.AsciiPreview.Split('\n'))
{
var trimmed = artLine.TrimEnd('\r');
var segments = RailPrefix();
if (ChatLine.HasColorTags(trimmed))
segments.AddRange(ChatLine.FromColoredText(trimmed).Segments);
else
segments.Add(new(trimmed, null));
lines.Add(new ChatLine(segments));
}
}
lines.Add(ImageActionLine(attachment));
break;
case Core.Models.AttachmentKind.Audio:
lines.Add(AttachmentActionLine(
$"♪ [Audio: {attachment.FileName}] [{FormatFileSize(attachment.FileSize)}]",
ChatColors.AudioAttr, attachment));
break;
default:
lines.Add(AttachmentActionLine(
$"[File: {attachment.FileName}] [{FormatFileSize(attachment.FileSize)}]",
ChatColors.FileAttr, attachment));
break;
}
}
// Link embeds (from caption URLs)
if (message.Embeds is { Count: > 0 })
{
var chatWidth = _chatWidth > 0 ? _chatWidth : 80;
foreach (var embed in message.Embeds)
lines.AddRange(FormatEmbed(embed, chatWidth));
}
foreach (var line in lines)
{
line.MessageId = message.Id;
line.SenderUsername = message.SenderUsername;
}
if (!string.IsNullOrEmpty(_currentUser))
{
// Being replied to counts as a mention, same as an explicit @nick
var isReplyToMe = message.ReplyTo is { } reply
&& reply.SenderUsername.Equals(_currentUser, StringComparison.OrdinalIgnoreCase);
var pattern = $@"@{Regex.Escape(_currentUser)}\b";
if (isReplyToMe
|| ((hasContent || isAction) && Regex.IsMatch(message.Content, pattern, RegexOptions.IgnoreCase)))
{
foreach (var line in lines)
line.IsMention = true;
}
}
return lines;
}
/// <summary>
/// Builds the action line below an image preview: "[open] [↓ save original] name [size]".
/// Each bracket is an <see cref="AttachmentActionSpan"/> so a mouse click can target it;
/// keyboard activation (Enter) uses the default action, open.
/// </summary>
private static ChatLine ImageActionLine(AttachmentDto attachment)
{
var segments = RailPrefix();
var col = segments.Sum(s => s.Text.GetColumns());
var spans = new List<AttachmentActionSpan>();
void AddAction(string text, AttachmentAction action)
{
var width = text.GetColumns();
spans.Add(new AttachmentActionSpan(col, col + width - 1, action));
segments.Add(new(text, ChatColors.FileAttr));
col += width;
}
AddAction("[open]", AttachmentAction.OpenImage);
segments.Add(new(" ", null));
col += 1;
AddAction("[↓ save original]", AttachmentAction.SaveImage);
segments.Add(new($" {attachment.FileName} [{FormatFileSize(attachment.FileSize)}]", ChatColors.FileAttr));
return new ChatLine(segments)
{
AttachmentUrl = attachment.Url,
AttachmentFileName = attachment.FileName,
AttachmentKind = attachment.Kind,
ActionSpans = spans,
ContinuationPrefixSegments = RailPrefix(),
};
}
/// <summary>
/// Builds a clickable attachment line carrying the metadata the message list uses to
/// route activation (play audio, download file, save original image).
/// </summary>
private static ChatLine AttachmentActionLine(string text, Attribute color, AttachmentDto attachment)
{
var segments = RailPrefix();
segments.Add(new(text, color));
return new ChatLine(segments)
{
AttachmentUrl = attachment.Url,
AttachmentFileName = attachment.FileName,
AttachmentKind = attachment.Kind,
ContinuationPrefixSegments = RailPrefix(),
};
}
/// <summary>
/// Leading segments of a message header line: dim "HH:mm ", the right-aligned
/// nick column, and the " │ " rail. Message text follows at <see cref="ContentIndentCols"/>.
/// </summary>
private static List<ChatSegment> HeaderSegments(string time, string nick, Attribute? nickColor) =>
[
new($"{time} ", ChatColors.TimestampAttr),
new(PadNick(nick), nickColor),
new(" │ ", ChatColors.RailAttr),
];
/// <summary>Header variant for system/status lines: "--" in the nick column.</summary>
private static List<ChatSegment> SystemHeaderSegments(string time) =>
[
new($"{time} ", ChatColors.TimestampAttr),
new(PadNick("--"), ChatColors.TimestampAttr),
new(" │ ", ChatColors.RailAttr),
];
/// <summary>Header variant for /me actions: "*" in the nick column, "* nick text" content.</summary>
private static List<ChatSegment> ActionHeaderSegments(string time) =>
[
new($"{time} ", ChatColors.TimestampAttr),
new(PadNick("*"), ChatColors.TimestampAttr),
new(" │ ", ChatColors.RailAttr),
];
/// <summary>
/// The dim "┌ nick: snippet" line above a reply. Carries the original message id so
/// activating it jumps there. Room-encrypted snippets arrive already decrypted (or as
/// the locked placeholder) — this only truncates for display.
/// </summary>
private static ChatLine ReplyQuoteLine(ReplyRefDto replyTo)
{
const int maxSnippetCols = 60;
var snippet = replyTo.Content.Replace('\n', ' ').Replace('\r', ' ');
if (MessageConventions.TryParseAction(snippet, out var actionText))
snippet = $"* {replyTo.SenderUsername} {actionText}";
snippet = EmojiHelper.ReplaceEmoji(snippet);
if (snippet.GetColumns() > maxSnippetCols)
{
var sb = new StringBuilder();
int used = 0;
foreach (var g in GraphemeHelper.GetGraphemes(snippet))
{
var gCols = Math.Max(g.GetColumns(), 1);
if (used + gCols > maxSnippetCols - 1) break;
sb.Append(g);
used += gCols;
}
snippet = sb.Append('…').ToString();
}
var segments = RailPrefix();
segments.Add(new("┌ ", ChatColors.RailAttr));
segments.Add(new($"{replyTo.SenderUsername}: ", NickColorHelper.GetAttribute(replyTo.SenderUsername)));
segments.Add(new(snippet, ChatColors.SystemAttr));
return new ChatLine(segments)
{
JumpToMessageId = replyTo.MessageId,
ContinuationPrefixSegments = RailPrefix(),
};
}
/// <summary>
/// Indent segments aligning continuation/attachment/embed lines under the message
/// text, extending the │ rail. Returns a fresh mutable list each call.
/// </summary>
private static List<ChatSegment> RailPrefix() =>
[
new(new string(' ', 6 + NickColWidth + 1), null),
new("│ ", ChatColors.RailAttr),
];
/// <summary>
/// Right-aligns a nick into the fixed nick column, truncating over-long nicks
/// with an ellipsis. Grapheme/column aware.
/// </summary>
internal static string PadNick(string nick)
{
var cols = nick.GetColumns();
if (cols > NickColWidth)
{
var sb = new StringBuilder();
int used = 0;
foreach (var g in GraphemeHelper.GetGraphemes(nick))
{
var gCols = Math.Max(g.GetColumns(), 1);
if (used + gCols > NickColWidth - 1) break;
sb.Append(g);
used += gCols;
}
sb.Append('…');
nick = sb.ToString();
cols = used + 1;
}
return new string(' ', NickColWidth - cols) + nick;
}
internal static string DateRuleLabel(DateTime date) => date.ToString("ddd, MMM d yyyy");
private static ChatLine DateRule(DateTime date)
{
var label = DateRuleLabel(date);
return new ChatLine([new($"── {label} ──", ChatColors.DateRuleAttr)])
{
RuleLabel = label,
RuleAttr = ChatColors.DateRuleAttr,
};
}
private static ChatLine UnreadMarkerRule() =>
new([new("── new messages ──", ChatColors.UnreadMarkerAttr)])
{
RuleLabel = "new messages",
RuleAttr = ChatColors.UnreadMarkerAttr,
IsUnreadMarker = true,
};
private void RemoveUnreadMarker(string channel)
{
if (string.IsNullOrEmpty(channel) || !_markedChannels.Remove(channel))
return;
_markerAnchor.Remove(channel);
if (_channelMessages.TryGetValue(channel, out var messages))
messages.RemoveAll(l => l.IsUnreadMarker);
}
/// <summary>
/// Formats a chronological batch of messages, inserting a date rule before the
/// first message and at every day boundary. Outputs the batch's last local date.
/// </summary>
private List<ChatLine> FormatWithDateRules(List<MessageDto> messages, out DateTime? lastDate)
{
var lines = new List<ChatLine>();
lastDate = null;
foreach (var message in messages)
{
var date = message.SentAt.ToLocalTime().Date;
if (lastDate != date)
{
lines.Add(DateRule(date));
lastDate = date;
}
lines.AddRange(FormatMessage(message));
}
return lines;
}
private static List<ChatLine> FormatEmbed(EmbedDto embed, int chatWidth)
{
var lines = new List<ChatLine>();
const string border = "\u258f "; // ▏ + space
const int borderCols = 2;
int textWidth = chatWidth - ContentIndentCols - borderCols;
if (textWidth < 20) textWidth = 20;
var borderAttr = HexColorHelper.ParseHexColor(embed.ThemeColor) ?? ChatColors.EmbedBorderAttr;
void AddTextLine(string text, Attribute? color)
{
var segments = RailPrefix();
segments.Add(new ChatSegment(border, borderAttr));
segments.Add(new ChatSegment(text, color));
lines.Add(new ChatLine(segments));
}
if (!string.IsNullOrWhiteSpace(embed.SiteName))
AddTextLine(embed.SiteName, borderAttr);
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;
}
// Timestamps are compact HH:mm — the calendar day is carried by date rules,
// inserted at every local-day boundary. Convert to local first so a message
// near midnight lands under the right date rule.
private static string FormatTime(DateTimeOffset timestamp) =>
timestamp.ToLocalTime().ToString("HH:mm");
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);

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