mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 23:34:10 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
769aa5b468 | ||
|
|
6660944588 | ||
|
|
4b41438af0 | ||
|
|
a4fe432992 | ||
|
|
9279e8be06 | ||
|
|
7f9fcfe3cc | ||
|
|
62c5ab27c5 | ||
|
|
240892495b | ||
|
|
6e7cbf39f0 | ||
|
|
83d257591e | ||
|
|
e42f1a0965 | ||
|
|
fe6dfd3d4d | ||
|
|
d6282885e3 | ||
|
|
bd8b88add2 | ||
|
|
ff9a9e3dd0 | ||
|
|
aa6599a4e0 | ||
|
|
3091a146eb | ||
|
|
000764fdb4 | ||
|
|
56fccf5cfb | ||
|
|
b98673f8c5 | ||
|
|
335cfcc28a | ||
|
|
6aef6890cf | ||
|
|
5b8df9d505 | ||
|
|
6dbc29818c | ||
|
|
6758aceb5d | ||
|
|
06aba16303 | ||
|
|
89b028cf06 | ||
|
|
eb4aac9861 | ||
|
|
38d63d844d | ||
|
|
3f2211f42a | ||
|
|
0606394af2 | ||
|
|
ee18743721 | ||
|
|
0aaa371488 | ||
|
|
8689dc2a01 | ||
|
|
5d61266fd7 | ||
|
|
ae342381e0 | ||
|
|
ba536c6f9c | ||
|
|
92b89fa8a0 | ||
|
|
9029d63e54 | ||
|
|
023e62dea6 | ||
|
|
36e1ea0dfa | ||
|
|
be18cf88df | ||
|
|
595475c436 | ||
|
|
1b6c27247f | ||
|
|
4a598aa8a8 | ||
|
|
ed7d0a73a3 | ||
|
|
6af85559cb | ||
|
|
43f1b97244 | ||
|
|
9baa3ff566 | ||
|
|
0330f24e45 | ||
|
|
ac98845c69 | ||
|
|
d4f540285b | ||
|
|
3896640ac7 | ||
|
|
29bfd87d8b | ||
|
|
b0adb55b8f | ||
|
|
24a5bdb9a1 | ||
|
|
5391d2cb1c | ||
|
|
62996f85e9 | ||
|
|
ac5348dbb0 | ||
|
|
ad5627a4bb | ||
|
|
c7a5829fac | ||
|
|
a0d9d86956 | ||
|
|
0c16f44db6 | ||
|
|
fb4f6c34ed | ||
|
|
8647b05c12 | ||
|
|
e2bab3d0d9 | ||
|
|
6295831045 | ||
|
|
045515369c | ||
|
|
fe0e31f9a0 | ||
|
|
3273e62b37 | ||
|
|
cf5fca5772 | ||
|
|
38df05df41 | ||
|
|
760693befe | ||
|
|
241fee67e8 | ||
|
|
0e3dd932af | ||
|
|
81b09b1af7 | ||
|
|
94b31b6056 | ||
|
|
6e76065dcb | ||
|
|
bdcff74ad5 | ||
|
|
27a25b1b43 | ||
|
|
28c4b2993f | ||
|
|
3c760b0bd8 | ||
|
|
c2a8e9cfc8 | ||
|
|
45fd382b13 | ||
|
|
039390c8b0 | ||
|
|
c5be63db25 | ||
|
|
993bb1f973 | ||
|
|
94f968ccf9 | ||
|
|
4f96b8d986 | ||
|
|
fbb2958fdd | ||
|
|
62e9d7fff5 | ||
|
|
be4d686d4d | ||
|
|
c8ea124198 | ||
|
|
c5d8e25a86 | ||
|
|
03f4685ae6 | ||
|
|
17e64293b5 | ||
|
|
ce375c7619 | ||
|
|
d12f249222 | ||
|
|
bc8e2fd7da | ||
|
|
35a162d013 | ||
|
|
de34fb43b9 | ||
|
|
3e03a77aeb | ||
|
|
5ef338bbf7 | ||
|
|
f96f70f09a | ||
|
|
b7d58dc10d | ||
|
|
7fadd08702 | ||
|
|
576dd4bc94 | ||
|
|
d4cf35e04e | ||
|
|
23abb9b0bb | ||
|
|
dcb8bbc05f | ||
|
|
4930aee615 | ||
|
|
2cd3bbb570 | ||
|
|
0d959e317e | ||
|
|
75491d65e2 | ||
|
|
1861fd00f9 | ||
|
|
9996a6be97 | ||
|
|
cf9c647c03 | ||
|
|
1fa4348700 | ||
|
|
0c7c620f24 | ||
|
|
56a962c1af | ||
|
|
a65b41fb7f | ||
|
|
ab9e09a609 | ||
|
|
1bdc6ec99e | ||
|
|
fb12848e9a | ||
|
|
7e78c57665 | ||
|
|
9d4979d4a7 | ||
|
|
c0d38015ff | ||
|
|
3bc1446d1c | ||
|
|
411042c397 | ||
|
|
50817ce6e9 | ||
|
|
9265a9f312 | ||
|
|
a860a8fbc3 | ||
|
|
ae06e7ae12 | ||
|
|
f49148a85a | ||
|
|
575115cdca | ||
|
|
ca9973b411 | ||
|
|
4c850fc3c9 | ||
|
|
2efe54e417 | ||
|
|
dfa1b21d32 | ||
|
|
981875c495 | ||
|
|
c90ed27194 | ||
|
|
8f46ea0f2c | ||
|
|
f20ce3d50f | ||
|
|
107152298e | ||
|
|
7a8b5f02d3 | ||
|
|
b508a5854a | ||
|
|
7167b40013 | ||
|
|
6965ba573e | ||
|
|
9f303b5311 | ||
|
|
8fc95e8189 | ||
|
|
5d51558c5c | ||
|
|
7181748d40 | ||
|
|
0dbb02d12a | ||
|
|
38b60e5626 | ||
|
|
246eb2b0bb | ||
|
|
d9a66749e7 | ||
|
|
8dfb1a4fb8 | ||
|
|
3a0ea0d321 | ||
|
|
baebc093f5 | ||
|
|
b4c3ebd254 | ||
|
|
257ac34224 | ||
|
|
159a28e890 | ||
|
|
328821cd14 | ||
|
|
46a30c6b80 | ||
|
|
8dfdc163bf | ||
|
|
13297fd017 | ||
|
|
0422066851 | ||
|
|
cf5ef80c08 | ||
|
|
c8e33c96ce | ||
|
|
2a6dbb4461 | ||
|
|
caba400f43 | ||
|
|
552fe6afa3 | ||
|
|
87e8df4ec5 | ||
|
|
4c192717de | ||
|
|
1bf32450fd | ||
|
|
963c6d3384 | ||
|
|
fbc75ec744 | ||
|
|
7bd99be41e | ||
|
|
cb6b9d1e55 | ||
|
|
441aa679f6 | ||
|
|
da7c16d5d0 | ||
|
|
defc5873fe | ||
|
|
7952c13c2b | ||
|
|
844313a8c6 | ||
|
|
efc6aa8a02 | ||
|
|
ff184da095 |
@@ -0,0 +1,39 @@
|
|||||||
|
# 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
|
||||||
|
# Server__PublicHost=echohub.example.com
|
||||||
|
# Server__Admins__0=adminUsername
|
||||||
|
|
||||||
|
# ── 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__CleanupIntervalHours=1
|
||||||
|
# Storage__RetentionDays=30
|
||||||
|
|
||||||
|
# ── 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!
|
||||||
|
|
||||||
|
# ── Logging ──────────────────────────────────────────────────────────
|
||||||
|
# Serilog__MinimumLevel__Default=Information
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
name: CI / Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [master]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint-markdown:
|
|
||||||
name: Markdown Lint
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Run markdownlint
|
|
||||||
run: bash scripts/lint-markdown.sh
|
|
||||||
|
|
||||||
format-check:
|
|
||||||
name: Format Check
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup .NET 10
|
|
||||||
uses: actions/setup-dotnet@v4
|
|
||||||
with:
|
|
||||||
dotnet-version: '10.0.x'
|
|
||||||
|
|
||||||
- name: Check formatting
|
|
||||||
run: dotnet format src/EchoHub.slnx --verify-no-changes --verbosity diagnostic
|
|
||||||
|
|
||||||
detect-changes:
|
|
||||||
name: Detect Changes
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
src_changed: ${{ steps.check.outputs.src_changed }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Check for changes
|
|
||||||
id: check
|
|
||||||
env:
|
|
||||||
BEFORE: ${{ github.event.before }}
|
|
||||||
run: |
|
|
||||||
if [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
|
|
||||||
# Initial push — treat everything as changed
|
|
||||||
echo "src_changed=true" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
SRC_CHANGED=$(git diff --name-only "$BEFORE" HEAD -- 'src/' | wc -l)
|
|
||||||
|
|
||||||
[ "$SRC_CHANGED" -gt 0 ] && echo "src_changed=true" >> "$GITHUB_OUTPUT" || echo "src_changed=false" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
test:
|
|
||||||
name: Build & Test
|
|
||||||
needs: [lint-markdown, format-check, detect-changes]
|
|
||||||
if: needs.detect-changes.outputs.src_changed == 'true'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup .NET 10
|
|
||||||
uses: actions/setup-dotnet@v4
|
|
||||||
with:
|
|
||||||
dotnet-version: '10.0.x'
|
|
||||||
|
|
||||||
- name: Cache NuGet packages
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/.nuget/packages
|
|
||||||
key: nuget-${{ runner.os }}-${{ hashFiles('src/**/*.csproj') }}
|
|
||||||
restore-keys: nuget-${{ runner.os }}-
|
|
||||||
|
|
||||||
- name: Restore dependencies
|
|
||||||
run: dotnet restore src/EchoHub.slnx
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: dotnet build src/EchoHub.slnx --no-restore --configuration Release
|
|
||||||
|
|
||||||
- name: Test
|
|
||||||
run: dotnet test src/EchoHub.slnx --no-build --configuration Release --verbosity normal
|
|
||||||
|
|
||||||
release:
|
|
||||||
name: Create Release
|
|
||||||
needs: [lint-markdown, format-check, detect-changes, test]
|
|
||||||
if: needs.detect-changes.outputs.src_changed == 'true'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup .NET 10
|
|
||||||
uses: actions/setup-dotnet@v4
|
|
||||||
with:
|
|
||||||
dotnet-version: '10.0.x'
|
|
||||||
|
|
||||||
- name: Read version
|
|
||||||
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 release exists
|
|
||||||
id: check_release
|
|
||||||
run: |
|
|
||||||
if gh release view "${{ steps.version.outputs.tag }}" &>/dev/null; then
|
|
||||||
echo "exists=true" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Publish Server win-x64
|
|
||||||
if: steps.check_release.outputs.exists == 'false'
|
|
||||||
run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r win-x64 --self-contained true -o publish/server-win-x64
|
|
||||||
|
|
||||||
- name: Publish Server linux-x64
|
|
||||||
if: steps.check_release.outputs.exists == 'false'
|
|
||||||
run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r linux-x64 --self-contained true -o publish/server-linux-x64
|
|
||||||
|
|
||||||
- name: Publish Server osx-x64
|
|
||||||
if: steps.check_release.outputs.exists == 'false'
|
|
||||||
run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r osx-x64 --self-contained true -o publish/server-osx-x64
|
|
||||||
|
|
||||||
- name: Publish Server osx-arm64
|
|
||||||
if: steps.check_release.outputs.exists == 'false'
|
|
||||||
run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r osx-arm64 --self-contained true -o publish/server-osx-arm64
|
|
||||||
|
|
||||||
- name: Publish Client win-x64
|
|
||||||
if: steps.check_release.outputs.exists == 'false'
|
|
||||||
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r win-x64 --self-contained true -o publish/client-win-x64
|
|
||||||
|
|
||||||
- name: Publish Client linux-x64
|
|
||||||
if: steps.check_release.outputs.exists == 'false'
|
|
||||||
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r linux-x64 --self-contained true -o publish/client-linux-x64
|
|
||||||
|
|
||||||
- name: Publish Client osx-x64
|
|
||||||
if: steps.check_release.outputs.exists == 'false'
|
|
||||||
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r osx-x64 --self-contained true -o publish/client-osx-x64
|
|
||||||
|
|
||||||
- name: Publish Client osx-arm64
|
|
||||||
if: steps.check_release.outputs.exists == 'false'
|
|
||||||
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r osx-arm64 --self-contained true -o publish/client-osx-arm64
|
|
||||||
|
|
||||||
- name: Zip artifacts
|
|
||||||
if: steps.check_release.outputs.exists == 'false'
|
|
||||||
run: |
|
|
||||||
cd publish
|
|
||||||
zip -r ../EchoHub-Server-win-x64.zip server-win-x64/
|
|
||||||
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-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/
|
|
||||||
|
|
||||||
- name: Create GitHub Release
|
|
||||||
if: steps.check_release.outputs.exists == 'false'
|
|
||||||
run: |
|
|
||||||
gh release create "${{ steps.version.outputs.tag }}" \
|
|
||||||
--title "EchoHub ${{ steps.version.outputs.tag }}" \
|
|
||||||
--generate-notes \
|
|
||||||
EchoHub-Server-win-x64.zip \
|
|
||||||
EchoHub-Server-linux-x64.zip \
|
|
||||||
EchoHub-Server-osx-x64.zip \
|
|
||||||
EchoHub-Server-osx-arm64.zip \
|
|
||||||
EchoHub-Client-win-x64.zip \
|
|
||||||
EchoHub-Client-linux-x64.zip \
|
|
||||||
EchoHub-Client-osx-x64.zip \
|
|
||||||
EchoHub-Client-osx-arm64.zip
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
name: PR Check
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
push:
|
||||||
branches: [master]
|
branches: [master]
|
||||||
|
pull_request:
|
||||||
|
branches: [master, dev]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint-markdown:
|
lint-markdown:
|
||||||
@@ -28,25 +31,30 @@ jobs:
|
|||||||
- name: Check formatting
|
- name: Check formatting
|
||||||
run: dotnet format src/EchoHub.slnx --verify-no-changes --verbosity diagnostic
|
run: dotnet format src/EchoHub.slnx --verify-no-changes --verbosity diagnostic
|
||||||
|
|
||||||
test:
|
build-and-test:
|
||||||
name: Build & Test
|
name: Build & Test
|
||||||
needs: [lint-markdown, format-check]
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Check for src/ changes
|
- name: Check for src/ changes
|
||||||
id: changes
|
id: changes
|
||||||
env:
|
env:
|
||||||
|
EVENT: ${{ github.event_name }}
|
||||||
|
BEFORE: ${{ github.event.before }}
|
||||||
BASE_REF: ${{ github.base_ref }}
|
BASE_REF: ${{ github.base_ref }}
|
||||||
run: |
|
run: |
|
||||||
git fetch origin "$BASE_REF" --depth=1
|
if [ "$EVENT" = "pull_request" ]; then
|
||||||
CHANGED=$(git diff --name-only "origin/$BASE_REF"...HEAD -- 'src/' | wc -l)
|
git fetch origin "$BASE_REF" --depth=1
|
||||||
if [ "$CHANGED" -gt 0 ]; then
|
CHANGED=$(git diff --name-only "origin/$BASE_REF"...HEAD -- 'src/' | wc -l)
|
||||||
echo "src_changed=true" >> "$GITHUB_OUTPUT"
|
elif [ -z "$BEFORE" ] || [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
|
||||||
|
CHANGED=1
|
||||||
else
|
else
|
||||||
echo "src_changed=false" >> "$GITHUB_OUTPUT"
|
CHANGED=$(git diff --name-only "$BEFORE" HEAD -- 'src/' | wc -l)
|
||||||
fi
|
fi
|
||||||
|
[ "$CHANGED" -gt 0 ] && echo "src_changed=true" >> "$GITHUB_OUTPUT" || echo "src_changed=false" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Setup .NET 10
|
- name: Setup .NET 10
|
||||||
if: steps.changes.outputs.src_changed == 'true'
|
if: steps.changes.outputs.src_changed == 'true'
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
name: Docker
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
IMAGE: ghcr.io/huebyte/echohub-server
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docker:
|
||||||
|
name: Build & Push Docker Image
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- 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"
|
||||||
|
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"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- 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."
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
name: Create Release
|
||||||
|
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
|
||||||
|
|
||||||
|
- 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"
|
||||||
|
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"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Read version
|
||||||
|
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 release exists
|
||||||
|
id: check_release
|
||||||
|
run: |
|
||||||
|
if gh release view "${{ steps.version.outputs.tag }}" &>/dev/null; then
|
||||||
|
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Setup .NET 10
|
||||||
|
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: "10.0.x"
|
||||||
|
|
||||||
|
- name: Publish Server win-x64
|
||||||
|
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
||||||
|
run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r win-x64 --self-contained true -o publish/server-win-x64
|
||||||
|
|
||||||
|
- name: Publish Server linux-x64
|
||||||
|
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
||||||
|
run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r linux-x64 --self-contained true -o publish/server-linux-x64
|
||||||
|
|
||||||
|
- name: Publish Server osx-x64
|
||||||
|
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
||||||
|
run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r osx-x64 --self-contained true -o publish/server-osx-x64
|
||||||
|
|
||||||
|
- name: Publish Server osx-arm64
|
||||||
|
if: steps.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 -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 -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 -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 -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'
|
||||||
|
run: |
|
||||||
|
cd publish
|
||||||
|
zip -r ../EchoHub-Server-win-x64.zip server-win-x64/
|
||||||
|
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'
|
||||||
|
run: |
|
||||||
|
TAG="${{ steps.version.outputs.tag }}"
|
||||||
|
CHANGELOG_URL="https://huebyte.github.io/EchoHub/changelog/${TAG}.html"
|
||||||
|
REPO="https://github.com/${{ github.repository }}"
|
||||||
|
PREV_TAG=$(git tag --sort=-v:refname | grep -v "^${TAG}$" | head -n 1)
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "📋 **[Full Changelog](${CHANGELOG_URL})**"
|
||||||
|
echo ""
|
||||||
|
echo "---"
|
||||||
|
echo ""
|
||||||
|
echo "### Commits"
|
||||||
|
echo ""
|
||||||
|
if [ -n "$PREV_TAG" ]; then
|
||||||
|
git log "${PREV_TAG}..HEAD" --pretty=format:"- %s (%h)" --no-merges
|
||||||
|
else
|
||||||
|
git log HEAD --pretty=format:"- %s (%h)" --no-merges
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
echo ""
|
||||||
|
if [ -n "$PREV_TAG" ]; then
|
||||||
|
echo "*Version diff: [${PREV_TAG}...${TAG}](${REPO}/compare/${PREV_TAG}...${TAG})*"
|
||||||
|
else
|
||||||
|
echo "*Version diff: [${TAG}](${REPO}/commits/${TAG})*"
|
||||||
|
fi
|
||||||
|
} > release-notes.md
|
||||||
|
|
||||||
|
- name: Create GitHub Release
|
||||||
|
if: steps.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-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-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 }}
|
||||||
@@ -433,3 +433,6 @@ src/EchoHub.Server/uploads/*
|
|||||||
# DocFx generated output
|
# DocFx generated output
|
||||||
docs/_site/
|
docs/_site/
|
||||||
docs/_api_meta/
|
docs/_api_meta/
|
||||||
|
|
||||||
|
# Diff files
|
||||||
|
*.diff
|
||||||
|
|||||||
@@ -9,7 +9,9 @@
|
|||||||
// Allow inline HTML (docfx uses it)
|
// Allow inline HTML (docfx uses it)
|
||||||
"MD033": false,
|
"MD033": false,
|
||||||
// Allow bare URLs
|
// Allow bare URLs
|
||||||
"MD034": false
|
"MD034": false,
|
||||||
|
// Allow compact table pipe style (flow docs use compact tables)
|
||||||
|
"MD060": false
|
||||||
},
|
},
|
||||||
|
|
||||||
"globs": ["**/*.md"],
|
"globs": ["**/*.md"],
|
||||||
|
|||||||
@@ -13,18 +13,28 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="#what-is-this">What</a> •
|
<a href="#what-is-this">What</a> •
|
||||||
<a href="#getting-started">Setup</a> •
|
<a href="#getting-started">Setup</a> •
|
||||||
|
<a href="#irc-gateway">IRC</a> •
|
||||||
|
<a href="#deployment-with-nginx">Deploy</a> •
|
||||||
<a href="#client-commands">Commands</a> •
|
<a href="#client-commands">Commands</a> •
|
||||||
<a href="#configuration">Config</a> •
|
<a href="#configuration">Config</a> •
|
||||||
<a href="#license">License</a>
|
<a href="#license">License</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
<a href="https://echohub.voidcube.cloud/">Website</a> •
|
||||||
|
<a href="https://echohub.voidcube.cloud/servers">Public Servers</a> •
|
||||||
|
<a href="https://huebyte.github.io/EchoHub/">Documentation</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<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>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -40,21 +50,25 @@ Each server is fully independent — no central authority, no account federation
|
|||||||
```mermaid
|
```mermaid
|
||||||
graph TD
|
graph TD
|
||||||
subgraph Server["Server"]
|
subgraph Server["Server"]
|
||||||
|
ChatSvc["ChatService"]
|
||||||
Hub["SignalR ChatHub"]
|
Hub["SignalR ChatHub"]
|
||||||
|
IRC["IRC Gateway :6667"]
|
||||||
Auth["JWT Auth"]
|
Auth["JWT Auth"]
|
||||||
DB["SQLite DB (EF Core)"]
|
DB["SQLite DB (EF Core)"]
|
||||||
Files["File Storage"]
|
Files["File Storage"]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph Client["Client"]
|
subgraph Clients["Clients"]
|
||||||
TUI["Terminal GUI (TUI)"]
|
TUI["Terminal GUI (TUI)"]
|
||||||
Theme["Theme Engine"]
|
IRCClient["IRC Client (irssi, WeeChat, ...)"]
|
||||||
API["API Client (auto-refresh)"]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
API -- "WebSocket" --> Hub
|
TUI -- "WebSocket" --> Hub
|
||||||
API -- "REST" --> Auth
|
TUI -- "REST" --> Auth
|
||||||
Hub --> DB
|
IRCClient -- "TCP" --> IRC
|
||||||
|
Hub --> ChatSvc
|
||||||
|
IRC --> ChatSvc
|
||||||
|
ChatSvc --> DB
|
||||||
Auth --> DB
|
Auth --> DB
|
||||||
Files --> DB
|
Files --> DB
|
||||||
```
|
```
|
||||||
@@ -64,9 +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
|
||||||
- **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
|
||||||
@@ -81,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
|
||||||
@@ -122,6 +160,66 @@ Connect, register, chat. That's the whole onboarding flow.
|
|||||||
dotnet build src/EchoHub.slnx
|
dotnet build src/EchoHub.slnx
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## IRC Gateway
|
||||||
|
|
||||||
|
EchoHub includes a built-in IRC protocol gateway. Any standard IRC client can connect to the same server and chat alongside TUI users — messages flow both ways in real time.
|
||||||
|
|
||||||
|
### Enable It
|
||||||
|
|
||||||
|
In the server's `appsettings.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Irc": {
|
||||||
|
"Enabled": true,
|
||||||
|
"Port": 6667,
|
||||||
|
"ServerName": "echohub",
|
||||||
|
"Motd": "Welcome to EchoHub IRC Gateway!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Connect
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# irssi
|
||||||
|
irssi -c your-server.com -p 6667 -w <password> -n <username>
|
||||||
|
|
||||||
|
# WeeChat
|
||||||
|
/server add echohub your-server.com/6667 -password=<password> -nicks=<username>
|
||||||
|
/connect echohub
|
||||||
|
```
|
||||||
|
|
||||||
|
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) |
|
||||||
|
| Images | `[Image: filename]` + download URL + ASCII art line-by-line |
|
||||||
|
| File uploads | `[File: filename] /api/files/{id}` |
|
||||||
|
| Channels | `JOIN`, `PART`, `NAMES`, `TOPIC`, `LIST` |
|
||||||
|
| Presence | `AWAY`, `WHO`, `WHOIS` |
|
||||||
|
| Status | Maps to IRC away/here |
|
||||||
|
|
||||||
|
### TLS
|
||||||
|
|
||||||
|
If running behind **nginx** (recommended), let nginx handle TLS -- see [Deployment with nginx](#deployment-with-nginx) below.
|
||||||
|
|
||||||
|
For direct TLS without a reverse proxy, the IRC gateway can terminate TLS itself:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Irc": {
|
||||||
|
"TlsEnabled": true,
|
||||||
|
"TlsPort": 6697,
|
||||||
|
"TlsCertPath": "/path/to/cert.pfx",
|
||||||
|
"TlsCertPassword": "your-password"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Client Commands
|
## Client Commands
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
@@ -172,35 +270,107 @@ dotnet build src/EchoHub.slnx
|
|||||||
| `Jwt:Secret` | *(auto-generated)* | JWT signing key |
|
| `Jwt:Secret` | *(auto-generated)* | JWT signing key |
|
||||||
| `Server:Name` | `My EchoHub Server` | Server display name |
|
| `Server:Name` | `My EchoHub Server` | Server display name |
|
||||||
| `Server:Description` | `A self-hosted EchoHub chat server` | Server description |
|
| `Server:Description` | `A self-hosted EchoHub chat server` | Server description |
|
||||||
|
| `Server:PublicServer` | `false` | List on the [public directory](https://echohub.voidcube.cloud/servers) |
|
||||||
|
| `Server:PublicHost` | *(empty)* | Public hostname for directory listing |
|
||||||
|
| `Irc:Enabled` | `false` | Enable the IRC gateway |
|
||||||
|
| `Irc:Port` | `6667` | IRC listen port |
|
||||||
|
| `Irc:TlsEnabled` | `false` | Enable TLS for IRC |
|
||||||
|
| `Irc:TlsPort` | `6697` | IRC TLS port |
|
||||||
|
| `Irc:ServerName` | `echohub` | IRC server name in protocol messages |
|
||||||
|
| `Irc:Motd` | `Welcome to EchoHub IRC Gateway!` | Message of the day |
|
||||||
| `Cors:AllowedOrigins` | *(all origins)* | CORS whitelist |
|
| `Cors:AllowedOrigins` | *(all origins)* | CORS whitelist |
|
||||||
|
|
||||||
Logging uses Serilog — console + daily rolling files with 14-day retention. Configure it in the `Serilog` section of appsettings.
|
Logging uses Serilog — console + daily rolling files with 14-day retention. Configure in the `Serilog` section.
|
||||||
|
|
||||||
|
## Deployment with nginx
|
||||||
|
|
||||||
|
Most production deployments run behind nginx. Here's a config that handles both the HTTP/WebSocket server and the IRC gateway:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
# HTTP + WebSocket (EchoHub Server API + SignalR)
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name echohub.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/echohub.example.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/echohub.example.com/privkey.pem;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:5000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# Required for SignalR WebSocket
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $http_connection;
|
||||||
|
|
||||||
|
proxy_read_timeout 86400s;
|
||||||
|
proxy_send_timeout 86400s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Increase max upload size for file sharing
|
||||||
|
client_max_body_size 10m;
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTP → HTTPS redirect
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name echohub.example.com;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
# IRC TLS (port 6697 → plain IRC on 6667)
|
||||||
|
stream {
|
||||||
|
upstream irc_backend {
|
||||||
|
server 127.0.0.1:6667;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 6697 ssl;
|
||||||
|
proxy_pass irc_backend;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/echohub.example.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/echohub.example.com/privkey.pem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
With this setup, keep the EchoHub IRC gateway's `TlsEnabled` set to `false` — nginx terminates TLS. See the full example at [`examples/nginx.conf`](examples/nginx.conf).
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```text
|
```text
|
||||||
src/
|
src/
|
||||||
├── EchoHub.Core/ # Shared models, DTOs, contracts, validation
|
├── EchoHub.Core/ # Shared models, DTOs, contracts, validation
|
||||||
│ ├── Constants/ # ValidationConstants, HubConstants
|
│ ├── Constants/ # ValidationConstants, HubConstants
|
||||||
│ ├── Contracts/ # IEchoHubClient (SignalR interface)
|
│ ├── Contracts/ # IChatService, IChatBroadcaster, IEchoHubClient
|
||||||
│ ├── DTOs/ # Record DTOs
|
│ ├── DTOs/ # Record DTOs
|
||||||
│ └── Models/ # Entity models
|
│ └── Models/ # Entity models
|
||||||
│
|
│
|
||||||
├── EchoHub.Server/ # ASP.NET Core server
|
├── EchoHub.Server/ # ASP.NET Core server
|
||||||
│ ├── Auth/ # JWT token service
|
│ ├── Auth/ # JWT token service
|
||||||
│ ├── Controllers/ # REST API endpoints
|
│ ├── Controllers/ # REST API endpoints
|
||||||
│ ├── Data/ # EF Core DbContext + migrations
|
│ ├── Data/ # EF Core DbContext + migrations
|
||||||
│ ├── Hubs/ # SignalR ChatHub
|
│ ├── Hubs/ # SignalR ChatHub
|
||||||
│ ├── Services/ # Presence, file storage, image processing
|
│ ├── Services/ # ChatService, presence, file storage, image processing
|
||||||
│ └── Setup/ # First-run setup, DB initialization
|
│ └── Setup/ # First-run setup, DB initialization
|
||||||
│
|
│
|
||||||
├── EchoHub.Client/ # Terminal.Gui TUI client
|
├── EchoHub.Server.Irc/ # IRC protocol gateway
|
||||||
│ ├── Config/ # Client configuration
|
│ ├── IrcGatewayService # TCP listener (BackgroundService)
|
||||||
│ ├── Services/ # API client, SignalR connection
|
│ ├── IrcCommandHandler # IRC command dispatch (JOIN, PRIVMSG, etc.)
|
||||||
│ ├── Themes/ # 6 built-in themes
|
│ ├── IrcBroadcaster # Fans chat events to IRC connections
|
||||||
│ └── UI/ # MainWindow, dialogs, chat renderer
|
│ └── IrcMessageFormatter # MessageDto → IRC PRIVMSG lines
|
||||||
│
|
│
|
||||||
└── EchoHub.slnx # Solution file
|
├── EchoHub.Client/ # Terminal.Gui TUI client
|
||||||
|
│ ├── Config/ # Client configuration
|
||||||
|
│ ├── Services/ # API client, SignalR connection
|
||||||
|
│ ├── Themes/ # 13 built-in themes
|
||||||
|
│ └── UI/ # MainWindow, dialogs, chat renderer
|
||||||
|
│
|
||||||
|
└── EchoHub.slnx # Solution file
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
@@ -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 |
@@ -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:
|
||||||
@@ -5,6 +5,9 @@ Articles related to the EchoHub TUI client built with Terminal.Gui v2.
|
|||||||
## Topics
|
## Topics
|
||||||
|
|
||||||
- Terminal.Gui v2 patterns and conventions
|
- Terminal.Gui v2 patterns and conventions
|
||||||
- Theme system and customization
|
- Theme system and customization (including transparent theme)
|
||||||
- Command system reference
|
- Command system reference
|
||||||
- Configuration management
|
- Configuration and session persistence
|
||||||
|
- Audio playback and file downloads
|
||||||
|
- Automatic update checking
|
||||||
|
- [Notification sounds](../../articles/notification-sounds.md)
|
||||||
|
|||||||
@@ -5,5 +5,5 @@ Articles related to the EchoHub.Core shared library.
|
|||||||
## Topics
|
## Topics
|
||||||
|
|
||||||
- Data models and DTOs
|
- Data models and DTOs
|
||||||
- SignalR contract interface
|
- Contract interfaces (IChatService, IChannelService, IChatBroadcaster, IEchoHubClient)
|
||||||
- Validation constants and shared rules
|
- Validation constants and shared rules
|
||||||
|
|||||||
+6
-2
@@ -10,8 +10,12 @@ Terminal.Gui v2 TUI application -- UI components, services, themes, and configur
|
|||||||
|
|
||||||
### Core
|
### Core
|
||||||
|
|
||||||
Shared library -- DTOs, models, constants, and the SignalR client contract.
|
Shared library -- DTOs, models, constants, and contracts (`IChatService`, `IChannelService`, `IChatBroadcaster`, `IEchoHubClient`).
|
||||||
|
|
||||||
### Server
|
### Server
|
||||||
|
|
||||||
ASP.NET Core server -- controllers, hubs, authentication, and data access.
|
ASP.NET Core server -- controllers, hubs, ChatService, ChannelService, SignalRBroadcaster, authentication, file cleanup, and data access.
|
||||||
|
|
||||||
|
### Server.Irc
|
||||||
|
|
||||||
|
IRC protocol gateway -- TCP listener, command handler, IrcBroadcaster, and message formatter.
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ Articles related to the EchoHub server built with ASP.NET Core.
|
|||||||
|
|
||||||
- Authentication and JWT tokens
|
- Authentication and JWT tokens
|
||||||
- SignalR hub and real-time messaging
|
- SignalR hub and real-time messaging
|
||||||
- File upload and validation
|
- IRC gateway and protocol bridging
|
||||||
|
- ChatService and broadcaster pattern
|
||||||
|
- ChannelService and channel CRUD
|
||||||
|
- File upload, validation, and cleanup
|
||||||
- Rate limiting configuration
|
- Rate limiting configuration
|
||||||
- Database schema and migrations
|
- Database schema, migrations, and DataMigrationService
|
||||||
|
|||||||
+7
-3
@@ -3,16 +3,20 @@
|
|||||||
- name: API Reference
|
- name: API Reference
|
||||||
href: ../_api_meta/client/toc.yml
|
href: ../_api_meta/client/toc.yml
|
||||||
- name: Articles
|
- name: Articles
|
||||||
href: client-articles/
|
href: client-articles/toc.yml
|
||||||
- name: Core
|
- name: Core
|
||||||
items:
|
items:
|
||||||
- name: API Reference
|
- name: API Reference
|
||||||
href: ../_api_meta/core/toc.yml
|
href: ../_api_meta/core/toc.yml
|
||||||
- name: Articles
|
- name: Articles
|
||||||
href: core-articles/
|
href: core-articles/toc.yml
|
||||||
- name: Server
|
- name: Server
|
||||||
items:
|
items:
|
||||||
- name: API Reference
|
- name: API Reference
|
||||||
href: ../_api_meta/server/toc.yml
|
href: ../_api_meta/server/toc.yml
|
||||||
- name: Articles
|
- name: Articles
|
||||||
href: server-articles/
|
href: server-articles/toc.yml
|
||||||
|
- name: Server.Irc
|
||||||
|
items:
|
||||||
|
- name: API Reference
|
||||||
|
href: ../_api_meta/server-irc/toc.yml
|
||||||
|
|||||||
@@ -4,6 +4,16 @@
|
|||||||
|
|
||||||
EchoHub follows a decentralized model where each server is fully independent. There is no central authority or account federation. Users create one account per server.
|
EchoHub follows a decentralized model where each server is fully independent. There is no central authority or account federation. Users create one account per server.
|
||||||
|
|
||||||
|
The server exposes two protocol interfaces to the same chat backend:
|
||||||
|
|
||||||
|
```text
|
||||||
|
IRC Client ──► TCP :6667 ──► IrcGateway ──┐
|
||||||
|
├──► ChatService ──► DB + PresenceTracker
|
||||||
|
TUI Client ──► WebSocket ──► ChatHub ─────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Both protocols call into a shared `IChatService` for business logic. Events fan out to all registered `IChatBroadcaster` implementations (SignalR and IRC).
|
||||||
|
|
||||||
## Components
|
## Components
|
||||||
|
|
||||||
### EchoHub.Core
|
### EchoHub.Core
|
||||||
@@ -12,7 +22,7 @@ Shared library containing:
|
|||||||
|
|
||||||
- **Models**: `User`, `Channel`, `Message`, `RefreshToken`
|
- **Models**: `User`, `Channel`, `Message`, `RefreshToken`
|
||||||
- **DTOs**: Record types for API requests/responses
|
- **DTOs**: Record types for API requests/responses
|
||||||
- **Contracts**: `IEchoHubClient` -- the strongly-typed SignalR client interface
|
- **Contracts**: `IChatService` (protocol-agnostic chat operations), `IChannelService` (channel CRUD and membership), `IChatBroadcaster` (event fan-out interface), `IEchoHubClient` (SignalR client interface)
|
||||||
- **Constants**: `ValidationConstants` (shared regex patterns), `HubConstants`
|
- **Constants**: `ValidationConstants` (shared regex patterns), `HubConstants`
|
||||||
|
|
||||||
### EchoHub.Server
|
### EchoHub.Server
|
||||||
@@ -20,22 +30,44 @@ Shared library containing:
|
|||||||
ASP.NET Core web application:
|
ASP.NET Core web application:
|
||||||
|
|
||||||
- **Controllers**: REST API endpoints for auth, channels, users, files, server info
|
- **Controllers**: REST API endpoints for auth, channels, users, files, server info
|
||||||
- **Hubs**: SignalR `ChatHub` for real-time messaging
|
- **Hubs**: SignalR `ChatHub` -- thin adapter delegating to `IChatService`
|
||||||
- **Auth**: JWT token service (15-min access tokens, 30-day refresh tokens)
|
- **Auth**: JWT token service (15-min access tokens, 30-day refresh tokens)
|
||||||
- **Data**: EF Core with SQLite
|
- **Data**: EF Core with SQLite
|
||||||
- **Services**: Presence tracking, file storage, image-to-ASCII conversion
|
- **Services**: `ChatService` (core business logic), `ChannelService` (channel CRUD and membership), `SignalRBroadcaster`, presence tracking, file storage, image-to-ASCII conversion, `FileCleanupService` (periodic removal of expired uploads), `DataMigrationService` (startup schema/data evolution)
|
||||||
|
|
||||||
|
### EchoHub.Server.Irc
|
||||||
|
|
||||||
|
IRC protocol gateway (separate project for clean separation of concerns):
|
||||||
|
|
||||||
|
- **IrcGatewayService**: `BackgroundService` with TCP listener on configured port(s), optional TLS
|
||||||
|
- **IrcCommandHandler**: Per-client IRC command dispatch -- handles `CAP`/`SASL`, `NICK`/`USER`/`PASS`, `JOIN`/`PART`/`PRIVMSG`/`QUIT`, `NAMES`/`TOPIC`/`WHO`/`WHOIS`/`AWAY`/`LIST`/`MODE`/`MOTD`
|
||||||
|
- **IrcBroadcaster**: `IChatBroadcaster` implementation that formats chat events as IRC protocol lines, with echo suppression (IRC convention)
|
||||||
|
- **IrcMessageFormatter**: Converts `MessageDto` to IRC `PRIVMSG` lines -- splits long text at word boundaries (~400 byte chunks), sends images as ASCII art line-by-line
|
||||||
|
|
||||||
|
IRC users authenticate with existing EchoHub accounts via `PASS`/`NICK`/`USER` or SASL PLAIN (BCrypt verification against the database).
|
||||||
|
|
||||||
### EchoHub.Client
|
### EchoHub.Client
|
||||||
|
|
||||||
Terminal.Gui v2 TUI application:
|
Terminal.Gui v2 TUI application:
|
||||||
|
|
||||||
- **UI**: Main window, dialogs, chat renderer with ANSI color support
|
- **UI**: Main window, dialogs, chat renderer with ANSI color support
|
||||||
- **Services**: API client with automatic token refresh, SignalR connection wrapper
|
- **Services**: API client with automatic token refresh, SignalR connection wrapper, audio playback (NetCoreAudio), automatic update checker (AlwaysUpToDate)
|
||||||
- **Themes**: 6 built-in color themes
|
- **Themes**: 13 built-in color themes (including transparent theme with true terminal transparency)
|
||||||
- **Config**: Client configuration management
|
- **Config**: Client configuration management with session persistence ("Remember Me" refresh tokens)
|
||||||
|
|
||||||
## Communication
|
## Communication
|
||||||
|
|
||||||
- REST API for authentication, profile management, channel CRUD, file uploads
|
- **REST API** for authentication, profile management, channel CRUD, file uploads
|
||||||
- SignalR WebSocket for real-time messaging and presence updates
|
- **SignalR WebSocket** for real-time messaging and presence updates (TUI client)
|
||||||
|
- **IRC TCP** for real-time messaging via standard IRC protocol (IRC clients)
|
||||||
- JWT tokens passed via query string for SignalR authentication
|
- JWT tokens passed via query string for SignalR authentication
|
||||||
|
- IRC authentication via PASS/SASL PLAIN against BCrypt password hashes
|
||||||
|
|
||||||
|
## Broadcaster Pattern
|
||||||
|
|
||||||
|
The `IChatBroadcaster` interface allows multiple protocols to receive chat events:
|
||||||
|
|
||||||
|
- **SignalRBroadcaster**: Wraps `IHubContext<ChatHub>`, filters out IRC connections
|
||||||
|
- **IrcBroadcaster**: Iterates live IRC connections in a channel, formats events as IRC protocol lines
|
||||||
|
|
||||||
|
Both are registered in DI and called by `ChatService` when events occur. This means a message sent from an IRC client appears in the TUI client, and vice versa.
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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/`.
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# Message Encryption
|
||||||
|
|
||||||
|
EchoHub uses application-layer AES-256-GCM encryption to protect message content in transit between clients and the server. This is an additional layer on top of TLS, protecting against ISPs, proxies, and any middleman that can inspect HTTPS traffic (e.g. corporate proxies with trusted root CA certificates).
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Sender as TUI Client (Sender)
|
||||||
|
participant Server
|
||||||
|
participant Receiver as TUI Client (Receiver)
|
||||||
|
participant IRC as IRC Client
|
||||||
|
|
||||||
|
Sender->>Sender: encrypt(plaintext)
|
||||||
|
Sender->>Server: $ENC$v1$... (SignalR)
|
||||||
|
Server->>Server: decrypt → validate/sanitize
|
||||||
|
Server->>Server: fetch embeds on plaintext
|
||||||
|
Server->>Server: (optional) encrypt for DB storage
|
||||||
|
Server->>Server: encrypt(plaintext) with fresh nonce
|
||||||
|
Server->>Receiver: $ENC$v1$... (SignalR broadcast)
|
||||||
|
Receiver->>Receiver: decrypt → display
|
||||||
|
Server->>Server: decrypt for IRC
|
||||||
|
Server->>IRC: plaintext (IRC PRIVMSG)
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **Client encrypts** the message before sending it over SignalR
|
||||||
|
2. **Server decrypts** to validate content, sanitize newlines, and fetch link embeds
|
||||||
|
3. **Server re-encrypts** with a fresh nonce and broadcasts to all connected SignalR clients
|
||||||
|
4. **Clients decrypt** the broadcast and display the plaintext
|
||||||
|
5. **IRC clients** receive plaintext automatically (the IRC broadcaster decrypts before forwarding)
|
||||||
|
|
||||||
|
Each encryption uses a random 12-byte nonce, so the same message produces different ciphertext every time.
|
||||||
|
|
||||||
|
## Encryption Key
|
||||||
|
|
||||||
|
A 256-bit AES key is auto-generated on first server startup and saved to `appsettings.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Encryption": {
|
||||||
|
"Key": "base64-encoded-32-byte-key",
|
||||||
|
"EncryptDatabase": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The key is generated by `FirstRunSetup` using `RandomNumberGenerator.GetBytes(32)`. If the key is missing or empty when the server starts, a new one is created automatically.
|
||||||
|
|
||||||
|
Clients fetch the key after login via an authenticated endpoint (`GET /api/server/encryption-key`). No manual configuration is needed on the client side.
|
||||||
|
|
||||||
|
## Encrypted Content Format
|
||||||
|
|
||||||
|
Encrypted content uses a self-describing format:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ENC$v1${nonce_base64}${ciphertext+tag_base64}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`$ENC$v1$`** — version prefix (allows future algorithm changes)
|
||||||
|
- **Nonce** — 12 bytes, Base64-encoded, randomly generated per message
|
||||||
|
- **Ciphertext + Tag** — AES-256-GCM output with 16-byte authentication tag appended
|
||||||
|
|
||||||
|
The authentication tag ensures both confidentiality and integrity — any tampering with the ciphertext is detected during decryption.
|
||||||
|
|
||||||
|
## Database Encryption (Optional)
|
||||||
|
|
||||||
|
By default, messages are stored as **plaintext** in the database. Transport encryption still protects everything on the wire, but the SQLite file itself contains readable messages.
|
||||||
|
|
||||||
|
To encrypt messages at rest, enable the setting:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Encryption": {
|
||||||
|
"Key": "...",
|
||||||
|
"EncryptDatabase": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Behavior by Setting
|
||||||
|
|
||||||
|
| Setting | DB Storage | Transport | Key Rotation Risk |
|
||||||
|
| ----------------- | ---------- | --------- | ---------------------------------------------- |
|
||||||
|
| `false` (default) | Plaintext | Encrypted | None - stored data is unaffected |
|
||||||
|
| `true` | Encrypted | Encrypted | Changing the key makes old messages unreadable |
|
||||||
|
|
||||||
|
### Mixed Content
|
||||||
|
|
||||||
|
The server handles mixed encrypted/plaintext content in the database gracefully. When reading messages:
|
||||||
|
|
||||||
|
- Content starting with `$ENC$v1$` is decrypted
|
||||||
|
- Everything else is treated as plaintext
|
||||||
|
|
||||||
|
This means you can safely toggle `EncryptDatabase` at any time. Old messages remain readable regardless of the current setting.
|
||||||
|
|
||||||
|
### Enabling Encryption at Rest
|
||||||
|
|
||||||
|
When you set `EncryptDatabase: true`, only **new messages** are encrypted going forward. Existing plaintext messages in the database are not retroactively encrypted. This is intentional — it keeps key rotation safe and avoids irreversible bulk changes.
|
||||||
|
|
||||||
|
### Key Rotation
|
||||||
|
|
||||||
|
Changing the encryption key is safe:
|
||||||
|
|
||||||
|
- **Plaintext messages** — always readable regardless of key
|
||||||
|
- **Messages encrypted with the old key** — will show `[encrypted message — decryption failed]`
|
||||||
|
- **New messages** — encrypted with the new key going forward
|
||||||
|
|
||||||
|
If you need to recover old encrypted messages, restore the original key from a backup of `appsettings.json`. Since plaintext messages are never retroactively encrypted, you'll never lose access to your entire history from a key change.
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### What This Protects Against
|
||||||
|
|
||||||
|
- **Passive network sniffing** — messages are encrypted even if captured off the wire
|
||||||
|
- **ISP/proxy inspection** — content is encrypted at the application layer, independent of TLS
|
||||||
|
- **Database theft** (when `EncryptDatabase: true`) — SQLite file contains only ciphertext
|
||||||
|
|
||||||
|
### Limitations
|
||||||
|
|
||||||
|
- **TLS-inspecting proxies** — if a corporate proxy terminates TLS with a trusted root CA, it can intercept the key exchange (`GET /api/server/encryption-key`) and read all traffic. A future upgrade to ECDH key exchange would address this.
|
||||||
|
- **Server has full access** — the server decrypts all messages for processing. This is not end-to-end encryption between users; it's transport encryption between client and server.
|
||||||
|
- **IRC clients receive plaintext** — IRC is an open protocol and third-party clients cannot participate in the encryption scheme.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Messages show `[encrypted message — decryption failed, try re-logging to fetch the latest key]`
|
||||||
|
|
||||||
|
The client's encryption key doesn't match the server's. This happens when:
|
||||||
|
|
||||||
|
- The server's encryption key was rotated while the client was connected
|
||||||
|
- The client cached a stale key
|
||||||
|
|
||||||
|
**Fix**: Disconnect and reconnect (re-login). The client fetches the current key on each login.
|
||||||
|
|
||||||
|
### Messages show `[encrypted message — decryption failed]` in channel history
|
||||||
|
|
||||||
|
The server cannot decrypt messages stored in the database. This happens when:
|
||||||
|
|
||||||
|
- `EncryptDatabase` was enabled, and the key was changed afterwards
|
||||||
|
|
||||||
|
**Fix**: Restore the original key from a backup. There is no way to recover messages encrypted with a lost key.
|
||||||
@@ -1,28 +1,92 @@
|
|||||||
# Getting Started
|
# Getting Started
|
||||||
|
|
||||||
## Prerequisites
|
## Install the Client
|
||||||
|
|
||||||
- [.NET 10 SDK](https://dotnet.microsoft.com/download)
|
### Windows (Chocolatey)
|
||||||
|
|
||||||
## Run the Server
|
```bash
|
||||||
|
choco install echohub
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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.10
|
||||||
|
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
|
```bash
|
||||||
dotnet run --project src/EchoHub.Server
|
dotnet run --project src/EchoHub.Server
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Requires [.NET 10 SDK](https://dotnet.microsoft.com/download).
|
||||||
|
|
||||||
On first run, the server automatically:
|
On first run, the server automatically:
|
||||||
|
|
||||||
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 SQLite database with a `#general` channel
|
3. Creates the SQLite database with a `#general` channel
|
||||||
|
|
||||||
## Run the Client
|
## Usage
|
||||||
|
|
||||||
```bash
|
After installing the client, run `echohub` (or `dotnet run --project src/EchoHub.Client` from source).
|
||||||
dotnet run --project src/EchoHub.Client
|
Connect to a server, register an account, and start chatting.
|
||||||
|
|
||||||
|
## Connect via IRC
|
||||||
|
|
||||||
|
Enable the IRC gateway in the server's `appsettings.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Irc": {
|
||||||
|
"Enabled": true,
|
||||||
|
"Port": 6667
|
||||||
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Connect to a server, register an account, and start chatting.
|
Then connect with any standard IRC client:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
irssi -c localhost -p 6667 -w <password> -n <username>
|
||||||
|
```
|
||||||
|
|
||||||
|
IRC users must have an existing EchoHub account. Authentication works via `PASS`/`NICK`/`USER` or SASL PLAIN. Messages flow bidirectionally between IRC and TUI clients.
|
||||||
|
|
||||||
|
For TLS, set `TlsEnabled: true`, `TlsPort: 6697`, and provide a PKCS#12 certificate path.
|
||||||
|
|
||||||
|
See the [Architecture](architecture.md) page for details on how the IRC gateway integrates with the chat service.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Server configuration is in `appsettings.json` (auto-generated on first run). You can also use environment variables or command-line arguments to override settings.
|
||||||
|
|
||||||
|
See the [Configuration](configuration.md) guide for the full reference and how it all works.
|
||||||
|
|
||||||
## Build from Source
|
## Build from Source
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Notification Sounds
|
||||||
|
|
||||||
|
EchoHub can play a notification sound when someone @mentions you. This is **disabled by default** and must be enabled in your profile settings.
|
||||||
|
|
||||||
|
## Enabling Notifications
|
||||||
|
|
||||||
|
Open your profile (`/profile`) and check the **"Notification sound on @mention"** checkbox, then save. You can also adjust the **Volume** (0-100, default 30). All settings are persisted in `~/.echohub/config.json`.
|
||||||
|
|
||||||
|
## Customizing the Sound
|
||||||
|
|
||||||
|
The client ships with a default `Notification.mp3` in the `Assets` folder. To use your own notification sound, replace the file at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<app-directory>/Assets/Notification.mp3
|
||||||
|
```
|
||||||
|
|
||||||
|
The file must be a valid `.mp3` or `.wav` audio file. The replacement takes effect on the next app launch.
|
||||||
|
|
||||||
|
Alternatively, set a custom path in `~/.echohub/config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"notifications": {
|
||||||
|
"enabled": true,
|
||||||
|
"volume": 30,
|
||||||
|
"soundFile": "/path/to/your/sound.mp3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When `soundFile` is set, EchoHub uses that file instead of the bundled default.
|
||||||
|
|
||||||
|
## Disabling Notifications
|
||||||
|
|
||||||
|
Uncheck the option in your profile, or edit the config directly:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"notifications": {
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
+16
-4
@@ -1,4 +1,16 @@
|
|||||||
- name: Getting Started
|
- name: Guides
|
||||||
href: getting-started.md
|
items:
|
||||||
- name: Architecture
|
- name: Getting Started
|
||||||
href: architecture.md
|
href: getting-started.md
|
||||||
|
- name: Docker
|
||||||
|
href: docker.md
|
||||||
|
- name: Architecture
|
||||||
|
href: architecture.md
|
||||||
|
- name: Configuration
|
||||||
|
href: configuration.md
|
||||||
|
- name: Encryption
|
||||||
|
href: encryption.md
|
||||||
|
- name: Notification Sounds
|
||||||
|
href: notification-sounds.md
|
||||||
|
- name: Flows
|
||||||
|
href: ../flows/toc.yml
|
||||||
|
|||||||
@@ -4,4 +4,16 @@ 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.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
|
||||||
|
- [v0.2.0](v0.2.0.md) - IRC Gateway
|
||||||
|
- [v0.1.1](v0.1.1.md) - Directory Connection Self-Healing
|
||||||
- [v0.1.0](v0.1.0.md) - Initial Release
|
- [v0.1.0](v0.1.0.md) - Initial Release
|
||||||
|
|||||||
@@ -1,4 +1,28 @@
|
|||||||
- 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
|
||||||
|
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
|
||||||
|
href: v0.2.2.md
|
||||||
|
- name: v0.2.1
|
||||||
|
href: v0.2.1.md
|
||||||
|
- name: v0.2.0
|
||||||
|
href: v0.2.0.md
|
||||||
|
- name: v0.1.1
|
||||||
|
href: v0.1.1.md
|
||||||
- name: v0.1.0
|
- name: v0.1.0
|
||||||
href: v0.1.0.md
|
href: v0.1.0.md
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# v0.1.1 - Directory Connection Self-Healing
|
||||||
|
|
||||||
|
## Fixes
|
||||||
|
|
||||||
|
- Server now reconnects to the EchoHubSpace directory indefinitely when the API goes down, using exponential backoff (2s → 30s cap)
|
||||||
|
- If automatic reconnect is exhausted, the connection is rebuilt from scratch automatically
|
||||||
|
- Initial connection attempts also use exponential backoff instead of a fixed 30s delay
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# v0.2.0 - IRC Gateway
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **IRC protocol gateway** -- native IRC clients (irssi, WeeChat, HexChat, etc.) can connect to EchoHub servers
|
||||||
|
- **Cross-protocol messaging** -- messages flow bidirectionally between IRC and TUI clients in real time
|
||||||
|
- **SASL PLAIN authentication** -- IRC clients can authenticate via SASL or traditional PASS/NICK/USER
|
||||||
|
- **Full IRC command support** -- JOIN, PART, PRIVMSG, QUIT, NAMES, TOPIC, WHO, WHOIS, AWAY, LIST, MODE, MOTD
|
||||||
|
- **TLS support** -- optional encrypted IRC connections on port 6697
|
||||||
|
- **Image-to-IRC formatting** -- images appear as ASCII art line-by-line with download URLs
|
||||||
|
- **Message splitting** -- long messages automatically split at word boundaries (~400 byte chunks)
|
||||||
|
- **Configurable MOTD** -- server message of the day for IRC clients
|
||||||
|
|
||||||
|
## Architecture Changes
|
||||||
|
|
||||||
|
- Extracted shared business logic from `ChatHub` into protocol-agnostic `IChatService`
|
||||||
|
- Introduced `IChatBroadcaster` pattern for multi-protocol event fan-out
|
||||||
|
- `ChatHub` refactored to thin adapter delegating to `IChatService`
|
||||||
|
- `ChannelsController` updated to use `IChatService` for broadcasts
|
||||||
|
- New `EchoHub.Server.Irc` project for clean separation of concerns
|
||||||
|
- `IChatService` and `IChatBroadcaster` interfaces live in `EchoHub.Core/Contracts`
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# v0.2.1 - Shutdown & CI Fixes
|
||||||
|
|
||||||
|
## Fixes
|
||||||
|
|
||||||
|
- Fixed server hanging on Ctrl+C when the directory server is unreachable — `StopAsync` now cancels background services before disposing connections
|
||||||
|
- Fixed IRC gateway shutdown blocking indefinitely on unresponsive clients — send operations are now bounded to 2 seconds
|
||||||
|
- Fixed CI release workflow not having full git history for building release notes (`fetch-depth: 0`)
|
||||||
|
- Fixed `workflow_dispatch` trigger breaking change detection when `github.event.before` is empty
|
||||||
|
|
||||||
|
## Improvements
|
||||||
|
|
||||||
|
- GitHub releases now include a commit list and version diff link instead of generic auto-generated notes
|
||||||
|
- GitHub releases link to the full changelog on the docs site
|
||||||
@@ -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`
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# v0.2.2 - Startup & Shutdown Fixes
|
||||||
|
|
||||||
|
## Fixes
|
||||||
|
|
||||||
|
- Fixed server hanging on startup when IRC gateway is enabled — circular DI dependency between `IrcGatewayService` → `IChatService` → `IChatBroadcaster` → `IrcBroadcaster` caused the DI container to deadlock
|
||||||
|
- Fixed `SignalRBroadcaster` eagerly resolving `IHubContext<ChatHub>` during DI construction, which could deadlock on some platforms — now lazy-resolves via `IServiceProvider` on first use
|
||||||
|
- Simplified IRC service registration to use standard `AddSingleton<IChatBroadcaster, IrcBroadcaster>` instead of manual factory, breaking the circular resolution chain
|
||||||
|
- Fixed server hanging on Ctrl+C — replaced `await using` with explicit dispose bounded to 3 seconds, so a stuck `HubConnection` can no longer block shutdown
|
||||||
|
- Reduced host shutdown timeout from 30s (default) to 5s
|
||||||
|
- Caught `OperationCanceledException` in the directory service reconnect loop so cancellation exits immediately instead of propagating through dispose
|
||||||
|
|
||||||
|
## Refactoring
|
||||||
|
|
||||||
|
- Replaced primary constructors with standard constructor injection across all server classes for consistency
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# v0.2.3 - Moderation, Embeds & UI Overhaul
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Moderation System
|
||||||
|
|
||||||
|
- Added server roles: Owner, Admin, Mod, Member — first registered user is automatically Owner
|
||||||
|
- New `/kick`, `/ban`, `/unban`, `/mute`, `/unmute`, `/role`, `/nuke` commands for moderators and admins
|
||||||
|
- `ModerationController` with full REST API for role assignment, kicks, bans, mutes, message deletion, and channel nuking
|
||||||
|
- Mutes support optional duration (auto-expire) and blocked users cannot log in
|
||||||
|
- Role claim included in JWT tokens; role badges shown in the online users panel
|
||||||
|
- Kicked and banned users are forcibly disconnected in real time — server cleans up presence, broadcasts departures, and signals client disconnect
|
||||||
|
- Works for both SignalR and IRC connections; client shows an error dialog with the reason
|
||||||
|
|
||||||
|
### Private Channels
|
||||||
|
|
||||||
|
- Channels can be created as public or private via a checkbox in the Create Channel dialog
|
||||||
|
- Public channels are visible to all users; private channels only appear for members who joined them
|
||||||
|
- Persistent channel membership tracked in the database (`ChannelMembership` table)
|
||||||
|
- `GET /api/channels` returns the combined list: public channels + user's joined private channels
|
||||||
|
- Channel creators are automatically added as members
|
||||||
|
|
||||||
|
### OpenGraph Link Embeds
|
||||||
|
|
||||||
|
- Messages containing URLs now show a rich preview below the message text
|
||||||
|
- Multiple URLs per message supported (up to 3) — each gets its own embed
|
||||||
|
- Server-side fetching: detects URLs in a message, fetches each page, and parses OpenGraph meta tags (`og:title`, `og:description`, `og:site_name`)
|
||||||
|
- Embeds are persisted in the database as a JSON array and included in channel history
|
||||||
|
- TUI client renders embeds with a `▏` left border bar — site name and border in blue, title in white, description in gray; text word-wraps at actual viewport width
|
||||||
|
- IRC gateway receives a text-only embed preview (site name, title, description)
|
||||||
|
- Falls back to `<title>` tag when no OG tags are present; gracefully skips if no useful metadata is found
|
||||||
|
- 5-second fetch timeout ensures message delivery is never significantly delayed
|
||||||
|
- SSRF protection rejects private/loopback IP addresses before fetching
|
||||||
|
|
||||||
|
### Notification Sounds
|
||||||
|
|
||||||
|
- Incoming messages play a notification sound when the terminal is not focused
|
||||||
|
- Embedded MP3 asset with cross-platform playback support
|
||||||
|
|
||||||
|
### Online Users Panel
|
||||||
|
|
||||||
|
- Collapsible right-side panel showing online users in the current channel (toggle with F2)
|
||||||
|
- Users displayed with status indicators, role badges, and their custom nickname colors
|
||||||
|
- Panel updates on join, leave, and status change events
|
||||||
|
|
||||||
|
### @mention Highlighting
|
||||||
|
|
||||||
|
- `@username` text rendered in orange accent color in all messages
|
||||||
|
- Messages mentioning the current user get a full-line amber background highlight
|
||||||
|
- Works across multi-line messages and continuation lines
|
||||||
|
|
||||||
|
### ASCII Art Improvements
|
||||||
|
|
||||||
|
- Half-block character rendering (`▀`/`█`) with separate foreground + background colors for 2x vertical resolution
|
||||||
|
- Switched from ANSI escape codes to printable color tags (`{F:RRGGBB}`, `{B:RRGGBB}`, `{X}`) — no control bytes in stored content
|
||||||
|
- Optional size parameter for `/send` command: `-s` (40x40), `-m` (80x80, default), `-l` (120x120)
|
||||||
|
- IRC gateway converts color tags back to ANSI for IRC client compatibility
|
||||||
|
|
||||||
|
### Client UI
|
||||||
|
|
||||||
|
- Version number shown in the status bar
|
||||||
|
- Custom colored rendering for channel list (active indicator, unread count badges)
|
||||||
|
- Avatar upload field added to the profile edit dialog (file path or URL)
|
||||||
|
- Profile avatar now renders with full color tag support in the profile view dialog
|
||||||
|
- Update check notification on connect — shows a system message if a newer GitHub release exists
|
||||||
|
- Chat messages no longer show selection/focus highlight
|
||||||
|
- Exit shortcut changed from Ctrl+C to Alt+Q — frees Ctrl+C for copy
|
||||||
|
- Default history increased from 50 to 100 messages on channel join
|
||||||
|
|
||||||
|
## Fixes
|
||||||
|
|
||||||
|
- Fixed `#general` channel not visible after adding the `IsPublic` column — migration default changed to `true` and startup service ensures it
|
||||||
|
- Fixed channels disappearing when creating a new channel — replaced full re-fetch with incremental updates
|
||||||
|
- Wired `OnChannelUpdated` SignalR event so new public channels appear for all connected users in real time
|
||||||
|
- Fixed color tag parser using wrong regex group numbers (6,7,8 instead of 1,2,3) — new ASCII art was rendering without colors
|
||||||
|
- Full Unicode/emoji support — renderers use Terminal.Gui v2 grapheme cluster API (`GraphemeHelper`, `AddStr`) for proper wide character handling
|
||||||
|
- Emoji-to-text shortcode conversion for consistent cross-platform rendering
|
||||||
|
- Fixed `/send` and `/avatar` commands not handling file paths with spaces correctly, even when quoted
|
||||||
|
- Server-side newline spam protection — consecutive blank/whitespace-only lines collapsed to 1 and total lines capped at 30
|
||||||
|
- Fixed OG tag regex truncating descriptions containing apostrophes (e.g. `"HueByte's portfolio"` was cut to `"HueByte"`) — switched to backreference-based quote pairing
|
||||||
|
|
||||||
|
## Infrastructure
|
||||||
|
|
||||||
|
- New `LinkEmbedService` on the server — URL detection, HTML fetching (first 64KB), OG tag parsing via compiled regex
|
||||||
|
- `EmbedDto` record added to shared Core DTOs; `MessageDto.Embeds` list for multiple embeds per message
|
||||||
|
- `EmbedJson` nullable column on the `Message` table stores serialized embed data as JSON array (max 8KB); `DataMigrationService` auto-migrates old single-object format
|
||||||
|
- Dedicated `"OgFetch"` named HttpClient with bot User-Agent header and 5-second timeout
|
||||||
|
- `PresenceTracker.ForceRemoveUser()` for atomic user cleanup on kick/ban
|
||||||
|
- `IChatBroadcaster.ForceDisconnectUserAsync()` and `IEchoHubClient.ForceDisconnect` for force-disconnect signaling
|
||||||
|
- `NotificationSoundService` for cross-platform audio playback of embedded notification sounds
|
||||||
|
- Startup `DataMigrationService` automatically converts old ANSI-format messages to the new color tag format on server boot, logging the count of migrated records
|
||||||
|
- `EmojiHelper` utility for emoji-to-shortcode conversion
|
||||||
|
- Heartbeat handling in `ServerDirectoryService` for connection health checks
|
||||||
|
- Four new EF Core migrations: `AddModerationRoles`, `AddChannelIsPublic`, `AddChannelMembership`, `AddMessageEmbed`
|
||||||
|
- `ChannelMembership` table with cascade delete on both channel and user removal
|
||||||
@@ -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`
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
+17
-1
@@ -30,6 +30,16 @@
|
|||||||
],
|
],
|
||||||
"dest": "_api_meta/client",
|
"dest": "_api_meta/client",
|
||||||
"filter": "filterConfig.yml"
|
"filter": "filterConfig.yml"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": [
|
||||||
|
{
|
||||||
|
"src": "../src/EchoHub.Server.Irc/bin/Release/net10.0",
|
||||||
|
"files": ["EchoHub.Server.Irc.dll"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dest": "_api_meta/server-irc",
|
||||||
|
"filter": "filterConfig.yml"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
@@ -55,6 +65,12 @@
|
|||||||
"dest": "api/client",
|
"dest": "api/client",
|
||||||
"files": ["*.yml"],
|
"files": ["*.yml"],
|
||||||
"exclude": ["toc.yml"]
|
"exclude": ["toc.yml"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "_api_meta/server-irc",
|
||||||
|
"dest": "api/server-irc",
|
||||||
|
"files": ["*.yml"],
|
||||||
|
"exclude": ["toc.yml"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"resource": [
|
"resource": [
|
||||||
@@ -69,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'>·</span><a href='https://github.com/HueByte/EchoHub'>GitHub</a><span class='footer-sep'>·</span><a href='https://echohub.voidcube.cloud'>Website</a><span class='footer-sep'>·</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'>·</span><a href='https://github.com/HueByte/EchoHub'>GitHub</a><span class='footer-sep'>·</span><a href='https://echohub.voidcube.cloud'>Website</a></div></div>",
|
||||||
"_enableSearch": true,
|
"_enableSearch": true,
|
||||||
"_disableContribution": false,
|
"_disableContribution": false,
|
||||||
"_gitContribute": {
|
"_gitContribute": {
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
|
```
|
||||||
@@ -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 |
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
-2
@@ -4,11 +4,13 @@ _layout: landing
|
|||||||
|
|
||||||
# EchoHub Documentation
|
# EchoHub Documentation
|
||||||
|
|
||||||
Welcome to the EchoHub documentation. EchoHub is a decentralized, IRC-like chat application built with .NET 10 and SignalR.
|
Welcome to the EchoHub documentation. EchoHub is a decentralized, IRC-style chat platform. Self-hosted, terminal-first, with a built-in IRC gateway so native IRC clients can connect alongside the TUI client.
|
||||||
|
|
||||||
|
**Website:** [echohub.voidcube.cloud](https://echohub.voidcube.cloud/) | **Public Servers:** [Server Directory](https://echohub.voidcube.cloud/servers)
|
||||||
|
|
||||||
## Quick Links
|
## Quick Links
|
||||||
|
|
||||||
- [Getting Started](articles/getting-started.md) - Set up and run EchoHub
|
- [Getting Started](articles/getting-started.md) - Set up and run EchoHub
|
||||||
- [Architecture](articles/architecture.md) - Understand the system design
|
- [Architecture](articles/architecture.md) - System design and IRC gateway
|
||||||
- [API Reference](api/index.md) - Generated C# API documentation
|
- [API Reference](api/index.md) - Generated C# API documentation
|
||||||
- [Changelog](changelog/index.md) - Release history
|
- [Changelog](changelog/index.md) - Release history
|
||||||
|
|||||||
+1
-13
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
- [ ] password protected rooms
|
||||||
|
- [ ] 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
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# EchoHub nginx configuration example
|
||||||
|
#
|
||||||
|
# This config handles:
|
||||||
|
# - HTTPS reverse proxy for the HTTP API + SignalR WebSocket
|
||||||
|
# - TLS termination for IRC on port 6697
|
||||||
|
# - HTTP → HTTPS redirect
|
||||||
|
#
|
||||||
|
# Prerequisites:
|
||||||
|
# - EchoHub Server running on 127.0.0.1:5000
|
||||||
|
# - IRC gateway enabled on port 6667 (Irc:Enabled = true, Irc:TlsEnabled = false)
|
||||||
|
# - TLS certificate (e.g. from Let's Encrypt)
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# 1. Copy this file to /etc/nginx/sites-available/echohub
|
||||||
|
# 2. Replace "echohub.example.com" with your domain
|
||||||
|
# 3. Update certificate paths
|
||||||
|
# 4. ln -s /etc/nginx/sites-available/echohub /etc/nginx/sites-enabled/
|
||||||
|
# 5. nginx -t && systemctl reload nginx
|
||||||
|
#
|
||||||
|
# Note: The "stream" block for IRC TLS must go in the main nginx.conf
|
||||||
|
# (outside the http block), not in sites-available. See the bottom of
|
||||||
|
# this file for the stream config.
|
||||||
|
|
||||||
|
# --- Place this in /etc/nginx/sites-available/echohub ---
|
||||||
|
|
||||||
|
# HTTP → HTTPS redirect
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name echohub.example.com;
|
||||||
|
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTPS — API + SignalR WebSocket
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
listen [::]:443 ssl;
|
||||||
|
server_name echohub.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/echohub.example.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/echohub.example.com/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
|
||||||
|
# File upload limit (match EchoHub's MaxFileSizeBytes)
|
||||||
|
client_max_body_size 10m;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:5000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
# Standard proxy headers
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# Required for SignalR WebSocket upgrade
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $http_connection;
|
||||||
|
|
||||||
|
# Long timeout for WebSocket connections
|
||||||
|
proxy_read_timeout 86400s;
|
||||||
|
proxy_send_timeout 86400s;
|
||||||
|
|
||||||
|
# Disable buffering for real-time
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Place this in /etc/nginx/nginx.conf (outside the http block) ---
|
||||||
|
|
||||||
|
# IRC TLS termination (port 6697 → plain IRC on 6667)
|
||||||
|
# The stream module handles raw TCP, not HTTP.
|
||||||
|
#
|
||||||
|
# stream {
|
||||||
|
# upstream irc_backend {
|
||||||
|
# server 127.0.0.1:6667;
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
# server {
|
||||||
|
# listen 6697 ssl;
|
||||||
|
# listen [::]:6697 ssl;
|
||||||
|
# proxy_pass irc_backend;
|
||||||
|
#
|
||||||
|
# ssl_certificate /etc/letsencrypt/live/echohub.example.com/fullchain.pem;
|
||||||
|
# ssl_certificate_key /etc/letsencrypt/live/echohub.example.com/privkey.pem;
|
||||||
|
# ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
#
|
||||||
|
# # Timeout for idle IRC connections (24 hours)
|
||||||
|
# proxy_timeout 86400s;
|
||||||
|
# }
|
||||||
|
# }
|
||||||
Binary file not shown.
@@ -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
|
||||||
|
|
||||||
@@ -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</authors>
|
||||||
|
<owners>HueByte</owners>
|
||||||
|
<requireLicenseAcceptance>false</requireLicenseAcceptance>
|
||||||
|
<licenseUrl>https://github.com/HueByte/EchoHub/blob/master/LICENSE</licenseUrl>
|
||||||
|
<projectUrl>https://github.com/HueByte/EchoHub</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,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Hue
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
VERIFICATION
|
||||||
|
|
||||||
|
To verify the package contents:
|
||||||
|
|
||||||
|
1. Download the official release from:
|
||||||
|
https://github.com/HueByte/EchoHub/releases
|
||||||
|
|
||||||
|
2. Download: EchoHub-Client-win-x64.zip
|
||||||
|
|
||||||
|
3. Calculate the SHA256 checksum:
|
||||||
|
- PowerShell: Get-FileHash EchoHub-Client-win-x64.zip -Algorithm SHA256
|
||||||
|
- Linux/macOS: sha256sum EchoHub-Client-win-x64.zip
|
||||||
|
|
||||||
|
4. Compare with the checksum in chocolateyInstall.ps1 (checksum64 value).
|
||||||
|
|
||||||
|
LICENSE: MIT License - see LICENSE.txt in this directory or
|
||||||
|
https://github.com/HueByte/EchoHub/blob/master/LICENSE
|
||||||
@@ -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'
|
||||||
@@ -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.10"
|
||||||
|
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
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>0.1.0</Version>
|
<Version>0.2.10</Version>
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -10,7 +10,7 @@ public class CommandHandler
|
|||||||
public event Func<string, Task>? OnSetNick;
|
public event Func<string, Task>? OnSetNick;
|
||||||
public event Func<string, Task>? OnSetColor;
|
public event Func<string, Task>? OnSetColor;
|
||||||
public event Func<string, Task>? OnSetTheme;
|
public event Func<string, Task>? OnSetTheme;
|
||||||
public event Func<string, Task>? OnSendFile;
|
public event Func<string, string?, Task>? OnSendFile;
|
||||||
public event Func<string?, Task>? OnOpenProfile;
|
public event Func<string?, Task>? OnOpenProfile;
|
||||||
public event Func<Task>? OnOpenServers;
|
public event Func<Task>? OnOpenServers;
|
||||||
public event Func<string, Task>? OnJoinChannel;
|
public event Func<string, Task>? OnJoinChannel;
|
||||||
@@ -18,6 +18,14 @@ public class CommandHandler
|
|||||||
public event Func<string, Task>? OnSetTopic;
|
public event Func<string, Task>? OnSetTopic;
|
||||||
public event Func<Task>? OnListUsers;
|
public event Func<Task>? OnListUsers;
|
||||||
public event Func<string, Task>? OnSetAvatar;
|
public event Func<string, Task>? OnSetAvatar;
|
||||||
|
public event Func<string, string?, Task>? OnKickUser;
|
||||||
|
public event Func<string, string?, Task>? OnBanUser;
|
||||||
|
public event Func<string, Task>? OnUnbanUser;
|
||||||
|
public event Func<string, int?, Task>? OnMuteUser;
|
||||||
|
public event Func<string, Task>? OnUnmuteUser;
|
||||||
|
public event Func<string, string, Task>? OnAssignRole;
|
||||||
|
public event Func<Task>? OnNukeChannel;
|
||||||
|
public event Func<Task>? OnTestSound;
|
||||||
public event Func<Task>? OnQuit;
|
public event Func<Task>? OnQuit;
|
||||||
public event Func<Task>? OnHelp;
|
public event Func<Task>? OnHelp;
|
||||||
|
|
||||||
@@ -46,6 +54,14 @@ public class CommandHandler
|
|||||||
"leave" => await HandleLeave(),
|
"leave" => await HandleLeave(),
|
||||||
"topic" => await HandleTopic(args),
|
"topic" => await HandleTopic(args),
|
||||||
"users" => await HandleUsers(),
|
"users" => await HandleUsers(),
|
||||||
|
"kick" => await HandleKick(args),
|
||||||
|
"ban" => await HandleBan(args),
|
||||||
|
"unban" => await HandleUnban(args),
|
||||||
|
"mute" => await HandleMute(args),
|
||||||
|
"unmute" => await HandleUnmute(args),
|
||||||
|
"role" => await HandleRole(args),
|
||||||
|
"nuke" => await HandleNuke(),
|
||||||
|
"test-sound" => await HandleTestSound(),
|
||||||
"quit" or "exit" => await HandleQuit(),
|
"quit" or "exit" => await HandleQuit(),
|
||||||
"help" or "?" => await HandleHelp(),
|
"help" or "?" => await HandleHelp(),
|
||||||
_ => new CommandResult(true, $"Unknown command: /{command}. Type /help for available commands.", IsError: true),
|
_ => new CommandResult(true, $"Unknown command: /{command}. Type /help for available commands.", IsError: true),
|
||||||
@@ -120,15 +136,19 @@ public class CommandHandler
|
|||||||
private async Task<CommandResult> HandleSend(string args)
|
private async Task<CommandResult> HandleSend(string args)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(args))
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
return new CommandResult(true, "Usage: /send <filepath or URL>", IsError: true);
|
return new CommandResult(true, "Usage: /send <filepath or URL> [-s|-m|-l]", IsError: true);
|
||||||
|
|
||||||
var target = args.Trim().Trim('"');
|
// Extract optional size flag from end or start, respecting quoted paths
|
||||||
|
var (target, size) = ParsePathAndSizeFlag(args);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(target))
|
||||||
|
return new CommandResult(true, "Usage: /send <filepath or URL> [-s|-m|-l]", IsError: true);
|
||||||
|
|
||||||
if (Uri.TryCreate(target, UriKind.Absolute, out var uri)
|
if (Uri.TryCreate(target, UriKind.Absolute, out var uri)
|
||||||
&& (uri.Scheme == "http" || uri.Scheme == "https"))
|
&& (uri.Scheme == "http" || uri.Scheme == "https"))
|
||||||
{
|
{
|
||||||
if (OnSendFile is not null)
|
if (OnSendFile is not null)
|
||||||
await OnSendFile(target);
|
await OnSendFile(target, size);
|
||||||
var fileName = Path.GetFileName(uri.LocalPath);
|
var fileName = Path.GetFileName(uri.LocalPath);
|
||||||
if (string.IsNullOrWhiteSpace(fileName))
|
if (string.IsNullOrWhiteSpace(fileName))
|
||||||
fileName = "image";
|
fileName = "image";
|
||||||
@@ -139,7 +159,7 @@ public class CommandHandler
|
|||||||
return new CommandResult(true, $"File not found: {target}", IsError: true);
|
return new CommandResult(true, $"File not found: {target}", IsError: true);
|
||||||
|
|
||||||
if (OnSendFile is not null)
|
if (OnSendFile is not null)
|
||||||
await OnSendFile(target);
|
await OnSendFile(target, size);
|
||||||
return new CommandResult(true, $"Uploading: {Path.GetFileName(target)}...");
|
return new CommandResult(true, $"Uploading: {Path.GetFileName(target)}...");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +176,7 @@ public class CommandHandler
|
|||||||
if (string.IsNullOrWhiteSpace(args))
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
return new CommandResult(true, "Usage: /avatar <URL or filepath>", IsError: true);
|
return new CommandResult(true, "Usage: /avatar <URL or filepath>", IsError: true);
|
||||||
|
|
||||||
var target = args.Trim().Trim('"');
|
var target = StripQuotes(args.Trim());
|
||||||
|
|
||||||
if (OnSetAvatar is not null)
|
if (OnSetAvatar is not null)
|
||||||
await OnSetAvatar(target);
|
await OnSetAvatar(target);
|
||||||
@@ -212,6 +232,102 @@ public class CommandHandler
|
|||||||
return new CommandResult(true);
|
return new CommandResult(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleKick(string args)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
|
return new CommandResult(true, "Usage: /kick <username> [reason]", IsError: true);
|
||||||
|
|
||||||
|
var parts = args.Split(' ', 2, StringSplitOptions.TrimEntries);
|
||||||
|
var username = parts[0];
|
||||||
|
var reason = parts.Length > 1 ? parts[1] : null;
|
||||||
|
|
||||||
|
if (OnKickUser is not null)
|
||||||
|
await OnKickUser(username, reason);
|
||||||
|
return new CommandResult(true, $"Kicking {username}...");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleBan(string args)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
|
return new CommandResult(true, "Usage: /ban <username> [reason]", IsError: true);
|
||||||
|
|
||||||
|
var parts = args.Split(' ', 2, StringSplitOptions.TrimEntries);
|
||||||
|
var username = parts[0];
|
||||||
|
var reason = parts.Length > 1 ? parts[1] : null;
|
||||||
|
|
||||||
|
if (OnBanUser is not null)
|
||||||
|
await OnBanUser(username, reason);
|
||||||
|
return new CommandResult(true, $"Banning {username}...");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleUnban(string args)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
|
return new CommandResult(true, "Usage: /unban <username>", IsError: true);
|
||||||
|
|
||||||
|
if (OnUnbanUser is not null)
|
||||||
|
await OnUnbanUser(args.Trim());
|
||||||
|
return new CommandResult(true, $"Unbanning {args.Trim()}...");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleMute(string args)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
|
return new CommandResult(true, "Usage: /mute <username> [duration_minutes]", IsError: true);
|
||||||
|
|
||||||
|
var parts = args.Split(' ', 2, StringSplitOptions.TrimEntries);
|
||||||
|
var username = parts[0];
|
||||||
|
int? duration = parts.Length > 1 && int.TryParse(parts[1], out var d) ? d : null;
|
||||||
|
|
||||||
|
if (OnMuteUser is not null)
|
||||||
|
await OnMuteUser(username, duration);
|
||||||
|
return new CommandResult(true, $"Muting {username}...");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleUnmute(string args)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
|
return new CommandResult(true, "Usage: /unmute <username>", IsError: true);
|
||||||
|
|
||||||
|
if (OnUnmuteUser is not null)
|
||||||
|
await OnUnmuteUser(args.Trim());
|
||||||
|
return new CommandResult(true, $"Unmuting {args.Trim()}...");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleRole(string args)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
|
return new CommandResult(true, "Usage: /role <username> <admin|mod|member>", IsError: true);
|
||||||
|
|
||||||
|
var parts = args.Split(' ', 2, StringSplitOptions.TrimEntries);
|
||||||
|
if (parts.Length < 2)
|
||||||
|
return new CommandResult(true, "Usage: /role <username> <admin|mod|member>", IsError: true);
|
||||||
|
|
||||||
|
var username = parts[0];
|
||||||
|
var role = parts[1].ToLowerInvariant();
|
||||||
|
|
||||||
|
if (role is not ("admin" or "mod" or "member"))
|
||||||
|
return new CommandResult(true, "Invalid role. Use: admin, mod, or member", IsError: true);
|
||||||
|
|
||||||
|
if (OnAssignRole is not null)
|
||||||
|
await OnAssignRole(username, role);
|
||||||
|
return new CommandResult(true, $"Setting {username} to {role}...");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleNuke()
|
||||||
|
{
|
||||||
|
if (OnNukeChannel is not null)
|
||||||
|
await OnNukeChannel();
|
||||||
|
return new CommandResult(true, "Nuking channel history...");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleTestSound()
|
||||||
|
{
|
||||||
|
if (OnTestSound is not null)
|
||||||
|
await OnTestSound();
|
||||||
|
return new CommandResult(true, "Playing notification sound...");
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<CommandResult> HandleHelp()
|
private async Task<CommandResult> HandleHelp()
|
||||||
{
|
{
|
||||||
if (OnHelp is not null)
|
if (OnHelp is not null)
|
||||||
@@ -223,18 +339,72 @@ public class CommandHandler
|
|||||||
/nick <name> - Set display name
|
/nick <name> - Set display name
|
||||||
/color <#hex> - Set nickname color
|
/color <#hex> - Set nickname color
|
||||||
/theme <name> - Switch theme
|
/theme <name> - Switch theme
|
||||||
/send <filepath or URL> - Send a file or image
|
/send <filepath or URL> [-s|-m|-l] - Send file/image/audio (size flag for images)
|
||||||
/avatar <URL or filepath> - Set your avatar
|
/avatar <URL or filepath> - Set your avatar
|
||||||
/profile [username] - View a profile (yours if no name given)
|
/profile [username] - View a profile
|
||||||
/servers - Open saved servers
|
/servers - Open saved servers
|
||||||
/join <channel> - Join a channel
|
/join <channel> - Join a channel
|
||||||
/leave - Leave current channel
|
/leave - Leave current channel
|
||||||
/topic <text> - Set channel topic
|
/topic <text> - Set channel topic
|
||||||
/users - List online users
|
/users - List online users
|
||||||
|
Moderation:
|
||||||
|
/kick <user> [reason] - Kick a user (Mod+)
|
||||||
|
/ban <user> [reason] - Ban a user (Admin+)
|
||||||
|
/unban <user> - Unban a user (Admin+)
|
||||||
|
/mute <user> [minutes] - Mute a user (Mod+)
|
||||||
|
/unmute <user> - Unmute a user (Mod+)
|
||||||
|
/role <user> <admin|mod|member> - Assign role (Admin+)
|
||||||
|
/nuke - Clear channel history (Mod+)
|
||||||
|
/test-sound - Play notification sound
|
||||||
/quit - Exit the app
|
/quit - Exit the app
|
||||||
""");
|
""");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extract a file path (possibly quoted) and an optional size flag (-s, -m, -l).
|
||||||
|
/// The flag can appear before or after the path.
|
||||||
|
/// </summary>
|
||||||
|
private static (string Path, string? Size) ParsePathAndSizeFlag(string args)
|
||||||
|
{
|
||||||
|
var trimmed = args.Trim();
|
||||||
|
string? size = null;
|
||||||
|
|
||||||
|
// Check for flag at the end: "path" -m or path -m
|
||||||
|
if (trimmed.Length > 3)
|
||||||
|
{
|
||||||
|
var suffix = trimmed[^2..];
|
||||||
|
if (suffix is "-s" or "-m" or "-l" && trimmed[^3] == ' ')
|
||||||
|
{
|
||||||
|
size = suffix[1..];
|
||||||
|
trimmed = trimmed[..^3].TrimEnd();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for flag at the start: -m "path" or -m path
|
||||||
|
if (size is null && trimmed.Length > 3)
|
||||||
|
{
|
||||||
|
var prefix = trimmed[..2];
|
||||||
|
if (prefix is "-s" or "-m" or "-l" && trimmed[2] == ' ')
|
||||||
|
{
|
||||||
|
size = prefix[1..];
|
||||||
|
trimmed = trimmed[3..].TrimStart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (StripQuotes(trimmed), size);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Remove matching surrounding quotes (double or single) from a string.
|
||||||
|
/// </summary>
|
||||||
|
private static string StripQuotes(string s)
|
||||||
|
{
|
||||||
|
if (s.Length >= 2 &&
|
||||||
|
((s[0] == '"' && s[^1] == '"') || (s[0] == '\'' && s[^1] == '\'')))
|
||||||
|
return s[1..^1];
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
private static bool IsValidHex(string s) =>
|
private static bool IsValidHex(string s) =>
|
||||||
s.All(c => char.IsAsciiHexDigit(c));
|
s.All(c => char.IsAsciiHexDigit(c));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ public class ClientConfig
|
|||||||
public List<SavedServer> SavedServers { get; set; } = [];
|
public List<SavedServer> SavedServers { get; set; } = [];
|
||||||
public AccountPreset DefaultPreset { get; set; } = new();
|
public AccountPreset DefaultPreset { get; set; } = new();
|
||||||
public string ActiveTheme { get; set; } = "Default";
|
public string ActiveTheme { get; set; } = "Default";
|
||||||
|
public NotificationConfig Notifications { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class NotificationConfig
|
||||||
|
{
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
public byte Volume { get; set; } = 30;
|
||||||
|
public string? SoundFile { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SavedServer
|
public class SavedServer
|
||||||
@@ -12,7 +20,8 @@ public class SavedServer
|
|||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
public required string Url { get; set; }
|
public required string Url { get; set; }
|
||||||
public string? Username { get; set; }
|
public string? Username { get; set; }
|
||||||
public string? Token { get; set; }
|
public string? RefreshToken { get; set; }
|
||||||
|
public bool RememberMe { get; set; }
|
||||||
public DateTimeOffset LastConnected { get; set; }
|
public DateTimeOffset LastConnected { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,18 +5,23 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="AlwaysUpToDate" Version="2.0.2.20250223" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.3" />
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.3" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.3" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.3" />
|
||||||
|
<PackageReference Include="NetCoreAudio" Version="2.0.1" />
|
||||||
<PackageReference Include="Serilog" Version="4.3.1" />
|
<PackageReference Include="Serilog" Version="4.3.1" />
|
||||||
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
|
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
|
||||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||||
<PackageReference Include="Terminal.Gui" Version="2.0.0-develop.5027" />
|
<PackageReference Include="Terminal.Gui" Version="2.0.0-develop.5043" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Content Include="appsettings.json" Condition="Exists('appsettings.json')">
|
<Content Include="appsettings.json" Condition="Exists('appsettings.json')">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</Content>
|
</Content>
|
||||||
|
<Content Include="Assets\**">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
<EmbeddedResource Include="appsettings.example.json">
|
<EmbeddedResource Include="appsettings.example.json">
|
||||||
<LogicalName>EchoHub.Client.appsettings.example.json</LogicalName>
|
<LogicalName>EchoHub.Client.appsettings.example.json</LogicalName>
|
||||||
</EmbeddedResource>
|
</EmbeddedResource>
|
||||||
@@ -27,6 +32,8 @@
|
|||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
<PackageIcon></PackageIcon>
|
||||||
|
<ApplicationIcon>Assets\hue_icon.ico</ApplicationIcon>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,10 +1,59 @@
|
|||||||
using EchoHub.Client;
|
using EchoHub.Client;
|
||||||
using EchoHub.Client.Config;
|
using EchoHub.Client.Config;
|
||||||
|
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 ================
|
||||||
|
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");
|
var appSettingsPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
|
||||||
if (!File.Exists(appSettingsPath))
|
if (!File.Exists(appSettingsPath))
|
||||||
{
|
{
|
||||||
@@ -23,12 +72,50 @@ 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");
|
||||||
|
|
||||||
|
// == 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
|
try
|
||||||
{
|
{
|
||||||
var config = ConfigManager.Load();
|
var config = ConfigManager.Load();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Net.Http.Headers;
|
|||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
|
|
||||||
namespace EchoHub.Client.Services;
|
namespace EchoHub.Client.Services;
|
||||||
|
|
||||||
@@ -17,6 +18,8 @@ public sealed class ApiClient : IDisposable
|
|||||||
public string? RefreshToken => _refreshToken;
|
public string? RefreshToken => _refreshToken;
|
||||||
public string BaseUrl { get; }
|
public string BaseUrl { get; }
|
||||||
|
|
||||||
|
public event Action? OnTokensRefreshed;
|
||||||
|
|
||||||
public ApiClient(string baseUrl)
|
public ApiClient(string baseUrl)
|
||||||
{
|
{
|
||||||
BaseUrl = baseUrl.TrimEnd('/');
|
BaseUrl = baseUrl.TrimEnd('/');
|
||||||
@@ -29,7 +32,7 @@ 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)
|
||||||
{
|
{
|
||||||
var request = new RegisterRequest(username, password, displayName);
|
var request = new RegisterRequest(username, password, displayName);
|
||||||
var response = await _http.PostAsJsonAsync("/api/auth/register", request);
|
using var response = await _http.PostAsJsonAsync("/api/auth/register", request);
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
|
|
||||||
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
|
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
|
||||||
@@ -42,7 +45,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
public async Task<LoginResponse> LoginAsync(string username, string password)
|
public async Task<LoginResponse> LoginAsync(string username, string password)
|
||||||
{
|
{
|
||||||
var request = new LoginRequest(username, 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);
|
await EnsureSuccessAsync(response);
|
||||||
|
|
||||||
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
|
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
|
||||||
@@ -58,7 +61,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
throw new InvalidOperationException("No refresh token available.");
|
throw new InvalidOperationException("No refresh token available.");
|
||||||
|
|
||||||
var request = new RefreshRequest(_refreshToken);
|
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);
|
await EnsureSuccessAsync(response);
|
||||||
|
|
||||||
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
|
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
|
||||||
@@ -67,6 +70,19 @@ public sealed class ApiClient : IDisposable
|
|||||||
SetTokens(result);
|
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()
|
public async Task LogoutAsync()
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(_refreshToken))
|
if (!string.IsNullOrEmpty(_refreshToken))
|
||||||
@@ -74,7 +90,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var request = new RefreshRequest(_refreshToken);
|
var request = new RefreshRequest(_refreshToken);
|
||||||
await _http.PostAsJsonAsync("/api/auth/logout", request);
|
using var response = await _http.PostAsJsonAsync("/api/auth/logout", request);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -115,7 +131,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
public async Task<List<ChannelDto>> GetChannelsAsync()
|
public async Task<List<ChannelDto>> GetChannelsAsync()
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var response = await AuthenticatedGetAsync("/api/channels");
|
using var response = await AuthenticatedGetAsync("/api/channels");
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
var paginated = await response.Content.ReadFromJsonAsync<PaginatedResponse<ChannelDto>>();
|
var paginated = await response.Content.ReadFromJsonAsync<PaginatedResponse<ChannelDto>>();
|
||||||
return paginated?.Items ?? [];
|
return paginated?.Items ?? [];
|
||||||
@@ -127,10 +143,20 @@ public sealed class ApiClient : IDisposable
|
|||||||
return info;
|
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)
|
public async Task<UserProfileDto?> GetUserProfileAsync(string username)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
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);
|
await EnsureSuccessAsync(response);
|
||||||
return await response.Content.ReadFromJsonAsync<UserProfileDto>();
|
return await response.Content.ReadFromJsonAsync<UserProfileDto>();
|
||||||
}
|
}
|
||||||
@@ -138,7 +164,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
public async Task<UserProfileDto?> UpdateProfileAsync(UpdateProfileRequest request)
|
public async Task<UserProfileDto?> UpdateProfileAsync(UpdateProfileRequest request)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var response = await AuthenticatedRequestAsync(() =>
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
_http.PutAsJsonAsync("/api/users/profile", request));
|
_http.PutAsJsonAsync("/api/users/profile", request));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
return await response.Content.ReadFromJsonAsync<UserProfileDto>();
|
return await response.Content.ReadFromJsonAsync<UserProfileDto>();
|
||||||
@@ -152,14 +178,14 @@ public sealed class ApiClient : IDisposable
|
|||||||
streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(fileName));
|
streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(fileName));
|
||||||
content.Add(streamContent, "file", fileName);
|
content.Add(streamContent, "file", fileName);
|
||||||
|
|
||||||
var response = await AuthenticatedRequestAsync(() =>
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
_http.PostAsync("/api/users/avatar", content));
|
_http.PostAsync("/api/users/avatar", content));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
var result = await response.Content.ReadFromJsonAsync<AvatarUploadResponse>();
|
var result = await response.Content.ReadFromJsonAsync<AvatarUploadResponse>();
|
||||||
return result?.AvatarAscii;
|
return result?.AvatarAscii;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<MessageDto?> UploadFileAsync(string channelName, Stream fileStream, string fileName)
|
public async Task<MessageDto?> UploadFileAsync(string channelName, Stream fileStream, string fileName, string? size = null)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
using var content = new MultipartFormDataContent();
|
using var content = new MultipartFormDataContent();
|
||||||
@@ -167,27 +193,46 @@ public sealed class ApiClient : IDisposable
|
|||||||
streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(fileName));
|
streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(fileName));
|
||||||
content.Add(streamContent, "file", fileName);
|
content.Add(streamContent, "file", fileName);
|
||||||
|
|
||||||
var response = await AuthenticatedRequestAsync(() =>
|
var sizeQuery = size is not null ? $"?size={size}" : "";
|
||||||
_http.PostAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/upload", content));
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
|
_http.PostAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/upload{sizeQuery}", content));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
return await response.Content.ReadFromJsonAsync<MessageDto>();
|
return await response.Content.ReadFromJsonAsync<MessageDto>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<MessageDto?> SendUrlAsync(string channelName, string url)
|
public async Task<MessageDto?> SendUrlAsync(string channelName, string url, string? size = null)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var request = new SendUrlRequest(url);
|
var request = new SendUrlRequest(url);
|
||||||
var response = await AuthenticatedRequestAsync(() =>
|
var sizeQuery = size is not null ? $"?size={size}" : "";
|
||||||
_http.PostAsJsonAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/send-url", request));
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
|
_http.PostAsJsonAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/send-url{sizeQuery}", request));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
return await response.Content.ReadFromJsonAsync<MessageDto>();
|
return await response.Content.ReadFromJsonAsync<MessageDto>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ChannelDto?> CreateChannelAsync(string name, string? topic = null)
|
public async Task<string> DownloadFileToTempAsync(string relativeUrl, string fileName)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var request = new CreateChannelRequest(name, topic);
|
using var response = await AuthenticatedGetAsync(relativeUrl);
|
||||||
var response = await AuthenticatedRequestAsync(() =>
|
await EnsureSuccessAsync(response);
|
||||||
|
|
||||||
|
var tempDir = Path.Combine(Path.GetTempPath(), "EchoHub");
|
||||||
|
Directory.CreateDirectory(tempDir);
|
||||||
|
var tempPath = Path.Combine(tempDir, $"{Guid.NewGuid():N}_{fileName}");
|
||||||
|
|
||||||
|
await using var stream = await response.Content.ReadAsStreamAsync();
|
||||||
|
await using var file = File.Create(tempPath);
|
||||||
|
await stream.CopyToAsync(file);
|
||||||
|
|
||||||
|
return tempPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ChannelDto?> CreateChannelAsync(string name, string? topic = null, bool isPublic = true)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
var request = new CreateChannelRequest(name, topic, isPublic);
|
||||||
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
_http.PostAsJsonAsync("/api/channels", request));
|
_http.PostAsJsonAsync("/api/channels", request));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
return await response.Content.ReadFromJsonAsync<ChannelDto>();
|
return await response.Content.ReadFromJsonAsync<ChannelDto>();
|
||||||
@@ -197,7 +242,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var request = new UpdateTopicRequest(topic);
|
var request = new UpdateTopicRequest(topic);
|
||||||
var response = await AuthenticatedRequestAsync(() =>
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
_http.PutAsJsonAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/topic", request));
|
_http.PutAsJsonAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/topic", request));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
return await response.Content.ReadFromJsonAsync<ChannelDto>();
|
return await response.Content.ReadFromJsonAsync<ChannelDto>();
|
||||||
@@ -206,21 +251,89 @@ public sealed class ApiClient : IDisposable
|
|||||||
public async Task DeleteChannelAsync(string channelName)
|
public async Task DeleteChannelAsync(string channelName)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var response = await AuthenticatedRequestAsync(() =>
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
_http.DeleteAsync($"/api/channels/{Uri.EscapeDataString(channelName)}"));
|
_http.DeleteAsync($"/api/channels/{Uri.EscapeDataString(channelName)}"));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Moderation ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task AssignRoleAsync(string username, ServerRole role)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
|
_http.PostAsJsonAsync("/api/moderation/role", new AssignRoleRequest(username, role)));
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task KickUserAsync(string username, string? reason = null)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
|
_http.PostAsJsonAsync($"/api/moderation/kick/{Uri.EscapeDataString(username)}", new KickRequest(reason)));
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task BanUserAsync(string username, string? reason = null)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
|
_http.PostAsJsonAsync($"/api/moderation/ban/{Uri.EscapeDataString(username)}", new BanRequest(reason)));
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UnbanUserAsync(string username)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
|
_http.PostAsJsonAsync($"/api/moderation/unban/{Uri.EscapeDataString(username)}", new { }));
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task MuteUserAsync(string username, int? durationMinutes = null, string? reason = null)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
|
_http.PostAsJsonAsync($"/api/moderation/mute/{Uri.EscapeDataString(username)}", new MuteRequest(reason, durationMinutes)));
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UnmuteUserAsync(string username)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
|
_http.PostAsJsonAsync($"/api/moderation/unmute/{Uri.EscapeDataString(username)}", new { }));
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteMessageAsync(Guid messageId)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
|
_http.DeleteAsync($"/api/moderation/messages/{messageId}"));
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task NukeChannelAsync(string channelName)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
|
_http.DeleteAsync($"/api/moderation/channels/{Uri.EscapeDataString(channelName)}/nuke"));
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
}
|
||||||
|
|
||||||
private void SetTokens(LoginResponse result)
|
private void SetTokens(LoginResponse result)
|
||||||
{
|
{
|
||||||
_accessToken = result.Token;
|
_accessToken = result.Token;
|
||||||
_refreshToken = result.RefreshToken;
|
_refreshToken = result.RefreshToken;
|
||||||
_expiresAt = result.ExpiresAt;
|
_expiresAt = result.ExpiresAt;
|
||||||
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
|
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
|
||||||
|
OnTokensRefreshed?.Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Performs a GET request with automatic token refresh on 401.
|
/// Performs a GET request with automatic token refresh on 401.
|
||||||
|
/// Caller is responsible for disposing the returned response.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<HttpResponseMessage> AuthenticatedGetAsync(string url)
|
private async Task<HttpResponseMessage> AuthenticatedGetAsync(string url)
|
||||||
{
|
{
|
||||||
@@ -231,7 +344,9 @@ public sealed class ApiClient : IDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await RefreshTokenAsync();
|
await RefreshTokenAsync();
|
||||||
response = await _http.GetAsync(url);
|
var retryResponse = await _http.GetAsync(url);
|
||||||
|
response.Dispose();
|
||||||
|
response = retryResponse;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -244,6 +359,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Performs a request with automatic token refresh on 401.
|
/// Performs a request with automatic token refresh on 401.
|
||||||
|
/// Caller is responsible for disposing the returned response.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<HttpResponseMessage> AuthenticatedRequestAsync(Func<Task<HttpResponseMessage>> requestFactory)
|
private async Task<HttpResponseMessage> AuthenticatedRequestAsync(Func<Task<HttpResponseMessage>> requestFactory)
|
||||||
{
|
{
|
||||||
@@ -254,7 +370,9 @@ public sealed class ApiClient : IDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await RefreshTokenAsync();
|
await RefreshTokenAsync();
|
||||||
response = await requestFactory();
|
var retryResponse = await requestFactory();
|
||||||
|
response.Dispose();
|
||||||
|
response = retryResponse;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
using Serilog;
|
using Serilog;
|
||||||
using Terminal.Gui.App;
|
using Terminal.Gui.App;
|
||||||
|
|
||||||
namespace EchoHub.Client;
|
namespace EchoHub.Client.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Eliminates repeated Task.Run/try/catch/app.Invoke(ShowError) boilerplate.
|
/// Eliminates repeated Task.Run/try/catch/app.Invoke(ShowError) boilerplate.
|
||||||
@@ -0,0 +1,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,274 @@
|
|||||||
|
using EchoHub.Client.Config;
|
||||||
|
using EchoHub.Client.UI.Dialogs;
|
||||||
|
using EchoHub.Core.Constants;
|
||||||
|
using EchoHub.Core.DTOs;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of a successful connection, returned to AppOrchestrator for UI updates.
|
||||||
|
/// </summary>
|
||||||
|
internal record ConnectResult(
|
||||||
|
LoginResponse Login,
|
||||||
|
List<ChannelDto> Channels,
|
||||||
|
List<MessageDto> DefaultHistory);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owns connection lifecycle, authentication, SignalR event wiring, and channel tracking.
|
||||||
|
/// Fires events so AppOrchestrator can update the UI without managing connection internals.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ConnectionManager : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private EchoHubConnection? _connection;
|
||||||
|
private ApiClient? _apiClient;
|
||||||
|
private readonly ClientEncryptionService _encryption = new();
|
||||||
|
private readonly HashSet<string> _joinedChannels = [];
|
||||||
|
|
||||||
|
// ── Properties ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public bool IsConnected => _connection?.IsConnected == true;
|
||||||
|
public bool IsAuthenticated => _apiClient is not null;
|
||||||
|
public ApiClient? Api => _apiClient;
|
||||||
|
|
||||||
|
// ── Events (forwarded from SignalR) ───────────────────────────────────
|
||||||
|
|
||||||
|
public event Action<MessageDto>? MessageReceived;
|
||||||
|
public event Action<string, string, 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>? 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);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
loginResponse = await _apiClient.LoginAsync(info.Username, info.Password);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-persist rotated refresh tokens for Remember Me
|
||||||
|
_apiClient.OnTokensRefreshed += HandleTokensRefreshed;
|
||||||
|
|
||||||
|
// E2E encryption key
|
||||||
|
onStatus("Fetching encryption key...");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var encryptionKey = await _apiClient.GetEncryptionKeyAsync();
|
||||||
|
_encryption.SetKey(encryptionKey);
|
||||||
|
Log.Information("E2E encryption key established");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Failed to fetch encryption key — messages will not be encrypted");
|
||||||
|
}
|
||||||
|
|
||||||
|
onStatus("Authenticated, connecting...");
|
||||||
|
|
||||||
|
if (_connection is not null)
|
||||||
|
await _connection.DisposeAsync();
|
||||||
|
|
||||||
|
_connection = new EchoHubConnection(info.ServerUrl, _apiClient, _encryption);
|
||||||
|
WireConnectionEvents(_connection);
|
||||||
|
await _connection.ConnectAsync();
|
||||||
|
|
||||||
|
var channels = await _apiClient.GetChannelsAsync();
|
||||||
|
onStatus("Connected");
|
||||||
|
|
||||||
|
// Join default channel + fetch history
|
||||||
|
_joinedChannels.Clear();
|
||||||
|
_joinedChannels.Add(HubConstants.DefaultChannel);
|
||||||
|
await _connection.JoinChannelAsync(HubConstants.DefaultChannel);
|
||||||
|
|
||||||
|
List<MessageDto> history = [];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
history = await _connection.GetHistoryAsync(HubConstants.DefaultChannel);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// History might not be available
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ConnectResult(loginResponse, channels, history);
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Revoke refresh token on the server. Call <see cref="CleanupAsync"/> afterwards.
|
||||||
|
/// </summary>
|
||||||
|
public async Task LogoutAsync()
|
||||||
|
{
|
||||||
|
if (_apiClient is not null)
|
||||||
|
await _apiClient.LogoutAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Channel Operations ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task<List<MessageDto>> JoinChannelAsync(string channelName)
|
||||||
|
{
|
||||||
|
if (_connection is null) throw new InvalidOperationException("Not connected");
|
||||||
|
_joinedChannels.Add(channelName);
|
||||||
|
return await _connection.JoinChannelAsync(channelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task LeaveChannelAsync(string channelName)
|
||||||
|
{
|
||||||
|
if (_connection is null) throw new InvalidOperationException("Not connected");
|
||||||
|
await _connection.LeaveChannelAsync(channelName);
|
||||||
|
_joinedChannels.Remove(channelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Track a channel as joined (returns true if newly added).
|
||||||
|
/// </summary>
|
||||||
|
public bool TrackChannel(string channelName) => _joinedChannels.Add(channelName);
|
||||||
|
|
||||||
|
public void UntrackChannel(string channelName) => _joinedChannels.Remove(channelName);
|
||||||
|
|
||||||
|
// ── Delegate Operations ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
public Task SendMessageAsync(string channel, string content) =>
|
||||||
|
_connection?.SendMessageAsync(channel, content)
|
||||||
|
?? throw new InvalidOperationException("Not connected");
|
||||||
|
|
||||||
|
public Task<List<MessageDto>> GetHistoryAsync(string channel, 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)
|
||||||
|
{
|
||||||
|
_joinedChannels.Add(channel);
|
||||||
|
await _connection.JoinChannelAsync(channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.Information("Rejoined {Count} channel(s) after reconnect", channels.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SignalR Event Wiring ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void WireConnectionEvents(EchoHubConnection connection)
|
||||||
|
{
|
||||||
|
connection.OnMessageReceived += msg => MessageReceived?.Invoke(msg);
|
||||||
|
connection.OnUserJoined += (ch, user, 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.OnChannelNuked += ch => ChannelNuked?.Invoke(ch);
|
||||||
|
connection.OnChannelUpdated += ch => ChannelUpdated?.Invoke(ch);
|
||||||
|
connection.OnError += msg => Error?.Invoke(msg);
|
||||||
|
connection.OnConnectionStateChanged += status => ConnectionStatusChanged?.Invoke(status);
|
||||||
|
connection.OnReconnected += () => Reconnected?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Token Persistence ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void HandleTokensRefreshed()
|
||||||
|
{
|
||||||
|
if (_apiClient?.RefreshToken is null) return;
|
||||||
|
var config = ConfigManager.Load();
|
||||||
|
var server = config.SavedServers.FirstOrDefault(s =>
|
||||||
|
string.Equals(s.Url, _apiClient.BaseUrl, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (server is not null && server.RememberMe)
|
||||||
|
{
|
||||||
|
server.RefreshToken = _apiClient.RefreshToken;
|
||||||
|
ConfigManager.Save(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dispose ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
_apiClient?.Dispose();
|
||||||
|
if (_connection is not null)
|
||||||
|
await _connection.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,20 +8,27 @@ namespace EchoHub.Client.Services;
|
|||||||
public sealed class EchoHubConnection : IAsyncDisposable
|
public sealed class EchoHubConnection : IAsyncDisposable
|
||||||
{
|
{
|
||||||
private readonly HubConnection _connection;
|
private readonly HubConnection _connection;
|
||||||
|
private readonly ClientEncryptionService _encryption;
|
||||||
|
|
||||||
public event Action<MessageDto>? OnMessageReceived;
|
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<string, string>? OnUserLeft;
|
||||||
public event Action<ChannelDto>? OnChannelUpdated;
|
public event Action<ChannelDto>? OnChannelUpdated;
|
||||||
public event Action<UserPresenceDto>? OnUserStatusChanged;
|
public event Action<UserPresenceDto>? OnUserStatusChanged;
|
||||||
|
public event Action<string, string, string?>? OnUserKicked;
|
||||||
|
public event Action<string, string?>? OnUserBanned;
|
||||||
|
public event Action<string, Guid>? OnMessageDeleted;
|
||||||
|
public event Action<string>? OnChannelNuked;
|
||||||
|
public event Action<string>? OnForceDisconnect;
|
||||||
public event Action<string>? OnError;
|
public event Action<string>? OnError;
|
||||||
public event Action<string>? OnConnectionStateChanged;
|
public event Action<string>? OnConnectionStateChanged;
|
||||||
public event Action? OnReconnected;
|
public event Action? OnReconnected;
|
||||||
|
|
||||||
public bool IsConnected => _connection.State == HubConnectionState.Connected;
|
public bool IsConnected => _connection.State == HubConnectionState.Connected;
|
||||||
|
|
||||||
public EchoHubConnection(string serverUrl, ApiClient apiClient)
|
public EchoHubConnection(string serverUrl, ApiClient apiClient, ClientEncryptionService encryption)
|
||||||
{
|
{
|
||||||
|
_encryption = encryption;
|
||||||
var hubUrl = serverUrl.TrimEnd('/') + HubConstants.ChatHubPath;
|
var hubUrl = serverUrl.TrimEnd('/') + HubConstants.ChatHubPath;
|
||||||
|
|
||||||
_connection = new HubConnectionBuilder()
|
_connection = new HubConnectionBuilder()
|
||||||
@@ -58,12 +65,14 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
_connection.On<MessageDto>(nameof(Core.Contracts.IEchoHubClient.ReceiveMessage), message =>
|
_connection.On<MessageDto>(nameof(Core.Contracts.IEchoHubClient.ReceiveMessage), message =>
|
||||||
{
|
{
|
||||||
OnMessageReceived?.Invoke(message);
|
// Decrypt message content received from server
|
||||||
|
var decrypted = message with { Content = _encryption.Decrypt(message.Content) };
|
||||||
|
OnMessageReceived?.Invoke(decrypted);
|
||||||
});
|
});
|
||||||
|
|
||||||
_connection.On<string, string>(nameof(Core.Contracts.IEchoHubClient.UserJoined), (channelName, username) =>
|
_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) =>
|
_connection.On<string, string>(nameof(Core.Contracts.IEchoHubClient.UserLeft), (channelName, username) =>
|
||||||
@@ -81,6 +90,31 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
|||||||
OnUserStatusChanged?.Invoke(presence);
|
OnUserStatusChanged?.Invoke(presence);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
_connection.On<string, string, string?>(nameof(Core.Contracts.IEchoHubClient.UserKicked), (channelName, username, reason) =>
|
||||||
|
{
|
||||||
|
OnUserKicked?.Invoke(channelName, username, reason);
|
||||||
|
});
|
||||||
|
|
||||||
|
_connection.On<string, string?>(nameof(Core.Contracts.IEchoHubClient.UserBanned), (username, reason) =>
|
||||||
|
{
|
||||||
|
OnUserBanned?.Invoke(username, reason);
|
||||||
|
});
|
||||||
|
|
||||||
|
_connection.On<string, Guid>(nameof(Core.Contracts.IEchoHubClient.MessageDeleted), (channelName, messageId) =>
|
||||||
|
{
|
||||||
|
OnMessageDeleted?.Invoke(channelName, messageId);
|
||||||
|
});
|
||||||
|
|
||||||
|
_connection.On<string>(nameof(Core.Contracts.IEchoHubClient.ChannelNuked), channelName =>
|
||||||
|
{
|
||||||
|
OnChannelNuked?.Invoke(channelName);
|
||||||
|
});
|
||||||
|
|
||||||
|
_connection.On<string>(nameof(Core.Contracts.IEchoHubClient.ForceDisconnect), reason =>
|
||||||
|
{
|
||||||
|
OnForceDisconnect?.Invoke(reason);
|
||||||
|
});
|
||||||
|
|
||||||
_connection.On<string>(nameof(Core.Contracts.IEchoHubClient.Error), message =>
|
_connection.On<string>(nameof(Core.Contracts.IEchoHubClient.Error), message =>
|
||||||
{
|
{
|
||||||
OnError?.Invoke(message);
|
OnError?.Invoke(message);
|
||||||
@@ -102,7 +136,10 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
|||||||
|
|
||||||
public async Task<List<MessageDto>> JoinChannelAsync(string channelName)
|
public async Task<List<MessageDto>> JoinChannelAsync(string channelName)
|
||||||
{
|
{
|
||||||
return await _connection.InvokeAsync<List<MessageDto>>("JoinChannel", channelName);
|
var result = await _connection.InvokeAsync<JoinChannelResult>("JoinChannel", channelName);
|
||||||
|
if (!result.Success)
|
||||||
|
throw new InvalidOperationException(result.Error ?? "Failed to join channel.");
|
||||||
|
return DecryptMessages(result.History);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task LeaveChannelAsync(string channelName)
|
public async Task LeaveChannelAsync(string channelName)
|
||||||
@@ -112,12 +149,15 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
|||||||
|
|
||||||
public async Task SendMessageAsync(string channelName, string content)
|
public async Task SendMessageAsync(string channelName, string content)
|
||||||
{
|
{
|
||||||
await _connection.InvokeAsync("SendMessage", channelName, content);
|
// Encrypt content before sending to server
|
||||||
|
var encrypted = _encryption.Encrypt(content);
|
||||||
|
await _connection.InvokeAsync("SendMessage", channelName, encrypted);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<MessageDto>> GetHistoryAsync(string channelName, int count = HubConstants.DefaultHistoryCount)
|
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)
|
public async Task UpdateStatusAsync(UserStatus status, string? statusMessage = null)
|
||||||
@@ -130,6 +170,11 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
|||||||
return await _connection.InvokeAsync<List<UserPresenceDto>>("GetOnlineUsers", channelName);
|
return await _connection.InvokeAsync<List<UserPresenceDto>>("GetOnlineUsers", channelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<MessageDto> DecryptMessages(List<MessageDto> messages)
|
||||||
|
{
|
||||||
|
return messages.Select(m => m with { Content = _encryption.Decrypt(m.Content) }).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
await _connection.DisposeAsync();
|
await _connection.DisposeAsync();
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
using EchoHub.Client.Config;
|
||||||
|
using NetCoreAudio;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
public NotificationSoundService(NotificationConfig config)
|
||||||
|
{
|
||||||
|
_config = config;
|
||||||
|
ResolveSoundPath();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetEnabled(bool enabled) => _config.Enabled = enabled;
|
||||||
|
|
||||||
|
public void SetVolume(byte volume) => _config.Volume = Math.Min(volume, (byte)100);
|
||||||
|
|
||||||
|
public async Task PlayAsync()
|
||||||
|
{
|
||||||
|
if (!_config.Enabled || _resolvedSoundPath is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await PlayInternal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plays the notification sound regardless of the Enabled setting (for /test-sound).
|
||||||
|
/// </summary>
|
||||||
|
public async Task PlayTestAsync()
|
||||||
|
{
|
||||||
|
if (_resolvedSoundPath is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await PlayInternal();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PlayInternal()
|
||||||
|
{
|
||||||
|
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
|
||||||
|
{
|
||||||
|
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()
|
||||||
|
{
|
||||||
|
// 1. Explicit path from config (~/.echohub/config.json)
|
||||||
|
if (!string.IsNullOrWhiteSpace(_config.SoundFile))
|
||||||
|
{
|
||||||
|
if (File.Exists(_config.SoundFile))
|
||||||
|
{
|
||||||
|
_resolvedSoundPath = Path.GetFullPath(_config.SoundFile);
|
||||||
|
Log.Debug("Notification sound: {Path} (from config)", _resolvedSoundPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.Warning("Configured sound file not found: {Path}", _config.SoundFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Default: Notification.mp3 bundled next to the executable
|
||||||
|
var defaultPath = Path.Combine(AppContext.BaseDirectory, "Assets", "Notification.mp3");
|
||||||
|
|
||||||
|
if (File.Exists(defaultPath))
|
||||||
|
{
|
||||||
|
_resolvedSoundPath = defaultPath;
|
||||||
|
Log.Debug("Notification sound: {Path} (default)", _resolvedSoundPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.Information("No notification sound file found — notifications will be silent");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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,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;
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
using AlwaysUpToDate;
|
||||||
|
|
||||||
|
using EchoHub.Client.UI.Dialogs;
|
||||||
|
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
|
using Terminal.Gui.App;
|
||||||
|
using Terminal.Gui.Views;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.Services;
|
||||||
|
|
||||||
|
public sealed class UpdateChecker : IDisposable
|
||||||
|
{
|
||||||
|
private const string ManifestUrl = "https://echohub.voidcube.cloud/api/app/version";
|
||||||
|
|
||||||
|
private readonly Updater _updater;
|
||||||
|
private readonly IApplication _app;
|
||||||
|
private UpdateProgressDialog? _progressDialog;
|
||||||
|
private bool _manualCheck;
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
{
|
||||||
|
await _updater.CheckForUpdateAsync();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_manualCheck = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnUpdateAvailable(string version, string changelogUrl)
|
||||||
|
{
|
||||||
|
Log.Information("Update available: v{Version}", version);
|
||||||
|
|
||||||
|
_app.Invoke(() =>
|
||||||
|
{
|
||||||
|
var confirmed = UpdateConfirmDialog.Show(_app, CurrentVersion, version);
|
||||||
|
|
||||||
|
if (confirmed)
|
||||||
|
{
|
||||||
|
_progressDialog = new UpdateProgressDialog(_app, version);
|
||||||
|
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
// Create backup before the update starts
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_progressDialog?.UpdateProgress(0f, "Creating backup...");
|
||||||
|
UpdateBackupService.CreateBackup();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "Failed to create pre-update backup");
|
||||||
|
|
||||||
|
var proceed = false;
|
||||||
|
proceed = MessageBox.Query(
|
||||||
|
_app,
|
||||||
|
"Backup Warning",
|
||||||
|
$"Could not create backup: {ex.Message}\n\nContinue update without backup?",
|
||||||
|
"Continue", "Cancel") == 0;
|
||||||
|
|
||||||
|
if (!proceed)
|
||||||
|
{
|
||||||
|
_progressDialog?.Close();
|
||||||
|
_progressDialog = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_progressDialog?.UpdateProgress(0f, "Downloading update...");
|
||||||
|
await _updater.UpdateAsync();
|
||||||
|
});
|
||||||
|
|
||||||
|
_progressDialog.Show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnProgressChanged(UpdateStep step, long itemsProcessed, long? totalItems, double? progressPercentage)
|
||||||
|
{
|
||||||
|
var fraction = progressPercentage.HasValue ? (float)(progressPercentage.Value / 100.0) : 0f;
|
||||||
|
var statusText = $"{step}: {itemsProcessed}/{totalItems ?? 0} ({progressPercentage ?? 0:F0}%)";
|
||||||
|
|
||||||
|
if (!progressPercentage.HasValue)
|
||||||
|
{
|
||||||
|
statusText = $"{step}...";
|
||||||
|
}
|
||||||
|
|
||||||
|
_progressDialog?.UpdateProgress(fraction, statusText);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnUpdateStarted(string version)
|
||||||
|
{
|
||||||
|
Log.Information("Update started: v{Version}", version);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnNoUpdateAvailable()
|
||||||
|
{
|
||||||
|
Log.Debug("No update available");
|
||||||
|
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");
|
||||||
|
_app.Invoke(() =>
|
||||||
|
{
|
||||||
|
_progressDialog?.Close();
|
||||||
|
_progressDialog = null;
|
||||||
|
|
||||||
|
if (UpdateBackupService.BackupExists())
|
||||||
|
{
|
||||||
|
var restore = MessageBox.Query(
|
||||||
|
_app,
|
||||||
|
"Update Failed",
|
||||||
|
$"The update failed: {exception.Message}\n\n"
|
||||||
|
+ "A backup of the previous version is available.\nRestore now? (The app will restart.)",
|
||||||
|
"Restore", "Cancel");
|
||||||
|
|
||||||
|
if (restore == 0)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
UpdateBackupService.RestoreBackup();
|
||||||
|
// RestoreBackup calls Environment.Exit(0)
|
||||||
|
}
|
||||||
|
catch (Exception restoreEx)
|
||||||
|
{
|
||||||
|
Log.Error(restoreEx, "Backup restoration failed");
|
||||||
|
MessageBox.ErrorQuery(_app, "Restore Failed",
|
||||||
|
$"Could not restore backup: {restoreEx.Message}\n\nYou may need to re-download the application.", "OK");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
MessageBox.ErrorQuery(_app, "Update Failed",
|
||||||
|
$"The update failed: {exception.Message}\n\nYou may need to re-download the application.", "OK");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_updater.UpdateAvailable -= OnUpdateAvailable;
|
||||||
|
_updater.ProgressChanged -= OnProgressChanged;
|
||||||
|
_updater.UpdateStarted -= OnUpdateStarted;
|
||||||
|
_updater.NoUpdateAvailable -= OnNoUpdateAvailable;
|
||||||
|
_updater.OnException -= OnException;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using EchoHub.Core.Models;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Holds the current user's session state (username, status, status message).
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class UserSession
|
||||||
|
{
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
public UserStatus Status { get; set; } = UserStatus.Online;
|
||||||
|
public string? StatusMessage { get; set; }
|
||||||
|
|
||||||
|
public void Reset()
|
||||||
|
{
|
||||||
|
Username = string.Empty;
|
||||||
|
Status = UserStatus.Online;
|
||||||
|
StatusMessage = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -418,30 +418,30 @@ public static class ThemeManager
|
|||||||
Base = new ThemeColors
|
Base = new ThemeColors
|
||||||
{
|
{
|
||||||
Foreground = "White",
|
Foreground = "White",
|
||||||
Background = "Black",
|
Background = "None",
|
||||||
FocusForeground = "BrightCyan",
|
FocusForeground = "BrightCyan",
|
||||||
FocusBackground = "Black"
|
FocusBackground = "None"
|
||||||
},
|
},
|
||||||
Menu = new ThemeColors
|
Menu = new ThemeColors
|
||||||
{
|
{
|
||||||
Foreground = "White",
|
Foreground = "White",
|
||||||
Background = "Black",
|
Background = "None",
|
||||||
FocusForeground = "BrightCyan",
|
FocusForeground = "BrightCyan",
|
||||||
FocusBackground = "Black"
|
FocusBackground = "None"
|
||||||
},
|
},
|
||||||
Dialog = new ThemeColors
|
Dialog = new ThemeColors
|
||||||
{
|
{
|
||||||
Foreground = "White",
|
Foreground = "White",
|
||||||
Background = "Black",
|
Background = "DarkGray",
|
||||||
FocusForeground = "BrightCyan",
|
FocusForeground = "BrightCyan",
|
||||||
FocusBackground = "Black"
|
FocusBackground = "Black"
|
||||||
},
|
},
|
||||||
Status = new ThemeColors
|
Status = new ThemeColors
|
||||||
{
|
{
|
||||||
Foreground = "Gray",
|
Foreground = "Gray",
|
||||||
Background = "Black",
|
Background = "None",
|
||||||
FocusForeground = "Gray",
|
FocusForeground = "Gray",
|
||||||
FocusBackground = "Black"
|
FocusBackground = "None"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
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>
|
||||||
|
/// 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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
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>
|
||||||
|
/// A single line in the chat, composed of colored segments.
|
||||||
|
/// </summary>
|
||||||
|
public partial class ChatLine
|
||||||
|
{
|
||||||
|
public List<ChatSegment> Segments { get; }
|
||||||
|
public int TextLength { get; }
|
||||||
|
public Guid? MessageId { get; set; }
|
||||||
|
public bool IsMention { get; set; }
|
||||||
|
public string? AttachmentUrl { get; set; }
|
||||||
|
public string? AttachmentFileName { get; set; }
|
||||||
|
public MessageType? Type { get; set; }
|
||||||
|
public string? SenderUsername { get; set; }
|
||||||
|
/// <summary>Number of spaces to prepend on continuation lines when this line is word-wrapped.</summary>
|
||||||
|
public int ContinuationIndent { get; set; }
|
||||||
|
|
||||||
|
public ChatLine(string plainText)
|
||||||
|
{
|
||||||
|
Segments = [new ChatSegment(plainText, null)];
|
||||||
|
TextLength = plainText.GetColumns();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ChatLine(List<ChatSegment> segments)
|
||||||
|
{
|
||||||
|
Segments = segments;
|
||||||
|
TextLength = segments.Sum(s => s.Text.GetColumns());
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => string.Concat(Segments.Select(s => s.Text));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wrap this line into multiple lines that fit within the given width.
|
||||||
|
/// Continuation lines are indented with the specified number of spaces.
|
||||||
|
/// </summary>
|
||||||
|
public List<ChatLine> Wrap(int width, int continuationIndent = 0)
|
||||||
|
{
|
||||||
|
if (width <= 0 || TextLength <= width)
|
||||||
|
return [this];
|
||||||
|
|
||||||
|
var 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 && 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
|
||||||
|
foreach (var wrapped in results)
|
||||||
|
{
|
||||||
|
wrapped.AttachmentUrl = AttachmentUrl;
|
||||||
|
wrapped.AttachmentFileName = AttachmentFileName;
|
||||||
|
wrapped.Type = Type;
|
||||||
|
wrapped.MessageId = MessageId;
|
||||||
|
wrapped.SenderUsername = SenderUsername;
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if a line contains printable color tags.
|
||||||
|
/// </summary>
|
||||||
|
public static bool HasColorTags(string text) =>
|
||||||
|
text.Contains("{F:") || text.Contains("{B:") || text.Contains("{X}");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Remove all color tags from text, returning only the visible characters.
|
||||||
|
/// </summary>
|
||||||
|
public static string StripColorTags(string text) =>
|
||||||
|
ColorTagRegex().Replace(text, "");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse a string containing printable color tags into colored segments.
|
||||||
|
/// Format: {F:RRGGBB} (foreground), {B:RRGGBB} (background), {X} (reset).
|
||||||
|
/// </summary>
|
||||||
|
public static ChatLine FromColoredText(string text, Attribute? defaultAttr = null)
|
||||||
|
{
|
||||||
|
var segments = new List<ChatSegment>();
|
||||||
|
int lastIndex = 0;
|
||||||
|
Color? currentFg = null;
|
||||||
|
Color? currentBg = null;
|
||||||
|
var defaultFg = defaultAttr?.Foreground;
|
||||||
|
var defaultBg = defaultAttr?.Background ?? Color.None;
|
||||||
|
|
||||||
|
Attribute? BuildAttr()
|
||||||
|
{
|
||||||
|
if (currentFg is null && currentBg is null) return defaultAttr;
|
||||||
|
var fg = currentFg ?? defaultFg ?? Color.White;
|
||||||
|
var bg = currentBg ?? defaultBg;
|
||||||
|
return new Attribute(fg, bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (Match match in ColorTagRegex().Matches(text))
|
||||||
|
{
|
||||||
|
if (match.Index > lastIndex)
|
||||||
|
{
|
||||||
|
var t = text[lastIndex..match.Index];
|
||||||
|
if (t.Length > 0)
|
||||||
|
segments.Add(new ChatSegment(t, BuildAttr()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.Groups[1].Success)
|
||||||
|
{
|
||||||
|
currentFg = null;
|
||||||
|
currentBg = null;
|
||||||
|
}
|
||||||
|
else if (match.Groups[2].Success)
|
||||||
|
{
|
||||||
|
var hex = match.Groups[3].Value;
|
||||||
|
var r = Convert.ToInt32(hex[..2], 16);
|
||||||
|
var g = Convert.ToInt32(hex[2..4], 16);
|
||||||
|
var b = Convert.ToInt32(hex[4..6], 16);
|
||||||
|
if (match.Groups[2].Value == "F")
|
||||||
|
currentFg = new Color(r, g, b);
|
||||||
|
else
|
||||||
|
currentBg = new Color(r, g, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastIndex = match.Index + match.Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < text.Length)
|
||||||
|
{
|
||||||
|
var t = text[lastIndex..];
|
||||||
|
if (t.Length > 0)
|
||||||
|
segments.Add(new ChatSegment(t, BuildAttr()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments.Count > 0 ? new ChatLine(segments) : new ChatLine("");
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex(@"\{(?:(X)|(?:(F|B):([0-9A-Fa-f]{6})))\}")]
|
||||||
|
private static partial Regex ColorTagRegex();
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
using System.Collections;
|
||||||
|
using System.Collections.Specialized;
|
||||||
|
using Terminal.Gui.Drawing;
|
||||||
|
using Terminal.Gui.Text;
|
||||||
|
using Terminal.Gui.Views;
|
||||||
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI.Chat;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Custom list data source for chat messages with per-segment coloring.
|
||||||
|
/// </summary>
|
||||||
|
public class ChatListSource : IListDataSource
|
||||||
|
{
|
||||||
|
private readonly List<ChatLine> _lines = [];
|
||||||
|
|
||||||
|
public event NotifyCollectionChangedEventHandler? CollectionChanged;
|
||||||
|
|
||||||
|
public int Count => _lines.Count;
|
||||||
|
public int MaxItemLength { get; private set; }
|
||||||
|
public bool SuspendCollectionChangedEvent { get; set; }
|
||||||
|
|
||||||
|
public void Add(ChatLine line)
|
||||||
|
{
|
||||||
|
_lines.Add(line);
|
||||||
|
UpdateMaxLength(line);
|
||||||
|
RaiseCollectionChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddRange(IEnumerable<ChatLine> lines)
|
||||||
|
{
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
_lines.Add(line);
|
||||||
|
UpdateMaxLength(line);
|
||||||
|
}
|
||||||
|
RaiseCollectionChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void InsertRange(int index, IEnumerable<ChatLine> lines)
|
||||||
|
{
|
||||||
|
var items = lines.ToList();
|
||||||
|
_lines.InsertRange(index, items);
|
||||||
|
foreach (var line in items)
|
||||||
|
UpdateMaxLength(line);
|
||||||
|
RaiseCollectionChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
_lines.Clear();
|
||||||
|
MaxItemLength = 0;
|
||||||
|
RaiseCollectionChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ChatLine? GetLine(int index) => index >= 0 && index < _lines.Count ? _lines[index] : null;
|
||||||
|
|
||||||
|
public bool IsMarked(int item) => false;
|
||||||
|
public void SetMark(int item, bool value) { }
|
||||||
|
public IList ToList() => _lines.Select(l => l.ToString()).ToList();
|
||||||
|
|
||||||
|
public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0)
|
||||||
|
{
|
||||||
|
listView.Move(Math.Max(col - viewportX, 0), row);
|
||||||
|
|
||||||
|
var chatLine = _lines[item];
|
||||||
|
var normalAttr = listView.GetAttributeForRole(VisualRole.Normal);
|
||||||
|
var mentionBg = chatLine.IsMention ? ChatColors.MentionHighlightAttr.Background : (Color?)null;
|
||||||
|
|
||||||
|
int charPos = 0;
|
||||||
|
int drawnChars = 0;
|
||||||
|
|
||||||
|
foreach (var segment in chatLine.Segments)
|
||||||
|
{
|
||||||
|
var attr = segment.Color ?? normalAttr;
|
||||||
|
if (attr.Background == Color.None)
|
||||||
|
attr = attr with { Background = normalAttr.Background };
|
||||||
|
if (mentionBg.HasValue)
|
||||||
|
attr = attr with { Background = mentionBg.Value };
|
||||||
|
listView.SetAttribute(attr);
|
||||||
|
|
||||||
|
foreach (var grapheme in GraphemeHelper.GetGraphemes(segment.Text))
|
||||||
|
{
|
||||||
|
var cols = Math.Max(grapheme.GetColumns(), 1);
|
||||||
|
if (charPos >= viewportX && drawnChars + cols <= width)
|
||||||
|
{
|
||||||
|
listView.AddStr(grapheme);
|
||||||
|
drawnChars += cols;
|
||||||
|
}
|
||||||
|
charPos += cols;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var fillAttr = mentionBg.HasValue ? new Attribute(normalAttr.Foreground, mentionBg.Value) : normalAttr;
|
||||||
|
listView.SetAttribute(fillAttr);
|
||||||
|
for (int i = drawnChars; i < width; i++)
|
||||||
|
listView.AddStr(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateMaxLength(ChatLine line)
|
||||||
|
{
|
||||||
|
if (line.TextLength > MaxItemLength)
|
||||||
|
MaxItemLength = line.TextLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RaiseCollectionChanged()
|
||||||
|
{
|
||||||
|
if (!SuspendCollectionChangedEvent)
|
||||||
|
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using EchoHub.Client.UI.Helpers;
|
||||||
|
using EchoHub.Core.DTOs;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
|
using Terminal.Gui.Drawing;
|
||||||
|
using Terminal.Gui.Text;
|
||||||
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI.Chat;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owns chat message storage, formatting, and mutation.
|
||||||
|
/// Fires <see cref="MessagesChanged"/> when a channel's message list is modified
|
||||||
|
/// so the UI layer can refresh.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ChatMessageManager
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, List<ChatLine>> _channelMessages = [];
|
||||||
|
private readonly Dictionary<string, int> _channelUnread = [];
|
||||||
|
|
||||||
|
private string _currentUser = string.Empty;
|
||||||
|
private string _currentChannel = string.Empty;
|
||||||
|
private int _chatWidth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired after any mutation to a channel's messages. Parameter is the channel name.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<string>? MessagesChanged;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The currently active channel (used for unread tracking and @mention detection).
|
||||||
|
/// </summary>
|
||||||
|
public string CurrentChannel
|
||||||
|
{
|
||||||
|
get => _currentChannel;
|
||||||
|
set => _currentChannel = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string CurrentUser => _currentUser;
|
||||||
|
|
||||||
|
public void SetCurrentUser(string username) => _currentUser = username;
|
||||||
|
|
||||||
|
public void SetChatWidth(int width) => _chatWidth = width;
|
||||||
|
|
||||||
|
// ── Queries ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public List<ChatLine>? GetMessages(string channelName)
|
||||||
|
{
|
||||||
|
return _channelMessages.TryGetValue(channelName, out var messages) ? messages : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int GetUnreadCount(string channelName)
|
||||||
|
{
|
||||||
|
return _channelUnread.TryGetValue(channelName, out var count) ? count : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearUnread(string channelName)
|
||||||
|
{
|
||||||
|
_channelUnread[channelName] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal Dictionary<string, int> GetUnreadCounts() => _channelUnread;
|
||||||
|
|
||||||
|
// ── Mutations ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Format and store a received message. Increments unread count if not the active channel.
|
||||||
|
/// </summary>
|
||||||
|
public void AddMessage(MessageDto message)
|
||||||
|
{
|
||||||
|
var lines = FormatMessage(message);
|
||||||
|
if (!_channelMessages.TryGetValue(message.ChannelName, out var messages))
|
||||||
|
{
|
||||||
|
messages = [];
|
||||||
|
_channelMessages[message.ChannelName] = messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var line in lines)
|
||||||
|
messages.Add(line);
|
||||||
|
|
||||||
|
if (message.ChannelName == _currentChannel)
|
||||||
|
{
|
||||||
|
MessagesChanged?.Invoke(message.ChannelName);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_channelUnread.TryGetValue(message.ChannelName, out var count);
|
||||||
|
_channelUnread[message.ChannelName] = count + 1;
|
||||||
|
MessagesChanged?.Invoke(message.ChannelName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Add a system/informational message to a channel with colored styling.
|
||||||
|
/// </summary>
|
||||||
|
public void AddSystemMessage(string channelName, string text)
|
||||||
|
{
|
||||||
|
if (!_channelMessages.TryGetValue(channelName, out var messages))
|
||||||
|
{
|
||||||
|
messages = [];
|
||||||
|
_channelMessages[channelName] = messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
var time = DateTimeOffset.Now.ToString("HH:mm");
|
||||||
|
var textLines = text.Split('\n');
|
||||||
|
|
||||||
|
messages.Add(new ChatLine(
|
||||||
|
[
|
||||||
|
new($"[{time}] ", ChatColors.TimestampAttr),
|
||||||
|
new($"** {textLines[0].TrimEnd('\r')}", ChatColors.SystemAttr)
|
||||||
|
]));
|
||||||
|
|
||||||
|
var indent = new string(' ', $"[{time}] ** ".Length);
|
||||||
|
for (int i = 1; i < textLines.Length; i++)
|
||||||
|
{
|
||||||
|
var line = textLines[i].TrimEnd('\r');
|
||||||
|
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||||
|
messages.Add(new ChatLine(
|
||||||
|
[
|
||||||
|
new($"{indent}{line}", ChatColors.SystemAttr)
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channelName == _currentChannel)
|
||||||
|
MessagesChanged?.Invoke(channelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Add a status change message to a channel with colored styling.
|
||||||
|
/// </summary>
|
||||||
|
public void AddStatusMessage(string channelName, string username, string status)
|
||||||
|
{
|
||||||
|
var time = DateTimeOffset.Now.ToString("HH:mm");
|
||||||
|
var segments = new List<ChatSegment>
|
||||||
|
{
|
||||||
|
new($"[{time}] ", ChatColors.TimestampAttr),
|
||||||
|
new($"** {username} is now {status}", ChatColors.SystemAttr)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!_channelMessages.TryGetValue(channelName, out var messages))
|
||||||
|
{
|
||||||
|
messages = [];
|
||||||
|
_channelMessages[channelName] = messages;
|
||||||
|
}
|
||||||
|
messages.Add(new ChatLine(segments));
|
||||||
|
|
||||||
|
if (channelName == _currentChannel)
|
||||||
|
MessagesChanged?.Invoke(channelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Remove all lines associated with a specific message ID.
|
||||||
|
/// </summary>
|
||||||
|
public void RemoveMessage(string channelName, Guid messageId)
|
||||||
|
{
|
||||||
|
if (_channelMessages.TryGetValue(channelName, out var messages))
|
||||||
|
{
|
||||||
|
messages.RemoveAll(l => l.MessageId == messageId);
|
||||||
|
if (channelName == _currentChannel)
|
||||||
|
MessagesChanged?.Invoke(channelName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clear all messages from a specific channel.
|
||||||
|
/// </summary>
|
||||||
|
public void ClearChannelMessages(string channelName)
|
||||||
|
{
|
||||||
|
if (_channelMessages.TryGetValue(channelName, out var messages))
|
||||||
|
{
|
||||||
|
messages.Clear();
|
||||||
|
if (channelName == _currentChannel)
|
||||||
|
MessagesChanged?.Invoke(channelName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Load historical messages into a channel, replacing any existing messages.
|
||||||
|
/// </summary>
|
||||||
|
public void LoadHistory(string channelName, List<MessageDto> messages)
|
||||||
|
{
|
||||||
|
var formatted = messages.SelectMany(FormatMessage).ToList();
|
||||||
|
_channelMessages[channelName] = formatted;
|
||||||
|
|
||||||
|
if (channelName == _currentChannel)
|
||||||
|
MessagesChanged?.Invoke(channelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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>
|
||||||
|
/// Reset all message state (used on disconnect).
|
||||||
|
/// </summary>
|
||||||
|
public void ClearAll()
|
||||||
|
{
|
||||||
|
_channelMessages.Clear();
|
||||||
|
_channelUnread.Clear();
|
||||||
|
_currentChannel = string.Empty;
|
||||||
|
_currentUser = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Formatting ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private List<ChatLine> FormatMessage(MessageDto message)
|
||||||
|
{
|
||||||
|
var time = message.SentAt.ToLocalTime().ToString("HH:mm");
|
||||||
|
var senderName = message.SenderUsername + ":";
|
||||||
|
var senderColor = HexColorHelper.ParseHexColor(message.SenderNicknameColor);
|
||||||
|
|
||||||
|
var lines = new List<ChatLine>();
|
||||||
|
|
||||||
|
switch (message.Type)
|
||||||
|
{
|
||||||
|
case MessageType.Image:
|
||||||
|
lines.Add(BuildChatLine(time, senderName, senderColor, " [Image]"));
|
||||||
|
if (!string.IsNullOrWhiteSpace(message.Content))
|
||||||
|
{
|
||||||
|
foreach (var artLine in message.Content.Split('\n'))
|
||||||
|
{
|
||||||
|
var trimmed = artLine.TrimEnd('\r');
|
||||||
|
if (ChatLine.HasColorTags(trimmed))
|
||||||
|
lines.Add(ChatLine.FromColoredText(" " + trimmed));
|
||||||
|
else
|
||||||
|
lines.Add(new ChatLine($" {trimmed}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MessageType.Audio:
|
||||||
|
var audioName = message.AttachmentFileName ?? "unknown";
|
||||||
|
var audioSize = FormatFileSize(message.AttachmentFileSize);
|
||||||
|
var audioLine = BuildChatLineColored(time, senderName, senderColor,
|
||||||
|
$" \u266a [Audio: {audioName}] [{audioSize}]", ChatColors.AudioAttr);
|
||||||
|
audioLine.AttachmentUrl = message.AttachmentUrl;
|
||||||
|
audioLine.AttachmentFileName = audioName;
|
||||||
|
audioLine.Type = MessageType.Audio;
|
||||||
|
lines.Add(audioLine);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MessageType.File:
|
||||||
|
var fileName = message.AttachmentFileName ?? "unknown";
|
||||||
|
var fileSize = FormatFileSize(message.AttachmentFileSize);
|
||||||
|
var fileLine = BuildChatLineColored(time, senderName, senderColor,
|
||||||
|
$" [File: {fileName}] [{fileSize}]", ChatColors.FileAttr);
|
||||||
|
fileLine.AttachmentUrl = message.AttachmentUrl;
|
||||||
|
fileLine.AttachmentFileName = fileName;
|
||||||
|
fileLine.Type = MessageType.File;
|
||||||
|
lines.Add(fileLine);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MessageType.Text:
|
||||||
|
default:
|
||||||
|
var displayContent = EmojiHelper.ReplaceEmoji(message.Content);
|
||||||
|
var contentLines = displayContent.Split('\n');
|
||||||
|
var firstLine = contentLines[0].TrimEnd('\r');
|
||||||
|
lines.Add(BuildChatLineWithMentions(time, senderName, senderColor, $" {firstLine}"));
|
||||||
|
var indent = new string(' ', $"[{time}] {senderName} ".Length);
|
||||||
|
for (int i = 1; i < contentLines.Length; i++)
|
||||||
|
{
|
||||||
|
var contText = $"{indent}{contentLines[i].TrimEnd('\r')}";
|
||||||
|
lines.Add(new ChatLine(ChatColors.SplitMentions(contText)));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var l in lines)
|
||||||
|
l.ContinuationIndent = indent.Length;
|
||||||
|
|
||||||
|
if (message.Embeds is { Count: > 0 })
|
||||||
|
{
|
||||||
|
var chatWidth = _chatWidth > 0 ? _chatWidth : 80;
|
||||||
|
foreach (var embed in message.Embeds)
|
||||||
|
lines.AddRange(FormatEmbed(embed, indent, chatWidth));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
line.MessageId = message.Id;
|
||||||
|
line.SenderUsername = message.SenderUsername;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(_currentUser) && message.Type == MessageType.Text)
|
||||||
|
{
|
||||||
|
var pattern = $@"@{Regex.Escape(_currentUser)}\b";
|
||||||
|
if (Regex.IsMatch(message.Content, pattern, RegexOptions.IgnoreCase))
|
||||||
|
{
|
||||||
|
foreach (var line in lines)
|
||||||
|
line.IsMention = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChatLine BuildChatLine(string time, string senderName, Attribute? senderColor, string suffix)
|
||||||
|
{
|
||||||
|
var segments = new List<ChatSegment>
|
||||||
|
{
|
||||||
|
new($"[{time}] ", ChatColors.TimestampAttr),
|
||||||
|
new(senderName, senderColor),
|
||||||
|
new(suffix, null)
|
||||||
|
};
|
||||||
|
return new ChatLine(segments);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChatLine BuildChatLineColored(string time, string senderName, Attribute? senderColor, string suffix, Attribute suffixColor)
|
||||||
|
{
|
||||||
|
var segments = new List<ChatSegment>
|
||||||
|
{
|
||||||
|
new($"[{time}] ", ChatColors.TimestampAttr),
|
||||||
|
new(senderName, senderColor),
|
||||||
|
new(suffix, suffixColor)
|
||||||
|
};
|
||||||
|
return new ChatLine(segments);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChatLine BuildChatLineWithMentions(string time, string senderName, Attribute? senderColor, string suffix)
|
||||||
|
{
|
||||||
|
var segments = new List<ChatSegment>
|
||||||
|
{
|
||||||
|
new($"[{time}] ", ChatColors.TimestampAttr),
|
||||||
|
new(senderName, senderColor),
|
||||||
|
};
|
||||||
|
segments.AddRange(ChatColors.SplitMentions(suffix));
|
||||||
|
return new ChatLine(segments);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<ChatLine> FormatEmbed(EmbedDto embed, string indent, int chatWidth)
|
||||||
|
{
|
||||||
|
var lines = new List<ChatLine>();
|
||||||
|
const string border = "\u258f "; // ▏ + space
|
||||||
|
const int borderCols = 2;
|
||||||
|
int indentCols = indent.GetColumns();
|
||||||
|
int textWidth = chatWidth - indentCols - borderCols;
|
||||||
|
if (textWidth < 20) textWidth = 20;
|
||||||
|
|
||||||
|
var borderAttr = HexColorHelper.ParseHexColor(embed.ThemeColor) ?? ChatColors.EmbedBorderAttr;
|
||||||
|
|
||||||
|
void AddTextLine(string text, Attribute? color)
|
||||||
|
{
|
||||||
|
lines.Add(new ChatLine(
|
||||||
|
[
|
||||||
|
new ChatSegment(indent, null),
|
||||||
|
new ChatSegment(border, borderAttr),
|
||||||
|
new ChatSegment(text, color)
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string FormatFileSize(long? bytes)
|
||||||
|
{
|
||||||
|
if (bytes is null or 0)
|
||||||
|
return "?";
|
||||||
|
|
||||||
|
return bytes.Value switch
|
||||||
|
{
|
||||||
|
< 1024 => $"{bytes.Value} B",
|
||||||
|
< 1024 * 1024 => $"{bytes.Value / 1024.0:F1} KB",
|
||||||
|
< 1024 * 1024 * 1024 => $"{bytes.Value / (1024.0 * 1024.0):F1} MB",
|
||||||
|
_ => $"{bytes.Value / (1024.0 * 1024.0 * 1024.0):F1} GB"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI.Chat;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A colored text segment within a chat line.
|
||||||
|
/// </summary>
|
||||||
|
public record ChatSegment(string Text, Attribute? Color);
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using Terminal.Gui.Drawing;
|
||||||
|
using Terminal.Gui.Text;
|
||||||
|
using Terminal.Gui.Views;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI.Chat;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared rendering helpers for IListDataSource implementations.
|
||||||
|
/// </summary>
|
||||||
|
static class RenderHelpers
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Write text grapheme-by-grapheme to a ListView, respecting a width limit.
|
||||||
|
/// Returns the updated drawn-columns count.
|
||||||
|
/// </summary>
|
||||||
|
public static int WriteText(ListView lv, string text, int drawn, int maxWidth)
|
||||||
|
{
|
||||||
|
foreach (var grapheme in GraphemeHelper.GetGraphemes(text))
|
||||||
|
{
|
||||||
|
var cols = Math.Max(grapheme.GetColumns(), 1);
|
||||||
|
if (drawn + cols > maxWidth) break;
|
||||||
|
lv.AddStr(grapheme);
|
||||||
|
drawn += cols;
|
||||||
|
}
|
||||||
|
return drawn;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
using System.Collections;
|
|
||||||
using System.Collections.Specialized;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using Terminal.Gui.Drawing;
|
|
||||||
using Terminal.Gui.Views;
|
|
||||||
using Attribute = Terminal.Gui.Drawing.Attribute;
|
|
||||||
|
|
||||||
namespace EchoHub.Client.UI;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A colored text segment within a chat line.
|
|
||||||
/// </summary>
|
|
||||||
public record ChatSegment(string Text, Attribute? Color);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A single line in the chat, composed of colored segments.
|
|
||||||
/// </summary>
|
|
||||||
public partial class ChatLine
|
|
||||||
{
|
|
||||||
public List<ChatSegment> Segments { get; }
|
|
||||||
public int TextLength { get; }
|
|
||||||
|
|
||||||
public ChatLine(string plainText)
|
|
||||||
{
|
|
||||||
Segments = [new ChatSegment(plainText, null)];
|
|
||||||
TextLength = plainText.Length;
|
|
||||||
}
|
|
||||||
|
|
||||||
public ChatLine(List<ChatSegment> segments)
|
|
||||||
{
|
|
||||||
Segments = segments;
|
|
||||||
TextLength = segments.Sum(s => s.Text.Length);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override string ToString() => string.Concat(Segments.Select(s => s.Text));
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Wrap this line into multiple lines that fit within the given width.
|
|
||||||
/// Continuation lines are indented with the specified number of spaces.
|
|
||||||
/// </summary>
|
|
||||||
public List<ChatLine> Wrap(int width, int continuationIndent = 0)
|
|
||||||
{
|
|
||||||
if (width <= 0 || TextLength <= width)
|
|
||||||
return [this];
|
|
||||||
|
|
||||||
var results = new List<ChatLine>();
|
|
||||||
var currentSegments = new List<ChatSegment>();
|
|
||||||
int col = 0;
|
|
||||||
|
|
||||||
foreach (var segment in Segments)
|
|
||||||
{
|
|
||||||
int segPos = 0;
|
|
||||||
while (segPos < segment.Text.Length)
|
|
||||||
{
|
|
||||||
int remaining = width - col;
|
|
||||||
if (remaining <= 0)
|
|
||||||
{
|
|
||||||
// Emit current line and start a new one
|
|
||||||
results.Add(new ChatLine(currentSegments));
|
|
||||||
currentSegments = [];
|
|
||||||
|
|
||||||
// Add indent for continuation
|
|
||||||
if (continuationIndent > 0)
|
|
||||||
{
|
|
||||||
currentSegments.Add(new ChatSegment(new string(' ', continuationIndent), null));
|
|
||||||
col = continuationIndent;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
col = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
remaining = width - col;
|
|
||||||
}
|
|
||||||
|
|
||||||
int take = Math.Min(segment.Text.Length - segPos, remaining);
|
|
||||||
currentSegments.Add(new ChatSegment(segment.Text.Substring(segPos, take), segment.Color));
|
|
||||||
col += take;
|
|
||||||
segPos += take;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentSegments.Count > 0)
|
|
||||||
results.Add(new ChatLine(currentSegments));
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Parse a string containing ANSI 24-bit color escape codes into colored segments.
|
|
||||||
/// Format: \x1b[38;2;R;G;Bm (foreground color), \x1b[0m (reset)
|
|
||||||
/// </summary>
|
|
||||||
public static ChatLine FromAnsi(string ansiText, Attribute? defaultAttr = null)
|
|
||||||
{
|
|
||||||
var segments = new List<ChatSegment>();
|
|
||||||
var regex = AnsiColorRegex();
|
|
||||||
int lastIndex = 0;
|
|
||||||
Attribute? currentColor = defaultAttr;
|
|
||||||
|
|
||||||
foreach (Match match in regex.Matches(ansiText))
|
|
||||||
{
|
|
||||||
// Add any text before this escape sequence
|
|
||||||
if (match.Index > lastIndex)
|
|
||||||
{
|
|
||||||
var text = ansiText[lastIndex..match.Index];
|
|
||||||
if (text.Length > 0)
|
|
||||||
segments.Add(new ChatSegment(text, currentColor));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the escape sequence
|
|
||||||
if (match.Groups[1].Value == "0")
|
|
||||||
{
|
|
||||||
// Reset
|
|
||||||
currentColor = defaultAttr;
|
|
||||||
}
|
|
||||||
else if (match.Groups[2].Success)
|
|
||||||
{
|
|
||||||
// 38;2;R;G;B — 24-bit foreground color
|
|
||||||
var r = int.Parse(match.Groups[3].Value);
|
|
||||||
var g = int.Parse(match.Groups[4].Value);
|
|
||||||
var b = int.Parse(match.Groups[5].Value);
|
|
||||||
currentColor = new Attribute(new Color(r, g, b), Color.Black);
|
|
||||||
}
|
|
||||||
|
|
||||||
lastIndex = match.Index + match.Length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add remaining text
|
|
||||||
if (lastIndex < ansiText.Length)
|
|
||||||
{
|
|
||||||
var text = ansiText[lastIndex..];
|
|
||||||
if (text.Length > 0)
|
|
||||||
segments.Add(new ChatSegment(text, currentColor));
|
|
||||||
}
|
|
||||||
|
|
||||||
return segments.Count > 0 ? new ChatLine(segments) : new ChatLine("");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Matches: \x1b[0m (reset) or \x1b[38;2;R;G;Bm (24-bit foreground)
|
|
||||||
[GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")]
|
|
||||||
private static partial Regex AnsiColorRegex();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Custom list data source for chat messages with per-character coloring.
|
|
||||||
/// </summary>
|
|
||||||
public class ChatListSource : IListDataSource
|
|
||||||
{
|
|
||||||
private readonly List<ChatLine> _lines = [];
|
|
||||||
|
|
||||||
public event NotifyCollectionChangedEventHandler? CollectionChanged;
|
|
||||||
|
|
||||||
public int Count => _lines.Count;
|
|
||||||
public int MaxItemLength { get; private set; }
|
|
||||||
public bool SuspendCollectionChangedEvent { get; set; }
|
|
||||||
|
|
||||||
public void Add(ChatLine line)
|
|
||||||
{
|
|
||||||
_lines.Add(line);
|
|
||||||
UpdateMaxLength(line);
|
|
||||||
RaiseCollectionChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void AddRange(IEnumerable<ChatLine> lines)
|
|
||||||
{
|
|
||||||
foreach (var line in lines)
|
|
||||||
{
|
|
||||||
_lines.Add(line);
|
|
||||||
UpdateMaxLength(line);
|
|
||||||
}
|
|
||||||
RaiseCollectionChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void InsertRange(int index, IEnumerable<ChatLine> lines)
|
|
||||||
{
|
|
||||||
var items = lines.ToList();
|
|
||||||
_lines.InsertRange(index, items);
|
|
||||||
foreach (var line in items)
|
|
||||||
UpdateMaxLength(line);
|
|
||||||
RaiseCollectionChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Clear()
|
|
||||||
{
|
|
||||||
_lines.Clear();
|
|
||||||
MaxItemLength = 0;
|
|
||||||
RaiseCollectionChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsMarked(int item) => false;
|
|
||||||
public void SetMark(int item, bool value) { }
|
|
||||||
public IList ToList() => _lines.Select(l => l.ToString()).ToList();
|
|
||||||
|
|
||||||
public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0)
|
|
||||||
{
|
|
||||||
listView.Move(Math.Max(col - viewportX, 0), row);
|
|
||||||
|
|
||||||
var chatLine = _lines[item];
|
|
||||||
var normalAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal);
|
|
||||||
|
|
||||||
int charPos = 0;
|
|
||||||
int drawnChars = 0;
|
|
||||||
|
|
||||||
foreach (var segment in chatLine.Segments)
|
|
||||||
{
|
|
||||||
var attr = segment.Color ?? normalAttr;
|
|
||||||
listView.SetAttribute(attr);
|
|
||||||
|
|
||||||
foreach (var ch in segment.Text)
|
|
||||||
{
|
|
||||||
if (charPos >= viewportX && drawnChars < width)
|
|
||||||
{
|
|
||||||
listView.AddRune(new Rune(ch));
|
|
||||||
drawnChars++;
|
|
||||||
}
|
|
||||||
charPos++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fill remaining width with spaces using default colors
|
|
||||||
listView.SetAttribute(normalAttr);
|
|
||||||
while (drawnChars < width)
|
|
||||||
{
|
|
||||||
listView.AddRune(new Rune(' '));
|
|
||||||
drawnChars++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateMaxLength(ChatLine line)
|
|
||||||
{
|
|
||||||
if (line.TextLength > MaxItemLength)
|
|
||||||
MaxItemLength = line.TextLength;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RaiseCollectionChanged()
|
|
||||||
{
|
|
||||||
if (!SuspendCollectionChangedEvent)
|
|
||||||
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose() { }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Shared color attributes for chat rendering (timestamps, system messages).
|
|
||||||
/// </summary>
|
|
||||||
public static class ChatColors
|
|
||||||
{
|
|
||||||
public static readonly Attribute TimestampAttr = new(Color.DarkGray, Color.Black);
|
|
||||||
public static readonly Attribute SystemAttr = new(new Color(0, 180, 180), Color.Black);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Helper to parse hex colors to Terminal.Gui Attributes.
|
|
||||||
/// </summary>
|
|
||||||
public static class ColorHelper
|
|
||||||
{
|
|
||||||
public static Attribute? ParseHexColor(string? hex)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(hex))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
hex = hex.TrimStart('#');
|
|
||||||
if (hex.Length != 6)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var r = Convert.ToInt32(hex[..2], 16);
|
|
||||||
var g = Convert.ToInt32(hex[2..4], 16);
|
|
||||||
var b = Convert.ToInt32(hex[4..6], 16);
|
|
||||||
return new Attribute(new Color(r, g, b), Color.Black);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
using EchoHub.Client.Services;
|
||||||
|
using Terminal.Gui.App;
|
||||||
|
using Terminal.Gui.Drawing;
|
||||||
|
using Terminal.Gui.ViewBase;
|
||||||
|
using Terminal.Gui.Views;
|
||||||
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI.Dialogs;
|
||||||
|
|
||||||
|
public sealed class AudioPlayerDialog
|
||||||
|
{
|
||||||
|
// Block characters for wave animation (increasing height)
|
||||||
|
private static readonly string[] WaveBlocks = ["\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"];
|
||||||
|
private const int WaveBarCount = 24;
|
||||||
|
private const int AnimationIntervalMs = 150;
|
||||||
|
|
||||||
|
private static readonly Attribute WaveActiveAttr = new(new Color(180, 100, 255), Color.None);
|
||||||
|
private static readonly Attribute WaveIdleAttr = new(new Color(80, 50, 120), Color.None);
|
||||||
|
private static readonly Attribute FileNameAttr = new(new Color(180, 100, 255), Color.None);
|
||||||
|
private static readonly Attribute StatusPlayingAttr = new(new Color(0, 200, 0), Color.None);
|
||||||
|
private static readonly Attribute StatusPausedAttr = new(new Color(220, 180, 0), Color.None);
|
||||||
|
private static readonly Attribute StatusStoppedAttr = new(new Color(160, 160, 160), Color.None);
|
||||||
|
|
||||||
|
public static void Show(IApplication app, AudioPlaybackService audioService, string filePath, string fileName)
|
||||||
|
{
|
||||||
|
var dialog = new Dialog { Title = "Audio Player", Width = 52, Height = 12 };
|
||||||
|
|
||||||
|
// ── File name ──
|
||||||
|
var fileLabel = new Label
|
||||||
|
{
|
||||||
|
Text = $"\u266a {TruncateFileName(fileName, 44)}",
|
||||||
|
X = 2,
|
||||||
|
Y = 1,
|
||||||
|
Width = Dim.Fill(2)
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Wave visualization ──
|
||||||
|
var waveLabel = new Label
|
||||||
|
{
|
||||||
|
X = 2,
|
||||||
|
Y = 3,
|
||||||
|
Width = Dim.Fill(2),
|
||||||
|
Height = 1
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Status label ──
|
||||||
|
var statusLabel = new Label
|
||||||
|
{
|
||||||
|
Text = "Stopped",
|
||||||
|
X = 2,
|
||||||
|
Y = 5,
|
||||||
|
Width = 20
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Volume controls ──
|
||||||
|
var volumeHeaderLabel = new Label
|
||||||
|
{
|
||||||
|
Text = "Volume:",
|
||||||
|
X = 2,
|
||||||
|
Y = 7
|
||||||
|
};
|
||||||
|
|
||||||
|
byte currentVolume = 50;
|
||||||
|
|
||||||
|
var volDownButton = new Button
|
||||||
|
{
|
||||||
|
Text = "-",
|
||||||
|
X = 10,
|
||||||
|
Y = 7,
|
||||||
|
Width = 1,
|
||||||
|
Height = 1,
|
||||||
|
NoDecorations = true,
|
||||||
|
NoPadding = true,
|
||||||
|
ShadowStyle = ShadowStyle.None
|
||||||
|
};
|
||||||
|
|
||||||
|
var volumeBar = new ProgressBar
|
||||||
|
{
|
||||||
|
X = 12,
|
||||||
|
Y = 7,
|
||||||
|
Width = 22,
|
||||||
|
Height = 1,
|
||||||
|
Fraction = currentVolume / 100f,
|
||||||
|
ProgressBarStyle = ProgressBarStyle.Continuous
|
||||||
|
};
|
||||||
|
|
||||||
|
var volUpButton = new Button
|
||||||
|
{
|
||||||
|
Text = "+",
|
||||||
|
X = 35,
|
||||||
|
Y = 7,
|
||||||
|
Width = 1,
|
||||||
|
Height = 1,
|
||||||
|
NoDecorations = true,
|
||||||
|
NoPadding = true,
|
||||||
|
ShadowStyle = ShadowStyle.None
|
||||||
|
};
|
||||||
|
|
||||||
|
var volumePercentLabel = new Label
|
||||||
|
{
|
||||||
|
Text = $"{currentVolume}%",
|
||||||
|
X = 37,
|
||||||
|
Y = 7,
|
||||||
|
Width = 5
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Playback controls ──
|
||||||
|
var playButton = new Button
|
||||||
|
{
|
||||||
|
Text = "\u25b6 Play",
|
||||||
|
X = 2,
|
||||||
|
Y = 9,
|
||||||
|
IsDefault = true,
|
||||||
|
ShadowStyle = ShadowStyle.None
|
||||||
|
};
|
||||||
|
|
||||||
|
var stopButton = new Button
|
||||||
|
{
|
||||||
|
Text = "\u25a0 Stop",
|
||||||
|
X = Pos.Right(playButton) + 1,
|
||||||
|
Y = 9,
|
||||||
|
ShadowStyle = ShadowStyle.None
|
||||||
|
};
|
||||||
|
|
||||||
|
var closeButton = new Button
|
||||||
|
{
|
||||||
|
Text = "Close",
|
||||||
|
X = Pos.Right(stopButton) + 1,
|
||||||
|
Y = 9,
|
||||||
|
ShadowStyle = ShadowStyle.None
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Animation state ──
|
||||||
|
var animationOffset = 0;
|
||||||
|
var random = new Random();
|
||||||
|
// Pre-generate a repeating wave pattern
|
||||||
|
var wavePattern = new int[WaveBarCount + 8];
|
||||||
|
for (int i = 0; i < wavePattern.Length; i++)
|
||||||
|
wavePattern[i] = random.Next(0, WaveBlocks.Length);
|
||||||
|
|
||||||
|
Timer? animationTimer = null;
|
||||||
|
var isDisposed = false;
|
||||||
|
var isBusy = false;
|
||||||
|
|
||||||
|
// ── Helper functions ──
|
||||||
|
void UpdateWave(bool isActive)
|
||||||
|
{
|
||||||
|
if (isDisposed) return;
|
||||||
|
|
||||||
|
var bars = new string[WaveBarCount];
|
||||||
|
for (int i = 0; i < WaveBarCount; i++)
|
||||||
|
{
|
||||||
|
if (isActive)
|
||||||
|
{
|
||||||
|
var idx = wavePattern[(i + animationOffset) % wavePattern.Length];
|
||||||
|
bars[i] = WaveBlocks[idx];
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
bars[i] = WaveBlocks[1]; // low idle bars
|
||||||
|
}
|
||||||
|
}
|
||||||
|
waveLabel.Text = string.Join(" ", bars);
|
||||||
|
}
|
||||||
|
|
||||||
|
void UpdateStatus()
|
||||||
|
{
|
||||||
|
if (isDisposed) return;
|
||||||
|
|
||||||
|
if (audioService.IsPlaying && !audioService.IsPaused)
|
||||||
|
{
|
||||||
|
statusLabel.Text = "Playing";
|
||||||
|
playButton.Text = "\u23f8 Pause";
|
||||||
|
}
|
||||||
|
else if (audioService.IsPaused)
|
||||||
|
{
|
||||||
|
statusLabel.Text = "Paused";
|
||||||
|
playButton.Text = "\u25b6 Resume";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
statusLabel.Text = "Stopped";
|
||||||
|
playButton.Text = "\u25b6 Play";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void StartAnimation()
|
||||||
|
{
|
||||||
|
animationTimer?.Dispose();
|
||||||
|
animationTimer = new Timer(_ =>
|
||||||
|
{
|
||||||
|
if (isDisposed) return;
|
||||||
|
animationOffset++;
|
||||||
|
// Shuffle a few bars each tick for organic movement
|
||||||
|
var idx = random.Next(0, wavePattern.Length);
|
||||||
|
wavePattern[idx] = random.Next(0, WaveBlocks.Length);
|
||||||
|
|
||||||
|
app.Invoke(() =>
|
||||||
|
{
|
||||||
|
if (isDisposed) return;
|
||||||
|
UpdateWave(true);
|
||||||
|
});
|
||||||
|
}, null, 0, AnimationIntervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
void StopAnimation()
|
||||||
|
{
|
||||||
|
animationTimer?.Dispose();
|
||||||
|
animationTimer = null;
|
||||||
|
if (!isDisposed)
|
||||||
|
UpdateWave(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task UpdateVolume(byte newVolume)
|
||||||
|
{
|
||||||
|
currentVolume = Math.Clamp(newVolume, (byte)0, (byte)100);
|
||||||
|
await audioService.SetVolumeAsync(currentVolume);
|
||||||
|
if (!isDisposed)
|
||||||
|
{
|
||||||
|
volumeBar.Fraction = currentVolume / 100f;
|
||||||
|
volumePercentLabel.Text = $"{currentVolume}%";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Custom drawing for colored elements ──
|
||||||
|
fileLabel.DrawingContent += (s, e) =>
|
||||||
|
{
|
||||||
|
var normalAttr = fileLabel.GetAttributeForRole(VisualRole.Normal);
|
||||||
|
var resolvedAttr = FileNameAttr.Background == Color.None
|
||||||
|
? FileNameAttr with { Background = normalAttr.Background }
|
||||||
|
: FileNameAttr;
|
||||||
|
fileLabel.SetAttribute(resolvedAttr);
|
||||||
|
fileLabel.Move(0, 0);
|
||||||
|
var text = fileLabel.Text ?? "";
|
||||||
|
foreach (var g in Terminal.Gui.Drawing.GraphemeHelper.GetGraphemes(text))
|
||||||
|
fileLabel.AddStr(g);
|
||||||
|
// Fill remaining width
|
||||||
|
var width = fileLabel.Viewport.Width;
|
||||||
|
var textCols = Terminal.Gui.Text.StringExtensions.GetColumns(text);
|
||||||
|
for (int i = textCols; i < width; i++)
|
||||||
|
fileLabel.AddStr(" ");
|
||||||
|
e.Cancel = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
waveLabel.DrawingContent += (s, e) =>
|
||||||
|
{
|
||||||
|
var normalAttr = waveLabel.GetAttributeForRole(VisualRole.Normal);
|
||||||
|
var attr = (audioService.IsPlaying && !audioService.IsPaused) ? WaveActiveAttr : WaveIdleAttr;
|
||||||
|
var resolvedAttr = attr.Background == Color.None
|
||||||
|
? attr with { Background = normalAttr.Background }
|
||||||
|
: attr;
|
||||||
|
waveLabel.SetAttribute(resolvedAttr);
|
||||||
|
waveLabel.Move(0, 0);
|
||||||
|
var text = waveLabel.Text ?? "";
|
||||||
|
foreach (var g in Terminal.Gui.Drawing.GraphemeHelper.GetGraphemes(text))
|
||||||
|
waveLabel.AddStr(g);
|
||||||
|
var width = waveLabel.Viewport.Width;
|
||||||
|
var textCols = Terminal.Gui.Text.StringExtensions.GetColumns(text);
|
||||||
|
for (int i = textCols; i < width; i++)
|
||||||
|
waveLabel.AddStr(" ");
|
||||||
|
e.Cancel = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
statusLabel.DrawingContent += (s, e) =>
|
||||||
|
{
|
||||||
|
var normalAttr = statusLabel.GetAttributeForRole(VisualRole.Normal);
|
||||||
|
Attribute attr;
|
||||||
|
if (audioService.IsPlaying && !audioService.IsPaused)
|
||||||
|
attr = StatusPlayingAttr;
|
||||||
|
else if (audioService.IsPaused)
|
||||||
|
attr = StatusPausedAttr;
|
||||||
|
else
|
||||||
|
attr = StatusStoppedAttr;
|
||||||
|
|
||||||
|
var resolvedAttr = attr.Background == Color.None
|
||||||
|
? attr with { Background = normalAttr.Background }
|
||||||
|
: attr;
|
||||||
|
statusLabel.SetAttribute(resolvedAttr);
|
||||||
|
statusLabel.Move(0, 0);
|
||||||
|
var text = statusLabel.Text ?? "";
|
||||||
|
foreach (var g in Terminal.Gui.Drawing.GraphemeHelper.GetGraphemes(text))
|
||||||
|
statusLabel.AddStr(g);
|
||||||
|
var width = statusLabel.Viewport.Width;
|
||||||
|
var textCols = Terminal.Gui.Text.StringExtensions.GetColumns(text);
|
||||||
|
for (int i = textCols; i < width; i++)
|
||||||
|
statusLabel.AddStr(" ");
|
||||||
|
e.Cancel = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Event handlers ──
|
||||||
|
playButton.Accepting += (s, e) =>
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
if (isBusy) return;
|
||||||
|
isBusy = true;
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
if (audioService.IsPaused)
|
||||||
|
{
|
||||||
|
await audioService.ResumeAsync();
|
||||||
|
app.Invoke(() =>
|
||||||
|
{
|
||||||
|
UpdateStatus();
|
||||||
|
StartAnimation();
|
||||||
|
isBusy = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (audioService.IsPlaying)
|
||||||
|
{
|
||||||
|
await audioService.PauseAsync();
|
||||||
|
app.Invoke(() =>
|
||||||
|
{
|
||||||
|
UpdateStatus();
|
||||||
|
StopAnimation();
|
||||||
|
isBusy = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await audioService.SetVolumeAsync(currentVolume);
|
||||||
|
await audioService.PlayAsync(filePath);
|
||||||
|
app.Invoke(() =>
|
||||||
|
{
|
||||||
|
UpdateStatus();
|
||||||
|
StartAnimation();
|
||||||
|
isBusy = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
stopButton.Accepting += (s, e) =>
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
if (isBusy) return;
|
||||||
|
isBusy = true;
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
await audioService.StopAsync();
|
||||||
|
app.Invoke(() =>
|
||||||
|
{
|
||||||
|
UpdateStatus();
|
||||||
|
StopAnimation();
|
||||||
|
isBusy = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
closeButton.Accepting += (s, e) =>
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
isDisposed = true;
|
||||||
|
animationTimer?.Dispose();
|
||||||
|
_ = audioService.StopAsync(); // fire-and-forget
|
||||||
|
app.RequestStop();
|
||||||
|
};
|
||||||
|
|
||||||
|
volDownButton.Accepting += (s, e) =>
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
if (isBusy) return;
|
||||||
|
isBusy = true;
|
||||||
|
var newVol = (byte)Math.Max(0, currentVolume - 10);
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
await UpdateVolume(newVol);
|
||||||
|
app.Invoke(() =>
|
||||||
|
{
|
||||||
|
volumeBar.SetNeedsDraw();
|
||||||
|
volumePercentLabel.SetNeedsDraw();
|
||||||
|
isBusy = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
volUpButton.Accepting += (s, e) =>
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
if (isBusy) return;
|
||||||
|
isBusy = true;
|
||||||
|
var newVol = (byte)Math.Min(100, currentVolume + 10);
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
await UpdateVolume(newVol);
|
||||||
|
app.Invoke(() =>
|
||||||
|
{
|
||||||
|
volumeBar.SetNeedsDraw();
|
||||||
|
volumePercentLabel.SetNeedsDraw();
|
||||||
|
isBusy = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle playback finishing naturally
|
||||||
|
EventHandler? finishedHandler = null;
|
||||||
|
finishedHandler = (s, e) =>
|
||||||
|
{
|
||||||
|
app.Invoke(() =>
|
||||||
|
{
|
||||||
|
if (isDisposed) return;
|
||||||
|
UpdateStatus();
|
||||||
|
StopAnimation();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
audioService.PlaybackFinished += finishedHandler;
|
||||||
|
|
||||||
|
// ── Initial state ──
|
||||||
|
UpdateWave(false);
|
||||||
|
UpdateStatus();
|
||||||
|
|
||||||
|
dialog.Add(fileLabel, waveLabel, statusLabel,
|
||||||
|
volumeHeaderLabel, volDownButton, volumeBar, volumePercentLabel, volUpButton,
|
||||||
|
playButton, stopButton, closeButton);
|
||||||
|
|
||||||
|
playButton.SetFocus();
|
||||||
|
app.Run(dialog);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
isDisposed = true;
|
||||||
|
animationTimer?.Dispose();
|
||||||
|
audioService.PlaybackFinished -= finishedHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string TruncateFileName(string name, int maxLen)
|
||||||
|
{
|
||||||
|
if (name.Length <= maxLen)
|
||||||
|
return name;
|
||||||
|
|
||||||
|
var ext = Path.GetExtension(name);
|
||||||
|
var stem = Path.GetFileNameWithoutExtension(name);
|
||||||
|
var available = maxLen - ext.Length - 3; // 3 for "..."
|
||||||
|
if (available < 1)
|
||||||
|
return name[..maxLen];
|
||||||
|
|
||||||
|
return stem[..available] + "..." + ext;
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
-17
@@ -4,12 +4,14 @@ using Terminal.Gui.App;
|
|||||||
using Terminal.Gui.Views;
|
using Terminal.Gui.Views;
|
||||||
using Terminal.Gui.ViewBase;
|
using Terminal.Gui.ViewBase;
|
||||||
|
|
||||||
namespace EchoHub.Client.UI;
|
namespace EchoHub.Client.UI.Dialogs;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Result returned from the connect dialog.
|
/// Result returned from the connect dialog.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public record ConnectDialogResult(string ServerUrl, string Username, string Password, bool IsRegister);
|
public record ConnectDialogResult(
|
||||||
|
string ServerUrl, string Username, string Password,
|
||||||
|
bool IsRegister, bool RememberMe, string? SavedRefreshToken);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A Terminal.Gui dialog for entering server connection and authentication details.
|
/// A Terminal.Gui dialog for entering server connection and authentication details.
|
||||||
@@ -27,11 +29,12 @@ public sealed class ConnectDialog
|
|||||||
savedServers ??= [];
|
savedServers ??= [];
|
||||||
|
|
||||||
var hasSavedServers = savedServers.Count > 0;
|
var hasSavedServers = savedServers.Count > 0;
|
||||||
var dialogHeight = hasSavedServers ? 20 : 16;
|
var dialogHeight = hasSavedServers ? 22 : 18;
|
||||||
|
|
||||||
var dialog = new Dialog { Title = "Connect to Server", Width = 60, Height = dialogHeight };
|
var dialog = new Dialog { Title = "Connect to Server", Width = 60, Height = dialogHeight };
|
||||||
|
|
||||||
int yOffset = 0;
|
int yOffset = 0;
|
||||||
|
SavedServer? selectedSavedServer = null;
|
||||||
|
|
||||||
// -- Saved Servers section (if any) -----------------------------------
|
// -- Saved Servers section (if any) -----------------------------------
|
||||||
ListView? savedServerList = null;
|
ListView? savedServerList = null;
|
||||||
@@ -46,7 +49,11 @@ public sealed class ConnectDialog
|
|||||||
dialog.Add(savedLabel);
|
dialog.Add(savedLabel);
|
||||||
|
|
||||||
var serverDisplayNames = savedServers
|
var serverDisplayNames = savedServers
|
||||||
.Select(s => $"{s.Name} ({s.Username ?? "?"})")
|
.Select(s =>
|
||||||
|
{
|
||||||
|
var session = !string.IsNullOrEmpty(s.RefreshToken) ? " [session]" : "";
|
||||||
|
return $"{s.Name} ({s.Username ?? "?"}){session}";
|
||||||
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
savedServerList = new ListView
|
savedServerList = new ListView
|
||||||
@@ -115,17 +122,34 @@ public sealed class ConnectDialog
|
|||||||
Secret = true
|
Secret = true
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var tokenHintLabel = new Label
|
||||||
|
{
|
||||||
|
Text = "Session saved \u2014 password optional",
|
||||||
|
X = 15,
|
||||||
|
Y = yOffset + 6,
|
||||||
|
Width = Dim.Fill(2),
|
||||||
|
Visible = false
|
||||||
|
};
|
||||||
|
|
||||||
|
var rememberMeCheckbox = new CheckBox
|
||||||
|
{
|
||||||
|
Text = "Remember me",
|
||||||
|
X = 15,
|
||||||
|
Y = yOffset + 7,
|
||||||
|
Value = CheckState.UnChecked
|
||||||
|
};
|
||||||
|
|
||||||
var displayLabel = new Label
|
var displayLabel = new Label
|
||||||
{
|
{
|
||||||
Text = "Display Name:",
|
Text = "Display Name:",
|
||||||
X = 1,
|
X = 1,
|
||||||
Y = yOffset + 7
|
Y = yOffset + 9
|
||||||
};
|
};
|
||||||
var displayField = new TextField
|
var displayField = new TextField
|
||||||
{
|
{
|
||||||
Text = "",
|
Text = "",
|
||||||
X = 15,
|
X = 15,
|
||||||
Y = yOffset + 7,
|
Y = yOffset + 9,
|
||||||
Width = Dim.Fill(2)
|
Width = Dim.Fill(2)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -134,21 +158,21 @@ public sealed class ConnectDialog
|
|||||||
Text = "Login",
|
Text = "Login",
|
||||||
IsDefault = true,
|
IsDefault = true,
|
||||||
X = Pos.Center() - 20,
|
X = Pos.Center() - 20,
|
||||||
Y = yOffset + 9
|
Y = yOffset + 11
|
||||||
};
|
};
|
||||||
|
|
||||||
var registerButton = new Button
|
var registerButton = new Button
|
||||||
{
|
{
|
||||||
Text = "Register",
|
Text = "Register",
|
||||||
X = Pos.Center() - 5,
|
X = Pos.Center() - 5,
|
||||||
Y = yOffset + 9
|
Y = yOffset + 11
|
||||||
};
|
};
|
||||||
|
|
||||||
var cancelButton = new Button
|
var cancelButton = new Button
|
||||||
{
|
{
|
||||||
Text = "Cancel",
|
Text = "Cancel",
|
||||||
X = Pos.Center() + 10,
|
X = Pos.Center() + 10,
|
||||||
Y = yOffset + 9
|
Y = yOffset + 11
|
||||||
};
|
};
|
||||||
|
|
||||||
// Wire saved server selection to auto-fill fields
|
// Wire saved server selection to auto-fill fields
|
||||||
@@ -159,15 +183,32 @@ public sealed class ConnectDialog
|
|||||||
var index = e.NewValue;
|
var index = e.NewValue;
|
||||||
if (index.HasValue && index.Value >= 0 && index.Value < savedServers.Count)
|
if (index.HasValue && index.Value >= 0 && index.Value < savedServers.Count)
|
||||||
{
|
{
|
||||||
var server = savedServers[index.Value];
|
selectedSavedServer = savedServers[index.Value];
|
||||||
urlField.Text = server.Url;
|
urlField.Text = selectedSavedServer.Url;
|
||||||
userField.Text = server.Username ?? "";
|
userField.Text = selectedSavedServer.Username ?? "";
|
||||||
|
rememberMeCheckbox.Value = selectedSavedServer.RememberMe
|
||||||
|
? CheckState.Checked : CheckState.UnChecked;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(selectedSavedServer.RefreshToken))
|
||||||
|
{
|
||||||
|
passField.Text = "";
|
||||||
|
tokenHintLabel.Visible = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
tokenHintLabel.Visible = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Pre-fill with the first saved server
|
// Pre-fill with the first saved server
|
||||||
|
selectedSavedServer = savedServers[0];
|
||||||
urlField.Text = savedServers[0].Url;
|
urlField.Text = savedServers[0].Url;
|
||||||
userField.Text = savedServers[0].Username ?? "";
|
userField.Text = savedServers[0].Username ?? "";
|
||||||
|
rememberMeCheckbox.Value = savedServers[0].RememberMe
|
||||||
|
? CheckState.Checked : CheckState.UnChecked;
|
||||||
|
if (!string.IsNullOrEmpty(savedServers[0].RefreshToken))
|
||||||
|
tokenHintLabel.Visible = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
loginButton.Accepting += (s, e) =>
|
loginButton.Accepting += (s, e) =>
|
||||||
@@ -175,15 +216,34 @@ public sealed class ConnectDialog
|
|||||||
var url = urlField.Text?.Trim() ?? string.Empty;
|
var url = urlField.Text?.Trim() ?? string.Empty;
|
||||||
var user = userField.Text?.Trim() ?? string.Empty;
|
var user = userField.Text?.Trim() ?? string.Empty;
|
||||||
var pass = passField.Text ?? string.Empty;
|
var pass = passField.Text ?? string.Empty;
|
||||||
|
var rememberMe = rememberMeCheckbox.Value == CheckState.Checked;
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user) || string.IsNullOrEmpty(pass))
|
if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user))
|
||||||
{
|
{
|
||||||
MessageBox.ErrorQuery(app, "Validation", "Server URL, username, and password are required.", "OK");
|
MessageBox.ErrorQuery(app, "Validation", "Server URL and username are required.", "OK");
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
result = new ConnectDialogResult(url, user, pass, IsRegister: false);
|
// Determine if we can use a saved token
|
||||||
|
string? savedRefreshToken = null;
|
||||||
|
if (string.IsNullOrEmpty(pass)
|
||||||
|
&& selectedSavedServer is not null
|
||||||
|
&& !string.IsNullOrEmpty(selectedSavedServer.RefreshToken)
|
||||||
|
&& string.Equals(selectedSavedServer.Url, url, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& string.Equals(selectedSavedServer.Username, user, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
savedRefreshToken = selectedSavedServer.RefreshToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(pass) && savedRefreshToken is null)
|
||||||
|
{
|
||||||
|
MessageBox.ErrorQuery(app, "Validation", "Password is required.", "OK");
|
||||||
|
e.Handled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
result = new ConnectDialogResult(url, user, pass, IsRegister: false, rememberMe, savedRefreshToken);
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
app.RequestStop();
|
app.RequestStop();
|
||||||
};
|
};
|
||||||
@@ -193,6 +253,7 @@ public sealed class ConnectDialog
|
|||||||
var url = urlField.Text?.Trim() ?? string.Empty;
|
var url = urlField.Text?.Trim() ?? string.Empty;
|
||||||
var user = userField.Text?.Trim() ?? string.Empty;
|
var user = userField.Text?.Trim() ?? string.Empty;
|
||||||
var pass = passField.Text ?? string.Empty;
|
var pass = passField.Text ?? string.Empty;
|
||||||
|
var rememberMe = rememberMeCheckbox.Value == CheckState.Checked;
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user) || string.IsNullOrEmpty(pass))
|
if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user) || string.IsNullOrEmpty(pass))
|
||||||
{
|
{
|
||||||
@@ -201,7 +262,7 @@ public sealed class ConnectDialog
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
result = new ConnectDialogResult(url, user, pass, IsRegister: true);
|
result = new ConnectDialogResult(url, user, pass, IsRegister: true, rememberMe, SavedRefreshToken: null);
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
app.RequestStop();
|
app.RequestStop();
|
||||||
};
|
};
|
||||||
@@ -214,7 +275,8 @@ public sealed class ConnectDialog
|
|||||||
};
|
};
|
||||||
|
|
||||||
dialog.Add(urlLabel, urlField, userLabel, userField, passLabel, passField,
|
dialog.Add(urlLabel, urlField, userLabel, userField, passLabel, passField,
|
||||||
displayLabel, displayField, loginButton, registerButton, cancelButton);
|
tokenHintLabel, rememberMeCheckbox, displayLabel, displayField,
|
||||||
|
loginButton, registerButton, cancelButton);
|
||||||
|
|
||||||
if (hasSavedServers && savedServerList is not null)
|
if (hasSavedServers && savedServerList is not null)
|
||||||
savedServerList.SetFocus();
|
savedServerList.SetFocus();
|
||||||
+17
-8
@@ -2,9 +2,9 @@ using Terminal.Gui.App;
|
|||||||
using Terminal.Gui.Views;
|
using Terminal.Gui.Views;
|
||||||
using Terminal.Gui.ViewBase;
|
using Terminal.Gui.ViewBase;
|
||||||
|
|
||||||
namespace EchoHub.Client.UI;
|
namespace EchoHub.Client.UI.Dialogs;
|
||||||
|
|
||||||
public record CreateChannelResult(string Name, string? Topic);
|
public record CreateChannelResult(string Name, string? Topic, bool IsPublic);
|
||||||
|
|
||||||
public sealed class CreateChannelDialog
|
public sealed class CreateChannelDialog
|
||||||
{
|
{
|
||||||
@@ -12,7 +12,7 @@ public sealed class CreateChannelDialog
|
|||||||
{
|
{
|
||||||
CreateChannelResult? result = null;
|
CreateChannelResult? result = null;
|
||||||
|
|
||||||
var dialog = new Dialog { Title = "Create Channel", Width = 50, Height = 12 };
|
var dialog = new Dialog { Title = "Create Channel", Width = 50, Height = 14, 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) };
|
||||||
@@ -20,11 +20,19 @@ public sealed class CreateChannelDialog
|
|||||||
var topicLabel = new Label { Text = "Topic:", X = 1, Y = 3 };
|
var topicLabel = new Label { Text = "Topic:", X = 1, Y = 3 };
|
||||||
var topicField = new TextField { X = 10, Y = 3, Width = Dim.Fill(2) };
|
var topicField = new TextField { X = 10, Y = 3, Width = Dim.Fill(2) };
|
||||||
|
|
||||||
|
var publicCheckbox = new CheckBox
|
||||||
|
{
|
||||||
|
Text = "Public (visible to all users)",
|
||||||
|
X = 1,
|
||||||
|
Y = 5,
|
||||||
|
Value = CheckState.Checked
|
||||||
|
};
|
||||||
|
|
||||||
var hintLabel = new Label
|
var hintLabel = new Label
|
||||||
{
|
{
|
||||||
Text = "Lowercase letters, digits, hyphens, underscores (2-100 chars)",
|
Text = "Lowercase letters, digits, hyphens, underscores (2-100 chars)",
|
||||||
X = 1,
|
X = 1,
|
||||||
Y = 5,
|
Y = 7,
|
||||||
};
|
};
|
||||||
|
|
||||||
var createButton = new Button
|
var createButton = new Button
|
||||||
@@ -32,14 +40,14 @@ public sealed class CreateChannelDialog
|
|||||||
Text = "Create",
|
Text = "Create",
|
||||||
IsDefault = true,
|
IsDefault = true,
|
||||||
X = Pos.Center() - 10,
|
X = Pos.Center() - 10,
|
||||||
Y = 7
|
Y = 9
|
||||||
};
|
};
|
||||||
|
|
||||||
var cancelButton = new Button
|
var cancelButton = new Button
|
||||||
{
|
{
|
||||||
Text = "Cancel",
|
Text = "Cancel",
|
||||||
X = Pos.Center() + 5,
|
X = Pos.Center() + 5,
|
||||||
Y = 7
|
Y = 9
|
||||||
};
|
};
|
||||||
|
|
||||||
createButton.Accepting += (s, e) =>
|
createButton.Accepting += (s, e) =>
|
||||||
@@ -55,7 +63,8 @@ public sealed class CreateChannelDialog
|
|||||||
if (string.IsNullOrWhiteSpace(topic))
|
if (string.IsNullOrWhiteSpace(topic))
|
||||||
topic = null;
|
topic = null;
|
||||||
|
|
||||||
result = new CreateChannelResult(name, topic);
|
var isPublic = publicCheckbox.Value == CheckState.Checked;
|
||||||
|
result = new CreateChannelResult(name, topic, isPublic);
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
app.RequestStop();
|
app.RequestStop();
|
||||||
};
|
};
|
||||||
@@ -67,7 +76,7 @@ public sealed class CreateChannelDialog
|
|||||||
app.RequestStop();
|
app.RequestStop();
|
||||||
};
|
};
|
||||||
|
|
||||||
dialog.Add(nameLabel, nameField, topicLabel, topicField, hintLabel, createButton, cancelButton);
|
dialog.Add(nameLabel, nameField, topicLabel, topicField, publicCheckbox, hintLabel, createButton, cancelButton);
|
||||||
|
|
||||||
nameField.SetFocus();
|
nameField.SetFocus();
|
||||||
app.Run(dialog);
|
app.Run(dialog);
|
||||||
+96
-30
@@ -1,15 +1,16 @@
|
|||||||
|
using EchoHub.Client.UI.Helpers;
|
||||||
using Terminal.Gui.App;
|
using Terminal.Gui.App;
|
||||||
using Terminal.Gui.Views;
|
|
||||||
using Terminal.Gui.ViewBase;
|
|
||||||
using Terminal.Gui.Drawing;
|
using Terminal.Gui.Drawing;
|
||||||
|
using Terminal.Gui.ViewBase;
|
||||||
|
using Terminal.Gui.Views;
|
||||||
using Attribute = Terminal.Gui.Drawing.Attribute;
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
|
|
||||||
namespace EchoHub.Client.UI;
|
namespace EchoHub.Client.UI.Dialogs;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Result returned from the profile edit dialog.
|
/// Result returned from the profile edit dialog.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public record ProfileEditResult(string? DisplayName, string? Bio, string? NicknameColor);
|
public record ProfileEditResult(string? DisplayName, string? Bio, string? NicknameColor, string? AvatarPath, bool? NotificationSoundEnabled, byte? NotificationVolume);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A Terminal.Gui dialog for editing the user's profile (display name, bio, nickname color).
|
/// A Terminal.Gui dialog for editing the user's profile (display name, bio, nickname color).
|
||||||
@@ -19,11 +20,11 @@ public sealed class ProfileEditDialog
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Shows the profile edit dialog and returns the result, or null if cancelled.
|
/// Shows the profile edit dialog and returns the result, or null if cancelled.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static ProfileEditResult? Show(IApplication app, string? currentDisplayName, string? currentBio, string? currentColor)
|
public static ProfileEditResult? Show(IApplication app, string? currentDisplayName, string? currentBio, string? currentColor, bool notificationSoundEnabled = false, byte notificationVolume = 30)
|
||||||
{
|
{
|
||||||
ProfileEditResult? result = null;
|
ProfileEditResult? result = null;
|
||||||
|
|
||||||
var dialog = new Dialog { Title = "Edit Profile", Width = 60, Height = 18 };
|
var dialog = new Dialog { Title = "Edit Profile", Width = 60, Height = 26 };
|
||||||
|
|
||||||
// Display Name
|
// Display Name
|
||||||
var nameLabel = new Label
|
var nameLabel = new Label
|
||||||
@@ -102,20 +103,99 @@ public sealed class ProfileEditDialog
|
|||||||
UpdateColorPreview(colorPreview, colorField.Text);
|
UpdateColorPreview(colorPreview, colorField.Text);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Avatar
|
||||||
|
var avatarLabel = new Label
|
||||||
|
{
|
||||||
|
Text = "Avatar:",
|
||||||
|
X = 1,
|
||||||
|
Y = 10
|
||||||
|
};
|
||||||
|
var avatarField = new TextField
|
||||||
|
{
|
||||||
|
Text = "",
|
||||||
|
X = 17,
|
||||||
|
Y = 10,
|
||||||
|
Width = Dim.Fill(12)
|
||||||
|
};
|
||||||
|
var browseButton = new Button
|
||||||
|
{
|
||||||
|
Text = "Browse",
|
||||||
|
X = Pos.AnchorEnd(10),
|
||||||
|
Y = 10
|
||||||
|
};
|
||||||
|
var avatarHintLabel = new Label
|
||||||
|
{
|
||||||
|
Text = "(file path or URL)",
|
||||||
|
X = 17,
|
||||||
|
Y = 11
|
||||||
|
};
|
||||||
|
avatarHintLabel.SetScheme(new Scheme
|
||||||
|
{
|
||||||
|
Normal = new Attribute(Color.DarkGray, Color.Blue)
|
||||||
|
});
|
||||||
|
|
||||||
|
browseButton.Accepting += (s, e) =>
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
var openDialog = new OpenDialog
|
||||||
|
{
|
||||||
|
Title = "Select Avatar Image",
|
||||||
|
OpenMode = OpenMode.File,
|
||||||
|
};
|
||||||
|
app.Run(openDialog);
|
||||||
|
if (openDialog.FilePaths.Count > 0)
|
||||||
|
{
|
||||||
|
avatarField.Text = openDialog.FilePaths[0];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Notification Sound
|
||||||
|
var notifCheckbox = new CheckBox
|
||||||
|
{
|
||||||
|
Text = "Notification sound on @mention",
|
||||||
|
X = 1,
|
||||||
|
Y = 13,
|
||||||
|
Value = notificationSoundEnabled ? CheckState.Checked : CheckState.UnChecked
|
||||||
|
};
|
||||||
|
|
||||||
|
var volumeLabel = new Label
|
||||||
|
{
|
||||||
|
Text = "Volume:",
|
||||||
|
X = 1,
|
||||||
|
Y = 15
|
||||||
|
};
|
||||||
|
var volumeField = new TextField
|
||||||
|
{
|
||||||
|
Text = notificationVolume.ToString(),
|
||||||
|
X = 17,
|
||||||
|
Y = 15,
|
||||||
|
Width = 6
|
||||||
|
};
|
||||||
|
var volumeHintLabel = new Label
|
||||||
|
{
|
||||||
|
Text = "(0-100)",
|
||||||
|
X = 24,
|
||||||
|
Y = 15
|
||||||
|
};
|
||||||
|
volumeHintLabel.SetScheme(new Scheme
|
||||||
|
{
|
||||||
|
Normal = new Attribute(Color.DarkGray, Color.Blue)
|
||||||
|
});
|
||||||
|
|
||||||
// Buttons
|
// Buttons
|
||||||
var saveButton = new Button
|
var saveButton = new Button
|
||||||
{
|
{
|
||||||
Text = "Save",
|
Text = "Save",
|
||||||
IsDefault = true,
|
IsDefault = true,
|
||||||
X = Pos.Center() - 10,
|
X = Pos.Center() - 10,
|
||||||
Y = 10
|
Y = 18
|
||||||
};
|
};
|
||||||
|
|
||||||
var cancelButton = new Button
|
var cancelButton = new Button
|
||||||
{
|
{
|
||||||
Text = "Cancel",
|
Text = "Cancel",
|
||||||
X = Pos.Center() + 5,
|
X = Pos.Center() + 5,
|
||||||
Y = 10
|
Y = 18
|
||||||
};
|
};
|
||||||
|
|
||||||
saveButton.Accepting += (s, e) =>
|
saveButton.Accepting += (s, e) =>
|
||||||
@@ -123,8 +203,10 @@ public sealed class ProfileEditDialog
|
|||||||
var displayName = NullIfEmpty(nameField.Text?.Trim());
|
var displayName = NullIfEmpty(nameField.Text?.Trim());
|
||||||
var bio = NullIfEmpty(bioField.Text?.Trim());
|
var bio = NullIfEmpty(bioField.Text?.Trim());
|
||||||
var nicknameColor = NullIfEmpty(colorField.Text?.Trim());
|
var nicknameColor = NullIfEmpty(colorField.Text?.Trim());
|
||||||
|
var avatarPath = NullIfEmpty(avatarField.Text?.Trim());
|
||||||
|
|
||||||
result = new ProfileEditResult(displayName, bio, nicknameColor);
|
byte? volume = byte.TryParse(volumeField.Text, out var v) ? Math.Min(v, (byte)100) : null;
|
||||||
|
result = new ProfileEditResult(displayName, bio, nicknameColor, avatarPath, notifCheckbox.Value == CheckState.Checked, volume);
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
app.RequestStop();
|
app.RequestStop();
|
||||||
};
|
};
|
||||||
@@ -137,7 +219,10 @@ public sealed class ProfileEditDialog
|
|||||||
};
|
};
|
||||||
|
|
||||||
dialog.Add(nameLabel, nameField, bioLabel, bioField, colorLabel, colorField,
|
dialog.Add(nameLabel, nameField, bioLabel, bioField, colorLabel, colorField,
|
||||||
colorHintLabel, previewLabel, colorPreview, saveButton, cancelButton);
|
colorHintLabel, previewLabel, colorPreview,
|
||||||
|
avatarLabel, avatarField, browseButton, avatarHintLabel,
|
||||||
|
notifCheckbox, volumeLabel, volumeField, volumeHintLabel,
|
||||||
|
saveButton, cancelButton);
|
||||||
|
|
||||||
nameField.SetFocus();
|
nameField.SetFocus();
|
||||||
app.Run(dialog);
|
app.Run(dialog);
|
||||||
@@ -159,32 +244,13 @@ public sealed class ProfileEditDialog
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var color = ParseHexToTrueColor(hexColor.Trim());
|
var color = HexColorHelper.ParseHexToColor(hexColor.Trim(), Color.White);
|
||||||
preview.SetScheme(new Scheme
|
preview.SetScheme(new Scheme
|
||||||
{
|
{
|
||||||
Normal = new Attribute(color, Color.Blue)
|
Normal = new Attribute(color, Color.Blue)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Parses a hex color string to a Terminal.Gui TrueColor Color.
|
|
||||||
/// V2 supports TrueColor via new Color(r, g, b).
|
|
||||||
/// </summary>
|
|
||||||
private static Color ParseHexToTrueColor(string hex)
|
|
||||||
{
|
|
||||||
if (hex.StartsWith('#'))
|
|
||||||
hex = hex[1..];
|
|
||||||
|
|
||||||
if (hex.Length != 6 || !int.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null, out var rgb))
|
|
||||||
return Color.White;
|
|
||||||
|
|
||||||
int r = (rgb >> 16) & 0xFF;
|
|
||||||
int g = (rgb >> 8) & 0xFF;
|
|
||||||
int b = rgb & 0xFF;
|
|
||||||
|
|
||||||
return new Color(r, g, b);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? NullIfEmpty(string? value) =>
|
private static string? NullIfEmpty(string? value) =>
|
||||||
string.IsNullOrWhiteSpace(value) ? null : value;
|
string.IsNullOrWhiteSpace(value) ? null : value;
|
||||||
}
|
}
|
||||||
+34
-11
@@ -1,12 +1,14 @@
|
|||||||
using Terminal.Gui.App;
|
using EchoHub.Client.UI.Chat;
|
||||||
using Terminal.Gui.Views;
|
using EchoHub.Client.UI.Helpers;
|
||||||
using Terminal.Gui.ViewBase;
|
|
||||||
using Terminal.Gui.Drawing;
|
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
|
using Terminal.Gui.App;
|
||||||
|
using Terminal.Gui.Drawing;
|
||||||
|
using Terminal.Gui.ViewBase;
|
||||||
|
using Terminal.Gui.Views;
|
||||||
using Attribute = Terminal.Gui.Drawing.Attribute;
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
|
|
||||||
namespace EchoHub.Client.UI;
|
namespace EchoHub.Client.UI.Dialogs;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Action selected by the user in their own profile dialog.
|
/// Action selected by the user in their own profile dialog.
|
||||||
@@ -111,7 +113,7 @@ public sealed class ProfileViewDialog
|
|||||||
// Color
|
// Color
|
||||||
var colorLabel = new Label { Text = "Color:", X = 1, Y = row };
|
var colorLabel = new Label { Text = "Color:", X = 1, Y = row };
|
||||||
var colorValue = new Label { Text = profile.NicknameColor ?? "-", X = 14, Y = row };
|
var colorValue = new Label { Text = profile.NicknameColor ?? "-", X = 14, Y = row };
|
||||||
if (ColorHelper.ParseHexColor(profile.NicknameColor) is { } colorAttr)
|
if (HexColorHelper.ParseHexColor(profile.NicknameColor) is { } colorAttr)
|
||||||
colorValue.SetScheme(new Scheme { Normal = colorAttr });
|
colorValue.SetScheme(new Scheme { Normal = colorAttr });
|
||||||
dialog.Add(colorLabel, colorValue);
|
dialog.Add(colorLabel, colorValue);
|
||||||
row++;
|
row++;
|
||||||
@@ -140,12 +142,20 @@ public sealed class ProfileViewDialog
|
|||||||
dialog.Add(bioView);
|
dialog.Add(bioView);
|
||||||
row += 3;
|
row += 3;
|
||||||
|
|
||||||
// ASCII Avatar
|
// ASCII Avatar — render with color tags
|
||||||
if (!string.IsNullOrWhiteSpace(profile.AvatarAscii))
|
if (!string.IsNullOrWhiteSpace(profile.AvatarAscii))
|
||||||
{
|
{
|
||||||
row++;
|
row++;
|
||||||
var avatarLines = profile.AvatarAscii.Split('\n').Length;
|
var rawLines = profile.AvatarAscii.Split('\n');
|
||||||
var avatarHeight = Math.Min(avatarLines + 2, 6);
|
var avatarSource = new ChatListSource();
|
||||||
|
foreach (var line in rawLines)
|
||||||
|
{
|
||||||
|
avatarSource.Add(ChatLine.HasColorTags(line)
|
||||||
|
? ChatLine.FromColoredText(line)
|
||||||
|
: new ChatLine(line));
|
||||||
|
}
|
||||||
|
|
||||||
|
var avatarHeight = Math.Min(rawLines.Length + 2, 24);
|
||||||
var avatarFrame = new FrameView
|
var avatarFrame = new FrameView
|
||||||
{
|
{
|
||||||
Title = "Avatar",
|
Title = "Avatar",
|
||||||
@@ -154,9 +164,22 @@ public sealed class ProfileViewDialog
|
|||||||
Width = Dim.Fill(2),
|
Width = Dim.Fill(2),
|
||||||
Height = avatarHeight
|
Height = avatarHeight
|
||||||
};
|
};
|
||||||
avatarFrame.Add(new Label { Text = profile.AvatarAscii, X = 0, Y = 0 });
|
var avatarList = new ListView
|
||||||
|
{
|
||||||
|
X = 0,
|
||||||
|
Y = 0,
|
||||||
|
Width = Dim.Fill(),
|
||||||
|
Height = Dim.Fill(),
|
||||||
|
Source = avatarSource
|
||||||
|
};
|
||||||
|
avatarFrame.Add(avatarList);
|
||||||
dialog.Add(avatarFrame);
|
dialog.Add(avatarFrame);
|
||||||
// Grow dialog to fit avatar
|
|
||||||
|
// Grow dialog to fit avatar + widen for art
|
||||||
|
var artWidth = rawLines.Max(l => ChatLine.HasColorTags(l)
|
||||||
|
? ChatLine.FromColoredText(l).TextLength
|
||||||
|
: l.Length);
|
||||||
|
dialog.Width = Math.Max(50, artWidth + 6);
|
||||||
dialog.Height = row + avatarHeight + 4;
|
dialog.Height = row + avatarHeight + 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -3,7 +3,7 @@ using Terminal.Gui.Views;
|
|||||||
using Terminal.Gui.ViewBase;
|
using Terminal.Gui.ViewBase;
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
|
|
||||||
namespace EchoHub.Client.UI;
|
namespace EchoHub.Client.UI.Dialogs;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Result returned from the status dialog.
|
/// Result returned from the status dialog.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using Terminal.Gui.App;
|
||||||
|
using Terminal.Gui.Views;
|
||||||
|
using Terminal.Gui.ViewBase;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI.Dialogs;
|
||||||
|
|
||||||
|
public sealed class UpdateConfirmDialog
|
||||||
|
{
|
||||||
|
public static bool Show(IApplication app, string currentVersion, string newVersion)
|
||||||
|
{
|
||||||
|
var confirmed = false;
|
||||||
|
|
||||||
|
var dialog = new Dialog { Title = "Update Available", Width = 50, Height = 10 };
|
||||||
|
|
||||||
|
var messageLabel = new Label
|
||||||
|
{
|
||||||
|
Text = $"A new version of EchoHub is available.\n\n Current: {currentVersion}\n Latest: {newVersion}",
|
||||||
|
X = 1,
|
||||||
|
Y = 1,
|
||||||
|
Width = Dim.Fill(2),
|
||||||
|
Height = 4
|
||||||
|
};
|
||||||
|
|
||||||
|
var updateButton = new Button
|
||||||
|
{
|
||||||
|
Text = "Update",
|
||||||
|
IsDefault = true,
|
||||||
|
X = Pos.Center() - 10,
|
||||||
|
Y = 6
|
||||||
|
};
|
||||||
|
|
||||||
|
var cancelButton = new Button
|
||||||
|
{
|
||||||
|
Text = "Cancel",
|
||||||
|
X = Pos.Center() + 5,
|
||||||
|
Y = 6
|
||||||
|
};
|
||||||
|
|
||||||
|
updateButton.Accepting += (s, e) =>
|
||||||
|
{
|
||||||
|
confirmed = true;
|
||||||
|
e.Handled = true;
|
||||||
|
app.RequestStop();
|
||||||
|
};
|
||||||
|
|
||||||
|
cancelButton.Accepting += (s, e) =>
|
||||||
|
{
|
||||||
|
confirmed = false;
|
||||||
|
e.Handled = true;
|
||||||
|
app.RequestStop();
|
||||||
|
};
|
||||||
|
|
||||||
|
dialog.Add(messageLabel, updateButton, cancelButton);
|
||||||
|
app.Run(dialog);
|
||||||
|
|
||||||
|
return confirmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using Terminal.Gui.App;
|
||||||
|
using Terminal.Gui.Views;
|
||||||
|
using Terminal.Gui.ViewBase;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI.Dialogs;
|
||||||
|
|
||||||
|
public sealed class UpdateProgressDialog
|
||||||
|
{
|
||||||
|
private readonly Dialog _dialog;
|
||||||
|
private readonly ProgressBar _progressBar;
|
||||||
|
private readonly Label _infoLabel;
|
||||||
|
private readonly IApplication _app;
|
||||||
|
|
||||||
|
public UpdateProgressDialog(IApplication app, string newVersion)
|
||||||
|
{
|
||||||
|
_app = app;
|
||||||
|
|
||||||
|
_dialog = new Dialog { Title = $"Updating to {newVersion}", Width = 50, Height = 10 };
|
||||||
|
|
||||||
|
_infoLabel = new Label
|
||||||
|
{
|
||||||
|
Text = "Preparing update...",
|
||||||
|
X = 1,
|
||||||
|
Y = 1,
|
||||||
|
Width = Dim.Fill(2)
|
||||||
|
};
|
||||||
|
|
||||||
|
_progressBar = new ProgressBar
|
||||||
|
{
|
||||||
|
X = 1,
|
||||||
|
Y = 3,
|
||||||
|
Width = Dim.Fill(2),
|
||||||
|
Fraction = 0f
|
||||||
|
};
|
||||||
|
|
||||||
|
var cancelButton = new Button
|
||||||
|
{
|
||||||
|
Text = "Cancel",
|
||||||
|
X = Pos.Center(),
|
||||||
|
Y = 6
|
||||||
|
};
|
||||||
|
|
||||||
|
_dialog.Add(_infoLabel, _progressBar);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UpdateProgress(float fraction, string statusText)
|
||||||
|
{
|
||||||
|
_progressBar.Fraction = fraction;
|
||||||
|
_infoLabel.Text = statusText;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Show()
|
||||||
|
{
|
||||||
|
_app.Run(_dialog);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Close()
|
||||||
|
{
|
||||||
|
_app.RequestStop();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI.Helpers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts emoji grapheme clusters to text shortcodes for safe TUI rendering.
|
||||||
|
/// Terminal width calculations for emoji are unreliable across different terminals,
|
||||||
|
/// so we replace them with fixed-width ASCII shortcodes for display only.
|
||||||
|
/// </summary>
|
||||||
|
public static class EmojiHelper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Replace emoji graphemes in the text with :shortcode: equivalents.
|
||||||
|
/// Non-emoji text passes through unchanged.
|
||||||
|
/// </summary>
|
||||||
|
public static string ReplaceEmoji(string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text))
|
||||||
|
return text;
|
||||||
|
|
||||||
|
// Quick check: if no characters above BMP or supplementary emoji ranges, skip processing
|
||||||
|
bool hasEmoji = false;
|
||||||
|
foreach (var rune in text.EnumerateRunes())
|
||||||
|
{
|
||||||
|
if (IsEmojiRune(rune))
|
||||||
|
{
|
||||||
|
hasEmoji = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasEmoji)
|
||||||
|
return text;
|
||||||
|
|
||||||
|
var sb = new StringBuilder(text.Length);
|
||||||
|
var enumerator = StringInfo.GetTextElementEnumerator(text);
|
||||||
|
|
||||||
|
while (enumerator.MoveNext())
|
||||||
|
{
|
||||||
|
var grapheme = enumerator.GetTextElement();
|
||||||
|
|
||||||
|
// Check if this grapheme contains emoji runes
|
||||||
|
bool graphemeHasEmoji = false;
|
||||||
|
foreach (var rune in grapheme.EnumerateRunes())
|
||||||
|
{
|
||||||
|
if (IsEmojiRune(rune))
|
||||||
|
{
|
||||||
|
graphemeHasEmoji = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (graphemeHasEmoji)
|
||||||
|
{
|
||||||
|
// Try to find a shortcode for the whole grapheme first
|
||||||
|
if (EmojiShortcodes.TryGetValue(grapheme, out var shortcode))
|
||||||
|
{
|
||||||
|
sb.Append(shortcode);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Try the base emoji (first rune only, stripping modifiers/ZWJ)
|
||||||
|
var baseRune = GetBaseEmoji(grapheme);
|
||||||
|
if (baseRune is not null && EmojiShortcodes.TryGetValue(baseRune, out shortcode))
|
||||||
|
{
|
||||||
|
sb.Append(shortcode);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Unknown emoji — use generic placeholder
|
||||||
|
sb.Append("[emoji]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sb.Append(grapheme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsEmojiRune(Rune rune)
|
||||||
|
{
|
||||||
|
var value = rune.Value;
|
||||||
|
|
||||||
|
// Common emoji ranges
|
||||||
|
if (value >= 0x1F600 && value <= 0x1F64F) return true; // Emoticons
|
||||||
|
if (value >= 0x1F300 && value <= 0x1F5FF) return true; // Misc Symbols & Pictographs
|
||||||
|
if (value >= 0x1F680 && value <= 0x1F6FF) return true; // Transport & Map
|
||||||
|
if (value >= 0x1F900 && value <= 0x1F9FF) return true; // Supplemental Symbols
|
||||||
|
if (value >= 0x1FA00 && value <= 0x1FA6F) return true; // Chess Symbols
|
||||||
|
if (value >= 0x1FA70 && value <= 0x1FAFF) return true; // Symbols Extended-A
|
||||||
|
if (value >= 0x2600 && value <= 0x26FF) return true; // Misc Symbols
|
||||||
|
if (value >= 0x2700 && value <= 0x27BF) return true; // Dingbats
|
||||||
|
if (value >= 0xFE00 && value <= 0xFE0F) return true; // Variation Selectors
|
||||||
|
if (value >= 0x200D && value <= 0x200D) return true; // ZWJ
|
||||||
|
if (value >= 0x1F1E0 && value <= 0x1F1FF) return true; // Regional Indicators (flags)
|
||||||
|
if (value >= 0x231A && value <= 0x23F3) return true; // Misc Technical (watch, hourglass)
|
||||||
|
if (value >= 0x2934 && value <= 0x2935) return true; // Arrows
|
||||||
|
if (value >= 0x25AA && value <= 0x25FE) return true; // Geometric Shapes
|
||||||
|
if (value >= 0x2B05 && value <= 0x2B55) return true; // Misc Symbols & Arrows
|
||||||
|
if (value >= 0x3030 && value <= 0x303D) return true; // CJK Symbols
|
||||||
|
if (value == 0x00A9 || value == 0x00AE) return true; // © ®
|
||||||
|
if (value == 0x2122) return true; // ™
|
||||||
|
if (value >= 0x1F000 && value <= 0x1F02F) return true; // Mahjong & Dominos
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extract the base emoji string (first non-modifier, non-ZWJ rune) for lookup.
|
||||||
|
/// </summary>
|
||||||
|
private static string? GetBaseEmoji(string grapheme)
|
||||||
|
{
|
||||||
|
foreach (var rune in grapheme.EnumerateRunes())
|
||||||
|
{
|
||||||
|
// Skip ZWJ, variation selectors, skin tone modifiers
|
||||||
|
if (rune.Value == 0x200D) continue;
|
||||||
|
if (rune.Value >= 0xFE00 && rune.Value <= 0xFE0F) continue;
|
||||||
|
if (rune.Value >= 0x1F3FB && rune.Value <= 0x1F3FF) continue;
|
||||||
|
|
||||||
|
if (IsEmojiRune(rune))
|
||||||
|
return rune.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common emoji → shortcode mapping (display-only, covers most frequently used emoji)
|
||||||
|
private static readonly Dictionary<string, string> EmojiShortcodes = new()
|
||||||
|
{
|
||||||
|
// Smileys & Emotion
|
||||||
|
["\U0001F600"] = ":grinning:",
|
||||||
|
["\U0001F601"] = ":grin:",
|
||||||
|
["\U0001F602"] = ":joy:",
|
||||||
|
["\U0001F603"] = ":smiley:",
|
||||||
|
["\U0001F604"] = ":smile:",
|
||||||
|
["\U0001F605"] = ":sweat_smile:",
|
||||||
|
["\U0001F606"] = ":laughing:",
|
||||||
|
["\U0001F607"] = ":innocent:",
|
||||||
|
["\U0001F608"] = ":smiling_imp:",
|
||||||
|
["\U0001F609"] = ":wink:",
|
||||||
|
["\U0001F60A"] = ":blush:",
|
||||||
|
["\U0001F60B"] = ":yum:",
|
||||||
|
["\U0001F60C"] = ":relieved:",
|
||||||
|
["\U0001F60D"] = ":heart_eyes:",
|
||||||
|
["\U0001F60E"] = ":sunglasses:",
|
||||||
|
["\U0001F60F"] = ":smirk:",
|
||||||
|
["\U0001F610"] = ":neutral_face:",
|
||||||
|
["\U0001F611"] = ":expressionless:",
|
||||||
|
["\U0001F612"] = ":unamused:",
|
||||||
|
["\U0001F613"] = ":sweat:",
|
||||||
|
["\U0001F614"] = ":pensive:",
|
||||||
|
["\U0001F615"] = ":confused:",
|
||||||
|
["\U0001F616"] = ":confounded:",
|
||||||
|
["\U0001F617"] = ":kissing:",
|
||||||
|
["\U0001F618"] = ":kissing_heart:",
|
||||||
|
["\U0001F619"] = ":kissing_smiling_eyes:",
|
||||||
|
["\U0001F61A"] = ":kissing_closed_eyes:",
|
||||||
|
["\U0001F61B"] = ":stuck_out_tongue:",
|
||||||
|
["\U0001F61C"] = ":stuck_out_tongue_winking_eye:",
|
||||||
|
["\U0001F61D"] = ":stuck_out_tongue_closed_eyes:",
|
||||||
|
["\U0001F61E"] = ":disappointed:",
|
||||||
|
["\U0001F61F"] = ":worried:",
|
||||||
|
["\U0001F620"] = ":angry:",
|
||||||
|
["\U0001F621"] = ":rage:",
|
||||||
|
["\U0001F622"] = ":cry:",
|
||||||
|
["\U0001F623"] = ":persevere:",
|
||||||
|
["\U0001F624"] = ":triumph:",
|
||||||
|
["\U0001F625"] = ":disappointed_relieved:",
|
||||||
|
["\U0001F626"] = ":frowning:",
|
||||||
|
["\U0001F627"] = ":anguished:",
|
||||||
|
["\U0001F628"] = ":fearful:",
|
||||||
|
["\U0001F629"] = ":weary:",
|
||||||
|
["\U0001F62A"] = ":sleepy:",
|
||||||
|
["\U0001F62B"] = ":tired_face:",
|
||||||
|
["\U0001F62C"] = ":grimacing:",
|
||||||
|
["\U0001F62D"] = ":sob:",
|
||||||
|
["\U0001F62E"] = ":open_mouth:",
|
||||||
|
["\U0001F62F"] = ":hushed:",
|
||||||
|
["\U0001F630"] = ":cold_sweat:",
|
||||||
|
["\U0001F631"] = ":scream:",
|
||||||
|
["\U0001F632"] = ":astonished:",
|
||||||
|
["\U0001F633"] = ":flushed:",
|
||||||
|
["\U0001F634"] = ":sleeping:",
|
||||||
|
["\U0001F635"] = ":dizzy_face:",
|
||||||
|
["\U0001F636"] = ":no_mouth:",
|
||||||
|
["\U0001F637"] = ":mask:",
|
||||||
|
["\U0001F641"] = ":slightly_frowning_face:",
|
||||||
|
["\U0001F642"] = ":slightly_smiling_face:",
|
||||||
|
["\U0001F643"] = ":upside_down_face:",
|
||||||
|
["\U0001F644"] = ":roll_eyes:",
|
||||||
|
["\U0001F910"] = ":zipper_mouth:",
|
||||||
|
["\U0001F911"] = ":money_mouth:",
|
||||||
|
["\U0001F912"] = ":thermometer_face:",
|
||||||
|
["\U0001F913"] = ":nerd:",
|
||||||
|
["\U0001F914"] = ":thinking:",
|
||||||
|
["\U0001F915"] = ":head_bandage:",
|
||||||
|
["\U0001F920"] = ":cowboy:",
|
||||||
|
["\U0001F921"] = ":clown:",
|
||||||
|
["\U0001F922"] = ":nauseated:",
|
||||||
|
["\U0001F923"] = ":rofl:",
|
||||||
|
["\U0001F924"] = ":drooling:",
|
||||||
|
["\U0001F925"] = ":lying:",
|
||||||
|
["\U0001F929"] = ":star_struck:",
|
||||||
|
["\U0001F92A"] = ":zany:",
|
||||||
|
["\U0001F92B"] = ":shushing:",
|
||||||
|
["\U0001F92C"] = ":cursing:",
|
||||||
|
["\U0001F92D"] = ":hand_over_mouth:",
|
||||||
|
["\U0001F92E"] = ":vomiting:",
|
||||||
|
["\U0001F92F"] = ":exploding_head:",
|
||||||
|
["\U0001F970"] = ":smiling_face_with_hearts:",
|
||||||
|
["\U0001F971"] = ":yawning:",
|
||||||
|
["\U0001F972"] = ":smiling_with_tear:",
|
||||||
|
["\U0001F973"] = ":partying:",
|
||||||
|
["\U0001F974"] = ":woozy:",
|
||||||
|
["\U0001F975"] = ":hot_face:",
|
||||||
|
["\U0001F976"] = ":cold_face:",
|
||||||
|
["\U0001F979"] = ":holding_back_tears:",
|
||||||
|
["\U0001F97A"] = ":pleading:",
|
||||||
|
["\U0001FAE0"] = ":melting:",
|
||||||
|
["\U0001FAE1"] = ":saluting:",
|
||||||
|
["\U0001FAE2"] = ":face_with_open_eyes_hand_over_mouth:",
|
||||||
|
["\U0001FAE3"] = ":face_with_peeking_eye:",
|
||||||
|
["\U0001FAE4"] = ":face_with_diagonal_mouth:",
|
||||||
|
|
||||||
|
// Gestures
|
||||||
|
["\U0001F44D"] = ":+1:",
|
||||||
|
["\U0001F44E"] = ":-1:",
|
||||||
|
["\U0001F44B"] = ":wave:",
|
||||||
|
["\U0001F44C"] = ":ok_hand:",
|
||||||
|
["\U0001F44F"] = ":clap:",
|
||||||
|
["\U0001F44A"] = ":fist:",
|
||||||
|
["\U0001F91D"] = ":handshake:",
|
||||||
|
["\U0001F91E"] = ":crossed_fingers:",
|
||||||
|
["\U0001F91F"] = ":love_you:",
|
||||||
|
["\U0001F918"] = ":metal:",
|
||||||
|
["\U0001F919"] = ":call_me:",
|
||||||
|
["\U0001F590"] = ":raised_hand:",
|
||||||
|
["\U0001F4AA"] = ":muscle:",
|
||||||
|
["\U0001F926"] = ":facepalm:",
|
||||||
|
["\U0001F937"] = ":shrug:",
|
||||||
|
["\U0001F64F"] = ":pray:",
|
||||||
|
["\U0001F64C"] = ":raised_hands:",
|
||||||
|
["\U0001F64B"] = ":raising_hand:",
|
||||||
|
|
||||||
|
// Hearts & Symbols
|
||||||
|
["\u2764"] = "<3",
|
||||||
|
["\U0001F494"] = "</3",
|
||||||
|
["\U0001F495"] = ":two_hearts:",
|
||||||
|
["\U0001F496"] = ":sparkling_heart:",
|
||||||
|
["\U0001F497"] = ":heartpulse:",
|
||||||
|
["\U0001F498"] = ":cupid:",
|
||||||
|
["\U0001F499"] = ":blue_heart:",
|
||||||
|
["\U0001F49A"] = ":green_heart:",
|
||||||
|
["\U0001F49B"] = ":yellow_heart:",
|
||||||
|
["\U0001F49C"] = ":purple_heart:",
|
||||||
|
["\U0001F49D"] = ":gift_heart:",
|
||||||
|
["\U0001F49E"] = ":revolving_hearts:",
|
||||||
|
["\U0001F49F"] = ":heart_decoration:",
|
||||||
|
["\U0001F90D"] = ":white_heart:",
|
||||||
|
["\U0001F90E"] = ":brown_heart:",
|
||||||
|
["\U0001F5A4"] = ":black_heart:",
|
||||||
|
["\U0001F9E1"] = ":orange_heart:",
|
||||||
|
|
||||||
|
// Objects & Nature
|
||||||
|
["\U0001F525"] = ":fire:",
|
||||||
|
["\U0001F4A9"] = ":poop:",
|
||||||
|
["\U0001F480"] = ":skull:",
|
||||||
|
["\U0001F47B"] = ":ghost:",
|
||||||
|
["\U0001F47D"] = ":alien:",
|
||||||
|
["\U0001F916"] = ":robot:",
|
||||||
|
["\U0001F4AF"] = ":100:",
|
||||||
|
["\U0001F4A5"] = ":boom:",
|
||||||
|
["\U0001F4A4"] = ":zzz:",
|
||||||
|
["\U0001F4A2"] = ":anger:",
|
||||||
|
["\U0001F4AC"] = ":speech_balloon:",
|
||||||
|
["\U0001F440"] = ":eyes:",
|
||||||
|
["\U0001F3B5"] = ":musical_note:",
|
||||||
|
["\U0001F3B6"] = ":notes:",
|
||||||
|
["\U0001F389"] = ":tada:",
|
||||||
|
["\U0001F38A"] = ":confetti:",
|
||||||
|
["\U0001F381"] = ":gift:",
|
||||||
|
["\U0001F3C6"] = ":trophy:",
|
||||||
|
["\U0001F4B0"] = ":money_bag:",
|
||||||
|
["\U0001F4BB"] = ":computer:",
|
||||||
|
["\U0001F4F1"] = ":phone:",
|
||||||
|
["\U0001F4E7"] = ":email:",
|
||||||
|
["\U0001F511"] = ":key:",
|
||||||
|
["\U0001F512"] = ":lock:",
|
||||||
|
["\U0001F513"] = ":unlock:",
|
||||||
|
["\U0001F6A8"] = ":rotating_light:",
|
||||||
|
["\U0001F6AB"] = ":no_entry:",
|
||||||
|
|
||||||
|
// Animals
|
||||||
|
["\U0001F436"] = ":dog:",
|
||||||
|
["\U0001F431"] = ":cat:",
|
||||||
|
["\U0001F42D"] = ":mouse:",
|
||||||
|
["\U0001F430"] = ":rabbit:",
|
||||||
|
["\U0001F43B"] = ":bear:",
|
||||||
|
["\U0001F427"] = ":penguin:",
|
||||||
|
["\U0001F41D"] = ":bee:",
|
||||||
|
["\U0001F40D"] = ":snake:",
|
||||||
|
["\U0001F422"] = ":turtle:",
|
||||||
|
|
||||||
|
// Food & Drink
|
||||||
|
["\U0001F355"] = ":pizza:",
|
||||||
|
["\U0001F354"] = ":hamburger:",
|
||||||
|
["\U0001F37A"] = ":beer:",
|
||||||
|
["\U0001F377"] = ":wine:",
|
||||||
|
["\U0001F370"] = ":cake:",
|
||||||
|
["\u2615"] = ":coffee:",
|
||||||
|
["\U0001F382"] = ":birthday:",
|
||||||
|
|
||||||
|
// Misc symbols (BMP)
|
||||||
|
["\u2705"] = ":white_check_mark:",
|
||||||
|
["\u274C"] = ":x:",
|
||||||
|
["\u274E"] = ":negative_squared_cross_mark:",
|
||||||
|
["\u2714"] = ":heavy_check_mark:",
|
||||||
|
["\u2716"] = ":heavy_multiplication_x:",
|
||||||
|
["\u26A0"] = ":warning:",
|
||||||
|
["\u2B50"] = ":star:",
|
||||||
|
["\u2728"] = ":sparkles:",
|
||||||
|
["\u267B"] = ":recycle:",
|
||||||
|
["\u2611"] = ":ballot_box_with_check:",
|
||||||
|
["\u23F0"] = ":alarm_clock:",
|
||||||
|
["\u231A"] = ":watch:",
|
||||||
|
["\u231B"] = ":hourglass:",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using Terminal.Gui.Drawing;
|
||||||
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI.Helpers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper to parse hex colors to Terminal.Gui Attributes.
|
||||||
|
/// </summary>
|
||||||
|
public static class HexColorHelper
|
||||||
|
{
|
||||||
|
public static Attribute? ParseHexColor(string? hex)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(hex))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
hex = hex.TrimStart('#');
|
||||||
|
if (hex.Length != 6)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var r = Convert.ToInt32(hex[..2], 16);
|
||||||
|
var g = Convert.ToInt32(hex[2..4], 16);
|
||||||
|
var b = Convert.ToInt32(hex[4..6], 16);
|
||||||
|
return new Attribute(new Color(r, g, b), Color.None);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse a hex color string (with or without #) to a Terminal.Gui Color.
|
||||||
|
/// Returns <paramref name="fallback"/> if parsing fails.
|
||||||
|
/// </summary>
|
||||||
|
public static Color ParseHexToColor(string? hex, Color fallback = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(hex))
|
||||||
|
return fallback;
|
||||||
|
|
||||||
|
var trimmed = hex.TrimStart('#');
|
||||||
|
if (trimmed.Length != 6)
|
||||||
|
return fallback;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var r = Convert.ToInt32(trimmed[..2], 16);
|
||||||
|
var g = Convert.ToInt32(trimmed[2..4], 16);
|
||||||
|
var b = Convert.ToInt32(trimmed[4..6], 16);
|
||||||
|
return new Color(r, g, b);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using System.Collections;
|
||||||
|
using System.Collections.Specialized;
|
||||||
|
using EchoHub.Client.UI.Chat;
|
||||||
|
using Terminal.Gui.Drawing;
|
||||||
|
using Terminal.Gui.Text;
|
||||||
|
using Terminal.Gui.Views;
|
||||||
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI.ListSources;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Custom list data source for colored channel list rendering.
|
||||||
|
/// Active channel gets a > indicator, unread channels are bright with a count badge.
|
||||||
|
/// </summary>
|
||||||
|
public class ChannelListSource : IListDataSource
|
||||||
|
{
|
||||||
|
private readonly List<string> _channelNames = [];
|
||||||
|
private readonly Dictionary<string, int> _unreadCounts = [];
|
||||||
|
private string _activeChannel = string.Empty;
|
||||||
|
|
||||||
|
public event NotifyCollectionChangedEventHandler? CollectionChanged;
|
||||||
|
public int Count => _channelNames.Count;
|
||||||
|
public int MaxItemLength { get; private set; }
|
||||||
|
public bool SuspendCollectionChangedEvent { get; set; }
|
||||||
|
|
||||||
|
private static readonly Attribute ActiveAttr = new(Color.White, Color.None);
|
||||||
|
private static readonly Attribute UnreadAttr = new(Color.BrightCyan, Color.None);
|
||||||
|
private static readonly Attribute NormalAttr = new(Color.DarkGray, Color.None);
|
||||||
|
private static readonly Attribute BadgeAttr = new(Color.BrightYellow, Color.None);
|
||||||
|
|
||||||
|
public void Update(List<string> channels, Dictionary<string, int> unread, string activeChannel)
|
||||||
|
{
|
||||||
|
_channelNames.Clear();
|
||||||
|
_channelNames.AddRange(channels);
|
||||||
|
_unreadCounts.Clear();
|
||||||
|
foreach (var kv in unread)
|
||||||
|
_unreadCounts[kv.Key] = kv.Value;
|
||||||
|
_activeChannel = activeChannel;
|
||||||
|
MaxItemLength = channels.Count > 0 ? channels.Max(c => c.Length + 6) : 0;
|
||||||
|
if (!SuspendCollectionChangedEvent)
|
||||||
|
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsMarked(int item) => false;
|
||||||
|
public void SetMark(int item, bool value) { }
|
||||||
|
public IList ToList() => _channelNames.Select(n => $"#{n}").ToList();
|
||||||
|
|
||||||
|
public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0)
|
||||||
|
{
|
||||||
|
listView.Move(Math.Max(col - viewportX, 0), row);
|
||||||
|
|
||||||
|
var name = _channelNames[item];
|
||||||
|
var isActive = name == _activeChannel;
|
||||||
|
_unreadCounts.TryGetValue(name, out var unread);
|
||||||
|
var hasUnread = unread > 0;
|
||||||
|
|
||||||
|
var normalAttr = listView.GetAttributeForRole(VisualRole.Normal);
|
||||||
|
var focusAttr = listView.GetAttributeForRole(VisualRole.Focus);
|
||||||
|
var prefix = isActive ? "> " : " ";
|
||||||
|
var channelText = $"#{name}";
|
||||||
|
var badge = hasUnread ? $" ({unread})" : "";
|
||||||
|
|
||||||
|
// Resolve Transparent backgrounds to the view's actual background
|
||||||
|
Attribute Resolve(Attribute attr) =>
|
||||||
|
attr.Background == Color.None ? attr with { Background = normalAttr.Background } : attr;
|
||||||
|
|
||||||
|
int drawnChars = 0;
|
||||||
|
|
||||||
|
if (selected)
|
||||||
|
{
|
||||||
|
listView.SetAttribute(focusAttr);
|
||||||
|
drawnChars = RenderHelpers.WriteText(listView, prefix + channelText + badge, drawnChars, width);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
listView.SetAttribute(Resolve(isActive ? ActiveAttr : NormalAttr));
|
||||||
|
drawnChars = RenderHelpers.WriteText(listView, prefix, drawnChars, width);
|
||||||
|
|
||||||
|
listView.SetAttribute(Resolve(isActive ? ActiveAttr : hasUnread ? UnreadAttr : NormalAttr));
|
||||||
|
drawnChars = RenderHelpers.WriteText(listView, channelText, drawnChars, width);
|
||||||
|
|
||||||
|
if (hasUnread)
|
||||||
|
{
|
||||||
|
listView.SetAttribute(Resolve(BadgeAttr));
|
||||||
|
drawnChars = RenderHelpers.WriteText(listView, badge, drawnChars, width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var fillAttr = selected ? focusAttr : normalAttr;
|
||||||
|
listView.SetAttribute(fillAttr);
|
||||||
|
for (int i = drawnChars; i < width; i++)
|
||||||
|
listView.AddStr(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
@@ -0,0 +1,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() { }
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user