mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 23:34:10 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f4af9a543 | ||
|
|
b62729dc95 | ||
|
|
aae788028e | ||
|
|
c048b39e42 | ||
|
|
3886e7148c | ||
|
|
b9a80806e1 | ||
|
|
e797ec2542 | ||
|
|
dbf6565d18 | ||
|
|
494dcb46cf |
@@ -33,6 +33,8 @@ jobs:
|
|||||||
|
|
||||||
build-and-test:
|
build-and-test:
|
||||||
name: Build & Test
|
name: Build & Test
|
||||||
|
# Run the cheap, fast checks first; only spend build/test compute if they pass.
|
||||||
|
needs: [format-check, lint-markdown]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
name: Docker
|
name: Docker
|
||||||
|
|
||||||
|
# Runs only after the CI workflow completes, so an image is never pushed for a commit
|
||||||
|
# whose formatting, lint, build, or tests failed.
|
||||||
on:
|
on:
|
||||||
push:
|
workflow_run:
|
||||||
|
workflows: ["CI"]
|
||||||
|
types: [completed]
|
||||||
branches: [master]
|
branches: [master]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
@@ -15,23 +19,29 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
docker:
|
docker:
|
||||||
name: Build & Push Docker Image
|
name: Build & Push Docker Image
|
||||||
|
# Proceed only for a successful CI run on a master push, or a manual dispatch.
|
||||||
|
if: >-
|
||||||
|
github.event_name == 'workflow_dispatch' ||
|
||||||
|
(github.event.workflow_run.conclusion == 'success' &&
|
||||||
|
github.event.workflow_run.event == 'push' &&
|
||||||
|
github.event.workflow_run.head_branch == 'master')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
# The exact commit CI tested (workflow_run), or the current tip (manual dispatch).
|
||||||
|
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||||
|
|
||||||
- name: Check for src/ changes
|
- name: Check for src/ changes
|
||||||
id: changes
|
id: changes
|
||||||
env:
|
|
||||||
BEFORE: ${{ github.event.before }}
|
|
||||||
run: |
|
run: |
|
||||||
if [ -z "$BEFORE" ] || [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
|
if git rev-parse HEAD~1 >/dev/null 2>&1; then
|
||||||
echo "src_changed=true" >> "$GITHUB_OUTPUT"
|
CHANGED=$(git diff --name-only HEAD~1 HEAD -- 'src/' | wc -l)
|
||||||
else
|
else
|
||||||
CHANGED=$(git diff --name-only "$BEFORE" HEAD -- 'src/' | wc -l)
|
CHANGED=1
|
||||||
[ "$CHANGED" -gt 0 ] && echo "src_changed=true" >> "$GITHUB_OUTPUT" || echo "src_changed=false" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
fi
|
||||||
|
[ "$CHANGED" -gt 0 ] && echo "src_changed=true" >> "$GITHUB_OUTPUT" || echo "src_changed=false" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Read version
|
- name: Read version
|
||||||
if: steps.changes.outputs.src_changed == 'true'
|
if: steps.changes.outputs.src_changed == 'true'
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
|
# Runs only after the CI workflow completes, so a release is never published on a commit
|
||||||
|
# whose formatting, lint, build, or tests failed.
|
||||||
on:
|
on:
|
||||||
push:
|
workflow_run:
|
||||||
|
workflows: ["CI"]
|
||||||
|
types: [completed]
|
||||||
branches: [master]
|
branches: [master]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
@@ -11,6 +15,12 @@ permissions:
|
|||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
name: Create Release
|
name: Create Release
|
||||||
|
# Proceed only for a successful CI run on a master push, or a manual dispatch.
|
||||||
|
if: >-
|
||||||
|
github.event_name == 'workflow_dispatch' ||
|
||||||
|
(github.event.workflow_run.conclusion == 'success' &&
|
||||||
|
github.event.workflow_run.event == 'push' &&
|
||||||
|
github.event.workflow_run.head_branch == 'master')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
should_release: ${{ steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false' }}
|
should_release: ${{ steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false' }}
|
||||||
@@ -21,18 +31,18 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
# The exact commit CI tested (workflow_run), or the current tip (manual dispatch).
|
||||||
|
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||||
|
|
||||||
- name: Check for src/ changes
|
- name: Check for src/ changes
|
||||||
id: changes
|
id: changes
|
||||||
env:
|
|
||||||
BEFORE: ${{ github.event.before }}
|
|
||||||
run: |
|
run: |
|
||||||
if [ -z "$BEFORE" ] || [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
|
if git rev-parse HEAD~1 >/dev/null 2>&1; then
|
||||||
echo "src_changed=true" >> "$GITHUB_OUTPUT"
|
CHANGED=$(git diff --name-only HEAD~1 HEAD -- 'src/' | wc -l)
|
||||||
else
|
else
|
||||||
CHANGED=$(git diff --name-only "$BEFORE" HEAD -- 'src/' | wc -l)
|
CHANGED=1
|
||||||
[ "$CHANGED" -gt 0 ] && echo "src_changed=true" >> "$GITHUB_OUTPUT" || echo "src_changed=false" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
fi
|
||||||
|
[ "$CHANGED" -gt 0 ] && echo "src_changed=true" >> "$GITHUB_OUTPUT" || echo "src_changed=false" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Read version
|
- name: Read version
|
||||||
id: version
|
id: version
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# Encrypted Rooms (Password-Protected Channels)
|
||||||
|
|
||||||
|
An **encrypted room** is a channel whose entire content — every message and every file — is
|
||||||
|
end-to-end encrypted with a key derived from a shared passphrase. Only people who know the
|
||||||
|
passphrase can read the room. **Not even the server owner can read the content**, yet the server
|
||||||
|
can still gate who joins, and it can count and measure what's stored (message count, file sizes,
|
||||||
|
timestamps) without ever seeing the plaintext.
|
||||||
|
|
||||||
|
This is a stronger guarantee than the [transport and at-rest encryption](encryption.md) described
|
||||||
|
elsewhere, where the server decrypts every message to process it. Here the server is treated as
|
||||||
|
*untrusted* for content: it holds only ciphertext and wrapped keys.
|
||||||
|
|
||||||
|
> **The passphrase is the only key.** There is no recovery. If everyone who knows a room's
|
||||||
|
> passphrase forgets it, that room's history is permanently unreadable — by design.
|
||||||
|
|
||||||
|
## What the server can and cannot see
|
||||||
|
|
||||||
|
| The server **can** see | The server **cannot** see |
|
||||||
|
| --- | --- |
|
||||||
|
| That the channel is encrypted | Message text |
|
||||||
|
| Message count and timestamps | File contents |
|
||||||
|
| Who sent each message (sender identity) | Image previews (ASCII art) |
|
||||||
|
| Each attachment's **file name** and byte size | The passphrase, the room key, or the key-encryption key |
|
||||||
|
| The estimated total size (via `/meta`) | Anything that would let it decrypt the above |
|
||||||
|
|
||||||
|
File **names are stored in plaintext** so the file list stays usable — treat a file name itself as
|
||||||
|
non-secret. Everything *inside* the file is encrypted.
|
||||||
|
|
||||||
|
## Key hierarchy
|
||||||
|
|
||||||
|
Three keys are derived from one passphrase. The passphrase, the key-encryption key, and the room
|
||||||
|
content key **never leave the client**.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
P[Passphrase] -->|PBKDF2-SHA256, 210k iterations, per-room salt| OKM[64-byte output]
|
||||||
|
OKM --> AK["Auth key (first 32 bytes)"]
|
||||||
|
OKM --> KEK["Key-encryption key / KEK (last 32 bytes)"]
|
||||||
|
AK -->|sent as lowercase hex| SRV1["Server: BCrypt-hash as the join gate"]
|
||||||
|
RCK["Room Content Key (random 256-bit)"] -->|encrypts all content| CONTENT[Messages + files + previews]
|
||||||
|
RCK -->|AES-256-GCM wrap under KEK| WRAP["Wrapped room key"]
|
||||||
|
WRAP -->|stored| SRV2["Server: stores wrapped key + salt only"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Auth key** — the join credential. Derived from the passphrase, sent to the server as hex, and
|
||||||
|
stored only as a **BCrypt hash**. Proving knowledge of it is what lets you join; it reveals
|
||||||
|
nothing about the content key.
|
||||||
|
- **Key-encryption key (KEK)** — never sent. Used locally to *wrap* (encrypt) and *unwrap* the room
|
||||||
|
content key.
|
||||||
|
- **Room Content Key (RCK)** — a random 256-bit key generated once, at room creation. It encrypts
|
||||||
|
every message and file. The server stores it only in wrapped form, so it can hand the wrapped key
|
||||||
|
to a joiner but can never unwrap it itself.
|
||||||
|
|
||||||
|
All content encryption is **AES-256-GCM** with a random 12-byte nonce and a 16-byte authentication
|
||||||
|
tag per item, so identical inputs never produce identical ciphertext, and any tampering is detected.
|
||||||
|
|
||||||
|
Room-encrypted text carries a self-describing prefix so clients and the server can tell it apart
|
||||||
|
from plaintext:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$RC1$base64(nonce || tag || ciphertext)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating a room
|
||||||
|
|
||||||
|
The client does all the cryptography locally, then hands the server only what it needs to gate joins
|
||||||
|
and store (but not read) the content.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Client
|
||||||
|
participant Server
|
||||||
|
|
||||||
|
Client->>Client: generate salt + random Room Content Key (RCK)
|
||||||
|
Client->>Client: DeriveKeys(passphrase, salt) → auth key + KEK
|
||||||
|
Client->>Client: wrap RCK under KEK
|
||||||
|
Client->>Server: create channel { authKey(hex), salt, wrappedRoomKey }
|
||||||
|
Server->>Server: BCrypt-hash auth key, store salt + wrapped key
|
||||||
|
Note over Server: Server never receives passphrase, KEK, or RCK
|
||||||
|
```
|
||||||
|
|
||||||
|
## Joining a room
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Client
|
||||||
|
participant Server
|
||||||
|
|
||||||
|
Client->>Server: GET /crypto → { isEncrypted, salt }
|
||||||
|
Client->>Client: DeriveKeys(passphrase, salt) → auth key + KEK
|
||||||
|
Client->>Server: join { authKey(hex) }
|
||||||
|
Server->>Server: BCrypt-verify against stored hash
|
||||||
|
alt correct passphrase
|
||||||
|
Server->>Client: history (ciphertext) + wrapped room key
|
||||||
|
Client->>Client: unwrap RCK with KEK, then decrypt everything locally
|
||||||
|
else wrong passphrase
|
||||||
|
Server->>Client: rejected (join gate fails)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
A wrong passphrase fails the BCrypt gate, so the server never even hands out the wrapped key. Even if
|
||||||
|
it did, an attacker without the KEK cannot unwrap it.
|
||||||
|
|
||||||
|
## What gets encrypted
|
||||||
|
|
||||||
|
When you send a message or attach files to an encrypted room, the client encrypts each part with the
|
||||||
|
room content key **before** uploading:
|
||||||
|
|
||||||
|
- **Message text** → `$RC1$…` ciphertext.
|
||||||
|
- **Files** (any kind) → the whole blob is AES-256-GCM encrypted client-side; the server stores an
|
||||||
|
opaque ciphertext blob.
|
||||||
|
- **Image ASCII previews** → rendered on the client, then room-encrypted. The server never sees the
|
||||||
|
rendered art.
|
||||||
|
|
||||||
|
The server records each attachment's **kind**, **file name**, and **byte size** (of the ciphertext
|
||||||
|
blob) as metadata, and broadcasts the ciphertext to other members, who decrypt locally.
|
||||||
|
|
||||||
|
## Changing the passphrase
|
||||||
|
|
||||||
|
`/passwd <old> <new>` rotates the passphrase. Because only the *wrapping* of the room content key
|
||||||
|
changes — not the RCK itself — **all existing history stays readable**:
|
||||||
|
|
||||||
|
1. The client proves knowledge of the old passphrase (old auth key).
|
||||||
|
2. It unwraps the RCK with the old KEK, then re-wraps it under the new KEK (new salt).
|
||||||
|
3. It uploads the new auth key + salt + re-wrapped key. The content is never re-encrypted.
|
||||||
|
|
||||||
|
## Inspecting a room
|
||||||
|
|
||||||
|
Use `/meta` in any channel to see what the server knows about it, including encrypted rooms:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Room info for #private-room:
|
||||||
|
Room ID 3f2a…-…-…
|
||||||
|
Created 7/16/2026 2:31 PM
|
||||||
|
Messages 128
|
||||||
|
Unique users 4
|
||||||
|
Est. size 42.5 MB
|
||||||
|
Protection end-to-end encrypted
|
||||||
|
```
|
||||||
|
|
||||||
|
`Est. size` is the sum of stored attachment blob sizes plus message text length — an estimate of the
|
||||||
|
room's footprint, computed entirely from metadata the server holds without reading any content.
|
||||||
|
|
||||||
|
## Limitations & security notes
|
||||||
|
|
||||||
|
- **No recovery.** A lost passphrase means unrecoverable history. Keep it safe; there is no reset.
|
||||||
|
- **File names are plaintext.** They stay readable so the file list works — don't put secrets in a
|
||||||
|
file name.
|
||||||
|
- **IRC is disabled for encrypted rooms.** The IRC gateway forwards plaintext and cannot participate
|
||||||
|
in the room's key scheme, so encrypted channels are not bridged to IRC.
|
||||||
|
- **Metadata is visible.** Message counts, timestamps, sender identities, file names, and sizes are
|
||||||
|
intentionally readable so the server can moderate at the metadata level and report `/meta`.
|
||||||
|
- **Endpoint trust.** End-to-end encryption protects content from the server and the network, not
|
||||||
|
from a compromised client device that already holds the passphrase.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [Message Encryption](encryption.md) — transport (`$ENC$v1$`) and optional at-rest database
|
||||||
|
encryption, where the server *does* decrypt content for processing. Encrypted rooms are a separate,
|
||||||
|
stronger layer that sits on top.
|
||||||
@@ -118,7 +118,7 @@ If you need to recover old encrypted messages, restore the original key from a b
|
|||||||
### Limitations
|
### 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.
|
- **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.
|
- **Server has full access** — the server decrypts all messages for processing. This is not end-to-end encryption between users; it's transport encryption between client and server. For true end-to-end encryption where the server cannot read content, use [encrypted rooms](encrypted-rooms.md).
|
||||||
- **IRC clients receive plaintext** — IRC is an open protocol and third-party clients cannot participate in the encryption scheme.
|
- **IRC clients receive plaintext** — IRC is an open protocol and third-party clients cannot participate in the encryption scheme.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
href: configuration.md
|
href: configuration.md
|
||||||
- name: Encryption
|
- name: Encryption
|
||||||
href: encryption.md
|
href: encryption.md
|
||||||
|
- name: Encrypted Rooms
|
||||||
|
href: encrypted-rooms.md
|
||||||
- name: Notification Sounds
|
- name: Notification Sounds
|
||||||
href: notification-sounds.md
|
href: notification-sounds.md
|
||||||
- name: Flows
|
- name: Flows
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Release history for EchoHub.
|
|||||||
|
|
||||||
## Releases
|
## Releases
|
||||||
|
|
||||||
|
- [v0.2.13](v0.2.13.md) - Chat Visual Overhaul, Auto-Join All Channels & Persistent Read Positions
|
||||||
- [v0.2.12](v0.2.12.md) - End-to-End Encrypted Channels, IRC Channel Keys, Image Save & Ctrl+W Crash Fix
|
- [v0.2.12](v0.2.12.md) - End-to-End Encrypted Channels, IRC Channel Keys, Image Save & Ctrl+W Crash Fix
|
||||||
- [v0.2.11](v0.2.11.md) - EchoHubSpace Auth, Live Directory Updates & Server Browser Metadata
|
- [v0.2.11](v0.2.11.md) - EchoHubSpace Auth, Live Directory Updates & Server Browser Metadata
|
||||||
- [v0.2.10](v0.2.10.md) - Command Palette, Infinite History Scroll & Auto-Updater Fixes
|
- [v0.2.10](v0.2.10.md) - Command Palette, Infinite History Scroll & Auto-Updater Fixes
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
- name: Overview
|
- name: Overview
|
||||||
href: index.md
|
href: index.md
|
||||||
|
- name: v0.2.13
|
||||||
|
href: v0.2.13.md
|
||||||
- name: v0.2.12
|
- name: v0.2.12
|
||||||
href: v0.2.12.md
|
href: v0.2.12.md
|
||||||
- name: v0.2.11
|
- name: v0.2.11
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ Private channels are now genuinely private: password-protected channels are end-
|
|||||||
- Attach a file by drag & drop or by pasting — drop a file onto the terminal, or **copy a file in your file manager and press Ctrl+V**, to stage it as an attachment (the next Enter sends it with your caption). Multiple files at once are supported. Ctrl+V still pastes text when the clipboard holds text; Ctrl+Y is a paste alias. On Windows the copied-file paste reads the clipboard's file list directly (Windows Terminal never pastes copied files as text), with `xclip`/`wl-paste` used on Linux
|
- Attach a file by drag & drop or by pasting — drop a file onto the terminal, or **copy a file in your file manager and press Ctrl+V**, to stage it as an attachment (the next Enter sends it with your caption). Multiple files at once are supported. Ctrl+V still pastes text when the clipboard holds text; Ctrl+Y is a paste alias. On Windows the copied-file paste reads the clipboard's file list directly (Windows Terminal never pastes copied files as text), with `xclip`/`wl-paste` used on Linux
|
||||||
- Pick ASCII-art size for attached images — `/size` opens a Small/Medium/Large picker (40×40 / 80×80 / 120×120) with descriptions, `/size <s|m|l>` sets it directly, and `/send <file> -l` sets it for that message. The choice is a saved preference and applies to copy-paste/drag-drop images (which have no per-file flag); the current size is shown in the staging tray
|
- Pick ASCII-art size for attached images — `/size` opens a Small/Medium/Large picker (40×40 / 80×80 / 120×120) with descriptions, `/size <s|m|l>` sets it directly, and `/send <file> -l` sets it for that message. The choice is a saved preference and applies to copy-paste/drag-drop images (which have no per-file flag); the current size is shown in the staging tray
|
||||||
- New `TransparentLight` theme — dark characters on a transparent background, for light terminal color schemes (`/theme transparentlight`)
|
- New `TransparentLight` theme — dark characters on a transparent background, for light terminal color schemes (`/theme transparentlight`)
|
||||||
- Timestamps in messages are now aware of the current culture and display the short time pattern for today's messages and the short date+time pattern for older messages.
|
- **`/meta` command** — shows a summary of the current room: room id, created date, message count, unique participant count, estimated storage size, and protection level (open / password-protected / end-to-end encrypted). Works for encrypted rooms too, since these are all metadata the server tracks without reading content. (`/info` is an alias.)
|
||||||
|
- **Configurable upload limits** — a new `Uploads` section in the server settings sets the maximum size per file, image, audio clip, and avatar, plus the maximum attachments per message. Absent values fall back to the previous built-in limits, so existing servers are unaffected until they opt in.
|
||||||
|
- **Updater progress bar** — the self-updater now draws a real progress bar on the console (`Downloading [████████░░] 62%`) for each step, replacing the plain status lines, so a large download shows visible progress.
|
||||||
|
- Timestamps in messages are culture-aware and now **always include the date**: today's messages show a compact date + short time, and older messages show the general short date/time.
|
||||||
|
|
||||||
## Bug Fixes
|
## Bug Fixes
|
||||||
|
|
||||||
@@ -36,12 +39,16 @@ Private channels are now genuinely private: password-protected channels are end-
|
|||||||
- Attachments whose files have been pruned (retention cleanup deletes blobs older than `Storage:RetentionDays` but left the message rows) no longer render a dead download/preview. When channel history loads, the server checks which attachment blobs still exist: missing ones are dropped from the message, and an attachment-only message whose files are all gone is removed from the database.
|
- Attachments whose files have been pruned (retention cleanup deletes blobs older than `Storage:RetentionDays` but left the message rows) no longer render a dead download/preview. When channel history loads, the server checks which attachment blobs still exist: missing ones are dropped from the message, and an attachment-only message whose files are all gone is removed from the database.
|
||||||
- Fixed intermittent crash on Ctrl+W — Terminal.Gui binds Ctrl+W to clipboard-cut, and Windows clipboard contention (another app holding the clipboard) threw an unhandled `Win32Exception` that took the app down. Ctrl+W now deletes the previous word (readline behavior, no clipboard), and all clipboard shortcuts (Ctrl+X/C/V/Y) are guarded so transient clipboard failures log a warning instead of crashing
|
- Fixed intermittent crash on Ctrl+W — Terminal.Gui binds Ctrl+W to clipboard-cut, and Windows clipboard contention (another app holding the clipboard) threw an unhandled `Win32Exception` that took the app down. Ctrl+W now deletes the previous word (readline behavior, no clipboard), and all clipboard shortcuts (Ctrl+X/C/V/Y) are guarded so transient clipboard failures log a warning instead of crashing
|
||||||
- Fixed emoji shortcode replacement permanently disabling itself if a cursor update threw mid-replacement
|
- Fixed emoji shortcode replacement permanently disabling itself if a cursor update threw mid-replacement
|
||||||
|
- Fixed new messages showing no date — today's messages rendered time-only, and the "is it today?" check compared the server's UTC date against the local date, so the classification could also be wrong near midnight. Timestamps are now converted to local time first and always include the date.
|
||||||
|
- Fixed the self-updater hanging at "extracting" — the update ran while the Terminal.Gui main loop still owned the console, so the old and new processes deadlocked over it. The update now runs after the TUI shuts down, on a clean console
|
||||||
- IRC `LIST` no longer leaks private channels; protected channels are marked `[+k]`
|
- IRC `LIST` no longer leaks private channels; protected channels are marked `[+k]`
|
||||||
|
|
||||||
## API Changes
|
## API Changes
|
||||||
|
|
||||||
- `ChannelDto` gains `isProtected` and `isEncrypted`; `CreateChannelRequest` gains optional `password`, `encryptionSalt`, and `wrappedRoomKey`; SignalR `JoinChannel` takes an optional second `password` argument and `JoinChannelResult` gains `passwordRequired`, `encryptionSalt`, and `wrappedRoomKey` (older clients must update to join over SignalR)
|
- `ChannelDto` gains `isProtected` and `isEncrypted`; `CreateChannelRequest` gains optional `password`, `encryptionSalt`, and `wrappedRoomKey`; SignalR `JoinChannel` takes an optional second `password` argument and `JoinChannelResult` gains `passwordRequired`, `encryptionSalt`, and `wrappedRoomKey` (older clients must update to join over SignalR)
|
||||||
- New endpoints: `GET /api/channels/{channel}/crypto` (public crypto metadata — salt only, never the wrapped key) and `POST /api/channels/{channel}/rekey` (creator-only passphrase change)
|
- New endpoints: `GET /api/channels/{channel}/crypto` (public crypto metadata — salt only, never the wrapped key) and `POST /api/channels/{channel}/rekey` (creator-only passphrase change)
|
||||||
|
- New endpoint `GET /api/channels/{channel}/meta` returns a `ChannelMetaDto` (room id, name, topic, encrypted/protected flags, message count, unique user count, estimated size, created date) backing the `/meta` command
|
||||||
|
- New server `Uploads` configuration section (`MaxFileSizeMB`, `MaxImageSizeMB`, `MaxAudioSizeMB`, `MaxAvatarSizeMB`, `MaxAttachmentsPerMessage`); the message-upload endpoint's request-body ceiling is now derived from these values at runtime rather than from compile-time constants
|
||||||
- The upload endpoint accepts `type` and `content` form fields for encrypted channels, where the client supplies the declared message type and room-encrypted content
|
- The upload endpoint accepts `type` and `content` form fields for encrypted channels, where the client supplies the declared message type and room-encrypted content
|
||||||
- `ImageToAsciiService` and `FileValidationHelper` moved from `EchoHub.Server` to `EchoHub.Core` so the client can render ASCII art and detect file types for encrypted uploads
|
- `ImageToAsciiService` and `FileValidationHelper` moved from `EchoHub.Server` to `EchoHub.Core` so the client can render ASCII art and detect file types for encrypted uploads
|
||||||
- **Message shape change**: `MessageDto` drops `Type`/`AttachmentUrl`/`AttachmentFileName`/`AttachmentFileSize` and gains `Attachments` (a list of `AttachmentDto { Kind, Url, FileName, FileSize, AsciiPreview }`, null/empty for plain text). New `Attachment` entity + table with a cascade FK to `Message`
|
- **Message shape change**: `MessageDto` drops `Type`/`AttachmentUrl`/`AttachmentFileName`/`AttachmentFileSize` and gains `Attachments` (a list of `AttachmentDto { Kind, Url, FileName, FileSize, AsciiPreview }`, null/empty for plain text). New `Attachment` entity + table with a cascade FK to `Message`
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# v0.2.13
|
||||||
|
|
||||||
|
A visual overhaul of the chat client — WeeChat-style column layout, per-user nick colors, date rules, unread markers, an activity status segment, and an ASCII welcome banner — plus reliable cross-channel notifications: the client now joins all your channels at connect so unread counts and @mentions light up everywhere, and read positions persist across restarts so activity that happened while you were offline still shows. Modern polish, old IRC soul.
|
||||||
|
|
||||||
|
## New Features
|
||||||
|
|
||||||
|
- **Aligned message layout (WeeChat-style)** — every message renders as `HH:mm nickname │ text`: a dim timestamp, the nick right-aligned in a fixed 12-column gutter, and a dim `│` rail separating names from content. Word-wrapped continuation lines, multi-line messages, attachments, ASCII-art image previews, and link embeds all align under the text column and extend the rail, so conversations read as a clean two-column grid. Over-long nicks are truncated with `…` in the gutter only — profiles, mentions, and the users panel always use the full name. System and status lines (`joined`, `is now Away`, …) show `--` in the nick column, replacing the old `**` prefix.
|
||||||
|
- **Deterministic nickname colors** — users who haven't picked a `/color` now get an automatic, stable color derived from their nick (12-entry palette chosen to stay readable on dark and light backgrounds). The same user is the same color in message headers and the online-users panel, on every client, every session. An explicitly chosen nickname color still takes priority.
|
||||||
|
- **Date separator rules** — a full-width `── Wed, Jul 16 2026 ─────` rule is inserted whenever the calendar day changes (live, in loaded history, and when scrolling back through older messages). Timestamps are now a compact `HH:mm` — the date lives in the rules instead of being repeated on every line.
|
||||||
|
- **"New messages" marker** — the first message that arrives in a channel you're not viewing gets an orange `── new messages ──` rule above it (irssi-style). Switch to the channel and the marker shows exactly where you left off; it survives the history reload on channel switch and is consumed when you move away again.
|
||||||
|
- **Status bar activity segment** — channels with unread messages appear in the status bar as `│ Act: #dev,#random` (up to 4, `+n` overflow). Channels where you were @mentioned show in orange, others in cyan.
|
||||||
|
- **Connecting spinner** — transitional connection states (Connecting, Reconnecting, Authenticating…) now animate a braille spinner in the status bar instead of sitting on static text.
|
||||||
|
- **Mention-aware channel list** — a channel where you were @mentioned turns orange in the channel list (name and unread badge), escalating above the cyan plain-unread highlight. The highlight clears when you view the channel.
|
||||||
|
- **Welcome banner** — with no channel selected (fresh start, or after disconnecting) the chat pane shows a gold-gradient ASCII "ECHOHUB" logo with the version and key hints, instead of an empty box. Narrow panes get a compact variant.
|
||||||
|
- **Rounded frame borders** — the channels, chat, input, and users panels draw with rounded corners.
|
||||||
|
- **Auto-join all channels at connect** — the client now joins every channel it lists for you (public channels and prior memberships) as part of connecting, so message events flow for all of them: unread badges, @mention highlights, and the status-bar activity segment work without having to open each channel first. Protected channels you've never entered are skipped silently (joining them stays a prompted, manual action), and channels you `/leave` are remembered and excluded until you join them again.
|
||||||
|
- **Read positions survive restarts** — the client persists the last message you read per channel (locally, per server). On the next connect it compares that against fetched history, so messages that arrived while you were offline still produce unread counts, mention highlights, and a correctly placed "new messages" marker.
|
||||||
|
- **Theme-tinted frame borders** — themes can now color the window frame borders independently of text via a new optional `border` section (hex values supported). The Transparent and TransparentLight themes use it to draw dim gray borders instead of stark white ones, for a subtler, glassier look. Custom theme JSON files without a `border` section keep their base colors.
|
||||||
|
|
||||||
|
## Bug Fixes
|
||||||
|
|
||||||
|
- The @mention highlight no longer drops off the second and later lines of a long mention message — wrapped continuation lines now inherit the highlight.
|
||||||
|
- The unread-position marker is re-anchored after a channel switch reloads history, instead of being wiped by the reload before you could see it.
|
||||||
|
- Cached room keys for end-to-end encrypted channels no longer disappear from the config on reconnect. Saving the server entry after a successful connect replaced it wholesale, wiping the cached keys (and forcing a passphrase re-entry on the next launch); the entry is now updated in place.
|
||||||
|
- A failed channel rejoin after a reconnect (e.g. a channel deleted while you were away) no longer aborts rejoining the remaining channels.
|
||||||
|
- Client config file access is now serialized across threads (token refresh, room-key cache, and read-position checkpoints all write it), preventing rare config corruption.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
**/obj/
|
||||||
|
**/bin/
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>0.2.12</Version>
|
<Version>0.2.13</Version>
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
|
|
||||||
public MainWindow MainWindow => _mainWindow;
|
public MainWindow MainWindow => _mainWindow;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set when the user confirms an update. The host must run this after the Terminal.Gui main
|
||||||
|
/// loop exits (console restored), so the updater's in-place restart doesn't fight the TUI.
|
||||||
|
/// </summary>
|
||||||
|
public Func<Task>? PendingUpdate => _updateService.PendingUpdate;
|
||||||
|
|
||||||
public AppOrchestrator(IApplication app, ClientConfig config)
|
public AppOrchestrator(IApplication app, ClientConfig config)
|
||||||
{
|
{
|
||||||
_app = app;
|
_app = app;
|
||||||
@@ -61,6 +67,8 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
// Quit while still connected — capture read positions before tearing down
|
||||||
|
PersistLastReads();
|
||||||
_conn.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
_conn.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||||
_updateService.Dispose();
|
_updateService.Dispose();
|
||||||
}
|
}
|
||||||
@@ -121,6 +129,7 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
_commandHandler.OnLeaveChannel += HandleCmdLeaveChannel;
|
_commandHandler.OnLeaveChannel += HandleCmdLeaveChannel;
|
||||||
_commandHandler.OnSetTopic += HandleCmdSetTopic;
|
_commandHandler.OnSetTopic += HandleCmdSetTopic;
|
||||||
_commandHandler.OnListUsers += HandleCmdListUsers;
|
_commandHandler.OnListUsers += HandleCmdListUsers;
|
||||||
|
_commandHandler.OnRoomInfo += HandleCmdMeta;
|
||||||
_commandHandler.OnKickUser += HandleCmdKickUser;
|
_commandHandler.OnKickUser += HandleCmdKickUser;
|
||||||
_commandHandler.OnBanUser += HandleCmdBanUser;
|
_commandHandler.OnBanUser += HandleCmdBanUser;
|
||||||
_commandHandler.OnUnbanUser += HandleCmdUnbanUser;
|
_commandHandler.OnUnbanUser += HandleCmdUnbanUser;
|
||||||
@@ -377,6 +386,10 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
var history = await JoinChannelWithPasswordPromptAsync(channelName, password);
|
var history = await JoinChannelWithPasswordPromptAsync(channelName, password);
|
||||||
if (history is null) return; // user cancelled the password prompt
|
if (history is null) return; // user cancelled the password prompt
|
||||||
|
|
||||||
|
// A deliberate join cancels any earlier /leave exclusion
|
||||||
|
UpdateServerConfig(server =>
|
||||||
|
server.LeftChannels.RemoveAll(c => c.Equals(channelName, StringComparison.OrdinalIgnoreCase)));
|
||||||
|
|
||||||
InvokeUI(() =>
|
InvokeUI(() =>
|
||||||
{
|
{
|
||||||
_mainWindow.EnsureChannelInList(channelName);
|
_mainWindow.EnsureChannelInList(channelName);
|
||||||
@@ -546,6 +559,14 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _conn.LeaveChannelAsync(channel);
|
await _conn.LeaveChannelAsync(channel);
|
||||||
|
|
||||||
|
// Remember the leave so the connect-time auto-join doesn't pull us back in
|
||||||
|
UpdateServerConfig(server =>
|
||||||
|
{
|
||||||
|
if (!server.LeftChannels.Contains(channel, StringComparer.OrdinalIgnoreCase))
|
||||||
|
server.LeftChannels.Add(channel);
|
||||||
|
});
|
||||||
|
|
||||||
InvokeUI(() => _messageManager.AddSystemMessage(channel, $"You left #{channel}"));
|
InvokeUI(() => _messageManager.AddSystemMessage(channel, $"You left #{channel}"));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -605,6 +626,46 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task HandleCmdMeta()
|
||||||
|
{
|
||||||
|
if (!_conn.IsConnected || _conn.Api is null) return;
|
||||||
|
|
||||||
|
var channel = _mainWindow.CurrentChannel;
|
||||||
|
if (string.IsNullOrEmpty(channel)) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var meta = await _conn.Api.GetChannelMetaAsync(channel);
|
||||||
|
if (meta is null)
|
||||||
|
{
|
||||||
|
InvokeUI(() => _mainWindow.ShowError($"Channel #{channel} not found."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var size = meta.EstimatedSizeBytes <= 0 ? "0 B" : ChatMessageManager.FormatFileSize(meta.EstimatedSizeBytes);
|
||||||
|
var protection = meta.IsEncrypted ? "end-to-end encrypted"
|
||||||
|
: meta.IsProtected ? "password-protected"
|
||||||
|
: "open";
|
||||||
|
|
||||||
|
InvokeUI(() =>
|
||||||
|
{
|
||||||
|
_messageManager.AddSystemMessage(channel, $"Room info for #{meta.Name}:");
|
||||||
|
if (!string.IsNullOrWhiteSpace(meta.Topic))
|
||||||
|
_messageManager.AddSystemMessage(channel, $" Topic {meta.Topic}");
|
||||||
|
_messageManager.AddSystemMessage(channel, $" Room ID {meta.Id}");
|
||||||
|
_messageManager.AddSystemMessage(channel, $" Created {meta.CreatedAt.ToLocalTime():g}");
|
||||||
|
_messageManager.AddSystemMessage(channel, $" Messages {meta.MessageCount}");
|
||||||
|
_messageManager.AddSystemMessage(channel, $" Unique users {meta.UniqueUserCount}");
|
||||||
|
_messageManager.AddSystemMessage(channel, $" Est. size {size}");
|
||||||
|
_messageManager.AddSystemMessage(channel, $" Protection {protection}");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
InvokeUI(() => _mainWindow.ShowError($"Failed to fetch room info: {ex.Message}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task HandleCmdKickUser(string username, string? reason)
|
private async Task HandleCmdKickUser(string username, string? reason)
|
||||||
{
|
{
|
||||||
if (!_conn.IsAuthenticated) return;
|
if (!_conn.IsAuthenticated) return;
|
||||||
@@ -910,13 +971,28 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
|
|
||||||
_session.Username = result.Login.Username;
|
_session.Username = result.Login.Username;
|
||||||
|
|
||||||
|
// Persisted last-read markers for this server — used to seed unread counts,
|
||||||
|
// mention highlights, and "new messages" markers from the fetched histories.
|
||||||
|
var lastReads = ConfigManager.Load().SavedServers
|
||||||
|
.FirstOrDefault(s => string.Equals(s.Url, dialogResult.ServerUrl, StringComparison.OrdinalIgnoreCase))
|
||||||
|
?.LastReadMessages ?? [];
|
||||||
|
|
||||||
InvokeUI(() =>
|
InvokeUI(() =>
|
||||||
{
|
{
|
||||||
_mainWindow.SetCurrentUser(result.Login.DisplayName ?? result.Login.Username);
|
_mainWindow.SetCurrentUser(result.Login.DisplayName ?? result.Login.Username);
|
||||||
_mainWindow.SetChannels(result.Channels);
|
_mainWindow.SetChannels(result.Channels);
|
||||||
_mainWindow.SwitchToChannel(HubConstants.DefaultChannel);
|
_mainWindow.SwitchToChannel(HubConstants.DefaultChannel);
|
||||||
if (result.DefaultHistory.Count > 0)
|
|
||||||
_messageManager.LoadHistory(HubConstants.DefaultChannel, result.DefaultHistory);
|
foreach (var (channel, history) in result.Histories)
|
||||||
|
{
|
||||||
|
if (history.Count == 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
Guid? lastRead = lastReads.TryGetValue(channel, out var idText)
|
||||||
|
&& Guid.TryParse(idText, out var id) ? id : null;
|
||||||
|
_messageManager.LoadHistory(channel, history, lastRead);
|
||||||
|
}
|
||||||
|
|
||||||
_mainWindow.FocusInput();
|
_mainWindow.FocusInput();
|
||||||
FetchAndUpdateOnlineUsers();
|
FetchAndUpdateOnlineUsers();
|
||||||
});
|
});
|
||||||
@@ -928,6 +1004,7 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
{
|
{
|
||||||
Log.Information("Disconnecting from server");
|
Log.Information("Disconnecting from server");
|
||||||
lock (_channelUsersLock) _channelUsers.Clear();
|
lock (_channelUsersLock) _channelUsers.Clear();
|
||||||
|
PersistLastReads();
|
||||||
|
|
||||||
RunAsync(async () =>
|
RunAsync(async () =>
|
||||||
{
|
{
|
||||||
@@ -943,6 +1020,7 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
private void HandleLogout()
|
private void HandleLogout()
|
||||||
{
|
{
|
||||||
Log.Information("Logging out from server");
|
Log.Information("Logging out from server");
|
||||||
|
PersistLastReads();
|
||||||
|
|
||||||
RunAsync(async () =>
|
RunAsync(async () =>
|
||||||
{
|
{
|
||||||
@@ -1014,6 +1092,9 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
{
|
{
|
||||||
if (!_conn.IsConnected) return;
|
if (!_conn.IsConnected) return;
|
||||||
|
|
||||||
|
// Checkpoint read positions — the previous channel was just marked read
|
||||||
|
PersistLastReads();
|
||||||
|
|
||||||
RunAsync(async () =>
|
RunAsync(async () =>
|
||||||
{
|
{
|
||||||
if (_conn.TrackChannel(channelName))
|
if (_conn.TrackChannel(channelName))
|
||||||
@@ -1026,6 +1107,10 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
InvokeUI(() => _mainWindow.SwitchToChannel(HubConstants.DefaultChannel));
|
InvokeUI(() => _mainWindow.SwitchToChannel(HubConstants.DefaultChannel));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A deliberate join cancels any earlier /leave exclusion
|
||||||
|
UpdateServerConfig(server =>
|
||||||
|
server.LeftChannels.RemoveAll(c => c.Equals(channelName, StringComparison.OrdinalIgnoreCase)));
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -1620,20 +1705,63 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
|
|
||||||
private void SaveServerToConfig(ConnectDialogResult result)
|
private void SaveServerToConfig(ConnectDialogResult result)
|
||||||
{
|
{
|
||||||
var savedServer = new SavedServer
|
// Update the existing entry in place (never replace it) — the per-server entry also
|
||||||
|
// carries cached room keys, left channels, and last-read markers that must survive.
|
||||||
|
var config = ConfigManager.Load();
|
||||||
|
var server = config.SavedServers.FirstOrDefault(s =>
|
||||||
|
string.Equals(s.Url, result.ServerUrl, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (server is null)
|
||||||
{
|
{
|
||||||
Name = new Uri(result.ServerUrl).Host,
|
server = new SavedServer { Name = new Uri(result.ServerUrl).Host, Url = result.ServerUrl };
|
||||||
Url = result.ServerUrl,
|
config.SavedServers.Add(server);
|
||||||
Username = result.Username,
|
}
|
||||||
RefreshToken = result.RememberMe ? _conn.Api!.RefreshToken : null,
|
|
||||||
RememberMe = result.RememberMe,
|
server.Username = result.Username;
|
||||||
LastConnected = DateTimeOffset.Now
|
server.RefreshToken = result.RememberMe ? _conn.Api!.RefreshToken : null;
|
||||||
};
|
server.RememberMe = result.RememberMe;
|
||||||
ConfigManager.SaveServer(savedServer);
|
server.LastConnected = DateTimeOffset.Now;
|
||||||
_config = ConfigManager.Load();
|
|
||||||
|
ConfigManager.Save(config);
|
||||||
|
_config = config;
|
||||||
Log.Information("Connected successfully to {Url}", result.ServerUrl);
|
Log.Information("Connected successfully to {Url}", result.ServerUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mutates the current server's config entry and persists it. No-op when not
|
||||||
|
/// authenticated or the server isn't saved.
|
||||||
|
/// </summary>
|
||||||
|
private void UpdateServerConfig(Action<SavedServer> mutate)
|
||||||
|
{
|
||||||
|
var url = _conn.Api?.BaseUrl;
|
||||||
|
if (url is null) return;
|
||||||
|
|
||||||
|
var config = ConfigManager.Load();
|
||||||
|
var server = config.SavedServers.FirstOrDefault(s =>
|
||||||
|
string.Equals(s.Url, url, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (server is null) return;
|
||||||
|
|
||||||
|
mutate(server);
|
||||||
|
ConfigManager.Save(config);
|
||||||
|
_config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists the in-memory last-read message ids to the current server's config entry,
|
||||||
|
/// so unread/mention state can be reconstructed on the next connect.
|
||||||
|
/// </summary>
|
||||||
|
private void PersistLastReads()
|
||||||
|
{
|
||||||
|
var lastReads = _messageManager.LastReadIds;
|
||||||
|
if (lastReads.Count == 0) return;
|
||||||
|
|
||||||
|
UpdateServerConfig(server =>
|
||||||
|
{
|
||||||
|
foreach (var (channel, id) in lastReads)
|
||||||
|
server.LastReadMessages[channel] = id.ToString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private void ClearSavedToken(string serverUrl)
|
private void ClearSavedToken(string serverUrl)
|
||||||
{
|
{
|
||||||
var config = ConfigManager.Load();
|
var config = ConfigManager.Load();
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ public class CommandHandler
|
|||||||
public event Func<Task>? OnLeaveChannel;
|
public event Func<Task>? OnLeaveChannel;
|
||||||
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<Task>? OnRoomInfo;
|
||||||
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>? OnKickUser;
|
||||||
public event Func<string, string?, Task>? OnBanUser;
|
public event Func<string, string?, Task>? OnBanUser;
|
||||||
@@ -62,6 +63,7 @@ public class CommandHandler
|
|||||||
"leave" => await HandleLeave(),
|
"leave" => await HandleLeave(),
|
||||||
"topic" => await HandleTopic(args),
|
"topic" => await HandleTopic(args),
|
||||||
"users" => await HandleUsers(),
|
"users" => await HandleUsers(),
|
||||||
|
"meta" or "info" => await HandleMeta(),
|
||||||
"kick" => await HandleKick(args),
|
"kick" => await HandleKick(args),
|
||||||
"ban" => await HandleBan(args),
|
"ban" => await HandleBan(args),
|
||||||
"unban" => await HandleUnban(args),
|
"unban" => await HandleUnban(args),
|
||||||
@@ -273,6 +275,13 @@ public class CommandHandler
|
|||||||
return new CommandResult(true);
|
return new CommandResult(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleMeta()
|
||||||
|
{
|
||||||
|
if (OnRoomInfo is not null)
|
||||||
|
await OnRoomInfo();
|
||||||
|
return new CommandResult(true);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<CommandResult> HandleQuit()
|
private async Task<CommandResult> HandleQuit()
|
||||||
{
|
{
|
||||||
if (OnQuit is not null)
|
if (OnQuit is not null)
|
||||||
@@ -403,6 +412,7 @@ public class CommandHandler
|
|||||||
/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
|
||||||
|
/meta - Show room info (size, messages, users, created, id)
|
||||||
Moderation:
|
Moderation:
|
||||||
/kick <user> [reason] - Kick a user (Mod+)
|
/kick <user> [reason] - Kick a user (Mod+)
|
||||||
/ban <user> [reason] - Ban a user (Admin+)
|
/ban <user> [reason] - Ban a user (Admin+)
|
||||||
|
|||||||
@@ -42,6 +42,18 @@ public class SavedServer
|
|||||||
/// user's machine — the server never sees them.
|
/// user's machine — the server never sees them.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Dictionary<string, string> ChannelKeys { get; set; } = [];
|
public Dictionary<string, string> ChannelKeys { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Channels the user explicitly left with /leave. Excluded from the automatic
|
||||||
|
/// join-all-channels pass at connect until the user joins them again.
|
||||||
|
/// </summary>
|
||||||
|
public List<string> LeftChannels { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Last message the user has read per channel (message id as string), persisted so
|
||||||
|
/// unread counts, @mention highlights, and the "new messages" marker survive restarts.
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, string> LastReadMessages { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public class AccountPreset
|
public class AccountPreset
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ public static class ConfigManager
|
|||||||
|
|
||||||
private static readonly string ConfigPath = Path.Combine(ConfigDir, "config.json");
|
private static readonly string ConfigPath = Path.Combine(ConfigDir, "config.json");
|
||||||
|
|
||||||
|
// Load-mutate-save cycles run from both the UI thread and background tasks
|
||||||
|
// (token refresh, room keys, last-read checkpoints) — serialize file access.
|
||||||
|
private static readonly Lock FileLock = new();
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
{
|
{
|
||||||
WriteIndented = true,
|
WriteIndented = true,
|
||||||
@@ -17,54 +21,66 @@ public static class ConfigManager
|
|||||||
|
|
||||||
public static ClientConfig Load()
|
public static ClientConfig Load()
|
||||||
{
|
{
|
||||||
try
|
lock (FileLock)
|
||||||
{
|
{
|
||||||
if (!File.Exists(ConfigPath))
|
try
|
||||||
return new ClientConfig();
|
{
|
||||||
|
if (!File.Exists(ConfigPath))
|
||||||
|
return new ClientConfig();
|
||||||
|
|
||||||
var json = File.ReadAllText(ConfigPath);
|
var json = File.ReadAllText(ConfigPath);
|
||||||
return JsonSerializer.Deserialize<ClientConfig>(json, JsonOptions) ?? new ClientConfig();
|
return JsonSerializer.Deserialize<ClientConfig>(json, JsonOptions) ?? new ClientConfig();
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
return new ClientConfig();
|
return new ClientConfig();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void Save(ClientConfig config)
|
public static void Save(ClientConfig config)
|
||||||
{
|
{
|
||||||
try
|
lock (FileLock)
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(ConfigDir);
|
try
|
||||||
var json = JsonSerializer.Serialize(config, JsonOptions);
|
{
|
||||||
File.WriteAllText(ConfigPath, json);
|
Directory.CreateDirectory(ConfigDir);
|
||||||
}
|
var json = JsonSerializer.Serialize(config, JsonOptions);
|
||||||
catch
|
File.WriteAllText(ConfigPath, json);
|
||||||
{
|
}
|
||||||
// Silently fail — config save is best-effort
|
catch
|
||||||
|
{
|
||||||
|
// Silently fail — config save is best-effort
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void SaveServer(SavedServer server)
|
public static void SaveServer(SavedServer server)
|
||||||
{
|
{
|
||||||
var config = Load();
|
lock (FileLock)
|
||||||
var existing = config.SavedServers.FindIndex(s =>
|
{
|
||||||
string.Equals(s.Url, server.Url, StringComparison.OrdinalIgnoreCase));
|
var config = Load();
|
||||||
|
var existing = config.SavedServers.FindIndex(s =>
|
||||||
|
string.Equals(s.Url, server.Url, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
if (existing >= 0)
|
if (existing >= 0)
|
||||||
config.SavedServers[existing] = server;
|
config.SavedServers[existing] = server;
|
||||||
else
|
else
|
||||||
config.SavedServers.Add(server);
|
config.SavedServers.Add(server);
|
||||||
|
|
||||||
Save(config);
|
Save(config);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void RemoveServer(string url)
|
public static void RemoveServer(string url)
|
||||||
{
|
{
|
||||||
var config = Load();
|
lock (FileLock)
|
||||||
config.SavedServers.RemoveAll(s =>
|
{
|
||||||
string.Equals(s.Url, url, StringComparison.OrdinalIgnoreCase));
|
var config = Load();
|
||||||
|
config.SavedServers.RemoveAll(s =>
|
||||||
|
string.Equals(s.Url, url, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
Save(config);
|
Save(config);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,10 +126,24 @@ try
|
|||||||
var theme = ThemeManager.GetTheme(config.ActiveTheme);
|
var theme = ThemeManager.GetTheme(config.ActiveTheme);
|
||||||
ThemeManager.ApplyTheme(theme);
|
ThemeManager.ApplyTheme(theme);
|
||||||
|
|
||||||
using var orchestrator = new AppOrchestrator(app, config);
|
var orchestrator = new AppOrchestrator(app, config);
|
||||||
|
|
||||||
app.Run(orchestrator.MainWindow);
|
app.Run(orchestrator.MainWindow);
|
||||||
|
|
||||||
|
// Capture any confirmed update before tearing anything down, then restore the console.
|
||||||
|
var pendingUpdate = orchestrator.PendingUpdate;
|
||||||
app.Dispose();
|
app.Dispose();
|
||||||
|
|
||||||
|
// Apply the update on a clean console: the TUI has released it, so the updater can extract
|
||||||
|
// and restart the process without deadlocking against the alternate-screen buffer. This call
|
||||||
|
// ends by starting the new version and calling Environment.Exit, so it does not return.
|
||||||
|
if (pendingUpdate is not null)
|
||||||
|
{
|
||||||
|
Log.Information("Applying confirmed update after shutdown");
|
||||||
|
await pendingUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
orchestrator.Dispose();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -271,6 +271,20 @@ public sealed class ApiClient : IDisposable
|
|||||||
return await response.Content.ReadFromJsonAsync<ChannelCryptoDto>();
|
return await response.Content.ReadFromJsonAsync<ChannelCryptoDto>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches a channel's human-facing metadata (message count, unique posters, estimated
|
||||||
|
/// size, created date, room id) for the <c>/meta</c> command. Returns null if it doesn't exist.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<ChannelMetaDto?> GetChannelMetaAsync(string channelName)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
using var response = await AuthenticatedGetAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/meta");
|
||||||
|
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||||
|
return null;
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
return await response.Content.ReadFromJsonAsync<ChannelMetaDto>();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<ChannelDto?> RekeyChannelAsync(string channelName, RekeyChannelRequest request)
|
public async Task<ChannelDto?> RekeyChannelAsync(string channelName, RekeyChannelRequest request)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ namespace EchoHub.Client.Services;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Result of a successful connection, returned to AppOrchestrator for UI updates.
|
/// Result of a successful connection, returned to AppOrchestrator for UI updates.
|
||||||
|
/// <paramref name="Histories"/> holds the initial history of every auto-joined channel
|
||||||
|
/// (keyed by channel name, always including the default channel).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal record ConnectResult(
|
internal record ConnectResult(
|
||||||
LoginResponse Login,
|
LoginResponse Login,
|
||||||
List<ChannelDto> Channels,
|
List<ChannelDto> Channels,
|
||||||
List<MessageDto> DefaultHistory);
|
Dictionary<string, List<MessageDto>> Histories);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Owns connection lifecycle, authentication, SignalR event wiring, and channel tracking.
|
/// Owns connection lifecycle, authentication, SignalR event wiring, and channel tracking.
|
||||||
@@ -109,24 +111,54 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
|||||||
await _connection.ConnectAsync();
|
await _connection.ConnectAsync();
|
||||||
|
|
||||||
var channels = await _apiClient.GetChannelsAsync();
|
var channels = await _apiClient.GetChannelsAsync();
|
||||||
onStatus("Connected");
|
|
||||||
|
|
||||||
// Join default channel + fetch history
|
// Join default channel + fetch history
|
||||||
|
onStatus("Joining channels...");
|
||||||
_joinedChannels.Clear();
|
_joinedChannels.Clear();
|
||||||
_joinedChannels.Add(HubConstants.DefaultChannel);
|
_joinedChannels.Add(HubConstants.DefaultChannel);
|
||||||
await _connection.JoinChannelAsync(HubConstants.DefaultChannel);
|
await _connection.JoinChannelAsync(HubConstants.DefaultChannel);
|
||||||
|
|
||||||
List<MessageDto> history = [];
|
var histories = new Dictionary<string, List<MessageDto>>(StringComparer.OrdinalIgnoreCase);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
history = await _connection.GetHistoryAsync(HubConstants.DefaultChannel);
|
histories[HubConstants.DefaultChannel] = await _connection.GetHistoryAsync(HubConstants.DefaultChannel);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// History might not be available
|
// History might not be available
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ConnectResult(loginResponse, channels, history);
|
// Auto-join every other channel the server lists for this user (public +
|
||||||
|
// prior memberships) so message events — unread counts, @mentions — flow for
|
||||||
|
// all of them, not just channels opened this session. Channels the user left
|
||||||
|
// with /leave stay out until rejoined; protected channels we can't enter
|
||||||
|
// silently (no cached membership) are skipped, never prompted for.
|
||||||
|
var leftChannels = FindServer(ConfigManager.Load(), info.ServerUrl)?.LeftChannels ?? [];
|
||||||
|
foreach (var channel in channels)
|
||||||
|
{
|
||||||
|
if (channel.Name.Equals(HubConstants.DefaultChannel, StringComparison.OrdinalIgnoreCase))
|
||||||
|
continue;
|
||||||
|
if (leftChannels.Contains(channel.Name, StringComparer.OrdinalIgnoreCase))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var outcome = await _connection.JoinChannelAsync(channel.Name);
|
||||||
|
_joinedChannels.Add(channel.Name);
|
||||||
|
histories[channel.Name] = outcome.History;
|
||||||
|
}
|
||||||
|
catch (ChannelPasswordRequiredException)
|
||||||
|
{
|
||||||
|
// First-time protected channel — joining stays a manual, prompted action
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Auto-join failed for #{Channel}", channel.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onStatus("Connected");
|
||||||
|
return new ConnectResult(loginResponse, channels, histories);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -236,11 +268,19 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
|||||||
|
|
||||||
foreach (var channel in channels)
|
foreach (var channel in channels)
|
||||||
{
|
{
|
||||||
_joinedChannels.Add(channel);
|
// One channel gone bad (deleted, membership revoked) must not stop the rest
|
||||||
await _connection.JoinChannelAsync(channel);
|
try
|
||||||
|
{
|
||||||
|
await _connection.JoinChannelAsync(channel);
|
||||||
|
_joinedChannels.Add(channel);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Rejoin failed for #{Channel}", channel);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.Information("Rejoined {Count} channel(s) after reconnect", channels.Count);
|
Log.Information("Rejoined {Count} channel(s) after reconnect", _joinedChannels.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── SignalR Event Wiring ──────────────────────────────────────────────
|
// ── SignalR Event Wiring ──────────────────────────────────────────────
|
||||||
@@ -268,8 +308,7 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
if (_apiClient?.RefreshToken is null) return;
|
if (_apiClient?.RefreshToken is null) return;
|
||||||
var config = ConfigManager.Load();
|
var config = ConfigManager.Load();
|
||||||
var server = config.SavedServers.FirstOrDefault(s =>
|
var server = FindServer(config, _apiClient.BaseUrl);
|
||||||
string.Equals(s.Url, _apiClient.BaseUrl, StringComparison.OrdinalIgnoreCase));
|
|
||||||
if (server is not null && server.RememberMe)
|
if (server is not null && server.RememberMe)
|
||||||
{
|
{
|
||||||
server.RefreshToken = _apiClient.RefreshToken;
|
server.RefreshToken = _apiClient.RefreshToken;
|
||||||
@@ -277,6 +316,10 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static SavedServer? FindServer(ClientConfig config, string url) =>
|
||||||
|
config.SavedServers.FirstOrDefault(s =>
|
||||||
|
string.Equals(s.Url, url, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
// ── Dispose ───────────────────────────────────────────────────────────
|
// ── Dispose ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
using AlwaysUpToDate;
|
using AlwaysUpToDate;
|
||||||
|
|
||||||
using EchoHub.Client.UI.Dialogs;
|
using EchoHub.Client.UI.Dialogs;
|
||||||
@@ -15,9 +17,17 @@ public sealed class UpdateChecker : IDisposable
|
|||||||
|
|
||||||
private readonly Updater _updater;
|
private readonly Updater _updater;
|
||||||
private readonly IApplication _app;
|
private readonly IApplication _app;
|
||||||
private UpdateProgressDialog? _progressDialog;
|
private string? _pendingVersion;
|
||||||
|
private bool _applying;
|
||||||
|
private UpdateStep _lastStep = (UpdateStep)(-1);
|
||||||
private bool _manualCheck;
|
private bool _manualCheck;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set when the user confirms an update. The host runs this <b>after</b> the Terminal.Gui
|
||||||
|
/// main loop has exited and the console is restored, so the library's in-place restart doesn't
|
||||||
|
/// deadlock against a TUI that still owns the console.
|
||||||
|
/// </summary>
|
||||||
|
public Func<Task>? PendingUpdate { get; private set; }
|
||||||
|
|
||||||
public static string CurrentVersion => typeof(UpdateChecker).Assembly.GetName().Version?.ToString(3) ?? "0.0.0";
|
public static string CurrentVersion => typeof(UpdateChecker).Assembly.GetName().Version?.ToString(3) ?? "0.0.0";
|
||||||
|
|
||||||
@@ -40,7 +50,6 @@ public sealed class UpdateChecker : IDisposable
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public async Task CheckNowAsync()
|
public async Task CheckNowAsync()
|
||||||
{
|
{
|
||||||
_manualCheck = true;
|
_manualCheck = true;
|
||||||
@@ -54,67 +63,98 @@ public sealed class UpdateChecker : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void OnUpdateAvailable(string version, string changelogUrl)
|
private void OnUpdateAvailable(string version, string changelogUrl)
|
||||||
{
|
{
|
||||||
Log.Information("Update available: v{Version}", version);
|
Log.Information("Update available: v{Version}", version);
|
||||||
|
|
||||||
_app.Invoke(() =>
|
_app.Invoke(() =>
|
||||||
{
|
{
|
||||||
var confirmed = UpdateConfirmDialog.Show(_app, CurrentVersion, version);
|
var confirmed = UpdateConfirmDialog.Show(_app, CurrentVersion, version);
|
||||||
|
if (!confirmed)
|
||||||
|
return;
|
||||||
|
|
||||||
if (confirmed)
|
// Defer the actual download/extract/restart to after the TUI is torn down.
|
||||||
{
|
// Running it under the live main loop lets the library restart the process while
|
||||||
_progressDialog = new UpdateProgressDialog(_app, version);
|
// this one still holds the console in raw/alternate-screen mode — the two processes
|
||||||
|
// then deadlock over the console (the "stuck at N/N extracting" hang).
|
||||||
_ = Task.Run(async () =>
|
_pendingVersion = version;
|
||||||
{
|
PendingUpdate = ApplyUpdateAsync;
|
||||||
// Create backup before the update starts
|
_app.RequestStop();
|
||||||
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)
|
/// <summary>
|
||||||
|
/// Runs the update on a plain console (invoked by the host after the main loop exits).
|
||||||
|
/// Ends by restarting the app and exiting the process, or restoring the backup on failure.
|
||||||
|
/// </summary>
|
||||||
|
private async Task ApplyUpdateAsync()
|
||||||
{
|
{
|
||||||
var fraction = progressPercentage.HasValue ? (float)(progressPercentage.Value / 100.0) : 0f;
|
_applying = true;
|
||||||
var statusText = $"{step}: {itemsProcessed}/{totalItems ?? 0} ({progressPercentage ?? 0:F0}%)";
|
|
||||||
|
|
||||||
if (!progressPercentage.HasValue)
|
// The TUI restored the console on shutdown; make sure the block-glyph bar renders.
|
||||||
|
try { Console.OutputEncoding = Encoding.UTF8; } catch { /* redirected/non-interactive */ }
|
||||||
|
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine($"Updating EchoHub to v{_pendingVersion}...");
|
||||||
|
|
||||||
|
try
|
||||||
{
|
{
|
||||||
statusText = $"{step}...";
|
Console.WriteLine("Creating backup...");
|
||||||
|
UpdateBackupService.CreateBackup();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "Failed to create pre-update backup");
|
||||||
|
Console.WriteLine($"Warning: could not create a backup ({ex.Message}). Continuing without one.");
|
||||||
}
|
}
|
||||||
|
|
||||||
_progressDialog?.UpdateProgress(fraction, statusText);
|
Console.WriteLine("Downloading update...");
|
||||||
|
await _updater.UpdateAsync(); // download → extract → restart → Environment.Exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const int BarWidth = 28;
|
||||||
|
|
||||||
|
private void OnProgressChanged(UpdateStep step, long itemsProcessed, long? totalItems, double? progressPercentage)
|
||||||
|
{
|
||||||
|
// Before the TUI is torn down (i.e. during a check) there is no progress surface; the
|
||||||
|
// real work happens headless after shutdown, so draw a progress bar on the console.
|
||||||
|
if (!_applying)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Finish the previous step's line so each step keeps its completed bar.
|
||||||
|
if (step != _lastStep)
|
||||||
|
{
|
||||||
|
if (_lastStep != (UpdateStep)(-1))
|
||||||
|
Console.WriteLine();
|
||||||
|
_lastStep = step;
|
||||||
|
}
|
||||||
|
|
||||||
|
var label = Humanize(step);
|
||||||
|
|
||||||
|
if (progressPercentage is { } percent)
|
||||||
|
{
|
||||||
|
var pct = (int)Math.Clamp(Math.Round(percent), 0, 100);
|
||||||
|
var filled = pct * BarWidth / 100;
|
||||||
|
var bar = new string('█', filled) + new string('░', BarWidth - filled); // █ / ░
|
||||||
|
Console.Write($"\r {label,-13} [{bar}] {pct,3}% ");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Steps with no measurable total (verifying, restarting): show an indeterminate marker.
|
||||||
|
Console.Write($"\r {label,-13} working... ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Humanize(UpdateStep step) => step switch
|
||||||
|
{
|
||||||
|
UpdateStep.Downloading => "Downloading",
|
||||||
|
UpdateStep.VerifyingChecksum => "Verifying",
|
||||||
|
UpdateStep.Extracting => "Extracting",
|
||||||
|
UpdateStep.CleaningUp => "Cleaning up",
|
||||||
|
UpdateStep.Restarting => "Restarting",
|
||||||
|
_ => step.ToString(),
|
||||||
|
};
|
||||||
|
|
||||||
private void OnUpdateStarted(string version)
|
private void OnUpdateStarted(string version)
|
||||||
{
|
{
|
||||||
Log.Information("Update started: v{Version}", version);
|
Log.Information("Update started: v{Version}", version);
|
||||||
@@ -135,40 +175,40 @@ public sealed class UpdateChecker : IDisposable
|
|||||||
private void OnException(Exception exception)
|
private void OnException(Exception exception)
|
||||||
{
|
{
|
||||||
Log.Error(exception, "Update failed");
|
Log.Error(exception, "Update failed");
|
||||||
_app.Invoke(() =>
|
|
||||||
|
// Headless failure (post-shutdown): report and offer rollback on the console.
|
||||||
|
if (_applying)
|
||||||
{
|
{
|
||||||
_progressDialog?.Close();
|
Console.Error.WriteLine();
|
||||||
_progressDialog = null;
|
Console.Error.WriteLine($"Update failed: {exception.Message}");
|
||||||
|
|
||||||
if (UpdateBackupService.BackupExists())
|
if (UpdateBackupService.BackupExists())
|
||||||
{
|
{
|
||||||
var restore = MessageBox.Query(
|
Console.WriteLine("Restoring the previous version...");
|
||||||
_app,
|
try
|
||||||
"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(); // calls Environment.Exit(0)
|
||||||
{
|
}
|
||||||
UpdateBackupService.RestoreBackup();
|
catch (Exception restoreEx)
|
||||||
// RestoreBackup calls Environment.Exit(0)
|
{
|
||||||
}
|
Log.Error(restoreEx, "Backup restoration failed");
|
||||||
catch (Exception restoreEx)
|
Console.Error.WriteLine($"Restore failed: {restoreEx.Message}. Re-download EchoHub to recover.");
|
||||||
{
|
Environment.Exit(1);
|
||||||
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
|
else
|
||||||
{
|
{
|
||||||
MessageBox.ErrorQuery(_app, "Update Failed",
|
Console.Error.WriteLine("No backup available. Re-download EchoHub if it no longer starts.");
|
||||||
$"The update failed: {exception.Message}\n\nYou may need to re-download the application.", "OK");
|
Environment.Exit(1);
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failure during a check while the TUI is still running.
|
||||||
|
_app.Invoke(() =>
|
||||||
|
{
|
||||||
|
MessageBox.ErrorQuery(_app, "Update Check Failed",
|
||||||
|
$"Could not check for updates: {exception.Message}", "OK");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,5 +219,6 @@ public sealed class UpdateChecker : IDisposable
|
|||||||
_updater.UpdateStarted -= OnUpdateStarted;
|
_updater.UpdateStarted -= OnUpdateStarted;
|
||||||
_updater.NoUpdateAvailable -= OnNoUpdateAvailable;
|
_updater.NoUpdateAvailable -= OnNoUpdateAvailable;
|
||||||
_updater.OnException -= OnException;
|
_updater.OnException -= OnException;
|
||||||
|
_updater.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ public class Theme
|
|||||||
public ThemeColors Menu { get; set; } = new();
|
public ThemeColors Menu { get; set; } = new();
|
||||||
public ThemeColors Dialog { get; set; } = new();
|
public ThemeColors Dialog { get; set; } = new();
|
||||||
public ThemeColors Status { get; set; } = new();
|
public ThemeColors Status { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Colors for the main-window frame borders (and their titles). Null falls back
|
||||||
|
/// to <see cref="Base"/>. Lets themes tone borders down independently of text —
|
||||||
|
/// e.g. the transparent themes use a dim gray for a subtler, glassy look.
|
||||||
|
/// Supports hex values ("#6E6E6E") as well as named colors.
|
||||||
|
/// </summary>
|
||||||
|
public ThemeColors? Border { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ThemeColors
|
public class ThemeColors
|
||||||
|
|||||||
@@ -442,6 +442,14 @@ public static class ThemeManager
|
|||||||
Background = "None",
|
Background = "None",
|
||||||
FocusForeground = "Gray",
|
FocusForeground = "Gray",
|
||||||
FocusBackground = "None"
|
FocusBackground = "None"
|
||||||
|
},
|
||||||
|
// Dim, grayish borders — bright white frames fight the glassy transparent look
|
||||||
|
Border = new ThemeColors
|
||||||
|
{
|
||||||
|
Foreground = "#6E6E6E",
|
||||||
|
Background = "None",
|
||||||
|
FocusForeground = "#8A8A8A",
|
||||||
|
FocusBackground = "None"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -475,6 +483,14 @@ public static class ThemeManager
|
|||||||
Background = "None",
|
Background = "None",
|
||||||
FocusForeground = "DarkGray",
|
FocusForeground = "DarkGray",
|
||||||
FocusBackground = "None"
|
FocusBackground = "None"
|
||||||
|
},
|
||||||
|
// Softer gray borders against light terminal backgrounds
|
||||||
|
Border = new ThemeColors
|
||||||
|
{
|
||||||
|
Foreground = "#8F8F8F",
|
||||||
|
Background = "None",
|
||||||
|
FocusForeground = "#6E6E6E",
|
||||||
|
FocusBackground = "None"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -546,6 +562,8 @@ public static class ThemeManager
|
|||||||
SchemeManager.AddScheme("Base", BuildColorScheme(theme.Base));
|
SchemeManager.AddScheme("Base", BuildColorScheme(theme.Base));
|
||||||
SchemeManager.AddScheme("Menu", BuildColorScheme(theme.Menu));
|
SchemeManager.AddScheme("Menu", BuildColorScheme(theme.Menu));
|
||||||
SchemeManager.AddScheme("Dialog", BuildColorScheme(theme.Dialog));
|
SchemeManager.AddScheme("Dialog", BuildColorScheme(theme.Dialog));
|
||||||
|
// Frame borders/titles; themes without an explicit Border section keep Base
|
||||||
|
SchemeManager.AddScheme("Border", BuildColorScheme(theme.Border ?? theme.Base));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void SaveTheme(Theme theme)
|
public static void SaveTheme(Theme theme)
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ public static partial class ChatColors
|
|||||||
public static readonly Attribute AudioAttr = new(new Color(180, 100, 255), 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);
|
public static readonly Attribute FileAttr = new(new Color(100, 180, 255), Color.None);
|
||||||
|
|
||||||
|
/// <summary>The dim vertical rail (│) separating the nick column from message text.</summary>
|
||||||
|
public static readonly Attribute RailAttr = new(new Color(95, 95, 95), Color.None);
|
||||||
|
|
||||||
|
/// <summary>Horizontal date-separator rules (── Wed, Jul 16 ──…).</summary>
|
||||||
|
public static readonly Attribute DateRuleAttr = new(new Color(120, 120, 120), Color.None);
|
||||||
|
|
||||||
|
/// <summary>The irssi-style "new messages" unread marker rule.</summary>
|
||||||
|
public static readonly Attribute UnreadMarkerAttr = new(new Color(230, 140, 60), Color.None);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Split text around @mentions and #channels, giving each the appropriate accent color.
|
/// Split text around @mentions and #channels, giving each the appropriate accent color.
|
||||||
/// Non-special text uses the provided default color.
|
/// Non-special text uses the provided default color.
|
||||||
|
|||||||
@@ -23,6 +23,25 @@ public partial class ChatLine
|
|||||||
/// <summary>Number of spaces to prepend on continuation lines when this line is word-wrapped.</summary>
|
/// <summary>Number of spaces to prepend on continuation lines when this line is word-wrapped.</summary>
|
||||||
public int ContinuationIndent { get; set; }
|
public int ContinuationIndent { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Colored segments to prepend on continuation lines instead of plain spaces
|
||||||
|
/// (e.g. the nick-column rail " │ "). When set, takes
|
||||||
|
/// precedence over <see cref="ContinuationIndent"/>.
|
||||||
|
/// </summary>
|
||||||
|
public List<ChatSegment>? ContinuationPrefixSegments { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When set, this line is a horizontal separator rule (date change, unread marker).
|
||||||
|
/// The view regenerates it to the current viewport width instead of word-wrapping.
|
||||||
|
/// </summary>
|
||||||
|
public string? RuleLabel { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Color for a rule line; null falls back to <see cref="ChatColors.DateRuleAttr"/>.</summary>
|
||||||
|
public Attribute? RuleAttr { get; set; }
|
||||||
|
|
||||||
|
/// <summary>True for the "new messages" unread-marker rule so it can be removed on channel switch.</summary>
|
||||||
|
public bool IsUnreadMarker { get; set; }
|
||||||
|
|
||||||
public ChatLine(string plainText)
|
public ChatLine(string plainText)
|
||||||
{
|
{
|
||||||
Segments = [new ChatSegment(plainText, null)];
|
Segments = [new ChatSegment(plainText, null)];
|
||||||
@@ -43,9 +62,14 @@ public partial class ChatLine
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public List<ChatLine> Wrap(int width, int continuationIndent = 0)
|
public List<ChatLine> Wrap(int width, int continuationIndent = 0)
|
||||||
{
|
{
|
||||||
if (width <= 0 || TextLength <= width)
|
// Rules are regenerated to viewport width by the view; never word-wrap them.
|
||||||
|
if (RuleLabel is not null || width <= 0 || TextLength <= width)
|
||||||
return [this];
|
return [this];
|
||||||
|
|
||||||
|
var prefixSegments = ContinuationPrefixSegments;
|
||||||
|
if (prefixSegments is not null)
|
||||||
|
continuationIndent = prefixSegments.Sum(s => s.Text.GetColumns());
|
||||||
|
|
||||||
var tokens = new List<(string grapheme, Attribute? color)>();
|
var tokens = new List<(string grapheme, Attribute? color)>();
|
||||||
foreach (var segment in Segments)
|
foreach (var segment in Segments)
|
||||||
foreach (var g in GraphemeHelper.GetGraphemes(segment.Text))
|
foreach (var g in GraphemeHelper.GetGraphemes(segment.Text))
|
||||||
@@ -91,8 +115,13 @@ public partial class ChatLine
|
|||||||
}
|
}
|
||||||
|
|
||||||
var segments = new List<ChatSegment>();
|
var segments = new List<ChatSegment>();
|
||||||
if (!firstLine && continuationIndent > 0)
|
if (!firstLine)
|
||||||
segments.Add(new ChatSegment(new string(' ', continuationIndent), null));
|
{
|
||||||
|
if (prefixSegments is not null)
|
||||||
|
segments.AddRange(prefixSegments);
|
||||||
|
else if (continuationIndent > 0)
|
||||||
|
segments.Add(new ChatSegment(new string(' ', continuationIndent), null));
|
||||||
|
}
|
||||||
|
|
||||||
// Rebuild segments by grouping consecutive same-color tokens.
|
// Rebuild segments by grouping consecutive same-color tokens.
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
@@ -120,6 +149,7 @@ public partial class ChatLine
|
|||||||
return [this];
|
return [this];
|
||||||
|
|
||||||
// Propagate metadata to all wrapped lines so they remain clickable
|
// Propagate metadata to all wrapped lines so they remain clickable
|
||||||
|
// and keep the mention highlight across continuation lines
|
||||||
foreach (var wrapped in results)
|
foreach (var wrapped in results)
|
||||||
{
|
{
|
||||||
wrapped.AttachmentUrl = AttachmentUrl;
|
wrapped.AttachmentUrl = AttachmentUrl;
|
||||||
@@ -127,6 +157,7 @@ public partial class ChatLine
|
|||||||
wrapped.AttachmentKind = AttachmentKind;
|
wrapped.AttachmentKind = AttachmentKind;
|
||||||
wrapped.MessageId = MessageId;
|
wrapped.MessageId = MessageId;
|
||||||
wrapped.SenderUsername = SenderUsername;
|
wrapped.SenderUsername = SenderUsername;
|
||||||
|
wrapped.IsMention = IsMention;
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using EchoHub.Client.UI.Helpers;
|
using EchoHub.Client.UI.Helpers;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
@@ -15,8 +16,20 @@ namespace EchoHub.Client.UI.Chat;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ChatMessageManager
|
public sealed class ChatMessageManager
|
||||||
{
|
{
|
||||||
|
/// <summary>Columns reserved for the right-aligned nick column (WeeChat-style).</summary>
|
||||||
|
public const int NickColWidth = 12;
|
||||||
|
|
||||||
|
/// <summary>Columns before message text starts: "HH:mm " + nick column + " │ ".</summary>
|
||||||
|
public const int ContentIndentCols = 6 + NickColWidth + 3;
|
||||||
|
|
||||||
private readonly Dictionary<string, List<ChatLine>> _channelMessages = [];
|
private readonly Dictionary<string, List<ChatLine>> _channelMessages = [];
|
||||||
private readonly Dictionary<string, int> _channelUnread = [];
|
private readonly Dictionary<string, int> _channelUnread = [];
|
||||||
|
private readonly Dictionary<string, DateTime> _channelLastDate = [];
|
||||||
|
private readonly HashSet<string> _markedChannels = [];
|
||||||
|
private readonly Dictionary<string, Guid> _markerAnchor = [];
|
||||||
|
private readonly HashSet<string> _mentionChannels = [];
|
||||||
|
private readonly Dictionary<string, Guid> _lastRead = [];
|
||||||
|
private readonly Dictionary<string, Guid> _channelNewestId = [];
|
||||||
|
|
||||||
private string _currentUser = string.Empty;
|
private string _currentUser = string.Empty;
|
||||||
private string _currentChannel = string.Empty;
|
private string _currentChannel = string.Empty;
|
||||||
@@ -33,7 +46,18 @@ public sealed class ChatMessageManager
|
|||||||
public string CurrentChannel
|
public string CurrentChannel
|
||||||
{
|
{
|
||||||
get => _currentChannel;
|
get => _currentChannel;
|
||||||
set => _currentChannel = value;
|
set
|
||||||
|
{
|
||||||
|
if (_currentChannel == value)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Leaving a channel consumes its "new messages" marker so the next
|
||||||
|
// unread burst gets a fresh one (irssi behavior), and everything
|
||||||
|
// visible up to now counts as read.
|
||||||
|
RemoveUnreadMarker(_currentChannel);
|
||||||
|
MarkRead(_currentChannel);
|
||||||
|
_currentChannel = value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public string CurrentUser => _currentUser;
|
public string CurrentUser => _currentUser;
|
||||||
@@ -57,10 +81,27 @@ public sealed class ChatMessageManager
|
|||||||
public void ClearUnread(string channelName)
|
public void ClearUnread(string channelName)
|
||||||
{
|
{
|
||||||
_channelUnread[channelName] = 0;
|
_channelUnread[channelName] = 0;
|
||||||
|
_mentionChannels.Remove(channelName);
|
||||||
|
MarkRead(channelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Last message the user has read per channel — persisted by the orchestrator so
|
||||||
|
/// unread/mention state can be seeded from history on the next connect.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyDictionary<string, Guid> LastReadIds => _lastRead;
|
||||||
|
|
||||||
|
private void MarkRead(string channelName)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(channelName) && _channelNewestId.TryGetValue(channelName, out var newest))
|
||||||
|
_lastRead[channelName] = newest;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal Dictionary<string, int> GetUnreadCounts() => _channelUnread;
|
internal Dictionary<string, int> GetUnreadCounts() => _channelUnread;
|
||||||
|
|
||||||
|
/// <summary>Channels with an unread @mention of the current user (cleared by <see cref="ClearUnread"/>).</summary>
|
||||||
|
public IReadOnlySet<string> MentionChannels => _mentionChannels;
|
||||||
|
|
||||||
// ── Mutations ────────────────────────────────────────────────────
|
// ── Mutations ────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -75,19 +116,39 @@ public sealed class ChatMessageManager
|
|||||||
_channelMessages[message.ChannelName] = messages;
|
_channelMessages[message.ChannelName] = messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Day boundary → horizontal date rule
|
||||||
|
var msgDate = message.SentAt.ToLocalTime().Date;
|
||||||
|
if (!_channelLastDate.TryGetValue(message.ChannelName, out var lastDate) || lastDate != msgDate)
|
||||||
|
{
|
||||||
|
messages.Add(DateRule(msgDate));
|
||||||
|
_channelLastDate[message.ChannelName] = msgDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
var isCurrent = message.ChannelName == _currentChannel;
|
||||||
|
_channelNewestId[message.ChannelName] = message.Id;
|
||||||
|
if (isCurrent)
|
||||||
|
_lastRead[message.ChannelName] = message.Id;
|
||||||
|
|
||||||
|
// First unread message in an inactive channel → "new messages" marker,
|
||||||
|
// anchored to this message so a history reload can re-place it
|
||||||
|
if (!isCurrent && _markedChannels.Add(message.ChannelName))
|
||||||
|
{
|
||||||
|
messages.Add(UnreadMarkerRule());
|
||||||
|
_markerAnchor[message.ChannelName] = message.Id;
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var line in lines)
|
foreach (var line in lines)
|
||||||
messages.Add(line);
|
messages.Add(line);
|
||||||
|
|
||||||
if (message.ChannelName == _currentChannel)
|
if (!isCurrent)
|
||||||
{
|
|
||||||
MessagesChanged?.Invoke(message.ChannelName);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
_channelUnread.TryGetValue(message.ChannelName, out var count);
|
_channelUnread.TryGetValue(message.ChannelName, out var count);
|
||||||
_channelUnread[message.ChannelName] = count + 1;
|
_channelUnread[message.ChannelName] = count + 1;
|
||||||
MessagesChanged?.Invoke(message.ChannelName);
|
if (lines.Any(l => l.IsMention))
|
||||||
|
_mentionChannels.Add(message.ChannelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MessagesChanged?.Invoke(message.ChannelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -101,24 +162,20 @@ public sealed class ChatMessageManager
|
|||||||
_channelMessages[channelName] = messages;
|
_channelMessages[channelName] = messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
var time = FormatDateTime(DateTimeOffset.Now);
|
var time = FormatTime(DateTimeOffset.Now);
|
||||||
var textLines = text.Split('\n');
|
var textLines = text.Split('\n');
|
||||||
|
|
||||||
messages.Add(new ChatLine(
|
var header = SystemHeaderSegments(time);
|
||||||
[
|
header.Add(new(textLines[0].TrimEnd('\r'), ChatColors.SystemAttr));
|
||||||
new($"[{time}] ", ChatColors.TimestampAttr),
|
messages.Add(new ChatLine(header) { ContinuationPrefixSegments = RailPrefix() });
|
||||||
new($"** {textLines[0].TrimEnd('\r')}", ChatColors.SystemAttr)
|
|
||||||
]));
|
|
||||||
|
|
||||||
var indent = new string(' ', $"[{time}] ** ".Length);
|
|
||||||
for (int i = 1; i < textLines.Length; i++)
|
for (int i = 1; i < textLines.Length; i++)
|
||||||
{
|
{
|
||||||
var line = textLines[i].TrimEnd('\r');
|
var line = textLines[i].TrimEnd('\r');
|
||||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||||
messages.Add(new ChatLine(
|
var segments = RailPrefix();
|
||||||
[
|
segments.Add(new(line, ChatColors.SystemAttr));
|
||||||
new($"{indent}{line}", ChatColors.SystemAttr)
|
messages.Add(new ChatLine(segments) { ContinuationPrefixSegments = RailPrefix() });
|
||||||
]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (channelName == _currentChannel)
|
if (channelName == _currentChannel)
|
||||||
@@ -130,19 +187,16 @@ public sealed class ChatMessageManager
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public void AddStatusMessage(string channelName, string username, string status)
|
public void AddStatusMessage(string channelName, string username, string status)
|
||||||
{
|
{
|
||||||
var time = FormatDateTime(DateTimeOffset.Now);
|
var time = FormatTime(DateTimeOffset.Now);
|
||||||
var segments = new List<ChatSegment>
|
var segments = SystemHeaderSegments(time);
|
||||||
{
|
segments.Add(new($"{username} is now {status}", ChatColors.SystemAttr));
|
||||||
new($"[{time}] ", ChatColors.TimestampAttr),
|
|
||||||
new($"** {username} is now {status}", ChatColors.SystemAttr)
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!_channelMessages.TryGetValue(channelName, out var messages))
|
if (!_channelMessages.TryGetValue(channelName, out var messages))
|
||||||
{
|
{
|
||||||
messages = [];
|
messages = [];
|
||||||
_channelMessages[channelName] = messages;
|
_channelMessages[channelName] = messages;
|
||||||
}
|
}
|
||||||
messages.Add(new ChatLine(segments));
|
messages.Add(new ChatLine(segments) { ContinuationPrefixSegments = RailPrefix() });
|
||||||
|
|
||||||
if (channelName == _currentChannel)
|
if (channelName == _currentChannel)
|
||||||
MessagesChanged?.Invoke(channelName);
|
MessagesChanged?.Invoke(channelName);
|
||||||
@@ -169,6 +223,9 @@ public sealed class ChatMessageManager
|
|||||||
if (_channelMessages.TryGetValue(channelName, out var messages))
|
if (_channelMessages.TryGetValue(channelName, out var messages))
|
||||||
{
|
{
|
||||||
messages.Clear();
|
messages.Clear();
|
||||||
|
_channelLastDate.Remove(channelName);
|
||||||
|
_markedChannels.Remove(channelName);
|
||||||
|
_markerAnchor.Remove(channelName);
|
||||||
if (channelName == _currentChannel)
|
if (channelName == _currentChannel)
|
||||||
MessagesChanged?.Invoke(channelName);
|
MessagesChanged?.Invoke(channelName);
|
||||||
}
|
}
|
||||||
@@ -176,14 +233,88 @@ public sealed class ChatMessageManager
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Load historical messages into a channel, replacing any existing messages.
|
/// Load historical messages into a channel, replacing any existing messages.
|
||||||
|
/// When <paramref name="lastReadId"/> is given (persisted from a previous session),
|
||||||
|
/// messages after it seed the unread count, @mention highlight, and the
|
||||||
|
/// "new messages" marker — so activity that happened while offline still lights up.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void LoadHistory(string channelName, List<MessageDto> messages)
|
public void LoadHistory(string channelName, List<MessageDto> messages, Guid? lastReadId = null)
|
||||||
{
|
{
|
||||||
var formatted = messages.SelectMany(FormatMessage).ToList();
|
var formatted = FormatWithDateRules(messages, out var lastDate);
|
||||||
|
|
||||||
|
if (messages.Count > 0)
|
||||||
|
_channelNewestId[channelName] = messages[^1].Id;
|
||||||
|
|
||||||
|
// Re-place the "new messages" marker at its anchor — channel selection
|
||||||
|
// reloads history, which would otherwise wipe the marker right when the
|
||||||
|
// user switches in to read the unread backlog.
|
||||||
|
if (_markedChannels.Contains(channelName))
|
||||||
|
{
|
||||||
|
var anchorIdx = _markerAnchor.TryGetValue(channelName, out var anchorId)
|
||||||
|
? formatted.FindIndex(l => l.MessageId == anchorId)
|
||||||
|
: -1;
|
||||||
|
if (anchorIdx >= 0)
|
||||||
|
{
|
||||||
|
formatted.Insert(anchorIdx, UnreadMarkerRule());
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Anchor fell outside the fetched history window — drop the marker
|
||||||
|
_markedChannels.Remove(channelName);
|
||||||
|
_markerAnchor.Remove(channelName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (lastReadId is { } lastRead && messages.Count > 0)
|
||||||
|
{
|
||||||
|
SeedUnreadFromHistory(channelName, messages, formatted, lastRead);
|
||||||
|
}
|
||||||
|
|
||||||
_channelMessages[channelName] = formatted;
|
_channelMessages[channelName] = formatted;
|
||||||
|
|
||||||
|
if (lastDate is { } date)
|
||||||
|
_channelLastDate[channelName] = date;
|
||||||
|
else
|
||||||
|
_channelLastDate.Remove(channelName);
|
||||||
|
|
||||||
|
MessagesChanged?.Invoke(channelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reconstructs unread state from a persisted last-read message id: places the
|
||||||
|
/// "new messages" marker before the first unread message and, for inactive
|
||||||
|
/// channels, seeds the unread count and @mention highlight. A last-read id that
|
||||||
|
/// is no longer inside the fetched window treats the whole window as unread.
|
||||||
|
/// </summary>
|
||||||
|
private void SeedUnreadFromHistory(string channelName, List<MessageDto> messages,
|
||||||
|
List<ChatLine> formatted, Guid lastReadId)
|
||||||
|
{
|
||||||
|
// FindIndex miss (-1 → 0) means the last-read message is older than the fetched
|
||||||
|
// window: everything in the window counts as unread.
|
||||||
|
var firstUnread = messages.FindIndex(m => m.Id == lastReadId) + 1;
|
||||||
|
if (firstUnread >= messages.Count)
|
||||||
|
return; // everything read
|
||||||
|
|
||||||
|
var anchor = messages[firstUnread];
|
||||||
|
var lineIdx = formatted.FindIndex(l => l.MessageId == anchor.Id);
|
||||||
|
if (lineIdx < 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
formatted.Insert(lineIdx, UnreadMarkerRule());
|
||||||
|
_markedChannels.Add(channelName);
|
||||||
|
_markerAnchor[channelName] = anchor.Id;
|
||||||
|
|
||||||
|
// The active channel shows the marker but is being read right now —
|
||||||
|
// badges and mention highlights are only for background channels.
|
||||||
if (channelName == _currentChannel)
|
if (channelName == _currentChannel)
|
||||||
MessagesChanged?.Invoke(channelName);
|
return;
|
||||||
|
|
||||||
|
_channelUnread[channelName] = messages.Count - firstUnread;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(_currentUser))
|
||||||
|
{
|
||||||
|
var pattern = $@"@{Regex.Escape(_currentUser)}\b";
|
||||||
|
if (messages.Skip(firstUnread).Any(m => Regex.IsMatch(m.Content, pattern, RegexOptions.IgnoreCase)))
|
||||||
|
_mentionChannels.Add(channelName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -200,14 +331,23 @@ public sealed class ChatMessageManager
|
|||||||
.Select(l => l.MessageId!.Value)
|
.Select(l => l.MessageId!.Value)
|
||||||
.ToHashSet();
|
.ToHashSet();
|
||||||
|
|
||||||
var newLines = olderMessages
|
var fresh = olderMessages.Where(m => !existingIds.Contains(m.Id)).ToList();
|
||||||
.Where(m => !existingIds.Contains(m.Id))
|
var newLines = FormatWithDateRules(fresh, out var lastBatchDate);
|
||||||
.SelectMany(FormatMessage)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
if (newLines.Count == 0)
|
if (newLines.Count == 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
// The buffer's leading date rule is redundant when the prepended batch
|
||||||
|
// ends on the same day — the batch already carries that day's rule.
|
||||||
|
if (lastBatchDate is { } batchDate
|
||||||
|
&& existing.Count > 0
|
||||||
|
&& existing[0].RuleLabel is { } label
|
||||||
|
&& !existing[0].IsUnreadMarker
|
||||||
|
&& label == DateRuleLabel(batchDate))
|
||||||
|
{
|
||||||
|
existing.RemoveAt(0);
|
||||||
|
}
|
||||||
|
|
||||||
existing.InsertRange(0, newLines);
|
existing.InsertRange(0, newLines);
|
||||||
|
|
||||||
if (channelName == _currentChannel)
|
if (channelName == _currentChannel)
|
||||||
@@ -226,6 +366,12 @@ public sealed class ChatMessageManager
|
|||||||
{
|
{
|
||||||
_channelMessages.Clear();
|
_channelMessages.Clear();
|
||||||
_channelUnread.Clear();
|
_channelUnread.Clear();
|
||||||
|
_channelLastDate.Clear();
|
||||||
|
_markedChannels.Clear();
|
||||||
|
_markerAnchor.Clear();
|
||||||
|
_mentionChannels.Clear();
|
||||||
|
_lastRead.Clear();
|
||||||
|
_channelNewestId.Clear();
|
||||||
_currentChannel = string.Empty;
|
_currentChannel = string.Empty;
|
||||||
_currentUser = string.Empty;
|
_currentUser = string.Empty;
|
||||||
}
|
}
|
||||||
@@ -234,12 +380,9 @@ public sealed class ChatMessageManager
|
|||||||
|
|
||||||
private List<ChatLine> FormatMessage(MessageDto message)
|
private List<ChatLine> FormatMessage(MessageDto message)
|
||||||
{
|
{
|
||||||
var time = FormatDateTime(message.SentAt);
|
var time = FormatTime(message.SentAt);
|
||||||
var senderName = message.SenderUsername + ":";
|
var senderColor = HexColorHelper.ParseHexColor(message.SenderNicknameColor)
|
||||||
var senderColor = HexColorHelper.ParseHexColor(message.SenderNicknameColor);
|
?? NickColorHelper.GetAttribute(message.SenderUsername);
|
||||||
|
|
||||||
var indent = new string(' ', $"[{time}] {senderName} ".Length);
|
|
||||||
var pad = new string(' ', 7);
|
|
||||||
|
|
||||||
var lines = new List<ChatLine>();
|
var lines = new List<ChatLine>();
|
||||||
var hasContent = !string.IsNullOrWhiteSpace(message.Content);
|
var hasContent = !string.IsNullOrWhiteSpace(message.Content);
|
||||||
@@ -250,25 +393,35 @@ public sealed class ChatMessageManager
|
|||||||
{
|
{
|
||||||
var displayContent = EmojiHelper.ReplaceEmoji(message.Content);
|
var displayContent = EmojiHelper.ReplaceEmoji(message.Content);
|
||||||
var contentLines = displayContent.Split('\n');
|
var contentLines = displayContent.Split('\n');
|
||||||
lines.Add(BuildChatLineWithMentions(time, senderName, senderColor, $" {contentLines[0].TrimEnd('\r')}"));
|
|
||||||
|
var header = HeaderSegments(time, message.SenderUsername, senderColor);
|
||||||
|
header.AddRange(ChatColors.SplitMentions(contentLines[0].TrimEnd('\r')));
|
||||||
|
lines.Add(new ChatLine(header));
|
||||||
|
|
||||||
for (int i = 1; i < contentLines.Length; i++)
|
for (int i = 1; i < contentLines.Length; i++)
|
||||||
lines.Add(new ChatLine(ChatColors.SplitMentions($"{indent}{contentLines[i].TrimEnd('\r')}")));
|
{
|
||||||
|
var segments = RailPrefix();
|
||||||
|
segments.AddRange(ChatColors.SplitMentions(contentLines[i].TrimEnd('\r')));
|
||||||
|
lines.Add(new ChatLine(segments));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var summary = attachments.Count switch
|
var summary = attachments.Count switch
|
||||||
{
|
{
|
||||||
0 => " ",
|
0 => " ",
|
||||||
1 => $" [{attachments[0].Kind.ToString().ToLowerInvariant()}]",
|
1 => $"[{attachments[0].Kind.ToString().ToLowerInvariant()}]",
|
||||||
_ => $" [{attachments.Count} attachments]",
|
_ => $"[{attachments.Count} attachments]",
|
||||||
};
|
};
|
||||||
lines.Add(BuildChatLine(time, senderName, senderColor, summary));
|
var header = HeaderSegments(time, message.SenderUsername, senderColor);
|
||||||
|
header.Add(new(summary, null));
|
||||||
|
lines.Add(new ChatLine(header));
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var l in lines)
|
foreach (var l in lines)
|
||||||
l.ContinuationIndent = indent.Length;
|
l.ContinuationPrefixSegments = RailPrefix();
|
||||||
|
|
||||||
// One block per attachment
|
// One block per attachment — every block hangs off the nick-column rail
|
||||||
foreach (var attachment in attachments)
|
foreach (var attachment in attachments)
|
||||||
{
|
{
|
||||||
switch (attachment.Kind)
|
switch (attachment.Kind)
|
||||||
@@ -279,24 +432,27 @@ public sealed class ChatMessageManager
|
|||||||
foreach (var artLine in attachment.AsciiPreview.Split('\n'))
|
foreach (var artLine in attachment.AsciiPreview.Split('\n'))
|
||||||
{
|
{
|
||||||
var trimmed = artLine.TrimEnd('\r');
|
var trimmed = artLine.TrimEnd('\r');
|
||||||
lines.Add(ChatLine.HasColorTags(trimmed)
|
var segments = RailPrefix();
|
||||||
? ChatLine.FromColoredText(pad + trimmed)
|
if (ChatLine.HasColorTags(trimmed))
|
||||||
: new ChatLine($"{pad}{trimmed}"));
|
segments.AddRange(ChatLine.FromColoredText(trimmed).Segments);
|
||||||
|
else
|
||||||
|
segments.Add(new(trimmed, null));
|
||||||
|
lines.Add(new ChatLine(segments));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lines.Add(AttachmentActionLine(pad,
|
lines.Add(AttachmentActionLine(
|
||||||
$"[↓ save original] {attachment.FileName} [{FormatFileSize(attachment.FileSize)}]",
|
$"[↓ save original] {attachment.FileName} [{FormatFileSize(attachment.FileSize)}]",
|
||||||
ChatColors.FileAttr, attachment));
|
ChatColors.FileAttr, attachment));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case Core.Models.AttachmentKind.Audio:
|
case Core.Models.AttachmentKind.Audio:
|
||||||
lines.Add(AttachmentActionLine(pad,
|
lines.Add(AttachmentActionLine(
|
||||||
$"♪ [Audio: {attachment.FileName}] [{FormatFileSize(attachment.FileSize)}]",
|
$"♪ [Audio: {attachment.FileName}] [{FormatFileSize(attachment.FileSize)}]",
|
||||||
ChatColors.AudioAttr, attachment));
|
ChatColors.AudioAttr, attachment));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
lines.Add(AttachmentActionLine(pad,
|
lines.Add(AttachmentActionLine(
|
||||||
$"[File: {attachment.FileName}] [{FormatFileSize(attachment.FileSize)}]",
|
$"[File: {attachment.FileName}] [{FormatFileSize(attachment.FileSize)}]",
|
||||||
ChatColors.FileAttr, attachment));
|
ChatColors.FileAttr, attachment));
|
||||||
break;
|
break;
|
||||||
@@ -308,7 +464,7 @@ public sealed class ChatMessageManager
|
|||||||
{
|
{
|
||||||
var chatWidth = _chatWidth > 0 ? _chatWidth : 80;
|
var chatWidth = _chatWidth > 0 ? _chatWidth : 80;
|
||||||
foreach (var embed in message.Embeds)
|
foreach (var embed in message.Embeds)
|
||||||
lines.AddRange(FormatEmbed(embed, indent, chatWidth));
|
lines.AddRange(FormatEmbed(embed, chatWidth));
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var line in lines)
|
foreach (var line in lines)
|
||||||
@@ -334,71 +490,142 @@ public sealed class ChatMessageManager
|
|||||||
/// Builds a clickable attachment line carrying the metadata the message list uses to
|
/// Builds a clickable attachment line carrying the metadata the message list uses to
|
||||||
/// route activation (play audio, download file, save original image).
|
/// route activation (play audio, download file, save original image).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static ChatLine AttachmentActionLine(string pad, string text, Attribute color, AttachmentDto attachment)
|
private static ChatLine AttachmentActionLine(string text, Attribute color, AttachmentDto attachment)
|
||||||
{
|
{
|
||||||
var line = new ChatLine(new List<ChatSegment>
|
var segments = RailPrefix();
|
||||||
|
segments.Add(new(text, color));
|
||||||
|
return new ChatLine(segments)
|
||||||
{
|
{
|
||||||
new(pad, null),
|
AttachmentUrl = attachment.Url,
|
||||||
new(text, color),
|
AttachmentFileName = attachment.FileName,
|
||||||
});
|
AttachmentKind = attachment.Kind,
|
||||||
line.AttachmentUrl = attachment.Url;
|
ContinuationPrefixSegments = RailPrefix(),
|
||||||
line.AttachmentFileName = attachment.FileName;
|
|
||||||
line.AttachmentKind = attachment.Kind;
|
|
||||||
return line;
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
/// <summary>
|
||||||
|
/// Leading segments of a message header line: dim "HH:mm ", the right-aligned
|
||||||
|
/// nick column, and the " │ " rail. Message text follows at <see cref="ContentIndentCols"/>.
|
||||||
|
/// </summary>
|
||||||
|
private static List<ChatSegment> HeaderSegments(string time, string nick, Attribute? nickColor) =>
|
||||||
|
[
|
||||||
|
new($"{time} ", ChatColors.TimestampAttr),
|
||||||
|
new(PadNick(nick), nickColor),
|
||||||
|
new(" │ ", ChatColors.RailAttr),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>Header variant for system/status lines: "--" in the nick column.</summary>
|
||||||
|
private static List<ChatSegment> SystemHeaderSegments(string time) =>
|
||||||
|
[
|
||||||
|
new($"{time} ", ChatColors.TimestampAttr),
|
||||||
|
new(PadNick("--"), ChatColors.TimestampAttr),
|
||||||
|
new(" │ ", ChatColors.RailAttr),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indent segments aligning continuation/attachment/embed lines under the message
|
||||||
|
/// text, extending the │ rail. Returns a fresh mutable list each call.
|
||||||
|
/// </summary>
|
||||||
|
private static List<ChatSegment> RailPrefix() =>
|
||||||
|
[
|
||||||
|
new(new string(' ', 6 + NickColWidth + 1), null),
|
||||||
|
new("│ ", ChatColors.RailAttr),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Right-aligns a nick into the fixed nick column, truncating over-long nicks
|
||||||
|
/// with an ellipsis. Grapheme/column aware.
|
||||||
|
/// </summary>
|
||||||
|
internal static string PadNick(string nick)
|
||||||
{
|
{
|
||||||
var segments = new List<ChatSegment>
|
var cols = nick.GetColumns();
|
||||||
|
if (cols > NickColWidth)
|
||||||
{
|
{
|
||||||
new($"[{time}] ", ChatColors.TimestampAttr),
|
var sb = new StringBuilder();
|
||||||
new(senderName, senderColor),
|
int used = 0;
|
||||||
new(suffix, suffixColor)
|
foreach (var g in GraphemeHelper.GetGraphemes(nick))
|
||||||
};
|
{
|
||||||
return new ChatLine(segments);
|
var gCols = Math.Max(g.GetColumns(), 1);
|
||||||
|
if (used + gCols > NickColWidth - 1) break;
|
||||||
|
sb.Append(g);
|
||||||
|
used += gCols;
|
||||||
|
}
|
||||||
|
sb.Append('…');
|
||||||
|
nick = sb.ToString();
|
||||||
|
cols = used + 1;
|
||||||
|
}
|
||||||
|
return new string(' ', NickColWidth - cols) + nick;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ChatLine BuildChatLineWithMentions(string time, string senderName, Attribute? senderColor, string suffix)
|
internal static string DateRuleLabel(DateTime date) => date.ToString("ddd, MMM d yyyy");
|
||||||
|
|
||||||
|
private static ChatLine DateRule(DateTime date)
|
||||||
{
|
{
|
||||||
var segments = new List<ChatSegment>
|
var label = DateRuleLabel(date);
|
||||||
|
return new ChatLine([new($"── {label} ──", ChatColors.DateRuleAttr)])
|
||||||
{
|
{
|
||||||
new($"[{time}] ", ChatColors.TimestampAttr),
|
RuleLabel = label,
|
||||||
new(senderName, senderColor),
|
RuleAttr = ChatColors.DateRuleAttr,
|
||||||
};
|
};
|
||||||
segments.AddRange(ChatColors.SplitMentions(suffix));
|
|
||||||
return new ChatLine(segments);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<ChatLine> FormatEmbed(EmbedDto embed, string indent, int chatWidth)
|
private static ChatLine UnreadMarkerRule() =>
|
||||||
|
new([new("── new messages ──", ChatColors.UnreadMarkerAttr)])
|
||||||
|
{
|
||||||
|
RuleLabel = "new messages",
|
||||||
|
RuleAttr = ChatColors.UnreadMarkerAttr,
|
||||||
|
IsUnreadMarker = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
private void RemoveUnreadMarker(string channel)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(channel) || !_markedChannels.Remove(channel))
|
||||||
|
return;
|
||||||
|
|
||||||
|
_markerAnchor.Remove(channel);
|
||||||
|
if (_channelMessages.TryGetValue(channel, out var messages))
|
||||||
|
messages.RemoveAll(l => l.IsUnreadMarker);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Formats a chronological batch of messages, inserting a date rule before the
|
||||||
|
/// first message and at every day boundary. Outputs the batch's last local date.
|
||||||
|
/// </summary>
|
||||||
|
private List<ChatLine> FormatWithDateRules(List<MessageDto> messages, out DateTime? lastDate)
|
||||||
|
{
|
||||||
|
var lines = new List<ChatLine>();
|
||||||
|
lastDate = null;
|
||||||
|
|
||||||
|
foreach (var message in messages)
|
||||||
|
{
|
||||||
|
var date = message.SentAt.ToLocalTime().Date;
|
||||||
|
if (lastDate != date)
|
||||||
|
{
|
||||||
|
lines.Add(DateRule(date));
|
||||||
|
lastDate = date;
|
||||||
|
}
|
||||||
|
lines.AddRange(FormatMessage(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<ChatLine> FormatEmbed(EmbedDto embed, int chatWidth)
|
||||||
{
|
{
|
||||||
var lines = new List<ChatLine>();
|
var lines = new List<ChatLine>();
|
||||||
const string border = "\u258f "; // ▏ + space
|
const string border = "\u258f "; // ▏ + space
|
||||||
const int borderCols = 2;
|
const int borderCols = 2;
|
||||||
int indentCols = indent.GetColumns();
|
int textWidth = chatWidth - ContentIndentCols - borderCols;
|
||||||
int textWidth = chatWidth - indentCols - borderCols;
|
|
||||||
if (textWidth < 20) textWidth = 20;
|
if (textWidth < 20) textWidth = 20;
|
||||||
|
|
||||||
var borderAttr = HexColorHelper.ParseHexColor(embed.ThemeColor) ?? ChatColors.EmbedBorderAttr;
|
var borderAttr = HexColorHelper.ParseHexColor(embed.ThemeColor) ?? ChatColors.EmbedBorderAttr;
|
||||||
|
|
||||||
void AddTextLine(string text, Attribute? color)
|
void AddTextLine(string text, Attribute? color)
|
||||||
{
|
{
|
||||||
lines.Add(new ChatLine(
|
var segments = RailPrefix();
|
||||||
[
|
segments.Add(new ChatSegment(border, borderAttr));
|
||||||
new ChatSegment(indent, null),
|
segments.Add(new ChatSegment(text, color));
|
||||||
new ChatSegment(border, borderAttr),
|
lines.Add(new ChatLine(segments));
|
||||||
new ChatSegment(text, color)
|
|
||||||
]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(embed.SiteName))
|
if (!string.IsNullOrWhiteSpace(embed.SiteName))
|
||||||
@@ -449,13 +676,11 @@ public sealed class ChatMessageManager
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string FormatDateTime(DateTimeOffset timestamp)
|
// Timestamps are compact HH:mm — the calendar day is carried by date rules,
|
||||||
{
|
// inserted at every local-day boundary. Convert to local first so a message
|
||||||
if (timestamp.Date == DateTimeOffset.Now.Date)
|
// near midnight lands under the right date rule.
|
||||||
return timestamp.ToLocalTime().ToString("t");
|
private static string FormatTime(DateTimeOffset timestamp) =>
|
||||||
else
|
timestamp.ToLocalTime().ToString("HH:mm");
|
||||||
return timestamp.ToLocalTime().ToString("g");
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static string FormatFileSize(long? bytes)
|
internal static string FormatFileSize(long? bytes)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
using Terminal.Gui.Drawing;
|
||||||
|
using Terminal.Gui.Text;
|
||||||
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI.Chat;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The MOTD-style splash rendered into the chat pane when no channel is selected —
|
||||||
|
/// a gold-gradient ASCII logo with version and key hints, in the spirit of classic
|
||||||
|
/// IRC client greetings.
|
||||||
|
/// </summary>
|
||||||
|
internal static class WelcomeBanner
|
||||||
|
{
|
||||||
|
// "ECHOHUB" in FIGlet ANSI-Shadow (58 columns)
|
||||||
|
private static readonly string[] BigLogo =
|
||||||
|
[
|
||||||
|
"███████╗ ██████╗██╗ ██╗ ██████╗ ██╗ ██╗██╗ ██╗██████╗ ",
|
||||||
|
"██╔════╝██╔════╝██║ ██║██╔═══██╗██║ ██║██║ ██║██╔══██╗",
|
||||||
|
"█████╗ ██║ ███████║██║ ██║███████║██║ ██║██████╔╝",
|
||||||
|
"██╔══╝ ██║ ██╔══██║██║ ██║██╔══██║██║ ██║██╔══██╗",
|
||||||
|
"███████╗╚██████╗██║ ██║╚██████╔╝██║ ██║╚██████╔╝██████╔╝",
|
||||||
|
"╚══════╝ ╚═════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Compact box-drawing fallback for narrow panes (21 columns)
|
||||||
|
private static readonly string[] SmallLogo =
|
||||||
|
[
|
||||||
|
"┌─┐┌─┐┬ ┬┌─┐┬ ┬┬ ┬┌┐ ",
|
||||||
|
"├┤ │ ├─┤│ │├─┤│ │├┴┐",
|
||||||
|
"└─┘└─┘┴ ┴└─┘┴ ┴└─┘└─┘",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Vertical gold gradient, bright at the top fading to bronze — matches the
|
||||||
|
// EchoHub brand color used in the status bar.
|
||||||
|
private static readonly Color[] Gradient =
|
||||||
|
[
|
||||||
|
new(255, 215, 105),
|
||||||
|
new(245, 199, 89),
|
||||||
|
new(232, 183, 74),
|
||||||
|
new(216, 165, 60),
|
||||||
|
new(198, 146, 48),
|
||||||
|
new(178, 128, 38),
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly Attribute HintKeyAttr = new(new Color(140, 170, 200), Color.None);
|
||||||
|
private static readonly Attribute HintTextAttr = new(new Color(120, 120, 120), Color.None);
|
||||||
|
private static readonly Attribute TaglineAttr = new(new Color(160, 160, 160), Color.None);
|
||||||
|
|
||||||
|
private static readonly (string Key, string Hint)[] Hints =
|
||||||
|
[
|
||||||
|
("Server → Connect", "join a server"),
|
||||||
|
("Ctrl+K ", "search channels & users"),
|
||||||
|
("F2 ", "toggle the users panel"),
|
||||||
|
("/help ", "all commands"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds banner lines centered for the given viewport width.
|
||||||
|
/// </summary>
|
||||||
|
public static List<ChatLine> Build(int width, string version)
|
||||||
|
{
|
||||||
|
var logo = width >= BigLogo[0].GetColumns() + 2 ? BigLogo : SmallLogo;
|
||||||
|
int logoWidth = logo[0].GetColumns();
|
||||||
|
var pad = new string(' ', Math.Max((width - logoWidth) / 2, 0));
|
||||||
|
|
||||||
|
var lines = new List<ChatLine> { new(""), new("") };
|
||||||
|
|
||||||
|
for (int i = 0; i < logo.Length; i++)
|
||||||
|
{
|
||||||
|
// Scale the gradient across however many rows the chosen logo has
|
||||||
|
var color = Gradient[Math.Min(i * Gradient.Length / logo.Length, Gradient.Length - 1)];
|
||||||
|
lines.Add(new ChatLine([
|
||||||
|
new ChatSegment(pad, null),
|
||||||
|
new ChatSegment(logo[i], new Attribute(color, Color.None)),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.Add(new ChatLine(""));
|
||||||
|
|
||||||
|
var tagline = $"v{version} — terminal chat with that old IRC soul";
|
||||||
|
lines.Add(Centered(tagline, width, TaglineAttr));
|
||||||
|
lines.Add(new ChatLine(""));
|
||||||
|
|
||||||
|
int hintWidth = Hints.Max(h => h.Key.Length + 2 + h.Hint.Length);
|
||||||
|
var hintPad = new string(' ', Math.Max((width - hintWidth) / 2, 0));
|
||||||
|
foreach (var (key, hint) in Hints)
|
||||||
|
{
|
||||||
|
lines.Add(new ChatLine([
|
||||||
|
new ChatSegment(hintPad, null),
|
||||||
|
new ChatSegment(key, HintKeyAttr),
|
||||||
|
new ChatSegment(" " + hint, HintTextAttr),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChatLine Centered(string text, int width, Attribute attr)
|
||||||
|
{
|
||||||
|
var pad = new string(' ', Math.Max((width - text.GetColumns()) / 2, 0));
|
||||||
|
return new ChatLine([new ChatSegment(pad, null), new ChatSegment(text, attr)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
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,60 @@
|
|||||||
|
using Terminal.Gui.Drawing;
|
||||||
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI.Helpers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deterministic per-nick colors for users who haven't picked a nickname color.
|
||||||
|
/// The same nick always maps to the same palette entry (classic IRC client behavior),
|
||||||
|
/// so a busy channel stays scannable without any configuration.
|
||||||
|
/// </summary>
|
||||||
|
public static class NickColorHelper
|
||||||
|
{
|
||||||
|
// Medium-saturation truecolor values chosen to stay readable on both dark and
|
||||||
|
// light backgrounds. Order matters: changing it re-colors everyone.
|
||||||
|
private static readonly Attribute[] Palette =
|
||||||
|
[
|
||||||
|
new(new Color(224, 108, 117), Color.None), // soft red
|
||||||
|
new(new Color(152, 195, 121), Color.None), // green
|
||||||
|
new(new Color(229, 192, 123), Color.None), // sand
|
||||||
|
new(new Color(97, 175, 239), Color.None), // blue
|
||||||
|
new(new Color(198, 120, 221), Color.None), // magenta
|
||||||
|
new(new Color(86, 182, 194), Color.None), // teal
|
||||||
|
new(new Color(255, 160, 122), Color.None), // salmon
|
||||||
|
new(new Color(130, 170, 255), Color.None), // periwinkle
|
||||||
|
new(new Color(195, 232, 141), Color.None), // lime
|
||||||
|
new(new Color(137, 221, 255), Color.None), // sky
|
||||||
|
new(new Color(255, 203, 107), Color.None), // amber
|
||||||
|
new(new Color(240, 130, 170), Color.None), // rose
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stable palette index for a nick: case-insensitive FNV-1a over the nick,
|
||||||
|
/// reduced modulo <paramref name="paletteSize"/>. Pure function (no Terminal.Gui
|
||||||
|
/// types) so it is unit-testable without a display driver.
|
||||||
|
/// </summary>
|
||||||
|
public static int GetPaletteIndex(string nick, int paletteSize)
|
||||||
|
{
|
||||||
|
if (paletteSize <= 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
const uint fnvOffset = 2166136261;
|
||||||
|
const uint fnvPrime = 16777619;
|
||||||
|
|
||||||
|
uint hash = fnvOffset;
|
||||||
|
foreach (var ch in nick)
|
||||||
|
{
|
||||||
|
hash ^= char.ToLowerInvariant(ch);
|
||||||
|
hash *= fnvPrime;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int)(hash % (uint)paletteSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The color attribute for a nick. Used as a fallback when the user has no
|
||||||
|
/// explicit nickname color set.
|
||||||
|
/// </summary>
|
||||||
|
public static Attribute GetAttribute(string nick) =>
|
||||||
|
Palette[GetPaletteIndex(nick, Palette.Length)];
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ public class ChannelListSource : IListDataSource
|
|||||||
private readonly List<string> _channelNames = [];
|
private readonly List<string> _channelNames = [];
|
||||||
private readonly Dictionary<string, int> _unreadCounts = [];
|
private readonly Dictionary<string, int> _unreadCounts = [];
|
||||||
private readonly HashSet<string> _protectedChannels = [];
|
private readonly HashSet<string> _protectedChannels = [];
|
||||||
|
private readonly HashSet<string> _mentionChannels = [];
|
||||||
private string _activeChannel = string.Empty;
|
private string _activeChannel = string.Empty;
|
||||||
|
|
||||||
public event NotifyCollectionChangedEventHandler? CollectionChanged;
|
public event NotifyCollectionChangedEventHandler? CollectionChanged;
|
||||||
@@ -28,9 +29,10 @@ public class ChannelListSource : IListDataSource
|
|||||||
private static readonly Attribute UnreadAttr = new(Color.BrightCyan, 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 NormalAttr = new(Color.DarkGray, Color.None);
|
||||||
private static readonly Attribute BadgeAttr = new(Color.BrightYellow, Color.None);
|
private static readonly Attribute BadgeAttr = new(Color.BrightYellow, Color.None);
|
||||||
|
private static readonly Attribute MentionAttr = new(new Color(230, 140, 60), Color.None);
|
||||||
|
|
||||||
public void Update(List<string> channels, Dictionary<string, int> unread, string activeChannel,
|
public void Update(List<string> channels, Dictionary<string, int> unread, string activeChannel,
|
||||||
IReadOnlySet<string>? protectedChannels = null)
|
IReadOnlySet<string>? protectedChannels = null, IReadOnlySet<string>? mentionChannels = null)
|
||||||
{
|
{
|
||||||
_channelNames.Clear();
|
_channelNames.Clear();
|
||||||
_channelNames.AddRange(channels);
|
_channelNames.AddRange(channels);
|
||||||
@@ -40,6 +42,9 @@ public class ChannelListSource : IListDataSource
|
|||||||
_protectedChannels.Clear();
|
_protectedChannels.Clear();
|
||||||
if (protectedChannels is not null)
|
if (protectedChannels is not null)
|
||||||
_protectedChannels.UnionWith(protectedChannels);
|
_protectedChannels.UnionWith(protectedChannels);
|
||||||
|
_mentionChannels.Clear();
|
||||||
|
if (mentionChannels is not null)
|
||||||
|
_mentionChannels.UnionWith(mentionChannels);
|
||||||
_activeChannel = activeChannel;
|
_activeChannel = activeChannel;
|
||||||
MaxItemLength = channels.Count > 0 ? channels.Max(c => c.Length + 6) : 0;
|
MaxItemLength = channels.Count > 0 ? channels.Max(c => c.Length + 6) : 0;
|
||||||
if (!SuspendCollectionChangedEvent)
|
if (!SuspendCollectionChangedEvent)
|
||||||
@@ -82,12 +87,18 @@ public class ChannelListSource : IListDataSource
|
|||||||
listView.SetAttribute(Resolve(isActive ? ActiveAttr : NormalAttr));
|
listView.SetAttribute(Resolve(isActive ? ActiveAttr : NormalAttr));
|
||||||
drawnChars = RenderHelpers.WriteText(listView, prefix, drawnChars, width);
|
drawnChars = RenderHelpers.WriteText(listView, prefix, drawnChars, width);
|
||||||
|
|
||||||
listView.SetAttribute(Resolve(isActive ? ActiveAttr : hasUnread ? UnreadAttr : NormalAttr));
|
// Mentions escalate above plain unread: the whole entry turns orange
|
||||||
|
var hasMention = _mentionChannels.Contains(name);
|
||||||
|
var nameAttr = isActive ? ActiveAttr
|
||||||
|
: hasMention ? MentionAttr
|
||||||
|
: hasUnread ? UnreadAttr
|
||||||
|
: NormalAttr;
|
||||||
|
listView.SetAttribute(Resolve(nameAttr));
|
||||||
drawnChars = RenderHelpers.WriteText(listView, channelText, drawnChars, width);
|
drawnChars = RenderHelpers.WriteText(listView, channelText, drawnChars, width);
|
||||||
|
|
||||||
if (hasUnread)
|
if (hasUnread)
|
||||||
{
|
{
|
||||||
listView.SetAttribute(Resolve(BadgeAttr));
|
listView.SetAttribute(Resolve(hasMention ? MentionAttr : BadgeAttr));
|
||||||
drawnChars = RenderHelpers.WriteText(listView, badge, drawnChars, width);
|
drawnChars = RenderHelpers.WriteText(listView, badge, drawnChars, width);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -319,6 +319,12 @@ public sealed partial class MainWindow : Runnable
|
|||||||
_statusLabel.DrawingContent += OnStatusBarDrawContent;
|
_statusLabel.DrawingContent += OnStatusBarDrawContent;
|
||||||
Add(_statusLabel);
|
Add(_statusLabel);
|
||||||
|
|
||||||
|
// Rounded borders for a softer, modern frame look
|
||||||
|
channelsFrame.BorderStyle = LineStyle.Rounded;
|
||||||
|
_chatFrame.BorderStyle = LineStyle.Rounded;
|
||||||
|
_inputFrame.BorderStyle = LineStyle.Rounded;
|
||||||
|
_usersFrame.BorderStyle = LineStyle.Rounded;
|
||||||
|
|
||||||
// Apply our custom color schemes to all views
|
// Apply our custom color schemes to all views
|
||||||
ApplyColorSchemes();
|
ApplyColorSchemes();
|
||||||
|
|
||||||
@@ -360,6 +366,7 @@ public sealed partial class MainWindow : Runnable
|
|||||||
{
|
{
|
||||||
var baseScheme = SchemeManager.GetScheme("Base");
|
var baseScheme = SchemeManager.GetScheme("Base");
|
||||||
var menuScheme = SchemeManager.GetScheme("Menu");
|
var menuScheme = SchemeManager.GetScheme("Menu");
|
||||||
|
var borderScheme = SchemeManager.GetScheme("Border") ?? baseScheme;
|
||||||
|
|
||||||
if (baseScheme is not null)
|
if (baseScheme is not null)
|
||||||
{
|
{
|
||||||
@@ -370,6 +377,12 @@ public sealed partial class MainWindow : Runnable
|
|||||||
{
|
{
|
||||||
if (sub != _menuBar && sub != _statusLabel && sub != _topicLabel)
|
if (sub != _menuBar && sub != _statusLabel && sub != _topicLabel)
|
||||||
sub.SetScheme(baseScheme);
|
sub.SetScheme(baseScheme);
|
||||||
|
|
||||||
|
// Frame borders (and their titles) take the theme's border colors, so
|
||||||
|
// themes can tone them down independently of text (e.g. transparent
|
||||||
|
// themes use dim gray instead of eye-catching white)
|
||||||
|
if (sub is FrameView frame && borderScheme is not null)
|
||||||
|
frame.Border?.SetScheme(borderScheme);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -913,6 +926,9 @@ public sealed partial class MainWindow : Runnable
|
|||||||
RefreshMessages();
|
RefreshMessages();
|
||||||
else
|
else
|
||||||
RefreshChannelList();
|
RefreshChannelList();
|
||||||
|
|
||||||
|
// Background-channel activity feeds the status bar's Act segment
|
||||||
|
_statusLabel.SetNeedsDraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnHistoryPrepended(string channelName)
|
private void OnHistoryPrepended(string channelName)
|
||||||
@@ -1016,6 +1032,7 @@ public sealed partial class MainWindow : Runnable
|
|||||||
public void UpdateStatusBar(string status)
|
public void UpdateStatusBar(string status)
|
||||||
{
|
{
|
||||||
_connectionStatus = status;
|
_connectionStatus = status;
|
||||||
|
UpdateSpinner();
|
||||||
_statusLabel.SetNeedsDraw();
|
_statusLabel.SetNeedsDraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1023,6 +1040,38 @@ public sealed partial class MainWindow : Runnable
|
|||||||
private static readonly Attribute StatusDisconnectedAttr = new(new Color(220, 50, 50), Color.None);
|
private static readonly Attribute StatusDisconnectedAttr = new(new Color(220, 50, 50), Color.None);
|
||||||
private static readonly Attribute StatusTransitionalAttr = new(new Color(220, 180, 0), Color.None);
|
private static readonly Attribute StatusTransitionalAttr = new(new Color(220, 180, 0), Color.None);
|
||||||
private static readonly Attribute StatusBrandAttr = new(new Color(218, 165, 32), Color.None);
|
private static readonly Attribute StatusBrandAttr = new(new Color(218, 165, 32), Color.None);
|
||||||
|
private static readonly Attribute StatusActivityAttr = new(new Color(80, 200, 220), Color.None);
|
||||||
|
private static readonly Attribute StatusMentionAttr = new(new Color(230, 140, 60), Color.None);
|
||||||
|
|
||||||
|
// Braille spinner shown while the connection is in a transitional state
|
||||||
|
private static readonly string[] SpinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||||
|
private object? _spinnerToken;
|
||||||
|
private int _spinnerFrame;
|
||||||
|
|
||||||
|
private bool IsTransitionalStatus => _connectionStatus is not ("Connected" or "Disconnected");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts the spinner timer when entering a transitional connection state
|
||||||
|
/// (Connecting, Reconnecting, …); the timer stops itself once the state settles.
|
||||||
|
/// </summary>
|
||||||
|
private void UpdateSpinner()
|
||||||
|
{
|
||||||
|
if (!IsTransitionalStatus || _spinnerToken is not null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_spinnerToken = _app.AddTimeout(TimeSpan.FromMilliseconds(120), () =>
|
||||||
|
{
|
||||||
|
if (!IsTransitionalStatus)
|
||||||
|
{
|
||||||
|
_spinnerToken = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_spinnerFrame = (_spinnerFrame + 1) % SpinnerFrames.Length;
|
||||||
|
_statusLabel.SetNeedsDraw();
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private void OnStatusBarDrawContent(object? sender, DrawEventArgs e)
|
private void OnStatusBarDrawContent(object? sender, DrawEventArgs e)
|
||||||
{
|
{
|
||||||
@@ -1054,13 +1103,15 @@ public sealed partial class MainWindow : Runnable
|
|||||||
Write(" EchoHub", Resolve(StatusBrandAttr));
|
Write(" EchoHub", Resolve(StatusBrandAttr));
|
||||||
Write($" \u2502 v{AppVersion} \u2502 ", normalAttr);
|
Write($" \u2502 v{AppVersion} \u2502 ", normalAttr);
|
||||||
|
|
||||||
// Connection state with color
|
// Connection state with color; transitional states get an animated spinner
|
||||||
var statusAttr = _connectionStatus switch
|
var statusAttr = _connectionStatus switch
|
||||||
{
|
{
|
||||||
"Connected" => StatusConnectedAttr,
|
"Connected" => StatusConnectedAttr,
|
||||||
"Disconnected" => StatusDisconnectedAttr,
|
"Disconnected" => StatusDisconnectedAttr,
|
||||||
_ => StatusTransitionalAttr // Connecting, Reconnecting, Authenticating, etc.
|
_ => StatusTransitionalAttr // Connecting, Reconnecting, Authenticating, etc.
|
||||||
};
|
};
|
||||||
|
if (IsTransitionalStatus)
|
||||||
|
Write($"{SpinnerFrames[_spinnerFrame]} ", statusAttr);
|
||||||
Write(_connectionStatus, Resolve(statusAttr));
|
Write(_connectionStatus, Resolve(statusAttr));
|
||||||
|
|
||||||
// User
|
// User
|
||||||
@@ -1079,6 +1130,28 @@ public sealed partial class MainWindow : Runnable
|
|||||||
Write($" \u2502 #{currentChannel} - {typeSuffix}", normalAttr);
|
Write($" \u2502 #{currentChannel} - {typeSuffix}", normalAttr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Activity segment (irssi-style): channels with unread messages,
|
||||||
|
// mention-channels highlighted in orange
|
||||||
|
var activity = _messageManager.GetUnreadCounts()
|
||||||
|
.Where(kv => kv.Value > 0)
|
||||||
|
.Select(kv => kv.Key)
|
||||||
|
.OrderBy(n => n, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
if (activity.Count > 0)
|
||||||
|
{
|
||||||
|
const int maxShown = 4;
|
||||||
|
Write(" \u2502 Act: ", normalAttr);
|
||||||
|
var mentions = _messageManager.MentionChannels;
|
||||||
|
for (int i = 0; i < activity.Count && i < maxShown; i++)
|
||||||
|
{
|
||||||
|
if (i > 0)
|
||||||
|
Write(",", normalAttr);
|
||||||
|
Write($"#{activity[i]}", mentions.Contains(activity[i]) ? StatusMentionAttr : StatusActivityAttr);
|
||||||
|
}
|
||||||
|
if (activity.Count > maxShown)
|
||||||
|
Write($" +{activity.Count - maxShown}", normalAttr);
|
||||||
|
}
|
||||||
|
|
||||||
// Fill remaining space
|
// Fill remaining space
|
||||||
_statusLabel.SetAttribute(normalAttr);
|
_statusLabel.SetAttribute(normalAttr);
|
||||||
while (col < width)
|
while (col < width)
|
||||||
@@ -1181,24 +1254,29 @@ public sealed partial class MainWindow : Runnable
|
|||||||
|
|
||||||
private void RefreshMessages()
|
private void RefreshMessages()
|
||||||
{
|
{
|
||||||
|
var width = _messageList.Viewport.Width;
|
||||||
|
|
||||||
|
// Update cached width when viewport reports a valid value;
|
||||||
|
// fall back to last known width if viewport hasn't been laid out yet.
|
||||||
|
if (width > 0)
|
||||||
|
_lastChatWidth = width;
|
||||||
|
else
|
||||||
|
width = _lastChatWidth;
|
||||||
|
|
||||||
var messages = _messageManager.GetMessages(_messageManager.CurrentChannel);
|
var messages = _messageManager.GetMessages(_messageManager.CurrentChannel);
|
||||||
if (messages is not null)
|
if (messages is not null)
|
||||||
{
|
{
|
||||||
var width = _messageList.Viewport.Width;
|
|
||||||
|
|
||||||
// Update cached width when viewport reports a valid value;
|
|
||||||
// fall back to last known width if viewport hasn't been laid out yet.
|
|
||||||
if (width > 0)
|
|
||||||
_lastChatWidth = width;
|
|
||||||
else
|
|
||||||
width = _lastChatWidth;
|
|
||||||
|
|
||||||
var source = new ChatListSource();
|
var source = new ChatListSource();
|
||||||
|
|
||||||
if (width > 0)
|
if (width > 0)
|
||||||
{
|
{
|
||||||
foreach (var line in messages)
|
foreach (var line in messages)
|
||||||
source.AddRange(line.Wrap(width, line.ContinuationIndent));
|
{
|
||||||
|
if (line.RuleLabel is not null)
|
||||||
|
source.Add(ExpandRule(line, width));
|
||||||
|
else
|
||||||
|
source.AddRange(line.Wrap(width, line.ContinuationIndent));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -1211,16 +1289,36 @@ public sealed partial class MainWindow : Runnable
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_messageList.Source = new ChatListSource();
|
// No channel selected — greet with the MOTD-style splash
|
||||||
|
var source = new ChatListSource();
|
||||||
|
if (width > 0)
|
||||||
|
source.AddRange(WelcomeBanner.Build(width, AppVersion));
|
||||||
|
_messageList.Source = source;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regenerates a separator rule (date change / unread marker) to span the
|
||||||
|
/// current viewport width: "── label ────────…".
|
||||||
|
/// </summary>
|
||||||
|
private static ChatLine ExpandRule(ChatLine line, int width)
|
||||||
|
{
|
||||||
|
var attr = line.RuleAttr ?? ChatColors.DateRuleAttr;
|
||||||
|
var label = line.RuleLabel!;
|
||||||
|
var tailLen = Math.Max(width - 4 - label.GetColumns() - 1, 2);
|
||||||
|
return new ChatLine([new ChatSegment($"── {label} {new string('─', tailLen)}", attr)])
|
||||||
|
{
|
||||||
|
IsUnreadMarker = line.IsUnreadMarker,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Refresh the channel list view, showing unread counts next to channel names.
|
/// Refresh the channel list view, showing unread counts next to channel names.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void RefreshChannelList()
|
private void RefreshChannelList()
|
||||||
{
|
{
|
||||||
_channelListSource.Update(_channelNames, _messageManager.GetUnreadCounts(), _messageManager.CurrentChannel, _channelProtected);
|
_channelListSource.Update(_channelNames, _messageManager.GetUnreadCounts(), _messageManager.CurrentChannel,
|
||||||
|
_channelProtected, _messageManager.MentionChannels);
|
||||||
_channelList.Source = _channelListSource;
|
_channelList.Source = _channelListSource;
|
||||||
|
|
||||||
// Restore selection to current channel
|
// Restore selection to current channel
|
||||||
@@ -1296,8 +1394,11 @@ public sealed partial class MainWindow : Runnable
|
|||||||
var text = roleTag.Length > 0
|
var text = roleTag.Length > 0
|
||||||
? $"{statusIcon} {roleTag} {name}"
|
? $"{statusIcon} {roleTag} {name}"
|
||||||
: $"{statusIcon} {name}";
|
: $"{statusIcon} {name}";
|
||||||
var nameColor = HexColorHelper.ParseHexColor(u.NicknameColor);
|
// Fall back to the deterministic per-nick palette so user-list colors
|
||||||
return (text, nameColor, u.Username);
|
// match the same user's messages in chat.
|
||||||
|
var nameColor = HexColorHelper.ParseHexColor(u.NicknameColor)
|
||||||
|
?? NickColorHelper.GetAttribute(u.Username);
|
||||||
|
return (text, (Attribute?)nameColor, u.Username);
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
_usersListSource.Update(displayItems);
|
_usersListSource.Update(displayItems);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ public interface IChannelService
|
|||||||
Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName);
|
Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName);
|
||||||
Task<List<ChannelListItem>> GetChannelListAsync();
|
Task<List<ChannelListItem>> GetChannelListAsync();
|
||||||
Task<ChannelDto?> GetChannelByNameAsync(string channelName);
|
Task<ChannelDto?> GetChannelByNameAsync(string channelName);
|
||||||
|
Task<ChannelMetaDto?> GetChannelMetaAsync(string channelName);
|
||||||
Task<ChannelCryptoDto?> GetChannelCryptoAsync(string channelName);
|
Task<ChannelCryptoDto?> GetChannelCryptoAsync(string channelName);
|
||||||
Task<(string? EncryptionSalt, string? WrappedRoomKey)> GetChannelKeyEnvelopeAsync(string channelName);
|
Task<(string? EncryptionSalt, string? WrappedRoomKey)> GetChannelKeyEnvelopeAsync(string channelName);
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ namespace EchoHub.Core.Contracts;
|
|||||||
|
|
||||||
public interface IMessageEncryptionService
|
public interface IMessageEncryptionService
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Prefix marking transport/at-rest encrypted content. <see cref="Decrypt"/> is a
|
||||||
|
/// pass-through for values without it.
|
||||||
|
/// </summary>
|
||||||
|
const string CiphertextPrefix = "$ENC$v1$";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether database content should be encrypted at rest (server setting).
|
/// Whether database content should be encrypted at rest (server setting).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -58,6 +58,23 @@ public record CreateChannelRequest(
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public record ChannelCryptoDto(bool IsEncrypted, string? EncryptionSalt);
|
public record ChannelCryptoDto(bool IsEncrypted, string? EncryptionSalt);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Human-facing summary of a channel (the <c>/meta</c> command). For encrypted channels the
|
||||||
|
/// server still knows these figures — count, timestamps, and stored blob sizes — even though it
|
||||||
|
/// cannot read the content itself. <see cref="EstimatedSizeBytes"/> is the sum of stored
|
||||||
|
/// attachment blob sizes plus message text length, so it is an estimate, not an exact on-disk total.
|
||||||
|
/// </summary>
|
||||||
|
public record ChannelMetaDto(
|
||||||
|
Guid Id,
|
||||||
|
string Name,
|
||||||
|
string? Topic,
|
||||||
|
bool IsEncrypted,
|
||||||
|
bool IsProtected,
|
||||||
|
int MessageCount,
|
||||||
|
int UniqueUserCount,
|
||||||
|
long EstimatedSizeBytes,
|
||||||
|
DateTimeOffset CreatedAt);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Passphrase change for an encrypted channel: the client proves knowledge of the old
|
/// Passphrase change for an encrypted channel: the client proves knowledge of the old
|
||||||
/// passphrase (old auth key), then supplies the re-wrapped room key under the new one.
|
/// passphrase (old auth key), then supplies the re-wrapped room key under the new one.
|
||||||
|
|||||||
@@ -16,8 +16,16 @@ public class IrcBroadcaster : IChatBroadcaster
|
|||||||
|
|
||||||
public async Task SendMessageToChannelAsync(string channelName, MessageDto message)
|
public async Task SendMessageToChannelAsync(string channelName, MessageDto message)
|
||||||
{
|
{
|
||||||
// Decrypt content for IRC clients (they can't handle app-layer encryption)
|
// Decrypt content and attachment previews for IRC clients (they can't handle
|
||||||
var decryptedMessage = message with { Content = _encryption.Decrypt(message.Content) };
|
// app-layer encryption). E2E room ciphertext ($RC1$) passes through untouched;
|
||||||
|
// the formatter drops previews that are still ciphertext.
|
||||||
|
var decryptedMessage = message with
|
||||||
|
{
|
||||||
|
Content = _encryption.Decrypt(message.Content),
|
||||||
|
Attachments = message.Attachments?
|
||||||
|
.Select(a => a with { AsciiPreview = _encryption.DecryptNullable(a.AsciiPreview) })
|
||||||
|
.ToList(),
|
||||||
|
};
|
||||||
var lines = IrcMessageFormatter.FormatMessage(decryptedMessage);
|
var lines = IrcMessageFormatter.FormatMessage(decryptedMessage);
|
||||||
|
|
||||||
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
using EchoHub.Core.Contracts;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
|
using EchoHub.Core.Security;
|
||||||
|
|
||||||
namespace EchoHub.Server.Irc;
|
namespace EchoHub.Server.Irc;
|
||||||
|
|
||||||
@@ -34,7 +36,7 @@ public static partial class IrcMessageFormatter
|
|||||||
{
|
{
|
||||||
case AttachmentKind.Image:
|
case AttachmentKind.Image:
|
||||||
lines.Add($"{prefix} PRIVMSG {ircChannel} :[Image: {attachment.FileName}] {attachment.Url}");
|
lines.Add($"{prefix} PRIVMSG {ircChannel} :[Image: {attachment.FileName}] {attachment.Url}");
|
||||||
if (attachment.AsciiPreview is not null)
|
if (attachment.AsciiPreview is not null && !IsCiphertext(attachment.AsciiPreview))
|
||||||
{
|
{
|
||||||
foreach (var line in attachment.AsciiPreview.Split('\n'))
|
foreach (var line in attachment.AsciiPreview.Split('\n'))
|
||||||
{
|
{
|
||||||
@@ -66,6 +68,15 @@ public static partial class IrcMessageFormatter
|
|||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when a preview is still encrypted — transport ($ENC$v1$) if a broadcast path
|
||||||
|
/// forgot to decrypt it, or E2E room ciphertext ($RC1$) the server cannot decrypt.
|
||||||
|
/// Emitting it would flood IRC clients with a multi-KB base64 blob.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsCiphertext(string text) =>
|
||||||
|
text.StartsWith(IMessageEncryptionService.CiphertextPrefix, StringComparison.Ordinal)
|
||||||
|
|| text.StartsWith(RoomCrypto.CiphertextPrefix, StringComparison.Ordinal);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Format a link embed as IRC PRIVMSG lines (text-only, no ASCII thumbnail).
|
/// Format a link embed as IRC PRIVMSG lines (text-only, no ASCII thumbnail).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using EchoHub.Core.Constants;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Config;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Admin-configurable upload limits, bound from the <c>Uploads</c> configuration section.
|
||||||
|
/// Sizes are expressed in megabytes in configuration; the <c>*Bytes</c> accessors convert them
|
||||||
|
/// for enforcement. Every default mirrors <see cref="HubConstants"/> so an absent or partial
|
||||||
|
/// <c>Uploads</c> section preserves the historical built-in limits.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UploadLimits
|
||||||
|
{
|
||||||
|
public int MaxFileSizeMB { get; init; } = HubConstants.MaxFileSizeBytes / (1024 * 1024);
|
||||||
|
public int MaxImageSizeMB { get; init; } = HubConstants.MaxImageSizeBytes / (1024 * 1024);
|
||||||
|
public int MaxAudioSizeMB { get; init; } = HubConstants.MaxAudioFileSizeBytes / (1024 * 1024);
|
||||||
|
public int MaxAvatarSizeMB { get; init; } = HubConstants.MaxAvatarSizeBytes / (1024 * 1024);
|
||||||
|
public int MaxAttachmentsPerMessage { get; init; } = HubConstants.MaxAttachmentsPerMessage;
|
||||||
|
|
||||||
|
public long MaxFileSizeBytes => (long)MaxFileSizeMB * 1024 * 1024;
|
||||||
|
public long MaxImageSizeBytes => (long)MaxImageSizeMB * 1024 * 1024;
|
||||||
|
public long MaxAudioSizeBytes => (long)MaxAudioSizeMB * 1024 * 1024;
|
||||||
|
public long MaxAvatarSizeBytes => (long)MaxAvatarSizeMB * 1024 * 1024;
|
||||||
|
|
||||||
|
/// <summary>Maximum accepted size for a single attachment of the given kind.</summary>
|
||||||
|
public long MaxForKind(AttachmentKind kind) => kind switch
|
||||||
|
{
|
||||||
|
AttachmentKind.Image => MaxImageSizeBytes,
|
||||||
|
AttachmentKind.Audio => MaxAudioSizeBytes,
|
||||||
|
_ => MaxFileSizeBytes,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Absolute ceiling for one message request body (largest file × the attachment cap). Used to
|
||||||
|
/// size the request-body and multipart limits so a configured increase actually takes effect.
|
||||||
|
/// </summary>
|
||||||
|
public long MaxRequestBodyBytes => MaxFileSizeBytes * MaxAttachmentsPerMessage;
|
||||||
|
}
|
||||||
@@ -5,9 +5,11 @@ using EchoHub.Core.Security;
|
|||||||
using EchoHub.Core.Services;
|
using EchoHub.Core.Services;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
|
using EchoHub.Server.Config;
|
||||||
using EchoHub.Server.Data;
|
using EchoHub.Server.Data;
|
||||||
using EchoHub.Server.Services;
|
using EchoHub.Server.Services;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http.Features;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
|
||||||
@@ -26,6 +28,7 @@ public class ChannelsController : ControllerBase
|
|||||||
private readonly IHttpClientFactory _httpClientFactory;
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
private readonly IChatService _chatService;
|
private readonly IChatService _chatService;
|
||||||
private readonly IMessageEncryptionService _encryption;
|
private readonly IMessageEncryptionService _encryption;
|
||||||
|
private readonly UploadLimits _uploadLimits;
|
||||||
|
|
||||||
public ChannelsController(
|
public ChannelsController(
|
||||||
IChannelService channelService,
|
IChannelService channelService,
|
||||||
@@ -34,7 +37,8 @@ public class ChannelsController : ControllerBase
|
|||||||
ImageToAsciiService asciiService,
|
ImageToAsciiService asciiService,
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
IChatService chatService,
|
IChatService chatService,
|
||||||
IMessageEncryptionService encryption)
|
IMessageEncryptionService encryption,
|
||||||
|
UploadLimits uploadLimits)
|
||||||
{
|
{
|
||||||
_channelService = channelService;
|
_channelService = channelService;
|
||||||
_db = db;
|
_db = db;
|
||||||
@@ -43,6 +47,7 @@ public class ChannelsController : ControllerBase
|
|||||||
_httpClientFactory = httpClientFactory;
|
_httpClientFactory = httpClientFactory;
|
||||||
_chatService = chatService;
|
_chatService = chatService;
|
||||||
_encryption = encryption;
|
_encryption = encryption;
|
||||||
|
_uploadLimits = uploadLimits;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
@@ -93,6 +98,21 @@ public class ChannelsController : ControllerBase
|
|||||||
return Ok(crypto);
|
return Ok(crypto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Human-facing summary of a channel (message count, unique posters, estimated size,
|
||||||
|
/// created date, room id). Available for encrypted channels too — these are metadata the
|
||||||
|
/// server tracks even though it cannot read the messages themselves.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("{channel}/meta")]
|
||||||
|
public async Task<IActionResult> GetChannelMeta(string channel)
|
||||||
|
{
|
||||||
|
var meta = await _channelService.GetChannelMetaAsync(channel);
|
||||||
|
if (meta is null)
|
||||||
|
return NotFound(new ErrorResponse($"Channel '{channel}' does not exist."));
|
||||||
|
|
||||||
|
return Ok(meta);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Changes an encrypted channel's passphrase by re-wrapping its room key.
|
/// Changes an encrypted channel's passphrase by re-wrapping its room key.
|
||||||
/// The caller proves knowledge of the old passphrase via the old auth key;
|
/// The caller proves knowledge of the old passphrase via the old auth key;
|
||||||
@@ -152,12 +172,18 @@ public class ChannelsController : ControllerBase
|
|||||||
/// uploads ciphertext blobs and declares each file's kind (<c>kind</c>) and pre-rendered,
|
/// uploads ciphertext blobs and declares each file's kind (<c>kind</c>) and pre-rendered,
|
||||||
/// room-encrypted preview (<c>preview</c>), aligned by file order — the server never inspects them.
|
/// room-encrypted preview (<c>preview</c>), aligned by file order — the server never inspects them.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
// Request-body and multipart limits are applied at runtime from the configured UploadLimits
|
||||||
|
// (see below) rather than via [RequestSizeLimit]/[RequestFormLimits], which require
|
||||||
|
// compile-time constants and so couldn't honor the "Uploads" configuration section.
|
||||||
[HttpPost("{channel}/messages")]
|
[HttpPost("{channel}/messages")]
|
||||||
[EnableRateLimiting("upload")]
|
[EnableRateLimiting("upload")]
|
||||||
[RequestSizeLimit((long)HubConstants.MaxFileSizeBytes * HubConstants.MaxAttachmentsPerMessage)]
|
|
||||||
[RequestFormLimits(MultipartBodyLengthLimit = (long)HubConstants.MaxFileSizeBytes * HubConstants.MaxAttachmentsPerMessage)]
|
|
||||||
public async Task<IActionResult> SendMessageWithAttachments(string channel, [FromQuery] string? size = null)
|
public async Task<IActionResult> SendMessageWithAttachments(string channel, [FromQuery] string? size = null)
|
||||||
{
|
{
|
||||||
|
// Raise this request's body ceiling to the configured maximum before the body is read.
|
||||||
|
var bodySizeFeature = HttpContext.Features.Get<IHttpMaxRequestBodySizeFeature>();
|
||||||
|
if (bodySizeFeature is not null && !bodySizeFeature.IsReadOnly)
|
||||||
|
bodySizeFeature.MaxRequestBodySize = _uploadLimits.MaxRequestBodyBytes;
|
||||||
|
|
||||||
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
var usernameClaim = User.FindFirstValue("username");
|
var usernameClaim = User.FindFirstValue("username");
|
||||||
if (userIdClaim is null || usernameClaim is null)
|
if (userIdClaim is null || usernameClaim is null)
|
||||||
@@ -179,8 +205,8 @@ public class ChannelsController : ControllerBase
|
|||||||
var files = Request.Form.Files;
|
var files = Request.Form.Files;
|
||||||
if (files.Count == 0)
|
if (files.Count == 0)
|
||||||
return BadRequest(new ErrorResponse("At least one attachment is required. Send plain text over the chat connection."));
|
return BadRequest(new ErrorResponse("At least one attachment is required. Send plain text over the chat connection."));
|
||||||
if (files.Count > HubConstants.MaxAttachmentsPerMessage)
|
if (files.Count > _uploadLimits.MaxAttachmentsPerMessage)
|
||||||
return BadRequest(new ErrorResponse($"A message may carry at most {HubConstants.MaxAttachmentsPerMessage} attachments."));
|
return BadRequest(new ErrorResponse($"A message may carry at most {_uploadLimits.MaxAttachmentsPerMessage} attachments."));
|
||||||
|
|
||||||
var sender = await _db.Users.FindAsync(userId);
|
var sender = await _db.Users.FindAsync(userId);
|
||||||
if (sender is not null && sender.IsMuted && (sender.MutedUntil is null || sender.MutedUntil > DateTimeOffset.UtcNow))
|
if (sender is not null && sender.IsMuted && (sender.MutedUntil is null || sender.MutedUntil > DateTimeOffset.UtcNow))
|
||||||
@@ -215,7 +241,7 @@ public class ChannelsController : ControllerBase
|
|||||||
if (string.IsNullOrEmpty(previewPlain))
|
if (string.IsNullOrEmpty(previewPlain))
|
||||||
previewPlain = null;
|
previewPlain = null;
|
||||||
|
|
||||||
if (file.Length > MaxForKind(kind))
|
if (file.Length > _uploadLimits.MaxForKind(kind))
|
||||||
return BadRequest(new ErrorResponse($"'{file.FileName}' exceeds the maximum size."));
|
return BadRequest(new ErrorResponse($"'{file.FileName}' exceeds the maximum size."));
|
||||||
|
|
||||||
using var encryptedStream = file.OpenReadStream();
|
using var encryptedStream = file.OpenReadStream();
|
||||||
@@ -228,8 +254,8 @@ public class ChannelsController : ControllerBase
|
|||||||
var isAudio = !isImage && FileValidationHelper.IsAudioFile(file.FileName);
|
var isAudio = !isImage && FileValidationHelper.IsAudioFile(file.FileName);
|
||||||
kind = isImage ? AttachmentKind.Image : isAudio ? AttachmentKind.Audio : AttachmentKind.File;
|
kind = isImage ? AttachmentKind.Image : isAudio ? AttachmentKind.Audio : AttachmentKind.File;
|
||||||
|
|
||||||
if (file.Length > MaxForKind(kind))
|
if (file.Length > _uploadLimits.MaxForKind(kind))
|
||||||
return BadRequest(new ErrorResponse($"'{file.FileName}' exceeds the maximum size of {MaxForKind(kind) / (1024 * 1024)} MB."));
|
return BadRequest(new ErrorResponse($"'{file.FileName}' exceeds the maximum size of {_uploadLimits.MaxForKind(kind) / (1024 * 1024)} MB."));
|
||||||
|
|
||||||
string filePath;
|
string filePath;
|
||||||
(fileId, filePath) = await _fileStorage.SaveFileAsync(stream, file.FileName);
|
(fileId, filePath) = await _fileStorage.SaveFileAsync(stream, file.FileName);
|
||||||
@@ -296,13 +322,6 @@ public class ChannelsController : ControllerBase
|
|||||||
_ => AttachmentKind.File,
|
_ => AttachmentKind.File,
|
||||||
};
|
};
|
||||||
|
|
||||||
private static long MaxForKind(AttachmentKind kind) => kind switch
|
|
||||||
{
|
|
||||||
AttachmentKind.Image => HubConstants.MaxImageSizeBytes,
|
|
||||||
AttachmentKind.Audio => HubConstants.MaxAudioFileSizeBytes,
|
|
||||||
_ => HubConstants.MaxFileSizeBytes,
|
|
||||||
};
|
|
||||||
|
|
||||||
[HttpPost("{channel}/send-url")]
|
[HttpPost("{channel}/send-url")]
|
||||||
[EnableRateLimiting("upload")]
|
[EnableRateLimiting("upload")]
|
||||||
public async Task<IActionResult> SendUrl(string channel, [FromBody] SendUrlRequest request, [FromQuery] string? size = null)
|
public async Task<IActionResult> SendUrl(string channel, [FromBody] SendUrlRequest request, [FromQuery] string? size = null)
|
||||||
@@ -343,13 +362,13 @@ public class ChannelsController : ControllerBase
|
|||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
var contentLength = response.Content.Headers.ContentLength;
|
var contentLength = response.Content.Headers.ContentLength;
|
||||||
if (contentLength > HubConstants.MaxImageSizeBytes)
|
if (contentLength > _uploadLimits.MaxImageSizeBytes)
|
||||||
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxImageSizeBytes / (1024 * 1024)} MB."));
|
return BadRequest(new ErrorResponse($"File size exceeds maximum of {_uploadLimits.MaxImageSizeBytes / (1024 * 1024)} MB."));
|
||||||
|
|
||||||
imageBytes = await response.Content.ReadAsByteArrayAsync();
|
imageBytes = await response.Content.ReadAsByteArrayAsync();
|
||||||
|
|
||||||
if (imageBytes.Length > HubConstants.MaxImageSizeBytes)
|
if (imageBytes.Length > _uploadLimits.MaxImageSizeBytes)
|
||||||
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxImageSizeBytes / (1024 * 1024)} MB."));
|
return BadRequest(new ErrorResponse($"File size exceeds maximum of {_uploadLimits.MaxImageSizeBytes / (1024 * 1024)} MB."));
|
||||||
|
|
||||||
fileName = Path.GetFileName(uri.LocalPath);
|
fileName = Path.GetFileName(uri.LocalPath);
|
||||||
if (string.IsNullOrWhiteSpace(fileName) || !fileName.Contains('.'))
|
if (string.IsNullOrWhiteSpace(fileName) || !fileName.Contains('.'))
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using EchoHub.Core.Constants;
|
|||||||
using EchoHub.Core.Contracts;
|
using EchoHub.Core.Contracts;
|
||||||
using EchoHub.Core.Services;
|
using EchoHub.Core.Services;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
|
using EchoHub.Server.Config;
|
||||||
using EchoHub.Server.Services;
|
using EchoHub.Server.Services;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -18,11 +19,13 @@ public class UsersController : ControllerBase
|
|||||||
{
|
{
|
||||||
private readonly IUserService _userService;
|
private readonly IUserService _userService;
|
||||||
private readonly ImageToAsciiService _asciiService;
|
private readonly ImageToAsciiService _asciiService;
|
||||||
|
private readonly UploadLimits _uploadLimits;
|
||||||
|
|
||||||
public UsersController(IUserService userService, ImageToAsciiService asciiService)
|
public UsersController(IUserService userService, ImageToAsciiService asciiService, UploadLimits uploadLimits)
|
||||||
{
|
{
|
||||||
_userService = userService;
|
_userService = userService;
|
||||||
_asciiService = asciiService;
|
_asciiService = asciiService;
|
||||||
|
_uploadLimits = uploadLimits;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{username}/profile")]
|
[HttpGet("{username}/profile")]
|
||||||
@@ -65,8 +68,8 @@ public class UsersController : ControllerBase
|
|||||||
|
|
||||||
var file = Request.Form.Files[0];
|
var file = Request.Form.Files[0];
|
||||||
|
|
||||||
if (file.Length > HubConstants.MaxAvatarSizeBytes)
|
if (file.Length > _uploadLimits.MaxAvatarSizeBytes)
|
||||||
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxAvatarSizeBytes / (1024 * 1024)} MB."));
|
return BadRequest(new ErrorResponse($"File size exceeds maximum of {_uploadLimits.MaxAvatarSizeBytes / (1024 * 1024)} MB."));
|
||||||
|
|
||||||
using var stream = file.OpenReadStream();
|
using var stream = file.OpenReadStream();
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using EchoHub.Core.Contracts;
|
|||||||
using EchoHub.Core.Services;
|
using EchoHub.Core.Services;
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
using EchoHub.Server.Auth;
|
using EchoHub.Server.Auth;
|
||||||
|
using EchoHub.Server.Config;
|
||||||
using EchoHub.Server.Data;
|
using EchoHub.Server.Data;
|
||||||
using EchoHub.Server.Hubs;
|
using EchoHub.Server.Hubs;
|
||||||
using EchoHub.Server.Irc;
|
using EchoHub.Server.Irc;
|
||||||
@@ -102,6 +103,14 @@ while (true)
|
|||||||
builder.Services.AddControllers();
|
builder.Services.AddControllers();
|
||||||
builder.Services.AddSignalR();
|
builder.Services.AddSignalR();
|
||||||
|
|
||||||
|
// ── Upload limits (admin-configurable via the "Uploads" section) ─────
|
||||||
|
var uploadLimits = builder.Configuration.GetSection("Uploads").Get<UploadLimits>() ?? new UploadLimits();
|
||||||
|
builder.Services.AddSingleton(uploadLimits);
|
||||||
|
// Raise the multipart form ceiling to match the configured limits; per-endpoint
|
||||||
|
// request-body limits are applied at the action from the same values.
|
||||||
|
builder.Services.Configure<Microsoft.AspNetCore.Http.Features.FormOptions>(o =>
|
||||||
|
o.MultipartBodyLengthLimit = uploadLimits.MaxRequestBodyBytes);
|
||||||
|
|
||||||
// ── Services ─────────────────────────────────────────────────────────
|
// ── Services ─────────────────────────────────────────────────────────
|
||||||
builder.Services.AddSingleton<JwtTokenService>();
|
builder.Services.AddSingleton<JwtTokenService>();
|
||||||
builder.Services.AddSingleton<PresenceTracker>();
|
builder.Services.AddSingleton<PresenceTracker>();
|
||||||
|
|||||||
@@ -288,6 +288,42 @@ public class ChannelService : IChannelService
|
|||||||
c.PasswordHash != null, c.WrappedRoomKey != null);
|
c.PasswordHash != null, c.WrappedRoomKey != null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<ChannelMetaDto?> GetChannelMetaAsync(string channelName)
|
||||||
|
{
|
||||||
|
channelName = channelName.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
|
var c = await db.Channels.FirstOrDefaultAsync(ch => ch.Name == channelName);
|
||||||
|
if (c is null) return null;
|
||||||
|
|
||||||
|
var messageCount = await db.Messages.CountAsync(m => m.ChannelId == c.Id);
|
||||||
|
|
||||||
|
// Distinct senders that have posted here. Works the same for encrypted channels —
|
||||||
|
// sender identity is metadata the server keeps even when it can't read the messages.
|
||||||
|
var uniqueUsers = await db.Messages
|
||||||
|
.Where(m => m.ChannelId == c.Id)
|
||||||
|
.Select(m => m.SenderUserId)
|
||||||
|
.Distinct()
|
||||||
|
.CountAsync();
|
||||||
|
|
||||||
|
// Estimated footprint: stored attachment blob sizes + message text length. For encrypted
|
||||||
|
// channels these are the ciphertext sizes, which is the server's real on-disk cost.
|
||||||
|
var attachmentBytes = await db.Messages
|
||||||
|
.Where(m => m.ChannelId == c.Id)
|
||||||
|
.SelectMany(m => m.Attachments)
|
||||||
|
.SumAsync(a => (long?)a.FileSize) ?? 0;
|
||||||
|
var textBytes = await db.Messages
|
||||||
|
.Where(m => m.ChannelId == c.Id)
|
||||||
|
.SumAsync(m => (long?)m.Content.Length) ?? 0;
|
||||||
|
|
||||||
|
return new ChannelMetaDto(
|
||||||
|
c.Id, c.Name, c.Topic,
|
||||||
|
c.WrappedRoomKey != null, c.PasswordHash != null,
|
||||||
|
messageCount, uniqueUsers, attachmentBytes + textBytes, c.CreatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<ChannelCryptoDto?> GetChannelCryptoAsync(string channelName)
|
public async Task<ChannelCryptoDto?> GetChannelCryptoAsync(string channelName)
|
||||||
{
|
{
|
||||||
channelName = channelName.ToLowerInvariant().Trim();
|
channelName = channelName.ToLowerInvariant().Trim();
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ namespace EchoHub.Server.Services;
|
|||||||
|
|
||||||
public class MessageEncryptionService : IMessageEncryptionService
|
public class MessageEncryptionService : IMessageEncryptionService
|
||||||
{
|
{
|
||||||
private const string EncryptionPrefix = "$ENC$v1$";
|
private const string EncryptionPrefix = IMessageEncryptionService.CiphertextPrefix;
|
||||||
private const int NonceSizeBytes = 12;
|
private const int NonceSizeBytes = 12;
|
||||||
private const int TagSizeBytes = 16;
|
private const int TagSizeBytes = 16;
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,13 @@
|
|||||||
"CleanupIntervalHours": 1,
|
"CleanupIntervalHours": 1,
|
||||||
"RetentionDays": 30
|
"RetentionDays": 30
|
||||||
},
|
},
|
||||||
|
"Uploads": {
|
||||||
|
"MaxFileSizeMB": 100,
|
||||||
|
"MaxImageSizeMB": 10,
|
||||||
|
"MaxAudioSizeMB": 10,
|
||||||
|
"MaxAvatarSizeMB": 2,
|
||||||
|
"MaxAttachmentsPerMessage": 10
|
||||||
|
},
|
||||||
"Encryption": {
|
"Encryption": {
|
||||||
"Key": "",
|
"Key": "",
|
||||||
"EncryptDatabase": false
|
"EncryptDatabase": false
|
||||||
|
|||||||
@@ -472,4 +472,22 @@ public class CommandHandlerTests
|
|||||||
Assert.True(result.Handled);
|
Assert.True(result.Handled);
|
||||||
Assert.True(quitCalled);
|
Assert.True(quitCalled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── /meta ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("/meta")]
|
||||||
|
[InlineData("/info")]
|
||||||
|
public async Task HandleAsync_Meta_RaisesRoomInfo(string input)
|
||||||
|
{
|
||||||
|
var handler = CreateHandler();
|
||||||
|
var raised = false;
|
||||||
|
handler.OnRoomInfo += () => { raised = true; return Task.CompletedTask; };
|
||||||
|
|
||||||
|
var result = await handler.HandleAsync(input);
|
||||||
|
|
||||||
|
Assert.True(result.Handled);
|
||||||
|
Assert.False(result.IsError);
|
||||||
|
Assert.True(raised);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,27 @@ public class IrcBroadcasterTests
|
|||||||
Assert.DoesNotContain(output, l => l.Contains("$ENC$"));
|
Assert.DoesNotContain(output, l => l.Contains("$ENC$"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendMessage_DecryptsAttachmentAsciiPreview()
|
||||||
|
{
|
||||||
|
var (_, stream) = AddConnectionWithCapture("bob", "general");
|
||||||
|
|
||||||
|
var attachment = new AttachmentDto(
|
||||||
|
AttachmentKind.Image, "/api/files/abc", "photo.png", 1234,
|
||||||
|
_encryption.Encrypt("line1\nline2"));
|
||||||
|
var message = new MessageDto(
|
||||||
|
Guid.NewGuid(), _encryption.Encrypt("look at this"), "alice", null, "general",
|
||||||
|
DateTimeOffset.UtcNow, [attachment]);
|
||||||
|
|
||||||
|
await _broadcaster.SendMessageToChannelAsync("general", message);
|
||||||
|
|
||||||
|
var output = stream.GetOutputLines();
|
||||||
|
Assert.Contains(output, l => l.Contains("[Image: photo.png]"));
|
||||||
|
Assert.Contains(output, l => l.Contains("line1"));
|
||||||
|
Assert.Contains(output, l => l.Contains("line2"));
|
||||||
|
Assert.DoesNotContain(output, l => l.Contains("$ENC$"));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task SendMessage_SkipsSender()
|
public async Task SendMessage_SkipsSender()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -138,6 +138,19 @@ public class IrcMessageFormatterTests
|
|||||||
Assert.Equal(2, asciiLines.Count);
|
Assert.Equal(2, asciiLines.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("$ENC$v1$abc123$def456")] // transport ciphertext a broadcast path forgot to decrypt
|
||||||
|
[InlineData("$RC1$abc123def456")] // E2E room ciphertext the server cannot decrypt
|
||||||
|
public void FormatMessage_ImageMessage_SkipsCiphertextPreview(string ciphertextPreview)
|
||||||
|
{
|
||||||
|
var msg = CreateImageMessage(ciphertextPreview, "photo.jpg", "https://example.com/photo.jpg");
|
||||||
|
var lines = IrcMessageFormatter.FormatMessage(msg);
|
||||||
|
|
||||||
|
// Only the [Image: ...] header line — never the ciphertext blob
|
||||||
|
Assert.Single(lines);
|
||||||
|
Assert.Contains("[Image: photo.jpg]", lines[0]);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void FormatMessage_FileMessage_FormatsCorrectly()
|
public void FormatMessage_FileMessage_FormatsCorrectly()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -270,6 +270,11 @@ internal sealed class FakeChannelService : IChannelService
|
|||||||
public Task<ChannelDto?> GetChannelByNameAsync(string channelName) =>
|
public Task<ChannelDto?> GetChannelByNameAsync(string channelName) =>
|
||||||
Task.FromResult(ChannelByNameToReturn);
|
Task.FromResult(ChannelByNameToReturn);
|
||||||
|
|
||||||
|
public ChannelMetaDto? ChannelMetaToReturn { get; set; }
|
||||||
|
|
||||||
|
public Task<ChannelMetaDto?> GetChannelMetaAsync(string channelName) =>
|
||||||
|
Task.FromResult(ChannelMetaToReturn);
|
||||||
|
|
||||||
public Task<(bool Success, string? Error, bool PasswordRequired)> EnsureChannelMembershipAsync(Guid userId, string channelName, string? password = null) =>
|
public Task<(bool Success, string? Error, bool PasswordRequired)> EnsureChannelMembershipAsync(Guid userId, string channelName, string? password = null) =>
|
||||||
Task.FromResult(MembershipResult);
|
Task.FromResult(MembershipResult);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using EchoHub.Client.UI.Helpers;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace EchoHub.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the deterministic nick→palette-index hash.
|
||||||
|
/// Note: GetAttribute (Terminal.Gui Attribute) is excluded — Terminal.Gui's module
|
||||||
|
/// initializer requires a display driver unavailable in CI.
|
||||||
|
/// </summary>
|
||||||
|
public class NickColorHelperTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void GetPaletteIndex_SameNick_IsStable()
|
||||||
|
{
|
||||||
|
var first = NickColorHelper.GetPaletteIndex("alice", 12);
|
||||||
|
var second = NickColorHelper.GetPaletteIndex("alice", 12);
|
||||||
|
Assert.Equal(first, second);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetPaletteIndex_IsCaseInsensitive()
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
NickColorHelper.GetPaletteIndex("Alice", 12),
|
||||||
|
NickColorHelper.GetPaletteIndex("aLICE", 12));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("alice")]
|
||||||
|
[InlineData("bob")]
|
||||||
|
[InlineData("charlie_long_nickname")]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData("émile")]
|
||||||
|
public void GetPaletteIndex_AlwaysWithinRange(string nick)
|
||||||
|
{
|
||||||
|
var index = NickColorHelper.GetPaletteIndex(nick, 12);
|
||||||
|
Assert.InRange(index, 0, 11);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetPaletteIndex_DistributesAcrossPalette()
|
||||||
|
{
|
||||||
|
// Not a strict uniformity test — just that the hash isn't degenerate
|
||||||
|
var nicks = new[] { "alice", "bob", "carol", "dave", "erin", "frank", "grace", "heidi" };
|
||||||
|
var distinct = nicks.Select(n => NickColorHelper.GetPaletteIndex(n, 12)).Distinct().Count();
|
||||||
|
Assert.True(distinct >= 3, $"Expected at least 3 distinct palette slots, got {distinct}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetPaletteIndex_NonPositivePaletteSize_ReturnsZero()
|
||||||
|
{
|
||||||
|
Assert.Equal(0, NickColorHelper.GetPaletteIndex("alice", 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using EchoHub.Core.Constants;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
|
using EchoHub.Server.Config;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace EchoHub.Tests;
|
||||||
|
|
||||||
|
public class UploadLimitsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Defaults_MirrorHubConstants()
|
||||||
|
{
|
||||||
|
var limits = new UploadLimits();
|
||||||
|
|
||||||
|
Assert.Equal(HubConstants.MaxFileSizeBytes, limits.MaxFileSizeBytes);
|
||||||
|
Assert.Equal(HubConstants.MaxImageSizeBytes, limits.MaxImageSizeBytes);
|
||||||
|
Assert.Equal(HubConstants.MaxAudioFileSizeBytes, limits.MaxAudioSizeBytes);
|
||||||
|
Assert.Equal(HubConstants.MaxAvatarSizeBytes, limits.MaxAvatarSizeBytes);
|
||||||
|
Assert.Equal(HubConstants.MaxAttachmentsPerMessage, limits.MaxAttachmentsPerMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MaxForKind_MapsEachAttachmentKind()
|
||||||
|
{
|
||||||
|
var limits = new UploadLimits
|
||||||
|
{
|
||||||
|
MaxImageSizeMB = 5,
|
||||||
|
MaxAudioSizeMB = 7,
|
||||||
|
MaxFileSizeMB = 11,
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Equal(5L * 1024 * 1024, limits.MaxForKind(AttachmentKind.Image));
|
||||||
|
Assert.Equal(7L * 1024 * 1024, limits.MaxForKind(AttachmentKind.Audio));
|
||||||
|
Assert.Equal(11L * 1024 * 1024, limits.MaxForKind(AttachmentKind.File));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MaxRequestBodyBytes_IsFileSizeTimesAttachmentCap()
|
||||||
|
{
|
||||||
|
var limits = new UploadLimits { MaxFileSizeMB = 20, MaxAttachmentsPerMessage = 4 };
|
||||||
|
|
||||||
|
Assert.Equal(20L * 1024 * 1024 * 4, limits.MaxRequestBodyBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BoundFromConfiguration_OverridesDefaults()
|
||||||
|
{
|
||||||
|
var config = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["Uploads:MaxFileSizeMB"] = "250",
|
||||||
|
["Uploads:MaxImageSizeMB"] = "25",
|
||||||
|
["Uploads:MaxAttachmentsPerMessage"] = "3",
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var limits = config.GetSection("Uploads").Get<UploadLimits>()!;
|
||||||
|
|
||||||
|
Assert.Equal(250L * 1024 * 1024, limits.MaxFileSizeBytes);
|
||||||
|
Assert.Equal(25L * 1024 * 1024, limits.MaxImageSizeBytes);
|
||||||
|
Assert.Equal(3, limits.MaxAttachmentsPerMessage);
|
||||||
|
// Unspecified values keep their HubConstants-derived defaults.
|
||||||
|
Assert.Equal(HubConstants.MaxAudioFileSizeBytes, limits.MaxAudioSizeBytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user