Compare commits

44 Commits
Author SHA1 Message Date
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
43 changed files with 825 additions and 315 deletions
+49 -19
View File
@@ -12,6 +12,11 @@ jobs:
release: release:
name: Create Release name: Create Release
runs-on: ubuntu-latest 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: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
@@ -30,7 +35,6 @@ jobs:
fi fi
- name: Read version - name: Read version
if: steps.changes.outputs.src_changed == 'true'
id: version id: version
run: | run: |
VERSION=$(grep -oP '(?<=<Version>)[^<]+' src/Directory.Build.props) VERSION=$(grep -oP '(?<=<Version>)[^<]+' src/Directory.Build.props)
@@ -38,7 +42,6 @@ jobs:
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT" echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
- name: Check if release exists - name: Check if release exists
if: steps.changes.outputs.src_changed == 'true'
id: check_release id: check_release
run: | run: |
if gh release view "${{ steps.version.outputs.tag }}" &>/dev/null; then if gh release view "${{ steps.version.outputs.tag }}" &>/dev/null; then
@@ -53,7 +56,7 @@ jobs:
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false' if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
with: with:
dotnet-version: '10.0.x' dotnet-version: "10.0.x"
- name: Publish Server win-x64 - name: Publish Server win-x64
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false' if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
@@ -77,23 +80,23 @@ jobs:
- name: Publish Client win-x64 - name: Publish Client win-x64
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false' if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
run: 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 - name: Publish Client linux-x64
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false' if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
run: 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 - name: Publish Client osx-x64
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false' if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
run: 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 - name: Publish Client osx-arm64
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false' if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
run: 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 - name: Publish Client linux-arm64
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false' if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r linux-arm64 --self-contained true -o publish/client-linux-arm64 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 - name: Zip artifacts
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false' if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
@@ -158,21 +161,48 @@ jobs:
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Publish Chocolatey package choco:
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false' 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: | run: |
VERSION="${{ steps.version.outputs.version }}" $version = "${{ needs.release.outputs.version }}"
CHECKSUM=$(sha256sum EchoHub-Client-win-x64.zip | awk '{print $1}') $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 # Stamp version and checksum into package templates
sed -i "s/__VERSION__/$VERSION/g" packaging/choco/echohub.nuspec (Get-Content packaging/choco/echohub.nuspec) -replace '__VERSION__', $version | Set-Content packaging/choco/echohub.nuspec
sed -i "s/__VERSION__/$VERSION/g" packaging/choco/tools/chocolateyInstall.ps1 (Get-Content packaging/choco/tools/chocolateyInstall.ps1) -replace '__VERSION__', $version | Set-Content packaging/choco/tools/chocolateyInstall.ps1
sed -i "s/__CHECKSUM64__/$CHECKSUM/g" packaging/choco/tools/chocolateyInstall.ps1 (Get-Content packaging/choco/tools/chocolateyInstall.ps1) -replace '__CHECKSUM64__', $checksum | Set-Content packaging/choco/tools/chocolateyInstall.ps1
# Build and push the package
mkdir -p /tmp/choco-out
cd packaging/choco cd packaging/choco
choco pack echohub.nuspec --output-directory /tmp/choco-out choco pack echohub.nuspec --output-directory $env:TEMP
choco push /tmp/choco-out/echohub.*.nupkg --source https://push.chocolatey.org/ --api-key "$CHOCO_API_KEY" $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: env:
CHOCO_API_KEY: ${{ secrets.CHOCOLATEY_API_KEY }} CHOCO_API_KEY: ${{ secrets.CHOCOLATEY_API_KEY }}
+37 -12
View File
@@ -27,12 +27,14 @@
</p> </p>
<p align="center"> <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=".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="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> </p>
--- ---
@@ -76,10 +78,12 @@ graph TD
### Server ### Server
- **Self-hostable** — your server, your rules, your data - **Self-hostable** — your server, your rules, your data
- **Docker ready** — `docker compose up -d` and you're done
- **Real-time messaging** via SignalR WebSockets - **Real-time messaging** via SignalR WebSockets
- **IRC gateway** — native IRC clients connect alongside TUI users, full cross-protocol messaging - **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 - **JWT auth** with short-lived access tokens and 30-day refresh tokens
- **Channels** — create, set topics, delete (no 47-step permission wizard required) - **Channels** — create, set topics, delete (no 47-step permission wizard required)
- **Moderation** — ban, mute (timed or permanent), kick, role assignment
- **File & image uploads** with actual validation (magic bytes, not just trusting the extension) - **File & image uploads** with actual validation (magic bytes, not just trusting the extension)
- **Image-to-ASCII** — because images in a terminal is objectively cool - **Image-to-ASCII** — because images in a terminal is objectively cool
- **Presence tracking** — online/away/DND/invisible with custom status messages - **Presence tracking** — online/away/DND/invisible with custom status messages
@@ -94,34 +98,55 @@ graph TD
- **13 built-in themes** — including `hacker` for when you want to feel like you're in a movie - **13 built-in themes** — including `hacker` for when you want to feel like you're in a movie
- **Slash commands** — `/join`, `/send`, `/status`, `/theme`, etc. - **Slash commands** — `/join`, `/send`, `/status`, `/theme`, etc.
- **Colored nicknames** — pick your hex color, express yourself - **Colored nicknames** — pick your hex color, express yourself
- **Clickable everything** — usernames, @mentions, #channels — just press Enter
- **File/image sharing** — local files or URLs - **File/image sharing** — local files or URLs
- **Multi-server** — save and switch between servers - **Multi-server** — save and switch between servers
- **Auto-reconnect** — drops happen, it rejoins your channels automatically - **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 - **Message history** on join — you won't miss context
## Getting Started ## 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 ```bash
dotnet run --project src/EchoHub.Server 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 1. Creates `appsettings.json` from the example config
2. Generates a secure JWT secret 2. Generates a secure JWT secret
3. Creates the database with a `#general` channel 3. Creates the database with a `#general` channel
### Run the Client ### Run the Client (from source)
```bash ```bash
dotnet run --project src/EchoHub.Client dotnet run --project src/EchoHub.Client
@@ -165,7 +190,7 @@ irssi -c your-server.com -p 6667 -w <password> -n <username>
/connect echohub /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 ### What Works
+152
View File
@@ -0,0 +1,152 @@
# 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:PublicHost` | *(empty)* | Public hostname for the directory listing (e.g. `chat.example.com:5000`) |
| `Server:Admins` | `[]` | Array of admin usernames (e.g. `["alice", "bob"]`) |
### 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: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 |
### 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
```
+5 -14
View File
@@ -24,26 +24,17 @@ services:
## Configuration ## Configuration
All settings are configured through the `.env` file. These are ASP.NET Core environment variables that override `appsettings.json`. 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 | | Variable | Default | Description |
| --- | --- | --- | | --- | --- | --- |
| `Server__Name` | My EchoHub Server | Display name for your server | | `Server__Name` | My EchoHub Server | Display name for your server |
| `Server__Description` | A self-hosted EchoHub chat server | Server description |
| `Server__PublicServer` | `false` | List on the [public directory](https://echohub.voidcube.cloud/servers) |
| `Server__PublicHost` | *(empty)* | Public address for the directory listing |
| `Server__Admins__0` | *(empty)* | Admin username (use `__1`, `__2` for more) | | `Server__Admins__0` | *(empty)* | Admin username (use `__1`, `__2` for more) |
| `Jwt__Secret` | *(auto-generated)* | JWT signing key. Auto-generated on first run |
| `Encryption__Key` | *(auto-generated)* | AES encryption key. Auto-generated on first run |
| `Encryption__EncryptDatabase` | `false` | Encrypt message content in the database |
| `Storage__CleanupIntervalHours` | `1` | How often to clean expired uploads |
| `Storage__RetentionDays` | `30` | Days to keep uploaded files |
| `Irc__Enabled` | `false` | Enable the IRC gateway | | `Irc__Enabled` | `false` | Enable the IRC gateway |
| `Irc__Port` | `6667` | IRC plain-text port |
| `Irc__TlsEnabled` | `false` | Enable IRC over TLS |
| `Irc__TlsPort` | `6697` | IRC TLS port |
| `Irc__ServerName` | `echohub` | IRC server name shown to clients |
| `Irc__Motd` | Welcome to EchoHub IRC Gateway! | Message of the day |
| `Serilog__MinimumLevel__Default` | `Information` | Log level (`Debug`, `Warning`, etc.) | | `Serilog__MinimumLevel__Default` | `Information` | Log level (`Debug`, `Warning`, etc.) |
## Persistent Data ## Persistent Data
+3 -3
View File
@@ -17,7 +17,7 @@ curl -sSfL https://raw.githubusercontent.com/HueByte/EchoHub/master/scripts/inst
To install a specific version or to a custom directory: To install a specific version or to a custom directory:
```bash ```bash
curl -sSfL .../install.sh | sh -s -- --version 0.2.8 curl -sSfL .../install.sh | sh -s -- --version 0.2.10
curl -sSfL .../install.sh | sh -s -- --install-dir /opt/echohub curl -sSfL .../install.sh | sh -s -- --install-dir /opt/echohub
``` ```
@@ -84,9 +84,9 @@ See the [Architecture](architecture.md) page for details on how the IRC gateway
## Configuration ## 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 ## Build from Source
+16 -10
View File
@@ -1,10 +1,16 @@
- name: Getting Started - name: Guides
href: getting-started.md items:
- name: Docker - name: Getting Started
href: docker.md href: getting-started.md
- name: Architecture - name: Docker
href: architecture.md href: docker.md
- name: Encryption - name: Architecture
href: encryption.md href: architecture.md
- name: Notification Sounds - name: Configuration
href: notification-sounds.md href: configuration.md
- name: Encryption
href: encryption.md
- name: Notification Sounds
href: notification-sounds.md
- name: Flows
href: ../flows/toc.yml
+2
View File
@@ -4,6 +4,8 @@ Release history for EchoHub.
## Releases ## Releases
- [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.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.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.6](v0.2.6.md) - Major Refactoring & Code Organization
+4
View File
@@ -1,5 +1,9 @@
- name: Overview - name: Overview
href: index.md href: index.md
- name: v0.2.10
href: v0.2.10.md
- name: v0.2.9
href: v0.2.9.md
- name: v0.2.8 - name: v0.2.8
href: v0.2.8.md href: v0.2.8.md
- name: v0.2.7 - name: v0.2.7
+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.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
+1 -1
View File
@@ -85,7 +85,7 @@
"_appTitle": "EchoHub Documentation", "_appTitle": "EchoHub Documentation",
"_appLogoPath": "images/hue_icon.svg", "_appLogoPath": "images/hue_icon.svg",
"_appFaviconPath": "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, "_enableSearch": true,
"_disableContribution": false, "_disableContribution": false,
"_gitContribute": { "_gitContribute": {
-37
View File
@@ -37,19 +37,6 @@ sequenceDiagram
CM->>CM: Continue to connection setup (see Connection Flow) CM->>CM: Continue to connection setup (see Connection Flow)
``` ```
**Code references:**
| Step | File | Location |
|------|------|----------|
| Dialog UI | `src/EchoHub.Client/UI/Dialogs/ConnectDialog.cs` | Lines 251-268 (register handler) |
| Orchestrator entry | `src/EchoHub.Client/AppOrchestrator.cs` | Lines 550-592 (`HandleConnect`) |
| ConnectionManager auth | `src/EchoHub.Client/Services/ConnectionManager.cs` | Lines 74-76 (register branch) |
| ApiClient register | `src/EchoHub.Client/Services/ApiClient.cs` | Lines 32-43 (`RegisterAsync`) |
| AuthController register | `src/EchoHub.Server/Controllers/AuthController.cs` | Lines 28-49 |
| UserService register | `src/EchoHub.Server/Services/UserService.cs` | Lines 20-59 (`RegisterUserAsync`) |
| JWT generation | `src/EchoHub.Server/Auth/JwtTokenService.cs` | Lines 30-53 (access), 80-86 (refresh) |
| Token persistence | `src/EchoHub.Client/AppOrchestrator.cs` | Lines 1039-1053 (`SaveServerToConfig`) |
--- ---
## User Login ## User Login
@@ -85,18 +72,6 @@ sequenceDiagram
API->>API: SetTokens() API->>API: SetTokens()
``` ```
**Code references:**
| Step | File | Location |
|------|------|----------|
| Login button handler | `src/EchoHub.Client/UI/Dialogs/ConnectDialog.cs` | Lines 214-249 |
| Saved token branch | `src/EchoHub.Client/Services/ConnectionManager.cs` | Lines 69-71 |
| Password branch | `src/EchoHub.Client/Services/ConnectionManager.cs` | Lines 78-80 |
| ApiClient login | `src/EchoHub.Client/Services/ApiClient.cs` | Lines 45-56 (`LoginAsync`) |
| AuthController login | `src/EchoHub.Server/Controllers/AuthController.cs` | Lines 51-72 |
| AuthController refresh | `src/EchoHub.Server/Controllers/AuthController.cs` | Lines 74-108 |
| UserService authenticate | `src/EchoHub.Server/Services/UserService.cs` | Lines 61-83 |
--- ---
## Token Refresh ## Token Refresh
@@ -128,15 +103,3 @@ sequenceDiagram
API-->>Config: Persist new refresh token (if Remember Me) API-->>Config: Persist new refresh token (if Remember Me)
API->>SR: Retry original request with new token API->>SR: Retry original request with new token
``` ```
**Code references:**
| Step | File | Location |
|------|------|----------|
| Proactive check | `src/EchoHub.Client/Services/ApiClient.cs` | Lines 110-129 (`GetValidTokenAsync`) |
| Reactive 401 retry (GET) | `src/EchoHub.Client/Services/ApiClient.cs` | Lines 338-358 (`AuthenticatedGetAsync`) |
| Reactive 401 retry (POST/PUT/DELETE) | `src/EchoHub.Client/Services/ApiClient.cs` | Lines 364-384 (`AuthenticatedRequestAsync`) |
| Refresh HTTP call | `src/EchoHub.Client/Services/ApiClient.cs` | Lines 58-71 (`RefreshTokenAsync`) |
| SignalR token provider | `src/EchoHub.Client/Services/EchoHubConnection.cs` | Line 37 (`AccessTokenProvider`) |
| Server-side rotation | `src/EchoHub.Server/Controllers/AuthController.cs` | Lines 74-108 |
| Token persistence callback | `src/EchoHub.Client/Services/ConnectionManager.cs` | Lines 253-264 |
-35
View File
@@ -33,14 +33,6 @@ sequenceDiagram
CC-->>Client: 201 Created (ChannelDto) CC-->>Client: 201 Created (ChannelDto)
``` ```
**Code references:**
| Step | File | Location |
|------|------|----------|
| Controller endpoint | `src/EchoHub.Server/Controllers/ChannelsController.cs` | Lines 60-76 |
| Channel service create | `src/EchoHub.Server/Services/ChannelService.cs` | Lines 50-90 |
| Broadcast updated | `src/EchoHub.Server/Services/ChatService.cs` | Lines 308-309 |
--- ---
## Channel Deletion ## Channel Deletion
@@ -65,13 +57,6 @@ sequenceDiagram
CC-->>Client: 204 No Content CC-->>Client: 204 No Content
``` ```
**Code references:**
| Step | File | Location |
|------|------|----------|
| Controller endpoint | `src/EchoHub.Server/Controllers/ChannelsController.cs` | Lines 94-106 |
| Channel service delete | `src/EchoHub.Server/Services/ChannelService.cs` | Lines 119-144 |
--- ---
## Joining a Channel ## Joining a Channel
@@ -114,17 +99,6 @@ sequenceDiagram
Entry-->>Client: History messages Entry-->>Client: History messages
``` ```
**Code references:**
| Step | File | Location |
|------|------|----------|
| SignalR hub join | `src/EchoHub.Server/Hubs/ChatHub.cs` | Lines 59-81 |
| IRC join | `src/EchoHub.Server.Irc/IrcCommandHandler.cs` | Lines 361-414 |
| ChatService join | `src/EchoHub.Server/Services/ChatService.cs` | Lines 96-135 |
| Presence join | `src/EchoHub.Server/Services/PresenceTracker.cs` | Lines 58-70 |
| SignalR broadcast | `src/EchoHub.Server/Services/SignalRBroadcaster.cs` | Lines 26-32 |
| IRC broadcast | `src/EchoHub.Server.Irc/IrcBroadcaster.cs` | Lines 34-41 |
--- ---
## Leaving a Channel ## Leaving a Channel
@@ -148,12 +122,3 @@ sequenceDiagram
CS->>IRCB: SendUserLeftAsync(channel, username) CS->>IRCB: SendUserLeftAsync(channel, username)
end end
``` ```
**Code references:**
| Step | File | Location |
|------|------|----------|
| SignalR hub leave | `src/EchoHub.Server/Hubs/ChatHub.cs` | Lines 83-96 |
| IRC part | `src/EchoHub.Server.Irc/IrcCommandHandler.cs` | Lines 416-435 |
| ChatService leave | `src/EchoHub.Server/Services/ChatService.cs` | Lines 137-143 |
| Presence leave | `src/EchoHub.Server/Services/PresenceTracker.cs` | Lines 72-81 |
-34
View File
@@ -34,19 +34,6 @@ sequenceDiagram
EHC-->>CM: Decrypted history EHC-->>CM: Decrypted history
``` ```
**Code references:**
| Step | File | Location |
|------|------|----------|
| Connection orchestration | `src/EchoHub.Client/Services/ConnectionManager.cs` | Lines 58-140 (`ConnectAsync`) |
| EchoHubConnection setup | `src/EchoHub.Client/Services/EchoHubConnection.cs` | Lines 29-62 (constructor) |
| Handler registration | `src/EchoHub.Client/Services/EchoHubConnection.cs` | Lines 64-122 (`RegisterHandlers`) |
| Hub OnConnected | `src/EchoHub.Server/Hubs/ChatHub.cs` | Lines 31-43 |
| ChatService connected | `src/EchoHub.Server/Services/ChatService.cs` | Lines 41-57 |
| PresenceTracker connect | `src/EchoHub.Server/Services/PresenceTracker.cs` | Lines 13-29 |
| Join channel (hub) | `src/EchoHub.Server/Hubs/ChatHub.cs` | Lines 59-81 |
| Join channel (service) | `src/EchoHub.Server/Services/ChatService.cs` | Lines 96-135 |
--- ---
## IRC Client Connection ## IRC Client Connection
@@ -96,18 +83,6 @@ sequenceDiagram
Note over IRC,CH: Client is now ready for JOIN/PART/PRIVMSG Note over IRC,CH: Client is now ready for JOIN/PART/PRIVMSG
``` ```
**Code references:**
| Step | File | Location |
|------|------|----------|
| TCP listener | `src/EchoHub.Server.Irc/IrcGatewayService.cs` | Lines 45-90 (`ExecuteAsync`) |
| Client handler | `src/EchoHub.Server.Irc/IrcGatewayService.cs` | Lines 92-154 (`HandleClientAsync`) |
| Command read loop | `src/EchoHub.Server.Irc/IrcCommandHandler.cs` | Lines 40-98 (`RunAsync`) |
| SASL auth | `src/EchoHub.Server.Irc/IrcCommandHandler.cs` | Lines 136-207 (`HandleAuthenticateAsync`) |
| PASS/NICK/USER | `src/EchoHub.Server.Irc/IrcCommandHandler.cs` | Lines 209-267 |
| Registration completion | `src/EchoHub.Server.Irc/IrcCommandHandler.cs` | Lines 268-315 (`TryCompleteRegistrationAsync`) |
| Cleanup on disconnect | `src/EchoHub.Server.Irc/IrcGatewayService.cs` | Lines 136-153 |
--- ---
## User Disconnect & Presence ## User Disconnect & Presence
@@ -139,12 +114,3 @@ sequenceDiagram
end end
end end
``` ```
**Code references:**
| Step | File | Location |
|------|------|----------|
| SignalR disconnect | `src/EchoHub.Server/Hubs/ChatHub.cs` | Lines 45-57 |
| IRC cleanup | `src/EchoHub.Server.Irc/IrcGatewayService.cs` | Lines 136-153 |
| ChatService disconnect | `src/EchoHub.Server/Services/ChatService.cs` | Lines 59-94 |
| Presence disconnect | `src/EchoHub.Server/Services/PresenceTracker.cs` | Lines 31-53 |
-29
View File
@@ -28,15 +28,6 @@ sequenceDiagram
CS->>CS: BroadcastToAllAsync → fan out to clients CS->>CS: BroadcastToAllAsync → fan out to clients
``` ```
**Code references:**
| Step | File | Location |
|------|------|----------|
| Upload endpoint | `src/EchoHub.Server/Controllers/ChannelsController.cs` | Lines 108-200 |
| File validation | `src/EchoHub.Server/Services/FileValidationHelper.cs` | Lines 15-82 |
| File storage | `src/EchoHub.Server/Services/FileStorageService.cs` | Lines 1-47 |
| File download | `src/EchoHub.Server/Controllers/FilesController.cs` | Lines 22-53 |
--- ---
## Link Embed Resolution ## Link Embed Resolution
@@ -67,17 +58,6 @@ sequenceDiagram
Note over CS: Attached to MessageDto before broadcast Note over CS: Attached to MessageDto before broadcast
``` ```
**Code references:**
| Step | File | Location |
|------|------|----------|
| Entry point | `src/EchoHub.Server/Services/LinkEmbedService.cs` | Lines 28-51 (`TryGetEmbedsAsync`) |
| URL extraction | `src/EchoHub.Server/Services/LinkEmbedService.cs` | Lines 145-160 |
| Private IP blocking | `src/EchoHub.Server/Services/LinkEmbedService.cs` | Lines 162-181 |
| OG tag parsing | `src/EchoHub.Server/Services/LinkEmbedService.cs` | Lines 187-210 |
| Theme color parsing | `src/EchoHub.Server/Services/LinkEmbedService.cs` | Lines 117-143 |
| ChatService integration | `src/EchoHub.Server/Services/ChatService.cs` | Lines 194-201 |
--- ---
## Server Directory Registration ## Server Directory Registration
@@ -107,12 +87,3 @@ sequenceDiagram
Note over SDS,Dir: Exponential backoff on disconnect (2s → 30s max) Note over SDS,Dir: Exponential backoff on disconnect (2s → 30s max)
``` ```
**Code references:**
| Step | File | Location |
|------|------|----------|
| Service lifecycle | `src/EchoHub.Server/Services/ServerDirectoryService.cs` | Lines 29-122 |
| Registration | `src/EchoHub.Server/Services/ServerDirectoryService.cs` | Lines 195-212 |
| User count polling | `src/EchoHub.Server/Services/ServerDirectoryService.cs` | Lines 154-187 |
| Reconnection backoff | `src/EchoHub.Server/Services/ServerDirectoryService.cs` | Lines 77-82, 191 |
-42
View File
@@ -45,22 +45,6 @@ sequenceDiagram
end end
``` ```
**Code references:**
| Step | File | Location |
|------|------|----------|
| Input handler | `src/EchoHub.Client/UI/MainWindow.cs` | Lines 428-449 (`OnInputKeyDown`) |
| Orchestrator dispatch | `src/EchoHub.Client/AppOrchestrator.cs` | Lines 631-661 (`HandleMessageSubmitted`) |
| Client encrypt + send | `src/EchoHub.Client/Services/EchoHubConnection.cs` | Lines 148-153 (`SendMessageAsync`) |
| Hub receive | `src/EchoHub.Server/Hubs/ChatHub.cs` | Lines 98-111 (`SendMessage`) |
| ChatService process | `src/EchoHub.Server/Services/ChatService.cs` | Lines 145-241 (`SendMessageAsync`) |
| Mute check | `src/EchoHub.Server/Services/ChatService.cs` | Lines 177-190 |
| Link embeds | `src/EchoHub.Server/Services/LinkEmbedService.cs` | Lines 28-51 (`TryGetEmbedsAsync`) |
| DB insert | `src/EchoHub.Server/Services/ChatService.cs` | Lines 208-221 |
| Broadcast fan-out | `src/EchoHub.Server/Services/ChatService.cs` | Lines 311-324 (`BroadcastToAllAsync`) |
| SignalR broadcast | `src/EchoHub.Server/Services/SignalRBroadcaster.cs` | Lines 23-24 |
| IRC broadcast | `src/EchoHub.Server.Irc/IrcBroadcaster.cs` | Lines 17-32 |
--- ---
## Sending a Message (IRC) ## Sending a Message (IRC)
@@ -94,15 +78,6 @@ sequenceDiagram
end end
``` ```
**Code references:**
| Step | File | Location |
|------|------|----------|
| PRIVMSG handler | `src/EchoHub.Server.Irc/IrcCommandHandler.cs` | Lines 437-469 |
| Channel name conversion | `src/EchoHub.Server.Irc/IrcCommandHandler.cs` | Line 680 (`IrcToEchoHubChannel`) |
| ChatService (shared path) | `src/EchoHub.Server/Services/ChatService.cs` | Lines 145-241 |
| IRC echo suppression | `src/EchoHub.Server.Irc/IrcBroadcaster.cs` | Lines 25-26 |
--- ---
## Receiving a Message (TUI Client) ## Receiving a Message (TUI Client)
@@ -129,14 +104,6 @@ sequenceDiagram
end end
``` ```
**Code references:**
| Step | File | Location |
|------|------|----------|
| SignalR handler | `src/EchoHub.Client/Services/EchoHubConnection.cs` | Lines 64-71 |
| Orchestrator receive | `src/EchoHub.Client/AppOrchestrator.cs` | Lines 372-383 |
| @mention detection | `src/EchoHub.Client/AppOrchestrator.cs` | Lines 378-382 |
--- ---
## Command Execution ## Command Execution
@@ -199,12 +166,3 @@ sequenceDiagram
| `/users` | Local | Show userlist | | `/users` | Local | Show userlist |
| `/help` | Local | Show help text | | `/help` | Local | Show help text |
| `/quit` | Local | Exit application | | `/quit` | Local | Exit application |
**Code references:**
| Step | File | Location |
|------|------|----------|
| Command detection | `src/EchoHub.Client/AppOrchestrator.cs` | Lines 639-655 |
| Command dispatch | `src/EchoHub.Client/Commands/CommandHandler.cs` | Lines 34-69 (`HandleAsync`) |
| Command handlers wired | `src/EchoHub.Client/AppOrchestrator.cs` | Lines 97-117 |
| Individual handlers | `src/EchoHub.Client/AppOrchestrator.cs` | Lines 122-350 |
-12
View File
@@ -36,15 +36,3 @@ sequenceDiagram
CS->>IRCB: ForceDisconnectUserAsync(connectionIds, reason) CS->>IRCB: ForceDisconnectUserAsync(connectionIds, reason)
CS->>DB: Set Status=Invisible, LastSeenAt=now CS->>DB: Set Status=Invisible, LastSeenAt=now
``` ```
**Code references:**
| Step | File | Location |
|------|------|----------|
| Kick endpoint | `src/EchoHub.Server/Controllers/ModerationController.cs` | Lines 62-87 |
| Ban endpoint | `src/EchoHub.Server/Controllers/ModerationController.cs` | Lines 89-112 |
| Mute endpoint | `src/EchoHub.Server/Controllers/ModerationController.cs` | Lines 130-151 |
| Force disconnect | `src/EchoHub.Server/Controllers/ModerationController.cs` | Lines 232-256 |
| Mute expiration | `src/EchoHub.Server/Services/MuteExpirationService.cs` | Lines 22-62 |
| Mute enforcement | `src/EchoHub.Server/Services/ChatService.cs` | Lines 177-190 |
| Presence force remove | `src/EchoHub.Server/Services/PresenceTracker.cs` | Lines 161-178 |
+1 -13
View File
@@ -49,6 +49,7 @@ header.top-navbar {
.navbar .navbar-brand svg { .navbar .navbar-brand svg {
height: 28px; height: 28px;
width: auto; width: auto;
margin-right: 0.5em;
} }
.navbar .nav-link { .navbar .nav-link {
@@ -320,19 +321,6 @@ footer {
color: #E6C06E; color: #E6C06E;
} }
.footer-credit {
color: #484f58;
font-size: 0.85em;
}
.footer-credit a {
color: #6e7681;
}
.footer-credit a:hover {
color: #E6C06E;
}
/* --- Scrollbar --- */ /* --- Scrollbar --- */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 8px; width: 8px;
-3
View File
@@ -1,9 +1,6 @@
- name: Articles - name: Articles
href: articles/ href: articles/
homepage: articles/getting-started.md homepage: articles/getting-started.md
- name: Flows
href: flows/
homepage: flows/flows.md
- name: Changelog - name: Changelog
href: changelog/ href: changelog/
homepage: changelog/index.md homepage: changelog/index.md
+3 -2
View File
@@ -15,10 +15,11 @@
- [x] unmute timer does not seem to work - [x] unmute timer does not seem to work
- [ ] add IRC network like support - [ ] add IRC network like support
- that means basically multiple servers linked, so users can chat cross-server in this network - that means basically multiple servers linked, so users can chat cross-server in this network
- [ ] when users clicks public -> private -> public checkbox in the channel creation, it ends up creating the channel on 3rd check switch - [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) - [ ] add keyboard only controls | at least for most important parts and the rest might be accessible with: (down)
- [ ] add search bar / search modal that will allow users to instantly navigate to room / focus on app element & etc - [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) - [ ] 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] 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] 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 - [x] space between mod|admin "icon" and username
- [ ] Embeds still incorrectly display colors
+8 -8
View File
@@ -7,17 +7,17 @@
<authors>HueByte</authors> <authors>HueByte</authors>
<owners>HueByte</owners> <owners>HueByte</owners>
<requireLicenseAcceptance>false</requireLicenseAcceptance> <requireLicenseAcceptance>false</requireLicenseAcceptance>
<licenseUrl>https://github.com/HueByteByte/EchoHub/blob/master/LICENSE</licenseUrl> <licenseUrl>https://github.com/HueByte/EchoHub/blob/master/LICENSE</licenseUrl>
<projectUrl>https://github.com/HueByteByte/EchoHub</projectUrl> <projectUrl>https://github.com/HueByte/EchoHub</projectUrl>
<projectSourceUrl>https://github.com/HueByteByte/EchoHub</projectSourceUrl> <projectSourceUrl>https://github.com/HueByte/EchoHub</projectSourceUrl>
<docsUrl>https://HueBytebyte.github.io/EchoHub</docsUrl> <docsUrl>https://huebyte.github.io/EchoHub</docsUrl>
<bugTrackerUrl>https://github.com/HueByteByte/EchoHub/issues</bugTrackerUrl> <bugTrackerUrl>https://github.com/HueByte/EchoHub/issues</bugTrackerUrl>
<packageSourceUrl>https://github.com/HueByteByte/EchoHub/tree/master/packaging/choco</packageSourceUrl> <packageSourceUrl>https://github.com/HueByte/EchoHub/tree/master/packaging/choco</packageSourceUrl>
<iconUrl>https://raw.githubusercontent.com/HueByteByte/EchoHub/master/assets/HueByte_icon.png</iconUrl> <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> <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> <summary>Decentralized terminal chat client with IRC gateway support</summary>
<tags>chat irc decentralized tui terminal signalr echohub</tags> <tags>chat irc decentralized tui terminal signalr echohub</tags>
<releaseNotes>https://HueBytebyte.github.io/EchoHub/changelog/v__VERSION__.html</releaseNotes> <releaseNotes>https://huebyte.github.io/EchoHub/changelog/v__VERSION__.html</releaseNotes>
<copyright>Copyright (c) 2026 HueByte</copyright> <copyright>Copyright (c) 2026 HueByte</copyright>
</metadata> </metadata>
<files> <files>
+2 -1
View File
@@ -14,5 +14,6 @@ $packageArgs = @{
Install-ChocolateyZipPackage @packageArgs Install-ChocolateyZipPackage @packageArgs
# Create a shim so 'echohub' is available on PATH # Create a shim so 'echohub' is available on PATH
$exePath = Join-Path $toolsDir 'client-win-x64' 'EchoHub.Client.exe' $exeDir = Join-Path $toolsDir 'client-win-x64'
$exePath = Join-Path $exeDir 'EchoHub.Client.exe'
Install-BinFile -Name 'echohub' -Path $exePath Install-BinFile -Name 'echohub' -Path $exePath
+11 -4
View File
@@ -30,7 +30,7 @@ while [ $# -gt 0 ]; do
sed -n '2,8p' "$0" 2>/dev/null || true sed -n '2,8p' "$0" 2>/dev/null || true
echo "" echo ""
echo " curl -sSfL https://raw.githubusercontent.com/$REPO/master/scripts/install.sh | sh" echo " curl -sSfL https://raw.githubusercontent.com/$REPO/master/scripts/install.sh | sh"
echo " curl ... | sh -s -- --version 0.2.8" echo " curl ... | sh -s -- --version 0.2.10"
echo " curl ... | sh -s -- --install-dir /opt/echohub" echo " curl ... | sh -s -- --install-dir /opt/echohub"
exit 0 exit 0
;; ;;
@@ -199,10 +199,17 @@ main() {
fi fi
fi fi
# Install the binary # 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" mkdir -p "$install_dir"
cp "$src_dir/EchoHub.Client" "$install_dir/$BINARY_NAME" ln -sf "$app_dir/EchoHub.Client" "$install_dir/$BINARY_NAME"
chmod +x "$install_dir/$BINARY_NAME"
echo "" echo ""
+1 -1
View File
@@ -1,6 +1,6 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>0.2.8</Version> <Version>0.2.10</Version>
<GenerateDocumentationFile>true</GenerateDocumentationFile> <GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn> <NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup> </PropertyGroup>
+60
View File
@@ -30,6 +30,7 @@ public sealed class AppOrchestrator : IDisposable
private readonly ConnectionManager _conn = new(); private readonly ConnectionManager _conn = new();
private readonly Dictionary<string, List<UserPresenceDto>> _channelUsers = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, List<UserPresenceDto>> _channelUsers = new(StringComparer.OrdinalIgnoreCase);
private readonly Lock _channelUsersLock = new(); private readonly Lock _channelUsersLock = new();
private readonly HashSet<string> _channelsLoadingMore = new(StringComparer.OrdinalIgnoreCase);
private ClientConfig _config; private ClientConfig _config;
private readonly UserSession _session = new(); private readonly UserSession _session = new();
@@ -91,6 +92,8 @@ public sealed class AppOrchestrator : IDisposable
_mainWindow.OnRollbackRequested += HandleRollbackRequested; _mainWindow.OnRollbackRequested += HandleRollbackRequested;
_mainWindow.OnUserProfileRequested += HandleViewProfile; _mainWindow.OnUserProfileRequested += HandleViewProfile;
_mainWindow.OnChannelJoinRequested += HandleChannelJoinFromMessage; _mainWindow.OnChannelJoinRequested += HandleChannelJoinFromMessage;
_mainWindow.OnSearchRequested += HandleSearchRequested;
_mainWindow.OnLoadMoreRequested += HandleLoadMoreRequested;
} }
// ── Command Handler Wiring ───────────────────────────────────────────── // ── Command Handler Wiring ─────────────────────────────────────────────
@@ -720,6 +723,31 @@ public sealed class AppOrchestrator : IDisposable
}, "Failed to join channel"); }, "Failed to join channel");
} }
private void HandleLoadMoreRequested()
{
if (!_conn.IsConnected) return;
var channel = _mainWindow.CurrentChannel;
if (string.IsNullOrEmpty(channel)) return;
if (!_channelsLoadingMore.Add(channel)) return;
var offset = _messageManager.GetMessages(channel)?.Count ?? 0;
RunAsync(async () =>
{
try
{
var history = await _conn.GetHistoryAsync(channel, HubConstants.DefaultHistoryCount, offset);
InvokeUI(() => _messageManager.PrependHistory(channel, history));
}
finally
{
_channelsLoadingMore.Remove(channel);
}
}, "Failed to load more messages");
}
private void HandleChannelJoinFromMessage(string channelName) private void HandleChannelJoinFromMessage(string channelName)
{ {
if (!_conn.IsConnected) return; if (!_conn.IsConnected) return;
@@ -733,6 +761,38 @@ public sealed class AppOrchestrator : IDisposable
HandleChannelSelected(channelName); HandleChannelSelected(channelName);
} }
private void HandleSearchRequested()
{
var result = SearchDialog.Show(_app, _mainWindow.GetChannelNames());
if (result is null) return;
switch (result.Type)
{
case SearchResultType.Channel:
_mainWindow.SwitchToChannel(result.Key);
HandleChannelSelected(result.Key);
break;
case SearchResultType.Action:
switch (result.Key)
{
case "connect": HandleConnect(); break;
case "disconnect": HandleDisconnect(); break;
case "logout": HandleLogout(); break;
case "profile": HandleProfileRequested(); break;
case "status": HandleStatusRequested(); break;
case "create-channel": HandleCreateChannelRequested(); break;
case "delete-channel": HandleDeleteChannelRequested(); break;
case "servers": HandleSavedServersRequested(); break;
case "toggle-users": _mainWindow.ToggleUsersPanel(); break;
case "updates": HandleCheckForUpdatesRequested(); break;
case "quit": _app.RequestStop(); break;
}
break;
}
}
private void HandleProfileRequested() private void HandleProfileRequested()
{ {
HandleViewProfile(null); HandleViewProfile(null);
+1 -1
View File
@@ -12,7 +12,7 @@
<PackageReference Include="Serilog" Version="4.3.1" /> <PackageReference Include="Serilog" Version="4.3.1" />
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" /> <PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" /> <PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Terminal.Gui" Version="2.0.0-develop.5039" /> <PackageReference Include="Terminal.Gui" Version="2.0.0-develop.5043" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+5 -1
View File
@@ -4,6 +4,7 @@ using EchoHub.Client.Services;
using EchoHub.Client.Themes; using EchoHub.Client.Themes;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Serilog; using Serilog;
using Serilog.Settings.Configuration;
using Terminal.Gui.App; using Terminal.Gui.App;
// == CLI rollback: works without TUI, before anything else ================ // == CLI rollback: works without TUI, before anything else ================
@@ -71,8 +72,11 @@ var configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: false) .AddJsonFile("appsettings.json", optional: true, reloadOnChange: false)
.Build(); .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() Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(configuration) .ReadFrom.Configuration(configuration, serilogOptions)
.CreateLogger(); .CreateLogger();
Log.Information("EchoHub client starting"); Log.Information("EchoHub client starting");
@@ -196,8 +196,8 @@ internal sealed class ConnectionManager : IAsyncDisposable
_connection?.SendMessageAsync(channel, content) _connection?.SendMessageAsync(channel, content)
?? throw new InvalidOperationException("Not connected"); ?? throw new InvalidOperationException("Not connected");
public Task<List<MessageDto>> GetHistoryAsync(string channel) => public Task<List<MessageDto>> GetHistoryAsync(string channel, int count = HubConstants.DefaultHistoryCount, int offset = 0) =>
_connection?.GetHistoryAsync(channel) _connection?.GetHistoryAsync(channel, count, offset)
?? throw new InvalidOperationException("Not connected"); ?? throw new InvalidOperationException("Not connected");
public Task<List<UserPresenceDto>> GetOnlineUsersAsync(string channel) => public Task<List<UserPresenceDto>> GetOnlineUsersAsync(string channel) =>
@@ -154,9 +154,9 @@ public sealed class EchoHubConnection : IAsyncDisposable
await _connection.InvokeAsync("SendMessage", channelName, encrypted); await _connection.InvokeAsync("SendMessage", channelName, encrypted);
} }
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)
{ {
var messages = await _connection.InvokeAsync<List<MessageDto>>("GetChannelHistory", channelName, count); var messages = await _connection.InvokeAsync<List<MessageDto>>("GetChannelHistory", channelName, count, offset);
return DecryptMessages(messages); return DecryptMessages(messages);
} }
@@ -6,7 +6,11 @@ namespace EchoHub.Client.Services;
public class NotificationSoundService 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 Player _player = new();
private readonly SemaphoreSlim _lock = new(1, 1);
private readonly NotificationConfig _config; private readonly NotificationConfig _config;
private string? _resolvedSoundPath; private string? _resolvedSoundPath;
@@ -41,18 +45,31 @@ public class NotificationSoundService
private async Task PlayInternal() 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 try
{ {
if (_player.Playing)
await _player.Stop();
await _player.SetVolume(_config.Volume); await _player.SetVolume(_config.Volume);
await _player.Play(_resolvedSoundPath!); await _player.Play(_resolvedSoundPath!);
await Task.WhenAny(completion.Task, Task.Delay(PlaybackTimeout));
} }
catch (Exception ex) catch (Exception ex)
{ {
Log.Warning(ex, "Failed to play notification sound"); Log.Warning(ex, "Failed to play notification sound");
} }
finally
{
_player.PlaybackFinished -= OnFinished;
_lock.Release();
}
} }
private void ResolveSoundPath() private void ResolveSoundPath()
@@ -40,7 +40,37 @@ public static class UpdateBackupService
Log.Information("Creating pre-update backup of {AppDir} (v{Version})", appDir, version); Log.Information("Creating pre-update backup of {AppDir} (v{Version})", appDir, version);
ZipFile.CreateFromDirectory(appDir, BackupZipPath, CompressionLevel.Fastest, includeBaseDirectory: false); 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 info = new BackupInfo(version, appDir, DateTimeOffset.UtcNow);
var json = JsonSerializer.Serialize(info, BackupJsonContext.Default.BackupInfo); var json = JsonSerializer.Serialize(info, BackupJsonContext.Default.BackupInfo);
+9 -15
View File
@@ -71,7 +71,7 @@ public sealed class UpdateChecker : IDisposable
// Create backup before the update starts // Create backup before the update starts
try try
{ {
_app.Invoke(() => _progressDialog?.UpdateProgress(0f, "Creating backup...")); _progressDialog?.UpdateProgress(0f, "Creating backup...");
UpdateBackupService.CreateBackup(); UpdateBackupService.CreateBackup();
} }
catch (Exception ex) catch (Exception ex)
@@ -79,27 +79,21 @@ public sealed class UpdateChecker : IDisposable
Log.Error(ex, "Failed to create pre-update backup"); Log.Error(ex, "Failed to create pre-update backup");
var proceed = false; var proceed = false;
_app.Invoke(() => proceed = MessageBox.Query(
{ _app,
proceed = MessageBox.Query( "Backup Warning",
_app, $"Could not create backup: {ex.Message}\n\nContinue update without backup?",
"Backup Warning", "Continue", "Cancel") == 0;
$"Could not create backup: {ex.Message}\n\nContinue update without backup?",
"Continue", "Cancel") == 0;
});
if (!proceed) if (!proceed)
{ {
_app.Invoke(() => _progressDialog?.Close();
{ _progressDialog = null;
_progressDialog?.Close();
_progressDialog = null;
});
return; return;
} }
} }
_app.Invoke(() => _progressDialog?.UpdateProgress(0f, "Downloading update...")); _progressDialog?.UpdateProgress(0f, "Downloading update...");
await _updater.UpdateAsync(); await _updater.UpdateAsync();
}); });
@@ -186,6 +186,39 @@ public sealed class ChatMessageManager
MessagesChanged?.Invoke(channelName); MessagesChanged?.Invoke(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 newLines = olderMessages
.Where(m => !existingIds.Contains(m.Id))
.SelectMany(FormatMessage)
.ToList();
if (newLines.Count == 0)
return;
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> /// <summary>
/// Reset all message state (used on disconnect). /// Reset all message state (used on disconnect).
/// </summary> /// </summary>
@@ -12,7 +12,7 @@ public sealed class CreateChannelDialog
{ {
CreateChannelResult? result = null; CreateChannelResult? result = null;
var dialog = new Dialog { Title = "Create Channel", Width = 50, Height = 14 }; var dialog = new Dialog { Title = "Create Channel", Width = 50, Height = 14, CommandsToBubbleUp = [] };
var nameLabel = new Label { Text = "Name:", X = 1, Y = 1 }; var nameLabel = new Label { Text = "Name:", X = 1, Y = 1 };
var nameField = new TextField { X = 10, Y = 1, Width = Dim.Fill(2) }; var nameField = new TextField { X = 10, Y = 1, Width = Dim.Fill(2) };
@@ -0,0 +1,160 @@
using EchoHub.Client.UI.ListSources;
using System.Collections;
using System.Collections.Specialized;
using System.Diagnostics;
using Terminal.Gui.App;
using Terminal.Gui.Drawing;
using Terminal.Gui.Input;
using Terminal.Gui.Text;
using Terminal.Gui.ViewBase;
using Terminal.Gui.Views;
namespace EchoHub.Client.UI.Dialogs;
public enum SearchResultType
{
Channel,
Action
}
public record SearchResult(SearchResultType Type, string Key, string Label);
/// <summary>
/// Command-palette style search dialog (Ctrl+K) for navigating channels and triggering app actions.
/// </summary>
public static class SearchDialog
{
private static readonly IReadOnlyList<SearchResult> DefaultActions = [
new(SearchResultType.Action, "connect", "Connect to Server"),
new(SearchResultType.Action, "disconnect", "Disconnect"),
new(SearchResultType.Action, "logout", "Logout"),
new(SearchResultType.Action, "profile", "My Profile"),
new(SearchResultType.Action, "status", "Set Status"),
new(SearchResultType.Action, "create-channel", "Create Channel"),
new(SearchResultType.Action, "delete-channel", "Delete Channel"),
new(SearchResultType.Action, "servers", "Saved Servers"),
new(SearchResultType.Action, "toggle-users", "Toggle Users Panel"),
new(SearchResultType.Action, "updates", "Check for Updates"),
new(SearchResultType.Action, "quit", "Quit"),
];
public static SearchResult? Show(IApplication app, IReadOnlyList<string> channels)
{
SearchResult? result = null;
var source = new SearchListSource(BuildAllItems(channels));
var dialog = new Dialog
{
Title = "Search",
Width = 59,
Height = 22,
};
var hintLabel = new Label
{
Text = "Channels and actions \u2502 \u2193 to navigate \u2502 Enter to select",
X = 1,
Y = 1,
};
var searchField = new TextField
{
X = 1,
Y = 2,
Title = "Search",
Width = Dim.Fill(2),
};
var resultList = new ListView
{
X = 1,
Y = 4,
Width = Dim.Fill(2),
Height = Dim.Fill(3),
Source = source
};
var cancelButton = new Button
{
Text = "Cancel",
X = Pos.Center(),
Y = Pos.AnchorEnd(1),
};
if (source.Count > 0)
resultList.SelectedItem = 0;
searchField.KeyDown += (s, e) =>
{
if (e.KeyCode == Key.K.WithCtrl)
{
e.Handled = true;
app.RequestStop();
}
};
searchField.TextChanged += (s, e) =>
{
source.Filter(searchField.Text ?? string.Empty);
resultList.Source = source;
if (source.Count > 0)
resultList.SelectedItem = 0;
};
searchField.Accepting += (s, e) => TryConfirm(e);
resultList.Accepting += (s, e) => TryConfirm(e);
resultList.KeystrokeNavigator.SearchStringChanged += (s, e) =>
{
app.Invoke(() =>
{
searchField.SetFocus();
});
};
cancelButton.Accepting += (s, e) =>
{
result = null;
e.Handled = true;
app.RequestStop();
};
dialog.KeyDown += (s, e) =>
{
if (e.KeyCode == Key.K.WithCtrl)
{
e.Handled = true;
app.RequestStop();
}
};
dialog.Add(hintLabel, searchField, resultList, cancelButton);
searchField.SetFocus();
app.Run(dialog);
return result;
void TryConfirm(CommandEventArgs e)
{
var idx = resultList.SelectedItem ?? 0;
if (source.Count > 0 && idx >= 0 && idx < source.Count)
{
result = source.GetItem(idx);
e.Handled = true;
app.RequestStop();
}
}
}
private static List<SearchResult> BuildAllItems(IReadOnlyList<string> channels)
{
var items = new List<SearchResult>();
foreach (var ch in channels)
items.Add(new SearchResult(SearchResultType.Channel, ch, $"#{ch}"));
items.AddRange(DefaultActions);
return items;
}
}
@@ -0,0 +1,87 @@
using EchoHub.Client.UI.Chat;
using EchoHub.Client.UI.Dialogs;
using System.Collections;
using System.Collections.Specialized;
using Terminal.Gui.Drawing;
using Terminal.Gui.Text;
using Terminal.Gui.Views;
using Attribute = Terminal.Gui.Drawing.Attribute;
namespace EchoHub.Client.UI.ListSources;
/// <summary>
/// List data source for the search dialog with filtering and colored rendering.
/// </summary>
public class SearchListSource(List<SearchResult> items) : IListDataSource
{
private readonly List<SearchResult> _allItems = items;
private List<SearchResult> _filtered = [.. items];
private static readonly Attribute ChannelAttribute = new(Color.BrightCyan, Color.None);
private static readonly Attribute ActionAttribute = new(Color.White, Color.None);
public event NotifyCollectionChangedEventHandler? CollectionChanged;
public int Count => _filtered.Count;
public int MaxItemLength => _filtered.Count > 0 ? _filtered.Max(i => i.Label.GetColumns()) : 0;
public bool SuspendCollectionChangedEvent { get; set; }
public void Filter(string query)
{
if (string.IsNullOrWhiteSpace(query))
{
_filtered = [.. _allItems];
}
else
{
_filtered = [.. _allItems.Where(i =>
i.Label.Contains(query, StringComparison.OrdinalIgnoreCase)
|| i.Key.Contains(query, StringComparison.OrdinalIgnoreCase))];
}
if (!SuspendCollectionChangedEvent)
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
}
public SearchResult? GetItem(int index) => index >= 0 && index < _filtered.Count ? _filtered[index] : null;
public bool IsMarked(int item) => false;
public void SetMark(int item, bool value) { }
public IList ToList() => _filtered.Select(i => (object)i.Label).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 entry = _filtered[item];
var fillAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal);
Attribute itemAttr;
if (selected)
{
itemAttr = fillAttr;
}
else
{
var raw = entry.Type switch
{
SearchResultType.Channel => ChannelAttribute,
SearchResultType.Action => ActionAttribute,
_ => fillAttr
};
itemAttr = raw.Background == Color.None ? raw with { Background = fillAttr.Background } : raw;
}
listView.SetAttribute(itemAttr);
var drawn = RenderHelpers.WriteText(listView, entry.Label, 0, width);
listView.SetAttribute(fillAttr);
for (var i = drawn; i < width; i++)
listView.AddStr(" ");
}
public void Dispose() { }
}
+61 -1
View File
@@ -1,3 +1,4 @@
using System.Diagnostics;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using EchoHub.Client.Services; using EchoHub.Client.Services;
using EchoHub.Client.Themes; using EchoHub.Client.Themes;
@@ -48,6 +49,7 @@ public sealed partial class MainWindow : Runnable
private static readonly Key NewlineKey = Key.N.WithCtrl; private static readonly Key NewlineKey = Key.N.WithCtrl;
private static readonly Key AltQKey = Key.Q.WithAlt; private static readonly Key AltQKey = Key.Q.WithAlt;
private static readonly Key TabKey = Key.Tab; private static readonly Key TabKey = Key.Tab;
private static readonly Key CtrlKKey = Key.K.WithCtrl;
// Available slash commands for Tab autocomplete // Available slash commands for Tab autocomplete
private static readonly string[] SlashCommands = private static readonly string[] SlashCommands =
@@ -116,6 +118,11 @@ public sealed partial class MainWindow : Runnable
/// </summary> /// </summary>
public event Action? OnSavedServersRequested; public event Action? OnSavedServersRequested;
/// <summary>
/// Fired when the user scrolls to the top of the message list and older messages should be loaded.
/// </summary>
public event Action? OnLoadMoreRequested;
/// <summary> /// <summary>
/// Fired when the user requests to create a new channel. /// Fired when the user requests to create a new channel.
/// </summary> /// </summary>
@@ -151,11 +158,17 @@ public sealed partial class MainWindow : Runnable
/// </summary> /// </summary>
public event Action<string>? OnChannelJoinRequested; public event Action<string>? OnChannelJoinRequested;
/// <summary>
/// Fired when the user requests to open the search dialog (via menu or Ctrl+K).
/// </summary>
public event Action? OnSearchRequested;
public MainWindow(IApplication app, ChatMessageManager messageManager) public MainWindow(IApplication app, ChatMessageManager messageManager)
{ {
_app = app; _app = app;
_messageManager = messageManager; _messageManager = messageManager;
_messageManager.MessagesChanged += OnMessagesChanged; _messageManager.MessagesChanged += OnMessagesChanged;
_messageManager.HistoryPrepended += OnHistoryPrepended;
Arrangement = ViewArrangement.Fixed; Arrangement = ViewArrangement.Fixed;
// Menu bar at the top // Menu bar at the top
@@ -216,13 +229,16 @@ public sealed partial class MainWindow : Runnable
}; };
_messageList.Source = new ChatListSource(); _messageList.Source = new ChatListSource();
_messageList.Accepting += OnMessageListAccepting; _messageList.Accepting += OnMessageListAccepting;
_messageList.VerticalScrollBar.Scrolled += OnMessageListVerticalScrollBarScrolled;
_messageList.VerticalScrollBar.Visible = true;
_chatFrame.Add(_messageList); _chatFrame.Add(_messageList);
Add(_chatFrame); Add(_chatFrame);
// Bottom input area // Bottom input area
_inputFrame = new FrameView _inputFrame = new FrameView
{ {
Title = "Message \u2502 Enter=send \u2502 Ctrl+N=newline \u2502 Tab=complete", Title = "Message \u2502 Enter=send \u2502 Ctrl+N=newline \u2502 Tab=complete \u2502 Ctrl+K=search",
X = 22, X = 22,
Y = Pos.Bottom(_chatFrame), Y = Pos.Bottom(_chatFrame),
Width = Dim.Fill(UsersPanelWidth), Width = Dim.Fill(UsersPanelWidth),
@@ -472,6 +488,12 @@ public sealed partial class MainWindow : Runnable
} }
} }
private void OnMessageListVerticalScrollBarScrolled(object? sender, EventArgs<int> e)
{
if (_messageList.VerticalScrollBar.Value == 0)
OnLoadMoreRequested?.Invoke();
}
private void OnUsersListAccepting(object? sender, CommandEventArgs e) private void OnUsersListAccepting(object? sender, CommandEventArgs e)
{ {
var index = _usersList.SelectedItem; var index = _usersList.SelectedItem;
@@ -513,6 +535,11 @@ public sealed partial class MainWindow : Runnable
_app.RequestStop(); _app.RequestStop();
e.Handled = true; e.Handled = true;
} }
else if (e.KeyCode == CtrlKKey.KeyCode)
{
ShowSearchDialog();
e.Handled = true;
}
} }
private bool _suppressEmojiReplace; private bool _suppressEmojiReplace;
@@ -572,6 +599,9 @@ public sealed partial class MainWindow : Runnable
if (prefix.Length > text.Length) if (prefix.Length > text.Length)
_inputField.Text = prefix; _inputField.Text = prefix;
} }
// Move cursor to end after autocomplete
_inputField.InsertionPoint = new System.Drawing.Point(_inputField.Text?.Length ?? 0, 0);
} }
private void OnChatViewportChanged() private void OnChatViewportChanged()
@@ -597,6 +627,16 @@ public sealed partial class MainWindow : Runnable
ToggleUsersPanel(); ToggleUsersPanel();
e.Handled = true; e.Handled = true;
} }
else if (e.KeyCode == CtrlKKey.KeyCode)
{
ShowSearchDialog();
e.Handled = true;
}
}
private void ShowSearchDialog()
{
OnSearchRequested?.Invoke();
} }
private void OnMessagesChanged(string channelName) private void OnMessagesChanged(string channelName)
@@ -607,6 +647,26 @@ public sealed partial class MainWindow : Runnable
RefreshChannelList(); RefreshChannelList();
} }
private void OnHistoryPrepended(string channelName)
{
if (channelName != _messageManager.CurrentChannel)
return;
var messages = _messageManager.GetMessages(channelName);
if (messages is null)
return;
var oldCount = (_messageList.Source as ChatListSource)?.Count ?? 0;
RefreshMessages();
// Scroll to the item that was at the top before the prepend so the user
// stays at their previous reading position rather than jumping to the top.
var prependedCount = (_messageList.Source as ChatListSource)?.Count - oldCount;
if (prependedCount > 0)
_messageList.SelectedItem = prependedCount;
}
/// <summary> /// <summary>
/// Set the list of available channels, storing topics, and refresh the channel list view. /// Set the list of available channels, storing topics, and refresh the channel list view.
/// </summary> /// </summary>
@@ -13,7 +13,7 @@ public static partial class ValidationConstants
public const int MaxBioLength = 500; public const int MaxBioLength = 500;
public const int MaxStatusMessageLength = 100; public const int MaxStatusMessageLength = 100;
public const int MaxChannelTopicLength = 500; public const int MaxChannelTopicLength = 500;
public const int MaxHistoryCount = 100; public const int MaxHistoryCount = 200;
[GeneratedRegex(UsernamePattern)] [GeneratedRegex(UsernamePattern)]
public static partial Regex UsernameRegex(); public static partial Regex UsernameRegex();
+1 -1
View File
@@ -15,7 +15,7 @@ public interface IChatService
// Messaging // Messaging
Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content); Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content);
Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count); Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count, int offset = 0);
// Presence // Presence
Task<string?> UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage); Task<string?> UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage);
+2
View File
@@ -12,6 +12,8 @@ RUN dotnet restore EchoHub.Server/EchoHub.Server.csproj
COPY EchoHub.Core/ EchoHub.Core/ COPY EchoHub.Core/ EchoHub.Core/
COPY EchoHub.Server.Irc/ EchoHub.Server.Irc/ COPY EchoHub.Server.Irc/ EchoHub.Server.Irc/
COPY EchoHub.Server/ EchoHub.Server/ COPY EchoHub.Server/ EchoHub.Server/
# Icon referenced by Server.csproj ApplicationIcon
COPY EchoHub.Client/Assets/hue_icon.ico EchoHub.Client/Assets/hue_icon.ico
RUN dotnet publish EchoHub.Server/EchoHub.Server.csproj -c Release -o /app/publish --no-restore RUN dotnet publish EchoHub.Server/EchoHub.Server.csproj -c Release -o /app/publish --no-restore
# Runtime # Runtime
+2 -2
View File
@@ -106,11 +106,11 @@ public class ChatHub : Hub<IEchoHubClient>
} }
} }
public async Task<List<MessageDto>> GetChannelHistory(string channelName, int count = HubConstants.DefaultHistoryCount) public async Task<List<MessageDto>> GetChannelHistory(string channelName, int count = HubConstants.DefaultHistoryCount, int offset = 0)
{ {
try try
{ {
return await _chatService.GetChannelHistoryAsync(channelName, count); return await _chatService.GetChannelHistoryAsync(channelName, count, offset);
} }
catch (Exception ex) catch (Exception ex)
{ {
+5 -3
View File
@@ -245,15 +245,16 @@ public class ChatService : IChatService
return null; return null;
} }
public async Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count) public async Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count, int offset = 0)
{ {
channelName = channelName.ToLowerInvariant().Trim(); channelName = channelName.ToLowerInvariant().Trim();
count = Math.Clamp(count, 1, ValidationConstants.MaxHistoryCount); count = Math.Clamp(count, 1, ValidationConstants.MaxHistoryCount);
offset = Math.Max(offset, 0);
using var scope = _scopeFactory.CreateScope(); using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>(); var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
return await GetChannelHistoryInternalAsync(db, channelName, count); return await GetChannelHistoryInternalAsync(db, channelName, count, offset);
} }
public async Task<string?> UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage) public async Task<string?> UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage)
@@ -366,7 +367,7 @@ public class ChatService : IChatService
return string.Join('\n', result); return string.Join('\n', result);
} }
private async Task<List<MessageDto>> GetChannelHistoryInternalAsync(EchoHubDbContext db, string channelName, int count) private async Task<List<MessageDto>> GetChannelHistoryInternalAsync(EchoHubDbContext db, string channelName, int count, int offset = 0)
{ {
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (channel is null) if (channel is null)
@@ -375,6 +376,7 @@ public class ChatService : IChatService
var raw = await db.Messages var raw = await db.Messages
.Where(m => m.ChannelId == channel.Id) .Where(m => m.ChannelId == channel.Id)
.OrderByDescending(m => m.SentAt) .OrderByDescending(m => m.SentAt)
.Skip(offset)
.Take(count) .Take(count)
.Join(db.Users, .Join(db.Users,
m => m.SenderUserId, m => m.SenderUserId,
+1 -1
View File
@@ -190,7 +190,7 @@ internal sealed class FakeChatService : IChatService
return Task.FromResult(SendMessageError); return Task.FromResult(SendMessageError);
} }
public Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count) => public Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count, int offset = 0) =>
Task.FromResult(HistoryToReturn); Task.FromResult(HistoryToReturn);
public Task<string?> UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage) public Task<string?> UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage)