mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94f968ccf9 | ||
|
|
4f96b8d986 | ||
|
|
fbb2958fdd | ||
|
|
62e9d7fff5 | ||
|
|
be4d686d4d | ||
|
|
c8ea124198 | ||
|
|
c5d8e25a86 | ||
|
|
03f4685ae6 | ||
|
|
17e64293b5 | ||
|
|
ce375c7619 | ||
|
|
d12f249222 | ||
|
|
bc8e2fd7da | ||
|
|
35a162d013 | ||
|
|
de34fb43b9 | ||
|
|
3e03a77aeb | ||
|
|
5ef338bbf7 | ||
|
|
f96f70f09a | ||
|
|
b7d58dc10d | ||
|
|
7fadd08702 | ||
|
|
576dd4bc94 | ||
|
|
d4cf35e04e | ||
|
|
23abb9b0bb | ||
|
|
dcb8bbc05f | ||
|
|
4930aee615 | ||
|
|
2cd3bbb570 | ||
|
|
0d959e317e | ||
|
|
75491d65e2 | ||
|
|
1861fd00f9 | ||
|
|
9996a6be97 | ||
|
|
cf9c647c03 | ||
|
|
1fa4348700 | ||
|
|
0c7c620f24 | ||
|
|
56a962c1af | ||
|
|
a65b41fb7f | ||
|
|
ab9e09a609 | ||
|
|
1bdc6ec99e | ||
|
|
fb12848e9a | ||
|
|
7e78c57665 | ||
|
|
9d4979d4a7 | ||
|
|
c0d38015ff | ||
|
|
3bc1446d1c | ||
|
|
411042c397 | ||
|
|
50817ce6e9 | ||
|
|
9265a9f312 | ||
|
|
a860a8fbc3 | ||
|
|
ae06e7ae12 | ||
|
|
f49148a85a | ||
|
|
575115cdca | ||
|
|
ca9973b411 | ||
|
|
4c850fc3c9 | ||
|
|
2efe54e417 |
@@ -102,7 +102,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Build release notes
|
- name: Build release notes
|
||||||
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
||||||
id: notes
|
|
||||||
run: |
|
run: |
|
||||||
TAG="${{ steps.version.outputs.tag }}"
|
TAG="${{ steps.version.outputs.tag }}"
|
||||||
CHANGELOG_URL="https://huebyte.github.io/EchoHub/changelog/${TAG}.html"
|
CHANGELOG_URL="https://huebyte.github.io/EchoHub/changelog/${TAG}.html"
|
||||||
@@ -110,7 +109,6 @@ jobs:
|
|||||||
PREV_TAG=$(git tag --sort=-v:refname | grep -v "^${TAG}$" | head -n 1)
|
PREV_TAG=$(git tag --sort=-v:refname | grep -v "^${TAG}$" | head -n 1)
|
||||||
|
|
||||||
{
|
{
|
||||||
echo "body<<RELEASE_EOF"
|
|
||||||
echo "📋 **[Full Changelog](${CHANGELOG_URL})**"
|
echo "📋 **[Full Changelog](${CHANGELOG_URL})**"
|
||||||
echo ""
|
echo ""
|
||||||
echo "---"
|
echo "---"
|
||||||
@@ -129,15 +127,14 @@ jobs:
|
|||||||
else
|
else
|
||||||
echo "*Version diff: [${TAG}](${REPO}/commits/${TAG})*"
|
echo "*Version diff: [${TAG}](${REPO}/commits/${TAG})*"
|
||||||
fi
|
fi
|
||||||
echo "RELEASE_EOF"
|
} > release-notes.md
|
||||||
} >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
||||||
run: |
|
run: |
|
||||||
gh release create "${{ steps.version.outputs.tag }}" \
|
gh release create "${{ steps.version.outputs.tag }}" \
|
||||||
--title "EchoHub ${{ steps.version.outputs.tag }}" \
|
--title "EchoHub ${{ steps.version.outputs.tag }}" \
|
||||||
--notes "${{ steps.notes.outputs.body }}" \
|
--notes-file release-notes.md \
|
||||||
EchoHub-Server-win-x64.zip \
|
EchoHub-Server-win-x64.zip \
|
||||||
EchoHub-Server-linux-x64.zip \
|
EchoHub-Server-linux-x64.zip \
|
||||||
EchoHub-Server-osx-x64.zip \
|
EchoHub-Server-osx-x64.zip \
|
||||||
|
|||||||
@@ -433,3 +433,6 @@ src/EchoHub.Server/uploads/*
|
|||||||
# DocFx generated output
|
# DocFx generated output
|
||||||
docs/_site/
|
docs/_site/
|
||||||
docs/_api_meta/
|
docs/_api_meta/
|
||||||
|
|
||||||
|
# Diff files
|
||||||
|
*.diff
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
@@ -0,0 +1,11 @@
|
|||||||
|
<svg width="32" height="32" viewBox="0 0 1000 1000" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g stroke="#E6C06E" stroke-width="28" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="500" cy="500" r="420" />
|
||||||
|
<circle cx="500" cy="500" r="180" />
|
||||||
|
<polygon points="500,440 550,532 450,532" fill="#E6C06E" />
|
||||||
|
<line x1="500" y1="80" x2="500" y2="320" />
|
||||||
|
<circle cx="285" cy="390" r="8" fill="#E6C06E" />
|
||||||
|
<circle cx="715" cy="390" r="8" fill="#E6C06E" />
|
||||||
|
<circle cx="500" cy="750" r="8" fill="#E6C06E" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 590 B |
@@ -5,7 +5,9 @@ Articles related to the EchoHub TUI client built with Terminal.Gui v2.
|
|||||||
## Topics
|
## Topics
|
||||||
|
|
||||||
- Terminal.Gui v2 patterns and conventions
|
- Terminal.Gui v2 patterns and conventions
|
||||||
- Theme system and customization
|
- Theme system and customization (including transparent theme)
|
||||||
- Command system reference
|
- Command system reference
|
||||||
- Configuration management
|
- Configuration and session persistence
|
||||||
|
- Audio playback and file downloads
|
||||||
|
- Automatic update checking
|
||||||
- [Notification sounds](../../articles/notification-sounds.md)
|
- [Notification sounds](../../articles/notification-sounds.md)
|
||||||
|
|||||||
@@ -5,5 +5,5 @@ Articles related to the EchoHub.Core shared library.
|
|||||||
## Topics
|
## Topics
|
||||||
|
|
||||||
- Data models and DTOs
|
- Data models and DTOs
|
||||||
- Contract interfaces (IChatService, IChatBroadcaster, IEchoHubClient)
|
- Contract interfaces (IChatService, IChannelService, IChatBroadcaster, IEchoHubClient)
|
||||||
- Validation constants and shared rules
|
- Validation constants and shared rules
|
||||||
|
|||||||
+2
-2
@@ -10,11 +10,11 @@ Terminal.Gui v2 TUI application -- UI components, services, themes, and configur
|
|||||||
|
|
||||||
### Core
|
### Core
|
||||||
|
|
||||||
Shared library -- DTOs, models, constants, and contracts (`IChatService`, `IChatBroadcaster`, `IEchoHubClient`).
|
Shared library -- DTOs, models, constants, and contracts (`IChatService`, `IChannelService`, `IChatBroadcaster`, `IEchoHubClient`).
|
||||||
|
|
||||||
### Server
|
### Server
|
||||||
|
|
||||||
ASP.NET Core server -- controllers, hubs, ChatService, SignalRBroadcaster, authentication, and data access.
|
ASP.NET Core server -- controllers, hubs, ChatService, ChannelService, SignalRBroadcaster, authentication, file cleanup, and data access.
|
||||||
|
|
||||||
### Server.Irc
|
### Server.Irc
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Articles related to the EchoHub server built with ASP.NET Core.
|
|||||||
- SignalR hub and real-time messaging
|
- SignalR hub and real-time messaging
|
||||||
- IRC gateway and protocol bridging
|
- IRC gateway and protocol bridging
|
||||||
- ChatService and broadcaster pattern
|
- ChatService and broadcaster pattern
|
||||||
- File upload and validation
|
- ChannelService and channel CRUD
|
||||||
|
- File upload, validation, and cleanup
|
||||||
- Rate limiting configuration
|
- Rate limiting configuration
|
||||||
- Database schema and migrations
|
- Database schema, migrations, and DataMigrationService
|
||||||
|
|||||||
+3
-3
@@ -3,19 +3,19 @@
|
|||||||
- name: API Reference
|
- name: API Reference
|
||||||
href: ../_api_meta/client/toc.yml
|
href: ../_api_meta/client/toc.yml
|
||||||
- name: Articles
|
- name: Articles
|
||||||
href: client-articles/
|
href: client-articles/toc.yml
|
||||||
- name: Core
|
- name: Core
|
||||||
items:
|
items:
|
||||||
- name: API Reference
|
- name: API Reference
|
||||||
href: ../_api_meta/core/toc.yml
|
href: ../_api_meta/core/toc.yml
|
||||||
- name: Articles
|
- name: Articles
|
||||||
href: core-articles/
|
href: core-articles/toc.yml
|
||||||
- name: Server
|
- name: Server
|
||||||
items:
|
items:
|
||||||
- name: API Reference
|
- name: API Reference
|
||||||
href: ../_api_meta/server/toc.yml
|
href: ../_api_meta/server/toc.yml
|
||||||
- name: Articles
|
- name: Articles
|
||||||
href: server-articles/
|
href: server-articles/toc.yml
|
||||||
- name: Server.Irc
|
- name: Server.Irc
|
||||||
items:
|
items:
|
||||||
- name: API Reference
|
- name: API Reference
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ Shared library containing:
|
|||||||
|
|
||||||
- **Models**: `User`, `Channel`, `Message`, `RefreshToken`
|
- **Models**: `User`, `Channel`, `Message`, `RefreshToken`
|
||||||
- **DTOs**: Record types for API requests/responses
|
- **DTOs**: Record types for API requests/responses
|
||||||
- **Contracts**: `IChatService` (protocol-agnostic chat operations), `IChatBroadcaster` (event fan-out interface), `IEchoHubClient` (SignalR client interface)
|
- **Contracts**: `IChatService` (protocol-agnostic chat operations), `IChannelService` (channel CRUD and membership), `IChatBroadcaster` (event fan-out interface), `IEchoHubClient` (SignalR client interface)
|
||||||
- **Constants**: `ValidationConstants` (shared regex patterns), `HubConstants`
|
- **Constants**: `ValidationConstants` (shared regex patterns), `HubConstants`
|
||||||
|
|
||||||
### EchoHub.Server
|
### EchoHub.Server
|
||||||
@@ -33,7 +33,7 @@ ASP.NET Core web application:
|
|||||||
- **Hubs**: SignalR `ChatHub` -- thin adapter delegating to `IChatService`
|
- **Hubs**: SignalR `ChatHub` -- thin adapter delegating to `IChatService`
|
||||||
- **Auth**: JWT token service (15-min access tokens, 30-day refresh tokens)
|
- **Auth**: JWT token service (15-min access tokens, 30-day refresh tokens)
|
||||||
- **Data**: EF Core with SQLite
|
- **Data**: EF Core with SQLite
|
||||||
- **Services**: `ChatService` (core business logic), `SignalRBroadcaster`, presence tracking, file storage, image-to-ASCII conversion
|
- **Services**: `ChatService` (core business logic), `ChannelService` (channel CRUD and membership), `SignalRBroadcaster`, presence tracking, file storage, image-to-ASCII conversion, `FileCleanupService` (periodic removal of expired uploads), `DataMigrationService` (startup schema/data evolution)
|
||||||
|
|
||||||
### EchoHub.Server.Irc
|
### EchoHub.Server.Irc
|
||||||
|
|
||||||
@@ -51,9 +51,9 @@ IRC users authenticate with existing EchoHub accounts via `PASS`/`NICK`/`USER` o
|
|||||||
Terminal.Gui v2 TUI application:
|
Terminal.Gui v2 TUI application:
|
||||||
|
|
||||||
- **UI**: Main window, dialogs, chat renderer with ANSI color support
|
- **UI**: Main window, dialogs, chat renderer with ANSI color support
|
||||||
- **Services**: API client with automatic token refresh, SignalR connection wrapper
|
- **Services**: API client with automatic token refresh, SignalR connection wrapper, audio playback (NetCoreAudio), automatic update checker (AlwaysUpToDate)
|
||||||
- **Themes**: 13 built-in color themes
|
- **Themes**: 13 built-in color themes (including transparent theme with true terminal transparency)
|
||||||
- **Config**: Client configuration management
|
- **Config**: Client configuration management with session persistence ("Remember Me" refresh tokens)
|
||||||
|
|
||||||
## Communication
|
## Communication
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# Message Encryption
|
||||||
|
|
||||||
|
EchoHub uses application-layer AES-256-GCM encryption to protect message content in transit between clients and the server. This is an additional layer on top of TLS, protecting against ISPs, proxies, and any middleman that can inspect HTTPS traffic (e.g. corporate proxies with trusted root CA certificates).
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Sender as TUI Client (Sender)
|
||||||
|
participant Server
|
||||||
|
participant Receiver as TUI Client (Receiver)
|
||||||
|
participant IRC as IRC Client
|
||||||
|
|
||||||
|
Sender->>Sender: encrypt(plaintext)
|
||||||
|
Sender->>Server: $ENC$v1$... (SignalR)
|
||||||
|
Server->>Server: decrypt → validate/sanitize
|
||||||
|
Server->>Server: fetch embeds on plaintext
|
||||||
|
Server->>Server: (optional) encrypt for DB storage
|
||||||
|
Server->>Server: encrypt(plaintext) with fresh nonce
|
||||||
|
Server->>Receiver: $ENC$v1$... (SignalR broadcast)
|
||||||
|
Receiver->>Receiver: decrypt → display
|
||||||
|
Server->>Server: decrypt for IRC
|
||||||
|
Server->>IRC: plaintext (IRC PRIVMSG)
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **Client encrypts** the message before sending it over SignalR
|
||||||
|
2. **Server decrypts** to validate content, sanitize newlines, and fetch link embeds
|
||||||
|
3. **Server re-encrypts** with a fresh nonce and broadcasts to all connected SignalR clients
|
||||||
|
4. **Clients decrypt** the broadcast and display the plaintext
|
||||||
|
5. **IRC clients** receive plaintext automatically (the IRC broadcaster decrypts before forwarding)
|
||||||
|
|
||||||
|
Each encryption uses a random 12-byte nonce, so the same message produces different ciphertext every time.
|
||||||
|
|
||||||
|
## Encryption Key
|
||||||
|
|
||||||
|
A 256-bit AES key is auto-generated on first server startup and saved to `appsettings.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Encryption": {
|
||||||
|
"Key": "base64-encoded-32-byte-key",
|
||||||
|
"EncryptDatabase": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The key is generated by `FirstRunSetup` using `RandomNumberGenerator.GetBytes(32)`. If the key is missing or empty when the server starts, a new one is created automatically.
|
||||||
|
|
||||||
|
Clients fetch the key after login via an authenticated endpoint (`GET /api/server/encryption-key`). No manual configuration is needed on the client side.
|
||||||
|
|
||||||
|
## Encrypted Content Format
|
||||||
|
|
||||||
|
Encrypted content uses a self-describing format:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ENC$v1${nonce_base64}${ciphertext+tag_base64}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`$ENC$v1$`** — version prefix (allows future algorithm changes)
|
||||||
|
- **Nonce** — 12 bytes, Base64-encoded, randomly generated per message
|
||||||
|
- **Ciphertext + Tag** — AES-256-GCM output with 16-byte authentication tag appended
|
||||||
|
|
||||||
|
The authentication tag ensures both confidentiality and integrity — any tampering with the ciphertext is detected during decryption.
|
||||||
|
|
||||||
|
## Database Encryption (Optional)
|
||||||
|
|
||||||
|
By default, messages are stored as **plaintext** in the database. Transport encryption still protects everything on the wire, but the SQLite file itself contains readable messages.
|
||||||
|
|
||||||
|
To encrypt messages at rest, enable the setting:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Encryption": {
|
||||||
|
"Key": "...",
|
||||||
|
"EncryptDatabase": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Behavior by Setting
|
||||||
|
|
||||||
|
| Setting | DB Storage | Transport | Key Rotation Risk |
|
||||||
|
| ----------------- | ---------- | --------- | ---------------------------------------------- |
|
||||||
|
| `false` (default) | Plaintext | Encrypted | None - stored data is unaffected |
|
||||||
|
| `true` | Encrypted | Encrypted | Changing the key makes old messages unreadable |
|
||||||
|
|
||||||
|
### Mixed Content
|
||||||
|
|
||||||
|
The server handles mixed encrypted/plaintext content in the database gracefully. When reading messages:
|
||||||
|
|
||||||
|
- Content starting with `$ENC$v1$` is decrypted
|
||||||
|
- Everything else is treated as plaintext
|
||||||
|
|
||||||
|
This means you can safely toggle `EncryptDatabase` at any time. Old messages remain readable regardless of the current setting.
|
||||||
|
|
||||||
|
### Enabling Encryption at Rest
|
||||||
|
|
||||||
|
When you set `EncryptDatabase: true`, only **new messages** are encrypted going forward. Existing plaintext messages in the database are not retroactively encrypted. This is intentional — it keeps key rotation safe and avoids irreversible bulk changes.
|
||||||
|
|
||||||
|
### Key Rotation
|
||||||
|
|
||||||
|
Changing the encryption key is safe:
|
||||||
|
|
||||||
|
- **Plaintext messages** — always readable regardless of key
|
||||||
|
- **Messages encrypted with the old key** — will show `[encrypted message — decryption failed]`
|
||||||
|
- **New messages** — encrypted with the new key going forward
|
||||||
|
|
||||||
|
If you need to recover old encrypted messages, restore the original key from a backup of `appsettings.json`. Since plaintext messages are never retroactively encrypted, you'll never lose access to your entire history from a key change.
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### What This Protects Against
|
||||||
|
|
||||||
|
- **Passive network sniffing** — messages are encrypted even if captured off the wire
|
||||||
|
- **ISP/proxy inspection** — content is encrypted at the application layer, independent of TLS
|
||||||
|
- **Database theft** (when `EncryptDatabase: true`) — SQLite file contains only ciphertext
|
||||||
|
|
||||||
|
### Limitations
|
||||||
|
|
||||||
|
- **TLS-inspecting proxies** — if a corporate proxy terminates TLS with a trusted root CA, it can intercept the key exchange (`GET /api/server/encryption-key`) and read all traffic. A future upgrade to ECDH key exchange would address this.
|
||||||
|
- **Server has full access** — the server decrypts all messages for processing. This is not end-to-end encryption between users; it's transport encryption between client and server.
|
||||||
|
- **IRC clients receive plaintext** — IRC is an open protocol and third-party clients cannot participate in the encryption scheme.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Messages show `[encrypted message — decryption failed, try re-logging to fetch the latest key]`
|
||||||
|
|
||||||
|
The client's encryption key doesn't match the server's. This happens when:
|
||||||
|
|
||||||
|
- The server's encryption key was rotated while the client was connected
|
||||||
|
- The client cached a stale key
|
||||||
|
|
||||||
|
**Fix**: Disconnect and reconnect (re-login). The client fetches the current key on each login.
|
||||||
|
|
||||||
|
### Messages show `[encrypted message — decryption failed]` in channel history
|
||||||
|
|
||||||
|
The server cannot decrypt messages stored in the database. This happens when:
|
||||||
|
|
||||||
|
- `EncryptDatabase` was enabled, and the key was changed afterwards
|
||||||
|
|
||||||
|
**Fix**: Restore the original key from a backup. There is no way to recover messages encrypted with a lost key.
|
||||||
@@ -2,3 +2,7 @@
|
|||||||
href: getting-started.md
|
href: getting-started.md
|
||||||
- name: Architecture
|
- name: Architecture
|
||||||
href: architecture.md
|
href: architecture.md
|
||||||
|
- name: Encryption
|
||||||
|
href: encryption.md
|
||||||
|
- name: Notification Sounds
|
||||||
|
href: notification-sounds.md
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ Release history for EchoHub.
|
|||||||
|
|
||||||
## Releases
|
## Releases
|
||||||
|
|
||||||
|
- [v0.2.5](v0.2.5.md) - Session Persistence, Auto-Updates, Audio & Transparent Theme
|
||||||
|
- [v0.2.4](v0.2.4.md) - E2E Message Encryption
|
||||||
- [v0.2.3](v0.2.3.md) - Moderation, Embeds & UI Overhaul
|
- [v0.2.3](v0.2.3.md) - Moderation, Embeds & UI Overhaul
|
||||||
- [v0.2.2](v0.2.2.md) - Startup & Shutdown Fixes
|
- [v0.2.2](v0.2.2.md) - Startup & Shutdown Fixes
|
||||||
- [v0.2.1](v0.2.1.md) - Shutdown & CI Fixes
|
- [v0.2.1](v0.2.1.md) - Shutdown & CI Fixes
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
- name: Overview
|
- name: Overview
|
||||||
href: index.md
|
href: index.md
|
||||||
|
- name: v0.2.7
|
||||||
|
href: v0.2.7.md
|
||||||
|
- name: v0.2.6
|
||||||
|
href: v0.2.6.md
|
||||||
|
- name: v0.2.5
|
||||||
|
href: v0.2.5.md
|
||||||
|
- name: v0.2.4
|
||||||
|
href: v0.2.4.md
|
||||||
- name: v0.2.3
|
- name: v0.2.3
|
||||||
href: v0.2.3.md
|
href: v0.2.3.md
|
||||||
- name: v0.2.2
|
- name: v0.2.2
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# v0.2.4 - E2E Message Encryption
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### E2E Message Encryption
|
||||||
|
|
||||||
|
- Application-layer AES-256-GCM encryption for all message content between client and server
|
||||||
|
- Protects against ISPs, proxies, and any middleman reading chat messages — even if TLS is compromised
|
||||||
|
- 256-bit encryption key auto-generated on first server startup and saved to `appsettings.json`
|
||||||
|
- Client fetches the encryption key automatically after login via `GET /api/server/encryption-key`
|
||||||
|
- Client encrypts messages before sending via SignalR; server decrypts for validation and processing
|
||||||
|
- Server broadcasts encrypted content to SignalR clients; client decrypts transparently — no user action required
|
||||||
|
- Optional database encryption at rest via `Encryption:EncryptDatabase` setting (disabled by default)
|
||||||
|
- When enabled: new messages encrypted before DB storage (existing plaintext messages are not retroactively encrypted)
|
||||||
|
- When disabled: messages stored as plaintext, no risk of data loss from key rotation
|
||||||
|
- Reads handle mixed content (encrypted + plaintext) regardless of setting — safe to toggle at any time
|
||||||
|
- Key rotation is safe: old plaintext stays readable, new messages use the new key
|
||||||
|
- IRC gateway automatically decrypts messages before forwarding to IRC clients (plaintext over IRC)
|
||||||
|
- Encrypted content format: `$ENC$v1${nonce}${ciphertext+tag}` (Base64, 12-byte nonce, 16-byte auth tag)
|
||||||
|
- Server strips `$ENC$` prefix from user-typed messages to prevent format spoofing
|
||||||
|
- Graceful fallback: if decryption fails, shows `[encrypted message — decryption failed, try re-logging to fetch the latest key]`
|
||||||
|
|
||||||
|
## Infrastructure
|
||||||
|
|
||||||
|
- `IMessageEncryptionService` interface in Core; `MessageEncryptionService` server implementation and `ClientEncryptionService` client implementation
|
||||||
|
- `EncryptionKeyResponse` DTO and `GET /api/server/encryption-key` endpoint (authenticated, rate-limited)
|
||||||
|
- `FirstRunSetup.EnsureEncryptionKey()` auto-generates AES-256 key on first server run
|
||||||
|
- `Encryption:EncryptDatabase` server setting (default `false`) controls whether messages are encrypted at rest
|
||||||
|
- DB column max lengths increased for encrypted content: `Message.Content` 2000 → 16000, `Message.EmbedJson` 8000 → 32000
|
||||||
|
- EF Core migration: `AddEncryptionSupport`
|
||||||
|
- Encryption test suite: server-side, client-side, and cross-compatibility tests
|
||||||
|
- Documentation article: `docs/articles/encryption.md`
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# v0.2.5 - Session Persistence, Auto-Updates, Audio & Transparent Theme
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Audio Message Support
|
||||||
|
|
||||||
|
- New `Audio` message type — uploaded audio files (`.mp3`, `.wav`, `.ogg`, `.flac`, `.aac`, `.m4a`, `.wma`) are automatically detected and categorized
|
||||||
|
- TUI client renders audio messages with `♪ [Audio: filename] (Enter to play)` indicator
|
||||||
|
- Press Enter on an audio message to open the **Audio Player dialog** with animated wave visualization, play/pause/stop controls, and volume slider
|
||||||
|
- Per-type upload limits: **10 MB** images, **10 MB** audio, **100 MB** generic files (with Kestrel request size configured to match)
|
||||||
|
- `AudioPlaybackService` enhanced with pause/resume, volume control, and playback-finished events
|
||||||
|
- Fixed: wrapped audio/file messages now remain clickable on all lines (attachment metadata propagated through word-wrap)
|
||||||
|
- IRC gateway formats audio messages as `♪ [Audio: filename] url`
|
||||||
|
- Server detects audio files by extension via `FileValidationHelper.IsAudioFile()`
|
||||||
|
|
||||||
|
### File Downloads
|
||||||
|
|
||||||
|
- Press Enter on a file message in the TUI client to download and open it with the system default application
|
||||||
|
- `ApiClient.DownloadFileAsync()` streams file downloads from the server
|
||||||
|
- File and audio messages in the chat list now show colored indicators with interaction hints
|
||||||
|
|
||||||
|
### File Cleanup Service
|
||||||
|
|
||||||
|
- `FileCleanupService` (BackgroundService) periodically removes old uploaded files
|
||||||
|
- Configurable via `Storage:CleanupIntervalHours` (default 1h) and `Storage:RetentionDays` (default 30d)
|
||||||
|
|
||||||
|
### Data Migration Service
|
||||||
|
|
||||||
|
- `DataMigrationService` runs at startup to handle schema/data evolution
|
||||||
|
- Ensures `#general` channel and pre-existing channels are marked public
|
||||||
|
- Migrates legacy ANSI escape codes in image messages to printable color tags (`{F:RRGGBB}`, `{B:RRGGBB}`, `{X}`)
|
||||||
|
- Migrates legacy single-object `EmbedJson` to array format
|
||||||
|
- Promotes usernames listed in `Server:Admins` config to Admin role
|
||||||
|
|
||||||
|
### True Transparent Background
|
||||||
|
|
||||||
|
- Transparent theme now uses the terminal's native background instead of solid black
|
||||||
|
- Powered by `Color.None` (alpha=0) which emits ANSI `CSI 49m` (default background) instead of explicit RGB
|
||||||
|
- Terminal transparency, acrylic, wallpaper effects now show through the TUI
|
||||||
|
- Dialogs retain solid `DarkGray` background for readability
|
||||||
|
- Uses local Terminal.Gui fork (submodule) with transparent color support pending upstream merge ([gui-cs/Terminal.Gui#4234](https://github.com/gui-cs/Terminal.Gui/pull/4234))
|
||||||
|
|
||||||
|
### Enhanced Status Bar
|
||||||
|
|
||||||
|
- Status bar now shows **EchoHub** branding at the start
|
||||||
|
- Connection state is color-coded: green (Connected), red (Disconnected), yellow (transitional states like Connecting, Reconnecting, Authenticating)
|
||||||
|
- Current channel shows its type: `#channel - public` or `#channel - private`
|
||||||
|
|
||||||
|
### Remember Me (Session Persistence)
|
||||||
|
|
||||||
|
- "Remember me" checkbox in the connect dialog — saves a 30-day refresh token so users can reconnect without entering their password
|
||||||
|
- Saved servers with active sessions show `[session]` indicator in the connect dialog and saved servers list
|
||||||
|
- Token-based login: selecting a saved server with a session lets you click Login with an empty password
|
||||||
|
- Graceful expiry handling: if the saved session is expired or revoked, shows an error and prompts for password
|
||||||
|
- Refresh token rotation: rotated tokens are automatically persisted to config so the session stays valid across refreshes
|
||||||
|
- New "Logout" menu item (Server menu): revokes the refresh token server-side and clears the saved session
|
||||||
|
- Removed dead `SavedServer.Token` field (stored 15-min access token that was never read back)
|
||||||
|
|
||||||
|
### Automatic Update Checking
|
||||||
|
|
||||||
|
- `UpdateChecker` rewritten to use the [AlwaysUpToDate](https://github.com/AuriRex/AlwaysUpToDate) library for self-updating
|
||||||
|
- Polls the EchoHub version manifest hourly (active only in `RELEASE` builds)
|
||||||
|
- `UpdateConfirmDialog` shows current vs. available version with Update/Cancel buttons
|
||||||
|
- `UpdateProgressDialog` displays real-time download progress bar
|
||||||
|
- Errors are logged via Serilog instead of being silently swallowed
|
||||||
|
|
||||||
|
### Windows Installer
|
||||||
|
|
||||||
|
- New Inno Setup script (`installer/Installer.iss`) to build a Windows installer
|
||||||
|
- Auto-reads product version from the built EXE
|
||||||
|
- User-level install by default (no admin required), with admin override option
|
||||||
|
- Creates desktop and quick-launch shortcuts (optional)
|
||||||
|
- Supports English and German languages
|
||||||
|
|
||||||
|
### Application Icons
|
||||||
|
|
||||||
|
- New `hue_icon` branding assets (ICO, PNG, SVG) in `assets/`
|
||||||
|
- Application icon set on both Client and Server projects
|
||||||
|
|
||||||
|
### #general Channel Protection
|
||||||
|
|
||||||
|
- `#general` channel is now auto-recreated if somehow missing (both in `GetChannels` endpoint and `JoinChannel` flow)
|
||||||
|
- Users cannot `/leave` the #general channel (client-side guard)
|
||||||
|
- Connecting while already connected now prompts to disconnect first instead of silently leaking the previous connection
|
||||||
|
|
||||||
|
## Fixes
|
||||||
|
|
||||||
|
### IRC Gateway
|
||||||
|
|
||||||
|
- **Fixed: IRC JOIN history replay showed encrypted gibberish** — `IrcCommandHandler.HandleJoinAsync` now decrypts channel history before formatting for IRC clients (history was encrypted for SignalR transport but sent raw to IRC)
|
||||||
|
- **Fixed: `JoinedChannels` race condition** — `IrcClientConnection.JoinedChannels` replaced with thread-safe methods (`JoinChannel`, `LeaveChannel`, `IsInChannel`, `GetJoinedChannels`) using lock synchronization; prevents crashes when broadcaster threads read while the command handler writes
|
||||||
|
- **Fixed: `RequireRegistered` fire-and-forget** — converted from sync `bool` to `async Task<bool>` (`RequireRegisteredAsync`) so the error reply is properly awaited before the handler returns
|
||||||
|
|
||||||
|
### ChannelService Extraction
|
||||||
|
|
||||||
|
- Extracted channel management logic from `ChannelsController` and `ChatService` into a dedicated `IChannelService` / `ChannelService`
|
||||||
|
- `ChannelsController` is now a thin adapter — delegates CRUD operations to `IChannelService` and maps `ChannelError` to HTTP status codes
|
||||||
|
- `ChatService.JoinChannelAsync` delegates channel validation + membership to `IChannelService.EnsureChannelMembershipAsync()`
|
||||||
|
- New `ChannelOperationResult` result type with `ChannelError` enum for typed error handling across service boundaries
|
||||||
|
- IRC gateway uses `IChannelService` for topic queries and channel listing (instead of `IChatService`)
|
||||||
|
|
||||||
|
### EchoHub Branding
|
||||||
|
|
||||||
|
- Status bar "EchoHub" text now uses golden color (218, 165, 32)
|
||||||
|
- Extracted `ChatMessageManager` from `MainWindow` — message storage, formatting, and mutation logic now in a dedicated class, reducing MainWindow complexity
|
||||||
|
|
||||||
|
## Infrastructure
|
||||||
|
|
||||||
|
- Audio MIME types in `FilesController` (mp3, wav, ogg, flac, aac, m4a, wma)
|
||||||
|
- CI workflows updated to handle Terminal.Gui submodule `nuget.config` workaround (`rm -f` step)
|
||||||
|
- Root `nuget.config` added for package source management
|
||||||
|
- Terminal.Gui formatting excluded from CI format checks
|
||||||
|
- Recursive submodule fetching enabled in CI workflows
|
||||||
|
- Release workflow: use `--notes-file` instead of inline `--notes` interpolation to prevent shell expansion of special characters in commit messages
|
||||||
|
- `Server:Admins` config array in `appsettings.example.json` for designating admin usernames
|
||||||
|
- `Storage:CleanupIntervalHours` and `Storage:RetentionDays` added to `appsettings.example.json`
|
||||||
|
- `FakeChannelService` test helper added for IRC unit tests
|
||||||
|
- Test suites: ChatLine, CommandHandler, DataMigrationService, FileValidationHelper, ImageToAsciiService, IrcMessageFormatter, JwtTokenService, LinkEmbedService
|
||||||
|
- IRC abstraction layer test suite: IrcMessage parsing, IrcMessageFormatter, IrcClientConnection, IrcCommandHandler, IrcBroadcaster
|
||||||
|
- Test helpers: `TestDuplexStream`, `TestIrcConnectionFactory`, `FakeChatService`, `FakeChannelService`, `FakeEncryptionService` for IRC unit testing without network I/O
|
||||||
|
- 346 total tests
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# v0.2.6
|
||||||
|
|
||||||
|
## Refactoring
|
||||||
|
|
||||||
|
- Extracted `ChatMessageManager` from `MainWindow` — message storage, formatting, and mutation logic now in a dedicated class, reducing MainWindow complexity
|
||||||
|
- Split `ChatRenderer.cs` (8 classes, 548 lines) into 7 individual files: `ChatSegment`, `ChatLine`, `ChatListSource`, `ChannelListSource`, `UserListSource`, `ChatColors`, `ColorHelper`, `RenderHelpers`
|
||||||
|
- Extracted `ConnectionManager` from `AppOrchestrator` — connection lifecycle, authentication, SignalR event wiring, and channel tracking now in a dedicated service
|
||||||
|
- Extracted `AvatarHelper` — deduplicated avatar upload logic previously duplicated in `/avatar` command and profile edit dialog
|
||||||
|
- Consolidated `ProfileEditDialog.ParseHexToTrueColor` into shared `ColorHelper.ParseHexToColor`
|
||||||
|
- Extracted `UserSession` — session state (`Username`, `Status`, `StatusMessage`) now in a dedicated class instead of scattered fields
|
||||||
|
- Replaced 21 inline command handler lambdas with named `HandleCmd*` methods for improved readability
|
||||||
|
- Extracted shared `CleanupConnectionAsync` to deduplicate disconnect/logout cleanup logic
|
||||||
|
- Reorganized flat `UI/` folder (19 files) into subfolders: `Chat/`, `Dialogs/`, `ListSources/`, `Helpers/` with matching namespaces
|
||||||
|
- Moved `AsyncRunner` from project root to `Services/` with updated namespace
|
||||||
|
- Renamed `ColorHelper` → `HexColorHelper` to avoid namespace collision with Terminal.Gui's `ColorHelper` NuGet dependency
|
||||||
|
- Moved `hue_icon.ico` to `Client/Assets/`, removed duplicate from Server (Server now references shared icon via relative path)
|
||||||
|
- Add hex color parsing helper and implement custom list sources for channels and users
|
||||||
|
- Add dialogs for connection, channel creation, profile editing, and status management
|
||||||
|
|
||||||
|
## Infrastructure
|
||||||
|
|
||||||
|
- Release workflow: use `--notes-file` instead of inline `--notes` interpolation to prevent shell expansion of special characters in commit messages
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# v0.2.7
|
||||||
|
|
||||||
|
## Bug Fixes
|
||||||
|
|
||||||
|
- Fix user list empty on initial connect — `FetchAndUpdateOnlineUsers` was called before `InvokeUI` set the current channel, causing an early return
|
||||||
|
|
||||||
|
## Infrastructure
|
||||||
|
|
||||||
|
- Switch Terminal.Gui from local fork submodule back to NuGet package (`2.0.0-develop.5039`) — transparent color PR merged upstream
|
||||||
|
- Remove Terminal.Gui submodule, `.gitmodules`, and root `nuget.config` workaround
|
||||||
|
- Remove `rm -f` submodule nuget.config steps and `submodules: recursive` from all 3 CI workflows (ci, docs, release)
|
||||||
|
- Remove `--exclude src/Terminal.Gui/` from format check
|
||||||
Binary file not shown.
@@ -0,0 +1,52 @@
|
|||||||
|
; Script generated by the Inno Setup Script Wizard.
|
||||||
|
; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES!
|
||||||
|
|
||||||
|
#expr Exec('cmd.exe', '/C dotnet build -o "' + SourcePath + '\publish" -c Release ' + SourcePath + '..\src\EchoHub.Client\')
|
||||||
|
|
||||||
|
#define MyAppName "EchoHub"
|
||||||
|
#define MyAppVersion GetStringFileInfo("/publish/EchoHub.Client.exe","ProductVersion")
|
||||||
|
#define MyAppPublisher "Hue"
|
||||||
|
#define MyAppExeName "EchoHub.Client.exe"
|
||||||
|
|
||||||
|
[Setup]
|
||||||
|
; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications.
|
||||||
|
; (To generate a new GUID, click Tools | Generate GUID inside the IDE.)
|
||||||
|
AppId=c95b1292-3022-4c62-a131-4d46ace370f5
|
||||||
|
AppName={#MyAppName}
|
||||||
|
AppVersion={#MyAppVersion}
|
||||||
|
;AppVerName={#MyAppName} {#MyAppVersion}
|
||||||
|
AppPublisher={#MyAppPublisher}
|
||||||
|
DefaultDirName={autopf}\{#MyAppName}
|
||||||
|
DisableProgramGroupPage=yes
|
||||||
|
SetupIconFile=../assets/hue_icon.ico
|
||||||
|
; The [Icons] "quicklaunchicon" entry uses {userappdata} but its [Tasks] entry has a proper IsAdminInstallMode Check.
|
||||||
|
UsedUserAreasWarning=no
|
||||||
|
; Remove the following line to run in administrative install mode (install for all users.)
|
||||||
|
PrivilegesRequired=lowest
|
||||||
|
PrivilegesRequiredOverridesAllowed=dialog
|
||||||
|
OutputBaseFilename={#MyAppName}-Installer
|
||||||
|
OutputDir=.
|
||||||
|
Compression=lzma
|
||||||
|
SolidCompression=yes
|
||||||
|
WizardStyle=modern
|
||||||
|
|
||||||
|
[Languages]
|
||||||
|
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||||
|
Name: "german"; MessagesFile: "compiler:Languages\German.isl"
|
||||||
|
|
||||||
|
[Tasks]
|
||||||
|
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
|
||||||
|
Name: "quicklaunchicon"; Description: "{cm:CreateQuickLaunchIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked; OnlyBelowVersion: 6.1; Check: not IsAdminInstallMode
|
||||||
|
|
||||||
|
[Files]
|
||||||
|
Source: "publish\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs
|
||||||
|
; NOTE: Don't use "Flags: ignoreversion" on any shared system files
|
||||||
|
|
||||||
|
[Icons]
|
||||||
|
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
|
||||||
|
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
|
||||||
|
Name: "{userappdata}\Microsoft\Internet Explorer\Quick Launch\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: quicklaunchicon
|
||||||
|
|
||||||
|
[Run]
|
||||||
|
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>0.2.3</Version>
|
<Version>0.2.7</Version>
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -339,7 +339,7 @@ public class CommandHandler
|
|||||||
/nick <name> - Set display name
|
/nick <name> - Set display name
|
||||||
/color <#hex> - Set nickname color
|
/color <#hex> - Set nickname color
|
||||||
/theme <name> - Switch theme
|
/theme <name> - Switch theme
|
||||||
/send <filepath or URL> [-s|-m|-l] - Send a file or image (size: small/medium/large)
|
/send <filepath or URL> [-s|-m|-l] - Send file/image/audio (size flag for images)
|
||||||
/avatar <URL or filepath> - Set your avatar
|
/avatar <URL or filepath> - Set your avatar
|
||||||
/profile [username] - View a profile
|
/profile [username] - View a profile
|
||||||
/servers - Open saved servers
|
/servers - Open saved servers
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ public class SavedServer
|
|||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
public required string Url { get; set; }
|
public required string Url { get; set; }
|
||||||
public string? Username { get; set; }
|
public string? Username { get; set; }
|
||||||
public string? Token { get; set; }
|
public string? RefreshToken { get; set; }
|
||||||
|
public bool RememberMe { get; set; }
|
||||||
public DateTimeOffset LastConnected { get; set; }
|
public DateTimeOffset LastConnected { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,14 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="AlwaysUpToDate" Version="2.0.1" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.3" />
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.3" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.3" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.3" />
|
||||||
<PackageReference Include="NetCoreAudio" Version="2.0.1" />
|
<PackageReference Include="NetCoreAudio" Version="2.0.1" />
|
||||||
<PackageReference Include="Serilog" Version="4.3.1" />
|
<PackageReference Include="Serilog" Version="4.3.1" />
|
||||||
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
|
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
|
||||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||||
<PackageReference Include="Terminal.Gui" Version="2.0.0-develop.5027" />
|
<PackageReference Include="Terminal.Gui" Version="2.0.0-develop.5039" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -31,6 +32,8 @@
|
|||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
<PackageIcon></PackageIcon>
|
||||||
|
<ApplicationIcon>Assets\hue_icon.ico</ApplicationIcon>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ public sealed class ApiClient : IDisposable
|
|||||||
public string? RefreshToken => _refreshToken;
|
public string? RefreshToken => _refreshToken;
|
||||||
public string BaseUrl { get; }
|
public string BaseUrl { get; }
|
||||||
|
|
||||||
|
public event Action? OnTokensRefreshed;
|
||||||
|
|
||||||
public ApiClient(string baseUrl)
|
public ApiClient(string baseUrl)
|
||||||
{
|
{
|
||||||
BaseUrl = baseUrl.TrimEnd('/');
|
BaseUrl = baseUrl.TrimEnd('/');
|
||||||
@@ -68,6 +70,19 @@ public sealed class ApiClient : IDisposable
|
|||||||
SetTokens(result);
|
SetTokens(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<LoginResponse> LoginWithRefreshTokenAsync(string refreshToken)
|
||||||
|
{
|
||||||
|
var request = new RefreshRequest(refreshToken);
|
||||||
|
var response = await _http.PostAsJsonAsync("/api/auth/refresh", request);
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
|
||||||
|
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
|
||||||
|
?? throw new InvalidOperationException("Token refresh returned empty response.");
|
||||||
|
|
||||||
|
SetTokens(result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task LogoutAsync()
|
public async Task LogoutAsync()
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(_refreshToken))
|
if (!string.IsNullOrEmpty(_refreshToken))
|
||||||
@@ -128,6 +143,16 @@ public sealed class ApiClient : IDisposable
|
|||||||
return info;
|
return info;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<string> GetEncryptionKeyAsync()
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
var response = await AuthenticatedGetAsync("/api/server/encryption-key");
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
var result = await response.Content.ReadFromJsonAsync<EncryptionKeyResponse>()
|
||||||
|
?? throw new InvalidOperationException("Server returned empty encryption key response.");
|
||||||
|
return result.Key;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<UserProfileDto?> GetUserProfileAsync(string username)
|
public async Task<UserProfileDto?> GetUserProfileAsync(string username)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
@@ -186,6 +211,23 @@ public sealed class ApiClient : IDisposable
|
|||||||
return await response.Content.ReadFromJsonAsync<MessageDto>();
|
return await response.Content.ReadFromJsonAsync<MessageDto>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<string> DownloadFileToTempAsync(string relativeUrl, string fileName)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
var response = await AuthenticatedGetAsync(relativeUrl);
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
|
||||||
|
var tempDir = Path.Combine(Path.GetTempPath(), "EchoHub");
|
||||||
|
Directory.CreateDirectory(tempDir);
|
||||||
|
var tempPath = Path.Combine(tempDir, $"{Guid.NewGuid():N}_{fileName}");
|
||||||
|
|
||||||
|
await using var stream = await response.Content.ReadAsStreamAsync();
|
||||||
|
await using var file = File.Create(tempPath);
|
||||||
|
await stream.CopyToAsync(file);
|
||||||
|
|
||||||
|
return tempPath;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<ChannelDto?> CreateChannelAsync(string name, string? topic = null, bool isPublic = true)
|
public async Task<ChannelDto?> CreateChannelAsync(string name, string? topic = null, bool isPublic = true)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
@@ -286,6 +328,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
_refreshToken = result.RefreshToken;
|
_refreshToken = result.RefreshToken;
|
||||||
_expiresAt = result.ExpiresAt;
|
_expiresAt = result.ExpiresAt;
|
||||||
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
|
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
|
||||||
|
OnTokensRefreshed?.Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
using Serilog;
|
using Serilog;
|
||||||
using Terminal.Gui.App;
|
using Terminal.Gui.App;
|
||||||
|
|
||||||
namespace EchoHub.Client;
|
namespace EchoHub.Client.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Eliminates repeated Task.Run/try/catch/app.Invoke(ShowError) boilerplate.
|
/// Eliminates repeated Task.Run/try/catch/app.Invoke(ShowError) boilerplate.
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using NetCoreAudio;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.Services;
|
||||||
|
|
||||||
|
public class AudioPlaybackService
|
||||||
|
{
|
||||||
|
private readonly Player _player = new();
|
||||||
|
|
||||||
|
public bool IsPlaying => _player.Playing;
|
||||||
|
public bool IsPaused => _player.Paused;
|
||||||
|
|
||||||
|
public event EventHandler? PlaybackFinished;
|
||||||
|
|
||||||
|
public AudioPlaybackService()
|
||||||
|
{
|
||||||
|
_player.PlaybackFinished += (s, e) => PlaybackFinished?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task PlayAsync(string filePath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_player.Playing)
|
||||||
|
await _player.Stop();
|
||||||
|
|
||||||
|
await _player.Play(filePath);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Failed to play audio file: {Path}", filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task PauseAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_player.Playing && !_player.Paused)
|
||||||
|
await _player.Pause();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Failed to pause audio playback");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ResumeAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_player.Paused)
|
||||||
|
await _player.Resume();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Failed to resume audio playback");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StopAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_player.Playing)
|
||||||
|
await _player.Stop();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Failed to stop audio playback");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SetVolumeAsync(byte volume)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _player.SetVolume(Math.Min(volume, (byte)100));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Failed to set audio volume");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using Serilog;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared avatar upload logic — resolves a file path or URL to a stream
|
||||||
|
/// and uploads it via ApiClient.
|
||||||
|
/// </summary>
|
||||||
|
internal static class AvatarHelper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Upload an avatar from a local file path or HTTP(S) URL.
|
||||||
|
/// Returns the ASCII art response from the server.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<string?> UploadAsync(ApiClient apiClient, string target)
|
||||||
|
{
|
||||||
|
Stream stream;
|
||||||
|
string fileName;
|
||||||
|
|
||||||
|
if (Uri.TryCreate(target, UriKind.Absolute, out var uri)
|
||||||
|
&& (uri.Scheme == "http" || uri.Scheme == "https"))
|
||||||
|
{
|
||||||
|
using var http = new HttpClient();
|
||||||
|
var bytes = await http.GetByteArrayAsync(uri);
|
||||||
|
stream = new MemoryStream(bytes);
|
||||||
|
fileName = Path.GetFileName(uri.LocalPath);
|
||||||
|
if (string.IsNullOrWhiteSpace(fileName) || !fileName.Contains('.'))
|
||||||
|
fileName = "avatar.png";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (!File.Exists(target))
|
||||||
|
throw new FileNotFoundException($"File not found: {target}");
|
||||||
|
|
||||||
|
stream = File.OpenRead(target);
|
||||||
|
fileName = Path.GetFileName(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (stream)
|
||||||
|
{
|
||||||
|
return await apiClient.UploadAvatarAsync(stream, fileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using EchoHub.Core.Contracts;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Client-side encryption service. Uses the same AES-256-GCM format as the server
|
||||||
|
/// so messages are encrypted end-to-end between client and server.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ClientEncryptionService : IMessageEncryptionService
|
||||||
|
{
|
||||||
|
private const string EncryptionPrefix = "$ENC$v1$";
|
||||||
|
private const int NonceSizeBytes = 12;
|
||||||
|
private const int TagSizeBytes = 16;
|
||||||
|
|
||||||
|
private byte[]? _key;
|
||||||
|
|
||||||
|
public bool IsInitialized => _key is not null;
|
||||||
|
public bool EncryptDatabaseEnabled => false; // Not relevant for client
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initialize with the server's encryption key (fetched after login).
|
||||||
|
/// </summary>
|
||||||
|
public void SetKey(string base64Key)
|
||||||
|
{
|
||||||
|
_key = Convert.FromBase64String(base64Key);
|
||||||
|
|
||||||
|
if (_key.Length != 32)
|
||||||
|
throw new InvalidOperationException($"Encryption key must be exactly 32 bytes (256-bit). Got {_key.Length} bytes.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Encrypt(string plaintext)
|
||||||
|
{
|
||||||
|
if (_key is null)
|
||||||
|
return plaintext; // Not initialized — pass through
|
||||||
|
|
||||||
|
var plaintextBytes = Encoding.UTF8.GetBytes(plaintext);
|
||||||
|
var nonce = RandomNumberGenerator.GetBytes(NonceSizeBytes);
|
||||||
|
var ciphertext = new byte[plaintextBytes.Length];
|
||||||
|
var tag = new byte[TagSizeBytes];
|
||||||
|
|
||||||
|
using var aes = new AesGcm(_key, TagSizeBytes);
|
||||||
|
aes.Encrypt(nonce, plaintextBytes, ciphertext, tag);
|
||||||
|
|
||||||
|
var combined = new byte[ciphertext.Length + tag.Length];
|
||||||
|
Buffer.BlockCopy(ciphertext, 0, combined, 0, ciphertext.Length);
|
||||||
|
Buffer.BlockCopy(tag, 0, combined, ciphertext.Length, tag.Length);
|
||||||
|
|
||||||
|
return $"{EncryptionPrefix}{Convert.ToBase64String(nonce)}${Convert.ToBase64String(combined)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Decrypt(string content)
|
||||||
|
{
|
||||||
|
if (_key is null || !content.StartsWith(EncryptionPrefix))
|
||||||
|
return content; // Not initialized or legacy plaintext
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var payload = content[EncryptionPrefix.Length..];
|
||||||
|
var separatorIndex = payload.IndexOf('$');
|
||||||
|
if (separatorIndex < 0)
|
||||||
|
return content;
|
||||||
|
|
||||||
|
var nonceBase64 = payload[..separatorIndex];
|
||||||
|
var combinedBase64 = payload[(separatorIndex + 1)..];
|
||||||
|
|
||||||
|
var nonce = Convert.FromBase64String(nonceBase64);
|
||||||
|
var combined = Convert.FromBase64String(combinedBase64);
|
||||||
|
|
||||||
|
if (combined.Length < TagSizeBytes)
|
||||||
|
return content;
|
||||||
|
|
||||||
|
var ciphertextLength = combined.Length - TagSizeBytes;
|
||||||
|
var ciphertext = combined.AsSpan(0, ciphertextLength);
|
||||||
|
var tag = combined.AsSpan(ciphertextLength, TagSizeBytes);
|
||||||
|
var plaintext = new byte[ciphertextLength];
|
||||||
|
|
||||||
|
using var aes = new AesGcm(_key, TagSizeBytes);
|
||||||
|
aes.Decrypt(nonce, ciphertext, tag, plaintext);
|
||||||
|
|
||||||
|
return Encoding.UTF8.GetString(plaintext);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return "[encrypted message — decryption failed, try re-logging to fetch the latest key]";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? EncryptNullable(string? value)
|
||||||
|
=> value is null ? null : Encrypt(value);
|
||||||
|
|
||||||
|
public string? DecryptNullable(string? value)
|
||||||
|
=> value is null ? null : Decrypt(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
using EchoHub.Client.Config;
|
||||||
|
using EchoHub.Client.UI.Dialogs;
|
||||||
|
using EchoHub.Core.Constants;
|
||||||
|
using EchoHub.Core.DTOs;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of a successful connection, returned to AppOrchestrator for UI updates.
|
||||||
|
/// </summary>
|
||||||
|
internal record ConnectResult(
|
||||||
|
LoginResponse Login,
|
||||||
|
List<ChannelDto> Channels,
|
||||||
|
List<MessageDto> DefaultHistory);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owns connection lifecycle, authentication, SignalR event wiring, and channel tracking.
|
||||||
|
/// Fires events so AppOrchestrator can update the UI without managing connection internals.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ConnectionManager : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private EchoHubConnection? _connection;
|
||||||
|
private ApiClient? _apiClient;
|
||||||
|
private readonly ClientEncryptionService _encryption = new();
|
||||||
|
private readonly HashSet<string> _joinedChannels = [];
|
||||||
|
|
||||||
|
// ── Properties ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public bool IsConnected => _connection?.IsConnected == true;
|
||||||
|
public bool IsAuthenticated => _apiClient is not null;
|
||||||
|
public ApiClient? Api => _apiClient;
|
||||||
|
|
||||||
|
// ── Events (forwarded from SignalR) ───────────────────────────────────
|
||||||
|
|
||||||
|
public event Action<MessageDto>? MessageReceived;
|
||||||
|
public event Action<string, string>? UserJoined;
|
||||||
|
public event Action<string, string>? UserLeft;
|
||||||
|
public event Action<UserPresenceDto>? UserStatusChanged;
|
||||||
|
public event Action<string, string, string?>? UserKicked;
|
||||||
|
public event Action<string, string?>? UserBanned;
|
||||||
|
public event Action<string>? ForceDisconnected;
|
||||||
|
public event Action<string, Guid>? MessageDeleted;
|
||||||
|
public event Action<string>? ChannelNuked;
|
||||||
|
public event Action<ChannelDto>? ChannelUpdated;
|
||||||
|
public event Action<string>? Error;
|
||||||
|
public event Action<string>? ConnectionStatusChanged;
|
||||||
|
public event Action? Reconnected;
|
||||||
|
|
||||||
|
// ── Connect ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Full connection flow: authenticate → encryption → SignalR → join default channel.
|
||||||
|
/// Calls <paramref name="onStatus"/> with progress messages for UI updates.
|
||||||
|
/// Throws on auth failure (caller handles saved-session expiry, etc.).
|
||||||
|
/// </summary>
|
||||||
|
public async Task<ConnectResult> ConnectAsync(ConnectDialogResult info, Action<string> onStatus)
|
||||||
|
{
|
||||||
|
_apiClient?.Dispose();
|
||||||
|
_apiClient = new ApiClient(info.ServerUrl);
|
||||||
|
|
||||||
|
onStatus("Authenticating...");
|
||||||
|
|
||||||
|
LoginResponse loginResponse;
|
||||||
|
|
||||||
|
if (info.SavedRefreshToken is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
loginResponse = await _apiClient.LoginWithRefreshTokenAsync(info.SavedRefreshToken);
|
||||||
|
Log.Information("Authenticated via saved session for {User}", loginResponse.Username);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
_apiClient.Dispose();
|
||||||
|
_apiClient = null;
|
||||||
|
throw; // Caller handles saved-session expiry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (info.IsRegister)
|
||||||
|
{
|
||||||
|
loginResponse = await _apiClient.RegisterAsync(info.Username, info.Password);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
loginResponse = await _apiClient.LoginAsync(info.Username, info.Password);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-persist rotated refresh tokens for Remember Me
|
||||||
|
_apiClient.OnTokensRefreshed += HandleTokensRefreshed;
|
||||||
|
|
||||||
|
// E2E encryption key
|
||||||
|
onStatus("Fetching encryption key...");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var encryptionKey = await _apiClient.GetEncryptionKeyAsync();
|
||||||
|
_encryption.SetKey(encryptionKey);
|
||||||
|
Log.Information("E2E encryption key established");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Failed to fetch encryption key — messages will not be encrypted");
|
||||||
|
}
|
||||||
|
|
||||||
|
onStatus("Authenticated, connecting...");
|
||||||
|
|
||||||
|
if (_connection is not null)
|
||||||
|
await _connection.DisposeAsync();
|
||||||
|
|
||||||
|
_connection = new EchoHubConnection(info.ServerUrl, _apiClient, _encryption);
|
||||||
|
WireConnectionEvents(_connection);
|
||||||
|
await _connection.ConnectAsync();
|
||||||
|
|
||||||
|
var channels = await _apiClient.GetChannelsAsync();
|
||||||
|
onStatus("Connected");
|
||||||
|
|
||||||
|
// Join default channel + fetch history
|
||||||
|
_joinedChannels.Clear();
|
||||||
|
_joinedChannels.Add(HubConstants.DefaultChannel);
|
||||||
|
await _connection.JoinChannelAsync(HubConstants.DefaultChannel);
|
||||||
|
|
||||||
|
List<MessageDto> history = [];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
history = await _connection.GetHistoryAsync(HubConstants.DefaultChannel);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// History might not be available
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ConnectResult(loginResponse, channels, history);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cleanup ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Disconnect and dispose connection + API client, clear channel tracking.
|
||||||
|
/// </summary>
|
||||||
|
public async Task CleanupAsync()
|
||||||
|
{
|
||||||
|
if (_connection is not null)
|
||||||
|
{
|
||||||
|
await _connection.DisconnectAsync();
|
||||||
|
await _connection.DisposeAsync();
|
||||||
|
_connection = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_apiClient?.Dispose();
|
||||||
|
_apiClient = null;
|
||||||
|
_joinedChannels.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Revoke refresh token on the server. Call <see cref="CleanupAsync"/> afterwards.
|
||||||
|
/// </summary>
|
||||||
|
public async Task LogoutAsync()
|
||||||
|
{
|
||||||
|
if (_apiClient is not null)
|
||||||
|
await _apiClient.LogoutAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Channel Operations ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task<List<MessageDto>> JoinChannelAsync(string channelName)
|
||||||
|
{
|
||||||
|
if (_connection is null) throw new InvalidOperationException("Not connected");
|
||||||
|
_joinedChannels.Add(channelName);
|
||||||
|
return await _connection.JoinChannelAsync(channelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task LeaveChannelAsync(string channelName)
|
||||||
|
{
|
||||||
|
if (_connection is null) throw new InvalidOperationException("Not connected");
|
||||||
|
await _connection.LeaveChannelAsync(channelName);
|
||||||
|
_joinedChannels.Remove(channelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Track a channel as joined (returns true if newly added).
|
||||||
|
/// </summary>
|
||||||
|
public bool TrackChannel(string channelName) => _joinedChannels.Add(channelName);
|
||||||
|
|
||||||
|
public void UntrackChannel(string channelName) => _joinedChannels.Remove(channelName);
|
||||||
|
|
||||||
|
// ── Delegate Operations ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
public Task SendMessageAsync(string channel, string content) =>
|
||||||
|
_connection?.SendMessageAsync(channel, content)
|
||||||
|
?? throw new InvalidOperationException("Not connected");
|
||||||
|
|
||||||
|
public Task<List<MessageDto>> GetHistoryAsync(string channel) =>
|
||||||
|
_connection?.GetHistoryAsync(channel)
|
||||||
|
?? throw new InvalidOperationException("Not connected");
|
||||||
|
|
||||||
|
public Task<List<UserPresenceDto>> GetOnlineUsersAsync(string channel) =>
|
||||||
|
_connection?.GetOnlineUsersAsync(channel)
|
||||||
|
?? throw new InvalidOperationException("Not connected");
|
||||||
|
|
||||||
|
public Task UpdateStatusAsync(UserStatus status, string? message) =>
|
||||||
|
_connection?.UpdateStatusAsync(status, message)
|
||||||
|
?? throw new InvalidOperationException("Not connected");
|
||||||
|
|
||||||
|
// ── Reconnect ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rejoin all previously tracked channels after a reconnect.
|
||||||
|
/// </summary>
|
||||||
|
public async Task RejoinChannelsAsync()
|
||||||
|
{
|
||||||
|
var channels = _joinedChannels.ToList();
|
||||||
|
if (channels.Count == 0 || _connection is null) return;
|
||||||
|
|
||||||
|
_joinedChannels.Clear();
|
||||||
|
|
||||||
|
foreach (var channel in channels)
|
||||||
|
{
|
||||||
|
_joinedChannels.Add(channel);
|
||||||
|
await _connection.JoinChannelAsync(channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.Information("Rejoined {Count} channel(s) after reconnect", channels.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SignalR Event Wiring ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void WireConnectionEvents(EchoHubConnection connection)
|
||||||
|
{
|
||||||
|
connection.OnMessageReceived += msg => MessageReceived?.Invoke(msg);
|
||||||
|
connection.OnUserJoined += (ch, user) => UserJoined?.Invoke(ch, user);
|
||||||
|
connection.OnUserLeft += (ch, user) => UserLeft?.Invoke(ch, user);
|
||||||
|
connection.OnUserStatusChanged += p => UserStatusChanged?.Invoke(p);
|
||||||
|
connection.OnUserKicked += (ch, user, reason) => UserKicked?.Invoke(ch, user, reason);
|
||||||
|
connection.OnUserBanned += (user, reason) => UserBanned?.Invoke(user, reason);
|
||||||
|
connection.OnForceDisconnect += reason => ForceDisconnected?.Invoke(reason);
|
||||||
|
connection.OnMessageDeleted += (ch, id) => MessageDeleted?.Invoke(ch, id);
|
||||||
|
connection.OnChannelNuked += ch => ChannelNuked?.Invoke(ch);
|
||||||
|
connection.OnChannelUpdated += ch => ChannelUpdated?.Invoke(ch);
|
||||||
|
connection.OnError += msg => Error?.Invoke(msg);
|
||||||
|
connection.OnConnectionStateChanged += status => ConnectionStatusChanged?.Invoke(status);
|
||||||
|
connection.OnReconnected += () => Reconnected?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Token Persistence ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void HandleTokensRefreshed()
|
||||||
|
{
|
||||||
|
if (_apiClient?.RefreshToken is null) return;
|
||||||
|
var config = ConfigManager.Load();
|
||||||
|
var server = config.SavedServers.FirstOrDefault(s =>
|
||||||
|
string.Equals(s.Url, _apiClient.BaseUrl, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (server is not null && server.RememberMe)
|
||||||
|
{
|
||||||
|
server.RefreshToken = _apiClient.RefreshToken;
|
||||||
|
ConfigManager.Save(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dispose ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
_apiClient?.Dispose();
|
||||||
|
if (_connection is not null)
|
||||||
|
await _connection.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ namespace EchoHub.Client.Services;
|
|||||||
public sealed class EchoHubConnection : IAsyncDisposable
|
public sealed class EchoHubConnection : IAsyncDisposable
|
||||||
{
|
{
|
||||||
private readonly HubConnection _connection;
|
private readonly HubConnection _connection;
|
||||||
|
private readonly ClientEncryptionService _encryption;
|
||||||
|
|
||||||
public event Action<MessageDto>? OnMessageReceived;
|
public event Action<MessageDto>? OnMessageReceived;
|
||||||
public event Action<string, string>? OnUserJoined;
|
public event Action<string, string>? OnUserJoined;
|
||||||
@@ -25,8 +26,9 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
|||||||
|
|
||||||
public bool IsConnected => _connection.State == HubConnectionState.Connected;
|
public bool IsConnected => _connection.State == HubConnectionState.Connected;
|
||||||
|
|
||||||
public EchoHubConnection(string serverUrl, ApiClient apiClient)
|
public EchoHubConnection(string serverUrl, ApiClient apiClient, ClientEncryptionService encryption)
|
||||||
{
|
{
|
||||||
|
_encryption = encryption;
|
||||||
var hubUrl = serverUrl.TrimEnd('/') + HubConstants.ChatHubPath;
|
var hubUrl = serverUrl.TrimEnd('/') + HubConstants.ChatHubPath;
|
||||||
|
|
||||||
_connection = new HubConnectionBuilder()
|
_connection = new HubConnectionBuilder()
|
||||||
@@ -63,7 +65,9 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
_connection.On<MessageDto>(nameof(Core.Contracts.IEchoHubClient.ReceiveMessage), message =>
|
_connection.On<MessageDto>(nameof(Core.Contracts.IEchoHubClient.ReceiveMessage), message =>
|
||||||
{
|
{
|
||||||
OnMessageReceived?.Invoke(message);
|
// Decrypt message content received from server
|
||||||
|
var decrypted = message with { Content = _encryption.Decrypt(message.Content) };
|
||||||
|
OnMessageReceived?.Invoke(decrypted);
|
||||||
});
|
});
|
||||||
|
|
||||||
_connection.On<string, string>(nameof(Core.Contracts.IEchoHubClient.UserJoined), (channelName, username) =>
|
_connection.On<string, string>(nameof(Core.Contracts.IEchoHubClient.UserJoined), (channelName, username) =>
|
||||||
@@ -132,7 +136,8 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
|||||||
|
|
||||||
public async Task<List<MessageDto>> JoinChannelAsync(string channelName)
|
public async Task<List<MessageDto>> JoinChannelAsync(string channelName)
|
||||||
{
|
{
|
||||||
return await _connection.InvokeAsync<List<MessageDto>>("JoinChannel", channelName);
|
var messages = await _connection.InvokeAsync<List<MessageDto>>("JoinChannel", channelName);
|
||||||
|
return DecryptMessages(messages);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task LeaveChannelAsync(string channelName)
|
public async Task LeaveChannelAsync(string channelName)
|
||||||
@@ -142,12 +147,15 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
|||||||
|
|
||||||
public async Task SendMessageAsync(string channelName, string content)
|
public async Task SendMessageAsync(string channelName, string content)
|
||||||
{
|
{
|
||||||
await _connection.InvokeAsync("SendMessage", channelName, content);
|
// Encrypt content before sending to server
|
||||||
|
var encrypted = _encryption.Encrypt(content);
|
||||||
|
await _connection.InvokeAsync("SendMessage", channelName, encrypted);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<MessageDto>> GetHistoryAsync(string channelName, int count = HubConstants.DefaultHistoryCount)
|
public async Task<List<MessageDto>> GetHistoryAsync(string channelName, int count = HubConstants.DefaultHistoryCount)
|
||||||
{
|
{
|
||||||
return await _connection.InvokeAsync<List<MessageDto>>("GetChannelHistory", channelName, count);
|
var messages = await _connection.InvokeAsync<List<MessageDto>>("GetChannelHistory", channelName, count);
|
||||||
|
return DecryptMessages(messages);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateStatusAsync(UserStatus status, string? statusMessage = null)
|
public async Task UpdateStatusAsync(UserStatus status, string? statusMessage = null)
|
||||||
@@ -160,6 +168,11 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
|||||||
return await _connection.InvokeAsync<List<UserPresenceDto>>("GetOnlineUsers", channelName);
|
return await _connection.InvokeAsync<List<UserPresenceDto>>("GetOnlineUsers", channelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<MessageDto> DecryptMessages(List<MessageDto> messages)
|
||||||
|
{
|
||||||
|
return messages.Select(m => m with { Content = _encryption.Decrypt(m.Content) }).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
await _connection.DisposeAsync();
|
await _connection.DisposeAsync();
|
||||||
|
|||||||
@@ -1,47 +1,106 @@
|
|||||||
using System.Net.Http.Json;
|
using AlwaysUpToDate;
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
|
using EchoHub.Client.UI.Dialogs;
|
||||||
|
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
|
using Terminal.Gui.App;
|
||||||
|
|
||||||
namespace EchoHub.Client.Services;
|
namespace EchoHub.Client.Services;
|
||||||
|
|
||||||
public static class UpdateChecker
|
public sealed class UpdateChecker : IDisposable
|
||||||
{
|
{
|
||||||
private static readonly Uri ReleaseUrl =
|
private const string ManifestUrl = "https://echohub.voidcube.cloud/api/app/version";
|
||||||
new("https://api.github.com/repos/HueByte/EchoHub/releases/latest");
|
|
||||||
|
|
||||||
/// <summary>
|
private readonly Updater _updater;
|
||||||
/// Checks GitHub for a newer release. Returns the new version string if one exists, or null.
|
private readonly IApplication _app;
|
||||||
/// Never throws — all errors are silently swallowed.
|
private UpdateProgressDialog? _progressDialog;
|
||||||
/// </summary>
|
|
||||||
public static async Task<string?> CheckForUpdateAsync()
|
public static string CurrentVersion => typeof(UpdateChecker).Assembly.GetName().Version?.ToString(3) ?? "0.0.0";
|
||||||
|
|
||||||
|
public UpdateChecker(IApplication app)
|
||||||
{
|
{
|
||||||
try
|
_app = app;
|
||||||
{
|
_updater = new Updater(TimeSpan.FromHours(1), ManifestUrl, false);
|
||||||
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
|
|
||||||
http.DefaultRequestHeaders.UserAgent.ParseAdd("EchoHub-Client");
|
|
||||||
|
|
||||||
var release = await http.GetFromJsonAsync<GitHubRelease>(ReleaseUrl);
|
_updater.UpdateAvailable += OnUpdateAvailable;
|
||||||
if (release?.TagName is null)
|
_updater.ProgressChanged += OnProgressChanged;
|
||||||
return null;
|
_updater.UpdateStarted += OnUpdateStarted;
|
||||||
|
_updater.NoUpdateAvailable += OnNoUpdateAvailable;
|
||||||
var tag = release.TagName.TrimStart('v', 'V');
|
_updater.OnException += OnException;
|
||||||
if (!Version.TryParse(tag, out var latest))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
var currentStr = typeof(UpdateChecker).Assembly.GetName().Version?.ToString(3);
|
|
||||||
if (currentStr is null || !Version.TryParse(currentStr, out var current))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return latest > current ? tag : null;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class GitHubRelease
|
public void Start()
|
||||||
{
|
{
|
||||||
[JsonPropertyName("tag_name")]
|
#if RELEASE
|
||||||
public string? TagName { get; set; }
|
_updater.Start();
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnUpdateAvailable(string version, string changelogUrl)
|
||||||
|
{
|
||||||
|
Log.Information("Update available: v{Version}", version);
|
||||||
|
|
||||||
|
var confirmed = false;
|
||||||
|
_app.Invoke(() =>
|
||||||
|
{
|
||||||
|
confirmed = UpdateConfirmDialog.Show(_app, CurrentVersion, version);
|
||||||
|
|
||||||
|
|
||||||
|
if (confirmed)
|
||||||
|
{
|
||||||
|
_progressDialog = new UpdateProgressDialog(_app, version);
|
||||||
|
|
||||||
|
// Start the update; progress is reported via OnProgressChanged
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
await _updater.UpdateAsync();
|
||||||
|
});
|
||||||
|
|
||||||
|
_progressDialog?.Show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnProgressChanged(UpdateStep step, long itemsProcessed, long? totalItems, double? progressPercentage)
|
||||||
|
{
|
||||||
|
var fraction = progressPercentage.HasValue ? (float)(progressPercentage.Value / 100.0) : 0f;
|
||||||
|
var statusText = $"{step}: {itemsProcessed}/{totalItems ?? 0} ({progressPercentage ?? 0:F0}%)";
|
||||||
|
|
||||||
|
if (!progressPercentage.HasValue)
|
||||||
|
{
|
||||||
|
statusText = $"{step}...";
|
||||||
|
}
|
||||||
|
|
||||||
|
_progressDialog?.UpdateProgress(fraction, statusText);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnUpdateStarted(string version)
|
||||||
|
{
|
||||||
|
Log.Information("Update started: v{Version}", version);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnNoUpdateAvailable()
|
||||||
|
{
|
||||||
|
Log.Debug("No update available");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnException(Exception exception)
|
||||||
|
{
|
||||||
|
Log.Error(exception, "Update check failed");
|
||||||
|
_app.Invoke(() =>
|
||||||
|
{
|
||||||
|
_progressDialog?.Close();
|
||||||
|
_progressDialog = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_updater.UpdateAvailable -= OnUpdateAvailable;
|
||||||
|
_updater.ProgressChanged -= OnProgressChanged;
|
||||||
|
_updater.UpdateStarted -= OnUpdateStarted;
|
||||||
|
_updater.NoUpdateAvailable -= OnNoUpdateAvailable;
|
||||||
|
_updater.OnException -= OnException;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using EchoHub.Core.Models;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Holds the current user's session state (username, status, status message).
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class UserSession
|
||||||
|
{
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
public UserStatus Status { get; set; } = UserStatus.Online;
|
||||||
|
public string? StatusMessage { get; set; }
|
||||||
|
|
||||||
|
public void Reset()
|
||||||
|
{
|
||||||
|
Username = string.Empty;
|
||||||
|
Status = UserStatus.Online;
|
||||||
|
StatusMessage = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -418,30 +418,30 @@ public static class ThemeManager
|
|||||||
Base = new ThemeColors
|
Base = new ThemeColors
|
||||||
{
|
{
|
||||||
Foreground = "White",
|
Foreground = "White",
|
||||||
Background = "Black",
|
Background = "None",
|
||||||
FocusForeground = "BrightCyan",
|
FocusForeground = "BrightCyan",
|
||||||
FocusBackground = "Black"
|
FocusBackground = "None"
|
||||||
},
|
},
|
||||||
Menu = new ThemeColors
|
Menu = new ThemeColors
|
||||||
{
|
{
|
||||||
Foreground = "White",
|
Foreground = "White",
|
||||||
Background = "Black",
|
Background = "None",
|
||||||
FocusForeground = "BrightCyan",
|
FocusForeground = "BrightCyan",
|
||||||
FocusBackground = "Black"
|
FocusBackground = "None"
|
||||||
},
|
},
|
||||||
Dialog = new ThemeColors
|
Dialog = new ThemeColors
|
||||||
{
|
{
|
||||||
Foreground = "White",
|
Foreground = "White",
|
||||||
Background = "Black",
|
Background = "DarkGray",
|
||||||
FocusForeground = "BrightCyan",
|
FocusForeground = "BrightCyan",
|
||||||
FocusBackground = "Black"
|
FocusBackground = "Black"
|
||||||
},
|
},
|
||||||
Status = new ThemeColors
|
Status = new ThemeColors
|
||||||
{
|
{
|
||||||
Foreground = "Gray",
|
Foreground = "Gray",
|
||||||
Background = "Black",
|
Background = "None",
|
||||||
FocusForeground = "Gray",
|
FocusForeground = "Gray",
|
||||||
FocusBackground = "Black"
|
FocusBackground = "None"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Terminal.Gui.Drawing;
|
||||||
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI.Chat;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared color attributes for chat rendering (timestamps, system messages).
|
||||||
|
/// </summary>
|
||||||
|
public static partial class ChatColors
|
||||||
|
{
|
||||||
|
public static readonly Attribute TimestampAttr = new(Color.DarkGray, Color.None);
|
||||||
|
public static readonly Attribute SystemAttr = new(new Color(0, 180, 180), Color.None);
|
||||||
|
public static readonly Attribute MentionHighlightAttr = new(Color.White, new Color(80, 40, 0));
|
||||||
|
public static readonly Attribute MentionTextAttr = new(new Color(255, 180, 50), Color.None);
|
||||||
|
public static readonly Attribute EmbedBorderAttr = new(new Color(91, 155, 213), Color.None);
|
||||||
|
public static readonly Attribute EmbedTitleAttr = new(Color.White, Color.None);
|
||||||
|
public static readonly Attribute EmbedDescAttr = new(new Color(160, 160, 160), Color.None);
|
||||||
|
public static readonly Attribute EmbedUrlAttr = new(new Color(100, 100, 100), Color.None);
|
||||||
|
public static readonly Attribute AudioAttr = new(new Color(180, 100, 255), Color.None);
|
||||||
|
public static readonly Attribute FileAttr = new(new Color(100, 180, 255), Color.None);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Split text around @mentions, giving each @word the MentionTextAttr accent color.
|
||||||
|
/// Non-mention text uses the provided default color.
|
||||||
|
/// </summary>
|
||||||
|
public static List<ChatSegment> SplitMentions(string text, Attribute? defaultColor = null)
|
||||||
|
{
|
||||||
|
var segments = new List<ChatSegment>();
|
||||||
|
int lastIndex = 0;
|
||||||
|
|
||||||
|
foreach (Match match in MentionRegex().Matches(text))
|
||||||
|
{
|
||||||
|
if (match.Index > lastIndex)
|
||||||
|
segments.Add(new ChatSegment(text[lastIndex..match.Index], defaultColor));
|
||||||
|
|
||||||
|
segments.Add(new ChatSegment(match.Value, MentionTextAttr));
|
||||||
|
lastIndex = match.Index + match.Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < text.Length)
|
||||||
|
segments.Add(new ChatSegment(text[lastIndex..], defaultColor));
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex(@"@[\w-]+")]
|
||||||
|
private static partial Regex MentionRegex();
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
|
using Terminal.Gui.Drawing;
|
||||||
|
using Terminal.Gui.Text;
|
||||||
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI.Chat;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A single line in the chat, composed of colored segments.
|
||||||
|
/// </summary>
|
||||||
|
public partial class ChatLine
|
||||||
|
{
|
||||||
|
public List<ChatSegment> Segments { get; }
|
||||||
|
public int TextLength { get; }
|
||||||
|
public Guid? MessageId { get; set; }
|
||||||
|
public bool IsMention { get; set; }
|
||||||
|
public string? AttachmentUrl { get; set; }
|
||||||
|
public string? AttachmentFileName { get; set; }
|
||||||
|
public MessageType? Type { get; set; }
|
||||||
|
|
||||||
|
public ChatLine(string plainText)
|
||||||
|
{
|
||||||
|
Segments = [new ChatSegment(plainText, null)];
|
||||||
|
TextLength = plainText.GetColumns();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ChatLine(List<ChatSegment> segments)
|
||||||
|
{
|
||||||
|
Segments = segments;
|
||||||
|
TextLength = segments.Sum(s => s.Text.GetColumns());
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => string.Concat(Segments.Select(s => s.Text));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wrap this line into multiple lines that fit within the given width.
|
||||||
|
/// Continuation lines are indented with the specified number of spaces.
|
||||||
|
/// </summary>
|
||||||
|
public List<ChatLine> Wrap(int width, int continuationIndent = 0)
|
||||||
|
{
|
||||||
|
if (width <= 0 || TextLength <= width)
|
||||||
|
return [this];
|
||||||
|
|
||||||
|
var results = new List<ChatLine>();
|
||||||
|
var currentSegments = new List<ChatSegment>();
|
||||||
|
int col = 0;
|
||||||
|
|
||||||
|
foreach (var segment in Segments)
|
||||||
|
{
|
||||||
|
var text = segment.Text;
|
||||||
|
int chunkStart = 0;
|
||||||
|
int charPos = 0;
|
||||||
|
|
||||||
|
foreach (var grapheme in GraphemeHelper.GetGraphemes(text))
|
||||||
|
{
|
||||||
|
var graphemeCols = Math.Max(grapheme.GetColumns(), 1);
|
||||||
|
|
||||||
|
if (col + graphemeCols > width)
|
||||||
|
{
|
||||||
|
if (charPos > chunkStart)
|
||||||
|
currentSegments.Add(new ChatSegment(text[chunkStart..charPos], segment.Color));
|
||||||
|
|
||||||
|
results.Add(new ChatLine(currentSegments));
|
||||||
|
currentSegments = [];
|
||||||
|
|
||||||
|
if (continuationIndent > 0)
|
||||||
|
{
|
||||||
|
currentSegments.Add(new ChatSegment(new string(' ', continuationIndent), null));
|
||||||
|
col = continuationIndent;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
col = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
chunkStart = charPos;
|
||||||
|
}
|
||||||
|
|
||||||
|
col += graphemeCols;
|
||||||
|
charPos += grapheme.Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunkStart < text.Length)
|
||||||
|
currentSegments.Add(new ChatSegment(text[chunkStart..], segment.Color));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentSegments.Count > 0)
|
||||||
|
results.Add(new ChatLine(currentSegments));
|
||||||
|
|
||||||
|
// Propagate attachment/type metadata to all wrapped lines so they remain clickable
|
||||||
|
foreach (var wrapped in results)
|
||||||
|
{
|
||||||
|
wrapped.AttachmentUrl = AttachmentUrl;
|
||||||
|
wrapped.AttachmentFileName = AttachmentFileName;
|
||||||
|
wrapped.Type = Type;
|
||||||
|
wrapped.MessageId = MessageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if a line contains printable color tags.
|
||||||
|
/// </summary>
|
||||||
|
public static bool HasColorTags(string text) =>
|
||||||
|
text.Contains("{F:") || text.Contains("{B:") || text.Contains("{X}");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Remove all color tags from text, returning only the visible characters.
|
||||||
|
/// </summary>
|
||||||
|
public static string StripColorTags(string text) =>
|
||||||
|
ColorTagRegex().Replace(text, "");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse a string containing printable color tags into colored segments.
|
||||||
|
/// Format: {F:RRGGBB} (foreground), {B:RRGGBB} (background), {X} (reset).
|
||||||
|
/// </summary>
|
||||||
|
public static ChatLine FromColoredText(string text, Attribute? defaultAttr = null)
|
||||||
|
{
|
||||||
|
var segments = new List<ChatSegment>();
|
||||||
|
int lastIndex = 0;
|
||||||
|
Color? currentFg = null;
|
||||||
|
Color? currentBg = null;
|
||||||
|
var defaultFg = defaultAttr?.Foreground;
|
||||||
|
var defaultBg = defaultAttr?.Background ?? Color.None;
|
||||||
|
|
||||||
|
Attribute? BuildAttr()
|
||||||
|
{
|
||||||
|
if (currentFg is null && currentBg is null) return defaultAttr;
|
||||||
|
var fg = currentFg ?? defaultFg ?? Color.White;
|
||||||
|
var bg = currentBg ?? defaultBg;
|
||||||
|
return new Attribute(fg, bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (Match match in ColorTagRegex().Matches(text))
|
||||||
|
{
|
||||||
|
if (match.Index > lastIndex)
|
||||||
|
{
|
||||||
|
var t = text[lastIndex..match.Index];
|
||||||
|
if (t.Length > 0)
|
||||||
|
segments.Add(new ChatSegment(t, BuildAttr()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.Groups[1].Success)
|
||||||
|
{
|
||||||
|
currentFg = null;
|
||||||
|
currentBg = null;
|
||||||
|
}
|
||||||
|
else if (match.Groups[2].Success)
|
||||||
|
{
|
||||||
|
var hex = match.Groups[3].Value;
|
||||||
|
var r = Convert.ToInt32(hex[..2], 16);
|
||||||
|
var g = Convert.ToInt32(hex[2..4], 16);
|
||||||
|
var b = Convert.ToInt32(hex[4..6], 16);
|
||||||
|
if (match.Groups[2].Value == "F")
|
||||||
|
currentFg = new Color(r, g, b);
|
||||||
|
else
|
||||||
|
currentBg = new Color(r, g, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastIndex = match.Index + match.Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < text.Length)
|
||||||
|
{
|
||||||
|
var t = text[lastIndex..];
|
||||||
|
if (t.Length > 0)
|
||||||
|
segments.Add(new ChatSegment(t, BuildAttr()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments.Count > 0 ? new ChatLine(segments) : new ChatLine("");
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex(@"\{(?:(X)|(?:(F|B):([0-9A-Fa-f]{6})))\}")]
|
||||||
|
private static partial Regex ColorTagRegex();
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
using System.Collections;
|
||||||
|
using System.Collections.Specialized;
|
||||||
|
using Terminal.Gui.Drawing;
|
||||||
|
using Terminal.Gui.Text;
|
||||||
|
using Terminal.Gui.Views;
|
||||||
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI.Chat;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Custom list data source for chat messages with per-segment coloring.
|
||||||
|
/// </summary>
|
||||||
|
public class ChatListSource : IListDataSource
|
||||||
|
{
|
||||||
|
private readonly List<ChatLine> _lines = [];
|
||||||
|
|
||||||
|
public event NotifyCollectionChangedEventHandler? CollectionChanged;
|
||||||
|
|
||||||
|
public int Count => _lines.Count;
|
||||||
|
public int MaxItemLength { get; private set; }
|
||||||
|
public bool SuspendCollectionChangedEvent { get; set; }
|
||||||
|
|
||||||
|
public void Add(ChatLine line)
|
||||||
|
{
|
||||||
|
_lines.Add(line);
|
||||||
|
UpdateMaxLength(line);
|
||||||
|
RaiseCollectionChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddRange(IEnumerable<ChatLine> lines)
|
||||||
|
{
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
_lines.Add(line);
|
||||||
|
UpdateMaxLength(line);
|
||||||
|
}
|
||||||
|
RaiseCollectionChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void InsertRange(int index, IEnumerable<ChatLine> lines)
|
||||||
|
{
|
||||||
|
var items = lines.ToList();
|
||||||
|
_lines.InsertRange(index, items);
|
||||||
|
foreach (var line in items)
|
||||||
|
UpdateMaxLength(line);
|
||||||
|
RaiseCollectionChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
_lines.Clear();
|
||||||
|
MaxItemLength = 0;
|
||||||
|
RaiseCollectionChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ChatLine? GetLine(int index) => index >= 0 && index < _lines.Count ? _lines[index] : null;
|
||||||
|
|
||||||
|
public bool IsMarked(int item) => false;
|
||||||
|
public void SetMark(int item, bool value) { }
|
||||||
|
public IList ToList() => _lines.Select(l => l.ToString()).ToList();
|
||||||
|
|
||||||
|
public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0)
|
||||||
|
{
|
||||||
|
listView.Move(Math.Max(col - viewportX, 0), row);
|
||||||
|
|
||||||
|
var chatLine = _lines[item];
|
||||||
|
var normalAttr = listView.GetAttributeForRole(VisualRole.Normal);
|
||||||
|
var mentionBg = chatLine.IsMention ? ChatColors.MentionHighlightAttr.Background : (Color?)null;
|
||||||
|
|
||||||
|
int charPos = 0;
|
||||||
|
int drawnChars = 0;
|
||||||
|
|
||||||
|
foreach (var segment in chatLine.Segments)
|
||||||
|
{
|
||||||
|
var attr = segment.Color ?? normalAttr;
|
||||||
|
if (attr.Background == Color.None)
|
||||||
|
attr = attr with { Background = normalAttr.Background };
|
||||||
|
if (mentionBg.HasValue)
|
||||||
|
attr = attr with { Background = mentionBg.Value };
|
||||||
|
listView.SetAttribute(attr);
|
||||||
|
|
||||||
|
foreach (var grapheme in GraphemeHelper.GetGraphemes(segment.Text))
|
||||||
|
{
|
||||||
|
var cols = Math.Max(grapheme.GetColumns(), 1);
|
||||||
|
if (charPos >= viewportX && drawnChars + cols <= width)
|
||||||
|
{
|
||||||
|
listView.AddStr(grapheme);
|
||||||
|
drawnChars += cols;
|
||||||
|
}
|
||||||
|
charPos += cols;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var fillAttr = mentionBg.HasValue ? new Attribute(normalAttr.Foreground, mentionBg.Value) : normalAttr;
|
||||||
|
listView.SetAttribute(fillAttr);
|
||||||
|
for (int i = drawnChars; i < width; i++)
|
||||||
|
listView.AddStr(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateMaxLength(ChatLine line)
|
||||||
|
{
|
||||||
|
if (line.TextLength > MaxItemLength)
|
||||||
|
MaxItemLength = line.TextLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RaiseCollectionChanged()
|
||||||
|
{
|
||||||
|
if (!SuspendCollectionChangedEvent)
|
||||||
|
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using EchoHub.Client.UI.Helpers;
|
||||||
|
using EchoHub.Core.DTOs;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
|
using Terminal.Gui.Drawing;
|
||||||
|
using Terminal.Gui.Text;
|
||||||
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI.Chat;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owns chat message storage, formatting, and mutation.
|
||||||
|
/// Fires <see cref="MessagesChanged"/> when a channel's message list is modified
|
||||||
|
/// so the UI layer can refresh.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ChatMessageManager
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, List<ChatLine>> _channelMessages = [];
|
||||||
|
private readonly Dictionary<string, int> _channelUnread = [];
|
||||||
|
|
||||||
|
private string _currentUser = string.Empty;
|
||||||
|
private string _currentChannel = string.Empty;
|
||||||
|
private int _chatWidth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired after any mutation to a channel's messages. Parameter is the channel name.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<string>? MessagesChanged;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The currently active channel (used for unread tracking and @mention detection).
|
||||||
|
/// </summary>
|
||||||
|
public string CurrentChannel
|
||||||
|
{
|
||||||
|
get => _currentChannel;
|
||||||
|
set => _currentChannel = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string CurrentUser => _currentUser;
|
||||||
|
|
||||||
|
public void SetCurrentUser(string username) => _currentUser = username;
|
||||||
|
|
||||||
|
public void SetChatWidth(int width) => _chatWidth = width;
|
||||||
|
|
||||||
|
// ── Queries ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public List<ChatLine>? GetMessages(string channelName)
|
||||||
|
{
|
||||||
|
return _channelMessages.TryGetValue(channelName, out var messages) ? messages : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int GetUnreadCount(string channelName)
|
||||||
|
{
|
||||||
|
return _channelUnread.TryGetValue(channelName, out var count) ? count : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearUnread(string channelName)
|
||||||
|
{
|
||||||
|
_channelUnread[channelName] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal Dictionary<string, int> GetUnreadCounts() => _channelUnread;
|
||||||
|
|
||||||
|
// ── Mutations ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Format and store a received message. Increments unread count if not the active channel.
|
||||||
|
/// </summary>
|
||||||
|
public void AddMessage(MessageDto message)
|
||||||
|
{
|
||||||
|
var lines = FormatMessage(message);
|
||||||
|
if (!_channelMessages.TryGetValue(message.ChannelName, out var messages))
|
||||||
|
{
|
||||||
|
messages = [];
|
||||||
|
_channelMessages[message.ChannelName] = messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var line in lines)
|
||||||
|
messages.Add(line);
|
||||||
|
|
||||||
|
if (message.ChannelName == _currentChannel)
|
||||||
|
{
|
||||||
|
MessagesChanged?.Invoke(message.ChannelName);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_channelUnread.TryGetValue(message.ChannelName, out var count);
|
||||||
|
_channelUnread[message.ChannelName] = count + 1;
|
||||||
|
MessagesChanged?.Invoke(message.ChannelName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Add a system/informational message to a channel with colored styling.
|
||||||
|
/// </summary>
|
||||||
|
public void AddSystemMessage(string channelName, string text)
|
||||||
|
{
|
||||||
|
if (!_channelMessages.TryGetValue(channelName, out var messages))
|
||||||
|
{
|
||||||
|
messages = [];
|
||||||
|
_channelMessages[channelName] = messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
var time = DateTimeOffset.Now.ToString("HH:mm");
|
||||||
|
var textLines = text.Split('\n');
|
||||||
|
|
||||||
|
messages.Add(new ChatLine(
|
||||||
|
[
|
||||||
|
new($"[{time}] ", ChatColors.TimestampAttr),
|
||||||
|
new($"** {textLines[0].TrimEnd('\r')}", ChatColors.SystemAttr)
|
||||||
|
]));
|
||||||
|
|
||||||
|
var indent = new string(' ', $"[{time}] ** ".Length);
|
||||||
|
for (int i = 1; i < textLines.Length; i++)
|
||||||
|
{
|
||||||
|
var line = textLines[i].TrimEnd('\r');
|
||||||
|
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||||
|
messages.Add(new ChatLine(
|
||||||
|
[
|
||||||
|
new($"{indent}{line}", ChatColors.SystemAttr)
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channelName == _currentChannel)
|
||||||
|
MessagesChanged?.Invoke(channelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Add a status change message to a channel with colored styling.
|
||||||
|
/// </summary>
|
||||||
|
public void AddStatusMessage(string channelName, string username, string status)
|
||||||
|
{
|
||||||
|
var time = DateTimeOffset.Now.ToString("HH:mm");
|
||||||
|
var segments = new List<ChatSegment>
|
||||||
|
{
|
||||||
|
new($"[{time}] ", ChatColors.TimestampAttr),
|
||||||
|
new($"** {username} is now {status}", ChatColors.SystemAttr)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!_channelMessages.TryGetValue(channelName, out var messages))
|
||||||
|
{
|
||||||
|
messages = [];
|
||||||
|
_channelMessages[channelName] = messages;
|
||||||
|
}
|
||||||
|
messages.Add(new ChatLine(segments));
|
||||||
|
|
||||||
|
if (channelName == _currentChannel)
|
||||||
|
MessagesChanged?.Invoke(channelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Remove all lines associated with a specific message ID.
|
||||||
|
/// </summary>
|
||||||
|
public void RemoveMessage(string channelName, Guid messageId)
|
||||||
|
{
|
||||||
|
if (_channelMessages.TryGetValue(channelName, out var messages))
|
||||||
|
{
|
||||||
|
messages.RemoveAll(l => l.MessageId == messageId);
|
||||||
|
if (channelName == _currentChannel)
|
||||||
|
MessagesChanged?.Invoke(channelName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clear all messages from a specific channel.
|
||||||
|
/// </summary>
|
||||||
|
public void ClearChannelMessages(string channelName)
|
||||||
|
{
|
||||||
|
if (_channelMessages.TryGetValue(channelName, out var messages))
|
||||||
|
{
|
||||||
|
messages.Clear();
|
||||||
|
if (channelName == _currentChannel)
|
||||||
|
MessagesChanged?.Invoke(channelName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Load historical messages into a channel, replacing any existing messages.
|
||||||
|
/// </summary>
|
||||||
|
public void LoadHistory(string channelName, List<MessageDto> messages)
|
||||||
|
{
|
||||||
|
var formatted = messages.SelectMany(FormatMessage).ToList();
|
||||||
|
_channelMessages[channelName] = formatted;
|
||||||
|
|
||||||
|
if (channelName == _currentChannel)
|
||||||
|
MessagesChanged?.Invoke(channelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reset all message state (used on disconnect).
|
||||||
|
/// </summary>
|
||||||
|
public void ClearAll()
|
||||||
|
{
|
||||||
|
_channelMessages.Clear();
|
||||||
|
_channelUnread.Clear();
|
||||||
|
_currentChannel = string.Empty;
|
||||||
|
_currentUser = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Formatting ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private List<ChatLine> FormatMessage(MessageDto message)
|
||||||
|
{
|
||||||
|
var time = message.SentAt.ToLocalTime().ToString("HH:mm");
|
||||||
|
var senderName = message.SenderUsername + ":";
|
||||||
|
var senderColor = HexColorHelper.ParseHexColor(message.SenderNicknameColor);
|
||||||
|
|
||||||
|
var lines = new List<ChatLine>();
|
||||||
|
|
||||||
|
switch (message.Type)
|
||||||
|
{
|
||||||
|
case MessageType.Image:
|
||||||
|
lines.Add(BuildChatLine(time, senderName, senderColor, " [Image]"));
|
||||||
|
if (!string.IsNullOrWhiteSpace(message.Content))
|
||||||
|
{
|
||||||
|
foreach (var artLine in message.Content.Split('\n'))
|
||||||
|
{
|
||||||
|
var trimmed = artLine.TrimEnd('\r');
|
||||||
|
if (ChatLine.HasColorTags(trimmed))
|
||||||
|
lines.Add(ChatLine.FromColoredText(" " + trimmed));
|
||||||
|
else
|
||||||
|
lines.Add(new ChatLine($" {trimmed}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MessageType.Audio:
|
||||||
|
var audioName = message.AttachmentFileName ?? "unknown";
|
||||||
|
var audioSize = FormatFileSize(message.AttachmentFileSize);
|
||||||
|
var audioLine = BuildChatLineColored(time, senderName, senderColor,
|
||||||
|
$" \u266a [Audio: {audioName}] [{audioSize}]", ChatColors.AudioAttr);
|
||||||
|
audioLine.AttachmentUrl = message.AttachmentUrl;
|
||||||
|
audioLine.AttachmentFileName = audioName;
|
||||||
|
audioLine.Type = MessageType.Audio;
|
||||||
|
lines.Add(audioLine);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MessageType.File:
|
||||||
|
var fileName = message.AttachmentFileName ?? "unknown";
|
||||||
|
var fileSize = FormatFileSize(message.AttachmentFileSize);
|
||||||
|
var fileLine = BuildChatLineColored(time, senderName, senderColor,
|
||||||
|
$" [File: {fileName}] [{fileSize}]", ChatColors.FileAttr);
|
||||||
|
fileLine.AttachmentUrl = message.AttachmentUrl;
|
||||||
|
fileLine.AttachmentFileName = fileName;
|
||||||
|
fileLine.Type = MessageType.File;
|
||||||
|
lines.Add(fileLine);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MessageType.Text:
|
||||||
|
default:
|
||||||
|
var displayContent = EmojiHelper.ReplaceEmoji(message.Content);
|
||||||
|
var contentLines = displayContent.Split('\n');
|
||||||
|
var firstLine = contentLines[0].TrimEnd('\r');
|
||||||
|
lines.Add(BuildChatLineWithMentions(time, senderName, senderColor, $" {firstLine}"));
|
||||||
|
var indent = new string(' ', $"[{time}] {senderName} ".Length);
|
||||||
|
for (int i = 1; i < contentLines.Length; i++)
|
||||||
|
{
|
||||||
|
var contText = $"{indent}{contentLines[i].TrimEnd('\r')}";
|
||||||
|
lines.Add(new ChatLine(ChatColors.SplitMentions(contText)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.Embeds is { Count: > 0 })
|
||||||
|
{
|
||||||
|
var chatWidth = _chatWidth > 0 ? _chatWidth : 80;
|
||||||
|
foreach (var embed in message.Embeds)
|
||||||
|
lines.AddRange(FormatEmbed(embed, indent, chatWidth));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var line in lines)
|
||||||
|
line.MessageId = message.Id;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(_currentUser) && message.Type == MessageType.Text)
|
||||||
|
{
|
||||||
|
var pattern = $@"@{Regex.Escape(_currentUser)}\b";
|
||||||
|
if (Regex.IsMatch(message.Content, pattern, RegexOptions.IgnoreCase))
|
||||||
|
{
|
||||||
|
foreach (var line in lines)
|
||||||
|
line.IsMention = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChatLine BuildChatLine(string time, string senderName, Attribute? senderColor, string suffix)
|
||||||
|
{
|
||||||
|
var segments = new List<ChatSegment>
|
||||||
|
{
|
||||||
|
new($"[{time}] ", ChatColors.TimestampAttr),
|
||||||
|
new(senderName, senderColor),
|
||||||
|
new(suffix, null)
|
||||||
|
};
|
||||||
|
return new ChatLine(segments);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChatLine BuildChatLineColored(string time, string senderName, Attribute? senderColor, string suffix, Attribute suffixColor)
|
||||||
|
{
|
||||||
|
var segments = new List<ChatSegment>
|
||||||
|
{
|
||||||
|
new($"[{time}] ", ChatColors.TimestampAttr),
|
||||||
|
new(senderName, senderColor),
|
||||||
|
new(suffix, suffixColor)
|
||||||
|
};
|
||||||
|
return new ChatLine(segments);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChatLine BuildChatLineWithMentions(string time, string senderName, Attribute? senderColor, string suffix)
|
||||||
|
{
|
||||||
|
var segments = new List<ChatSegment>
|
||||||
|
{
|
||||||
|
new($"[{time}] ", ChatColors.TimestampAttr),
|
||||||
|
new(senderName, senderColor),
|
||||||
|
};
|
||||||
|
segments.AddRange(ChatColors.SplitMentions(suffix));
|
||||||
|
return new ChatLine(segments);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<ChatLine> FormatEmbed(EmbedDto embed, string indent, int chatWidth)
|
||||||
|
{
|
||||||
|
var lines = new List<ChatLine>();
|
||||||
|
const string border = "\u258f "; // ▏ + space
|
||||||
|
const int borderCols = 2;
|
||||||
|
int indentCols = indent.GetColumns();
|
||||||
|
int textWidth = chatWidth - indentCols - borderCols;
|
||||||
|
if (textWidth < 20) textWidth = 20;
|
||||||
|
|
||||||
|
void AddTextLine(string text, Attribute? color)
|
||||||
|
{
|
||||||
|
lines.Add(new ChatLine(
|
||||||
|
[
|
||||||
|
new ChatSegment(indent, null),
|
||||||
|
new ChatSegment(border, ChatColors.EmbedBorderAttr),
|
||||||
|
new ChatSegment(text, color)
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(embed.SiteName))
|
||||||
|
AddTextLine(embed.SiteName, ChatColors.EmbedBorderAttr);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(embed.Title))
|
||||||
|
{
|
||||||
|
foreach (var wrapped in WordWrap(embed.Title, textWidth))
|
||||||
|
AddTextLine(wrapped, ChatColors.EmbedTitleAttr);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(embed.Description))
|
||||||
|
{
|
||||||
|
foreach (var wrapped in WordWrap(embed.Description, textWidth))
|
||||||
|
AddTextLine(wrapped, ChatColors.EmbedDescAttr);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> WordWrap(string text, int maxCols)
|
||||||
|
{
|
||||||
|
if (maxCols <= 0)
|
||||||
|
return [text];
|
||||||
|
|
||||||
|
var result = new List<string>();
|
||||||
|
var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
var currentLine = "";
|
||||||
|
|
||||||
|
foreach (var word in words)
|
||||||
|
{
|
||||||
|
var candidate = currentLine.Length == 0 ? word : currentLine + " " + word;
|
||||||
|
if (candidate.GetColumns() <= maxCols)
|
||||||
|
{
|
||||||
|
currentLine = candidate;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (currentLine.Length > 0)
|
||||||
|
result.Add(currentLine);
|
||||||
|
currentLine = word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentLine.Length > 0)
|
||||||
|
result.Add(currentLine);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string FormatFileSize(long? bytes)
|
||||||
|
{
|
||||||
|
if (bytes is null or 0)
|
||||||
|
return "?";
|
||||||
|
|
||||||
|
return bytes.Value switch
|
||||||
|
{
|
||||||
|
< 1024 => $"{bytes.Value} B",
|
||||||
|
< 1024 * 1024 => $"{bytes.Value / 1024.0:F1} KB",
|
||||||
|
< 1024 * 1024 * 1024 => $"{bytes.Value / (1024.0 * 1024.0):F1} MB",
|
||||||
|
_ => $"{bytes.Value / (1024.0 * 1024.0 * 1024.0):F1} GB"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI.Chat;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A colored text segment within a chat line.
|
||||||
|
/// </summary>
|
||||||
|
public record ChatSegment(string Text, Attribute? Color);
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using Terminal.Gui.Drawing;
|
||||||
|
using Terminal.Gui.Text;
|
||||||
|
using Terminal.Gui.Views;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI.Chat;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared rendering helpers for IListDataSource implementations.
|
||||||
|
/// </summary>
|
||||||
|
static class RenderHelpers
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Write text grapheme-by-grapheme to a ListView, respecting a width limit.
|
||||||
|
/// Returns the updated drawn-columns count.
|
||||||
|
/// </summary>
|
||||||
|
public static int WriteText(ListView lv, string text, int drawn, int maxWidth)
|
||||||
|
{
|
||||||
|
foreach (var grapheme in GraphemeHelper.GetGraphemes(text))
|
||||||
|
{
|
||||||
|
var cols = Math.Max(grapheme.GetColumns(), 1);
|
||||||
|
if (drawn + cols > maxWidth) break;
|
||||||
|
lv.AddStr(grapheme);
|
||||||
|
drawn += cols;
|
||||||
|
}
|
||||||
|
return drawn;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,523 +0,0 @@
|
|||||||
using System.Collections;
|
|
||||||
using System.Collections.Specialized;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using Terminal.Gui.Drawing;
|
|
||||||
using Terminal.Gui.Text;
|
|
||||||
using Terminal.Gui.Views;
|
|
||||||
using Attribute = Terminal.Gui.Drawing.Attribute;
|
|
||||||
|
|
||||||
namespace EchoHub.Client.UI;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A colored text segment within a chat line.
|
|
||||||
/// </summary>
|
|
||||||
public record ChatSegment(string Text, Attribute? Color);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A single line in the chat, composed of colored segments.
|
|
||||||
/// </summary>
|
|
||||||
public partial class ChatLine
|
|
||||||
{
|
|
||||||
public List<ChatSegment> Segments { get; }
|
|
||||||
public int TextLength { get; }
|
|
||||||
public Guid? MessageId { get; set; }
|
|
||||||
public bool IsMention { get; set; }
|
|
||||||
|
|
||||||
public ChatLine(string plainText)
|
|
||||||
{
|
|
||||||
Segments = [new ChatSegment(plainText, null)];
|
|
||||||
TextLength = plainText.GetColumns();
|
|
||||||
}
|
|
||||||
|
|
||||||
public ChatLine(List<ChatSegment> segments)
|
|
||||||
{
|
|
||||||
Segments = segments;
|
|
||||||
TextLength = segments.Sum(s => s.Text.GetColumns());
|
|
||||||
}
|
|
||||||
|
|
||||||
public override string ToString() => string.Concat(Segments.Select(s => s.Text));
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Wrap this line into multiple lines that fit within the given width.
|
|
||||||
/// Continuation lines are indented with the specified number of spaces.
|
|
||||||
/// </summary>
|
|
||||||
public List<ChatLine> Wrap(int width, int continuationIndent = 0)
|
|
||||||
{
|
|
||||||
if (width <= 0 || TextLength <= width)
|
|
||||||
return [this];
|
|
||||||
|
|
||||||
var results = new List<ChatLine>();
|
|
||||||
var currentSegments = new List<ChatSegment>();
|
|
||||||
int col = 0;
|
|
||||||
|
|
||||||
foreach (var segment in Segments)
|
|
||||||
{
|
|
||||||
var text = segment.Text;
|
|
||||||
int chunkStart = 0;
|
|
||||||
int charPos = 0;
|
|
||||||
|
|
||||||
foreach (var grapheme in GraphemeHelper.GetGraphemes(text))
|
|
||||||
{
|
|
||||||
var graphemeCols = Math.Max(grapheme.GetColumns(), 1);
|
|
||||||
|
|
||||||
if (col + graphemeCols > width)
|
|
||||||
{
|
|
||||||
if (charPos > chunkStart)
|
|
||||||
currentSegments.Add(new ChatSegment(text[chunkStart..charPos], segment.Color));
|
|
||||||
|
|
||||||
results.Add(new ChatLine(currentSegments));
|
|
||||||
currentSegments = [];
|
|
||||||
|
|
||||||
if (continuationIndent > 0)
|
|
||||||
{
|
|
||||||
currentSegments.Add(new ChatSegment(new string(' ', continuationIndent), null));
|
|
||||||
col = continuationIndent;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
col = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
chunkStart = charPos;
|
|
||||||
}
|
|
||||||
|
|
||||||
col += graphemeCols;
|
|
||||||
charPos += grapheme.Length;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chunkStart < text.Length)
|
|
||||||
currentSegments.Add(new ChatSegment(text[chunkStart..], segment.Color));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentSegments.Count > 0)
|
|
||||||
results.Add(new ChatLine(currentSegments));
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns true if a line contains printable color tags.
|
|
||||||
/// </summary>
|
|
||||||
public static bool HasColorTags(string text) =>
|
|
||||||
text.Contains("{F:") || text.Contains("{B:") || text.Contains("{X}");
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Remove all color tags from text, returning only the visible characters.
|
|
||||||
/// </summary>
|
|
||||||
public static string StripColorTags(string text) =>
|
|
||||||
ColorTagRegex().Replace(text, "");
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Parse a string containing printable color tags into colored segments.
|
|
||||||
/// Format: {F:RRGGBB} (foreground), {B:RRGGBB} (background), {X} (reset).
|
|
||||||
/// </summary>
|
|
||||||
public static ChatLine FromColoredText(string text, Attribute? defaultAttr = null)
|
|
||||||
{
|
|
||||||
var segments = new List<ChatSegment>();
|
|
||||||
int lastIndex = 0;
|
|
||||||
Color? currentFg = null;
|
|
||||||
Color? currentBg = null;
|
|
||||||
var defaultFg = defaultAttr?.Foreground;
|
|
||||||
var defaultBg = defaultAttr?.Background ?? Color.Black;
|
|
||||||
|
|
||||||
Attribute? BuildAttr()
|
|
||||||
{
|
|
||||||
if (currentFg is null && currentBg is null) return defaultAttr;
|
|
||||||
var fg = currentFg ?? defaultFg ?? Color.White;
|
|
||||||
var bg = currentBg ?? defaultBg;
|
|
||||||
return new Attribute(fg, bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (Match match in ColorTagRegex().Matches(text))
|
|
||||||
{
|
|
||||||
if (match.Index > lastIndex)
|
|
||||||
{
|
|
||||||
var t = text[lastIndex..match.Index];
|
|
||||||
if (t.Length > 0)
|
|
||||||
segments.Add(new ChatSegment(t, BuildAttr()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (match.Groups[1].Success)
|
|
||||||
{
|
|
||||||
currentFg = null;
|
|
||||||
currentBg = null;
|
|
||||||
}
|
|
||||||
else if (match.Groups[2].Success)
|
|
||||||
{
|
|
||||||
var hex = match.Groups[3].Value;
|
|
||||||
var r = Convert.ToInt32(hex[..2], 16);
|
|
||||||
var g = Convert.ToInt32(hex[2..4], 16);
|
|
||||||
var b = Convert.ToInt32(hex[4..6], 16);
|
|
||||||
if (match.Groups[2].Value == "F")
|
|
||||||
currentFg = new Color(r, g, b);
|
|
||||||
else
|
|
||||||
currentBg = new Color(r, g, b);
|
|
||||||
}
|
|
||||||
|
|
||||||
lastIndex = match.Index + match.Length;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastIndex < text.Length)
|
|
||||||
{
|
|
||||||
var t = text[lastIndex..];
|
|
||||||
if (t.Length > 0)
|
|
||||||
segments.Add(new ChatSegment(t, BuildAttr()));
|
|
||||||
}
|
|
||||||
|
|
||||||
return segments.Count > 0 ? new ChatLine(segments) : new ChatLine("");
|
|
||||||
}
|
|
||||||
|
|
||||||
[GeneratedRegex(@"\{(?:(X)|(?:(F|B):([0-9A-Fa-f]{6})))\}")]
|
|
||||||
private static partial Regex ColorTagRegex();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Custom list data source for chat messages with per-segment coloring.
|
|
||||||
/// </summary>
|
|
||||||
public class ChatListSource : IListDataSource
|
|
||||||
{
|
|
||||||
private readonly List<ChatLine> _lines = [];
|
|
||||||
|
|
||||||
public event NotifyCollectionChangedEventHandler? CollectionChanged;
|
|
||||||
|
|
||||||
public int Count => _lines.Count;
|
|
||||||
public int MaxItemLength { get; private set; }
|
|
||||||
public bool SuspendCollectionChangedEvent { get; set; }
|
|
||||||
|
|
||||||
public void Add(ChatLine line)
|
|
||||||
{
|
|
||||||
_lines.Add(line);
|
|
||||||
UpdateMaxLength(line);
|
|
||||||
RaiseCollectionChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void AddRange(IEnumerable<ChatLine> lines)
|
|
||||||
{
|
|
||||||
foreach (var line in lines)
|
|
||||||
{
|
|
||||||
_lines.Add(line);
|
|
||||||
UpdateMaxLength(line);
|
|
||||||
}
|
|
||||||
RaiseCollectionChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void InsertRange(int index, IEnumerable<ChatLine> lines)
|
|
||||||
{
|
|
||||||
var items = lines.ToList();
|
|
||||||
_lines.InsertRange(index, items);
|
|
||||||
foreach (var line in items)
|
|
||||||
UpdateMaxLength(line);
|
|
||||||
RaiseCollectionChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Clear()
|
|
||||||
{
|
|
||||||
_lines.Clear();
|
|
||||||
MaxItemLength = 0;
|
|
||||||
RaiseCollectionChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsMarked(int item) => false;
|
|
||||||
public void SetMark(int item, bool value) { }
|
|
||||||
public IList ToList() => _lines.Select(l => l.ToString()).ToList();
|
|
||||||
|
|
||||||
public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0)
|
|
||||||
{
|
|
||||||
listView.Move(Math.Max(col - viewportX, 0), row);
|
|
||||||
|
|
||||||
var chatLine = _lines[item];
|
|
||||||
var normalAttr = listView.GetAttributeForRole(VisualRole.Normal);
|
|
||||||
var mentionBg = chatLine.IsMention ? ChatColors.MentionHighlightAttr.Background : (Color?)null;
|
|
||||||
|
|
||||||
int charPos = 0;
|
|
||||||
int drawnChars = 0;
|
|
||||||
|
|
||||||
foreach (var segment in chatLine.Segments)
|
|
||||||
{
|
|
||||||
var attr = segment.Color ?? normalAttr;
|
|
||||||
if (mentionBg.HasValue)
|
|
||||||
attr = new Attribute(attr.Foreground, mentionBg.Value);
|
|
||||||
listView.SetAttribute(attr);
|
|
||||||
|
|
||||||
foreach (var grapheme in GraphemeHelper.GetGraphemes(segment.Text))
|
|
||||||
{
|
|
||||||
var cols = Math.Max(grapheme.GetColumns(), 1);
|
|
||||||
if (charPos >= viewportX && drawnChars + cols <= width)
|
|
||||||
{
|
|
||||||
listView.AddStr(grapheme);
|
|
||||||
drawnChars += cols;
|
|
||||||
}
|
|
||||||
charPos += cols;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var fillAttr = mentionBg.HasValue ? new Attribute(normalAttr.Foreground, mentionBg.Value) : normalAttr;
|
|
||||||
listView.SetAttribute(fillAttr);
|
|
||||||
for (int i = drawnChars; i < width; i++)
|
|
||||||
listView.AddStr(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateMaxLength(ChatLine line)
|
|
||||||
{
|
|
||||||
if (line.TextLength > MaxItemLength)
|
|
||||||
MaxItemLength = line.TextLength;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RaiseCollectionChanged()
|
|
||||||
{
|
|
||||||
if (!SuspendCollectionChangedEvent)
|
|
||||||
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose() { }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Custom list data source for colored channel list rendering.
|
|
||||||
/// Active channel gets a > indicator, unread channels are bright with a count badge.
|
|
||||||
/// </summary>
|
|
||||||
public class ChannelListSource : IListDataSource
|
|
||||||
{
|
|
||||||
private readonly List<string> _channelNames = [];
|
|
||||||
private readonly Dictionary<string, int> _unreadCounts = [];
|
|
||||||
private string _activeChannel = string.Empty;
|
|
||||||
|
|
||||||
public event NotifyCollectionChangedEventHandler? CollectionChanged;
|
|
||||||
public int Count => _channelNames.Count;
|
|
||||||
public int MaxItemLength { get; private set; }
|
|
||||||
public bool SuspendCollectionChangedEvent { get; set; }
|
|
||||||
|
|
||||||
private static readonly Attribute ActiveAttr = new(Color.White, Color.Black);
|
|
||||||
private static readonly Attribute UnreadAttr = new(Color.BrightCyan, Color.Black);
|
|
||||||
private static readonly Attribute NormalAttr = new(Color.DarkGray, Color.Black);
|
|
||||||
private static readonly Attribute BadgeAttr = new(Color.BrightYellow, Color.Black);
|
|
||||||
|
|
||||||
public void Update(List<string> channels, Dictionary<string, int> unread, string activeChannel)
|
|
||||||
{
|
|
||||||
_channelNames.Clear();
|
|
||||||
_channelNames.AddRange(channels);
|
|
||||||
_unreadCounts.Clear();
|
|
||||||
foreach (var kv in unread)
|
|
||||||
_unreadCounts[kv.Key] = kv.Value;
|
|
||||||
_activeChannel = activeChannel;
|
|
||||||
MaxItemLength = channels.Count > 0 ? channels.Max(c => c.Length + 6) : 0;
|
|
||||||
if (!SuspendCollectionChangedEvent)
|
|
||||||
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsMarked(int item) => false;
|
|
||||||
public void SetMark(int item, bool value) { }
|
|
||||||
public IList ToList() => _channelNames.Select(n => $"#{n}").ToList();
|
|
||||||
|
|
||||||
public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0)
|
|
||||||
{
|
|
||||||
listView.Move(Math.Max(col - viewportX, 0), row);
|
|
||||||
|
|
||||||
var name = _channelNames[item];
|
|
||||||
var isActive = name == _activeChannel;
|
|
||||||
_unreadCounts.TryGetValue(name, out var unread);
|
|
||||||
var hasUnread = unread > 0;
|
|
||||||
|
|
||||||
var focusAttr = listView.GetAttributeForRole(VisualRole.Focus);
|
|
||||||
var prefix = isActive ? "> " : " ";
|
|
||||||
var channelText = $"#{name}";
|
|
||||||
var badge = hasUnread ? $" ({unread})" : "";
|
|
||||||
|
|
||||||
int drawnChars = 0;
|
|
||||||
|
|
||||||
if (selected)
|
|
||||||
{
|
|
||||||
listView.SetAttribute(focusAttr);
|
|
||||||
drawnChars = RenderHelpers.WriteText(listView, prefix + channelText + badge, drawnChars, width);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
listView.SetAttribute(isActive ? ActiveAttr : NormalAttr);
|
|
||||||
drawnChars = RenderHelpers.WriteText(listView, prefix, drawnChars, width);
|
|
||||||
|
|
||||||
listView.SetAttribute(isActive ? ActiveAttr : hasUnread ? UnreadAttr : NormalAttr);
|
|
||||||
drawnChars = RenderHelpers.WriteText(listView, channelText, drawnChars, width);
|
|
||||||
|
|
||||||
if (hasUnread)
|
|
||||||
{
|
|
||||||
listView.SetAttribute(BadgeAttr);
|
|
||||||
drawnChars = RenderHelpers.WriteText(listView, badge, drawnChars, width);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var fillAttr = selected ? focusAttr : listView.GetAttributeForRole(VisualRole.Normal);
|
|
||||||
listView.SetAttribute(fillAttr);
|
|
||||||
for (int i = drawnChars; i < width; i++)
|
|
||||||
listView.AddStr(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose() { }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Custom list data source for the online users panel with per-user nickname colors.
|
|
||||||
/// </summary>
|
|
||||||
public class UserListSource : IListDataSource
|
|
||||||
{
|
|
||||||
private readonly List<(string Text, Attribute? NameColor)> _users = [];
|
|
||||||
|
|
||||||
public event NotifyCollectionChangedEventHandler? CollectionChanged;
|
|
||||||
public int Count => _users.Count;
|
|
||||||
public int MaxItemLength { get; private set; }
|
|
||||||
public bool SuspendCollectionChangedEvent { get; set; }
|
|
||||||
|
|
||||||
public void Update(List<(string Text, Attribute? NameColor)> users)
|
|
||||||
{
|
|
||||||
_users.Clear();
|
|
||||||
_users.AddRange(users);
|
|
||||||
MaxItemLength = users.Count > 0 ? users.Max(u => u.Text.GetColumns()) : 0;
|
|
||||||
if (!SuspendCollectionChangedEvent)
|
|
||||||
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsMarked(int item) => false;
|
|
||||||
public void SetMark(int item, bool value) { }
|
|
||||||
public IList ToList() => _users.Select(u => u.Text).ToList();
|
|
||||||
|
|
||||||
public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0)
|
|
||||||
{
|
|
||||||
listView.Move(Math.Max(col - viewportX, 0), row);
|
|
||||||
|
|
||||||
var (text, nameColor) = _users[item];
|
|
||||||
var normalAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal);
|
|
||||||
|
|
||||||
// Find where the name starts (after status icon + space + optional role badge)
|
|
||||||
// Format: "● ★Username" or "● Username"
|
|
||||||
var graphemes = GraphemeHelper.GetGraphemes(text).ToList();
|
|
||||||
int nameStart = 0;
|
|
||||||
while (nameStart < graphemes.Count)
|
|
||||||
{
|
|
||||||
var g = graphemes[nameStart];
|
|
||||||
if (g.Length > 0 && (char.IsLetterOrDigit(g[0]) || g[0] == '_'))
|
|
||||||
break;
|
|
||||||
nameStart++;
|
|
||||||
}
|
|
||||||
|
|
||||||
int drawnChars = 0;
|
|
||||||
|
|
||||||
// Draw prefix (status icon + role badge) in normal color
|
|
||||||
listView.SetAttribute(normalAttr);
|
|
||||||
for (int i = 0; i < nameStart; i++)
|
|
||||||
{
|
|
||||||
var cols = Math.Max(graphemes[i].GetColumns(), 1);
|
|
||||||
if (drawnChars + cols > width) break;
|
|
||||||
listView.AddStr(graphemes[i]);
|
|
||||||
drawnChars += cols;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw name in nickname color
|
|
||||||
var userAttr = selected ? normalAttr : nameColor ?? normalAttr;
|
|
||||||
listView.SetAttribute(userAttr);
|
|
||||||
for (int i = nameStart; i < graphemes.Count; i++)
|
|
||||||
{
|
|
||||||
var cols = Math.Max(graphemes[i].GetColumns(), 1);
|
|
||||||
if (drawnChars + cols > width) break;
|
|
||||||
listView.AddStr(graphemes[i]);
|
|
||||||
drawnChars += cols;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fill rest
|
|
||||||
listView.SetAttribute(normalAttr);
|
|
||||||
for (int i = drawnChars; i < width; i++)
|
|
||||||
listView.AddStr(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose() { }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Shared rendering helpers for IListDataSource implementations.
|
|
||||||
/// </summary>
|
|
||||||
static class RenderHelpers
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Write text grapheme-by-grapheme to a ListView, respecting a width limit.
|
|
||||||
/// Returns the updated drawn-columns count.
|
|
||||||
/// </summary>
|
|
||||||
public static int WriteText(ListView lv, string text, int drawn, int maxWidth)
|
|
||||||
{
|
|
||||||
foreach (var grapheme in GraphemeHelper.GetGraphemes(text))
|
|
||||||
{
|
|
||||||
var cols = Math.Max(grapheme.GetColumns(), 1);
|
|
||||||
if (drawn + cols > maxWidth) break;
|
|
||||||
lv.AddStr(grapheme);
|
|
||||||
drawn += cols;
|
|
||||||
}
|
|
||||||
return drawn;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Shared color attributes for chat rendering (timestamps, system messages).
|
|
||||||
/// </summary>
|
|
||||||
public static partial class ChatColors
|
|
||||||
{
|
|
||||||
public static readonly Attribute TimestampAttr = new(Color.DarkGray, Color.Black);
|
|
||||||
public static readonly Attribute SystemAttr = new(new Color(0, 180, 180), Color.Black);
|
|
||||||
public static readonly Attribute MentionHighlightAttr = new(Color.White, new Color(80, 40, 0));
|
|
||||||
public static readonly Attribute MentionTextAttr = new(new Color(255, 180, 50), Color.Black);
|
|
||||||
public static readonly Attribute EmbedBorderAttr = new(new Color(91, 155, 213), Color.Black);
|
|
||||||
public static readonly Attribute EmbedTitleAttr = new(Color.White, Color.Black);
|
|
||||||
public static readonly Attribute EmbedDescAttr = new(new Color(160, 160, 160), Color.Black);
|
|
||||||
public static readonly Attribute EmbedUrlAttr = new(new Color(100, 100, 100), Color.Black);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Split text around @mentions, giving each @word the MentionTextAttr accent color.
|
|
||||||
/// Non-mention text uses the provided default color.
|
|
||||||
/// </summary>
|
|
||||||
public static List<ChatSegment> SplitMentions(string text, Attribute? defaultColor = null)
|
|
||||||
{
|
|
||||||
var segments = new List<ChatSegment>();
|
|
||||||
int lastIndex = 0;
|
|
||||||
|
|
||||||
foreach (Match match in MentionRegex().Matches(text))
|
|
||||||
{
|
|
||||||
if (match.Index > lastIndex)
|
|
||||||
segments.Add(new ChatSegment(text[lastIndex..match.Index], defaultColor));
|
|
||||||
|
|
||||||
segments.Add(new ChatSegment(match.Value, MentionTextAttr));
|
|
||||||
lastIndex = match.Index + match.Length;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastIndex < text.Length)
|
|
||||||
segments.Add(new ChatSegment(text[lastIndex..], defaultColor));
|
|
||||||
|
|
||||||
return segments;
|
|
||||||
}
|
|
||||||
|
|
||||||
[GeneratedRegex(@"@[\w-]+")]
|
|
||||||
private static partial Regex MentionRegex();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Helper to parse hex colors to Terminal.Gui Attributes.
|
|
||||||
/// </summary>
|
|
||||||
public static class ColorHelper
|
|
||||||
{
|
|
||||||
public static Attribute? ParseHexColor(string? hex)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(hex))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
hex = hex.TrimStart('#');
|
|
||||||
if (hex.Length != 6)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var r = Convert.ToInt32(hex[..2], 16);
|
|
||||||
var g = Convert.ToInt32(hex[2..4], 16);
|
|
||||||
var b = Convert.ToInt32(hex[4..6], 16);
|
|
||||||
return new Attribute(new Color(r, g, b), Color.Black);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
using EchoHub.Client.Services;
|
||||||
|
using Terminal.Gui.App;
|
||||||
|
using Terminal.Gui.Drawing;
|
||||||
|
using Terminal.Gui.ViewBase;
|
||||||
|
using Terminal.Gui.Views;
|
||||||
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI.Dialogs;
|
||||||
|
|
||||||
|
public sealed class AudioPlayerDialog
|
||||||
|
{
|
||||||
|
// Block characters for wave animation (increasing height)
|
||||||
|
private static readonly string[] WaveBlocks = ["\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"];
|
||||||
|
private const int WaveBarCount = 24;
|
||||||
|
private const int AnimationIntervalMs = 150;
|
||||||
|
|
||||||
|
private static readonly Attribute WaveActiveAttr = new(new Color(180, 100, 255), Color.None);
|
||||||
|
private static readonly Attribute WaveIdleAttr = new(new Color(80, 50, 120), Color.None);
|
||||||
|
private static readonly Attribute FileNameAttr = new(new Color(180, 100, 255), Color.None);
|
||||||
|
private static readonly Attribute StatusPlayingAttr = new(new Color(0, 200, 0), Color.None);
|
||||||
|
private static readonly Attribute StatusPausedAttr = new(new Color(220, 180, 0), Color.None);
|
||||||
|
private static readonly Attribute StatusStoppedAttr = new(new Color(160, 160, 160), Color.None);
|
||||||
|
|
||||||
|
public static void Show(IApplication app, AudioPlaybackService audioService, string filePath, string fileName)
|
||||||
|
{
|
||||||
|
var dialog = new Dialog { Title = "Audio Player", Width = 52, Height = 14 };
|
||||||
|
|
||||||
|
// ── File name ──
|
||||||
|
var fileLabel = new Label
|
||||||
|
{
|
||||||
|
Text = $"\u266a {TruncateFileName(fileName, 44)}",
|
||||||
|
X = 2,
|
||||||
|
Y = 1,
|
||||||
|
Width = Dim.Fill(2)
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Wave visualization ──
|
||||||
|
var waveLabel = new Label
|
||||||
|
{
|
||||||
|
X = 2,
|
||||||
|
Y = 3,
|
||||||
|
Width = Dim.Fill(2),
|
||||||
|
Height = 1
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Status label ──
|
||||||
|
var statusLabel = new Label
|
||||||
|
{
|
||||||
|
Text = "Stopped",
|
||||||
|
X = 2,
|
||||||
|
Y = 5,
|
||||||
|
Width = 20
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Volume controls ──
|
||||||
|
var volumeHeaderLabel = new Label
|
||||||
|
{
|
||||||
|
Text = "Volume:",
|
||||||
|
X = 2,
|
||||||
|
Y = 7
|
||||||
|
};
|
||||||
|
|
||||||
|
byte currentVolume = 50;
|
||||||
|
var volumeBar = new ProgressBar
|
||||||
|
{
|
||||||
|
X = 14,
|
||||||
|
Y = 7,
|
||||||
|
Width = 20,
|
||||||
|
Height = 1,
|
||||||
|
Fraction = currentVolume / 100f,
|
||||||
|
ProgressBarStyle = ProgressBarStyle.Continuous
|
||||||
|
};
|
||||||
|
|
||||||
|
var volumePercentLabel = new Label
|
||||||
|
{
|
||||||
|
Text = $"{currentVolume}%",
|
||||||
|
X = 35,
|
||||||
|
Y = 7,
|
||||||
|
Width = 5
|
||||||
|
};
|
||||||
|
|
||||||
|
var volDownButton = new Button
|
||||||
|
{
|
||||||
|
Text = "-",
|
||||||
|
X = 10,
|
||||||
|
Y = 7,
|
||||||
|
Width = 3
|
||||||
|
};
|
||||||
|
|
||||||
|
var volUpButton = new Button
|
||||||
|
{
|
||||||
|
Text = "+",
|
||||||
|
X = 41,
|
||||||
|
Y = 7,
|
||||||
|
Width = 3
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Playback controls ──
|
||||||
|
var playButton = new Button
|
||||||
|
{
|
||||||
|
Text = "\u25b6 Play",
|
||||||
|
X = 2,
|
||||||
|
Y = 10,
|
||||||
|
IsDefault = true
|
||||||
|
};
|
||||||
|
|
||||||
|
var stopButton = new Button
|
||||||
|
{
|
||||||
|
Text = "\u25a0 Stop",
|
||||||
|
X = Pos.Right(playButton) + 2,
|
||||||
|
Y = 10
|
||||||
|
};
|
||||||
|
|
||||||
|
var closeButton = new Button
|
||||||
|
{
|
||||||
|
Text = "Close",
|
||||||
|
X = Pos.Right(stopButton) + 2,
|
||||||
|
Y = 10
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Animation state ──
|
||||||
|
var animationOffset = 0;
|
||||||
|
var random = new Random();
|
||||||
|
// Pre-generate a repeating wave pattern
|
||||||
|
var wavePattern = new int[WaveBarCount + 8];
|
||||||
|
for (int i = 0; i < wavePattern.Length; i++)
|
||||||
|
wavePattern[i] = random.Next(0, WaveBlocks.Length);
|
||||||
|
|
||||||
|
Timer? animationTimer = null;
|
||||||
|
var isDisposed = false;
|
||||||
|
|
||||||
|
// ── Helper functions ──
|
||||||
|
void UpdateWave(bool isActive)
|
||||||
|
{
|
||||||
|
if (isDisposed) return;
|
||||||
|
|
||||||
|
var bars = new string[WaveBarCount];
|
||||||
|
for (int i = 0; i < WaveBarCount; i++)
|
||||||
|
{
|
||||||
|
if (isActive)
|
||||||
|
{
|
||||||
|
var idx = wavePattern[(i + animationOffset) % wavePattern.Length];
|
||||||
|
bars[i] = WaveBlocks[idx];
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
bars[i] = WaveBlocks[1]; // low idle bars
|
||||||
|
}
|
||||||
|
}
|
||||||
|
waveLabel.Text = string.Join(" ", bars);
|
||||||
|
}
|
||||||
|
|
||||||
|
void UpdateStatus()
|
||||||
|
{
|
||||||
|
if (isDisposed) return;
|
||||||
|
|
||||||
|
if (audioService.IsPlaying && !audioService.IsPaused)
|
||||||
|
{
|
||||||
|
statusLabel.Text = "Playing";
|
||||||
|
playButton.Text = "\u23f8 Pause";
|
||||||
|
}
|
||||||
|
else if (audioService.IsPaused)
|
||||||
|
{
|
||||||
|
statusLabel.Text = "Paused";
|
||||||
|
playButton.Text = "\u25b6 Resume";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
statusLabel.Text = "Stopped";
|
||||||
|
playButton.Text = "\u25b6 Play";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void StartAnimation()
|
||||||
|
{
|
||||||
|
animationTimer?.Dispose();
|
||||||
|
animationTimer = new Timer(_ =>
|
||||||
|
{
|
||||||
|
if (isDisposed) return;
|
||||||
|
animationOffset++;
|
||||||
|
// Shuffle a few bars each tick for organic movement
|
||||||
|
var idx = random.Next(0, wavePattern.Length);
|
||||||
|
wavePattern[idx] = random.Next(0, WaveBlocks.Length);
|
||||||
|
|
||||||
|
app.Invoke(() =>
|
||||||
|
{
|
||||||
|
if (isDisposed) return;
|
||||||
|
UpdateWave(true);
|
||||||
|
});
|
||||||
|
}, null, 0, AnimationIntervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
void StopAnimation()
|
||||||
|
{
|
||||||
|
animationTimer?.Dispose();
|
||||||
|
animationTimer = null;
|
||||||
|
if (!isDisposed)
|
||||||
|
UpdateWave(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task UpdateVolume(byte newVolume)
|
||||||
|
{
|
||||||
|
currentVolume = Math.Clamp(newVolume, (byte)0, (byte)100);
|
||||||
|
await audioService.SetVolumeAsync(currentVolume);
|
||||||
|
if (!isDisposed)
|
||||||
|
{
|
||||||
|
volumeBar.Fraction = currentVolume / 100f;
|
||||||
|
volumePercentLabel.Text = $"{currentVolume}%";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Custom drawing for colored elements ──
|
||||||
|
fileLabel.DrawingContent += (s, e) =>
|
||||||
|
{
|
||||||
|
var normalAttr = fileLabel.GetAttributeForRole(VisualRole.Normal);
|
||||||
|
var resolvedAttr = FileNameAttr.Background == Color.None
|
||||||
|
? FileNameAttr with { Background = normalAttr.Background }
|
||||||
|
: FileNameAttr;
|
||||||
|
fileLabel.SetAttribute(resolvedAttr);
|
||||||
|
fileLabel.Move(0, 0);
|
||||||
|
var text = fileLabel.Text ?? "";
|
||||||
|
foreach (var g in Terminal.Gui.Drawing.GraphemeHelper.GetGraphemes(text))
|
||||||
|
fileLabel.AddStr(g);
|
||||||
|
// Fill remaining width
|
||||||
|
var width = fileLabel.Viewport.Width;
|
||||||
|
var textCols = Terminal.Gui.Text.StringExtensions.GetColumns(text);
|
||||||
|
for (int i = textCols; i < width; i++)
|
||||||
|
fileLabel.AddStr(" ");
|
||||||
|
e.Cancel = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
waveLabel.DrawingContent += (s, e) =>
|
||||||
|
{
|
||||||
|
var normalAttr = waveLabel.GetAttributeForRole(VisualRole.Normal);
|
||||||
|
var attr = (audioService.IsPlaying && !audioService.IsPaused) ? WaveActiveAttr : WaveIdleAttr;
|
||||||
|
var resolvedAttr = attr.Background == Color.None
|
||||||
|
? attr with { Background = normalAttr.Background }
|
||||||
|
: attr;
|
||||||
|
waveLabel.SetAttribute(resolvedAttr);
|
||||||
|
waveLabel.Move(0, 0);
|
||||||
|
var text = waveLabel.Text ?? "";
|
||||||
|
foreach (var g in Terminal.Gui.Drawing.GraphemeHelper.GetGraphemes(text))
|
||||||
|
waveLabel.AddStr(g);
|
||||||
|
var width = waveLabel.Viewport.Width;
|
||||||
|
var textCols = Terminal.Gui.Text.StringExtensions.GetColumns(text);
|
||||||
|
for (int i = textCols; i < width; i++)
|
||||||
|
waveLabel.AddStr(" ");
|
||||||
|
e.Cancel = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
statusLabel.DrawingContent += (s, e) =>
|
||||||
|
{
|
||||||
|
var normalAttr = statusLabel.GetAttributeForRole(VisualRole.Normal);
|
||||||
|
Attribute attr;
|
||||||
|
if (audioService.IsPlaying && !audioService.IsPaused)
|
||||||
|
attr = StatusPlayingAttr;
|
||||||
|
else if (audioService.IsPaused)
|
||||||
|
attr = StatusPausedAttr;
|
||||||
|
else
|
||||||
|
attr = StatusStoppedAttr;
|
||||||
|
|
||||||
|
var resolvedAttr = attr.Background == Color.None
|
||||||
|
? attr with { Background = normalAttr.Background }
|
||||||
|
: attr;
|
||||||
|
statusLabel.SetAttribute(resolvedAttr);
|
||||||
|
statusLabel.Move(0, 0);
|
||||||
|
var text = statusLabel.Text ?? "";
|
||||||
|
foreach (var g in Terminal.Gui.Drawing.GraphemeHelper.GetGraphemes(text))
|
||||||
|
statusLabel.AddStr(g);
|
||||||
|
var width = statusLabel.Viewport.Width;
|
||||||
|
var textCols = Terminal.Gui.Text.StringExtensions.GetColumns(text);
|
||||||
|
for (int i = textCols; i < width; i++)
|
||||||
|
statusLabel.AddStr(" ");
|
||||||
|
e.Cancel = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Event handlers ──
|
||||||
|
playButton.Accepting += (s, e) =>
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
if (audioService.IsPaused)
|
||||||
|
{
|
||||||
|
await audioService.ResumeAsync();
|
||||||
|
app.Invoke(() =>
|
||||||
|
{
|
||||||
|
UpdateStatus();
|
||||||
|
StartAnimation();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (audioService.IsPlaying)
|
||||||
|
{
|
||||||
|
await audioService.PauseAsync();
|
||||||
|
app.Invoke(() =>
|
||||||
|
{
|
||||||
|
UpdateStatus();
|
||||||
|
StopAnimation();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await audioService.SetVolumeAsync(currentVolume);
|
||||||
|
await audioService.PlayAsync(filePath);
|
||||||
|
app.Invoke(() =>
|
||||||
|
{
|
||||||
|
UpdateStatus();
|
||||||
|
StartAnimation();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
stopButton.Accepting += (s, e) =>
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
await audioService.StopAsync();
|
||||||
|
app.Invoke(() =>
|
||||||
|
{
|
||||||
|
UpdateStatus();
|
||||||
|
StopAnimation();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
closeButton.Accepting += (s, e) =>
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
isDisposed = true;
|
||||||
|
animationTimer?.Dispose();
|
||||||
|
_ = audioService.StopAsync(); // fire-and-forget
|
||||||
|
app.RequestStop();
|
||||||
|
};
|
||||||
|
|
||||||
|
volDownButton.Accepting += (s, e) =>
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
var newVol = (byte)Math.Max(0, currentVolume - 10);
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
await UpdateVolume(newVol);
|
||||||
|
app.Invoke(() =>
|
||||||
|
{
|
||||||
|
volumeBar.SetNeedsDraw();
|
||||||
|
volumePercentLabel.SetNeedsDraw();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
volUpButton.Accepting += (s, e) =>
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
var newVol = (byte)Math.Min(100, currentVolume + 10);
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
await UpdateVolume(newVol);
|
||||||
|
app.Invoke(() =>
|
||||||
|
{
|
||||||
|
volumeBar.SetNeedsDraw();
|
||||||
|
volumePercentLabel.SetNeedsDraw();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle playback finishing naturally
|
||||||
|
EventHandler? finishedHandler = null;
|
||||||
|
finishedHandler = (s, e) =>
|
||||||
|
{
|
||||||
|
app.Invoke(() =>
|
||||||
|
{
|
||||||
|
if (isDisposed) return;
|
||||||
|
UpdateStatus();
|
||||||
|
StopAnimation();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
audioService.PlaybackFinished += finishedHandler;
|
||||||
|
|
||||||
|
// ── Initial state ──
|
||||||
|
UpdateWave(false);
|
||||||
|
UpdateStatus();
|
||||||
|
|
||||||
|
dialog.Add(fileLabel, waveLabel, statusLabel,
|
||||||
|
volumeHeaderLabel, volDownButton, volumeBar, volumePercentLabel, volUpButton,
|
||||||
|
playButton, stopButton, closeButton);
|
||||||
|
|
||||||
|
playButton.SetFocus();
|
||||||
|
app.Run(dialog);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
isDisposed = true;
|
||||||
|
animationTimer?.Dispose();
|
||||||
|
audioService.PlaybackFinished -= finishedHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string TruncateFileName(string name, int maxLen)
|
||||||
|
{
|
||||||
|
if (name.Length <= maxLen)
|
||||||
|
return name;
|
||||||
|
|
||||||
|
var ext = Path.GetExtension(name);
|
||||||
|
var stem = Path.GetFileNameWithoutExtension(name);
|
||||||
|
var available = maxLen - ext.Length - 3; // 3 for "..."
|
||||||
|
if (available < 1)
|
||||||
|
return name[..maxLen];
|
||||||
|
|
||||||
|
return stem[..available] + "..." + ext;
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
-17
@@ -4,12 +4,14 @@ using Terminal.Gui.App;
|
|||||||
using Terminal.Gui.Views;
|
using Terminal.Gui.Views;
|
||||||
using Terminal.Gui.ViewBase;
|
using Terminal.Gui.ViewBase;
|
||||||
|
|
||||||
namespace EchoHub.Client.UI;
|
namespace EchoHub.Client.UI.Dialogs;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Result returned from the connect dialog.
|
/// Result returned from the connect dialog.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public record ConnectDialogResult(string ServerUrl, string Username, string Password, bool IsRegister);
|
public record ConnectDialogResult(
|
||||||
|
string ServerUrl, string Username, string Password,
|
||||||
|
bool IsRegister, bool RememberMe, string? SavedRefreshToken);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A Terminal.Gui dialog for entering server connection and authentication details.
|
/// A Terminal.Gui dialog for entering server connection and authentication details.
|
||||||
@@ -27,11 +29,12 @@ public sealed class ConnectDialog
|
|||||||
savedServers ??= [];
|
savedServers ??= [];
|
||||||
|
|
||||||
var hasSavedServers = savedServers.Count > 0;
|
var hasSavedServers = savedServers.Count > 0;
|
||||||
var dialogHeight = hasSavedServers ? 20 : 16;
|
var dialogHeight = hasSavedServers ? 22 : 18;
|
||||||
|
|
||||||
var dialog = new Dialog { Title = "Connect to Server", Width = 60, Height = dialogHeight };
|
var dialog = new Dialog { Title = "Connect to Server", Width = 60, Height = dialogHeight };
|
||||||
|
|
||||||
int yOffset = 0;
|
int yOffset = 0;
|
||||||
|
SavedServer? selectedSavedServer = null;
|
||||||
|
|
||||||
// -- Saved Servers section (if any) -----------------------------------
|
// -- Saved Servers section (if any) -----------------------------------
|
||||||
ListView? savedServerList = null;
|
ListView? savedServerList = null;
|
||||||
@@ -46,7 +49,11 @@ public sealed class ConnectDialog
|
|||||||
dialog.Add(savedLabel);
|
dialog.Add(savedLabel);
|
||||||
|
|
||||||
var serverDisplayNames = savedServers
|
var serverDisplayNames = savedServers
|
||||||
.Select(s => $"{s.Name} ({s.Username ?? "?"})")
|
.Select(s =>
|
||||||
|
{
|
||||||
|
var session = !string.IsNullOrEmpty(s.RefreshToken) ? " [session]" : "";
|
||||||
|
return $"{s.Name} ({s.Username ?? "?"}){session}";
|
||||||
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
savedServerList = new ListView
|
savedServerList = new ListView
|
||||||
@@ -115,17 +122,34 @@ public sealed class ConnectDialog
|
|||||||
Secret = true
|
Secret = true
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var tokenHintLabel = new Label
|
||||||
|
{
|
||||||
|
Text = "Session saved \u2014 password optional",
|
||||||
|
X = 15,
|
||||||
|
Y = yOffset + 6,
|
||||||
|
Width = Dim.Fill(2),
|
||||||
|
Visible = false
|
||||||
|
};
|
||||||
|
|
||||||
|
var rememberMeCheckbox = new CheckBox
|
||||||
|
{
|
||||||
|
Text = "Remember me",
|
||||||
|
X = 15,
|
||||||
|
Y = yOffset + 7,
|
||||||
|
Value = CheckState.UnChecked
|
||||||
|
};
|
||||||
|
|
||||||
var displayLabel = new Label
|
var displayLabel = new Label
|
||||||
{
|
{
|
||||||
Text = "Display Name:",
|
Text = "Display Name:",
|
||||||
X = 1,
|
X = 1,
|
||||||
Y = yOffset + 7
|
Y = yOffset + 9
|
||||||
};
|
};
|
||||||
var displayField = new TextField
|
var displayField = new TextField
|
||||||
{
|
{
|
||||||
Text = "",
|
Text = "",
|
||||||
X = 15,
|
X = 15,
|
||||||
Y = yOffset + 7,
|
Y = yOffset + 9,
|
||||||
Width = Dim.Fill(2)
|
Width = Dim.Fill(2)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -134,21 +158,21 @@ public sealed class ConnectDialog
|
|||||||
Text = "Login",
|
Text = "Login",
|
||||||
IsDefault = true,
|
IsDefault = true,
|
||||||
X = Pos.Center() - 20,
|
X = Pos.Center() - 20,
|
||||||
Y = yOffset + 9
|
Y = yOffset + 11
|
||||||
};
|
};
|
||||||
|
|
||||||
var registerButton = new Button
|
var registerButton = new Button
|
||||||
{
|
{
|
||||||
Text = "Register",
|
Text = "Register",
|
||||||
X = Pos.Center() - 5,
|
X = Pos.Center() - 5,
|
||||||
Y = yOffset + 9
|
Y = yOffset + 11
|
||||||
};
|
};
|
||||||
|
|
||||||
var cancelButton = new Button
|
var cancelButton = new Button
|
||||||
{
|
{
|
||||||
Text = "Cancel",
|
Text = "Cancel",
|
||||||
X = Pos.Center() + 10,
|
X = Pos.Center() + 10,
|
||||||
Y = yOffset + 9
|
Y = yOffset + 11
|
||||||
};
|
};
|
||||||
|
|
||||||
// Wire saved server selection to auto-fill fields
|
// Wire saved server selection to auto-fill fields
|
||||||
@@ -159,15 +183,32 @@ public sealed class ConnectDialog
|
|||||||
var index = e.NewValue;
|
var index = e.NewValue;
|
||||||
if (index.HasValue && index.Value >= 0 && index.Value < savedServers.Count)
|
if (index.HasValue && index.Value >= 0 && index.Value < savedServers.Count)
|
||||||
{
|
{
|
||||||
var server = savedServers[index.Value];
|
selectedSavedServer = savedServers[index.Value];
|
||||||
urlField.Text = server.Url;
|
urlField.Text = selectedSavedServer.Url;
|
||||||
userField.Text = server.Username ?? "";
|
userField.Text = selectedSavedServer.Username ?? "";
|
||||||
|
rememberMeCheckbox.Value = selectedSavedServer.RememberMe
|
||||||
|
? CheckState.Checked : CheckState.UnChecked;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(selectedSavedServer.RefreshToken))
|
||||||
|
{
|
||||||
|
passField.Text = "";
|
||||||
|
tokenHintLabel.Visible = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
tokenHintLabel.Visible = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Pre-fill with the first saved server
|
// Pre-fill with the first saved server
|
||||||
|
selectedSavedServer = savedServers[0];
|
||||||
urlField.Text = savedServers[0].Url;
|
urlField.Text = savedServers[0].Url;
|
||||||
userField.Text = savedServers[0].Username ?? "";
|
userField.Text = savedServers[0].Username ?? "";
|
||||||
|
rememberMeCheckbox.Value = savedServers[0].RememberMe
|
||||||
|
? CheckState.Checked : CheckState.UnChecked;
|
||||||
|
if (!string.IsNullOrEmpty(savedServers[0].RefreshToken))
|
||||||
|
tokenHintLabel.Visible = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
loginButton.Accepting += (s, e) =>
|
loginButton.Accepting += (s, e) =>
|
||||||
@@ -175,15 +216,34 @@ public sealed class ConnectDialog
|
|||||||
var url = urlField.Text?.Trim() ?? string.Empty;
|
var url = urlField.Text?.Trim() ?? string.Empty;
|
||||||
var user = userField.Text?.Trim() ?? string.Empty;
|
var user = userField.Text?.Trim() ?? string.Empty;
|
||||||
var pass = passField.Text ?? string.Empty;
|
var pass = passField.Text ?? string.Empty;
|
||||||
|
var rememberMe = rememberMeCheckbox.Value == CheckState.Checked;
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user) || string.IsNullOrEmpty(pass))
|
if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user))
|
||||||
{
|
{
|
||||||
MessageBox.ErrorQuery(app, "Validation", "Server URL, username, and password are required.", "OK");
|
MessageBox.ErrorQuery(app, "Validation", "Server URL and username are required.", "OK");
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
result = new ConnectDialogResult(url, user, pass, IsRegister: false);
|
// Determine if we can use a saved token
|
||||||
|
string? savedRefreshToken = null;
|
||||||
|
if (string.IsNullOrEmpty(pass)
|
||||||
|
&& selectedSavedServer is not null
|
||||||
|
&& !string.IsNullOrEmpty(selectedSavedServer.RefreshToken)
|
||||||
|
&& string.Equals(selectedSavedServer.Url, url, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& string.Equals(selectedSavedServer.Username, user, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
savedRefreshToken = selectedSavedServer.RefreshToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(pass) && savedRefreshToken is null)
|
||||||
|
{
|
||||||
|
MessageBox.ErrorQuery(app, "Validation", "Password is required.", "OK");
|
||||||
|
e.Handled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
result = new ConnectDialogResult(url, user, pass, IsRegister: false, rememberMe, savedRefreshToken);
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
app.RequestStop();
|
app.RequestStop();
|
||||||
};
|
};
|
||||||
@@ -193,6 +253,7 @@ public sealed class ConnectDialog
|
|||||||
var url = urlField.Text?.Trim() ?? string.Empty;
|
var url = urlField.Text?.Trim() ?? string.Empty;
|
||||||
var user = userField.Text?.Trim() ?? string.Empty;
|
var user = userField.Text?.Trim() ?? string.Empty;
|
||||||
var pass = passField.Text ?? string.Empty;
|
var pass = passField.Text ?? string.Empty;
|
||||||
|
var rememberMe = rememberMeCheckbox.Value == CheckState.Checked;
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user) || string.IsNullOrEmpty(pass))
|
if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user) || string.IsNullOrEmpty(pass))
|
||||||
{
|
{
|
||||||
@@ -201,7 +262,7 @@ public sealed class ConnectDialog
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
result = new ConnectDialogResult(url, user, pass, IsRegister: true);
|
result = new ConnectDialogResult(url, user, pass, IsRegister: true, rememberMe, SavedRefreshToken: null);
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
app.RequestStop();
|
app.RequestStop();
|
||||||
};
|
};
|
||||||
@@ -214,7 +275,8 @@ public sealed class ConnectDialog
|
|||||||
};
|
};
|
||||||
|
|
||||||
dialog.Add(urlLabel, urlField, userLabel, userField, passLabel, passField,
|
dialog.Add(urlLabel, urlField, userLabel, userField, passLabel, passField,
|
||||||
displayLabel, displayField, loginButton, registerButton, cancelButton);
|
tokenHintLabel, rememberMeCheckbox, displayLabel, displayField,
|
||||||
|
loginButton, registerButton, cancelButton);
|
||||||
|
|
||||||
if (hasSavedServers && savedServerList is not null)
|
if (hasSavedServers && savedServerList is not null)
|
||||||
savedServerList.SetFocus();
|
savedServerList.SetFocus();
|
||||||
+1
-1
@@ -2,7 +2,7 @@ using Terminal.Gui.App;
|
|||||||
using Terminal.Gui.Views;
|
using Terminal.Gui.Views;
|
||||||
using Terminal.Gui.ViewBase;
|
using Terminal.Gui.ViewBase;
|
||||||
|
|
||||||
namespace EchoHub.Client.UI;
|
namespace EchoHub.Client.UI.Dialogs;
|
||||||
|
|
||||||
public record CreateChannelResult(string Name, string? Topic, bool IsPublic);
|
public record CreateChannelResult(string Name, string? Topic, bool IsPublic);
|
||||||
|
|
||||||
+5
-23
@@ -1,10 +1,11 @@
|
|||||||
|
using EchoHub.Client.UI.Helpers;
|
||||||
using Terminal.Gui.App;
|
using Terminal.Gui.App;
|
||||||
using Terminal.Gui.Views;
|
|
||||||
using Terminal.Gui.ViewBase;
|
|
||||||
using Terminal.Gui.Drawing;
|
using Terminal.Gui.Drawing;
|
||||||
|
using Terminal.Gui.ViewBase;
|
||||||
|
using Terminal.Gui.Views;
|
||||||
using Attribute = Terminal.Gui.Drawing.Attribute;
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
|
|
||||||
namespace EchoHub.Client.UI;
|
namespace EchoHub.Client.UI.Dialogs;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Result returned from the profile edit dialog.
|
/// Result returned from the profile edit dialog.
|
||||||
@@ -243,32 +244,13 @@ public sealed class ProfileEditDialog
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var color = ParseHexToTrueColor(hexColor.Trim());
|
var color = HexColorHelper.ParseHexToColor(hexColor.Trim(), Color.White);
|
||||||
preview.SetScheme(new Scheme
|
preview.SetScheme(new Scheme
|
||||||
{
|
{
|
||||||
Normal = new Attribute(color, Color.Blue)
|
Normal = new Attribute(color, Color.Blue)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Parses a hex color string to a Terminal.Gui TrueColor Color.
|
|
||||||
/// V2 supports TrueColor via new Color(r, g, b).
|
|
||||||
/// </summary>
|
|
||||||
private static Color ParseHexToTrueColor(string hex)
|
|
||||||
{
|
|
||||||
if (hex.StartsWith('#'))
|
|
||||||
hex = hex[1..];
|
|
||||||
|
|
||||||
if (hex.Length != 6 || !int.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null, out var rgb))
|
|
||||||
return Color.White;
|
|
||||||
|
|
||||||
int r = (rgb >> 16) & 0xFF;
|
|
||||||
int g = (rgb >> 8) & 0xFF;
|
|
||||||
int b = rgb & 0xFF;
|
|
||||||
|
|
||||||
return new Color(r, g, b);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? NullIfEmpty(string? value) =>
|
private static string? NullIfEmpty(string? value) =>
|
||||||
string.IsNullOrWhiteSpace(value) ? null : value;
|
string.IsNullOrWhiteSpace(value) ? null : value;
|
||||||
}
|
}
|
||||||
+8
-6
@@ -1,12 +1,14 @@
|
|||||||
using Terminal.Gui.App;
|
using EchoHub.Client.UI.Chat;
|
||||||
using Terminal.Gui.Views;
|
using EchoHub.Client.UI.Helpers;
|
||||||
using Terminal.Gui.ViewBase;
|
|
||||||
using Terminal.Gui.Drawing;
|
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
|
using Terminal.Gui.App;
|
||||||
|
using Terminal.Gui.Drawing;
|
||||||
|
using Terminal.Gui.ViewBase;
|
||||||
|
using Terminal.Gui.Views;
|
||||||
using Attribute = Terminal.Gui.Drawing.Attribute;
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
|
|
||||||
namespace EchoHub.Client.UI;
|
namespace EchoHub.Client.UI.Dialogs;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Action selected by the user in their own profile dialog.
|
/// Action selected by the user in their own profile dialog.
|
||||||
@@ -111,7 +113,7 @@ public sealed class ProfileViewDialog
|
|||||||
// Color
|
// Color
|
||||||
var colorLabel = new Label { Text = "Color:", X = 1, Y = row };
|
var colorLabel = new Label { Text = "Color:", X = 1, Y = row };
|
||||||
var colorValue = new Label { Text = profile.NicknameColor ?? "-", X = 14, Y = row };
|
var colorValue = new Label { Text = profile.NicknameColor ?? "-", X = 14, Y = row };
|
||||||
if (ColorHelper.ParseHexColor(profile.NicknameColor) is { } colorAttr)
|
if (HexColorHelper.ParseHexColor(profile.NicknameColor) is { } colorAttr)
|
||||||
colorValue.SetScheme(new Scheme { Normal = colorAttr });
|
colorValue.SetScheme(new Scheme { Normal = colorAttr });
|
||||||
dialog.Add(colorLabel, colorValue);
|
dialog.Add(colorLabel, colorValue);
|
||||||
row++;
|
row++;
|
||||||
+1
-1
@@ -3,7 +3,7 @@ using Terminal.Gui.Views;
|
|||||||
using Terminal.Gui.ViewBase;
|
using Terminal.Gui.ViewBase;
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
|
|
||||||
namespace EchoHub.Client.UI;
|
namespace EchoHub.Client.UI.Dialogs;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Result returned from the status dialog.
|
/// Result returned from the status dialog.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using Terminal.Gui.App;
|
||||||
|
using Terminal.Gui.Views;
|
||||||
|
using Terminal.Gui.ViewBase;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI.Dialogs;
|
||||||
|
|
||||||
|
public sealed class UpdateConfirmDialog
|
||||||
|
{
|
||||||
|
public static bool Show(IApplication app, string currentVersion, string newVersion)
|
||||||
|
{
|
||||||
|
var confirmed = false;
|
||||||
|
|
||||||
|
var dialog = new Dialog { Title = "Update Available", Width = 50, Height = 10 };
|
||||||
|
|
||||||
|
var messageLabel = new Label
|
||||||
|
{
|
||||||
|
Text = $"A new version of EchoHub is available.\n\n Current: {currentVersion}\n Latest: {newVersion}",
|
||||||
|
X = 1,
|
||||||
|
Y = 1,
|
||||||
|
Width = Dim.Fill(2),
|
||||||
|
Height = 4
|
||||||
|
};
|
||||||
|
|
||||||
|
var updateButton = new Button
|
||||||
|
{
|
||||||
|
Text = "Update",
|
||||||
|
IsDefault = true,
|
||||||
|
X = Pos.Center() - 10,
|
||||||
|
Y = 6
|
||||||
|
};
|
||||||
|
|
||||||
|
var cancelButton = new Button
|
||||||
|
{
|
||||||
|
Text = "Cancel",
|
||||||
|
X = Pos.Center() + 5,
|
||||||
|
Y = 6
|
||||||
|
};
|
||||||
|
|
||||||
|
updateButton.Accepting += (s, e) =>
|
||||||
|
{
|
||||||
|
confirmed = true;
|
||||||
|
e.Handled = true;
|
||||||
|
app.RequestStop();
|
||||||
|
};
|
||||||
|
|
||||||
|
cancelButton.Accepting += (s, e) =>
|
||||||
|
{
|
||||||
|
confirmed = false;
|
||||||
|
e.Handled = true;
|
||||||
|
app.RequestStop();
|
||||||
|
};
|
||||||
|
|
||||||
|
dialog.Add(messageLabel, updateButton, cancelButton);
|
||||||
|
app.Run(dialog);
|
||||||
|
|
||||||
|
return confirmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using Terminal.Gui.App;
|
||||||
|
using Terminal.Gui.Views;
|
||||||
|
using Terminal.Gui.ViewBase;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI.Dialogs;
|
||||||
|
|
||||||
|
public sealed class UpdateProgressDialog
|
||||||
|
{
|
||||||
|
private readonly Dialog _dialog;
|
||||||
|
private readonly ProgressBar _progressBar;
|
||||||
|
private readonly Label _infoLabel;
|
||||||
|
private readonly IApplication _app;
|
||||||
|
|
||||||
|
public UpdateProgressDialog(IApplication app, string newVersion)
|
||||||
|
{
|
||||||
|
_app = app;
|
||||||
|
|
||||||
|
_dialog = new Dialog { Title = $"Updating to {newVersion}", Width = 50, Height = 10 };
|
||||||
|
|
||||||
|
_infoLabel = new Label
|
||||||
|
{
|
||||||
|
Text = "Preparing update...",
|
||||||
|
X = 1,
|
||||||
|
Y = 1,
|
||||||
|
Width = Dim.Fill(2)
|
||||||
|
};
|
||||||
|
|
||||||
|
_progressBar = new ProgressBar
|
||||||
|
{
|
||||||
|
X = 1,
|
||||||
|
Y = 3,
|
||||||
|
Width = Dim.Fill(2),
|
||||||
|
Fraction = 0f
|
||||||
|
};
|
||||||
|
|
||||||
|
var cancelButton = new Button
|
||||||
|
{
|
||||||
|
Text = "Cancel",
|
||||||
|
X = Pos.Center(),
|
||||||
|
Y = 6
|
||||||
|
};
|
||||||
|
|
||||||
|
_dialog.Add(_infoLabel, _progressBar);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UpdateProgress(float fraction, string statusText)
|
||||||
|
{
|
||||||
|
_progressBar.Fraction = fraction;
|
||||||
|
_infoLabel.Text = statusText;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Show()
|
||||||
|
{
|
||||||
|
_app.Run(_dialog);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Close()
|
||||||
|
{
|
||||||
|
_app.RequestStop();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
namespace EchoHub.Client.UI;
|
namespace EchoHub.Client.UI.Helpers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Converts emoji grapheme clusters to text shortcodes for safe TUI rendering.
|
/// Converts emoji grapheme clusters to text shortcodes for safe TUI rendering.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using Terminal.Gui.Drawing;
|
||||||
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI.Helpers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper to parse hex colors to Terminal.Gui Attributes.
|
||||||
|
/// </summary>
|
||||||
|
public static class HexColorHelper
|
||||||
|
{
|
||||||
|
public static Attribute? ParseHexColor(string? hex)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(hex))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
hex = hex.TrimStart('#');
|
||||||
|
if (hex.Length != 6)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var r = Convert.ToInt32(hex[..2], 16);
|
||||||
|
var g = Convert.ToInt32(hex[2..4], 16);
|
||||||
|
var b = Convert.ToInt32(hex[4..6], 16);
|
||||||
|
return new Attribute(new Color(r, g, b), Color.None);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse a hex color string (with or without #) to a Terminal.Gui Color.
|
||||||
|
/// Returns <paramref name="fallback"/> if parsing fails.
|
||||||
|
/// </summary>
|
||||||
|
public static Color ParseHexToColor(string? hex, Color fallback = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(hex))
|
||||||
|
return fallback;
|
||||||
|
|
||||||
|
var trimmed = hex.TrimStart('#');
|
||||||
|
if (trimmed.Length != 6)
|
||||||
|
return fallback;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var r = Convert.ToInt32(trimmed[..2], 16);
|
||||||
|
var g = Convert.ToInt32(trimmed[2..4], 16);
|
||||||
|
var b = Convert.ToInt32(trimmed[4..6], 16);
|
||||||
|
return new Color(r, g, b);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using System.Collections;
|
||||||
|
using System.Collections.Specialized;
|
||||||
|
using EchoHub.Client.UI.Chat;
|
||||||
|
using Terminal.Gui.Drawing;
|
||||||
|
using Terminal.Gui.Text;
|
||||||
|
using Terminal.Gui.Views;
|
||||||
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI.ListSources;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Custom list data source for colored channel list rendering.
|
||||||
|
/// Active channel gets a > indicator, unread channels are bright with a count badge.
|
||||||
|
/// </summary>
|
||||||
|
public class ChannelListSource : IListDataSource
|
||||||
|
{
|
||||||
|
private readonly List<string> _channelNames = [];
|
||||||
|
private readonly Dictionary<string, int> _unreadCounts = [];
|
||||||
|
private string _activeChannel = string.Empty;
|
||||||
|
|
||||||
|
public event NotifyCollectionChangedEventHandler? CollectionChanged;
|
||||||
|
public int Count => _channelNames.Count;
|
||||||
|
public int MaxItemLength { get; private set; }
|
||||||
|
public bool SuspendCollectionChangedEvent { get; set; }
|
||||||
|
|
||||||
|
private static readonly Attribute ActiveAttr = new(Color.White, Color.None);
|
||||||
|
private static readonly Attribute UnreadAttr = new(Color.BrightCyan, Color.None);
|
||||||
|
private static readonly Attribute NormalAttr = new(Color.DarkGray, Color.None);
|
||||||
|
private static readonly Attribute BadgeAttr = new(Color.BrightYellow, Color.None);
|
||||||
|
|
||||||
|
public void Update(List<string> channels, Dictionary<string, int> unread, string activeChannel)
|
||||||
|
{
|
||||||
|
_channelNames.Clear();
|
||||||
|
_channelNames.AddRange(channels);
|
||||||
|
_unreadCounts.Clear();
|
||||||
|
foreach (var kv in unread)
|
||||||
|
_unreadCounts[kv.Key] = kv.Value;
|
||||||
|
_activeChannel = activeChannel;
|
||||||
|
MaxItemLength = channels.Count > 0 ? channels.Max(c => c.Length + 6) : 0;
|
||||||
|
if (!SuspendCollectionChangedEvent)
|
||||||
|
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsMarked(int item) => false;
|
||||||
|
public void SetMark(int item, bool value) { }
|
||||||
|
public IList ToList() => _channelNames.Select(n => $"#{n}").ToList();
|
||||||
|
|
||||||
|
public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0)
|
||||||
|
{
|
||||||
|
listView.Move(Math.Max(col - viewportX, 0), row);
|
||||||
|
|
||||||
|
var name = _channelNames[item];
|
||||||
|
var isActive = name == _activeChannel;
|
||||||
|
_unreadCounts.TryGetValue(name, out var unread);
|
||||||
|
var hasUnread = unread > 0;
|
||||||
|
|
||||||
|
var normalAttr = listView.GetAttributeForRole(VisualRole.Normal);
|
||||||
|
var focusAttr = listView.GetAttributeForRole(VisualRole.Focus);
|
||||||
|
var prefix = isActive ? "> " : " ";
|
||||||
|
var channelText = $"#{name}";
|
||||||
|
var badge = hasUnread ? $" ({unread})" : "";
|
||||||
|
|
||||||
|
// Resolve Transparent backgrounds to the view's actual background
|
||||||
|
Attribute Resolve(Attribute attr) =>
|
||||||
|
attr.Background == Color.None ? attr with { Background = normalAttr.Background } : attr;
|
||||||
|
|
||||||
|
int drawnChars = 0;
|
||||||
|
|
||||||
|
if (selected)
|
||||||
|
{
|
||||||
|
listView.SetAttribute(focusAttr);
|
||||||
|
drawnChars = RenderHelpers.WriteText(listView, prefix + channelText + badge, drawnChars, width);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
listView.SetAttribute(Resolve(isActive ? ActiveAttr : NormalAttr));
|
||||||
|
drawnChars = RenderHelpers.WriteText(listView, prefix, drawnChars, width);
|
||||||
|
|
||||||
|
listView.SetAttribute(Resolve(isActive ? ActiveAttr : hasUnread ? UnreadAttr : NormalAttr));
|
||||||
|
drawnChars = RenderHelpers.WriteText(listView, channelText, drawnChars, width);
|
||||||
|
|
||||||
|
if (hasUnread)
|
||||||
|
{
|
||||||
|
listView.SetAttribute(Resolve(BadgeAttr));
|
||||||
|
drawnChars = RenderHelpers.WriteText(listView, badge, drawnChars, width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var fillAttr = selected ? focusAttr : normalAttr;
|
||||||
|
listView.SetAttribute(fillAttr);
|
||||||
|
for (int i = drawnChars; i < width; i++)
|
||||||
|
listView.AddStr(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using System.Collections;
|
||||||
|
using System.Collections.Specialized;
|
||||||
|
using Terminal.Gui.Drawing;
|
||||||
|
using Terminal.Gui.Text;
|
||||||
|
using Terminal.Gui.Views;
|
||||||
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI.ListSources;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Custom list data source for the online users panel with per-user nickname colors.
|
||||||
|
/// </summary>
|
||||||
|
public class UserListSource : IListDataSource
|
||||||
|
{
|
||||||
|
private readonly List<(string Text, Attribute? NameColor)> _users = [];
|
||||||
|
|
||||||
|
public event NotifyCollectionChangedEventHandler? CollectionChanged;
|
||||||
|
public int Count => _users.Count;
|
||||||
|
public int MaxItemLength { get; private set; }
|
||||||
|
public bool SuspendCollectionChangedEvent { get; set; }
|
||||||
|
|
||||||
|
public void Update(List<(string Text, Attribute? NameColor)> users)
|
||||||
|
{
|
||||||
|
_users.Clear();
|
||||||
|
_users.AddRange(users);
|
||||||
|
MaxItemLength = users.Count > 0 ? users.Max(u => u.Text.GetColumns()) : 0;
|
||||||
|
if (!SuspendCollectionChangedEvent)
|
||||||
|
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsMarked(int item) => false;
|
||||||
|
public void SetMark(int item, bool value) { }
|
||||||
|
public IList ToList() => _users.Select(u => u.Text).ToList();
|
||||||
|
|
||||||
|
public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0)
|
||||||
|
{
|
||||||
|
listView.Move(Math.Max(col - viewportX, 0), row);
|
||||||
|
|
||||||
|
var (text, nameColor) = _users[item];
|
||||||
|
var normalAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal);
|
||||||
|
|
||||||
|
// Find where the name starts (after status icon + space + optional role badge)
|
||||||
|
// Format: "● ★Username" or "● Username"
|
||||||
|
var graphemes = GraphemeHelper.GetGraphemes(text).ToList();
|
||||||
|
int nameStart = 0;
|
||||||
|
while (nameStart < graphemes.Count)
|
||||||
|
{
|
||||||
|
var g = graphemes[nameStart];
|
||||||
|
if (g.Length > 0 && (char.IsLetterOrDigit(g[0]) || g[0] == '_'))
|
||||||
|
break;
|
||||||
|
nameStart++;
|
||||||
|
}
|
||||||
|
|
||||||
|
int drawnChars = 0;
|
||||||
|
|
||||||
|
// Draw prefix (status icon + role badge) in normal color
|
||||||
|
listView.SetAttribute(normalAttr);
|
||||||
|
for (int i = 0; i < nameStart; i++)
|
||||||
|
{
|
||||||
|
var cols = Math.Max(graphemes[i].GetColumns(), 1);
|
||||||
|
if (drawnChars + cols > width) break;
|
||||||
|
listView.AddStr(graphemes[i]);
|
||||||
|
drawnChars += cols;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw name in nickname color
|
||||||
|
var userAttr = selected ? normalAttr : nameColor ?? normalAttr;
|
||||||
|
listView.SetAttribute(userAttr);
|
||||||
|
for (int i = nameStart; i < graphemes.Count; i++)
|
||||||
|
{
|
||||||
|
var cols = Math.Max(graphemes[i].GetColumns(), 1);
|
||||||
|
if (drawnChars + cols > width) break;
|
||||||
|
listView.AddStr(graphemes[i]);
|
||||||
|
drawnChars += cols;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill rest
|
||||||
|
listView.SetAttribute(normalAttr);
|
||||||
|
for (int i = drawnChars; i < width; i++)
|
||||||
|
listView.AddStr(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
+154
-351
@@ -1,5 +1,7 @@
|
|||||||
using System.Text.RegularExpressions;
|
|
||||||
using EchoHub.Client.Themes;
|
using EchoHub.Client.Themes;
|
||||||
|
using EchoHub.Client.UI.Chat;
|
||||||
|
using EchoHub.Client.UI.Helpers;
|
||||||
|
using EchoHub.Client.UI.ListSources;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
using Terminal.Gui.App;
|
using Terminal.Gui.App;
|
||||||
@@ -55,12 +57,11 @@ public sealed class MainWindow : Runnable
|
|||||||
];
|
];
|
||||||
|
|
||||||
private readonly List<string> _channelNames = [];
|
private readonly List<string> _channelNames = [];
|
||||||
private readonly Dictionary<string, List<ChatLine>> _channelMessages = [];
|
|
||||||
private readonly Dictionary<string, int> _channelUnread = [];
|
|
||||||
private readonly Dictionary<string, string?> _channelTopics = [];
|
private readonly Dictionary<string, string?> _channelTopics = [];
|
||||||
|
private readonly Dictionary<string, bool> _channelPublic = [];
|
||||||
private readonly ChannelListSource _channelListSource;
|
private readonly ChannelListSource _channelListSource;
|
||||||
private string _currentChannel = string.Empty;
|
private readonly ChatMessageManager _messageManager;
|
||||||
private string _currentUser = string.Empty;
|
private string _connectionStatus = "Disconnected";
|
||||||
private int _lastChatWidth;
|
private int _lastChatWidth;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -83,6 +84,11 @@ public sealed class MainWindow : Runnable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public event Action? OnDisconnectRequested;
|
public event Action? OnDisconnectRequested;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired when the user requests to logout (disconnect + revoke session).
|
||||||
|
/// </summary>
|
||||||
|
public event Action? OnLogoutRequested;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fired when the user requests to open their profile panel.
|
/// Fired when the user requests to open their profile panel.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -113,9 +119,21 @@ public sealed class MainWindow : Runnable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public event Action? OnDeleteChannelRequested;
|
public event Action? OnDeleteChannelRequested;
|
||||||
|
|
||||||
public MainWindow(IApplication app)
|
/// <summary>
|
||||||
|
/// Fired when the user activates (Enter/click) an audio message. Parameters: attachmentUrl, fileName.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<string, string>? OnAudioPlayRequested;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired when the user activates (Enter/click) a file message. Parameters: attachmentUrl, fileName.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<string, string>? OnFileDownloadRequested;
|
||||||
|
|
||||||
|
public MainWindow(IApplication app, ChatMessageManager messageManager)
|
||||||
{
|
{
|
||||||
_app = app;
|
_app = app;
|
||||||
|
_messageManager = messageManager;
|
||||||
|
_messageManager.MessagesChanged += OnMessagesChanged;
|
||||||
Arrangement = ViewArrangement.Fixed;
|
Arrangement = ViewArrangement.Fixed;
|
||||||
|
|
||||||
// Menu bar at the top
|
// Menu bar at the top
|
||||||
@@ -175,6 +193,7 @@ public sealed class MainWindow : Runnable
|
|||||||
Height = Dim.Fill()
|
Height = Dim.Fill()
|
||||||
};
|
};
|
||||||
_messageList.Source = new ChatListSource();
|
_messageList.Source = new ChatListSource();
|
||||||
|
_messageList.Accepting += OnMessageListAccepting;
|
||||||
_chatFrame.Add(_messageList);
|
_chatFrame.Add(_messageList);
|
||||||
Add(_chatFrame);
|
Add(_chatFrame);
|
||||||
|
|
||||||
@@ -223,16 +242,17 @@ public sealed class MainWindow : Runnable
|
|||||||
_usersFrame.Add(_usersList);
|
_usersFrame.Add(_usersList);
|
||||||
Add(_usersFrame);
|
Add(_usersFrame);
|
||||||
|
|
||||||
// Status bar at the very bottom
|
// Status bar at the very bottom — custom drawing for colored connection state
|
||||||
_statusLabel = new Label
|
_statusLabel = new Label
|
||||||
{
|
{
|
||||||
Text = "Disconnected",
|
Text = "",
|
||||||
X = 0,
|
X = 0,
|
||||||
Y = Pos.AnchorEnd(1),
|
Y = Pos.AnchorEnd(1),
|
||||||
Width = Dim.Fill(),
|
Width = Dim.Fill(),
|
||||||
Height = 1
|
Height = 1
|
||||||
};
|
};
|
||||||
_statusLabel.SetScheme(SchemeManager.GetScheme("Menu"));
|
_statusLabel.SetScheme(SchemeManager.GetScheme("Menu"));
|
||||||
|
_statusLabel.DrawingContent += OnStatusBarDrawContent;
|
||||||
Add(_statusLabel);
|
Add(_statusLabel);
|
||||||
|
|
||||||
// Apply our custom color schemes to all views
|
// Apply our custom color schemes to all views
|
||||||
@@ -308,6 +328,7 @@ public sealed class MainWindow : Runnable
|
|||||||
{
|
{
|
||||||
new MenuItem("_Connect...", "Connect to a server", () => OnConnectRequested?.Invoke(), Key.Empty),
|
new MenuItem("_Connect...", "Connect to a server", () => OnConnectRequested?.Invoke(), Key.Empty),
|
||||||
new MenuItem("_Disconnect", "Disconnect from server", () => OnDisconnectRequested?.Invoke(), Key.Empty),
|
new MenuItem("_Disconnect", "Disconnect from server", () => OnDisconnectRequested?.Invoke(), Key.Empty),
|
||||||
|
new MenuItem("_Logout", "Logout and clear session", () => OnLogoutRequested?.Invoke(), Key.Empty),
|
||||||
new Line(),
|
new Line(),
|
||||||
new MenuItem("New C_hannel...", "Create a new channel", () => OnCreateChannelRequested?.Invoke(), Key.Empty),
|
new MenuItem("New C_hannel...", "Create a new channel", () => OnCreateChannelRequested?.Invoke(), Key.Empty),
|
||||||
new MenuItem("_Delete Channel", "Delete the current channel", () => OnDeleteChannelRequested?.Invoke(), Key.Empty),
|
new MenuItem("_Delete Channel", "Delete the current channel", () => OnDeleteChannelRequested?.Invoke(), Key.Empty),
|
||||||
@@ -352,7 +373,7 @@ public sealed class MainWindow : Runnable
|
|||||||
if (index.HasValue && index.Value >= 0 && index.Value < _channelNames.Count)
|
if (index.HasValue && index.Value >= 0 && index.Value < _channelNames.Count)
|
||||||
{
|
{
|
||||||
var channelName = _channelNames[index.Value];
|
var channelName = _channelNames[index.Value];
|
||||||
if (channelName != _currentChannel)
|
if (channelName != _messageManager.CurrentChannel)
|
||||||
{
|
{
|
||||||
SwitchToChannel(channelName);
|
SwitchToChannel(channelName);
|
||||||
OnChannelSelected?.Invoke(channelName);
|
OnChannelSelected?.Invoke(channelName);
|
||||||
@@ -360,6 +381,31 @@ public sealed class MainWindow : Runnable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnMessageListAccepting(object? sender, CommandEventArgs e)
|
||||||
|
{
|
||||||
|
if (_messageList.Source is not ChatListSource source)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var index = _messageList.SelectedItem;
|
||||||
|
if (!index.HasValue || index.Value < 0 || index.Value >= source.Count)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var line = source.GetLine(index.Value);
|
||||||
|
if (line?.AttachmentUrl is null || line.AttachmentFileName is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (line.Type == MessageType.Audio)
|
||||||
|
{
|
||||||
|
OnAudioPlayRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
else if (line.Type == MessageType.File)
|
||||||
|
{
|
||||||
|
OnFileDownloadRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void OnInputKeyDown(object? sender, Key e)
|
private void OnInputKeyDown(object? sender, Key e)
|
||||||
{
|
{
|
||||||
if (e.KeyCode == TabKey.KeyCode)
|
if (e.KeyCode == TabKey.KeyCode)
|
||||||
@@ -375,9 +421,9 @@ public sealed class MainWindow : Runnable
|
|||||||
else if (e.KeyCode == EnterKey.KeyCode)
|
else if (e.KeyCode == EnterKey.KeyCode)
|
||||||
{
|
{
|
||||||
var text = _inputField.Text?.Trim() ?? string.Empty;
|
var text = _inputField.Text?.Trim() ?? string.Empty;
|
||||||
if (!string.IsNullOrEmpty(text) && !string.IsNullOrEmpty(_currentChannel))
|
if (!string.IsNullOrEmpty(text) && !string.IsNullOrEmpty(_messageManager.CurrentChannel))
|
||||||
{
|
{
|
||||||
OnMessageSubmitted?.Invoke(_currentChannel, text);
|
OnMessageSubmitted?.Invoke(_messageManager.CurrentChannel, text);
|
||||||
_inputField.Text = string.Empty;
|
_inputField.Text = string.Empty;
|
||||||
}
|
}
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
@@ -454,6 +500,7 @@ public sealed class MainWindow : Runnable
|
|||||||
if (newWidth > 0 && newWidth != _lastChatWidth)
|
if (newWidth > 0 && newWidth != _lastChatWidth)
|
||||||
{
|
{
|
||||||
_lastChatWidth = newWidth;
|
_lastChatWidth = newWidth;
|
||||||
|
_messageManager.SetChatWidth(newWidth);
|
||||||
RefreshMessages();
|
RefreshMessages();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -472,125 +519,12 @@ public sealed class MainWindow : Runnable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
private void OnMessagesChanged(string channelName)
|
||||||
/// Add a message to the specified channel's message list and refresh if it is the current channel.
|
|
||||||
/// Tracks unread count for non-active channels.
|
|
||||||
/// </summary>
|
|
||||||
public void AddMessage(MessageDto message)
|
|
||||||
{
|
{
|
||||||
var lines = FormatMessage(message);
|
if (channelName == _messageManager.CurrentChannel)
|
||||||
if (!_channelMessages.TryGetValue(message.ChannelName, out var messages))
|
|
||||||
{
|
|
||||||
messages = [];
|
|
||||||
_channelMessages[message.ChannelName] = messages;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var line in lines)
|
|
||||||
{
|
|
||||||
messages.Add(line);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.ChannelName == _currentChannel)
|
|
||||||
{
|
|
||||||
RefreshMessages();
|
RefreshMessages();
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
|
||||||
// Increment unread count for non-active channels
|
|
||||||
_channelUnread.TryGetValue(message.ChannelName, out var count);
|
|
||||||
_channelUnread[message.ChannelName] = count + 1;
|
|
||||||
RefreshChannelList();
|
RefreshChannelList();
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Add a system/informational message to a channel with colored styling.
|
|
||||||
/// </summary>
|
|
||||||
public void AddSystemMessage(string channelName, string text)
|
|
||||||
{
|
|
||||||
if (!_channelMessages.TryGetValue(channelName, out var messages))
|
|
||||||
{
|
|
||||||
messages = [];
|
|
||||||
_channelMessages[channelName] = messages;
|
|
||||||
}
|
|
||||||
|
|
||||||
var time = DateTimeOffset.Now.ToString("HH:mm");
|
|
||||||
var textLines = text.Split('\n');
|
|
||||||
|
|
||||||
// First line gets timestamp prefix
|
|
||||||
messages.Add(new ChatLine(
|
|
||||||
[
|
|
||||||
new($"[{time}] ", ChatColors.TimestampAttr),
|
|
||||||
new($"** {textLines[0].TrimEnd('\r')}", ChatColors.SystemAttr)
|
|
||||||
]));
|
|
||||||
|
|
||||||
// Continuation lines are indented to align
|
|
||||||
var indent = new string(' ', $"[{time}] ** ".Length);
|
|
||||||
for (int i = 1; i < textLines.Length; i++)
|
|
||||||
{
|
|
||||||
var line = textLines[i].TrimEnd('\r');
|
|
||||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
|
||||||
messages.Add(new ChatLine(
|
|
||||||
[
|
|
||||||
new($"{indent}{line}", ChatColors.SystemAttr)
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (channelName == _currentChannel)
|
|
||||||
{
|
|
||||||
RefreshMessages();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Add a status change message to a channel with colored styling.
|
|
||||||
/// </summary>
|
|
||||||
public void AddStatusMessage(string channelName, string username, string status)
|
|
||||||
{
|
|
||||||
var time = DateTimeOffset.Now.ToString("HH:mm");
|
|
||||||
var segments = new List<ChatSegment>
|
|
||||||
{
|
|
||||||
new($"[{time}] ", ChatColors.TimestampAttr),
|
|
||||||
new($"** {username} is now {status}", ChatColors.SystemAttr)
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!_channelMessages.TryGetValue(channelName, out var messages))
|
|
||||||
{
|
|
||||||
messages = [];
|
|
||||||
_channelMessages[channelName] = messages;
|
|
||||||
}
|
|
||||||
messages.Add(new ChatLine(segments));
|
|
||||||
|
|
||||||
if (channelName == _currentChannel)
|
|
||||||
{
|
|
||||||
RefreshMessages();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Remove all lines associated with a specific message ID.
|
|
||||||
/// </summary>
|
|
||||||
public void RemoveMessage(string channelName, Guid messageId)
|
|
||||||
{
|
|
||||||
if (_channelMessages.TryGetValue(channelName, out var messages))
|
|
||||||
{
|
|
||||||
messages.RemoveAll(l => l.MessageId == messageId);
|
|
||||||
if (channelName == _currentChannel)
|
|
||||||
RefreshMessages();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Clear all messages from a specific channel.
|
|
||||||
/// </summary>
|
|
||||||
public void ClearChannelMessages(string channelName)
|
|
||||||
{
|
|
||||||
if (_channelMessages.TryGetValue(channelName, out var messages))
|
|
||||||
{
|
|
||||||
messages.Clear();
|
|
||||||
if (channelName == _currentChannel)
|
|
||||||
RefreshMessages();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -600,12 +534,12 @@ public sealed class MainWindow : Runnable
|
|||||||
{
|
{
|
||||||
_channelNames.Clear();
|
_channelNames.Clear();
|
||||||
_channelTopics.Clear();
|
_channelTopics.Clear();
|
||||||
|
_channelPublic.Clear();
|
||||||
foreach (var ch in channels)
|
foreach (var ch in channels)
|
||||||
{
|
{
|
||||||
_channelNames.Add(ch.Name);
|
_channelNames.Add(ch.Name);
|
||||||
_channelTopics[ch.Name] = ch.Topic;
|
_channelTopics[ch.Name] = ch.Topic;
|
||||||
if (!_channelMessages.ContainsKey(ch.Name))
|
_channelPublic[ch.Name] = ch.IsPublic;
|
||||||
_channelMessages[ch.Name] = [];
|
|
||||||
}
|
}
|
||||||
RefreshChannelList();
|
RefreshChannelList();
|
||||||
}
|
}
|
||||||
@@ -613,14 +547,15 @@ public sealed class MainWindow : Runnable
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Ensure a channel exists in the left panel list (used for private channels joined via /join).
|
/// Ensure a channel exists in the left panel list (used for private channels joined via /join).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void EnsureChannelInList(string channelName)
|
public void EnsureChannelInList(string channelName, bool? isPublic = null)
|
||||||
{
|
{
|
||||||
|
if (isPublic.HasValue)
|
||||||
|
_channelPublic[channelName] = isPublic.Value;
|
||||||
|
|
||||||
if (_channelNames.Contains(channelName))
|
if (_channelNames.Contains(channelName))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
_channelNames.Add(channelName);
|
_channelNames.Add(channelName);
|
||||||
if (!_channelMessages.ContainsKey(channelName))
|
|
||||||
_channelMessages[channelName] = [];
|
|
||||||
RefreshChannelList();
|
RefreshChannelList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -631,6 +566,7 @@ public sealed class MainWindow : Runnable
|
|||||||
{
|
{
|
||||||
_channelNames.Remove(channelName);
|
_channelNames.Remove(channelName);
|
||||||
_channelTopics.Remove(channelName);
|
_channelTopics.Remove(channelName);
|
||||||
|
_channelPublic.Remove(channelName);
|
||||||
RefreshChannelList();
|
RefreshChannelList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -640,7 +576,7 @@ public sealed class MainWindow : Runnable
|
|||||||
public void SetChannelTopic(string channelName, string? topic)
|
public void SetChannelTopic(string channelName, string? topic)
|
||||||
{
|
{
|
||||||
_channelTopics[channelName] = topic;
|
_channelTopics[channelName] = topic;
|
||||||
if (channelName == _currentChannel)
|
if (channelName == _messageManager.CurrentChannel)
|
||||||
UpdateTopicBar();
|
UpdateTopicBar();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -657,23 +593,92 @@ public sealed class MainWindow : Runnable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public void UpdateStatusBar(string status)
|
public void UpdateStatusBar(string status)
|
||||||
{
|
{
|
||||||
var userPart = string.IsNullOrEmpty(_currentUser) ? "" : $" \u2502 User: {_currentUser}";
|
_connectionStatus = status;
|
||||||
var channelPart = string.IsNullOrEmpty(_currentChannel) ? "" : $" \u2502 #{_currentChannel}";
|
_statusLabel.SetNeedsDraw();
|
||||||
_statusLabel.Text = $" v{AppVersion} \u2502 {status}{userPart}{channelPart}";
|
}
|
||||||
|
|
||||||
|
private static readonly Attribute StatusConnectedAttr = new(new Color(0, 200, 0), Color.None);
|
||||||
|
private static readonly Attribute StatusDisconnectedAttr = new(new Color(220, 50, 50), Color.None);
|
||||||
|
private static readonly Attribute StatusTransitionalAttr = new(new Color(220, 180, 0), Color.None);
|
||||||
|
private static readonly Attribute StatusBrandAttr = new(new Color(218, 165, 32), Color.None);
|
||||||
|
|
||||||
|
private void OnStatusBarDrawContent(object? sender, DrawEventArgs e)
|
||||||
|
{
|
||||||
|
var menuScheme = SchemeManager.GetScheme("Menu");
|
||||||
|
var normalAttr = menuScheme?.Normal ?? _statusLabel.GetAttributeForRole(VisualRole.Normal);
|
||||||
|
var width = _statusLabel.Viewport.Width;
|
||||||
|
if (width <= 0) return;
|
||||||
|
|
||||||
|
// Resolve None background for colored segments
|
||||||
|
var bg = normalAttr.Background;
|
||||||
|
Attribute Resolve(Attribute a) => a.Background == Color.None ? a with { Background = bg } : a;
|
||||||
|
|
||||||
|
int col = 0;
|
||||||
|
|
||||||
|
void Write(string text, Attribute attr)
|
||||||
|
{
|
||||||
|
_statusLabel.SetAttribute(Resolve(attr));
|
||||||
|
foreach (var g in GraphemeHelper.GetGraphemes(text))
|
||||||
|
{
|
||||||
|
var cols = Math.Max(g.GetColumns(), 1);
|
||||||
|
if (col + cols > width) return;
|
||||||
|
_statusLabel.Move(col, 0);
|
||||||
|
_statusLabel.AddStr(g);
|
||||||
|
col += cols;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EchoHub branding
|
||||||
|
Write(" EchoHub", Resolve(StatusBrandAttr));
|
||||||
|
Write($" \u2502 v{AppVersion} \u2502 ", normalAttr);
|
||||||
|
|
||||||
|
// Connection state with color
|
||||||
|
var statusAttr = _connectionStatus switch
|
||||||
|
{
|
||||||
|
"Connected" => StatusConnectedAttr,
|
||||||
|
"Disconnected" => StatusDisconnectedAttr,
|
||||||
|
_ => StatusTransitionalAttr // Connecting, Reconnecting, Authenticating, etc.
|
||||||
|
};
|
||||||
|
Write(_connectionStatus, Resolve(statusAttr));
|
||||||
|
|
||||||
|
// User
|
||||||
|
var currentUser = _messageManager.CurrentUser;
|
||||||
|
if (!string.IsNullOrEmpty(currentUser))
|
||||||
|
Write($" \u2502 User: {currentUser}", normalAttr);
|
||||||
|
|
||||||
|
// Channel + type
|
||||||
|
var currentChannel = _messageManager.CurrentChannel;
|
||||||
|
if (!string.IsNullOrEmpty(currentChannel))
|
||||||
|
{
|
||||||
|
_channelPublic.TryGetValue(currentChannel, out var isPublic);
|
||||||
|
var typeSuffix = isPublic ? "public" : "private";
|
||||||
|
Write($" \u2502 #{currentChannel} - {typeSuffix}", normalAttr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill remaining space
|
||||||
|
_statusLabel.SetAttribute(normalAttr);
|
||||||
|
while (col < width)
|
||||||
|
{
|
||||||
|
_statusLabel.Move(col, 0);
|
||||||
|
_statusLabel.AddStr(" ");
|
||||||
|
col++;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.Cancel = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Set the current user name for display in the status bar.
|
/// Set the current user name (delegates to message manager for @mention detection).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void SetCurrentUser(string username)
|
public void SetCurrentUser(string username)
|
||||||
{
|
{
|
||||||
_currentUser = username;
|
_messageManager.SetCurrentUser(username);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get the current channel name.
|
/// Get the current channel name.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string CurrentChannel => _currentChannel;
|
public string CurrentChannel => _messageManager.CurrentChannel;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get all channel names that have message buffers (for broadcasting status changes).
|
/// Get all channel names that have message buffers (for broadcasting status changes).
|
||||||
@@ -685,15 +690,15 @@ public sealed class MainWindow : Runnable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public void SwitchToChannel(string channelName)
|
public void SwitchToChannel(string channelName)
|
||||||
{
|
{
|
||||||
_currentChannel = channelName;
|
_messageManager.CurrentChannel = channelName;
|
||||||
_chatFrame.Title = $"#{channelName}";
|
_chatFrame.Title = $"#{channelName}";
|
||||||
|
|
||||||
// Reset unread count for this channel
|
_messageManager.ClearUnread(channelName);
|
||||||
_channelUnread[channelName] = 0;
|
|
||||||
RefreshChannelList();
|
RefreshChannelList();
|
||||||
|
|
||||||
RefreshMessages();
|
RefreshMessages();
|
||||||
UpdateTopicBar();
|
UpdateTopicBar();
|
||||||
|
_statusLabel.SetNeedsDraw();
|
||||||
|
|
||||||
// Update channel list selection
|
// Update channel list selection
|
||||||
var idx = _channelNames.IndexOf(channelName);
|
var idx = _channelNames.IndexOf(channelName);
|
||||||
@@ -701,32 +706,15 @@ public sealed class MainWindow : Runnable
|
|||||||
_channelList.SelectedItem = idx;
|
_channelList.SelectedItem = idx;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Load historical messages into a channel, replacing any existing messages.
|
|
||||||
/// </summary>
|
|
||||||
public void LoadHistory(string channelName, List<MessageDto> messages)
|
|
||||||
{
|
|
||||||
var formatted = messages.SelectMany(FormatMessage).ToList();
|
|
||||||
_channelMessages[channelName] = formatted;
|
|
||||||
|
|
||||||
if (channelName == _currentChannel)
|
|
||||||
{
|
|
||||||
RefreshMessages();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Clear all messages and channels (used on disconnect).
|
/// Clear all messages and channels (used on disconnect).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void ClearAll()
|
public void ClearAll()
|
||||||
{
|
{
|
||||||
_channelNames.Clear();
|
_channelNames.Clear();
|
||||||
_channelMessages.Clear();
|
_messageManager.ClearAll();
|
||||||
_channelUnread.Clear();
|
|
||||||
_channelTopics.Clear();
|
_channelTopics.Clear();
|
||||||
_currentChannel = string.Empty;
|
_channelPublic.Clear();
|
||||||
_currentUser = string.Empty;
|
|
||||||
_channelListSource.Update([], [], string.Empty);
|
_channelListSource.Update([], [], string.Empty);
|
||||||
_channelList.Source = _channelListSource;
|
_channelList.Source = _channelListSource;
|
||||||
_chatFrame.Title = "Chat";
|
_chatFrame.Title = "Chat";
|
||||||
@@ -748,7 +736,8 @@ public sealed class MainWindow : Runnable
|
|||||||
|
|
||||||
private void RefreshMessages()
|
private void RefreshMessages()
|
||||||
{
|
{
|
||||||
if (_channelMessages.TryGetValue(_currentChannel, out var messages))
|
var messages = _messageManager.GetMessages(_messageManager.CurrentChannel);
|
||||||
|
if (messages is not null)
|
||||||
{
|
{
|
||||||
var width = _messageList.Viewport.Width;
|
var width = _messageList.Viewport.Width;
|
||||||
|
|
||||||
@@ -786,11 +775,11 @@ public sealed class MainWindow : Runnable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private void RefreshChannelList()
|
private void RefreshChannelList()
|
||||||
{
|
{
|
||||||
_channelListSource.Update(_channelNames, _channelUnread, _currentChannel);
|
_channelListSource.Update(_channelNames, _messageManager.GetUnreadCounts(), _messageManager.CurrentChannel);
|
||||||
_channelList.Source = _channelListSource;
|
_channelList.Source = _channelListSource;
|
||||||
|
|
||||||
// Restore selection to current channel
|
// Restore selection to current channel
|
||||||
var idx = _channelNames.IndexOf(_currentChannel);
|
var idx = _channelNames.IndexOf(_messageManager.CurrentChannel);
|
||||||
if (idx >= 0)
|
if (idx >= 0)
|
||||||
_channelList.SelectedItem = idx;
|
_channelList.SelectedItem = idx;
|
||||||
}
|
}
|
||||||
@@ -800,7 +789,7 @@ public sealed class MainWindow : Runnable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private void UpdateTopicBar()
|
private void UpdateTopicBar()
|
||||||
{
|
{
|
||||||
_channelTopics.TryGetValue(_currentChannel, out var topic);
|
_channelTopics.TryGetValue(_messageManager.CurrentChannel, out var topic);
|
||||||
if (!string.IsNullOrWhiteSpace(topic))
|
if (!string.IsNullOrWhiteSpace(topic))
|
||||||
{
|
{
|
||||||
_topicLabel.Text = $" Topic: {topic}";
|
_topicLabel.Text = $" Topic: {topic}";
|
||||||
@@ -860,7 +849,7 @@ public sealed class MainWindow : Runnable
|
|||||||
_ => ""
|
_ => ""
|
||||||
};
|
};
|
||||||
var text = $"{statusIcon} {roleTag}{name}";
|
var text = $"{statusIcon} {roleTag}{name}";
|
||||||
var nameColor = ColorHelper.ParseHexColor(u.NicknameColor);
|
var nameColor = HexColorHelper.ParseHexColor(u.NicknameColor);
|
||||||
return (text, nameColor);
|
return (text, nameColor);
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
@@ -869,190 +858,4 @@ public sealed class MainWindow : Runnable
|
|||||||
_usersFrame.Title = $"Users ({users.Count})";
|
_usersFrame.Title = $"Users ({users.Count})";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Format a message DTO into one or more display lines based on its MessageType.
|
|
||||||
/// Timestamps are dimmed and sender names are colored.
|
|
||||||
/// </summary>
|
|
||||||
private List<ChatLine> FormatMessage(MessageDto message)
|
|
||||||
{
|
|
||||||
var time = message.SentAt.ToLocalTime().ToString("HH:mm");
|
|
||||||
var senderName = message.SenderUsername + ":";
|
|
||||||
var senderColor = ColorHelper.ParseHexColor(message.SenderNicknameColor);
|
|
||||||
|
|
||||||
var lines = new List<ChatLine>();
|
|
||||||
|
|
||||||
switch (message.Type)
|
|
||||||
{
|
|
||||||
case MessageType.Image:
|
|
||||||
lines.Add(BuildChatLine(time, senderName, senderColor, " [Image]"));
|
|
||||||
// Content IS the ASCII art — add each line as a separate list item
|
|
||||||
if (!string.IsNullOrWhiteSpace(message.Content))
|
|
||||||
{
|
|
||||||
foreach (var artLine in message.Content.Split('\n'))
|
|
||||||
{
|
|
||||||
// Parse color tags from colored ASCII art
|
|
||||||
var trimmed = artLine.TrimEnd('\r');
|
|
||||||
if (ChatLine.HasColorTags(trimmed))
|
|
||||||
lines.Add(ChatLine.FromColoredText(" " + trimmed));
|
|
||||||
else
|
|
||||||
lines.Add(new ChatLine($" {trimmed}"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case MessageType.File:
|
|
||||||
var fileName = message.AttachmentFileName ?? "unknown";
|
|
||||||
var fileContent = !string.IsNullOrWhiteSpace(message.Content) ? $" {message.Content}" : "";
|
|
||||||
lines.Add(BuildChatLine(time, senderName, senderColor, $" [File: {fileName}]{fileContent}"));
|
|
||||||
break;
|
|
||||||
|
|
||||||
case MessageType.Text:
|
|
||||||
default:
|
|
||||||
var displayContent = EmojiHelper.ReplaceEmoji(message.Content);
|
|
||||||
var contentLines = displayContent.Split('\n');
|
|
||||||
var firstLine = contentLines[0].TrimEnd('\r');
|
|
||||||
lines.Add(BuildChatLineWithMentions(time, senderName, senderColor, $" {firstLine}"));
|
|
||||||
// Continuation lines indented to align with first line's content
|
|
||||||
var indent = new string(' ', $"[{time}] {senderName} ".Length);
|
|
||||||
for (int i = 1; i < contentLines.Length; i++)
|
|
||||||
{
|
|
||||||
var contText = $"{indent}{contentLines[i].TrimEnd('\r')}";
|
|
||||||
lines.Add(new ChatLine(ChatColors.SplitMentions(contText)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render link embeds if present
|
|
||||||
if (message.Embeds is { Count: > 0 })
|
|
||||||
{
|
|
||||||
var chatWidth = _lastChatWidth > 0 ? _lastChatWidth : 80;
|
|
||||||
foreach (var embed in message.Embeds)
|
|
||||||
lines.AddRange(FormatEmbed(embed, indent, chatWidth));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tag all lines with the message ID for deletion support
|
|
||||||
foreach (var line in lines)
|
|
||||||
line.MessageId = message.Id;
|
|
||||||
|
|
||||||
// Check for @mention of current user
|
|
||||||
if (!string.IsNullOrEmpty(_currentUser) && message.Type == MessageType.Text)
|
|
||||||
{
|
|
||||||
var pattern = $@"@{Regex.Escape(_currentUser)}\b";
|
|
||||||
if (Regex.IsMatch(message.Content, pattern, RegexOptions.IgnoreCase))
|
|
||||||
{
|
|
||||||
foreach (var line in lines)
|
|
||||||
line.IsMention = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Build a chat line with a dimmed timestamp and optionally colored sender name.
|
|
||||||
/// </summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Build a chat line with @mention highlighting in the suffix text.
|
|
||||||
/// </summary>
|
|
||||||
private static ChatLine BuildChatLineWithMentions(string time, string senderName, Attribute? senderColor, string suffix)
|
|
||||||
{
|
|
||||||
var segments = new List<ChatSegment>
|
|
||||||
{
|
|
||||||
new($"[{time}] ", ChatColors.TimestampAttr),
|
|
||||||
new(senderName, senderColor),
|
|
||||||
};
|
|
||||||
segments.AddRange(ChatColors.SplitMentions(suffix));
|
|
||||||
return new ChatLine(segments);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Format a link embed as indented chat lines with a left border bar,
|
|
||||||
/// text at full width, and optional icon below (preview image).
|
|
||||||
/// Each line is pre-wrapped to fit chatWidth so ChatLine.Wrap won't break layout.
|
|
||||||
/// </summary>
|
|
||||||
private static List<ChatLine> FormatEmbed(EmbedDto embed, string indent, int chatWidth)
|
|
||||||
{
|
|
||||||
var lines = new List<ChatLine>();
|
|
||||||
const string border = "\u258f "; // ▏ + space
|
|
||||||
const int borderCols = 2;
|
|
||||||
int indentCols = indent.GetColumns();
|
|
||||||
int textWidth = chatWidth - indentCols - borderCols;
|
|
||||||
if (textWidth < 20) textWidth = 20;
|
|
||||||
|
|
||||||
// Helper: create a bordered text line
|
|
||||||
void AddTextLine(string text, Attribute? color)
|
|
||||||
{
|
|
||||||
lines.Add(new ChatLine(
|
|
||||||
[
|
|
||||||
new ChatSegment(indent, null),
|
|
||||||
new ChatSegment(border, ChatColors.EmbedBorderAttr),
|
|
||||||
new ChatSegment(text, color)
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Site name
|
|
||||||
if (!string.IsNullOrWhiteSpace(embed.SiteName))
|
|
||||||
AddTextLine(embed.SiteName, ChatColors.EmbedBorderAttr);
|
|
||||||
|
|
||||||
// Title
|
|
||||||
if (!string.IsNullOrWhiteSpace(embed.Title))
|
|
||||||
{
|
|
||||||
foreach (var wrapped in WordWrap(embed.Title, textWidth))
|
|
||||||
AddTextLine(wrapped, ChatColors.EmbedTitleAttr);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Description (word-wrapped at full available width)
|
|
||||||
if (!string.IsNullOrWhiteSpace(embed.Description))
|
|
||||||
{
|
|
||||||
foreach (var wrapped in WordWrap(embed.Description, textWidth))
|
|
||||||
AddTextLine(wrapped, ChatColors.EmbedDescAttr);
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Simple word-wrap: splits text into lines that fit within maxCols display columns.
|
|
||||||
/// </summary>
|
|
||||||
private static List<string> WordWrap(string text, int maxCols)
|
|
||||||
{
|
|
||||||
if (maxCols <= 0)
|
|
||||||
return [text];
|
|
||||||
|
|
||||||
var result = new List<string>();
|
|
||||||
var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
|
||||||
var currentLine = "";
|
|
||||||
|
|
||||||
foreach (var word in words)
|
|
||||||
{
|
|
||||||
var candidate = currentLine.Length == 0 ? word : currentLine + " " + word;
|
|
||||||
if (candidate.GetColumns() <= maxCols)
|
|
||||||
{
|
|
||||||
currentLine = candidate;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (currentLine.Length > 0)
|
|
||||||
result.Add(currentLine);
|
|
||||||
// If a single word exceeds maxCols, just add it as-is
|
|
||||||
currentLine = word;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentLine.Length > 0)
|
|
||||||
result.Add(currentLine);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ public static class HubConstants
|
|||||||
public const string DefaultChannel = "general";
|
public const string DefaultChannel = "general";
|
||||||
public const int DefaultHistoryCount = 100;
|
public const int DefaultHistoryCount = 100;
|
||||||
public const int MaxMessageLength = 2000;
|
public const int MaxMessageLength = 2000;
|
||||||
public const int MaxFileSizeBytes = 10 * 1024 * 1024; // 10 MB
|
public const int MaxImageSizeBytes = 10 * 1024 * 1024; // 10 MB
|
||||||
|
public const int MaxAudioFileSizeBytes = 10 * 1024 * 1024; // 10 MB
|
||||||
|
public const int MaxFileSizeBytes = 100 * 1024 * 1024; // 100 MB
|
||||||
public const int MaxAvatarSizeBytes = 2 * 1024 * 1024; // 2 MB
|
public const int MaxAvatarSizeBytes = 2 * 1024 * 1024; // 2 MB
|
||||||
public const int MaxMessageNewlines = 30;
|
public const int MaxMessageNewlines = 30;
|
||||||
public const int MaxConsecutiveNewlines = 1;
|
public const int MaxConsecutiveNewlines = 1;
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using EchoHub.Core.DTOs;
|
||||||
|
|
||||||
|
namespace EchoHub.Core.Contracts;
|
||||||
|
|
||||||
|
public interface IChannelService
|
||||||
|
{
|
||||||
|
// Channel CRUD
|
||||||
|
Task<PaginatedResponse<ChannelDto>> GetChannelsAsync(Guid userId, int offset, int limit);
|
||||||
|
Task<ChannelOperationResult> CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic);
|
||||||
|
Task<ChannelOperationResult> UpdateTopicAsync(Guid callerUserId, string channelName, string? topic);
|
||||||
|
Task<ChannelOperationResult> DeleteChannelAsync(Guid callerUserId, string channelName);
|
||||||
|
|
||||||
|
// Channel queries
|
||||||
|
Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName);
|
||||||
|
Task<List<ChannelListItem>> GetChannelListAsync();
|
||||||
|
Task<ChannelDto?> GetChannelByNameAsync(string channelName);
|
||||||
|
|
||||||
|
// Membership
|
||||||
|
Task<(bool Success, string? Error)> EnsureChannelMembershipAsync(Guid userId, string channelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ChannelListItem(string Name, string? Topic, int OnlineCount);
|
||||||
@@ -25,12 +25,8 @@ public interface IChatService
|
|||||||
Task BroadcastMessageAsync(string channelName, MessageDto message);
|
Task BroadcastMessageAsync(string channelName, MessageDto message);
|
||||||
Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null);
|
Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null);
|
||||||
|
|
||||||
// Query operations (used by IRC gateway for WHOIS, TOPIC, LIST, AUTH)
|
// Query operations (used by IRC gateway for WHOIS, AUTH)
|
||||||
Task<UserProfileDto?> GetUserProfileAsync(string username);
|
Task<UserProfileDto?> GetUserProfileAsync(string username);
|
||||||
Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName);
|
|
||||||
Task<List<ChannelListItem>> GetChannelListAsync();
|
|
||||||
Task<List<string>> GetChannelsForUserAsync(string username);
|
Task<List<string>> GetChannelsForUserAsync(string username);
|
||||||
Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password);
|
Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password);
|
||||||
}
|
}
|
||||||
|
|
||||||
public record ChannelListItem(string Name, string? Topic, int OnlineCount);
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace EchoHub.Core.Contracts;
|
||||||
|
|
||||||
|
public interface IMessageEncryptionService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Whether database content should be encrypted at rest (server setting).
|
||||||
|
/// </summary>
|
||||||
|
bool EncryptDatabaseEnabled { get; }
|
||||||
|
|
||||||
|
string Encrypt(string plaintext);
|
||||||
|
string Decrypt(string content);
|
||||||
|
string? EncryptNullable(string? value);
|
||||||
|
string? DecryptNullable(string? value);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ public record MessageDto(
|
|||||||
string? AttachmentUrl,
|
string? AttachmentUrl,
|
||||||
string? AttachmentFileName,
|
string? AttachmentFileName,
|
||||||
DateTimeOffset SentAt,
|
DateTimeOffset SentAt,
|
||||||
|
long? AttachmentFileSize = null,
|
||||||
List<EmbedDto>? Embeds = null);
|
List<EmbedDto>? Embeds = null);
|
||||||
|
|
||||||
public record ChannelDto(
|
public record ChannelDto(
|
||||||
|
|||||||
@@ -1,5 +1,26 @@
|
|||||||
namespace EchoHub.Core.DTOs;
|
namespace EchoHub.Core.DTOs;
|
||||||
|
|
||||||
|
public record ApiResponse(bool Success, string? Message = null, List<string>? Errors = null);
|
||||||
|
|
||||||
|
public record ApiResponse<T>(bool Success, string? Message = null, List<string>? Errors = null, T? Data = default);
|
||||||
|
|
||||||
public record ErrorResponse(string Error, string? Detail = null);
|
public record ErrorResponse(string Error, string? Detail = null);
|
||||||
|
|
||||||
public record PaginatedResponse<T>(List<T> Items, int Total, int Offset, int Limit);
|
public record PaginatedResponse<T>(List<T> Items, int Total, int Offset, int Limit);
|
||||||
|
|
||||||
|
public enum ChannelError
|
||||||
|
{
|
||||||
|
ValidationFailed,
|
||||||
|
AlreadyExists,
|
||||||
|
NotFound,
|
||||||
|
Forbidden,
|
||||||
|
Protected
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ChannelOperationResult(ChannelDto? Channel, ChannelError? Error, string? ErrorMessage)
|
||||||
|
{
|
||||||
|
public bool IsSuccess => Error is null;
|
||||||
|
|
||||||
|
public static ChannelOperationResult Success(ChannelDto channel) => new(channel, null, null);
|
||||||
|
public static ChannelOperationResult Fail(ChannelError error, string message) => new(null, error, message);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
namespace EchoHub.Core.DTOs;
|
namespace EchoHub.Core.DTOs;
|
||||||
|
|
||||||
public record ServerStatusDto(string Name, string? Description, int OnlineUsers, int TotalChannels);
|
public record ServerStatusDto(string Name, string? Description, int OnlineUsers, int TotalChannels);
|
||||||
|
|
||||||
|
public record EncryptionKeyResponse(string Key);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ public class Message
|
|||||||
public MessageType Type { get; set; } = MessageType.Text;
|
public MessageType Type { get; set; } = MessageType.Text;
|
||||||
public string? AttachmentUrl { get; set; }
|
public string? AttachmentUrl { get; set; }
|
||||||
public string? AttachmentFileName { get; set; }
|
public string? AttachmentFileName { get; set; }
|
||||||
|
public long? AttachmentFileSize { get; set; }
|
||||||
public string? EmbedJson { get; set; }
|
public string? EmbedJson { get; set; }
|
||||||
public DateTimeOffset SentAt { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset SentAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ public enum MessageType
|
|||||||
{
|
{
|
||||||
Text,
|
Text,
|
||||||
Image,
|
Image,
|
||||||
File
|
File,
|
||||||
|
Audio
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,15 +6,19 @@ namespace EchoHub.Server.Irc;
|
|||||||
public class IrcBroadcaster : IChatBroadcaster
|
public class IrcBroadcaster : IChatBroadcaster
|
||||||
{
|
{
|
||||||
private readonly IrcGatewayService _gateway;
|
private readonly IrcGatewayService _gateway;
|
||||||
|
private readonly IMessageEncryptionService _encryption;
|
||||||
|
|
||||||
public IrcBroadcaster(IrcGatewayService gateway)
|
public IrcBroadcaster(IrcGatewayService gateway, IMessageEncryptionService encryption)
|
||||||
{
|
{
|
||||||
_gateway = gateway;
|
_gateway = gateway;
|
||||||
|
_encryption = encryption;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SendMessageToChannelAsync(string channelName, MessageDto message)
|
public async Task SendMessageToChannelAsync(string channelName, MessageDto message)
|
||||||
{
|
{
|
||||||
var lines = IrcMessageFormatter.FormatMessage(message);
|
// Decrypt content for IRC clients (they can't handle app-layer encryption)
|
||||||
|
var decryptedMessage = message with { Content = _encryption.Decrypt(message.Content) };
|
||||||
|
var lines = IrcMessageFormatter.FormatMessage(decryptedMessage);
|
||||||
|
|
||||||
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -27,14 +27,20 @@ public sealed class IrcClientConnection : IAsyncDisposable
|
|||||||
public bool IsSasl { get; set; }
|
public bool IsSasl { get; set; }
|
||||||
public bool CapNegotiating { get; set; }
|
public bool CapNegotiating { get; set; }
|
||||||
|
|
||||||
// Channel state
|
// Channel state — thread-safe: written by command handler, read by broadcaster threads
|
||||||
public HashSet<string> JoinedChannels { get; } = new(StringComparer.OrdinalIgnoreCase);
|
private readonly HashSet<string> _joinedChannels = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly object _channelLock = new();
|
||||||
|
|
||||||
// Away state
|
// Away state
|
||||||
public string? AwayMessage { get; set; }
|
public string? AwayMessage { get; set; }
|
||||||
|
|
||||||
public string Hostmask => $"{Nickname}!{Username ?? Nickname}@echohub";
|
public string Hostmask => $"{Nickname}!{Username ?? Nickname}@echohub";
|
||||||
|
|
||||||
|
public void JoinChannel(string channel) { lock (_channelLock) _joinedChannels.Add(channel); }
|
||||||
|
public void LeaveChannel(string channel) { lock (_channelLock) _joinedChannels.Remove(channel); }
|
||||||
|
public bool IsInChannel(string channel) { lock (_channelLock) return _joinedChannels.Contains(channel); }
|
||||||
|
public List<string> GetJoinedChannels() { lock (_channelLock) return [.. _joinedChannels]; }
|
||||||
|
|
||||||
public IrcClientConnection(TcpClient tcpClient, Stream stream)
|
public IrcClientConnection(TcpClient tcpClient, Stream stream)
|
||||||
{
|
{
|
||||||
_tcpClient = tcpClient;
|
_tcpClient = tcpClient;
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ public sealed class IrcCommandHandler
|
|||||||
private readonly IrcClientConnection _conn;
|
private readonly IrcClientConnection _conn;
|
||||||
private readonly IrcOptions _options;
|
private readonly IrcOptions _options;
|
||||||
private readonly IChatService _chatService;
|
private readonly IChatService _chatService;
|
||||||
|
private readonly IChannelService _channelService;
|
||||||
|
private readonly IMessageEncryptionService _encryption;
|
||||||
private readonly ILogger _logger;
|
private readonly ILogger _logger;
|
||||||
|
|
||||||
private string ServerName => _options.ServerName;
|
private string ServerName => _options.ServerName;
|
||||||
@@ -20,11 +22,15 @@ public sealed class IrcCommandHandler
|
|||||||
IrcClientConnection conn,
|
IrcClientConnection conn,
|
||||||
IrcOptions options,
|
IrcOptions options,
|
||||||
IChatService chatService,
|
IChatService chatService,
|
||||||
|
IChannelService channelService,
|
||||||
|
IMessageEncryptionService encryption,
|
||||||
ILogger logger)
|
ILogger logger)
|
||||||
{
|
{
|
||||||
_conn = conn;
|
_conn = conn;
|
||||||
_options = options;
|
_options = options;
|
||||||
_chatService = chatService;
|
_chatService = chatService;
|
||||||
|
_channelService = channelService;
|
||||||
|
_encryption = encryption;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,7 +325,7 @@ public sealed class IrcCommandHandler
|
|||||||
|
|
||||||
private async Task HandleJoinAsync(IrcMessage msg)
|
private async Task HandleJoinAsync(IrcMessage msg)
|
||||||
{
|
{
|
||||||
if (!RequireRegistered()) return;
|
if (!await RequireRegisteredAsync()) return;
|
||||||
|
|
||||||
if (msg.Parameters.Count < 1)
|
if (msg.Parameters.Count < 1)
|
||||||
{
|
{
|
||||||
@@ -350,7 +356,7 @@ public sealed class IrcCommandHandler
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
_conn.JoinedChannels.Add(channelName);
|
_conn.JoinChannel(channelName);
|
||||||
|
|
||||||
// Confirm JOIN to the client
|
// Confirm JOIN to the client
|
||||||
await _conn.SendAsync($":{_conn.Hostmask} JOIN #{channelName}");
|
await _conn.SendAsync($":{_conn.Hostmask} JOIN #{channelName}");
|
||||||
@@ -361,10 +367,11 @@ public sealed class IrcCommandHandler
|
|||||||
// Send NAMES list
|
// Send NAMES list
|
||||||
await SendNamesReplyAsync(channelName);
|
await SendNamesReplyAsync(channelName);
|
||||||
|
|
||||||
// Replay history
|
// Replay history (decrypt — history is encrypted for SignalR transport)
|
||||||
foreach (var m in history)
|
foreach (var m in history)
|
||||||
{
|
{
|
||||||
var lines = IrcMessageFormatter.FormatMessage(m);
|
var decrypted = m with { Content = _encryption.Decrypt(m.Content) };
|
||||||
|
var lines = IrcMessageFormatter.FormatMessage(decrypted);
|
||||||
foreach (var line in lines)
|
foreach (var line in lines)
|
||||||
await _conn.SendAsync(line);
|
await _conn.SendAsync(line);
|
||||||
}
|
}
|
||||||
@@ -373,7 +380,7 @@ public sealed class IrcCommandHandler
|
|||||||
|
|
||||||
private async Task HandlePartAsync(IrcMessage msg)
|
private async Task HandlePartAsync(IrcMessage msg)
|
||||||
{
|
{
|
||||||
if (!RequireRegistered()) return;
|
if (!await RequireRegisteredAsync()) return;
|
||||||
if (msg.Parameters.Count < 1) return;
|
if (msg.Parameters.Count < 1) return;
|
||||||
|
|
||||||
var channels = msg.Parameters[0].Split(',', StringSplitOptions.RemoveEmptyEntries);
|
var channels = msg.Parameters[0].Split(',', StringSplitOptions.RemoveEmptyEntries);
|
||||||
@@ -385,7 +392,7 @@ public sealed class IrcCommandHandler
|
|||||||
if (channelName is null) continue;
|
if (channelName is null) continue;
|
||||||
|
|
||||||
await _chatService.LeaveChannelAsync(_conn.ConnectionId, _conn.Nickname!, channelName);
|
await _chatService.LeaveChannelAsync(_conn.ConnectionId, _conn.Nickname!, channelName);
|
||||||
_conn.JoinedChannels.Remove(channelName);
|
_conn.LeaveChannel(channelName);
|
||||||
|
|
||||||
await _conn.SendAsync($":{_conn.Hostmask} PART #{channelName}" +
|
await _conn.SendAsync($":{_conn.Hostmask} PART #{channelName}" +
|
||||||
(partMessage is not null ? $" :{partMessage}" : ""));
|
(partMessage is not null ? $" :{partMessage}" : ""));
|
||||||
@@ -394,7 +401,7 @@ public sealed class IrcCommandHandler
|
|||||||
|
|
||||||
private async Task HandlePrivmsgAsync(IrcMessage msg)
|
private async Task HandlePrivmsgAsync(IrcMessage msg)
|
||||||
{
|
{
|
||||||
if (!RequireRegistered()) return;
|
if (!await RequireRegisteredAsync()) return;
|
||||||
|
|
||||||
if (msg.Parameters.Count < 2)
|
if (msg.Parameters.Count < 2)
|
||||||
{
|
{
|
||||||
@@ -436,7 +443,7 @@ public sealed class IrcCommandHandler
|
|||||||
|
|
||||||
private async Task HandleNamesAsync(IrcMessage msg)
|
private async Task HandleNamesAsync(IrcMessage msg)
|
||||||
{
|
{
|
||||||
if (!RequireRegistered()) return;
|
if (!await RequireRegisteredAsync()) return;
|
||||||
if (msg.Parameters.Count < 1) return;
|
if (msg.Parameters.Count < 1) return;
|
||||||
|
|
||||||
var channelName = IrcToEchoHubChannel(msg.Parameters[0]);
|
var channelName = IrcToEchoHubChannel(msg.Parameters[0]);
|
||||||
@@ -458,7 +465,7 @@ public sealed class IrcCommandHandler
|
|||||||
|
|
||||||
private async Task HandleTopicAsync(IrcMessage msg)
|
private async Task HandleTopicAsync(IrcMessage msg)
|
||||||
{
|
{
|
||||||
if (!RequireRegistered()) return;
|
if (!await RequireRegisteredAsync()) return;
|
||||||
if (msg.Parameters.Count < 1) return;
|
if (msg.Parameters.Count < 1) return;
|
||||||
|
|
||||||
var channelName = IrcToEchoHubChannel(msg.Parameters[0]);
|
var channelName = IrcToEchoHubChannel(msg.Parameters[0]);
|
||||||
@@ -477,7 +484,7 @@ public sealed class IrcCommandHandler
|
|||||||
|
|
||||||
private async Task SendChannelTopicAsync(string channelName)
|
private async Task SendChannelTopicAsync(string channelName)
|
||||||
{
|
{
|
||||||
var (topic, exists) = await _chatService.GetChannelTopicAsync(channelName);
|
var (topic, exists) = await _channelService.GetChannelTopicAsync(channelName);
|
||||||
|
|
||||||
if (!exists) return;
|
if (!exists) return;
|
||||||
|
|
||||||
@@ -495,7 +502,7 @@ public sealed class IrcCommandHandler
|
|||||||
|
|
||||||
private async Task HandleWhoAsync(IrcMessage msg)
|
private async Task HandleWhoAsync(IrcMessage msg)
|
||||||
{
|
{
|
||||||
if (!RequireRegistered()) return;
|
if (!await RequireRegisteredAsync()) return;
|
||||||
if (msg.Parameters.Count < 1) return;
|
if (msg.Parameters.Count < 1) return;
|
||||||
|
|
||||||
var channelName = IrcToEchoHubChannel(msg.Parameters[0]);
|
var channelName = IrcToEchoHubChannel(msg.Parameters[0]);
|
||||||
@@ -516,7 +523,7 @@ public sealed class IrcCommandHandler
|
|||||||
|
|
||||||
private async Task HandleWhoisAsync(IrcMessage msg)
|
private async Task HandleWhoisAsync(IrcMessage msg)
|
||||||
{
|
{
|
||||||
if (!RequireRegistered()) return;
|
if (!await RequireRegisteredAsync()) return;
|
||||||
if (msg.Parameters.Count < 1) return;
|
if (msg.Parameters.Count < 1) return;
|
||||||
|
|
||||||
var nick = msg.Parameters[^1].ToLowerInvariant();
|
var nick = msg.Parameters[^1].ToLowerInvariant();
|
||||||
@@ -559,7 +566,7 @@ public sealed class IrcCommandHandler
|
|||||||
|
|
||||||
private async Task HandleAwayAsync(IrcMessage msg)
|
private async Task HandleAwayAsync(IrcMessage msg)
|
||||||
{
|
{
|
||||||
if (!RequireRegistered()) return;
|
if (!await RequireRegisteredAsync()) return;
|
||||||
|
|
||||||
if (msg.Parameters.Count > 0 && !string.IsNullOrWhiteSpace(msg.Parameters[0]))
|
if (msg.Parameters.Count > 0 && !string.IsNullOrWhiteSpace(msg.Parameters[0]))
|
||||||
{
|
{
|
||||||
@@ -581,9 +588,9 @@ public sealed class IrcCommandHandler
|
|||||||
|
|
||||||
private async Task HandleListAsync(IrcMessage msg)
|
private async Task HandleListAsync(IrcMessage msg)
|
||||||
{
|
{
|
||||||
if (!RequireRegistered()) return;
|
if (!await RequireRegisteredAsync()) return;
|
||||||
|
|
||||||
var channels = await _chatService.GetChannelListAsync();
|
var channels = await _channelService.GetChannelListAsync();
|
||||||
|
|
||||||
foreach (var ch in channels)
|
foreach (var ch in channels)
|
||||||
{
|
{
|
||||||
@@ -597,7 +604,7 @@ public sealed class IrcCommandHandler
|
|||||||
|
|
||||||
private async Task HandleModeAsync(IrcMessage msg)
|
private async Task HandleModeAsync(IrcMessage msg)
|
||||||
{
|
{
|
||||||
if (!RequireRegistered()) return;
|
if (!await RequireRegisteredAsync()) return;
|
||||||
if (msg.Parameters.Count < 1) return;
|
if (msg.Parameters.Count < 1) return;
|
||||||
|
|
||||||
var target = msg.Parameters[0];
|
var target = msg.Parameters[0];
|
||||||
@@ -621,11 +628,11 @@ public sealed class IrcCommandHandler
|
|||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private bool RequireRegistered()
|
private async Task<bool> RequireRegisteredAsync()
|
||||||
{
|
{
|
||||||
if (_conn.IsRegistered) return true;
|
if (_conn.IsRegistered) return true;
|
||||||
|
|
||||||
_ = _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOTREGISTERED,
|
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOTREGISTERED,
|
||||||
":You have not registered");
|
":You have not registered");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ public sealed class IrcGatewayService : BackgroundService
|
|||||||
public IEnumerable<IrcClientConnection> GetConnectionsInChannel(string channelName)
|
public IEnumerable<IrcClientConnection> GetConnectionsInChannel(string channelName)
|
||||||
{
|
{
|
||||||
return _connections.Values
|
return _connections.Values
|
||||||
.Where(c => c.IsAuthenticated && c.JoinedChannels.Contains(channelName));
|
.Where(c => c.IsAuthenticated && c.IsInChannel(channelName));
|
||||||
}
|
}
|
||||||
|
|
||||||
public IEnumerable<IrcClientConnection> GetAllConnections()
|
public IEnumerable<IrcClientConnection> GetAllConnections()
|
||||||
@@ -120,8 +120,10 @@ public sealed class IrcGatewayService : BackgroundService
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
chatService = _services.GetRequiredService<IChatService>();
|
chatService = _services.GetRequiredService<IChatService>();
|
||||||
|
var channelService = _services.GetRequiredService<IChannelService>();
|
||||||
|
var encryption = _services.GetRequiredService<IMessageEncryptionService>();
|
||||||
var handler = new IrcCommandHandler(
|
var handler = new IrcCommandHandler(
|
||||||
connection, _options, chatService, _logger);
|
connection, _options, chatService, channelService, encryption, _logger);
|
||||||
|
|
||||||
await handler.RunAsync(ct);
|
await handler.RunAsync(ct);
|
||||||
}
|
}
|
||||||
@@ -133,7 +135,7 @@ public sealed class IrcGatewayService : BackgroundService
|
|||||||
{
|
{
|
||||||
if (connection.IsAuthenticated)
|
if (connection.IsAuthenticated)
|
||||||
{
|
{
|
||||||
foreach (var ch in connection.JoinedChannels.ToList())
|
foreach (var ch in connection.GetJoinedChannels())
|
||||||
{
|
{
|
||||||
if (chatService is null) break;
|
if (chatService is null) break;
|
||||||
await chatService.LeaveChannelAsync(
|
await chatService.LeaveChannelAsync(
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ public static partial class IrcMessageFormatter
|
|||||||
case MessageType.File:
|
case MessageType.File:
|
||||||
lines.Add($"{prefix} PRIVMSG {ircChannel} :[File: {message.AttachmentFileName}] {message.AttachmentUrl}");
|
lines.Add($"{prefix} PRIVMSG {ircChannel} :[File: {message.AttachmentFileName}] {message.AttachmentUrl}");
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case MessageType.Audio:
|
||||||
|
lines.Add($"{prefix} PRIVMSG {ircChannel} :\u266a [Audio: {message.AttachmentFileName}] {message.AttachmentUrl}");
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines;
|
return lines;
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ using EchoHub.Server.Services;
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace EchoHub.Server.Controllers;
|
namespace EchoHub.Server.Controllers;
|
||||||
|
|
||||||
@@ -18,24 +17,30 @@ namespace EchoHub.Server.Controllers;
|
|||||||
[EnableRateLimiting("general")]
|
[EnableRateLimiting("general")]
|
||||||
public class ChannelsController : ControllerBase
|
public class ChannelsController : ControllerBase
|
||||||
{
|
{
|
||||||
|
private readonly IChannelService _channelService;
|
||||||
private readonly EchoHubDbContext _db;
|
private readonly EchoHubDbContext _db;
|
||||||
private readonly FileStorageService _fileStorage;
|
private readonly FileStorageService _fileStorage;
|
||||||
private readonly ImageToAsciiService _asciiService;
|
private readonly ImageToAsciiService _asciiService;
|
||||||
private readonly IHttpClientFactory _httpClientFactory;
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
private readonly IChatService _chatService;
|
private readonly IChatService _chatService;
|
||||||
|
private readonly IMessageEncryptionService _encryption;
|
||||||
|
|
||||||
public ChannelsController(
|
public ChannelsController(
|
||||||
|
IChannelService channelService,
|
||||||
EchoHubDbContext db,
|
EchoHubDbContext db,
|
||||||
FileStorageService fileStorage,
|
FileStorageService fileStorage,
|
||||||
ImageToAsciiService asciiService,
|
ImageToAsciiService asciiService,
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
IChatService chatService)
|
IChatService chatService,
|
||||||
|
IMessageEncryptionService encryption)
|
||||||
{
|
{
|
||||||
|
_channelService = channelService;
|
||||||
_db = db;
|
_db = db;
|
||||||
_fileStorage = fileStorage;
|
_fileStorage = fileStorage;
|
||||||
_asciiService = asciiService;
|
_asciiService = asciiService;
|
||||||
_httpClientFactory = httpClientFactory;
|
_httpClientFactory = httpClientFactory;
|
||||||
_chatService = chatService;
|
_chatService = chatService;
|
||||||
|
_encryption = encryption;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
@@ -45,74 +50,29 @@ public class ChannelsController : ControllerBase
|
|||||||
if (userIdClaim is null)
|
if (userIdClaim is null)
|
||||||
return Unauthorized(new ErrorResponse("Authentication required."));
|
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||||
|
|
||||||
var userId = Guid.Parse(userIdClaim);
|
|
||||||
offset = Math.Max(0, offset);
|
offset = Math.Max(0, offset);
|
||||||
limit = Math.Clamp(limit, 1, 100);
|
limit = Math.Clamp(limit, 1, 100);
|
||||||
|
|
||||||
// Public channels + private channels the user has joined
|
var result = await _channelService.GetChannelsAsync(Guid.Parse(userIdClaim), offset, limit);
|
||||||
var query = _db.Channels.Where(c =>
|
return Ok(result);
|
||||||
c.IsPublic || _db.ChannelMemberships.Any(m => m.ChannelId == c.Id && m.UserId == userId));
|
|
||||||
var total = await query.CountAsync();
|
|
||||||
|
|
||||||
var channels = await query
|
|
||||||
.OrderBy(c => c.Name)
|
|
||||||
.Skip(offset)
|
|
||||||
.Take(limit)
|
|
||||||
.Select(c => new ChannelDto(
|
|
||||||
c.Id,
|
|
||||||
c.Name,
|
|
||||||
c.Topic,
|
|
||||||
c.IsPublic,
|
|
||||||
c.Messages.Count,
|
|
||||||
c.CreatedAt))
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
return Ok(new PaginatedResponse<ChannelDto>(channels, total, offset, limit));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> CreateChannel([FromBody] CreateChannelRequest request)
|
public async Task<IActionResult> CreateChannel([FromBody] CreateChannelRequest request)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(request.Name))
|
|
||||||
return BadRequest(new ErrorResponse("Channel name is required."));
|
|
||||||
|
|
||||||
var channelName = request.Name.ToLowerInvariant().Trim();
|
|
||||||
|
|
||||||
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
|
||||||
return BadRequest(new ErrorResponse("Channel name must be 2-100 characters and contain only letters, digits, underscores, or hyphens."));
|
|
||||||
|
|
||||||
if (await _db.Channels.AnyAsync(c => c.Name == channelName))
|
|
||||||
return Conflict(new ErrorResponse($"Channel '{channelName}' already exists."));
|
|
||||||
|
|
||||||
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
if (userIdClaim is null)
|
if (userIdClaim is null)
|
||||||
return Unauthorized(new ErrorResponse("Authentication required."));
|
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||||
|
|
||||||
var channel = new Channel
|
var result = await _channelService.CreateChannelAsync(
|
||||||
{
|
Guid.Parse(userIdClaim), request.Name, request.Topic, request.IsPublic);
|
||||||
Id = Guid.NewGuid(),
|
if (!result.IsSuccess)
|
||||||
Name = channelName,
|
return MapChannelError(result);
|
||||||
Topic = request.Topic?.Trim(),
|
|
||||||
IsPublic = request.IsPublic,
|
|
||||||
CreatedByUserId = Guid.Parse(userIdClaim),
|
|
||||||
};
|
|
||||||
|
|
||||||
_db.Channels.Add(channel);
|
if (result.Channel!.IsPublic)
|
||||||
|
await _chatService.BroadcastChannelUpdatedAsync(result.Channel);
|
||||||
|
|
||||||
// Creator automatically becomes a member
|
return Created($"/api/channels/{result.Channel.Name}", result.Channel);
|
||||||
_db.ChannelMemberships.Add(new ChannelMembership
|
|
||||||
{
|
|
||||||
UserId = Guid.Parse(userIdClaim),
|
|
||||||
ChannelId = channel.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
|
|
||||||
var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, channel.IsPublic, 0, channel.CreatedAt);
|
|
||||||
if (channel.IsPublic)
|
|
||||||
await _chatService.BroadcastChannelUpdatedAsync(dto);
|
|
||||||
|
|
||||||
return Created($"/api/channels/{channelName}", dto);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{channel}/topic")]
|
[HttpPut("{channel}/topic")]
|
||||||
@@ -122,26 +82,13 @@ public class ChannelsController : ControllerBase
|
|||||||
if (userIdClaim is null)
|
if (userIdClaim is null)
|
||||||
return Unauthorized(new ErrorResponse("Authentication required."));
|
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||||
|
|
||||||
var channelName = channel.ToLowerInvariant().Trim();
|
var result = await _channelService.UpdateTopicAsync(
|
||||||
var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
Guid.Parse(userIdClaim), channel, request.Topic);
|
||||||
|
if (!result.IsSuccess)
|
||||||
|
return MapChannelError(result);
|
||||||
|
|
||||||
if (dbChannel is null)
|
await _chatService.BroadcastChannelUpdatedAsync(result.Channel!, channel.ToLowerInvariant().Trim());
|
||||||
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
return Ok(result.Channel);
|
||||||
|
|
||||||
if (dbChannel.CreatedByUserId != Guid.Parse(userIdClaim))
|
|
||||||
return StatusCode(403, new ErrorResponse("Only the channel creator can update the topic."));
|
|
||||||
|
|
||||||
if (request.Topic is not null && request.Topic.Length > ValidationConstants.MaxChannelTopicLength)
|
|
||||||
return BadRequest(new ErrorResponse($"Topic must not exceed {ValidationConstants.MaxChannelTopicLength} characters."));
|
|
||||||
|
|
||||||
dbChannel.Topic = request.Topic?.Trim();
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
|
|
||||||
var messageCount = await _db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id);
|
|
||||||
var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, messageCount, dbChannel.CreatedAt);
|
|
||||||
await _chatService.BroadcastChannelUpdatedAsync(dto, channelName);
|
|
||||||
|
|
||||||
return Ok(dto);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{channel}")]
|
[HttpDelete("{channel}")]
|
||||||
@@ -151,29 +98,17 @@ public class ChannelsController : ControllerBase
|
|||||||
if (userIdClaim is null)
|
if (userIdClaim is null)
|
||||||
return Unauthorized(new ErrorResponse("Authentication required."));
|
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||||
|
|
||||||
var channelName = channel.ToLowerInvariant().Trim();
|
var result = await _channelService.DeleteChannelAsync(Guid.Parse(userIdClaim), channel);
|
||||||
|
if (!result.IsSuccess)
|
||||||
if (channelName == HubConstants.DefaultChannel)
|
return MapChannelError(result);
|
||||||
return BadRequest(new ErrorResponse($"The '{HubConstants.DefaultChannel}' channel cannot be deleted."));
|
|
||||||
|
|
||||||
var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
|
||||||
|
|
||||||
if (dbChannel is null)
|
|
||||||
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
|
||||||
|
|
||||||
var userId = Guid.Parse(userIdClaim);
|
|
||||||
var caller = await _db.Users.FindAsync(userId);
|
|
||||||
if (dbChannel.CreatedByUserId != userId && (caller is null || caller.Role < ServerRole.Admin))
|
|
||||||
return StatusCode(403, new ErrorResponse("Only the channel creator or an admin can delete the channel."));
|
|
||||||
|
|
||||||
_db.Channels.Remove(dbChannel);
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{channel}/upload")]
|
[HttpPost("{channel}/upload")]
|
||||||
[EnableRateLimiting("upload")]
|
[EnableRateLimiting("upload")]
|
||||||
|
[RequestSizeLimit(HubConstants.MaxFileSizeBytes)]
|
||||||
|
[RequestFormLimits(MultipartBodyLengthLimit = HubConstants.MaxFileSizeBytes)]
|
||||||
public async Task<IActionResult> Upload(string channel, [FromQuery] string? size = null)
|
public async Task<IActionResult> Upload(string channel, [FromQuery] string? size = null)
|
||||||
{
|
{
|
||||||
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
@@ -187,8 +122,8 @@ public class ChannelsController : ControllerBase
|
|||||||
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
||||||
return BadRequest(new ErrorResponse("Invalid channel name format."));
|
return BadRequest(new ErrorResponse("Invalid channel name format."));
|
||||||
|
|
||||||
var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
var channelDto = await _channelService.GetChannelByNameAsync(channelName);
|
||||||
if (dbChannel is null)
|
if (channelDto is null)
|
||||||
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
||||||
|
|
||||||
if (!Request.HasFormContentType || Request.Form.Files.Count == 0)
|
if (!Request.HasFormContentType || Request.Form.Files.Count == 0)
|
||||||
@@ -196,16 +131,23 @@ public class ChannelsController : ControllerBase
|
|||||||
|
|
||||||
var file = Request.Form.Files[0];
|
var file = Request.Form.Files[0];
|
||||||
|
|
||||||
if (file.Length > HubConstants.MaxFileSizeBytes)
|
// Detect file type early so we can apply the correct size limit
|
||||||
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB."));
|
|
||||||
|
|
||||||
// Detect if file is an image by checking magic bytes
|
|
||||||
using var stream = file.OpenReadStream();
|
using var stream = file.OpenReadStream();
|
||||||
var isImage = FileValidationHelper.IsValidImage(stream);
|
var isImage = FileValidationHelper.IsValidImage(stream);
|
||||||
|
var isAudio = !isImage && FileValidationHelper.IsAudioFile(file.FileName);
|
||||||
|
|
||||||
|
var maxSize = isImage ? HubConstants.MaxImageSizeBytes
|
||||||
|
: isAudio ? HubConstants.MaxAudioFileSizeBytes
|
||||||
|
: HubConstants.MaxFileSizeBytes;
|
||||||
|
|
||||||
|
if (file.Length > maxSize)
|
||||||
|
return BadRequest(new ErrorResponse($"File size exceeds maximum of {maxSize / (1024 * 1024)} MB."));
|
||||||
|
|
||||||
var (fileId, filePath) = await _fileStorage.SaveFileAsync(stream, file.FileName);
|
var (fileId, filePath) = await _fileStorage.SaveFileAsync(stream, file.FileName);
|
||||||
|
|
||||||
var messageType = isImage ? MessageType.Image : MessageType.File;
|
var messageType = isImage ? MessageType.Image
|
||||||
|
: isAudio ? MessageType.Audio
|
||||||
|
: MessageType.File;
|
||||||
string content;
|
string content;
|
||||||
|
|
||||||
if (isImage)
|
if (isImage)
|
||||||
@@ -221,16 +163,18 @@ public class ChannelsController : ControllerBase
|
|||||||
|
|
||||||
var attachmentUrl = $"/api/files/{fileId}";
|
var attachmentUrl = $"/api/files/{fileId}";
|
||||||
var sender = await _db.Users.FindAsync(userId);
|
var sender = await _db.Users.FindAsync(userId);
|
||||||
|
var dbContent = _encryption.EncryptDatabaseEnabled ? _encryption.Encrypt(content) : content;
|
||||||
|
|
||||||
var message = new Message
|
var message = new Message
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
Content = content,
|
Content = dbContent,
|
||||||
Type = messageType,
|
Type = messageType,
|
||||||
AttachmentUrl = attachmentUrl,
|
AttachmentUrl = attachmentUrl,
|
||||||
AttachmentFileName = file.FileName,
|
AttachmentFileName = file.FileName,
|
||||||
|
AttachmentFileSize = file.Length,
|
||||||
SentAt = DateTimeOffset.UtcNow,
|
SentAt = DateTimeOffset.UtcNow,
|
||||||
ChannelId = dbChannel.Id,
|
ChannelId = channelDto.Id,
|
||||||
SenderUserId = userId,
|
SenderUserId = userId,
|
||||||
SenderUsername = usernameClaim,
|
SenderUsername = usernameClaim,
|
||||||
};
|
};
|
||||||
@@ -238,16 +182,18 @@ public class ChannelsController : ControllerBase
|
|||||||
_db.Messages.Add(message);
|
_db.Messages.Add(message);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Encrypt for transport — clients decrypt
|
||||||
var messageDto = new MessageDto(
|
var messageDto = new MessageDto(
|
||||||
message.Id,
|
message.Id,
|
||||||
message.Content,
|
_encryption.Encrypt(content),
|
||||||
message.SenderUsername,
|
message.SenderUsername,
|
||||||
sender?.NicknameColor,
|
sender?.NicknameColor,
|
||||||
channelName,
|
channelName,
|
||||||
messageType,
|
messageType,
|
||||||
attachmentUrl,
|
attachmentUrl,
|
||||||
file.FileName,
|
file.FileName,
|
||||||
message.SentAt);
|
message.SentAt,
|
||||||
|
file.Length);
|
||||||
|
|
||||||
await _chatService.BroadcastMessageAsync(channelName, messageDto);
|
await _chatService.BroadcastMessageAsync(channelName, messageDto);
|
||||||
|
|
||||||
@@ -269,8 +215,8 @@ public class ChannelsController : ControllerBase
|
|||||||
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
||||||
return BadRequest(new ErrorResponse("Invalid channel name format."));
|
return BadRequest(new ErrorResponse("Invalid channel name format."));
|
||||||
|
|
||||||
var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
var channelDto = await _channelService.GetChannelByNameAsync(channelName);
|
||||||
if (dbChannel is null)
|
if (channelDto is null)
|
||||||
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(request.Url))
|
if (string.IsNullOrWhiteSpace(request.Url))
|
||||||
@@ -290,13 +236,13 @@ public class ChannelsController : ControllerBase
|
|||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
var contentLength = response.Content.Headers.ContentLength;
|
var contentLength = response.Content.Headers.ContentLength;
|
||||||
if (contentLength > HubConstants.MaxFileSizeBytes)
|
if (contentLength > HubConstants.MaxImageSizeBytes)
|
||||||
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB."));
|
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxImageSizeBytes / (1024 * 1024)} MB."));
|
||||||
|
|
||||||
imageBytes = await response.Content.ReadAsByteArrayAsync();
|
imageBytes = await response.Content.ReadAsByteArrayAsync();
|
||||||
|
|
||||||
if (imageBytes.Length > HubConstants.MaxFileSizeBytes)
|
if (imageBytes.Length > HubConstants.MaxImageSizeBytes)
|
||||||
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB."));
|
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.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('.'))
|
||||||
@@ -339,16 +285,18 @@ public class ChannelsController : ControllerBase
|
|||||||
|
|
||||||
var attachmentUrl = $"/api/files/{fileId}";
|
var attachmentUrl = $"/api/files/{fileId}";
|
||||||
var sender = await _db.Users.FindAsync(userId);
|
var sender = await _db.Users.FindAsync(userId);
|
||||||
|
var dbContent = _encryption.EncryptDatabaseEnabled ? _encryption.Encrypt(content) : content;
|
||||||
|
|
||||||
var message = new Message
|
var message = new Message
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
Content = content,
|
Content = dbContent,
|
||||||
Type = MessageType.Image,
|
Type = MessageType.Image,
|
||||||
AttachmentUrl = attachmentUrl,
|
AttachmentUrl = attachmentUrl,
|
||||||
AttachmentFileName = fileName,
|
AttachmentFileName = fileName,
|
||||||
|
AttachmentFileSize = imageBytes.Length,
|
||||||
SentAt = DateTimeOffset.UtcNow,
|
SentAt = DateTimeOffset.UtcNow,
|
||||||
ChannelId = dbChannel.Id,
|
ChannelId = channelDto.Id,
|
||||||
SenderUserId = userId,
|
SenderUserId = userId,
|
||||||
SenderUsername = usernameClaim,
|
SenderUsername = usernameClaim,
|
||||||
};
|
};
|
||||||
@@ -356,19 +304,31 @@ public class ChannelsController : ControllerBase
|
|||||||
_db.Messages.Add(message);
|
_db.Messages.Add(message);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Encrypt for transport — clients decrypt
|
||||||
var messageDto = new MessageDto(
|
var messageDto = new MessageDto(
|
||||||
message.Id,
|
message.Id,
|
||||||
message.Content,
|
_encryption.Encrypt(content),
|
||||||
message.SenderUsername,
|
message.SenderUsername,
|
||||||
sender?.NicknameColor,
|
sender?.NicknameColor,
|
||||||
channelName,
|
channelName,
|
||||||
MessageType.Image,
|
MessageType.Image,
|
||||||
attachmentUrl,
|
attachmentUrl,
|
||||||
fileName,
|
fileName,
|
||||||
message.SentAt);
|
message.SentAt,
|
||||||
|
imageBytes.Length);
|
||||||
|
|
||||||
await _chatService.BroadcastMessageAsync(channelName, messageDto);
|
await _chatService.BroadcastMessageAsync(channelName, messageDto);
|
||||||
|
|
||||||
return Ok(messageDto);
|
return Ok(messageDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private IActionResult MapChannelError(ChannelOperationResult result) => result.Error switch
|
||||||
|
{
|
||||||
|
ChannelError.ValidationFailed => BadRequest(new ErrorResponse(result.ErrorMessage!)),
|
||||||
|
ChannelError.AlreadyExists => Conflict(new ErrorResponse(result.ErrorMessage!)),
|
||||||
|
ChannelError.NotFound => NotFound(new ErrorResponse(result.ErrorMessage!)),
|
||||||
|
ChannelError.Forbidden => StatusCode(403, new ErrorResponse(result.ErrorMessage!)),
|
||||||
|
ChannelError.Protected => BadRequest(new ErrorResponse(result.ErrorMessage!)),
|
||||||
|
_ => BadRequest(new ErrorResponse(result.ErrorMessage ?? "Unknown error.")),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ public class FilesController : ControllerBase
|
|||||||
".png" => "image/png",
|
".png" => "image/png",
|
||||||
".gif" => "image/gif",
|
".gif" => "image/gif",
|
||||||
".webp" => "image/webp",
|
".webp" => "image/webp",
|
||||||
|
".mp3" => "audio/mpeg",
|
||||||
|
".wav" => "audio/wav",
|
||||||
|
".ogg" => "audio/ogg",
|
||||||
|
".flac" => "audio/flac",
|
||||||
|
".aac" => "audio/aac",
|
||||||
|
".m4a" => "audio/mp4",
|
||||||
|
".wma" => "audio/x-ms-wma",
|
||||||
".pdf" => "application/pdf",
|
".pdf" => "application/pdf",
|
||||||
".txt" => "text/plain",
|
".txt" => "text/plain",
|
||||||
_ => "application/octet-stream"
|
_ => "application/octet-stream"
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
using EchoHub.Server.Data;
|
using EchoHub.Server.Data;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace EchoHub.Server.Controllers;
|
namespace EchoHub.Server.Controllers;
|
||||||
@@ -32,4 +34,17 @@ public class ServerController : ControllerBase
|
|||||||
|
|
||||||
return Ok(status);
|
return Ok(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("encryption-key")]
|
||||||
|
[Authorize]
|
||||||
|
[EnableRateLimiting("auth")]
|
||||||
|
public IActionResult GetEncryptionKey()
|
||||||
|
{
|
||||||
|
var key = _config["Encryption:Key"];
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(key))
|
||||||
|
return StatusCode(503, new ErrorResponse("Encryption is not configured on this server."));
|
||||||
|
|
||||||
|
return Ok(new EncryptionKeyResponse(key));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,11 +55,11 @@ public class EchoHubDbContext : DbContext
|
|||||||
{
|
{
|
||||||
entity.HasKey(m => m.Id);
|
entity.HasKey(m => m.Id);
|
||||||
entity.HasIndex(m => m.SentAt);
|
entity.HasIndex(m => m.SentAt);
|
||||||
entity.Property(m => m.Content).IsRequired().HasMaxLength(2000);
|
entity.Property(m => m.Content).IsRequired().HasMaxLength(16000); // Increased for encrypted content (Base64 overhead)
|
||||||
entity.Property(m => m.SenderUsername).IsRequired().HasMaxLength(50);
|
entity.Property(m => m.SenderUsername).IsRequired().HasMaxLength(50);
|
||||||
entity.Property(m => m.AttachmentUrl).HasMaxLength(500);
|
entity.Property(m => m.AttachmentUrl).HasMaxLength(500);
|
||||||
entity.Property(m => m.AttachmentFileName).HasMaxLength(255);
|
entity.Property(m => m.AttachmentFileName).HasMaxLength(255);
|
||||||
entity.Property(m => m.EmbedJson).HasMaxLength(8000);
|
entity.Property(m => m.EmbedJson).HasMaxLength(32000); // Increased for encrypted embed JSON
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<ChannelMembership>(entity =>
|
modelBuilder.Entity<ChannelMembership>(entity =>
|
||||||
|
|||||||
+264
@@ -0,0 +1,264 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using EchoHub.Server.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(EchoHubDbContext))]
|
||||||
|
[Migration("20260220133627_AddEncryptionSupport")]
|
||||||
|
partial class AddEncryptionSupport
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.3");
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("CreatedByUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPublic")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Topic")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Channels");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("ChannelId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("JoinedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "ChannelId");
|
||||||
|
|
||||||
|
b.HasIndex("ChannelId");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("ChannelMemberships");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("AttachmentFileName")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("AttachmentUrl")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("ChannelId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Content")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("EmbedJson")
|
||||||
|
.HasMaxLength(32000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("SenderUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("SenderUsername")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("SentAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ChannelId");
|
||||||
|
|
||||||
|
b.HasIndex("SentAt");
|
||||||
|
|
||||||
|
b.ToTable("Messages");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("ExpiresAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("RevokedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("TokenHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TokenHash");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("RefreshTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.User", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("AvatarAscii")
|
||||||
|
.HasMaxLength(10000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Bio")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsBanned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsMuted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("LastSeenAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("MutedUntil")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("NicknameColor")
|
||||||
|
.HasMaxLength(7)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Role")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("StatusMessage")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Username")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("EchoHub.Core.Models.Channel", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ChannelId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("EchoHub.Core.Models.User", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("EchoHub.Core.Models.Channel", "Channel")
|
||||||
|
.WithMany("Messages")
|
||||||
|
.HasForeignKey("ChannelId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Channel");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("EchoHub.Core.Models.User", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Messages");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddEncryptionSupport : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+267
@@ -0,0 +1,267 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using EchoHub.Server.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(EchoHubDbContext))]
|
||||||
|
[Migration("20260221193444_AddAttachmentFileSize")]
|
||||||
|
partial class AddAttachmentFileSize
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.3");
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("CreatedByUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPublic")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Topic")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Channels");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("ChannelId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("JoinedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "ChannelId");
|
||||||
|
|
||||||
|
b.HasIndex("ChannelId");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("ChannelMemberships");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("AttachmentFileName")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long?>("AttachmentFileSize")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AttachmentUrl")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("ChannelId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Content")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("EmbedJson")
|
||||||
|
.HasMaxLength(32000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("SenderUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("SenderUsername")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("SentAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ChannelId");
|
||||||
|
|
||||||
|
b.HasIndex("SentAt");
|
||||||
|
|
||||||
|
b.ToTable("Messages");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("ExpiresAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("RevokedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("TokenHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TokenHash");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("RefreshTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.User", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("AvatarAscii")
|
||||||
|
.HasMaxLength(10000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Bio")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsBanned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsMuted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("LastSeenAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("MutedUntil")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("NicknameColor")
|
||||||
|
.HasMaxLength(7)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Role")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("StatusMessage")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Username")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("EchoHub.Core.Models.Channel", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ChannelId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("EchoHub.Core.Models.User", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("EchoHub.Core.Models.Channel", "Channel")
|
||||||
|
.WithMany("Messages")
|
||||||
|
.HasForeignKey("ChannelId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Channel");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("EchoHub.Core.Models.User", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Messages");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddAttachmentFileSize : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<long>(
|
||||||
|
name: "AttachmentFileSize",
|
||||||
|
table: "Messages",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "AttachmentFileSize",
|
||||||
|
table: "Messages");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,6 +79,9 @@ namespace EchoHub.Server.Data.Migrations
|
|||||||
.HasMaxLength(255)
|
.HasMaxLength(255)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long?>("AttachmentFileSize")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<string>("AttachmentUrl")
|
b.Property<string>("AttachmentUrl")
|
||||||
.HasMaxLength(500)
|
.HasMaxLength(500)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
@@ -88,11 +91,11 @@ namespace EchoHub.Server.Data.Migrations
|
|||||||
|
|
||||||
b.Property<string>("Content")
|
b.Property<string>("Content")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(2000)
|
.HasMaxLength(16000)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("EmbedJson")
|
b.Property<string>("EmbedJson")
|
||||||
.HasMaxLength(8000)
|
.HasMaxLength(32000)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<Guid>("SenderUserId")
|
b.Property<Guid>("SenderUserId")
|
||||||
|
|||||||
@@ -22,6 +22,8 @@
|
|||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<PackageIcon></PackageIcon>
|
||||||
|
<ApplicationIcon>..\EchoHub.Client\Assets\hue_icon.ico</ApplicationIcon>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -108,9 +108,14 @@ while (true)
|
|||||||
builder.Services.AddSingleton<FileStorageService>();
|
builder.Services.AddSingleton<FileStorageService>();
|
||||||
builder.Services.AddSingleton<LinkEmbedService>();
|
builder.Services.AddSingleton<LinkEmbedService>();
|
||||||
builder.Services.AddHostedService<ServerDirectoryService>();
|
builder.Services.AddHostedService<ServerDirectoryService>();
|
||||||
|
builder.Services.AddHostedService<FileCleanupService>();
|
||||||
|
|
||||||
|
// ── Encryption ─────────────────────────────────────────────────────
|
||||||
|
builder.Services.AddSingleton<IMessageEncryptionService, MessageEncryptionService>();
|
||||||
|
|
||||||
// ── Chat Service + Broadcasters ─────────────────────────────────────
|
// ── Chat Service + Broadcasters ─────────────────────────────────────
|
||||||
builder.Services.AddSingleton<IChatBroadcaster, SignalRBroadcaster>();
|
builder.Services.AddSingleton<IChatBroadcaster, SignalRBroadcaster>();
|
||||||
|
builder.Services.AddSingleton<IChannelService, ChannelService>();
|
||||||
builder.Services.AddSingleton<IChatService, ChatService>();
|
builder.Services.AddSingleton<IChatService, ChatService>();
|
||||||
|
|
||||||
// ── IRC Gateway (optional) ──────────────────────────────────────────
|
// ── IRC Gateway (optional) ──────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
using EchoHub.Core.Constants;
|
||||||
|
using EchoHub.Core.Contracts;
|
||||||
|
using EchoHub.Core.DTOs;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
|
using EchoHub.Server.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Services;
|
||||||
|
|
||||||
|
public class ChannelService : IChannelService
|
||||||
|
{
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly PresenceTracker _presenceTracker;
|
||||||
|
private readonly ILogger<ChannelService> _logger;
|
||||||
|
|
||||||
|
public ChannelService(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
PresenceTracker presenceTracker,
|
||||||
|
ILogger<ChannelService> logger)
|
||||||
|
{
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
_presenceTracker = presenceTracker;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PaginatedResponse<ChannelDto>> GetChannelsAsync(Guid userId, int offset, int limit)
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
|
await EnsureDefaultChannelAsync(db);
|
||||||
|
|
||||||
|
var query = db.Channels.Where(c =>
|
||||||
|
c.IsPublic || db.ChannelMemberships.Any(m => m.ChannelId == c.Id && m.UserId == userId));
|
||||||
|
var total = await query.CountAsync();
|
||||||
|
|
||||||
|
var channels = await query
|
||||||
|
.OrderBy(c => c.Name)
|
||||||
|
.Skip(offset)
|
||||||
|
.Take(limit)
|
||||||
|
.Select(c => new ChannelDto(
|
||||||
|
c.Id, c.Name, c.Topic, c.IsPublic, c.Messages.Count, c.CreatedAt))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return new PaginatedResponse<ChannelDto>(channels, total, offset, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ChannelOperationResult> CreateChannelAsync(
|
||||||
|
Guid creatorUserId, string name, string? topic, bool isPublic)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
return ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Channel name is required.");
|
||||||
|
|
||||||
|
var channelName = name.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
|
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
||||||
|
return ChannelOperationResult.Fail(ChannelError.ValidationFailed,
|
||||||
|
"Channel name must be 2-100 characters and contain only letters, digits, underscores, or hyphens.");
|
||||||
|
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
|
if (await db.Channels.AnyAsync(c => c.Name == channelName))
|
||||||
|
return ChannelOperationResult.Fail(ChannelError.AlreadyExists, $"Channel '{channelName}' already exists.");
|
||||||
|
|
||||||
|
var channel = new Channel
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Name = channelName,
|
||||||
|
Topic = topic?.Trim(),
|
||||||
|
IsPublic = isPublic,
|
||||||
|
CreatedByUserId = creatorUserId,
|
||||||
|
};
|
||||||
|
|
||||||
|
db.Channels.Add(channel);
|
||||||
|
|
||||||
|
// Creator automatically becomes a member
|
||||||
|
db.ChannelMemberships.Add(new ChannelMembership
|
||||||
|
{
|
||||||
|
UserId = creatorUserId,
|
||||||
|
ChannelId = channel.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, channel.IsPublic, 0, channel.CreatedAt);
|
||||||
|
return ChannelOperationResult.Success(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ChannelOperationResult> UpdateTopicAsync(
|
||||||
|
Guid callerUserId, string channelName, string? topic)
|
||||||
|
{
|
||||||
|
channelName = channelName.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
|
if (topic is not null && topic.Length > ValidationConstants.MaxChannelTopicLength)
|
||||||
|
return ChannelOperationResult.Fail(ChannelError.ValidationFailed,
|
||||||
|
$"Topic must not exceed {ValidationConstants.MaxChannelTopicLength} characters.");
|
||||||
|
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
|
var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||||
|
if (dbChannel is null)
|
||||||
|
return ChannelOperationResult.Fail(ChannelError.NotFound, $"Channel '{channelName}' does not exist.");
|
||||||
|
|
||||||
|
if (dbChannel.CreatedByUserId != callerUserId)
|
||||||
|
return ChannelOperationResult.Fail(ChannelError.Forbidden, "Only the channel creator can update the topic.");
|
||||||
|
|
||||||
|
dbChannel.Topic = topic?.Trim();
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var messageCount = await db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id);
|
||||||
|
var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, messageCount, dbChannel.CreatedAt);
|
||||||
|
return ChannelOperationResult.Success(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ChannelOperationResult> DeleteChannelAsync(Guid callerUserId, string channelName)
|
||||||
|
{
|
||||||
|
channelName = channelName.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
|
if (channelName == HubConstants.DefaultChannel)
|
||||||
|
return ChannelOperationResult.Fail(ChannelError.Protected,
|
||||||
|
$"The '{HubConstants.DefaultChannel}' channel cannot be deleted.");
|
||||||
|
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
|
var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||||
|
if (dbChannel is null)
|
||||||
|
return ChannelOperationResult.Fail(ChannelError.NotFound, $"Channel '{channelName}' does not exist.");
|
||||||
|
|
||||||
|
var caller = await db.Users.FindAsync(callerUserId);
|
||||||
|
if (dbChannel.CreatedByUserId != callerUserId && (caller is null || caller.Role < ServerRole.Admin))
|
||||||
|
return ChannelOperationResult.Fail(ChannelError.Forbidden,
|
||||||
|
"Only the channel creator or an admin can delete the channel.");
|
||||||
|
|
||||||
|
db.Channels.Remove(dbChannel);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, 0, dbChannel.CreatedAt);
|
||||||
|
return ChannelOperationResult.Success(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName)
|
||||||
|
{
|
||||||
|
channelName = channelName.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
|
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||||
|
if (channel is null) return (null, false);
|
||||||
|
|
||||||
|
return (channel.Topic, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<ChannelListItem>> GetChannelListAsync()
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
|
var channels = await db.Channels.OrderBy(c => c.Name).ToListAsync();
|
||||||
|
|
||||||
|
return channels.Select(c => new ChannelListItem(
|
||||||
|
c.Name, c.Topic,
|
||||||
|
_presenceTracker.GetOnlineUsersInChannel(c.Name).Count)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ChannelDto?> GetChannelByNameAsync(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);
|
||||||
|
return new ChannelDto(c.Id, c.Name, c.Topic, c.IsPublic, messageCount, c.CreatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(bool Success, string? Error)> EnsureChannelMembershipAsync(Guid userId, string channelName)
|
||||||
|
{
|
||||||
|
channelName = channelName.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
|
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
||||||
|
return (false, "Invalid channel name. Use 2-100 characters: letters, digits, underscores, or hyphens.");
|
||||||
|
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
|
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||||
|
if (channel is null)
|
||||||
|
{
|
||||||
|
// Auto-recreate #general if it was somehow removed
|
||||||
|
if (channelName == HubConstants.DefaultChannel)
|
||||||
|
{
|
||||||
|
channel = new Channel
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Name = HubConstants.DefaultChannel,
|
||||||
|
Topic = "General discussion",
|
||||||
|
CreatedByUserId = Guid.Empty,
|
||||||
|
};
|
||||||
|
db.Channels.Add(channel);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
_logger.LogWarning("Default channel '{Channel}' was missing and has been recreated", HubConstants.DefaultChannel);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return (false, $"Channel '{channelName}' does not exist. Create it first via the channel list.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasMembership = await db.ChannelMemberships
|
||||||
|
.AnyAsync(m => m.UserId == userId && m.ChannelId == channel.Id);
|
||||||
|
if (!hasMembership)
|
||||||
|
{
|
||||||
|
db.ChannelMemberships.Add(new ChannelMembership
|
||||||
|
{
|
||||||
|
UserId = userId,
|
||||||
|
ChannelId = channel.Id,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (true, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task EnsureDefaultChannelAsync(EchoHubDbContext db)
|
||||||
|
{
|
||||||
|
if (!await db.Channels.AnyAsync(c => c.Name == HubConstants.DefaultChannel))
|
||||||
|
{
|
||||||
|
db.Channels.Add(new Channel
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Name = HubConstants.DefaultChannel,
|
||||||
|
Topic = "General discussion",
|
||||||
|
CreatedByUserId = Guid.Empty,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@ public class ChatService : IChatService
|
|||||||
private readonly PresenceTracker _presenceTracker;
|
private readonly PresenceTracker _presenceTracker;
|
||||||
private readonly IEnumerable<IChatBroadcaster> _broadcasters;
|
private readonly IEnumerable<IChatBroadcaster> _broadcasters;
|
||||||
private readonly LinkEmbedService _embedService;
|
private readonly LinkEmbedService _embedService;
|
||||||
|
private readonly IMessageEncryptionService _encryption;
|
||||||
|
private readonly IChannelService _channelService;
|
||||||
private readonly ILogger<ChatService> _logger;
|
private readonly ILogger<ChatService> _logger;
|
||||||
|
|
||||||
public ChatService(
|
public ChatService(
|
||||||
@@ -23,12 +25,16 @@ public class ChatService : IChatService
|
|||||||
PresenceTracker presenceTracker,
|
PresenceTracker presenceTracker,
|
||||||
IEnumerable<IChatBroadcaster> broadcasters,
|
IEnumerable<IChatBroadcaster> broadcasters,
|
||||||
LinkEmbedService embedService,
|
LinkEmbedService embedService,
|
||||||
|
IMessageEncryptionService encryption,
|
||||||
|
IChannelService channelService,
|
||||||
ILogger<ChatService> logger)
|
ILogger<ChatService> logger)
|
||||||
{
|
{
|
||||||
_scopeFactory = scopeFactory;
|
_scopeFactory = scopeFactory;
|
||||||
_presenceTracker = presenceTracker;
|
_presenceTracker = presenceTracker;
|
||||||
_broadcasters = broadcasters;
|
_broadcasters = broadcasters;
|
||||||
_embedService = embedService;
|
_embedService = embedService;
|
||||||
|
_encryption = encryption;
|
||||||
|
_channelService = channelService;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,28 +98,10 @@ public class ChatService : IChatService
|
|||||||
{
|
{
|
||||||
channelName = channelName.ToLowerInvariant().Trim();
|
channelName = channelName.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
// Delegate channel validation + membership to ChannelService
|
||||||
return ([], "Invalid channel name. Use 2-100 characters: letters, digits, underscores, or hyphens.");
|
var (success, error) = await _channelService.EnsureChannelMembershipAsync(userId, channelName);
|
||||||
|
if (!success)
|
||||||
using var scope = _scopeFactory.CreateScope();
|
return ([], error);
|
||||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
|
||||||
|
|
||||||
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
|
||||||
if (channel is null)
|
|
||||||
return ([], $"Channel '{channelName}' does not exist. Create it first via the channel list.");
|
|
||||||
|
|
||||||
// Persist membership so the channel shows in the user's channel list
|
|
||||||
var hasMembership = await db.ChannelMemberships
|
|
||||||
.AnyAsync(m => m.UserId == userId && m.ChannelId == channel.Id);
|
|
||||||
if (!hasMembership)
|
|
||||||
{
|
|
||||||
db.ChannelMemberships.Add(new ChannelMembership
|
|
||||||
{
|
|
||||||
UserId = userId,
|
|
||||||
ChannelId = channel.Id,
|
|
||||||
});
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
var isNewJoin = _presenceTracker.JoinChannel(username, channelName);
|
var isNewJoin = _presenceTracker.JoinChannel(username, channelName);
|
||||||
|
|
||||||
@@ -123,7 +111,7 @@ public class ChatService : IChatService
|
|||||||
_logger.LogInformation("{User} joined channel '{Channel}'", username, channelName);
|
_logger.LogInformation("{User} joined channel '{Channel}'", username, channelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
var history = await GetChannelHistoryInternalAsync(db, channelName, HubConstants.DefaultHistoryCount);
|
var history = await GetChannelHistoryAsync(channelName, HubConstants.DefaultHistoryCount);
|
||||||
return (history, null);
|
return (history, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,14 +130,21 @@ public class ChatService : IChatService
|
|||||||
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
||||||
return "Invalid channel name.";
|
return "Invalid channel name.";
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(content))
|
// Decrypt content (client sends encrypted; IRC sends plaintext — Decrypt handles both)
|
||||||
|
var plaintext = _encryption.Decrypt(content);
|
||||||
|
|
||||||
|
// Strip encryption prefix if a user typed it literally (prevents spoofing)
|
||||||
|
while (plaintext.StartsWith("$ENC$"))
|
||||||
|
plaintext = plaintext["$ENC$".Length..];
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(plaintext))
|
||||||
return "Message content cannot be empty.";
|
return "Message content cannot be empty.";
|
||||||
|
|
||||||
if (content.Length > HubConstants.MaxMessageLength)
|
if (plaintext.Length > HubConstants.MaxMessageLength)
|
||||||
return $"Message exceeds maximum length of {HubConstants.MaxMessageLength} characters.";
|
return $"Message exceeds maximum length of {HubConstants.MaxMessageLength} characters.";
|
||||||
|
|
||||||
// Sanitize: collapse excessive newlines
|
// Sanitize on plaintext: collapse excessive newlines
|
||||||
content = SanitizeNewlines(content);
|
plaintext = SanitizeNewlines(plaintext);
|
||||||
|
|
||||||
using var scope = _scopeFactory.CreateScope();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
@@ -175,35 +170,42 @@ public class ChatService : IChatService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt to fetch link embeds for URLs in the message
|
// Attempt to fetch link embeds for URLs in the plaintext message
|
||||||
List<EmbedDto>? embeds = null;
|
List<EmbedDto>? embeds = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
embeds = await _embedService.TryGetEmbedsAsync(content);
|
embeds = await _embedService.TryGetEmbedsAsync(plaintext);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to fetch link embeds for message in '{Channel}'", channelName);
|
_logger.LogWarning(ex, "Failed to fetch link embeds for message in '{Channel}'", channelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store in DB — encrypted at rest if enabled, plaintext otherwise
|
||||||
|
var embedJson = embeds is not null ? JsonSerializer.Serialize(embeds) : null;
|
||||||
|
var dbContent = _encryption.EncryptDatabaseEnabled ? _encryption.Encrypt(plaintext) : plaintext;
|
||||||
|
var dbEmbedJson = _encryption.EncryptDatabaseEnabled ? _encryption.EncryptNullable(embedJson) : embedJson;
|
||||||
|
|
||||||
var message = new Message
|
var message = new Message
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
Content = content,
|
Content = dbContent,
|
||||||
Type = MessageType.Text,
|
Type = MessageType.Text,
|
||||||
SentAt = DateTimeOffset.UtcNow,
|
SentAt = DateTimeOffset.UtcNow,
|
||||||
ChannelId = channel.Id,
|
ChannelId = channel.Id,
|
||||||
SenderUserId = userId,
|
SenderUserId = userId,
|
||||||
SenderUsername = username,
|
SenderUsername = username,
|
||||||
EmbedJson = embeds is not null ? JsonSerializer.Serialize(embeds) : null,
|
EmbedJson = dbEmbedJson,
|
||||||
};
|
};
|
||||||
|
|
||||||
db.Messages.Add(message);
|
db.Messages.Add(message);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Broadcast encrypted for SignalR clients; IRC broadcaster gets plaintext
|
||||||
|
var encryptedContent = _encryption.Encrypt(plaintext);
|
||||||
var messageDto = new MessageDto(
|
var messageDto = new MessageDto(
|
||||||
message.Id,
|
message.Id,
|
||||||
message.Content,
|
encryptedContent,
|
||||||
message.SenderUsername,
|
message.SenderUsername,
|
||||||
sender?.NicknameColor,
|
sender?.NicknameColor,
|
||||||
channelName,
|
channelName,
|
||||||
@@ -211,7 +213,7 @@ public class ChatService : IChatService
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
message.SentAt,
|
message.SentAt,
|
||||||
embeds);
|
Embeds: embeds);
|
||||||
|
|
||||||
await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto));
|
await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto));
|
||||||
|
|
||||||
@@ -318,32 +320,6 @@ public class ChatService : IChatService
|
|||||||
user.StatusMessage, user.Role, user.CreatedAt, user.LastSeenAt);
|
user.StatusMessage, user.Role, user.CreatedAt, user.LastSeenAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName)
|
|
||||||
{
|
|
||||||
channelName = channelName.ToLowerInvariant().Trim();
|
|
||||||
|
|
||||||
using var scope = _scopeFactory.CreateScope();
|
|
||||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
|
||||||
|
|
||||||
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
|
||||||
if (channel is null) return (null, false);
|
|
||||||
|
|
||||||
return (channel.Topic, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<List<ChannelListItem>> GetChannelListAsync()
|
|
||||||
{
|
|
||||||
using var scope = _scopeFactory.CreateScope();
|
|
||||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
|
||||||
|
|
||||||
var channels = await db.Channels.OrderBy(c => c.Name).ToListAsync();
|
|
||||||
|
|
||||||
return channels.Select(c => new ChannelListItem(
|
|
||||||
c.Name,
|
|
||||||
c.Topic,
|
|
||||||
_presenceTracker.GetOnlineUsersInChannel(c.Name).Count)).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<List<string>> GetChannelsForUserAsync(string username)
|
public Task<List<string>> GetChannelsForUserAsync(string username)
|
||||||
=> Task.FromResult(_presenceTracker.GetChannelsForUser(username));
|
=> Task.FromResult(_presenceTracker.GetChannelsForUser(username));
|
||||||
|
|
||||||
@@ -398,7 +374,7 @@ public class ChatService : IChatService
|
|||||||
return string.Join('\n', result);
|
return string.Join('\n', result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<List<MessageDto>> GetChannelHistoryInternalAsync(EchoHubDbContext db, string channelName, int count)
|
private async Task<List<MessageDto>> GetChannelHistoryInternalAsync(EchoHubDbContext db, string channelName, int count)
|
||||||
{
|
{
|
||||||
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||||
if (channel is null)
|
if (channel is null)
|
||||||
@@ -418,16 +394,21 @@ public class ChatService : IChatService
|
|||||||
|
|
||||||
return raw.Select(x =>
|
return raw.Select(x =>
|
||||||
{
|
{
|
||||||
|
// Decrypt DB content (handles both encrypted and plaintext via prefix detection)
|
||||||
|
var plaintext = _encryption.Decrypt(x.m.Content);
|
||||||
|
var embedJsonPlain = _encryption.DecryptNullable(x.m.EmbedJson);
|
||||||
|
|
||||||
List<EmbedDto>? embeds = null;
|
List<EmbedDto>? embeds = null;
|
||||||
if (x.m.EmbedJson is not null)
|
if (embedJsonPlain is not null)
|
||||||
{
|
{
|
||||||
try { embeds = JsonSerializer.Deserialize<List<EmbedDto>>(x.m.EmbedJson); }
|
try { embeds = JsonSerializer.Deserialize<List<EmbedDto>>(embedJsonPlain); }
|
||||||
catch { /* ignore malformed JSON */ }
|
catch { /* ignore malformed JSON */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Encrypt for transport — client decrypts
|
||||||
return new MessageDto(
|
return new MessageDto(
|
||||||
x.m.Id,
|
x.m.Id,
|
||||||
x.m.Content,
|
_encryption.Encrypt(plaintext),
|
||||||
x.m.SenderUsername,
|
x.m.SenderUsername,
|
||||||
x.NicknameColor,
|
x.NicknameColor,
|
||||||
channelName,
|
channelName,
|
||||||
@@ -435,6 +416,7 @@ public class ChatService : IChatService
|
|||||||
x.m.AttachmentUrl,
|
x.m.AttachmentUrl,
|
||||||
x.m.AttachmentFileName,
|
x.m.AttachmentFileName,
|
||||||
x.m.SentAt,
|
x.m.SentAt,
|
||||||
|
x.m.AttachmentFileSize,
|
||||||
embeds);
|
embeds);
|
||||||
}).ToList();
|
}).ToList();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
namespace EchoHub.Server.Services;
|
||||||
|
|
||||||
|
public sealed class FileCleanupService : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
private readonly ILogger<FileCleanupService> _logger;
|
||||||
|
|
||||||
|
public FileCleanupService(IConfiguration configuration, ILogger<FileCleanupService> logger)
|
||||||
|
{
|
||||||
|
_configuration = configuration;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
var intervalHours = _configuration.GetValue("Storage:CleanupIntervalHours", 1);
|
||||||
|
var retentionDays = _configuration.GetValue("Storage:RetentionDays", 30);
|
||||||
|
var storagePath = _configuration["Storage:Path"]
|
||||||
|
?? Path.Combine(AppContext.BaseDirectory, "uploads");
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"File cleanup service started — interval: {Hours}h, retention: {Days}d, path: {Path}",
|
||||||
|
intervalHours, retentionDays, storagePath);
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromHours(intervalHours), stoppingToken);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
CleanupOldFiles(storagePath, retentionDays);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error during file cleanup");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CleanupOldFiles(string storagePath, int retentionDays)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(storagePath))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var cutoff = DateTime.UtcNow.AddDays(-retentionDays);
|
||||||
|
var files = Directory.GetFiles(storagePath);
|
||||||
|
var deleted = 0;
|
||||||
|
|
||||||
|
foreach (var file in files)
|
||||||
|
{
|
||||||
|
var createdAt = File.GetCreationTimeUtc(file);
|
||||||
|
if (createdAt < cutoff)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.Delete(file);
|
||||||
|
deleted++;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to delete old file: {File}", file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleted > 0)
|
||||||
|
_logger.LogInformation("File cleanup: deleted {Count} files older than {Days} days", deleted, retentionDays);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,20 @@ public static class FileValidationHelper
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static readonly HashSet<string> AudioExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
".mp3", ".wav", ".ogg", ".flac", ".aac", ".m4a", ".wma"
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks whether the file name has a recognized audio extension.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsAudioFile(string fileName)
|
||||||
|
{
|
||||||
|
var ext = Path.GetExtension(fileName);
|
||||||
|
return !string.IsNullOrEmpty(ext) && AudioExtensions.Contains(ext);
|
||||||
|
}
|
||||||
|
|
||||||
private static bool StartsWith(byte[] buffer, int length, byte[] magic)
|
private static bool StartsWith(byte[] buffer, int length, byte[] magic)
|
||||||
{
|
{
|
||||||
if (length < magic.Length)
|
if (length < magic.Length)
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using EchoHub.Core.Contracts;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Services;
|
||||||
|
|
||||||
|
public class MessageEncryptionService : IMessageEncryptionService
|
||||||
|
{
|
||||||
|
private const string EncryptionPrefix = "$ENC$v1$";
|
||||||
|
private const int NonceSizeBytes = 12;
|
||||||
|
private const int TagSizeBytes = 16;
|
||||||
|
|
||||||
|
private readonly byte[] _key;
|
||||||
|
private readonly ILogger<MessageEncryptionService> _logger;
|
||||||
|
|
||||||
|
public bool EncryptDatabaseEnabled { get; }
|
||||||
|
|
||||||
|
public MessageEncryptionService(IConfiguration configuration, ILogger<MessageEncryptionService> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
|
||||||
|
var keyBase64 = configuration["Encryption:Key"]
|
||||||
|
?? throw new InvalidOperationException("Encryption:Key must be configured in appsettings.json.");
|
||||||
|
|
||||||
|
_key = Convert.FromBase64String(keyBase64);
|
||||||
|
|
||||||
|
if (_key.Length != 32)
|
||||||
|
throw new InvalidOperationException($"Encryption:Key must be exactly 32 bytes (256-bit). Got {_key.Length} bytes.");
|
||||||
|
|
||||||
|
EncryptDatabaseEnabled = configuration.GetValue<bool>("Encryption:EncryptDatabase");
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Encrypt(string plaintext)
|
||||||
|
{
|
||||||
|
var plaintextBytes = Encoding.UTF8.GetBytes(plaintext);
|
||||||
|
var nonce = RandomNumberGenerator.GetBytes(NonceSizeBytes);
|
||||||
|
var ciphertext = new byte[plaintextBytes.Length];
|
||||||
|
var tag = new byte[TagSizeBytes];
|
||||||
|
|
||||||
|
using var aes = new AesGcm(_key, TagSizeBytes);
|
||||||
|
aes.Encrypt(nonce, plaintextBytes, ciphertext, tag);
|
||||||
|
|
||||||
|
// Combine ciphertext + tag for storage
|
||||||
|
var combined = new byte[ciphertext.Length + tag.Length];
|
||||||
|
Buffer.BlockCopy(ciphertext, 0, combined, 0, ciphertext.Length);
|
||||||
|
Buffer.BlockCopy(tag, 0, combined, ciphertext.Length, tag.Length);
|
||||||
|
|
||||||
|
return $"{EncryptionPrefix}{Convert.ToBase64String(nonce)}${Convert.ToBase64String(combined)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Decrypt(string content)
|
||||||
|
{
|
||||||
|
if (!content.StartsWith(EncryptionPrefix))
|
||||||
|
return content; // Legacy plaintext
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var payload = content[EncryptionPrefix.Length..];
|
||||||
|
var separatorIndex = payload.IndexOf('$');
|
||||||
|
if (separatorIndex < 0)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Malformed encrypted content: missing separator");
|
||||||
|
return "[encrypted message — decryption failed]";
|
||||||
|
}
|
||||||
|
|
||||||
|
var nonceBase64 = payload[..separatorIndex];
|
||||||
|
var combinedBase64 = payload[(separatorIndex + 1)..];
|
||||||
|
|
||||||
|
var nonce = Convert.FromBase64String(nonceBase64);
|
||||||
|
var combined = Convert.FromBase64String(combinedBase64);
|
||||||
|
|
||||||
|
if (combined.Length < TagSizeBytes)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Malformed encrypted content: data too short");
|
||||||
|
return "[encrypted message — decryption failed]";
|
||||||
|
}
|
||||||
|
|
||||||
|
var ciphertextLength = combined.Length - TagSizeBytes;
|
||||||
|
var ciphertext = combined.AsSpan(0, ciphertextLength);
|
||||||
|
var tag = combined.AsSpan(ciphertextLength, TagSizeBytes);
|
||||||
|
var plaintext = new byte[ciphertextLength];
|
||||||
|
|
||||||
|
using var aes = new AesGcm(_key, TagSizeBytes);
|
||||||
|
aes.Decrypt(nonce, ciphertext, tag, plaintext);
|
||||||
|
|
||||||
|
return Encoding.UTF8.GetString(plaintext);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to decrypt message content");
|
||||||
|
return "[encrypted message — decryption failed]";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? EncryptNullable(string? value)
|
||||||
|
=> value is null ? null : Encrypt(value);
|
||||||
|
|
||||||
|
public string? DecryptNullable(string? value)
|
||||||
|
=> value is null ? null : Decrypt(value);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using EchoHub.Core.Constants;
|
using EchoHub.Core.Constants;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
using EchoHub.Server.Data;
|
using EchoHub.Server.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
@@ -12,12 +13,14 @@ public static partial class DataMigrationService
|
|||||||
{
|
{
|
||||||
using var scope = services.CreateScope();
|
using var scope = services.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
var config = scope.ServiceProvider.GetRequiredService<IConfiguration>();
|
||||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>()
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>()
|
||||||
.CreateLogger("EchoHub.Server.Setup.DataMigration");
|
.CreateLogger("EchoHub.Server.Setup.DataMigration");
|
||||||
|
|
||||||
await EnsureDefaultChannelsPublicAsync(db, logger);
|
await EnsureDefaultChannelsPublicAsync(db, logger);
|
||||||
await MigrateAnsiMessagesAsync(db, logger);
|
await MigrateAnsiMessagesAsync(db, logger);
|
||||||
await MigrateEmbedJsonToArrayAsync(db, logger);
|
await MigrateEmbedJsonToArrayAsync(db, logger);
|
||||||
|
await EnsureConfiguredAdminsAsync(db, config, logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -94,6 +97,40 @@ public static partial class DataMigrationService
|
|||||||
[GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2|48;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")]
|
[GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2|48;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")]
|
||||||
private static partial Regex AnsiColorRegex();
|
private static partial Regex AnsiColorRegex();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ensure usernames listed in Server:Admins config are at least Admin role.
|
||||||
|
/// Acts as a safety net in case the first registered user didn't get Owner role.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task EnsureConfiguredAdminsAsync(EchoHubDbContext db, IConfiguration config, ILogger logger)
|
||||||
|
{
|
||||||
|
var adminUsernames = config.GetSection("Server:Admins").Get<string[]>();
|
||||||
|
if (adminUsernames is not { Length: > 0 })
|
||||||
|
return;
|
||||||
|
|
||||||
|
var promoted = 0;
|
||||||
|
foreach (var username in adminUsernames)
|
||||||
|
{
|
||||||
|
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username);
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Configured admin '{Username}' not found in database (not registered yet).", username);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.Role < ServerRole.Admin)
|
||||||
|
{
|
||||||
|
var oldRole = user.Role;
|
||||||
|
user.Role = ServerRole.Admin;
|
||||||
|
promoted++;
|
||||||
|
logger.LogInformation("Promoted '{Username}' from {OldRole} to Admin (configured in Server:Admins).",
|
||||||
|
username, oldRole);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (promoted > 0)
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Migrate old single-object EmbedJson ("{...}") to array format ("[{...}]").
|
/// Migrate old single-object EmbedJson ("{...}") to array format ("[{...}]").
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -136,4 +173,5 @@ public static partial class DataMigrationService
|
|||||||
logger.LogInformation("Migrated {Count} embed records from single-object to array format.", modified);
|
logger.LogInformation("Migrated {Count} embed records from single-object to array format.", modified);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ public static class FirstRunSetup
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
EnsureJwtSecret(settingsPath);
|
EnsureJwtSecret(settingsPath);
|
||||||
|
EnsureEncryptionKey(settingsPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void EnsureJwtSecret(string settingsPath)
|
private static void EnsureJwtSecret(string settingsPath)
|
||||||
@@ -45,4 +46,27 @@ public static class FirstRunSetup
|
|||||||
File.WriteAllText(settingsPath, root.ToJsonString(writeOptions));
|
File.WriteAllText(settingsPath, root.ToJsonString(writeOptions));
|
||||||
Console.WriteLine("Generated new JWT secret in appsettings.json.");
|
Console.WriteLine("Generated new JWT secret in appsettings.json.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void EnsureEncryptionKey(string settingsPath)
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(settingsPath);
|
||||||
|
var root = JsonNode.Parse(json, documentOptions: new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip });
|
||||||
|
if (root is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var currentKey = root["Encryption"]?["Key"]?.GetValue<string>();
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(currentKey))
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Generate a 256-bit (32-byte) AES key
|
||||||
|
var key = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||||
|
|
||||||
|
root["Encryption"] ??= new JsonObject();
|
||||||
|
root["Encryption"]!["Key"] = key;
|
||||||
|
|
||||||
|
var writeOptions = new JsonSerializerOptions { WriteIndented = true };
|
||||||
|
File.WriteAllText(settingsPath, root.ToJsonString(writeOptions));
|
||||||
|
Console.WriteLine("Generated new encryption key in appsettings.json.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,16 @@
|
|||||||
"Name": "My EchoHub Server",
|
"Name": "My EchoHub Server",
|
||||||
"Description": "A self-hosted EchoHub chat server",
|
"Description": "A self-hosted EchoHub chat server",
|
||||||
"PublicServer": false,
|
"PublicServer": false,
|
||||||
"PublicHost": ""
|
"PublicHost": "",
|
||||||
|
"Admins": []
|
||||||
|
},
|
||||||
|
"Storage": {
|
||||||
|
"CleanupIntervalHours": 1,
|
||||||
|
"RetentionDays": 30
|
||||||
|
},
|
||||||
|
"Encryption": {
|
||||||
|
"Key": "",
|
||||||
|
"EncryptDatabase": false
|
||||||
},
|
},
|
||||||
"Irc": {
|
"Irc": {
|
||||||
"Enabled": false,
|
"Enabled": false,
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
using EchoHub.Client.UI.Chat;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace EchoHub.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for static string-utility methods on ChatLine.
|
||||||
|
/// Note: Tests that construct ChatLine/ChatListSource or use Terminal.Gui types
|
||||||
|
/// (Attribute, Color) are excluded because Terminal.Gui's module initializer
|
||||||
|
/// requires a display driver which is unavailable in CI/test environments.
|
||||||
|
/// </summary>
|
||||||
|
public class ChatLineTests
|
||||||
|
{
|
||||||
|
// ── HasColorTags ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HasColorTags_ForegroundTag_ReturnsTrue()
|
||||||
|
{
|
||||||
|
Assert.True(ChatLine.HasColorTags("Hello {F:FF0000}world"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HasColorTags_BackgroundTag_ReturnsTrue()
|
||||||
|
{
|
||||||
|
Assert.True(ChatLine.HasColorTags("Hello {B:00FF00}world"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HasColorTags_ResetTag_ReturnsTrue()
|
||||||
|
{
|
||||||
|
Assert.True(ChatLine.HasColorTags("Hello{X}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HasColorTags_NoTags_ReturnsFalse()
|
||||||
|
{
|
||||||
|
Assert.False(ChatLine.HasColorTags("Hello world"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HasColorTags_PartialTag_ReturnsFalse()
|
||||||
|
{
|
||||||
|
// {Z:...} is not a valid tag (only F or B)
|
||||||
|
Assert.False(ChatLine.HasColorTags("Hello {Z:000000}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HasColorTags_EmptyString_ReturnsFalse()
|
||||||
|
{
|
||||||
|
Assert.False(ChatLine.HasColorTags(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("{F:AABBCC}text")]
|
||||||
|
[InlineData("prefix{B:112233}suffix")]
|
||||||
|
[InlineData("a{X}b")]
|
||||||
|
[InlineData("{F:000000}{B:FFFFFF}{X}")]
|
||||||
|
public void HasColorTags_VariousValidTags_ReturnsTrue(string input)
|
||||||
|
{
|
||||||
|
Assert.True(ChatLine.HasColorTags(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── StripColorTags ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StripColorTags_RemovesAllTags()
|
||||||
|
{
|
||||||
|
var result = ChatLine.StripColorTags("{F:FF0000}red{B:00FF00}green{X}");
|
||||||
|
Assert.Equal("redgreen", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StripColorTags_NoTags_ReturnsOriginal()
|
||||||
|
{
|
||||||
|
var result = ChatLine.StripColorTags("plain text");
|
||||||
|
Assert.Equal("plain text", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StripColorTags_OnlyTags_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
var result = ChatLine.StripColorTags("{F:AABBCC}{B:112233}{X}");
|
||||||
|
Assert.Equal("", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StripColorTags_MixedContent_KeepsText()
|
||||||
|
{
|
||||||
|
var result = ChatLine.StripColorTags("before{F:FF0000}middle{X}after");
|
||||||
|
Assert.Equal("beforemiddleafter", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StripColorTags_MultipleConsecutiveTags_AllStripped()
|
||||||
|
{
|
||||||
|
var result = ChatLine.StripColorTags("{F:FF0000}{B:00FF00}{X}{F:0000FF}text{X}");
|
||||||
|
Assert.Equal("text", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StripColorTags_PreservesNonTagBraces()
|
||||||
|
{
|
||||||
|
// {Hello} is not a valid tag and should be preserved
|
||||||
|
var result = ChatLine.StripColorTags("{Hello} world");
|
||||||
|
Assert.Equal("{Hello} world", result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using EchoHub.Client.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace EchoHub.Tests;
|
||||||
|
|
||||||
|
public class ClientEncryptionServiceTests
|
||||||
|
{
|
||||||
|
private static string GenerateKey() =>
|
||||||
|
Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||||
|
|
||||||
|
private static ClientEncryptionService CreateInitialized(string? key = null)
|
||||||
|
{
|
||||||
|
var service = new ClientEncryptionService();
|
||||||
|
service.SetKey(key ?? GenerateKey());
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Initialization ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsInitialized_DefaultFalse()
|
||||||
|
{
|
||||||
|
var service = new ClientEncryptionService();
|
||||||
|
Assert.False(service.IsInitialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsInitialized_TrueAfterSetKey()
|
||||||
|
{
|
||||||
|
var service = CreateInitialized();
|
||||||
|
Assert.True(service.IsInitialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EncryptDatabaseEnabled_AlwaysFalse()
|
||||||
|
{
|
||||||
|
var service = CreateInitialized();
|
||||||
|
Assert.False(service.EncryptDatabaseEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetKey_WrongLength_Throws()
|
||||||
|
{
|
||||||
|
var service = new ClientEncryptionService();
|
||||||
|
var shortKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16));
|
||||||
|
|
||||||
|
Assert.Throws<InvalidOperationException>(() => service.SetKey(shortKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetKey_InvalidBase64_Throws()
|
||||||
|
{
|
||||||
|
var service = new ClientEncryptionService();
|
||||||
|
Assert.Throws<FormatException>(() => service.SetKey("not-valid-base64!!!"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Encrypt before initialization ────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Encrypt_NotInitialized_PassesThrough()
|
||||||
|
{
|
||||||
|
var service = new ClientEncryptionService();
|
||||||
|
var result = service.Encrypt("hello");
|
||||||
|
|
||||||
|
Assert.Equal("hello", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decrypt_NotInitialized_PassesThrough()
|
||||||
|
{
|
||||||
|
var service = new ClientEncryptionService();
|
||||||
|
var result = service.Decrypt("$ENC$v1$something$else");
|
||||||
|
|
||||||
|
Assert.Equal("$ENC$v1$something$else", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Encrypt after initialization ─────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Encrypt_ProducesEncryptedFormat()
|
||||||
|
{
|
||||||
|
var service = CreateInitialized();
|
||||||
|
var encrypted = service.Encrypt("Hello, world!");
|
||||||
|
|
||||||
|
Assert.StartsWith("$ENC$v1$", encrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Encrypt_DifferentNonceEachTime()
|
||||||
|
{
|
||||||
|
var service = CreateInitialized();
|
||||||
|
var a = service.Encrypt("same message");
|
||||||
|
var b = service.Encrypt("same message");
|
||||||
|
|
||||||
|
Assert.NotEqual(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Encrypt_EmptyString_Works()
|
||||||
|
{
|
||||||
|
var service = CreateInitialized();
|
||||||
|
var encrypted = service.Encrypt("");
|
||||||
|
|
||||||
|
Assert.StartsWith("$ENC$v1$", encrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Decrypt ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decrypt_RoundTrip()
|
||||||
|
{
|
||||||
|
var service = CreateInitialized();
|
||||||
|
var original = "Hello, world!";
|
||||||
|
|
||||||
|
var encrypted = service.Encrypt(original);
|
||||||
|
var decrypted = service.Decrypt(encrypted);
|
||||||
|
|
||||||
|
Assert.Equal(original, decrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decrypt_EmptyString_RoundTrip()
|
||||||
|
{
|
||||||
|
var service = CreateInitialized();
|
||||||
|
var encrypted = service.Encrypt("");
|
||||||
|
var decrypted = service.Decrypt(encrypted);
|
||||||
|
|
||||||
|
Assert.Equal("", decrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decrypt_Unicode_RoundTrip()
|
||||||
|
{
|
||||||
|
var service = CreateInitialized();
|
||||||
|
var original = "Hello 🌍 世界 مرحبا";
|
||||||
|
|
||||||
|
var encrypted = service.Encrypt(original);
|
||||||
|
var decrypted = service.Decrypt(encrypted);
|
||||||
|
|
||||||
|
Assert.Equal(original, decrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decrypt_PlaintextPassthrough()
|
||||||
|
{
|
||||||
|
var service = CreateInitialized();
|
||||||
|
var plaintext = "This is just plain text";
|
||||||
|
|
||||||
|
var result = service.Decrypt(plaintext);
|
||||||
|
|
||||||
|
Assert.Equal(plaintext, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decrypt_WrongKey_ReturnsFailureMessage()
|
||||||
|
{
|
||||||
|
var encryptor = CreateInitialized();
|
||||||
|
var decryptor = CreateInitialized(); // different key
|
||||||
|
|
||||||
|
var encrypted = encryptor.Encrypt("secret message");
|
||||||
|
var result = decryptor.Decrypt(encrypted);
|
||||||
|
|
||||||
|
Assert.Contains("decryption failed", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decrypt_MalformedContent_ReturnsOriginal()
|
||||||
|
{
|
||||||
|
var service = CreateInitialized();
|
||||||
|
|
||||||
|
// Malformed: prefix present but no valid separator after nonce
|
||||||
|
var result = service.Decrypt("$ENC$v1$noseperatorhere");
|
||||||
|
|
||||||
|
Assert.Equal("$ENC$v1$noseperatorhere", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decrypt_TamperedCiphertext_ReturnsFailure()
|
||||||
|
{
|
||||||
|
var service = CreateInitialized();
|
||||||
|
var encrypted = service.Encrypt("original message");
|
||||||
|
|
||||||
|
var tampered = encrypted[..^5] + "XXXXX";
|
||||||
|
var result = service.Decrypt(tampered);
|
||||||
|
|
||||||
|
Assert.Contains("decryption failed", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Nullable helpers ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EncryptNullable_Null_ReturnsNull()
|
||||||
|
{
|
||||||
|
var service = CreateInitialized();
|
||||||
|
Assert.Null(service.EncryptNullable(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DecryptNullable_Null_ReturnsNull()
|
||||||
|
{
|
||||||
|
var service = CreateInitialized();
|
||||||
|
Assert.Null(service.DecryptNullable(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Key replacement ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetKey_CanBeCalledMultipleTimes()
|
||||||
|
{
|
||||||
|
var service = new ClientEncryptionService();
|
||||||
|
|
||||||
|
var key1 = GenerateKey();
|
||||||
|
var key2 = GenerateKey();
|
||||||
|
|
||||||
|
service.SetKey(key1);
|
||||||
|
var encrypted1 = service.Encrypt("test");
|
||||||
|
|
||||||
|
service.SetKey(key2);
|
||||||
|
var encrypted2 = service.Encrypt("test");
|
||||||
|
|
||||||
|
// Both produce encrypted content
|
||||||
|
Assert.StartsWith("$ENC$v1$", encrypted1);
|
||||||
|
Assert.StartsWith("$ENC$v1$", encrypted2);
|
||||||
|
|
||||||
|
// Old key's content can't be decrypted with new key
|
||||||
|
var result = service.Decrypt(encrypted1);
|
||||||
|
Assert.Contains("decryption failed", result);
|
||||||
|
|
||||||
|
// New key's content decrypts fine
|
||||||
|
Assert.Equal("test", service.Decrypt(encrypted2));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
using EchoHub.Client.Commands;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace EchoHub.Tests;
|
||||||
|
|
||||||
|
public class CommandHandlerTests
|
||||||
|
{
|
||||||
|
private CommandHandler CreateHandler() => new();
|
||||||
|
|
||||||
|
// ── IsCommand ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsCommand_StartsWithSlash_ReturnsTrue()
|
||||||
|
{
|
||||||
|
var handler = CreateHandler();
|
||||||
|
Assert.True(handler.IsCommand("/help"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsCommand_NoSlash_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var handler = CreateHandler();
|
||||||
|
Assert.False(handler.IsCommand("hello"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsCommand_EmptyString_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var handler = CreateHandler();
|
||||||
|
Assert.False(handler.IsCommand(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HandleAsync — not a command ───────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_NotCommand_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var handler = CreateHandler();
|
||||||
|
var result = await handler.HandleAsync("hello world");
|
||||||
|
|
||||||
|
Assert.False(result.Handled);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HandleAsync — unknown command ─────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_UnknownCommand_ReturnsError()
|
||||||
|
{
|
||||||
|
var handler = CreateHandler();
|
||||||
|
var result = await handler.HandleAsync("/doesnotexist");
|
||||||
|
|
||||||
|
Assert.True(result.Handled);
|
||||||
|
Assert.True(result.IsError);
|
||||||
|
Assert.Contains("Unknown command", result.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── /status ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_StatusOnline_SetsOnlineStatus()
|
||||||
|
{
|
||||||
|
var handler = CreateHandler();
|
||||||
|
UserStatus? capturedStatus = null;
|
||||||
|
handler.OnSetStatus += (status, msg) => { capturedStatus = status; return Task.CompletedTask; };
|
||||||
|
|
||||||
|
var result = await handler.HandleAsync("/status online");
|
||||||
|
|
||||||
|
Assert.True(result.Handled);
|
||||||
|
Assert.False(result.IsError);
|
||||||
|
Assert.Equal(UserStatus.Online, capturedStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_StatusAway_SetsAwayStatus()
|
||||||
|
{
|
||||||
|
var handler = CreateHandler();
|
||||||
|
UserStatus? capturedStatus = null;
|
||||||
|
handler.OnSetStatus += (status, msg) => { capturedStatus = status; return Task.CompletedTask; };
|
||||||
|
|
||||||
|
await handler.HandleAsync("/status away");
|
||||||
|
Assert.Equal(UserStatus.Away, capturedStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_StatusCustomMessage_SetsStatusMessage()
|
||||||
|
{
|
||||||
|
var handler = CreateHandler();
|
||||||
|
string? capturedMessage = null;
|
||||||
|
handler.OnSetStatus += (status, msg) => { capturedMessage = msg; return Task.CompletedTask; };
|
||||||
|
|
||||||
|
var result = await handler.HandleAsync("/status brb lunch");
|
||||||
|
|
||||||
|
Assert.True(result.Handled);
|
||||||
|
Assert.Contains("brb lunch", result.Message);
|
||||||
|
Assert.Equal("brb lunch", capturedMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_StatusNoArgs_ReturnsError()
|
||||||
|
{
|
||||||
|
var handler = CreateHandler();
|
||||||
|
var result = await handler.HandleAsync("/status");
|
||||||
|
|
||||||
|
Assert.True(result.IsError);
|
||||||
|
Assert.Contains("Usage", result.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── /nick ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_Nick_SetsDisplayName()
|
||||||
|
{
|
||||||
|
var handler = CreateHandler();
|
||||||
|
string? capturedNick = null;
|
||||||
|
handler.OnSetNick += nick => { capturedNick = nick; return Task.CompletedTask; };
|
||||||
|
|
||||||
|
var result = await handler.HandleAsync("/nick Bob Smith");
|
||||||
|
|
||||||
|
Assert.True(result.Handled);
|
||||||
|
Assert.Equal("Bob Smith", capturedNick);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_Nick_EmptyArgs_ReturnsError()
|
||||||
|
{
|
||||||
|
var handler = CreateHandler();
|
||||||
|
var result = await handler.HandleAsync("/nick");
|
||||||
|
|
||||||
|
Assert.True(result.IsError);
|
||||||
|
Assert.Contains("Usage", result.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── /color ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_Color_ValidHex_Succeeds()
|
||||||
|
{
|
||||||
|
var handler = CreateHandler();
|
||||||
|
string? capturedColor = null;
|
||||||
|
handler.OnSetColor += color => { capturedColor = color; return Task.CompletedTask; };
|
||||||
|
|
||||||
|
var result = await handler.HandleAsync("/color #FF5733");
|
||||||
|
|
||||||
|
Assert.True(result.Handled);
|
||||||
|
Assert.False(result.IsError);
|
||||||
|
Assert.Equal("#FF5733", capturedColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_Color_WithoutHash_AddsHash()
|
||||||
|
{
|
||||||
|
var handler = CreateHandler();
|
||||||
|
string? capturedColor = null;
|
||||||
|
handler.OnSetColor += color => { capturedColor = color; return Task.CompletedTask; };
|
||||||
|
|
||||||
|
await handler.HandleAsync("/color FF5733");
|
||||||
|
Assert.Equal("#FF5733", capturedColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_Color_InvalidHex_ReturnsError()
|
||||||
|
{
|
||||||
|
var handler = CreateHandler();
|
||||||
|
var result = await handler.HandleAsync("/color #ZZZZZZ");
|
||||||
|
|
||||||
|
Assert.True(result.IsError);
|
||||||
|
Assert.Contains("Invalid color", result.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_Color_TooShort_ReturnsError()
|
||||||
|
{
|
||||||
|
var handler = CreateHandler();
|
||||||
|
var result = await handler.HandleAsync("/color #FFF");
|
||||||
|
|
||||||
|
Assert.True(result.IsError);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_Color_NoArgs_ReturnsError()
|
||||||
|
{
|
||||||
|
var handler = CreateHandler();
|
||||||
|
var result = await handler.HandleAsync("/color");
|
||||||
|
|
||||||
|
Assert.True(result.IsError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── /join ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_Join_StripsHashPrefix()
|
||||||
|
{
|
||||||
|
var handler = CreateHandler();
|
||||||
|
string? capturedChannel = null;
|
||||||
|
handler.OnJoinChannel += ch => { capturedChannel = ch; return Task.CompletedTask; };
|
||||||
|
|
||||||
|
await handler.HandleAsync("/join #random");
|
||||||
|
Assert.Equal("random", capturedChannel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_Join_NoHash_PassedDirectly()
|
||||||
|
{
|
||||||
|
var handler = CreateHandler();
|
||||||
|
string? capturedChannel = null;
|
||||||
|
handler.OnJoinChannel += ch => { capturedChannel = ch; return Task.CompletedTask; };
|
||||||
|
|
||||||
|
await handler.HandleAsync("/join random");
|
||||||
|
Assert.Equal("random", capturedChannel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_Join_NoArgs_ReturnsError()
|
||||||
|
{
|
||||||
|
var handler = CreateHandler();
|
||||||
|
var result = await handler.HandleAsync("/join");
|
||||||
|
|
||||||
|
Assert.True(result.IsError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── /kick ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_Kick_WithReason_ParsesUsernameAndReason()
|
||||||
|
{
|
||||||
|
var handler = CreateHandler();
|
||||||
|
string? capturedUser = null;
|
||||||
|
string? capturedReason = null;
|
||||||
|
handler.OnKickUser += (user, reason) =>
|
||||||
|
{
|
||||||
|
capturedUser = user;
|
||||||
|
capturedReason = reason;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
|
||||||
|
await handler.HandleAsync("/kick baduser being rude");
|
||||||
|
Assert.Equal("baduser", capturedUser);
|
||||||
|
Assert.Equal("being rude", capturedReason);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_Kick_WithoutReason_NullReason()
|
||||||
|
{
|
||||||
|
var handler = CreateHandler();
|
||||||
|
string? capturedReason = "initial";
|
||||||
|
handler.OnKickUser += (user, reason) =>
|
||||||
|
{
|
||||||
|
capturedReason = reason;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
|
||||||
|
await handler.HandleAsync("/kick baduser");
|
||||||
|
Assert.Null(capturedReason);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_Kick_NoArgs_ReturnsError()
|
||||||
|
{
|
||||||
|
var handler = CreateHandler();
|
||||||
|
var result = await handler.HandleAsync("/kick");
|
||||||
|
Assert.True(result.IsError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── /mute ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_Mute_WithDuration_ParsesDuration()
|
||||||
|
{
|
||||||
|
var handler = CreateHandler();
|
||||||
|
int? capturedDuration = null;
|
||||||
|
handler.OnMuteUser += (user, duration) =>
|
||||||
|
{
|
||||||
|
capturedDuration = duration;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
|
||||||
|
await handler.HandleAsync("/mute alice 30");
|
||||||
|
Assert.Equal(30, capturedDuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_Mute_WithoutDuration_NullDuration()
|
||||||
|
{
|
||||||
|
var handler = CreateHandler();
|
||||||
|
int? capturedDuration = -1;
|
||||||
|
handler.OnMuteUser += (user, duration) =>
|
||||||
|
{
|
||||||
|
capturedDuration = duration;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
|
||||||
|
await handler.HandleAsync("/mute alice");
|
||||||
|
Assert.Null(capturedDuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── /role ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("admin")]
|
||||||
|
[InlineData("mod")]
|
||||||
|
[InlineData("member")]
|
||||||
|
public async Task HandleAsync_Role_ValidRole_Succeeds(string role)
|
||||||
|
{
|
||||||
|
var handler = CreateHandler();
|
||||||
|
string? capturedRole = null;
|
||||||
|
handler.OnAssignRole += (user, r) => { capturedRole = r; return Task.CompletedTask; };
|
||||||
|
|
||||||
|
var result = await handler.HandleAsync($"/role alice {role}");
|
||||||
|
|
||||||
|
Assert.True(result.Handled);
|
||||||
|
Assert.False(result.IsError);
|
||||||
|
Assert.Equal(role, capturedRole);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_Role_InvalidRole_ReturnsError()
|
||||||
|
{
|
||||||
|
var handler = CreateHandler();
|
||||||
|
var result = await handler.HandleAsync("/role alice superadmin");
|
||||||
|
|
||||||
|
Assert.True(result.IsError);
|
||||||
|
Assert.Contains("Invalid role", result.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_Role_MissingRole_ReturnsError()
|
||||||
|
{
|
||||||
|
var handler = CreateHandler();
|
||||||
|
var result = await handler.HandleAsync("/role alice");
|
||||||
|
|
||||||
|
Assert.True(result.IsError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── /send ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_Send_NoArgs_ReturnsUsageError()
|
||||||
|
{
|
||||||
|
var handler = CreateHandler();
|
||||||
|
var result = await handler.HandleAsync("/send");
|
||||||
|
|
||||||
|
Assert.True(result.IsError);
|
||||||
|
Assert.Contains("Usage", result.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_Send_UrlInput_RecognizedAsUrl()
|
||||||
|
{
|
||||||
|
var handler = CreateHandler();
|
||||||
|
string? capturedTarget = null;
|
||||||
|
handler.OnSendFile += (target, size) => { capturedTarget = target; return Task.CompletedTask; };
|
||||||
|
|
||||||
|
var result = await handler.HandleAsync("/send https://example.com/image.png");
|
||||||
|
|
||||||
|
Assert.True(result.Handled);
|
||||||
|
Assert.False(result.IsError);
|
||||||
|
Assert.Equal("https://example.com/image.png", capturedTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_Send_UrlWithSizeFlag_ExtractsSizeCorrectly()
|
||||||
|
{
|
||||||
|
var handler = CreateHandler();
|
||||||
|
string? capturedSize = null;
|
||||||
|
handler.OnSendFile += (target, size) => { capturedSize = size; return Task.CompletedTask; };
|
||||||
|
|
||||||
|
await handler.HandleAsync("/send https://example.com/photo.jpg -s");
|
||||||
|
Assert.Equal("s", capturedSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── /help ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_Help_ReturnsHelpText()
|
||||||
|
{
|
||||||
|
var handler = CreateHandler();
|
||||||
|
var result = await handler.HandleAsync("/help");
|
||||||
|
|
||||||
|
Assert.True(result.Handled);
|
||||||
|
Assert.Contains("Available commands", result.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_QuestionMark_ReturnsHelp()
|
||||||
|
{
|
||||||
|
var handler = CreateHandler();
|
||||||
|
var result = await handler.HandleAsync("/?");
|
||||||
|
|
||||||
|
Assert.True(result.Handled);
|
||||||
|
Assert.Contains("Available commands", result.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── /ban ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_Ban_WithReason_ParsesUsernameAndReason()
|
||||||
|
{
|
||||||
|
var handler = CreateHandler();
|
||||||
|
string? capturedUser = null;
|
||||||
|
string? capturedReason = null;
|
||||||
|
handler.OnBanUser += (user, reason) =>
|
||||||
|
{
|
||||||
|
capturedUser = user;
|
||||||
|
capturedReason = reason;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
|
||||||
|
await handler.HandleAsync("/ban troll spamming links");
|
||||||
|
Assert.Equal("troll", capturedUser);
|
||||||
|
Assert.Equal("spamming links", capturedReason);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── /topic ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_Topic_SetsTopic()
|
||||||
|
{
|
||||||
|
var handler = CreateHandler();
|
||||||
|
string? capturedTopic = null;
|
||||||
|
handler.OnSetTopic += topic => { capturedTopic = topic; return Task.CompletedTask; };
|
||||||
|
|
||||||
|
var result = await handler.HandleAsync("/topic Welcome to our channel!");
|
||||||
|
|
||||||
|
Assert.True(result.Handled);
|
||||||
|
Assert.Equal("Welcome to our channel!", capturedTopic);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_Topic_NoArgs_ReturnsError()
|
||||||
|
{
|
||||||
|
var handler = CreateHandler();
|
||||||
|
var result = await handler.HandleAsync("/topic");
|
||||||
|
Assert.True(result.IsError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── /quit and /exit ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_Quit_Handled()
|
||||||
|
{
|
||||||
|
var handler = CreateHandler();
|
||||||
|
var quitCalled = false;
|
||||||
|
handler.OnQuit += () => { quitCalled = true; return Task.CompletedTask; };
|
||||||
|
|
||||||
|
var result = await handler.HandleAsync("/quit");
|
||||||
|
Assert.True(result.Handled);
|
||||||
|
Assert.True(quitCalled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_Exit_Handled()
|
||||||
|
{
|
||||||
|
var handler = CreateHandler();
|
||||||
|
var quitCalled = false;
|
||||||
|
handler.OnQuit += () => { quitCalled = true; return Task.CompletedTask; };
|
||||||
|
|
||||||
|
var result = await handler.HandleAsync("/exit");
|
||||||
|
Assert.True(result.Handled);
|
||||||
|
Assert.True(quitCalled);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using EchoHub.Server.Setup;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace EchoHub.Tests;
|
||||||
|
|
||||||
|
public class DataMigrationServiceTests
|
||||||
|
{
|
||||||
|
// ── AnsiToColorTags ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnsiToColorTags_ForegroundEscape_ConvertedToColorTag()
|
||||||
|
{
|
||||||
|
var ansi = "\x1b[38;2;255;0;0mred text";
|
||||||
|
var result = DataMigrationService.AnsiToColorTags(ansi);
|
||||||
|
|
||||||
|
Assert.Equal("{F:FF0000}red text", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnsiToColorTags_BackgroundEscape_ConvertedToColorTag()
|
||||||
|
{
|
||||||
|
var ansi = "\x1b[48;2;0;255;0mgreen bg";
|
||||||
|
var result = DataMigrationService.AnsiToColorTags(ansi);
|
||||||
|
|
||||||
|
Assert.Equal("{B:00FF00}green bg", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnsiToColorTags_ResetEscape_ConvertedToResetTag()
|
||||||
|
{
|
||||||
|
var ansi = "\x1b[0m";
|
||||||
|
var result = DataMigrationService.AnsiToColorTags(ansi);
|
||||||
|
|
||||||
|
Assert.Equal("{X}", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnsiToColorTags_NoEscapes_ReturnsUnchanged()
|
||||||
|
{
|
||||||
|
var text = "Hello, world!";
|
||||||
|
var result = DataMigrationService.AnsiToColorTags(text);
|
||||||
|
|
||||||
|
Assert.Equal("Hello, world!", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnsiToColorTags_MixedContent_ConvertsEscapesOnly()
|
||||||
|
{
|
||||||
|
var ansi = "before\x1b[38;2;100;200;50mtextafter";
|
||||||
|
var result = DataMigrationService.AnsiToColorTags(ansi);
|
||||||
|
|
||||||
|
Assert.Equal("before{F:64C832}textafter", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnsiToColorTags_MultipleTags_AllConverted()
|
||||||
|
{
|
||||||
|
var ansi = "\x1b[38;2;255;0;0mred\x1b[48;2;0;0;255mblue bg\x1b[0mreset";
|
||||||
|
var result = DataMigrationService.AnsiToColorTags(ansi);
|
||||||
|
|
||||||
|
Assert.Equal("{F:FF0000}red{B:0000FF}blue bg{X}reset", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnsiToColorTags_RoundTrip_WithColorTagsToAnsi()
|
||||||
|
{
|
||||||
|
// AnsiToColorTags and IrcMessageFormatter.ColorTagsToAnsi should be inverses
|
||||||
|
var original = "{F:FF0000}red{B:00FF00}green{X}";
|
||||||
|
var ansi = EchoHub.Server.Irc.IrcMessageFormatter.ColorTagsToAnsi(original);
|
||||||
|
var backToTags = DataMigrationService.AnsiToColorTags(ansi);
|
||||||
|
|
||||||
|
Assert.Equal(original, backToTags);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,8 +18,10 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\EchoHub.Client\EchoHub.Client.csproj" />
|
||||||
<ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" />
|
<ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" />
|
||||||
<ProjectReference Include="..\EchoHub.Server\EchoHub.Server.csproj" />
|
<ProjectReference Include="..\EchoHub.Server\EchoHub.Server.csproj" />
|
||||||
|
<ProjectReference Include="..\EchoHub.Server.Irc\EchoHub.Server.Irc.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using EchoHub.Client.Services;
|
||||||
|
using EchoHub.Server.Services;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace EchoHub.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests that server and client encryption services are fully interoperable —
|
||||||
|
/// content encrypted by one can be decrypted by the other using the same key.
|
||||||
|
/// </summary>
|
||||||
|
public class EncryptionCompatibilityTests
|
||||||
|
{
|
||||||
|
private static readonly string SharedKey =
|
||||||
|
Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||||
|
|
||||||
|
private static MessageEncryptionService CreateServer(string? key = null)
|
||||||
|
{
|
||||||
|
var config = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["Encryption:Key"] = key ?? SharedKey,
|
||||||
|
["Encryption:EncryptDatabase"] = "false",
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var logger = NullLoggerFactory.Instance.CreateLogger<MessageEncryptionService>();
|
||||||
|
return new MessageEncryptionService(config, logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ClientEncryptionService CreateClient(string? key = null)
|
||||||
|
{
|
||||||
|
var service = new ClientEncryptionService();
|
||||||
|
service.SetKey(key ?? SharedKey);
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cross-service round trips ────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClientEncrypt_ServerDecrypt()
|
||||||
|
{
|
||||||
|
var client = CreateClient();
|
||||||
|
var server = CreateServer();
|
||||||
|
var original = "Hello from client!";
|
||||||
|
|
||||||
|
var encrypted = client.Encrypt(original);
|
||||||
|
var decrypted = server.Decrypt(encrypted);
|
||||||
|
|
||||||
|
Assert.Equal(original, decrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ServerEncrypt_ClientDecrypt()
|
||||||
|
{
|
||||||
|
var server = CreateServer();
|
||||||
|
var client = CreateClient();
|
||||||
|
var original = "Hello from server!";
|
||||||
|
|
||||||
|
var encrypted = server.Encrypt(original);
|
||||||
|
var decrypted = client.Decrypt(encrypted);
|
||||||
|
|
||||||
|
Assert.Equal(original, decrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData("a")]
|
||||||
|
[InlineData("Hello, world!")]
|
||||||
|
[InlineData("Hello 🌍 世界 مرحبا")]
|
||||||
|
[InlineData("Line1\nLine2\nLine3")]
|
||||||
|
public void CrossDecrypt_VariousMessages(string message)
|
||||||
|
{
|
||||||
|
var server = CreateServer();
|
||||||
|
var client = CreateClient();
|
||||||
|
|
||||||
|
// Client → Server
|
||||||
|
var clientEncrypted = client.Encrypt(message);
|
||||||
|
Assert.Equal(message, server.Decrypt(clientEncrypted));
|
||||||
|
|
||||||
|
// Server → Client
|
||||||
|
var serverEncrypted = server.Encrypt(message);
|
||||||
|
Assert.Equal(message, client.Decrypt(serverEncrypted));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CrossDecrypt_LargeMessage()
|
||||||
|
{
|
||||||
|
var server = CreateServer();
|
||||||
|
var client = CreateClient();
|
||||||
|
var original = new string('X', 10000);
|
||||||
|
|
||||||
|
var encrypted = client.Encrypt(original);
|
||||||
|
Assert.Equal(original, server.Decrypt(encrypted));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Key mismatch ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DifferentKeys_ClientEncrypt_ServerCantDecrypt()
|
||||||
|
{
|
||||||
|
var clientKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||||
|
var serverKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||||
|
|
||||||
|
var client = CreateClient(clientKey);
|
||||||
|
var server = CreateServer(serverKey);
|
||||||
|
|
||||||
|
var encrypted = client.Encrypt("secret");
|
||||||
|
var result = server.Decrypt(encrypted);
|
||||||
|
|
||||||
|
Assert.Contains("decryption failed", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DifferentKeys_ServerEncrypt_ClientCantDecrypt()
|
||||||
|
{
|
||||||
|
var clientKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||||
|
var serverKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||||
|
|
||||||
|
var server = CreateServer(serverKey);
|
||||||
|
var client = CreateClient(clientKey);
|
||||||
|
|
||||||
|
var encrypted = server.Encrypt("secret");
|
||||||
|
var result = client.Decrypt(encrypted);
|
||||||
|
|
||||||
|
Assert.Contains("decryption failed", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Plaintext passthrough ────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Server_DecryptsPlaintext_AsPassthrough()
|
||||||
|
{
|
||||||
|
var server = CreateServer();
|
||||||
|
Assert.Equal("plain text", server.Decrypt("plain text"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Client_DecryptsPlaintext_AsPassthrough()
|
||||||
|
{
|
||||||
|
var client = CreateClient();
|
||||||
|
Assert.Equal("plain text", client.Decrypt("plain text"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Full E2E flow simulation ─────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FullFlow_ClientSend_ServerProcess_BroadcastBack()
|
||||||
|
{
|
||||||
|
var client = CreateClient();
|
||||||
|
var server = CreateServer();
|
||||||
|
|
||||||
|
// 1. Client encrypts and sends
|
||||||
|
var originalMessage = "Hello everyone!";
|
||||||
|
var clientEncrypted = client.Encrypt(originalMessage);
|
||||||
|
Assert.StartsWith("$ENC$v1$", clientEncrypted);
|
||||||
|
|
||||||
|
// 2. Server decrypts for processing
|
||||||
|
var serverPlaintext = server.Decrypt(clientEncrypted);
|
||||||
|
Assert.Equal(originalMessage, serverPlaintext);
|
||||||
|
|
||||||
|
// 3. Server re-encrypts for broadcast (different nonce)
|
||||||
|
var serverEncrypted = server.Encrypt(serverPlaintext);
|
||||||
|
Assert.StartsWith("$ENC$v1$", serverEncrypted);
|
||||||
|
Assert.NotEqual(clientEncrypted, serverEncrypted); // different nonce
|
||||||
|
|
||||||
|
// 4. Receiving client decrypts the broadcast
|
||||||
|
var receivedPlaintext = client.Decrypt(serverEncrypted);
|
||||||
|
Assert.Equal(originalMessage, receivedPlaintext);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FullFlow_ServerGeneratedMessage_EncryptForBroadcast()
|
||||||
|
{
|
||||||
|
var server = CreateServer();
|
||||||
|
var client = CreateClient();
|
||||||
|
|
||||||
|
// Server generates a system message (e.g. file upload notification)
|
||||||
|
var systemMessage = "user uploaded file.png";
|
||||||
|
|
||||||
|
// Server encrypts for broadcast
|
||||||
|
var encrypted = server.Encrypt(systemMessage);
|
||||||
|
|
||||||
|
// Client decrypts
|
||||||
|
var decrypted = client.Decrypt(encrypted);
|
||||||
|
Assert.Equal(systemMessage, decrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FullFlow_HistoryRetrieve_ServerEncrypts_ClientDecrypts()
|
||||||
|
{
|
||||||
|
var server = CreateServer();
|
||||||
|
var client = CreateClient();
|
||||||
|
|
||||||
|
// Simulate loading N messages from DB (plaintext) and encrypting for transport
|
||||||
|
var messages = new[] { "msg1", "Hello 🌍", "msg with\nnewline" };
|
||||||
|
|
||||||
|
foreach (var original in messages)
|
||||||
|
{
|
||||||
|
var encrypted = server.Encrypt(original);
|
||||||
|
var decrypted = client.Decrypt(encrypted);
|
||||||
|
Assert.Equal(original, decrypted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,4 +53,48 @@ public class FileValidationHelperTests
|
|||||||
FileValidationHelper.IsValidImage(stream);
|
FileValidationHelper.IsValidImage(stream);
|
||||||
Assert.Equal(0, stream.Position);
|
Assert.Equal(0, stream.Position);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── IsAudioFile tests ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("song.mp3")]
|
||||||
|
[InlineData("track.wav")]
|
||||||
|
[InlineData("audio.ogg")]
|
||||||
|
[InlineData("music.flac")]
|
||||||
|
[InlineData("clip.aac")]
|
||||||
|
[InlineData("podcast.m4a")]
|
||||||
|
[InlineData("old.wma")]
|
||||||
|
public void IsAudioFile_SupportedExtensions_ReturnsTrue(string fileName)
|
||||||
|
{
|
||||||
|
Assert.True(FileValidationHelper.IsAudioFile(fileName));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("song.MP3")]
|
||||||
|
[InlineData("track.Wav")]
|
||||||
|
[InlineData("audio.OGG")]
|
||||||
|
[InlineData("music.FLAC")]
|
||||||
|
public void IsAudioFile_CaseInsensitive_ReturnsTrue(string fileName)
|
||||||
|
{
|
||||||
|
Assert.True(FileValidationHelper.IsAudioFile(fileName));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("document.txt")]
|
||||||
|
[InlineData("report.pdf")]
|
||||||
|
[InlineData("app.exe")]
|
||||||
|
[InlineData("photo.jpg")]
|
||||||
|
[InlineData("image.png")]
|
||||||
|
public void IsAudioFile_NonAudioExtension_ReturnsFalse(string fileName)
|
||||||
|
{
|
||||||
|
Assert.False(FileValidationHelper.IsAudioFile(fileName));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData("noextension")]
|
||||||
|
public void IsAudioFile_EmptyOrNoExtension_ReturnsFalse(string fileName)
|
||||||
|
{
|
||||||
|
Assert.False(FileValidationHelper.IsAudioFile(fileName));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
using EchoHub.Core.Constants;
|
||||||
|
using EchoHub.Server.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace EchoHub.Tests;
|
||||||
|
|
||||||
|
public class ImageToAsciiServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void GetDimensions_Small_Returns40x40()
|
||||||
|
{
|
||||||
|
var (w, h) = ImageToAsciiService.GetDimensions("s");
|
||||||
|
Assert.Equal(40, w);
|
||||||
|
Assert.Equal(40, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetDimensions_Large_Returns120x120()
|
||||||
|
{
|
||||||
|
var (w, h) = ImageToAsciiService.GetDimensions("l");
|
||||||
|
Assert.Equal(120, w);
|
||||||
|
Assert.Equal(120, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetDimensions_Default_Returns80x80()
|
||||||
|
{
|
||||||
|
var (w, h) = ImageToAsciiService.GetDimensions("m");
|
||||||
|
Assert.Equal(HubConstants.AsciiArtWidth, w);
|
||||||
|
Assert.Equal(HubConstants.AsciiArtHeightHalfBlock, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetDimensions_Null_ReturnsDefault()
|
||||||
|
{
|
||||||
|
var (w, h) = ImageToAsciiService.GetDimensions(null);
|
||||||
|
Assert.Equal(HubConstants.AsciiArtWidth, w);
|
||||||
|
Assert.Equal(HubConstants.AsciiArtHeightHalfBlock, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetDimensions_CaseInsensitive()
|
||||||
|
{
|
||||||
|
var (w1, h1) = ImageToAsciiService.GetDimensions("S");
|
||||||
|
var (w2, h2) = ImageToAsciiService.GetDimensions("s");
|
||||||
|
Assert.Equal(w1, w2);
|
||||||
|
Assert.Equal(h1, h2);
|
||||||
|
|
||||||
|
var (w3, h3) = ImageToAsciiService.GetDimensions("L");
|
||||||
|
var (w4, h4) = ImageToAsciiService.GetDimensions("l");
|
||||||
|
Assert.Equal(w3, w4);
|
||||||
|
Assert.Equal(h3, h4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetDimensions_UnknownSize_ReturnsDefault()
|
||||||
|
{
|
||||||
|
var (w, h) = ImageToAsciiService.GetDimensions("xl");
|
||||||
|
Assert.Equal(HubConstants.AsciiArtWidth, w);
|
||||||
|
Assert.Equal(HubConstants.AsciiArtHeightHalfBlock, h);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using EchoHub.Core.DTOs;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
|
using EchoHub.Server.Irc;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace EchoHub.Tests.Irc;
|
||||||
|
|
||||||
|
public class IrcBroadcasterTests
|
||||||
|
{
|
||||||
|
private readonly IrcOptions _options = new() { ServerName = "testserver", Enabled = true };
|
||||||
|
private readonly FakeEncryptionService _encryption = new();
|
||||||
|
private readonly IrcGatewayService _gateway;
|
||||||
|
private readonly IrcBroadcaster _broadcaster;
|
||||||
|
|
||||||
|
public IrcBroadcasterTests()
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection()
|
||||||
|
.AddSingleton<IOptions<IrcOptions>>(Options.Create(_options))
|
||||||
|
.BuildServiceProvider();
|
||||||
|
|
||||||
|
_gateway = new IrcGatewayService(
|
||||||
|
Options.Create(_options), services, NullLogger<IrcGatewayService>.Instance);
|
||||||
|
|
||||||
|
_broadcaster = new IrcBroadcaster(_gateway, _encryption);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Injects a test connection into the gateway's internal connection map.
|
||||||
|
/// </summary>
|
||||||
|
private IrcClientConnection AddConnection(string nickname, params string[] channels)
|
||||||
|
{
|
||||||
|
var (conn, _) = TestIrcConnectionFactory.CreateAuthenticated(nickname);
|
||||||
|
|
||||||
|
foreach (var ch in channels)
|
||||||
|
conn.JoinChannel(ch);
|
||||||
|
|
||||||
|
// Insert into gateway's ConcurrentDictionary via the public IReadOnlyDictionary
|
||||||
|
var connections = (ConcurrentDictionary<string, IrcClientConnection>)_gateway.Connections;
|
||||||
|
connections[conn.ConnectionId] = conn;
|
||||||
|
|
||||||
|
return conn;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> CaptureOutput(IrcClientConnection conn)
|
||||||
|
{
|
||||||
|
// We need to get the stream from the connection — but it's private.
|
||||||
|
// Since we used TestIrcConnectionFactory, the TestDuplexStream was passed to the constructor.
|
||||||
|
// We can't easily access it. Instead, we create connections differently for these tests.
|
||||||
|
// Let's use a different approach.
|
||||||
|
throw new NotSupportedException("Use AddConnectionWithCapture instead");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a connection that can capture output and injects it into the gateway.
|
||||||
|
/// </summary>
|
||||||
|
private (IrcClientConnection Connection, TestDuplexStream Stream) AddConnectionWithCapture(
|
||||||
|
string nickname, params string[] channels)
|
||||||
|
{
|
||||||
|
var (conn, stream) = TestIrcConnectionFactory.CreateAuthenticated(nickname);
|
||||||
|
|
||||||
|
foreach (var ch in channels)
|
||||||
|
conn.JoinChannel(ch);
|
||||||
|
|
||||||
|
var connections = (ConcurrentDictionary<string, IrcClientConnection>)_gateway.Connections;
|
||||||
|
connections[conn.ConnectionId] = conn;
|
||||||
|
|
||||||
|
return (conn, stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SendMessageToChannelAsync ────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendMessage_DecryptsContent()
|
||||||
|
{
|
||||||
|
var (_, stream) = AddConnectionWithCapture("bob", "general");
|
||||||
|
|
||||||
|
var encryptedContent = _encryption.Encrypt("Hello world!");
|
||||||
|
var message = new MessageDto(
|
||||||
|
Guid.NewGuid(), encryptedContent, "alice", null, "general",
|
||||||
|
MessageType.Text, null, null, DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
await _broadcaster.SendMessageToChannelAsync("general", message);
|
||||||
|
|
||||||
|
var output = stream.GetOutputLines();
|
||||||
|
Assert.Contains(output, l => l.Contains("Hello world!"));
|
||||||
|
Assert.DoesNotContain(output, l => l.Contains("$ENC$"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendMessage_SkipsSender()
|
||||||
|
{
|
||||||
|
var (_, aliceStream) = AddConnectionWithCapture("alice", "general");
|
||||||
|
var (_, bobStream) = AddConnectionWithCapture("bob", "general");
|
||||||
|
|
||||||
|
var message = new MessageDto(
|
||||||
|
Guid.NewGuid(), _encryption.Encrypt("Hi"), "alice", null, "general",
|
||||||
|
MessageType.Text, null, null, DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
await _broadcaster.SendMessageToChannelAsync("general", message);
|
||||||
|
|
||||||
|
// Alice (sender) should NOT receive the message
|
||||||
|
Assert.Empty(aliceStream.GetOutputLines());
|
||||||
|
|
||||||
|
// Bob should receive it
|
||||||
|
Assert.NotEmpty(bobStream.GetOutputLines());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendMessage_OnlySendsToChannelMembers()
|
||||||
|
{
|
||||||
|
var (_, generalStream) = AddConnectionWithCapture("bob", "general");
|
||||||
|
var (_, randomStream) = AddConnectionWithCapture("charlie", "random");
|
||||||
|
|
||||||
|
var message = new MessageDto(
|
||||||
|
Guid.NewGuid(), _encryption.Encrypt("Hi"), "alice", null, "general",
|
||||||
|
MessageType.Text, null, null, DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
await _broadcaster.SendMessageToChannelAsync("general", message);
|
||||||
|
|
||||||
|
Assert.NotEmpty(generalStream.GetOutputLines());
|
||||||
|
Assert.Empty(randomStream.GetOutputLines());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SendUserJoinedAsync ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendUserJoined_NotifiesOtherMembers()
|
||||||
|
{
|
||||||
|
var (_, bobStream) = AddConnectionWithCapture("bob", "general");
|
||||||
|
|
||||||
|
await _broadcaster.SendUserJoinedAsync("general", "alice");
|
||||||
|
|
||||||
|
var output = bobStream.GetOutputLines();
|
||||||
|
Assert.Contains(output, l => l.Contains("JOIN #general") && l.Contains("alice"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendUserJoined_ExcludesConnectionId()
|
||||||
|
{
|
||||||
|
var (conn, excludedStream) = AddConnectionWithCapture("alice", "general");
|
||||||
|
var (_, bobStream) = AddConnectionWithCapture("bob", "general");
|
||||||
|
|
||||||
|
await _broadcaster.SendUserJoinedAsync("general", "alice", conn.ConnectionId);
|
||||||
|
|
||||||
|
// Excluded connection should not get the message
|
||||||
|
Assert.Empty(excludedStream.GetOutputLines());
|
||||||
|
Assert.NotEmpty(bobStream.GetOutputLines());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SendUserLeftAsync ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendUserLeft_NotifiesOtherMembers()
|
||||||
|
{
|
||||||
|
var (_, bobStream) = AddConnectionWithCapture("bob", "general");
|
||||||
|
AddConnectionWithCapture("alice", "general");
|
||||||
|
|
||||||
|
await _broadcaster.SendUserLeftAsync("general", "alice");
|
||||||
|
|
||||||
|
var output = bobStream.GetOutputLines();
|
||||||
|
Assert.Contains(output, l => l.Contains("PART #general") && l.Contains("alice"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendUserLeft_SkipsSender()
|
||||||
|
{
|
||||||
|
var (_, aliceStream) = AddConnectionWithCapture("alice", "general");
|
||||||
|
|
||||||
|
await _broadcaster.SendUserLeftAsync("general", "alice");
|
||||||
|
|
||||||
|
Assert.Empty(aliceStream.GetOutputLines());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SendChannelUpdatedAsync ──────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendChannelUpdated_WithTopic_SendsTopicMessage()
|
||||||
|
{
|
||||||
|
var (_, stream) = AddConnectionWithCapture("bob", "general");
|
||||||
|
|
||||||
|
var channel = new ChannelDto(
|
||||||
|
Guid.NewGuid(), "general", "New topic!", true, 0, DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
await _broadcaster.SendChannelUpdatedAsync(channel, "general");
|
||||||
|
|
||||||
|
var output = stream.GetOutputLines();
|
||||||
|
Assert.Contains(output, l => l.Contains("TOPIC #general") && l.Contains("New topic!"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendChannelUpdated_NullTopic_DoesNotSend()
|
||||||
|
{
|
||||||
|
var (_, stream) = AddConnectionWithCapture("bob", "general");
|
||||||
|
|
||||||
|
var channel = new ChannelDto(
|
||||||
|
Guid.NewGuid(), "general", null, true, 0, DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
await _broadcaster.SendChannelUpdatedAsync(channel, "general");
|
||||||
|
|
||||||
|
Assert.Empty(stream.GetOutputLines());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SendErrorAsync ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendError_IrcConnection_SendsNotice()
|
||||||
|
{
|
||||||
|
var (conn, stream) = AddConnectionWithCapture("alice", "general");
|
||||||
|
|
||||||
|
await _broadcaster.SendErrorAsync(conn.ConnectionId, "Something went wrong");
|
||||||
|
|
||||||
|
var output = stream.GetOutputLines();
|
||||||
|
Assert.Contains(output, l => l.Contains("NOTICE") && l.Contains("Something went wrong"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendError_NonIrcConnection_DoesNothing()
|
||||||
|
{
|
||||||
|
// SignalR connection IDs don't start with "irc-"
|
||||||
|
await _broadcaster.SendErrorAsync("signalr-connection-123", "Error");
|
||||||
|
// No crash, no output — the method silently returns
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SendUserKickedAsync ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendUserKicked_NotifiesChannel()
|
||||||
|
{
|
||||||
|
var (_, stream) = AddConnectionWithCapture("bob", "general");
|
||||||
|
|
||||||
|
await _broadcaster.SendUserKickedAsync("general", "alice", "Spam");
|
||||||
|
|
||||||
|
var output = stream.GetOutputLines();
|
||||||
|
Assert.Contains(output, l => l.Contains("KICK #general alice") && l.Contains("Spam"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SendUserBannedAsync ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendUserBanned_NotifiesBannedUser()
|
||||||
|
{
|
||||||
|
var (_, stream) = AddConnectionWithCapture("alice", "general");
|
||||||
|
|
||||||
|
await _broadcaster.SendUserBannedAsync("alice", "Repeated violations");
|
||||||
|
|
||||||
|
var output = stream.GetOutputLines();
|
||||||
|
Assert.Contains(output, l => l.Contains("NOTICE") && l.Contains("banned"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SendMessageDeletedAsync ──────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendMessageDeleted_NotifiesChannel()
|
||||||
|
{
|
||||||
|
var (_, stream) = AddConnectionWithCapture("bob", "general");
|
||||||
|
var msgId = Guid.NewGuid();
|
||||||
|
|
||||||
|
await _broadcaster.SendMessageDeletedAsync("general", msgId);
|
||||||
|
|
||||||
|
var output = stream.GetOutputLines();
|
||||||
|
Assert.Contains(output, l => l.Contains("deleted") && l.Contains(msgId.ToString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SendChannelNukedAsync ────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendChannelNuked_NotifiesChannel()
|
||||||
|
{
|
||||||
|
var (_, stream) = AddConnectionWithCapture("bob", "general");
|
||||||
|
|
||||||
|
await _broadcaster.SendChannelNukedAsync("general");
|
||||||
|
|
||||||
|
var output = stream.GetOutputLines();
|
||||||
|
Assert.Contains(output, l => l.Contains("cleared"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ForceDisconnectUserAsync ─────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ForceDisconnect_IrcConnection_SendsErrorAndCloses()
|
||||||
|
{
|
||||||
|
var (conn, stream) = AddConnectionWithCapture("alice", "general");
|
||||||
|
|
||||||
|
await _broadcaster.ForceDisconnectUserAsync([conn.ConnectionId], "Banned");
|
||||||
|
|
||||||
|
var output = stream.GetOutputLines();
|
||||||
|
Assert.Contains(output, l => l.Contains("ERROR") && l.Contains("Banned"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ForceDisconnect_NonIrcConnection_Ignores()
|
||||||
|
{
|
||||||
|
// Should not throw when given SignalR connection IDs
|
||||||
|
await _broadcaster.ForceDisconnectUserAsync(["signalr-abc", "signalr-def"], "Banned");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SendUserStatusChangedAsync ───────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendUserStatusChanged_IsNoOp()
|
||||||
|
{
|
||||||
|
var (_, stream) = AddConnectionWithCapture("bob", "general");
|
||||||
|
|
||||||
|
var presence = new UserPresenceDto(
|
||||||
|
"alice", null, null, UserStatus.Away, "brb", ServerRole.Member);
|
||||||
|
|
||||||
|
await _broadcaster.SendUserStatusChangedAsync(["general"], presence);
|
||||||
|
|
||||||
|
// IRC doesn't push status changes — clients use WHOIS/WHO
|
||||||
|
Assert.Empty(stream.GetOutputLines());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
using EchoHub.Server.Irc;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace EchoHub.Tests.Irc;
|
||||||
|
|
||||||
|
public class IrcClientConnectionTests
|
||||||
|
{
|
||||||
|
// ── Connection identity ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConnectionId_StartsWithIrcPrefix()
|
||||||
|
{
|
||||||
|
var (conn, _) = TestIrcConnectionFactory.Create();
|
||||||
|
Assert.StartsWith("irc-", conn.ConnectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConnectionId_IsUnique()
|
||||||
|
{
|
||||||
|
var (conn1, _) = TestIrcConnectionFactory.Create();
|
||||||
|
var (conn2, _) = TestIrcConnectionFactory.Create();
|
||||||
|
Assert.NotEqual(conn1.ConnectionId, conn2.ConnectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hostmask ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Hostmask_WithNicknameAndUsername_FormatsCorrectly()
|
||||||
|
{
|
||||||
|
var (conn, _) = TestIrcConnectionFactory.Create();
|
||||||
|
conn.Nickname = "alice";
|
||||||
|
conn.Username = "alice_user";
|
||||||
|
|
||||||
|
Assert.Equal("alice!alice_user@echohub", conn.Hostmask);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Hostmask_WithoutUsername_FallsBackToNickname()
|
||||||
|
{
|
||||||
|
var (conn, _) = TestIrcConnectionFactory.Create();
|
||||||
|
conn.Nickname = "alice";
|
||||||
|
|
||||||
|
Assert.Equal("alice!alice@echohub", conn.Hostmask);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── I/O ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadLineAsync_ReturnsInputLines()
|
||||||
|
{
|
||||||
|
var (conn, _) = TestIrcConnectionFactory.Create("PING", "PONG");
|
||||||
|
|
||||||
|
var line1 = await conn.ReadLineAsync(CancellationToken.None);
|
||||||
|
var line2 = await conn.ReadLineAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal("PING", line1);
|
||||||
|
Assert.Equal("PONG", line2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadLineAsync_EndOfStream_ReturnsNull()
|
||||||
|
{
|
||||||
|
var (conn, _) = TestIrcConnectionFactory.Create("PING");
|
||||||
|
|
||||||
|
await conn.ReadLineAsync(CancellationToken.None); // consume "PING"
|
||||||
|
var result = await conn.ReadLineAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendAsync_WritesToOutput()
|
||||||
|
{
|
||||||
|
var (conn, stream) = TestIrcConnectionFactory.Create();
|
||||||
|
|
||||||
|
await conn.SendAsync(":server 001 alice :Welcome!");
|
||||||
|
|
||||||
|
var output = stream.GetOutputLines();
|
||||||
|
Assert.Single(output);
|
||||||
|
Assert.Equal(":server 001 alice :Welcome!", output[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendAsync_MultipleLines_AllCaptured()
|
||||||
|
{
|
||||||
|
var (conn, stream) = TestIrcConnectionFactory.Create();
|
||||||
|
|
||||||
|
await conn.SendAsync("line1");
|
||||||
|
await conn.SendAsync("line2");
|
||||||
|
await conn.SendAsync("line3");
|
||||||
|
|
||||||
|
var output = stream.GetOutputLines();
|
||||||
|
Assert.Equal(3, output.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendNumericAsync_FormatsCorrectly()
|
||||||
|
{
|
||||||
|
var (conn, stream) = TestIrcConnectionFactory.Create();
|
||||||
|
conn.Nickname = "alice";
|
||||||
|
|
||||||
|
await conn.SendNumericAsync("testserver", "001", ":Welcome!");
|
||||||
|
|
||||||
|
var output = stream.GetOutputLines();
|
||||||
|
Assert.Single(output);
|
||||||
|
Assert.Equal(":testserver 001 alice :Welcome!", output[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendNumericAsync_NoNickname_UsesStar()
|
||||||
|
{
|
||||||
|
var (conn, stream) = TestIrcConnectionFactory.Create();
|
||||||
|
|
||||||
|
await conn.SendNumericAsync("testserver", "451", ":Not registered");
|
||||||
|
|
||||||
|
var output = stream.GetOutputLines();
|
||||||
|
Assert.Contains("*", output[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Thread-safe channel operations ───────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void JoinChannel_AddsChannel()
|
||||||
|
{
|
||||||
|
var (conn, _) = TestIrcConnectionFactory.Create();
|
||||||
|
conn.JoinChannel("general");
|
||||||
|
|
||||||
|
Assert.True(conn.IsInChannel("general"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LeaveChannel_RemovesChannel()
|
||||||
|
{
|
||||||
|
var (conn, _) = TestIrcConnectionFactory.Create();
|
||||||
|
conn.JoinChannel("general");
|
||||||
|
conn.LeaveChannel("general");
|
||||||
|
|
||||||
|
Assert.False(conn.IsInChannel("general"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsInChannel_CaseInsensitive()
|
||||||
|
{
|
||||||
|
var (conn, _) = TestIrcConnectionFactory.Create();
|
||||||
|
conn.JoinChannel("General");
|
||||||
|
|
||||||
|
Assert.True(conn.IsInChannel("general"));
|
||||||
|
Assert.True(conn.IsInChannel("GENERAL"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetJoinedChannels_ReturnsSnapshot()
|
||||||
|
{
|
||||||
|
var (conn, _) = TestIrcConnectionFactory.Create();
|
||||||
|
conn.JoinChannel("general");
|
||||||
|
conn.JoinChannel("random");
|
||||||
|
|
||||||
|
var channels = conn.GetJoinedChannels();
|
||||||
|
Assert.Equal(2, channels.Count);
|
||||||
|
Assert.Contains("general", channels);
|
||||||
|
Assert.Contains("random", channels);
|
||||||
|
|
||||||
|
// Modifying the returned list shouldn't affect the connection state
|
||||||
|
channels.Clear();
|
||||||
|
Assert.True(conn.IsInChannel("general"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task JoinedChannels_ConcurrentAccess_DoesNotThrow()
|
||||||
|
{
|
||||||
|
var (conn, _) = TestIrcConnectionFactory.Create();
|
||||||
|
|
||||||
|
// Simulate concurrent reads and writes (broadcaster reads while handler writes)
|
||||||
|
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
||||||
|
var writerTask = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
for (int i = 0; i < 1000 && !cts.IsCancellationRequested; i++)
|
||||||
|
{
|
||||||
|
conn.JoinChannel($"channel-{i}");
|
||||||
|
await Task.Yield();
|
||||||
|
if (i % 3 == 0) conn.LeaveChannel($"channel-{i}");
|
||||||
|
}
|
||||||
|
}, cts.Token);
|
||||||
|
|
||||||
|
var readerTask = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
for (int i = 0; i < 1000 && !cts.IsCancellationRequested; i++)
|
||||||
|
{
|
||||||
|
_ = conn.IsInChannel($"channel-{i}");
|
||||||
|
_ = conn.GetJoinedChannels();
|
||||||
|
await Task.Yield();
|
||||||
|
}
|
||||||
|
}, cts.Token);
|
||||||
|
|
||||||
|
// Should complete without exceptions
|
||||||
|
await Task.WhenAll(writerTask, readerTask);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Default state ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NewConnection_IsNotRegistered()
|
||||||
|
{
|
||||||
|
var (conn, _) = TestIrcConnectionFactory.Create();
|
||||||
|
|
||||||
|
Assert.False(conn.IsRegistered);
|
||||||
|
Assert.False(conn.IsAuthenticated);
|
||||||
|
Assert.Null(conn.Nickname);
|
||||||
|
Assert.Null(conn.Username);
|
||||||
|
Assert.Null(conn.UserId);
|
||||||
|
Assert.Null(conn.AwayMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user