mirror of
https://github.com/Stone-Red-Code/EchoHub.git
synced 2026-09-04 09:06:07 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc8e2fd7da | ||
|
|
35a162d013 | ||
|
|
de34fb43b9 | ||
|
|
3e03a77aeb | ||
|
|
5ef338bbf7 | ||
|
|
f96f70f09a | ||
|
|
b7d58dc10d | ||
|
|
7fadd08702 | ||
|
|
576dd4bc94 | ||
|
|
d4cf35e04e | ||
|
|
23abb9b0bb | ||
|
|
dcb8bbc05f | ||
|
|
4930aee615 | ||
|
|
2cd3bbb570 | ||
|
|
0d959e317e | ||
|
|
75491d65e2 | ||
|
|
1861fd00f9 | ||
|
|
9996a6be97 | ||
|
|
cf9c647c03 | ||
|
|
1fa4348700 | ||
|
|
0c7c620f24 | ||
|
|
56a962c1af | ||
|
|
a65b41fb7f | ||
|
|
ab9e09a609 | ||
|
|
1bdc6ec99e | ||
|
|
fb12848e9a | ||
|
|
7e78c57665 | ||
|
|
9d4979d4a7 | ||
|
|
c0d38015ff | ||
|
|
3bc1446d1c | ||
|
|
411042c397 | ||
|
|
50817ce6e9 | ||
|
|
9265a9f312 | ||
|
|
a860a8fbc3 | ||
|
|
ae06e7ae12 | ||
|
|
f49148a85a | ||
|
|
575115cdca | ||
|
|
ca9973b411 | ||
|
|
4c850fc3c9 | ||
|
|
2efe54e417 | ||
|
|
dfa1b21d32 | ||
|
|
981875c495 | ||
|
|
c90ed27194 | ||
|
|
8f46ea0f2c | ||
|
|
f20ce3d50f | ||
|
|
107152298e | ||
|
|
7a8b5f02d3 | ||
|
|
b508a5854a | ||
|
|
7167b40013 | ||
|
|
6965ba573e | ||
|
|
9f303b5311 | ||
|
|
8fc95e8189 | ||
|
|
5d51558c5c | ||
|
|
7181748d40 | ||
|
|
0dbb02d12a | ||
|
|
38b60e5626 | ||
|
|
246eb2b0bb | ||
|
|
d9a66749e7 | ||
|
|
8dfb1a4fb8 | ||
|
|
3a0ea0d321 | ||
|
|
baebc093f5 | ||
|
|
b4c3ebd254 | ||
|
|
257ac34224 | ||
|
|
159a28e890 | ||
|
|
328821cd14 | ||
|
|
46a30c6b80 | ||
|
|
8dfdc163bf | ||
|
|
13297fd017 | ||
|
|
0422066851 | ||
|
|
cf5ef80c08 | ||
|
|
c8e33c96ce | ||
|
|
2a6dbb4461 | ||
|
|
caba400f43 | ||
|
|
552fe6afa3 |
@@ -22,6 +22,12 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
# TEMPORARY: Terminal.Gui submodule's nuget.config breaks restore — remove until PR #4234 is merged
|
||||||
|
- name: Remove submodule NuGet config
|
||||||
|
run: rm -f src/Terminal.Gui/nuget.config
|
||||||
|
|
||||||
- name: Setup .NET 10
|
- name: Setup .NET 10
|
||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@v4
|
||||||
@@ -29,7 +35,7 @@ jobs:
|
|||||||
dotnet-version: '10.0.x'
|
dotnet-version: '10.0.x'
|
||||||
|
|
||||||
- name: Check formatting
|
- name: Check formatting
|
||||||
run: dotnet format src/EchoHub.slnx --verify-no-changes --verbosity diagnostic
|
run: dotnet format src/EchoHub.slnx --verify-no-changes --verbosity diagnostic --exclude src/Terminal.Gui/
|
||||||
|
|
||||||
build-and-test:
|
build-and-test:
|
||||||
name: Build & Test
|
name: Build & Test
|
||||||
@@ -38,6 +44,11 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
# TEMPORARY: Terminal.Gui submodule's nuget.config breaks restore — remove until PR #4234 is merged
|
||||||
|
- name: Remove submodule NuGet config
|
||||||
|
run: rm -f src/Terminal.Gui/nuget.config
|
||||||
|
|
||||||
- name: Check for src/ changes
|
- name: Check for src/ changes
|
||||||
id: changes
|
id: changes
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
# TEMPORARY: Terminal.Gui submodule's nuget.config breaks restore — remove until PR #4234 is merged
|
||||||
|
- name: Remove submodule NuGet config
|
||||||
|
run: rm -f src/Terminal.Gui/nuget.config
|
||||||
|
|
||||||
- name: Setup .NET 10
|
- name: Setup .NET 10
|
||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@v4
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
# TEMPORARY: Terminal.Gui submodule's nuget.config breaks restore — remove until PR #4234 is merged
|
||||||
|
- name: Remove submodule NuGet config
|
||||||
|
run: rm -f src/Terminal.Gui/nuget.config
|
||||||
|
|
||||||
- name: Check for src/ changes
|
- name: Check for src/ changes
|
||||||
id: changes
|
id: changes
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "src/Terminal.Gui"]
|
||||||
|
path = src/Terminal.Gui
|
||||||
|
url = https://github.com/HueByte/Terminal.Gui.git
|
||||||
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,6 +5,9 @@ Articles related to the EchoHub TUI client built with Terminal.Gui v2.
|
|||||||
## Topics
|
## Topics
|
||||||
|
|
||||||
- Terminal.Gui v2 patterns and conventions
|
- Terminal.Gui v2 patterns and conventions
|
||||||
- Theme system and customization
|
- Theme system and customization (including transparent theme)
|
||||||
- Command system reference
|
- Command system reference
|
||||||
- Configuration management
|
- Configuration and session persistence
|
||||||
|
- Audio playback and file downloads
|
||||||
|
- Automatic update checking
|
||||||
|
- [Notification sounds](../../articles/notification-sounds.md)
|
||||||
|
|||||||
@@ -5,5 +5,5 @@ Articles related to the EchoHub.Core shared library.
|
|||||||
## Topics
|
## Topics
|
||||||
|
|
||||||
- Data models and DTOs
|
- Data models and DTOs
|
||||||
- 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.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Notification Sounds
|
||||||
|
|
||||||
|
EchoHub can play a notification sound when someone @mentions you. This is **disabled by default** and must be enabled in your profile settings.
|
||||||
|
|
||||||
|
## Enabling Notifications
|
||||||
|
|
||||||
|
Open your profile (`/profile`) and check the **"Notification sound on @mention"** checkbox, then save. You can also adjust the **Volume** (0-100, default 30). All settings are persisted in `~/.echohub/config.json`.
|
||||||
|
|
||||||
|
## Customizing the Sound
|
||||||
|
|
||||||
|
The client ships with a default `Notification.mp3` in the `Assets` folder. To use your own notification sound, replace the file at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<app-directory>/Assets/Notification.mp3
|
||||||
|
```
|
||||||
|
|
||||||
|
The file must be a valid `.mp3` or `.wav` audio file. The replacement takes effect on the next app launch.
|
||||||
|
|
||||||
|
Alternatively, set a custom path in `~/.echohub/config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"notifications": {
|
||||||
|
"enabled": true,
|
||||||
|
"volume": 30,
|
||||||
|
"soundFile": "/path/to/your/sound.mp3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When `soundFile` is set, EchoHub uses that file instead of the bundled default.
|
||||||
|
|
||||||
|
## Disabling Notifications
|
||||||
|
|
||||||
|
Uncheck the option in your profile, or edit the config directly:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"notifications": {
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -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,10 @@ 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.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
|
||||||
- [v0.2.0](v0.2.0.md) - IRC Gateway
|
- [v0.2.0](v0.2.0.md) - IRC Gateway
|
||||||
- [v0.1.1](v0.1.1.md) - Directory Connection Self-Healing
|
- [v0.1.1](v0.1.1.md) - Directory Connection Self-Healing
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
- name: Overview
|
- name: Overview
|
||||||
href: index.md
|
href: index.md
|
||||||
|
- name: v0.2.5
|
||||||
|
href: v0.2.5.md
|
||||||
|
- name: v0.2.4
|
||||||
|
href: v0.2.4.md
|
||||||
|
- name: v0.2.3
|
||||||
|
href: v0.2.3.md
|
||||||
|
- name: v0.2.2
|
||||||
|
href: v0.2.2.md
|
||||||
- name: v0.2.1
|
- name: v0.2.1
|
||||||
href: v0.2.1.md
|
href: v0.2.1.md
|
||||||
- name: v0.2.0
|
- name: v0.2.0
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# v0.2.2 - Startup & Shutdown Fixes
|
||||||
|
|
||||||
|
## Fixes
|
||||||
|
|
||||||
|
- Fixed server hanging on startup when IRC gateway is enabled — circular DI dependency between `IrcGatewayService` → `IChatService` → `IChatBroadcaster` → `IrcBroadcaster` caused the DI container to deadlock
|
||||||
|
- Fixed `SignalRBroadcaster` eagerly resolving `IHubContext<ChatHub>` during DI construction, which could deadlock on some platforms — now lazy-resolves via `IServiceProvider` on first use
|
||||||
|
- Simplified IRC service registration to use standard `AddSingleton<IChatBroadcaster, IrcBroadcaster>` instead of manual factory, breaking the circular resolution chain
|
||||||
|
- Fixed server hanging on Ctrl+C — replaced `await using` with explicit dispose bounded to 3 seconds, so a stuck `HubConnection` can no longer block shutdown
|
||||||
|
- Reduced host shutdown timeout from 30s (default) to 5s
|
||||||
|
- Caught `OperationCanceledException` in the directory service reconnect loop so cancellation exits immediately instead of propagating through dispose
|
||||||
|
|
||||||
|
## Refactoring
|
||||||
|
|
||||||
|
- Replaced primary constructors with standard constructor injection across all server classes for consistency
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# v0.2.3 - Moderation, Embeds & UI Overhaul
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Moderation System
|
||||||
|
|
||||||
|
- Added server roles: Owner, Admin, Mod, Member — first registered user is automatically Owner
|
||||||
|
- New `/kick`, `/ban`, `/unban`, `/mute`, `/unmute`, `/role`, `/nuke` commands for moderators and admins
|
||||||
|
- `ModerationController` with full REST API for role assignment, kicks, bans, mutes, message deletion, and channel nuking
|
||||||
|
- Mutes support optional duration (auto-expire) and blocked users cannot log in
|
||||||
|
- Role claim included in JWT tokens; role badges shown in the online users panel
|
||||||
|
- Kicked and banned users are forcibly disconnected in real time — server cleans up presence, broadcasts departures, and signals client disconnect
|
||||||
|
- Works for both SignalR and IRC connections; client shows an error dialog with the reason
|
||||||
|
|
||||||
|
### Private Channels
|
||||||
|
|
||||||
|
- Channels can be created as public or private via a checkbox in the Create Channel dialog
|
||||||
|
- Public channels are visible to all users; private channels only appear for members who joined them
|
||||||
|
- Persistent channel membership tracked in the database (`ChannelMembership` table)
|
||||||
|
- `GET /api/channels` returns the combined list: public channels + user's joined private channels
|
||||||
|
- Channel creators are automatically added as members
|
||||||
|
|
||||||
|
### OpenGraph Link Embeds
|
||||||
|
|
||||||
|
- Messages containing URLs now show a rich preview below the message text
|
||||||
|
- Multiple URLs per message supported (up to 3) — each gets its own embed
|
||||||
|
- Server-side fetching: detects URLs in a message, fetches each page, and parses OpenGraph meta tags (`og:title`, `og:description`, `og:site_name`)
|
||||||
|
- Embeds are persisted in the database as a JSON array and included in channel history
|
||||||
|
- TUI client renders embeds with a `▏` left border bar — site name and border in blue, title in white, description in gray; text word-wraps at actual viewport width
|
||||||
|
- IRC gateway receives a text-only embed preview (site name, title, description)
|
||||||
|
- Falls back to `<title>` tag when no OG tags are present; gracefully skips if no useful metadata is found
|
||||||
|
- 5-second fetch timeout ensures message delivery is never significantly delayed
|
||||||
|
- SSRF protection rejects private/loopback IP addresses before fetching
|
||||||
|
|
||||||
|
### Notification Sounds
|
||||||
|
|
||||||
|
- Incoming messages play a notification sound when the terminal is not focused
|
||||||
|
- Embedded MP3 asset with cross-platform playback support
|
||||||
|
|
||||||
|
### Online Users Panel
|
||||||
|
|
||||||
|
- Collapsible right-side panel showing online users in the current channel (toggle with F2)
|
||||||
|
- Users displayed with status indicators, role badges, and their custom nickname colors
|
||||||
|
- Panel updates on join, leave, and status change events
|
||||||
|
|
||||||
|
### @mention Highlighting
|
||||||
|
|
||||||
|
- `@username` text rendered in orange accent color in all messages
|
||||||
|
- Messages mentioning the current user get a full-line amber background highlight
|
||||||
|
- Works across multi-line messages and continuation lines
|
||||||
|
|
||||||
|
### ASCII Art Improvements
|
||||||
|
|
||||||
|
- Half-block character rendering (`▀`/`█`) with separate foreground + background colors for 2x vertical resolution
|
||||||
|
- Switched from ANSI escape codes to printable color tags (`{F:RRGGBB}`, `{B:RRGGBB}`, `{X}`) — no control bytes in stored content
|
||||||
|
- Optional size parameter for `/send` command: `-s` (40x40), `-m` (80x80, default), `-l` (120x120)
|
||||||
|
- IRC gateway converts color tags back to ANSI for IRC client compatibility
|
||||||
|
|
||||||
|
### Client UI
|
||||||
|
|
||||||
|
- Version number shown in the status bar
|
||||||
|
- Custom colored rendering for channel list (active indicator, unread count badges)
|
||||||
|
- Avatar upload field added to the profile edit dialog (file path or URL)
|
||||||
|
- Profile avatar now renders with full color tag support in the profile view dialog
|
||||||
|
- Update check notification on connect — shows a system message if a newer GitHub release exists
|
||||||
|
- Chat messages no longer show selection/focus highlight
|
||||||
|
- Exit shortcut changed from Ctrl+C to Alt+Q — frees Ctrl+C for copy
|
||||||
|
- Default history increased from 50 to 100 messages on channel join
|
||||||
|
|
||||||
|
## Fixes
|
||||||
|
|
||||||
|
- Fixed `#general` channel not visible after adding the `IsPublic` column — migration default changed to `true` and startup service ensures it
|
||||||
|
- Fixed channels disappearing when creating a new channel — replaced full re-fetch with incremental updates
|
||||||
|
- Wired `OnChannelUpdated` SignalR event so new public channels appear for all connected users in real time
|
||||||
|
- Fixed color tag parser using wrong regex group numbers (6,7,8 instead of 1,2,3) — new ASCII art was rendering without colors
|
||||||
|
- Full Unicode/emoji support — renderers use Terminal.Gui v2 grapheme cluster API (`GraphemeHelper`, `AddStr`) for proper wide character handling
|
||||||
|
- Emoji-to-text shortcode conversion for consistent cross-platform rendering
|
||||||
|
- Fixed `/send` and `/avatar` commands not handling file paths with spaces correctly, even when quoted
|
||||||
|
- Server-side newline spam protection — consecutive blank/whitespace-only lines collapsed to 1 and total lines capped at 30
|
||||||
|
- Fixed OG tag regex truncating descriptions containing apostrophes (e.g. `"HueByte's portfolio"` was cut to `"HueByte"`) — switched to backreference-based quote pairing
|
||||||
|
|
||||||
|
## Infrastructure
|
||||||
|
|
||||||
|
- New `LinkEmbedService` on the server — URL detection, HTML fetching (first 64KB), OG tag parsing via compiled regex
|
||||||
|
- `EmbedDto` record added to shared Core DTOs; `MessageDto.Embeds` list for multiple embeds per message
|
||||||
|
- `EmbedJson` nullable column on the `Message` table stores serialized embed data as JSON array (max 8KB); `DataMigrationService` auto-migrates old single-object format
|
||||||
|
- Dedicated `"OgFetch"` named HttpClient with bot User-Agent header and 5-second timeout
|
||||||
|
- `PresenceTracker.ForceRemoveUser()` for atomic user cleanup on kick/ban
|
||||||
|
- `IChatBroadcaster.ForceDisconnectUserAsync()` and `IEchoHubClient.ForceDisconnect` for force-disconnect signaling
|
||||||
|
- `NotificationSoundService` for cross-platform audio playback of embedded notification sounds
|
||||||
|
- Startup `DataMigrationService` automatically converts old ANSI-format messages to the new color tag format on server boot, logging the count of migrated records
|
||||||
|
- `EmojiHelper` utility for emoji-to-shortcode conversion
|
||||||
|
- Heartbeat handling in `ServerDirectoryService` for connection health checks
|
||||||
|
- Four new EF Core migrations: `AddModerationRoles`, `AddChannelIsPublic`, `AddChannelMembership`, `AddMessageEmbed`
|
||||||
|
- `ChannelMembership` table with cascade delete on both channel and user removal
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# v0.2.4 - E2E Message Encryption
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### E2E Message Encryption
|
||||||
|
|
||||||
|
- Application-layer AES-256-GCM encryption for all message content between client and server
|
||||||
|
- Protects against ISPs, proxies, and any middleman reading chat messages — even if TLS is compromised
|
||||||
|
- 256-bit encryption key auto-generated on first server startup and saved to `appsettings.json`
|
||||||
|
- Client fetches the encryption key automatically after login via `GET /api/server/encryption-key`
|
||||||
|
- Client encrypts messages before sending via SignalR; server decrypts for validation and processing
|
||||||
|
- Server broadcasts encrypted content to SignalR clients; client decrypts transparently — no user action required
|
||||||
|
- Optional database encryption at rest via `Encryption:EncryptDatabase` setting (disabled by default)
|
||||||
|
- When enabled: new messages encrypted before DB storage (existing plaintext messages are not retroactively encrypted)
|
||||||
|
- When disabled: messages stored as plaintext, no risk of data loss from key rotation
|
||||||
|
- Reads handle mixed content (encrypted + plaintext) regardless of setting — safe to toggle at any time
|
||||||
|
- Key rotation is safe: old plaintext stays readable, new messages use the new key
|
||||||
|
- IRC gateway automatically decrypts messages before forwarding to IRC clients (plaintext over IRC)
|
||||||
|
- Encrypted content format: `$ENC$v1${nonce}${ciphertext+tag}` (Base64, 12-byte nonce, 16-byte auth tag)
|
||||||
|
- Server strips `$ENC$` prefix from user-typed messages to prevent format spoofing
|
||||||
|
- Graceful fallback: if decryption fails, shows `[encrypted message — decryption failed, try re-logging to fetch the latest key]`
|
||||||
|
|
||||||
|
## Infrastructure
|
||||||
|
|
||||||
|
- `IMessageEncryptionService` interface in Core; `MessageEncryptionService` server implementation and `ClientEncryptionService` client implementation
|
||||||
|
- `EncryptionKeyResponse` DTO and `GET /api/server/encryption-key` endpoint (authenticated, rate-limited)
|
||||||
|
- `FirstRunSetup.EnsureEncryptionKey()` auto-generates AES-256 key on first server run
|
||||||
|
- `Encryption:EncryptDatabase` server setting (default `false`) controls whether messages are encrypted at rest
|
||||||
|
- DB column max lengths increased for encrypted content: `Message.Content` 2000 → 16000, `Message.EmbedJson` 8000 → 32000
|
||||||
|
- EF Core migration: `AddEncryptionSupport`
|
||||||
|
- Encryption test suite: server-side, client-side, and cross-compatibility tests
|
||||||
|
- Documentation article: `docs/articles/encryption.md`
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# 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.Transparent` (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)
|
||||||
|
|
||||||
|
## 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
|
||||||
|
- `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
|
||||||
Binary file not shown.
@@ -0,0 +1,52 @@
|
|||||||
|
; Script generated by the Inno Setup Script Wizard.
|
||||||
|
; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES!
|
||||||
|
|
||||||
|
#expr Exec('cmd.exe', '/C dotnet build -o "' + SourcePath + '\publish" -c Release ' + SourcePath + '..\src\EchoHub.Client\')
|
||||||
|
|
||||||
|
#define MyAppName "EchoHub"
|
||||||
|
#define MyAppVersion GetStringFileInfo("/publish/EchoHub.Client.exe","ProductVersion")
|
||||||
|
#define MyAppPublisher "Hue"
|
||||||
|
#define MyAppExeName "EchoHub.Client.exe"
|
||||||
|
|
||||||
|
[Setup]
|
||||||
|
; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications.
|
||||||
|
; (To generate a new GUID, click Tools | Generate GUID inside the IDE.)
|
||||||
|
AppId=c95b1292-3022-4c62-a131-4d46ace370f5
|
||||||
|
AppName={#MyAppName}
|
||||||
|
AppVersion={#MyAppVersion}
|
||||||
|
;AppVerName={#MyAppName} {#MyAppVersion}
|
||||||
|
AppPublisher={#MyAppPublisher}
|
||||||
|
DefaultDirName={autopf}\{#MyAppName}
|
||||||
|
DisableProgramGroupPage=yes
|
||||||
|
SetupIconFile=../assets/hue_icon.ico
|
||||||
|
; The [Icons] "quicklaunchicon" entry uses {userappdata} but its [Tasks] entry has a proper IsAdminInstallMode Check.
|
||||||
|
UsedUserAreasWarning=no
|
||||||
|
; Remove the following line to run in administrative install mode (install for all users.)
|
||||||
|
PrivilegesRequired=lowest
|
||||||
|
PrivilegesRequiredOverridesAllowed=dialog
|
||||||
|
OutputBaseFilename={#MyAppName}-Installer
|
||||||
|
OutputDir=.
|
||||||
|
Compression=lzma
|
||||||
|
SolidCompression=yes
|
||||||
|
WizardStyle=modern
|
||||||
|
|
||||||
|
[Languages]
|
||||||
|
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||||
|
Name: "german"; MessagesFile: "compiler:Languages\German.isl"
|
||||||
|
|
||||||
|
[Tasks]
|
||||||
|
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
|
||||||
|
Name: "quicklaunchicon"; Description: "{cm:CreateQuickLaunchIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked; OnlyBelowVersion: 6.1; Check: not IsAdminInstallMode
|
||||||
|
|
||||||
|
[Files]
|
||||||
|
Source: "publish\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs
|
||||||
|
; NOTE: Don't use "Flags: ignoreversion" on any shared system files
|
||||||
|
|
||||||
|
[Icons]
|
||||||
|
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
|
||||||
|
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
|
||||||
|
Name: "{userappdata}\Microsoft\Internet Explorer\Quick Launch\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: quicklaunchicon
|
||||||
|
|
||||||
|
[Run]
|
||||||
|
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
|
||||||
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<configuration>
|
||||||
|
<packageSources>
|
||||||
|
<clear />
|
||||||
|
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
|
||||||
|
</packageSources>
|
||||||
|
<packageSourceMapping>
|
||||||
|
<packageSource key="nuget.org">
|
||||||
|
<package pattern="*" />
|
||||||
|
</packageSource>
|
||||||
|
</packageSourceMapping>
|
||||||
|
</configuration>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>0.2.1</Version>
|
<Version>0.2.5</Version>
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -21,9 +21,13 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
private readonly IApplication _app;
|
private readonly IApplication _app;
|
||||||
private readonly MainWindow _mainWindow;
|
private readonly MainWindow _mainWindow;
|
||||||
private readonly CommandHandler _commandHandler;
|
private readonly CommandHandler _commandHandler;
|
||||||
|
private readonly NotificationSoundService _notificationSound;
|
||||||
|
private readonly AudioPlaybackService _audioPlayback = new();
|
||||||
|
private readonly UpdateChecker _updateService;
|
||||||
|
|
||||||
private EchoHubConnection? _connection;
|
private EchoHubConnection? _connection;
|
||||||
private ApiClient? _apiClient;
|
private ApiClient? _apiClient;
|
||||||
|
private readonly ClientEncryptionService _encryption = new();
|
||||||
private ClientConfig _config;
|
private ClientConfig _config;
|
||||||
private UserStatus _currentStatus = UserStatus.Online;
|
private UserStatus _currentStatus = UserStatus.Online;
|
||||||
private string? _currentStatusMessage;
|
private string? _currentStatusMessage;
|
||||||
@@ -41,10 +45,14 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
_config = config;
|
_config = config;
|
||||||
_mainWindow = new MainWindow(app);
|
_mainWindow = new MainWindow(app);
|
||||||
_commandHandler = new CommandHandler();
|
_commandHandler = new CommandHandler();
|
||||||
|
_notificationSound = new NotificationSoundService(config.Notifications);
|
||||||
|
_updateService = new UpdateChecker(app);
|
||||||
|
|
||||||
WireMainWindowEvents();
|
WireMainWindowEvents();
|
||||||
WireCommandHandlerEvents();
|
WireCommandHandlerEvents();
|
||||||
|
|
||||||
|
_updateService.Start();
|
||||||
|
|
||||||
_mainWindow.UpdateStatusBar("Disconnected");
|
_mainWindow.UpdateStatusBar("Disconnected");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +60,7 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
{
|
{
|
||||||
_connection?.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
_connection?.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||||
_apiClient?.Dispose();
|
_apiClient?.Dispose();
|
||||||
|
_updateService.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Convenience Helpers ────────────────────────────────────────────────
|
// ── Convenience Helpers ────────────────────────────────────────────────
|
||||||
@@ -69,6 +78,7 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
{
|
{
|
||||||
_mainWindow.OnConnectRequested += HandleConnect;
|
_mainWindow.OnConnectRequested += HandleConnect;
|
||||||
_mainWindow.OnDisconnectRequested += HandleDisconnect;
|
_mainWindow.OnDisconnectRequested += HandleDisconnect;
|
||||||
|
_mainWindow.OnLogoutRequested += HandleLogout;
|
||||||
_mainWindow.OnMessageSubmitted += HandleMessageSubmitted;
|
_mainWindow.OnMessageSubmitted += HandleMessageSubmitted;
|
||||||
_mainWindow.OnChannelSelected += HandleChannelSelected;
|
_mainWindow.OnChannelSelected += HandleChannelSelected;
|
||||||
_mainWindow.OnProfileRequested += HandleProfileRequested;
|
_mainWindow.OnProfileRequested += HandleProfileRequested;
|
||||||
@@ -76,6 +86,9 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
_mainWindow.OnThemeSelected += HandleThemeSelected;
|
_mainWindow.OnThemeSelected += HandleThemeSelected;
|
||||||
_mainWindow.OnSavedServersRequested += HandleSavedServersRequested;
|
_mainWindow.OnSavedServersRequested += HandleSavedServersRequested;
|
||||||
_mainWindow.OnCreateChannelRequested += HandleCreateChannelRequested;
|
_mainWindow.OnCreateChannelRequested += HandleCreateChannelRequested;
|
||||||
|
_mainWindow.OnDeleteChannelRequested += HandleDeleteChannelRequested;
|
||||||
|
_mainWindow.OnAudioPlayRequested += HandleAudioPlayRequested;
|
||||||
|
_mainWindow.OnFileDownloadRequested += HandleFileDownloadRequested;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Command Handler Wiring ─────────────────────────────────────────────
|
// ── Command Handler Wiring ─────────────────────────────────────────────
|
||||||
@@ -116,7 +129,7 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
};
|
};
|
||||||
|
|
||||||
_commandHandler.OnSendFile += async (target) =>
|
_commandHandler.OnSendFile += async (target, size) =>
|
||||||
{
|
{
|
||||||
if (!IsAuthenticated || !IsConnected) return;
|
if (!IsAuthenticated || !IsConnected) return;
|
||||||
|
|
||||||
@@ -128,13 +141,13 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
if (Uri.TryCreate(target, UriKind.Absolute, out var uri)
|
if (Uri.TryCreate(target, UriKind.Absolute, out var uri)
|
||||||
&& (uri.Scheme == "http" || uri.Scheme == "https"))
|
&& (uri.Scheme == "http" || uri.Scheme == "https"))
|
||||||
{
|
{
|
||||||
await _apiClient!.SendUrlAsync(channel, target);
|
await _apiClient!.SendUrlAsync(channel, target, size);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
await using var stream = File.OpenRead(target);
|
await using var stream = File.OpenRead(target);
|
||||||
var fileName = Path.GetFileName(target);
|
var fileName = Path.GetFileName(target);
|
||||||
await _apiClient!.UploadFileAsync(channel, stream, fileName);
|
await _apiClient!.UploadFileAsync(channel, stream, fileName, size);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -211,6 +224,8 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
var history = await _connection!.JoinChannelAsync(channelName);
|
var history = await _connection!.JoinChannelAsync(channelName);
|
||||||
InvokeUI(() =>
|
InvokeUI(() =>
|
||||||
{
|
{
|
||||||
|
// Add to channel list if not already there (e.g. private channels)
|
||||||
|
_mainWindow.EnsureChannelInList(channelName);
|
||||||
_mainWindow.SwitchToChannel(channelName);
|
_mainWindow.SwitchToChannel(channelName);
|
||||||
if (history.Count > 0)
|
if (history.Count > 0)
|
||||||
_mainWindow.LoadHistory(channelName, history);
|
_mainWindow.LoadHistory(channelName, history);
|
||||||
@@ -229,6 +244,12 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
var channel = _mainWindow.CurrentChannel;
|
var channel = _mainWindow.CurrentChannel;
|
||||||
if (string.IsNullOrEmpty(channel)) return;
|
if (string.IsNullOrEmpty(channel)) return;
|
||||||
|
|
||||||
|
if (channel == HubConstants.DefaultChannel)
|
||||||
|
{
|
||||||
|
InvokeUI(() => _mainWindow.ShowError($"You cannot leave the #{HubConstants.DefaultChannel} channel."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _connection!.LeaveChannelAsync(channel);
|
await _connection!.LeaveChannelAsync(channel);
|
||||||
@@ -292,6 +313,61 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
_commandHandler.OnKickUser += async (username, reason) =>
|
||||||
|
{
|
||||||
|
if (!IsAuthenticated) return;
|
||||||
|
await _apiClient!.KickUserAsync(username, reason);
|
||||||
|
};
|
||||||
|
|
||||||
|
_commandHandler.OnBanUser += async (username, reason) =>
|
||||||
|
{
|
||||||
|
if (!IsAuthenticated) return;
|
||||||
|
await _apiClient!.BanUserAsync(username, reason);
|
||||||
|
};
|
||||||
|
|
||||||
|
_commandHandler.OnUnbanUser += async (username) =>
|
||||||
|
{
|
||||||
|
if (!IsAuthenticated) return;
|
||||||
|
await _apiClient!.UnbanUserAsync(username);
|
||||||
|
};
|
||||||
|
|
||||||
|
_commandHandler.OnMuteUser += async (username, duration) =>
|
||||||
|
{
|
||||||
|
if (!IsAuthenticated) return;
|
||||||
|
await _apiClient!.MuteUserAsync(username, duration);
|
||||||
|
};
|
||||||
|
|
||||||
|
_commandHandler.OnUnmuteUser += async (username) =>
|
||||||
|
{
|
||||||
|
if (!IsAuthenticated) return;
|
||||||
|
await _apiClient!.UnmuteUserAsync(username);
|
||||||
|
};
|
||||||
|
|
||||||
|
_commandHandler.OnAssignRole += async (username, roleStr) =>
|
||||||
|
{
|
||||||
|
if (!IsAuthenticated) return;
|
||||||
|
var role = roleStr switch
|
||||||
|
{
|
||||||
|
"admin" => ServerRole.Admin,
|
||||||
|
"mod" => ServerRole.Mod,
|
||||||
|
_ => ServerRole.Member,
|
||||||
|
};
|
||||||
|
await _apiClient!.AssignRoleAsync(username, role);
|
||||||
|
};
|
||||||
|
|
||||||
|
_commandHandler.OnNukeChannel += async () =>
|
||||||
|
{
|
||||||
|
if (!IsAuthenticated) return;
|
||||||
|
var channel = _mainWindow.CurrentChannel;
|
||||||
|
if (string.IsNullOrEmpty(channel)) return;
|
||||||
|
await _apiClient!.NukeChannelAsync(channel);
|
||||||
|
};
|
||||||
|
|
||||||
|
_commandHandler.OnTestSound += async () =>
|
||||||
|
{
|
||||||
|
await _notificationSound.PlayTestAsync();
|
||||||
|
};
|
||||||
|
|
||||||
_commandHandler.OnQuit += () =>
|
_commandHandler.OnQuit += () =>
|
||||||
{
|
{
|
||||||
InvokeUI(() => _app.RequestStop());
|
InvokeUI(() => _app.RequestStop());
|
||||||
@@ -303,6 +379,16 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
|
|
||||||
private void HandleConnect()
|
private void HandleConnect()
|
||||||
{
|
{
|
||||||
|
if (IsConnected)
|
||||||
|
{
|
||||||
|
var confirm = MessageBox.Query(_app, "Already Connected",
|
||||||
|
"You are already connected to a server.\nDisconnect and connect to a new one?", "Yes", "Cancel");
|
||||||
|
|
||||||
|
if (confirm != 0) return;
|
||||||
|
|
||||||
|
HandleDisconnect();
|
||||||
|
}
|
||||||
|
|
||||||
var result = ConnectDialog.Show(_app, _config.SavedServers);
|
var result = ConnectDialog.Show(_app, _config.SavedServers);
|
||||||
if (result is null) return;
|
if (result is null) return;
|
||||||
|
|
||||||
@@ -316,12 +402,68 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
|
|
||||||
InvokeUI(() => _mainWindow.UpdateStatusBar("Authenticating..."));
|
InvokeUI(() => _mainWindow.UpdateStatusBar("Authenticating..."));
|
||||||
|
|
||||||
var loginResponse = result.IsRegister
|
LoginResponse loginResponse;
|
||||||
? await _apiClient.RegisterAsync(result.Username, result.Password)
|
|
||||||
: await _apiClient.LoginAsync(result.Username, result.Password);
|
if (result.SavedRefreshToken is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
loginResponse = await _apiClient.LoginWithRefreshTokenAsync(result.SavedRefreshToken);
|
||||||
|
Log.Information("Authenticated via saved session for {User}", loginResponse.Username);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Saved session expired or revoked");
|
||||||
|
ClearSavedToken(result.ServerUrl);
|
||||||
|
InvokeUI(() =>
|
||||||
|
{
|
||||||
|
_mainWindow.UpdateStatusBar("Disconnected");
|
||||||
|
MessageBox.ErrorQuery(_app, "Session Expired",
|
||||||
|
"Your saved session has expired or was revoked.\nPlease log in with your password.", "OK");
|
||||||
|
});
|
||||||
|
_apiClient.Dispose();
|
||||||
|
_apiClient = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (result.IsRegister)
|
||||||
|
{
|
||||||
|
loginResponse = await _apiClient.RegisterAsync(result.Username, result.Password);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
loginResponse = await _apiClient.LoginAsync(result.Username, result.Password);
|
||||||
|
}
|
||||||
|
|
||||||
_currentUsername = loginResponse.Username;
|
_currentUsername = loginResponse.Username;
|
||||||
|
|
||||||
|
// Persist rotated refresh tokens for Remember Me
|
||||||
|
_apiClient.OnTokensRefreshed += () =>
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch encryption key for E2E message encryption
|
||||||
|
InvokeUI(() => _mainWindow.UpdateStatusBar("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");
|
||||||
|
}
|
||||||
|
|
||||||
InvokeUI(() =>
|
InvokeUI(() =>
|
||||||
{
|
{
|
||||||
_mainWindow.SetCurrentUser(loginResponse.DisplayName ?? loginResponse.Username);
|
_mainWindow.SetCurrentUser(loginResponse.DisplayName ?? loginResponse.Username);
|
||||||
@@ -331,7 +473,7 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
if (_connection is not null)
|
if (_connection is not null)
|
||||||
await _connection.DisposeAsync();
|
await _connection.DisposeAsync();
|
||||||
|
|
||||||
_connection = new EchoHubConnection(result.ServerUrl, _apiClient);
|
_connection = new EchoHubConnection(result.ServerUrl, _apiClient, _encryption);
|
||||||
WireConnectionEvents(_connection);
|
WireConnectionEvents(_connection);
|
||||||
await _connection.ConnectAsync();
|
await _connection.ConnectAsync();
|
||||||
|
|
||||||
@@ -361,6 +503,7 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
// History might not be available
|
// History might not be available
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FetchAndUpdateOnlineUsers();
|
||||||
SaveServerToConfig(result);
|
SaveServerToConfig(result);
|
||||||
}, "Connection failed", "Connect");
|
}, "Connection failed", "Connect");
|
||||||
}
|
}
|
||||||
@@ -390,6 +533,38 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
}, "Disconnect error", "Disconnect");
|
}, "Disconnect error", "Disconnect");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void HandleLogout()
|
||||||
|
{
|
||||||
|
Log.Information("Logging out from server");
|
||||||
|
|
||||||
|
RunAsync(async () =>
|
||||||
|
{
|
||||||
|
if (_apiClient is not null)
|
||||||
|
{
|
||||||
|
var baseUrl = _apiClient.BaseUrl;
|
||||||
|
await _apiClient.LogoutAsync();
|
||||||
|
ClearSavedToken(baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_connection is not null)
|
||||||
|
{
|
||||||
|
await _connection.DisconnectAsync();
|
||||||
|
await _connection.DisposeAsync();
|
||||||
|
_connection = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_apiClient?.Dispose();
|
||||||
|
_apiClient = null;
|
||||||
|
_joinedChannels.Clear();
|
||||||
|
|
||||||
|
InvokeUI(() =>
|
||||||
|
{
|
||||||
|
_mainWindow.ClearAll();
|
||||||
|
_mainWindow.UpdateStatusBar("Disconnected");
|
||||||
|
});
|
||||||
|
}, "Logout error", "Logout");
|
||||||
|
}
|
||||||
|
|
||||||
private void HandleMessageSubmitted(string channelName, string content)
|
private void HandleMessageSubmitted(string channelName, string content)
|
||||||
{
|
{
|
||||||
if (!IsConnected)
|
if (!IsConnected)
|
||||||
@@ -440,6 +615,8 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
{
|
{
|
||||||
// History might not be available
|
// History might not be available
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FetchAndUpdateOnlineUsers();
|
||||||
}, "Failed to join channel");
|
}, "Failed to join channel");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -504,7 +681,9 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
var editResult = ProfileEditDialog.Show(_app,
|
var editResult = ProfileEditDialog.Show(_app,
|
||||||
currentProfile?.DisplayName,
|
currentProfile?.DisplayName,
|
||||||
currentProfile?.Bio,
|
currentProfile?.Bio,
|
||||||
currentProfile?.NicknameColor);
|
currentProfile?.NicknameColor,
|
||||||
|
_config.Notifications.Enabled,
|
||||||
|
_config.Notifications.Volume);
|
||||||
|
|
||||||
if (editResult is null) return;
|
if (editResult is null) return;
|
||||||
|
|
||||||
@@ -526,6 +705,57 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Upload avatar if specified
|
||||||
|
if (editResult.AvatarPath is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Stream stream;
|
||||||
|
string fileName;
|
||||||
|
|
||||||
|
if (Uri.TryCreate(editResult.AvatarPath, 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
|
||||||
|
{
|
||||||
|
stream = File.OpenRead(editResult.AvatarPath);
|
||||||
|
fileName = Path.GetFileName(editResult.AvatarPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (stream)
|
||||||
|
{
|
||||||
|
await _apiClient!.UploadAvatarAsync(stream, fileName);
|
||||||
|
var channel = _mainWindow.CurrentChannel;
|
||||||
|
if (!string.IsNullOrEmpty(channel))
|
||||||
|
InvokeUI(() => _mainWindow.AddSystemMessage(channel, "Avatar updated."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "Avatar upload failed for {Target}", editResult.AvatarPath);
|
||||||
|
InvokeUI(() => _mainWindow.ShowError($"Avatar upload failed: {ex.Message}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editResult.NotificationSoundEnabled.HasValue)
|
||||||
|
{
|
||||||
|
_config.Notifications.Enabled = editResult.NotificationSoundEnabled.Value;
|
||||||
|
_notificationSound.SetEnabled(editResult.NotificationSoundEnabled.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editResult.NotificationVolume.HasValue)
|
||||||
|
{
|
||||||
|
_config.Notifications.Volume = editResult.NotificationVolume.Value;
|
||||||
|
_notificationSound.SetVolume(editResult.NotificationVolume.Value);
|
||||||
|
}
|
||||||
|
|
||||||
_config.DefaultPreset = new AccountPreset
|
_config.DefaultPreset = new AccountPreset
|
||||||
{
|
{
|
||||||
DisplayName = editResult.DisplayName,
|
DisplayName = editResult.DisplayName,
|
||||||
@@ -580,7 +810,11 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
var serverLines = _config.SavedServers
|
var serverLines = _config.SavedServers
|
||||||
.Select(s => $"{s.Name} ({s.Url}) - {s.Username ?? "?"} - {s.LastConnected:yyyy-MM-dd}")
|
.Select(s =>
|
||||||
|
{
|
||||||
|
var session = !string.IsNullOrEmpty(s.RefreshToken) ? " [session saved]" : "";
|
||||||
|
return $"{s.Name} ({s.Url}) - {s.Username ?? "?"} - {s.LastConnected:yyyy-MM-dd}{session}";
|
||||||
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
MessageBox.Query(_app, "Saved Servers", string.Join("\n", serverLines), "OK");
|
MessageBox.Query(_app, "Saved Servers", string.Join("\n", serverLines), "OK");
|
||||||
@@ -599,17 +833,16 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
|
|
||||||
RunAsync(async () =>
|
RunAsync(async () =>
|
||||||
{
|
{
|
||||||
var channel = await _apiClient!.CreateChannelAsync(result.Name, result.Topic);
|
var channel = await _apiClient!.CreateChannelAsync(result.Name, result.Topic, result.IsPublic);
|
||||||
if (channel is null) return;
|
if (channel is null) return;
|
||||||
|
|
||||||
_joinedChannels.Add(channel.Name);
|
_joinedChannels.Add(channel.Name);
|
||||||
var history = await _connection!.JoinChannelAsync(channel.Name);
|
var history = await _connection!.JoinChannelAsync(channel.Name);
|
||||||
|
|
||||||
// Refresh the channel list
|
|
||||||
var channels = await _apiClient.GetChannelsAsync();
|
|
||||||
InvokeUI(() =>
|
InvokeUI(() =>
|
||||||
{
|
{
|
||||||
_mainWindow.SetChannels(channels);
|
_mainWindow.EnsureChannelInList(channel.Name);
|
||||||
|
_mainWindow.SetChannelTopic(channel.Name, channel.Topic);
|
||||||
_mainWindow.SwitchToChannel(channel.Name);
|
_mainWindow.SwitchToChannel(channel.Name);
|
||||||
if (history.Count > 0)
|
if (history.Count > 0)
|
||||||
_mainWindow.LoadHistory(channel.Name, history);
|
_mainWindow.LoadHistory(channel.Name, history);
|
||||||
@@ -617,18 +850,108 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
}, "Failed to create channel");
|
}, "Failed to create channel");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void HandleDeleteChannelRequested()
|
||||||
|
{
|
||||||
|
if (!IsAuthenticated || !IsConnected)
|
||||||
|
{
|
||||||
|
_mainWindow.ShowError("Not connected to a server.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var channel = _mainWindow.CurrentChannel;
|
||||||
|
if (string.IsNullOrEmpty(channel))
|
||||||
|
{
|
||||||
|
_mainWindow.ShowError("No channel selected.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channel == HubConstants.DefaultChannel)
|
||||||
|
{
|
||||||
|
_mainWindow.ShowError($"The #{HubConstants.DefaultChannel} channel cannot be deleted.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var confirm = MessageBox.Query(_app, "Delete Channel",
|
||||||
|
$"Are you sure you want to delete #{channel}?\nThis will remove all messages permanently.", "Delete", "Cancel");
|
||||||
|
|
||||||
|
if (confirm != 0) return;
|
||||||
|
|
||||||
|
RunAsync(async () =>
|
||||||
|
{
|
||||||
|
await _apiClient!.DeleteChannelAsync(channel);
|
||||||
|
_joinedChannels.Remove(channel);
|
||||||
|
|
||||||
|
InvokeUI(() =>
|
||||||
|
{
|
||||||
|
_mainWindow.RemoveChannel(channel);
|
||||||
|
_mainWindow.SwitchToChannel(HubConstants.DefaultChannel);
|
||||||
|
_mainWindow.AddSystemMessage(HubConstants.DefaultChannel, $"Channel #{channel} has been deleted.");
|
||||||
|
});
|
||||||
|
}, "Failed to delete channel");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleAudioPlayRequested(string attachmentUrl, string fileName)
|
||||||
|
{
|
||||||
|
if (!IsAuthenticated) return;
|
||||||
|
|
||||||
|
RunAsync(async () =>
|
||||||
|
{
|
||||||
|
InvokeUI(() => _mainWindow.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}..."));
|
||||||
|
var tempPath = await _apiClient!.DownloadFileToTempAsync(attachmentUrl, fileName);
|
||||||
|
InvokeUI(() => AudioPlayerDialog.Show(_app, _audioPlayback, tempPath, fileName));
|
||||||
|
}, "Failed to play audio");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleFileDownloadRequested(string attachmentUrl, string fileName)
|
||||||
|
{
|
||||||
|
if (!IsAuthenticated) return;
|
||||||
|
|
||||||
|
RunAsync(async () =>
|
||||||
|
{
|
||||||
|
InvokeUI(() => _mainWindow.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}..."));
|
||||||
|
var tempPath = await _apiClient!.DownloadFileToTempAsync(attachmentUrl, fileName);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var psi = new System.Diagnostics.ProcessStartInfo(tempPath) { UseShellExecute = true };
|
||||||
|
System.Diagnostics.Process.Start(psi);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Failed to open file with default app: {Path}", tempPath);
|
||||||
|
InvokeUI(() => _mainWindow.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloaded to: {tempPath}"));
|
||||||
|
}
|
||||||
|
}, "Failed to download file");
|
||||||
|
}
|
||||||
|
|
||||||
// ── Connection Event Wiring ────────────────────────────────────────────
|
// ── Connection Event Wiring ────────────────────────────────────────────
|
||||||
|
|
||||||
private void WireConnectionEvents(EchoHubConnection connection)
|
private void WireConnectionEvents(EchoHubConnection connection)
|
||||||
{
|
{
|
||||||
connection.OnMessageReceived += message =>
|
connection.OnMessageReceived += message =>
|
||||||
|
{
|
||||||
InvokeUI(() => _mainWindow.AddMessage(message));
|
InvokeUI(() => _mainWindow.AddMessage(message));
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(_currentUsername)
|
||||||
|
&& message.Content.Contains($"@{_currentUsername}", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_ = _notificationSound.PlayAsync();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
connection.OnUserJoined += (channelName, username) =>
|
connection.OnUserJoined += (channelName, username) =>
|
||||||
|
{
|
||||||
InvokeUI(() => _mainWindow.AddSystemMessage(channelName, $"{username} joined the channel"));
|
InvokeUI(() => _mainWindow.AddSystemMessage(channelName, $"{username} joined the channel"));
|
||||||
|
if (channelName == _mainWindow.CurrentChannel)
|
||||||
|
FetchAndUpdateOnlineUsers();
|
||||||
|
};
|
||||||
|
|
||||||
connection.OnUserLeft += (channelName, username) =>
|
connection.OnUserLeft += (channelName, username) =>
|
||||||
|
{
|
||||||
InvokeUI(() => _mainWindow.AddSystemMessage(channelName, $"{username} left the channel"));
|
InvokeUI(() => _mainWindow.AddSystemMessage(channelName, $"{username} left the channel"));
|
||||||
|
if (channelName == _mainWindow.CurrentChannel)
|
||||||
|
FetchAndUpdateOnlineUsers();
|
||||||
|
};
|
||||||
|
|
||||||
connection.OnUserStatusChanged += presence =>
|
connection.OnUserStatusChanged += presence =>
|
||||||
{
|
{
|
||||||
@@ -642,6 +965,67 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
foreach (var channelName in _mainWindow.GetChannelNames())
|
foreach (var channelName in _mainWindow.GetChannelNames())
|
||||||
_mainWindow.AddStatusMessage(channelName, displayName, statusText);
|
_mainWindow.AddStatusMessage(channelName, displayName, statusText);
|
||||||
});
|
});
|
||||||
|
FetchAndUpdateOnlineUsers();
|
||||||
|
};
|
||||||
|
|
||||||
|
connection.OnUserKicked += (channelName, username, reason) =>
|
||||||
|
{
|
||||||
|
var reasonText = reason is not null ? $" ({reason})" : "";
|
||||||
|
InvokeUI(() =>
|
||||||
|
{
|
||||||
|
_mainWindow.AddSystemMessage(channelName, $"{username} was kicked{reasonText}");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
connection.OnUserBanned += (username, reason) =>
|
||||||
|
{
|
||||||
|
var reasonText = reason is not null ? $" ({reason})" : "";
|
||||||
|
InvokeUI(() =>
|
||||||
|
{
|
||||||
|
// Show ban notification for other users in the channel
|
||||||
|
if (!username.Equals(_currentUsername, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var channel = _mainWindow.CurrentChannel;
|
||||||
|
if (!string.IsNullOrEmpty(channel))
|
||||||
|
_mainWindow.AddSystemMessage(channel, $"{username} was banned{reasonText}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
connection.OnForceDisconnect += reason =>
|
||||||
|
{
|
||||||
|
InvokeUI(() =>
|
||||||
|
{
|
||||||
|
_mainWindow.ShowError(reason);
|
||||||
|
HandleDisconnect();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
connection.OnMessageDeleted += (channelName, messageId) =>
|
||||||
|
{
|
||||||
|
InvokeUI(() =>
|
||||||
|
{
|
||||||
|
_mainWindow.RemoveMessage(channelName, messageId);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
connection.OnChannelNuked += channelName =>
|
||||||
|
{
|
||||||
|
InvokeUI(() =>
|
||||||
|
{
|
||||||
|
_mainWindow.ClearChannelMessages(channelName);
|
||||||
|
_mainWindow.AddSystemMessage(channelName, "Channel history has been cleared by a moderator.");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
connection.OnChannelUpdated += channel =>
|
||||||
|
{
|
||||||
|
InvokeUI(() =>
|
||||||
|
{
|
||||||
|
if (channel.IsPublic)
|
||||||
|
_mainWindow.EnsureChannelInList(channel.Name, channel.IsPublic);
|
||||||
|
_mainWindow.SetChannelTopic(channel.Name, channel.Topic);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
connection.OnError += errorMessage =>
|
connection.OnError += errorMessage =>
|
||||||
@@ -673,6 +1057,25 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
|
|
||||||
// ── Private Helpers ────────────────────────────────────────────────────
|
// ── Private Helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void FetchAndUpdateOnlineUsers()
|
||||||
|
{
|
||||||
|
var channel = _mainWindow.CurrentChannel;
|
||||||
|
if (string.IsNullOrEmpty(channel) || !IsConnected) return;
|
||||||
|
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var users = await _connection!.GetOnlineUsersAsync(channel);
|
||||||
|
InvokeUI(() => _mainWindow.UpdateOnlineUsers(users));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Debug(ex, "Failed to fetch online users for {Channel}", channel);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private void SaveServerToConfig(ConnectDialogResult result)
|
private void SaveServerToConfig(ConnectDialogResult result)
|
||||||
{
|
{
|
||||||
var savedServer = new SavedServer
|
var savedServer = new SavedServer
|
||||||
@@ -680,11 +1083,25 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
Name = new Uri(result.ServerUrl).Host,
|
Name = new Uri(result.ServerUrl).Host,
|
||||||
Url = result.ServerUrl,
|
Url = result.ServerUrl,
|
||||||
Username = result.Username,
|
Username = result.Username,
|
||||||
Token = _apiClient!.Token,
|
RefreshToken = result.RememberMe ? _apiClient!.RefreshToken : null,
|
||||||
|
RememberMe = result.RememberMe,
|
||||||
LastConnected = DateTimeOffset.Now
|
LastConnected = DateTimeOffset.Now
|
||||||
};
|
};
|
||||||
ConfigManager.SaveServer(savedServer);
|
ConfigManager.SaveServer(savedServer);
|
||||||
_config = ConfigManager.Load();
|
_config = ConfigManager.Load();
|
||||||
Log.Information("Connected successfully to {Url}", result.ServerUrl);
|
Log.Information("Connected successfully to {Url}", result.ServerUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ClearSavedToken(string serverUrl)
|
||||||
|
{
|
||||||
|
var config = ConfigManager.Load();
|
||||||
|
var server = config.SavedServers.FirstOrDefault(s =>
|
||||||
|
string.Equals(s.Url, serverUrl, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (server is not null)
|
||||||
|
{
|
||||||
|
server.RefreshToken = null;
|
||||||
|
ConfigManager.Save(config);
|
||||||
|
_config = config;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -10,7 +10,7 @@ public class CommandHandler
|
|||||||
public event Func<string, Task>? OnSetNick;
|
public event Func<string, Task>? OnSetNick;
|
||||||
public event Func<string, Task>? OnSetColor;
|
public event Func<string, Task>? OnSetColor;
|
||||||
public event Func<string, Task>? OnSetTheme;
|
public event Func<string, Task>? OnSetTheme;
|
||||||
public event Func<string, Task>? OnSendFile;
|
public event Func<string, string?, Task>? OnSendFile;
|
||||||
public event Func<string?, Task>? OnOpenProfile;
|
public event Func<string?, Task>? OnOpenProfile;
|
||||||
public event Func<Task>? OnOpenServers;
|
public event Func<Task>? OnOpenServers;
|
||||||
public event Func<string, Task>? OnJoinChannel;
|
public event Func<string, Task>? OnJoinChannel;
|
||||||
@@ -18,6 +18,14 @@ public class CommandHandler
|
|||||||
public event Func<string, Task>? OnSetTopic;
|
public event Func<string, Task>? OnSetTopic;
|
||||||
public event Func<Task>? OnListUsers;
|
public event Func<Task>? OnListUsers;
|
||||||
public event Func<string, Task>? OnSetAvatar;
|
public event Func<string, Task>? OnSetAvatar;
|
||||||
|
public event Func<string, string?, Task>? OnKickUser;
|
||||||
|
public event Func<string, string?, Task>? OnBanUser;
|
||||||
|
public event Func<string, Task>? OnUnbanUser;
|
||||||
|
public event Func<string, int?, Task>? OnMuteUser;
|
||||||
|
public event Func<string, Task>? OnUnmuteUser;
|
||||||
|
public event Func<string, string, Task>? OnAssignRole;
|
||||||
|
public event Func<Task>? OnNukeChannel;
|
||||||
|
public event Func<Task>? OnTestSound;
|
||||||
public event Func<Task>? OnQuit;
|
public event Func<Task>? OnQuit;
|
||||||
public event Func<Task>? OnHelp;
|
public event Func<Task>? OnHelp;
|
||||||
|
|
||||||
@@ -46,6 +54,14 @@ public class CommandHandler
|
|||||||
"leave" => await HandleLeave(),
|
"leave" => await HandleLeave(),
|
||||||
"topic" => await HandleTopic(args),
|
"topic" => await HandleTopic(args),
|
||||||
"users" => await HandleUsers(),
|
"users" => await HandleUsers(),
|
||||||
|
"kick" => await HandleKick(args),
|
||||||
|
"ban" => await HandleBan(args),
|
||||||
|
"unban" => await HandleUnban(args),
|
||||||
|
"mute" => await HandleMute(args),
|
||||||
|
"unmute" => await HandleUnmute(args),
|
||||||
|
"role" => await HandleRole(args),
|
||||||
|
"nuke" => await HandleNuke(),
|
||||||
|
"test-sound" => await HandleTestSound(),
|
||||||
"quit" or "exit" => await HandleQuit(),
|
"quit" or "exit" => await HandleQuit(),
|
||||||
"help" or "?" => await HandleHelp(),
|
"help" or "?" => await HandleHelp(),
|
||||||
_ => new CommandResult(true, $"Unknown command: /{command}. Type /help for available commands.", IsError: true),
|
_ => new CommandResult(true, $"Unknown command: /{command}. Type /help for available commands.", IsError: true),
|
||||||
@@ -120,15 +136,19 @@ public class CommandHandler
|
|||||||
private async Task<CommandResult> HandleSend(string args)
|
private async Task<CommandResult> HandleSend(string args)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(args))
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
return new CommandResult(true, "Usage: /send <filepath or URL>", IsError: true);
|
return new CommandResult(true, "Usage: /send <filepath or URL> [-s|-m|-l]", IsError: true);
|
||||||
|
|
||||||
var target = args.Trim().Trim('"');
|
// Extract optional size flag from end or start, respecting quoted paths
|
||||||
|
var (target, size) = ParsePathAndSizeFlag(args);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(target))
|
||||||
|
return new CommandResult(true, "Usage: /send <filepath or URL> [-s|-m|-l]", IsError: true);
|
||||||
|
|
||||||
if (Uri.TryCreate(target, UriKind.Absolute, out var uri)
|
if (Uri.TryCreate(target, UriKind.Absolute, out var uri)
|
||||||
&& (uri.Scheme == "http" || uri.Scheme == "https"))
|
&& (uri.Scheme == "http" || uri.Scheme == "https"))
|
||||||
{
|
{
|
||||||
if (OnSendFile is not null)
|
if (OnSendFile is not null)
|
||||||
await OnSendFile(target);
|
await OnSendFile(target, size);
|
||||||
var fileName = Path.GetFileName(uri.LocalPath);
|
var fileName = Path.GetFileName(uri.LocalPath);
|
||||||
if (string.IsNullOrWhiteSpace(fileName))
|
if (string.IsNullOrWhiteSpace(fileName))
|
||||||
fileName = "image";
|
fileName = "image";
|
||||||
@@ -139,7 +159,7 @@ public class CommandHandler
|
|||||||
return new CommandResult(true, $"File not found: {target}", IsError: true);
|
return new CommandResult(true, $"File not found: {target}", IsError: true);
|
||||||
|
|
||||||
if (OnSendFile is not null)
|
if (OnSendFile is not null)
|
||||||
await OnSendFile(target);
|
await OnSendFile(target, size);
|
||||||
return new CommandResult(true, $"Uploading: {Path.GetFileName(target)}...");
|
return new CommandResult(true, $"Uploading: {Path.GetFileName(target)}...");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +176,7 @@ public class CommandHandler
|
|||||||
if (string.IsNullOrWhiteSpace(args))
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
return new CommandResult(true, "Usage: /avatar <URL or filepath>", IsError: true);
|
return new CommandResult(true, "Usage: /avatar <URL or filepath>", IsError: true);
|
||||||
|
|
||||||
var target = args.Trim().Trim('"');
|
var target = StripQuotes(args.Trim());
|
||||||
|
|
||||||
if (OnSetAvatar is not null)
|
if (OnSetAvatar is not null)
|
||||||
await OnSetAvatar(target);
|
await OnSetAvatar(target);
|
||||||
@@ -212,6 +232,102 @@ public class CommandHandler
|
|||||||
return new CommandResult(true);
|
return new CommandResult(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleKick(string args)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
|
return new CommandResult(true, "Usage: /kick <username> [reason]", IsError: true);
|
||||||
|
|
||||||
|
var parts = args.Split(' ', 2, StringSplitOptions.TrimEntries);
|
||||||
|
var username = parts[0];
|
||||||
|
var reason = parts.Length > 1 ? parts[1] : null;
|
||||||
|
|
||||||
|
if (OnKickUser is not null)
|
||||||
|
await OnKickUser(username, reason);
|
||||||
|
return new CommandResult(true, $"Kicking {username}...");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleBan(string args)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
|
return new CommandResult(true, "Usage: /ban <username> [reason]", IsError: true);
|
||||||
|
|
||||||
|
var parts = args.Split(' ', 2, StringSplitOptions.TrimEntries);
|
||||||
|
var username = parts[0];
|
||||||
|
var reason = parts.Length > 1 ? parts[1] : null;
|
||||||
|
|
||||||
|
if (OnBanUser is not null)
|
||||||
|
await OnBanUser(username, reason);
|
||||||
|
return new CommandResult(true, $"Banning {username}...");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleUnban(string args)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
|
return new CommandResult(true, "Usage: /unban <username>", IsError: true);
|
||||||
|
|
||||||
|
if (OnUnbanUser is not null)
|
||||||
|
await OnUnbanUser(args.Trim());
|
||||||
|
return new CommandResult(true, $"Unbanning {args.Trim()}...");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleMute(string args)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
|
return new CommandResult(true, "Usage: /mute <username> [duration_minutes]", IsError: true);
|
||||||
|
|
||||||
|
var parts = args.Split(' ', 2, StringSplitOptions.TrimEntries);
|
||||||
|
var username = parts[0];
|
||||||
|
int? duration = parts.Length > 1 && int.TryParse(parts[1], out var d) ? d : null;
|
||||||
|
|
||||||
|
if (OnMuteUser is not null)
|
||||||
|
await OnMuteUser(username, duration);
|
||||||
|
return new CommandResult(true, $"Muting {username}...");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleUnmute(string args)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
|
return new CommandResult(true, "Usage: /unmute <username>", IsError: true);
|
||||||
|
|
||||||
|
if (OnUnmuteUser is not null)
|
||||||
|
await OnUnmuteUser(args.Trim());
|
||||||
|
return new CommandResult(true, $"Unmuting {args.Trim()}...");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleRole(string args)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
|
return new CommandResult(true, "Usage: /role <username> <admin|mod|member>", IsError: true);
|
||||||
|
|
||||||
|
var parts = args.Split(' ', 2, StringSplitOptions.TrimEntries);
|
||||||
|
if (parts.Length < 2)
|
||||||
|
return new CommandResult(true, "Usage: /role <username> <admin|mod|member>", IsError: true);
|
||||||
|
|
||||||
|
var username = parts[0];
|
||||||
|
var role = parts[1].ToLowerInvariant();
|
||||||
|
|
||||||
|
if (role is not ("admin" or "mod" or "member"))
|
||||||
|
return new CommandResult(true, "Invalid role. Use: admin, mod, or member", IsError: true);
|
||||||
|
|
||||||
|
if (OnAssignRole is not null)
|
||||||
|
await OnAssignRole(username, role);
|
||||||
|
return new CommandResult(true, $"Setting {username} to {role}...");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleNuke()
|
||||||
|
{
|
||||||
|
if (OnNukeChannel is not null)
|
||||||
|
await OnNukeChannel();
|
||||||
|
return new CommandResult(true, "Nuking channel history...");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleTestSound()
|
||||||
|
{
|
||||||
|
if (OnTestSound is not null)
|
||||||
|
await OnTestSound();
|
||||||
|
return new CommandResult(true, "Playing notification sound...");
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<CommandResult> HandleHelp()
|
private async Task<CommandResult> HandleHelp()
|
||||||
{
|
{
|
||||||
if (OnHelp is not null)
|
if (OnHelp is not null)
|
||||||
@@ -223,18 +339,72 @@ public class CommandHandler
|
|||||||
/nick <name> - Set display name
|
/nick <name> - Set display name
|
||||||
/color <#hex> - Set nickname color
|
/color <#hex> - Set nickname color
|
||||||
/theme <name> - Switch theme
|
/theme <name> - Switch theme
|
||||||
/send <filepath or URL> - Send a file or image
|
/send <filepath or URL> [-s|-m|-l] - Send file/image/audio (size flag for images)
|
||||||
/avatar <URL or filepath> - Set your avatar
|
/avatar <URL or filepath> - Set your avatar
|
||||||
/profile [username] - View a profile (yours if no name given)
|
/profile [username] - View a profile
|
||||||
/servers - Open saved servers
|
/servers - Open saved servers
|
||||||
/join <channel> - Join a channel
|
/join <channel> - Join a channel
|
||||||
/leave - Leave current channel
|
/leave - Leave current channel
|
||||||
/topic <text> - Set channel topic
|
/topic <text> - Set channel topic
|
||||||
/users - List online users
|
/users - List online users
|
||||||
|
Moderation:
|
||||||
|
/kick <user> [reason] - Kick a user (Mod+)
|
||||||
|
/ban <user> [reason] - Ban a user (Admin+)
|
||||||
|
/unban <user> - Unban a user (Admin+)
|
||||||
|
/mute <user> [minutes] - Mute a user (Mod+)
|
||||||
|
/unmute <user> - Unmute a user (Mod+)
|
||||||
|
/role <user> <admin|mod|member> - Assign role (Admin+)
|
||||||
|
/nuke - Clear channel history (Mod+)
|
||||||
|
/test-sound - Play notification sound
|
||||||
/quit - Exit the app
|
/quit - Exit the app
|
||||||
""");
|
""");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extract a file path (possibly quoted) and an optional size flag (-s, -m, -l).
|
||||||
|
/// The flag can appear before or after the path.
|
||||||
|
/// </summary>
|
||||||
|
private static (string Path, string? Size) ParsePathAndSizeFlag(string args)
|
||||||
|
{
|
||||||
|
var trimmed = args.Trim();
|
||||||
|
string? size = null;
|
||||||
|
|
||||||
|
// Check for flag at the end: "path" -m or path -m
|
||||||
|
if (trimmed.Length > 3)
|
||||||
|
{
|
||||||
|
var suffix = trimmed[^2..];
|
||||||
|
if (suffix is "-s" or "-m" or "-l" && trimmed[^3] == ' ')
|
||||||
|
{
|
||||||
|
size = suffix[1..];
|
||||||
|
trimmed = trimmed[..^3].TrimEnd();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for flag at the start: -m "path" or -m path
|
||||||
|
if (size is null && trimmed.Length > 3)
|
||||||
|
{
|
||||||
|
var prefix = trimmed[..2];
|
||||||
|
if (prefix is "-s" or "-m" or "-l" && trimmed[2] == ' ')
|
||||||
|
{
|
||||||
|
size = prefix[1..];
|
||||||
|
trimmed = trimmed[3..].TrimStart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (StripQuotes(trimmed), size);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Remove matching surrounding quotes (double or single) from a string.
|
||||||
|
/// </summary>
|
||||||
|
private static string StripQuotes(string s)
|
||||||
|
{
|
||||||
|
if (s.Length >= 2 &&
|
||||||
|
((s[0] == '"' && s[^1] == '"') || (s[0] == '\'' && s[^1] == '\'')))
|
||||||
|
return s[1..^1];
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
private static bool IsValidHex(string s) =>
|
private static bool IsValidHex(string s) =>
|
||||||
s.All(c => char.IsAsciiHexDigit(c));
|
s.All(c => char.IsAsciiHexDigit(c));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ public class ClientConfig
|
|||||||
public List<SavedServer> SavedServers { get; set; } = [];
|
public List<SavedServer> SavedServers { get; set; } = [];
|
||||||
public AccountPreset DefaultPreset { get; set; } = new();
|
public AccountPreset DefaultPreset { get; set; } = new();
|
||||||
public string ActiveTheme { get; set; } = "Default";
|
public string ActiveTheme { get; set; } = "Default";
|
||||||
|
public NotificationConfig Notifications { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class NotificationConfig
|
||||||
|
{
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
public byte Volume { get; set; } = 30;
|
||||||
|
public string? SoundFile { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SavedServer
|
public class SavedServer
|
||||||
@@ -12,7 +20,8 @@ public class SavedServer
|
|||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
public required string Url { get; set; }
|
public required string Url { get; set; }
|
||||||
public string? Username { get; set; }
|
public string? Username { get; set; }
|
||||||
public string? Token { get; set; }
|
public string? RefreshToken { get; set; }
|
||||||
|
public bool RememberMe { get; set; }
|
||||||
public DateTimeOffset LastConnected { get; set; }
|
public DateTimeOffset LastConnected { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,18 +5,25 @@
|
|||||||
</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="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" />
|
<!-- Using local fork until transparent color support is merged upstream (gui-cs/Terminal.Gui#4234) -->
|
||||||
|
<ProjectReference Include="..\Terminal.Gui\Terminal.Gui\Terminal.Gui.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Content Include="appsettings.json" Condition="Exists('appsettings.json')">
|
<Content Include="appsettings.json" Condition="Exists('appsettings.json')">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</Content>
|
</Content>
|
||||||
|
<Content Include="hue_icon.ico" />
|
||||||
|
<Content Include="Assets\**">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
<EmbeddedResource Include="appsettings.example.json">
|
<EmbeddedResource Include="appsettings.example.json">
|
||||||
<LogicalName>EchoHub.Client.appsettings.example.json</LogicalName>
|
<LogicalName>EchoHub.Client.appsettings.example.json</LogicalName>
|
||||||
</EmbeddedResource>
|
</EmbeddedResource>
|
||||||
@@ -27,6 +34,8 @@
|
|||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
<PackageIcon></PackageIcon>
|
||||||
|
<ApplicationIcon>hue_icon.ico</ApplicationIcon>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ using EchoHub.Client.Themes;
|
|||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using Terminal.Gui.App;
|
using Terminal.Gui.App;
|
||||||
|
using Terminal.Gui.Drawing;
|
||||||
|
|
||||||
|
|
||||||
var appSettingsPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
|
var appSettingsPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
|
||||||
if (!File.Exists(appSettingsPath))
|
if (!File.Exists(appSettingsPath))
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Net.Http.Headers;
|
|||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
|
|
||||||
namespace EchoHub.Client.Services;
|
namespace EchoHub.Client.Services;
|
||||||
|
|
||||||
@@ -17,6 +18,8 @@ public sealed class ApiClient : IDisposable
|
|||||||
public string? RefreshToken => _refreshToken;
|
public string? RefreshToken => _refreshToken;
|
||||||
public string BaseUrl { get; }
|
public string BaseUrl { get; }
|
||||||
|
|
||||||
|
public event Action? OnTokensRefreshed;
|
||||||
|
|
||||||
public ApiClient(string baseUrl)
|
public ApiClient(string baseUrl)
|
||||||
{
|
{
|
||||||
BaseUrl = baseUrl.TrimEnd('/');
|
BaseUrl = baseUrl.TrimEnd('/');
|
||||||
@@ -67,6 +70,19 @@ public sealed class ApiClient : IDisposable
|
|||||||
SetTokens(result);
|
SetTokens(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<LoginResponse> LoginWithRefreshTokenAsync(string refreshToken)
|
||||||
|
{
|
||||||
|
var request = new RefreshRequest(refreshToken);
|
||||||
|
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))
|
||||||
@@ -127,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();
|
||||||
@@ -159,7 +185,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
return result?.AvatarAscii;
|
return result?.AvatarAscii;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<MessageDto?> UploadFileAsync(string channelName, Stream fileStream, string fileName)
|
public async Task<MessageDto?> UploadFileAsync(string channelName, Stream fileStream, string fileName, string? size = null)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
using var content = new MultipartFormDataContent();
|
using var content = new MultipartFormDataContent();
|
||||||
@@ -167,26 +193,45 @@ public sealed class ApiClient : IDisposable
|
|||||||
streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(fileName));
|
streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(fileName));
|
||||||
content.Add(streamContent, "file", fileName);
|
content.Add(streamContent, "file", fileName);
|
||||||
|
|
||||||
|
var sizeQuery = size is not null ? $"?size={size}" : "";
|
||||||
var response = await AuthenticatedRequestAsync(() =>
|
var response = await AuthenticatedRequestAsync(() =>
|
||||||
_http.PostAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/upload", content));
|
_http.PostAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/upload{sizeQuery}", content));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
return await response.Content.ReadFromJsonAsync<MessageDto>();
|
return await response.Content.ReadFromJsonAsync<MessageDto>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<MessageDto?> SendUrlAsync(string channelName, string url)
|
public async Task<MessageDto?> SendUrlAsync(string channelName, string url, string? size = null)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var request = new SendUrlRequest(url);
|
var request = new SendUrlRequest(url);
|
||||||
|
var sizeQuery = size is not null ? $"?size={size}" : "";
|
||||||
var response = await AuthenticatedRequestAsync(() =>
|
var response = await AuthenticatedRequestAsync(() =>
|
||||||
_http.PostAsJsonAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/send-url", request));
|
_http.PostAsJsonAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/send-url{sizeQuery}", request));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
return await response.Content.ReadFromJsonAsync<MessageDto>();
|
return await response.Content.ReadFromJsonAsync<MessageDto>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ChannelDto?> CreateChannelAsync(string name, string? topic = null)
|
public async Task<string> DownloadFileToTempAsync(string relativeUrl, string fileName)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var request = new CreateChannelRequest(name, topic);
|
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)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
var request = new CreateChannelRequest(name, topic, isPublic);
|
||||||
var response = await AuthenticatedRequestAsync(() =>
|
var response = await AuthenticatedRequestAsync(() =>
|
||||||
_http.PostAsJsonAsync("/api/channels", request));
|
_http.PostAsJsonAsync("/api/channels", request));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
@@ -211,12 +256,79 @@ public sealed class ApiClient : IDisposable
|
|||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Moderation ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task AssignRoleAsync(string username, ServerRole role)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
var response = await AuthenticatedRequestAsync(() =>
|
||||||
|
_http.PostAsJsonAsync("/api/moderation/role", new AssignRoleRequest(username, role)));
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task KickUserAsync(string username, string? reason = null)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
var response = await AuthenticatedRequestAsync(() =>
|
||||||
|
_http.PostAsJsonAsync($"/api/moderation/kick/{Uri.EscapeDataString(username)}", new KickRequest(reason)));
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task BanUserAsync(string username, string? reason = null)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
var response = await AuthenticatedRequestAsync(() =>
|
||||||
|
_http.PostAsJsonAsync($"/api/moderation/ban/{Uri.EscapeDataString(username)}", new BanRequest(reason)));
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UnbanUserAsync(string username)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
var response = await AuthenticatedRequestAsync(() =>
|
||||||
|
_http.PostAsJsonAsync($"/api/moderation/unban/{Uri.EscapeDataString(username)}", new { }));
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task MuteUserAsync(string username, int? durationMinutes = null, string? reason = null)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
var response = await AuthenticatedRequestAsync(() =>
|
||||||
|
_http.PostAsJsonAsync($"/api/moderation/mute/{Uri.EscapeDataString(username)}", new MuteRequest(reason, durationMinutes)));
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UnmuteUserAsync(string username)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
var response = await AuthenticatedRequestAsync(() =>
|
||||||
|
_http.PostAsJsonAsync($"/api/moderation/unmute/{Uri.EscapeDataString(username)}", new { }));
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteMessageAsync(Guid messageId)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
var response = await AuthenticatedRequestAsync(() =>
|
||||||
|
_http.DeleteAsync($"/api/moderation/messages/{messageId}"));
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task NukeChannelAsync(string channelName)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
var response = await AuthenticatedRequestAsync(() =>
|
||||||
|
_http.DeleteAsync($"/api/moderation/channels/{Uri.EscapeDataString(channelName)}/nuke"));
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
}
|
||||||
|
|
||||||
private void SetTokens(LoginResponse result)
|
private void SetTokens(LoginResponse result)
|
||||||
{
|
{
|
||||||
_accessToken = result.Token;
|
_accessToken = result.Token;
|
||||||
_refreshToken = result.RefreshToken;
|
_refreshToken = result.RefreshToken;
|
||||||
_expiresAt = result.ExpiresAt;
|
_expiresAt = result.ExpiresAt;
|
||||||
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
|
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
|
||||||
|
OnTokensRefreshed?.Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -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,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);
|
||||||
|
}
|
||||||
@@ -8,20 +8,27 @@ namespace EchoHub.Client.Services;
|
|||||||
public sealed class EchoHubConnection : IAsyncDisposable
|
public sealed class EchoHubConnection : IAsyncDisposable
|
||||||
{
|
{
|
||||||
private readonly HubConnection _connection;
|
private readonly HubConnection _connection;
|
||||||
|
private readonly ClientEncryptionService _encryption;
|
||||||
|
|
||||||
public event Action<MessageDto>? OnMessageReceived;
|
public event Action<MessageDto>? OnMessageReceived;
|
||||||
public event Action<string, string>? OnUserJoined;
|
public event Action<string, string>? OnUserJoined;
|
||||||
public event Action<string, string>? OnUserLeft;
|
public event Action<string, string>? OnUserLeft;
|
||||||
public event Action<ChannelDto>? OnChannelUpdated;
|
public event Action<ChannelDto>? OnChannelUpdated;
|
||||||
public event Action<UserPresenceDto>? OnUserStatusChanged;
|
public event Action<UserPresenceDto>? OnUserStatusChanged;
|
||||||
|
public event Action<string, string, string?>? OnUserKicked;
|
||||||
|
public event Action<string, string?>? OnUserBanned;
|
||||||
|
public event Action<string, Guid>? OnMessageDeleted;
|
||||||
|
public event Action<string>? OnChannelNuked;
|
||||||
|
public event Action<string>? OnForceDisconnect;
|
||||||
public event Action<string>? OnError;
|
public event Action<string>? OnError;
|
||||||
public event Action<string>? OnConnectionStateChanged;
|
public event Action<string>? OnConnectionStateChanged;
|
||||||
public event Action? OnReconnected;
|
public event Action? OnReconnected;
|
||||||
|
|
||||||
public bool IsConnected => _connection.State == HubConnectionState.Connected;
|
public bool IsConnected => _connection.State == HubConnectionState.Connected;
|
||||||
|
|
||||||
public EchoHubConnection(string serverUrl, ApiClient apiClient)
|
public EchoHubConnection(string serverUrl, ApiClient apiClient, ClientEncryptionService encryption)
|
||||||
{
|
{
|
||||||
|
_encryption = encryption;
|
||||||
var hubUrl = serverUrl.TrimEnd('/') + HubConstants.ChatHubPath;
|
var hubUrl = serverUrl.TrimEnd('/') + HubConstants.ChatHubPath;
|
||||||
|
|
||||||
_connection = new HubConnectionBuilder()
|
_connection = new HubConnectionBuilder()
|
||||||
@@ -58,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) =>
|
||||||
@@ -81,6 +90,31 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
|||||||
OnUserStatusChanged?.Invoke(presence);
|
OnUserStatusChanged?.Invoke(presence);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
_connection.On<string, string, string?>(nameof(Core.Contracts.IEchoHubClient.UserKicked), (channelName, username, reason) =>
|
||||||
|
{
|
||||||
|
OnUserKicked?.Invoke(channelName, username, reason);
|
||||||
|
});
|
||||||
|
|
||||||
|
_connection.On<string, string?>(nameof(Core.Contracts.IEchoHubClient.UserBanned), (username, reason) =>
|
||||||
|
{
|
||||||
|
OnUserBanned?.Invoke(username, reason);
|
||||||
|
});
|
||||||
|
|
||||||
|
_connection.On<string, Guid>(nameof(Core.Contracts.IEchoHubClient.MessageDeleted), (channelName, messageId) =>
|
||||||
|
{
|
||||||
|
OnMessageDeleted?.Invoke(channelName, messageId);
|
||||||
|
});
|
||||||
|
|
||||||
|
_connection.On<string>(nameof(Core.Contracts.IEchoHubClient.ChannelNuked), channelName =>
|
||||||
|
{
|
||||||
|
OnChannelNuked?.Invoke(channelName);
|
||||||
|
});
|
||||||
|
|
||||||
|
_connection.On<string>(nameof(Core.Contracts.IEchoHubClient.ForceDisconnect), reason =>
|
||||||
|
{
|
||||||
|
OnForceDisconnect?.Invoke(reason);
|
||||||
|
});
|
||||||
|
|
||||||
_connection.On<string>(nameof(Core.Contracts.IEchoHubClient.Error), message =>
|
_connection.On<string>(nameof(Core.Contracts.IEchoHubClient.Error), message =>
|
||||||
{
|
{
|
||||||
OnError?.Invoke(message);
|
OnError?.Invoke(message);
|
||||||
@@ -102,7 +136,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)
|
||||||
@@ -112,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)
|
||||||
@@ -130,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();
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using EchoHub.Client.Config;
|
||||||
|
using NetCoreAudio;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.Services;
|
||||||
|
|
||||||
|
public class NotificationSoundService
|
||||||
|
{
|
||||||
|
private readonly Player _player = new();
|
||||||
|
private readonly NotificationConfig _config;
|
||||||
|
private string? _resolvedSoundPath;
|
||||||
|
|
||||||
|
public NotificationSoundService(NotificationConfig config)
|
||||||
|
{
|
||||||
|
_config = config;
|
||||||
|
ResolveSoundPath();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetEnabled(bool enabled) => _config.Enabled = enabled;
|
||||||
|
|
||||||
|
public void SetVolume(byte volume) => _config.Volume = Math.Min(volume, (byte)100);
|
||||||
|
|
||||||
|
public async Task PlayAsync()
|
||||||
|
{
|
||||||
|
if (!_config.Enabled || _resolvedSoundPath is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await PlayInternal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plays the notification sound regardless of the Enabled setting (for /test-sound).
|
||||||
|
/// </summary>
|
||||||
|
public async Task PlayTestAsync()
|
||||||
|
{
|
||||||
|
if (_resolvedSoundPath is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await PlayInternal();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PlayInternal()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_player.Playing)
|
||||||
|
await _player.Stop();
|
||||||
|
|
||||||
|
await _player.SetVolume(_config.Volume);
|
||||||
|
await _player.Play(_resolvedSoundPath!);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Failed to play notification sound");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResolveSoundPath()
|
||||||
|
{
|
||||||
|
// 1. Explicit path from config (~/.echohub/config.json)
|
||||||
|
if (!string.IsNullOrWhiteSpace(_config.SoundFile))
|
||||||
|
{
|
||||||
|
if (File.Exists(_config.SoundFile))
|
||||||
|
{
|
||||||
|
_resolvedSoundPath = Path.GetFullPath(_config.SoundFile);
|
||||||
|
Log.Debug("Notification sound: {Path} (from config)", _resolvedSoundPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.Warning("Configured sound file not found: {Path}", _config.SoundFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Default: Notification.mp3 bundled next to the executable
|
||||||
|
var defaultPath = Path.Combine(AppContext.BaseDirectory, "Assets", "Notification.mp3");
|
||||||
|
|
||||||
|
if (File.Exists(defaultPath))
|
||||||
|
{
|
||||||
|
_resolvedSoundPath = defaultPath;
|
||||||
|
Log.Debug("Notification sound: {Path} (default)", _resolvedSoundPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.Information("No notification sound file found — notifications will be silent");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
using AlwaysUpToDate;
|
||||||
|
|
||||||
|
using EchoHub.Client.UI;
|
||||||
|
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
|
using Terminal.Gui.App;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.Services;
|
||||||
|
|
||||||
|
public sealed class UpdateChecker : IDisposable
|
||||||
|
{
|
||||||
|
private const string ManifestUrl = "https://echohub.voidcube.cloud/api/app/version";
|
||||||
|
|
||||||
|
private readonly Updater _updater;
|
||||||
|
private readonly IApplication _app;
|
||||||
|
private UpdateProgressDialog? _progressDialog;
|
||||||
|
|
||||||
|
public static string CurrentVersion => typeof(UpdateChecker).Assembly.GetName().Version?.ToString(3) ?? "0.0.0";
|
||||||
|
|
||||||
|
public UpdateChecker(IApplication app)
|
||||||
|
{
|
||||||
|
_app = app;
|
||||||
|
_updater = new Updater(TimeSpan.FromHours(1), ManifestUrl, false);
|
||||||
|
|
||||||
|
_updater.UpdateAvailable += OnUpdateAvailable;
|
||||||
|
_updater.ProgressChanged += OnProgressChanged;
|
||||||
|
_updater.UpdateStarted += OnUpdateStarted;
|
||||||
|
_updater.NoUpdateAvailable += OnNoUpdateAvailable;
|
||||||
|
_updater.OnException += OnException;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
#if RELEASE
|
||||||
|
_updater.Start();
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -418,30 +418,30 @@ public static class ThemeManager
|
|||||||
Base = new ThemeColors
|
Base = new ThemeColors
|
||||||
{
|
{
|
||||||
Foreground = "White",
|
Foreground = "White",
|
||||||
Background = "Black",
|
Background = "Transparent",
|
||||||
FocusForeground = "BrightCyan",
|
FocusForeground = "BrightCyan",
|
||||||
FocusBackground = "Black"
|
FocusBackground = "Transparent"
|
||||||
},
|
},
|
||||||
Menu = new ThemeColors
|
Menu = new ThemeColors
|
||||||
{
|
{
|
||||||
Foreground = "White",
|
Foreground = "White",
|
||||||
Background = "Black",
|
Background = "Transparent",
|
||||||
FocusForeground = "BrightCyan",
|
FocusForeground = "BrightCyan",
|
||||||
FocusBackground = "Black"
|
FocusBackground = "Transparent"
|
||||||
},
|
},
|
||||||
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 = "Transparent",
|
||||||
FocusForeground = "Gray",
|
FocusForeground = "Gray",
|
||||||
FocusBackground = "Black"
|
FocusBackground = "Transparent"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
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.Transparent);
|
||||||
|
private static readonly Attribute WaveIdleAttr = new(new Color(80, 50, 120), Color.Transparent);
|
||||||
|
private static readonly Attribute FileNameAttr = new(new Color(180, 100, 255), Color.Transparent);
|
||||||
|
private static readonly Attribute StatusPlayingAttr = new(new Color(0, 200, 0), Color.Transparent);
|
||||||
|
private static readonly Attribute StatusPausedAttr = new(new Color(220, 180, 0), Color.Transparent);
|
||||||
|
private static readonly Attribute StatusStoppedAttr = new(new Color(160, 160, 160), Color.Transparent);
|
||||||
|
|
||||||
|
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.Transparent
|
||||||
|
? 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.Transparent
|
||||||
|
? 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.Transparent
|
||||||
|
? 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
using System.Collections;
|
using System.Collections;
|
||||||
using System.Collections.Specialized;
|
using System.Collections.Specialized;
|
||||||
using System.Text;
|
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
using Terminal.Gui.Drawing;
|
using Terminal.Gui.Drawing;
|
||||||
|
using Terminal.Gui.Text;
|
||||||
using Terminal.Gui.Views;
|
using Terminal.Gui.Views;
|
||||||
using Attribute = Terminal.Gui.Drawing.Attribute;
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
|
|
||||||
@@ -20,17 +21,22 @@ public partial class ChatLine
|
|||||||
{
|
{
|
||||||
public List<ChatSegment> Segments { get; }
|
public List<ChatSegment> Segments { get; }
|
||||||
public int TextLength { 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)
|
public ChatLine(string plainText)
|
||||||
{
|
{
|
||||||
Segments = [new ChatSegment(plainText, null)];
|
Segments = [new ChatSegment(plainText, null)];
|
||||||
TextLength = plainText.Length;
|
TextLength = plainText.GetColumns();
|
||||||
}
|
}
|
||||||
|
|
||||||
public ChatLine(List<ChatSegment> segments)
|
public ChatLine(List<ChatSegment> segments)
|
||||||
{
|
{
|
||||||
Segments = segments;
|
Segments = segments;
|
||||||
TextLength = segments.Sum(s => s.Text.Length);
|
TextLength = segments.Sum(s => s.Text.GetColumns());
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString() => string.Concat(Segments.Select(s => s.Text));
|
public override string ToString() => string.Concat(Segments.Select(s => s.Text));
|
||||||
@@ -50,17 +56,22 @@ public partial class ChatLine
|
|||||||
|
|
||||||
foreach (var segment in Segments)
|
foreach (var segment in Segments)
|
||||||
{
|
{
|
||||||
int segPos = 0;
|
var text = segment.Text;
|
||||||
while (segPos < segment.Text.Length)
|
int chunkStart = 0;
|
||||||
|
int charPos = 0;
|
||||||
|
|
||||||
|
foreach (var grapheme in GraphemeHelper.GetGraphemes(text))
|
||||||
{
|
{
|
||||||
int remaining = width - col;
|
var graphemeCols = Math.Max(grapheme.GetColumns(), 1);
|
||||||
if (remaining <= 0)
|
|
||||||
|
if (col + graphemeCols > width)
|
||||||
{
|
{
|
||||||
// Emit current line and start a new one
|
if (charPos > chunkStart)
|
||||||
|
currentSegments.Add(new ChatSegment(text[chunkStart..charPos], segment.Color));
|
||||||
|
|
||||||
results.Add(new ChatLine(currentSegments));
|
results.Add(new ChatLine(currentSegments));
|
||||||
currentSegments = [];
|
currentSegments = [];
|
||||||
|
|
||||||
// Add indent for continuation
|
|
||||||
if (continuationIndent > 0)
|
if (continuationIndent > 0)
|
||||||
{
|
{
|
||||||
currentSegments.Add(new ChatSegment(new string(' ', continuationIndent), null));
|
currentSegments.Add(new ChatSegment(new string(' ', continuationIndent), null));
|
||||||
@@ -71,79 +82,110 @@ public partial class ChatLine
|
|||||||
col = 0;
|
col = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
remaining = width - col;
|
chunkStart = charPos;
|
||||||
}
|
}
|
||||||
|
|
||||||
int take = Math.Min(segment.Text.Length - segPos, remaining);
|
col += graphemeCols;
|
||||||
currentSegments.Add(new ChatSegment(segment.Text.Substring(segPos, take), segment.Color));
|
charPos += grapheme.Length;
|
||||||
col += take;
|
|
||||||
segPos += take;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (chunkStart < text.Length)
|
||||||
|
currentSegments.Add(new ChatSegment(text[chunkStart..], segment.Color));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentSegments.Count > 0)
|
if (currentSegments.Count > 0)
|
||||||
results.Add(new ChatLine(currentSegments));
|
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;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parse a string containing ANSI 24-bit color escape codes into colored segments.
|
/// Returns true if a line contains printable color tags.
|
||||||
/// Format: \x1b[38;2;R;G;Bm (foreground color), \x1b[0m (reset)
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static ChatLine FromAnsi(string ansiText, Attribute? defaultAttr = null)
|
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>();
|
var segments = new List<ChatSegment>();
|
||||||
var regex = AnsiColorRegex();
|
|
||||||
int lastIndex = 0;
|
int lastIndex = 0;
|
||||||
Attribute? currentColor = defaultAttr;
|
Color? currentFg = null;
|
||||||
|
Color? currentBg = null;
|
||||||
|
var defaultFg = defaultAttr?.Foreground;
|
||||||
|
var defaultBg = defaultAttr?.Background ?? Color.Transparent;
|
||||||
|
|
||||||
foreach (Match match in regex.Matches(ansiText))
|
Attribute? BuildAttr()
|
||||||
{
|
{
|
||||||
// Add any text before this escape sequence
|
if (currentFg is null && currentBg is null) return defaultAttr;
|
||||||
if (match.Index > lastIndex)
|
var fg = currentFg ?? defaultFg ?? Color.White;
|
||||||
{
|
var bg = currentBg ?? defaultBg;
|
||||||
var text = ansiText[lastIndex..match.Index];
|
return new Attribute(fg, bg);
|
||||||
if (text.Length > 0)
|
|
||||||
segments.Add(new ChatSegment(text, currentColor));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the escape sequence
|
foreach (Match match in ColorTagRegex().Matches(text))
|
||||||
if (match.Groups[1].Value == "0")
|
|
||||||
{
|
{
|
||||||
// Reset
|
if (match.Index > lastIndex)
|
||||||
currentColor = defaultAttr;
|
{
|
||||||
|
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)
|
else if (match.Groups[2].Success)
|
||||||
{
|
{
|
||||||
// 38;2;R;G;B — 24-bit foreground color
|
var hex = match.Groups[3].Value;
|
||||||
var r = int.Parse(match.Groups[3].Value);
|
var r = Convert.ToInt32(hex[..2], 16);
|
||||||
var g = int.Parse(match.Groups[4].Value);
|
var g = Convert.ToInt32(hex[2..4], 16);
|
||||||
var b = int.Parse(match.Groups[5].Value);
|
var b = Convert.ToInt32(hex[4..6], 16);
|
||||||
currentColor = new Attribute(new Color(r, g, b), Color.Black);
|
if (match.Groups[2].Value == "F")
|
||||||
|
currentFg = new Color(r, g, b);
|
||||||
|
else
|
||||||
|
currentBg = new Color(r, g, b);
|
||||||
}
|
}
|
||||||
|
|
||||||
lastIndex = match.Index + match.Length;
|
lastIndex = match.Index + match.Length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add remaining text
|
if (lastIndex < text.Length)
|
||||||
if (lastIndex < ansiText.Length)
|
|
||||||
{
|
{
|
||||||
var text = ansiText[lastIndex..];
|
var t = text[lastIndex..];
|
||||||
if (text.Length > 0)
|
if (t.Length > 0)
|
||||||
segments.Add(new ChatSegment(text, currentColor));
|
segments.Add(new ChatSegment(t, BuildAttr()));
|
||||||
}
|
}
|
||||||
|
|
||||||
return segments.Count > 0 ? new ChatLine(segments) : new ChatLine("");
|
return segments.Count > 0 ? new ChatLine(segments) : new ChatLine("");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Matches: \x1b[0m (reset) or \x1b[38;2;R;G;Bm (24-bit foreground)
|
[GeneratedRegex(@"\{(?:(X)|(?:(F|B):([0-9A-Fa-f]{6})))\}")]
|
||||||
[GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")]
|
private static partial Regex ColorTagRegex();
|
||||||
private static partial Regex AnsiColorRegex();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Custom list data source for chat messages with per-character coloring.
|
/// Custom list data source for chat messages with per-segment coloring.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ChatListSource : IListDataSource
|
public class ChatListSource : IListDataSource
|
||||||
{
|
{
|
||||||
@@ -188,6 +230,8 @@ public class ChatListSource : IListDataSource
|
|||||||
RaiseCollectionChanged();
|
RaiseCollectionChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ChatLine? GetLine(int index) => index >= 0 && index < _lines.Count ? _lines[index] : null;
|
||||||
|
|
||||||
public bool IsMarked(int item) => false;
|
public bool IsMarked(int item) => false;
|
||||||
public void SetMark(int item, bool value) { }
|
public void SetMark(int item, bool value) { }
|
||||||
public IList ToList() => _lines.Select(l => l.ToString()).ToList();
|
public IList ToList() => _lines.Select(l => l.ToString()).ToList();
|
||||||
@@ -197,7 +241,8 @@ public class ChatListSource : IListDataSource
|
|||||||
listView.Move(Math.Max(col - viewportX, 0), row);
|
listView.Move(Math.Max(col - viewportX, 0), row);
|
||||||
|
|
||||||
var chatLine = _lines[item];
|
var chatLine = _lines[item];
|
||||||
var normalAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal);
|
var normalAttr = listView.GetAttributeForRole(VisualRole.Normal);
|
||||||
|
var mentionBg = chatLine.IsMention ? ChatColors.MentionHighlightAttr.Background : (Color?)null;
|
||||||
|
|
||||||
int charPos = 0;
|
int charPos = 0;
|
||||||
int drawnChars = 0;
|
int drawnChars = 0;
|
||||||
@@ -205,26 +250,28 @@ public class ChatListSource : IListDataSource
|
|||||||
foreach (var segment in chatLine.Segments)
|
foreach (var segment in chatLine.Segments)
|
||||||
{
|
{
|
||||||
var attr = segment.Color ?? normalAttr;
|
var attr = segment.Color ?? normalAttr;
|
||||||
|
if (attr.Background == Color.Transparent)
|
||||||
|
attr = attr with { Background = normalAttr.Background };
|
||||||
|
if (mentionBg.HasValue)
|
||||||
|
attr = attr with { Background = mentionBg.Value };
|
||||||
listView.SetAttribute(attr);
|
listView.SetAttribute(attr);
|
||||||
|
|
||||||
foreach (var ch in segment.Text)
|
foreach (var grapheme in GraphemeHelper.GetGraphemes(segment.Text))
|
||||||
{
|
{
|
||||||
if (charPos >= viewportX && drawnChars < width)
|
var cols = Math.Max(grapheme.GetColumns(), 1);
|
||||||
|
if (charPos >= viewportX && drawnChars + cols <= width)
|
||||||
{
|
{
|
||||||
listView.AddRune(new Rune(ch));
|
listView.AddStr(grapheme);
|
||||||
drawnChars++;
|
drawnChars += cols;
|
||||||
}
|
}
|
||||||
charPos++;
|
charPos += cols;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fill remaining width with spaces using default colors
|
var fillAttr = mentionBg.HasValue ? new Attribute(normalAttr.Foreground, mentionBg.Value) : normalAttr;
|
||||||
listView.SetAttribute(normalAttr);
|
listView.SetAttribute(fillAttr);
|
||||||
while (drawnChars < width)
|
for (int i = drawnChars; i < width; i++)
|
||||||
{
|
listView.AddStr(" ");
|
||||||
listView.AddRune(new Rune(' '));
|
|
||||||
drawnChars++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateMaxLength(ChatLine line)
|
private void UpdateMaxLength(ChatLine line)
|
||||||
@@ -242,13 +289,233 @@ public class ChatListSource : IListDataSource
|
|||||||
public void Dispose() { }
|
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.Transparent);
|
||||||
|
private static readonly Attribute UnreadAttr = new(Color.BrightCyan, Color.Transparent);
|
||||||
|
private static readonly Attribute NormalAttr = new(Color.DarkGray, Color.Transparent);
|
||||||
|
private static readonly Attribute BadgeAttr = new(Color.BrightYellow, Color.Transparent);
|
||||||
|
|
||||||
|
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.Transparent ? 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() { }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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>
|
/// <summary>
|
||||||
/// Shared color attributes for chat rendering (timestamps, system messages).
|
/// Shared color attributes for chat rendering (timestamps, system messages).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class ChatColors
|
public static partial class ChatColors
|
||||||
{
|
{
|
||||||
public static readonly Attribute TimestampAttr = new(Color.DarkGray, Color.Black);
|
public static readonly Attribute TimestampAttr = new(Color.DarkGray, Color.Transparent);
|
||||||
public static readonly Attribute SystemAttr = new(new Color(0, 180, 180), Color.Black);
|
public static readonly Attribute SystemAttr = new(new Color(0, 180, 180), Color.Transparent);
|
||||||
|
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.Transparent);
|
||||||
|
public static readonly Attribute EmbedBorderAttr = new(new Color(91, 155, 213), Color.Transparent);
|
||||||
|
public static readonly Attribute EmbedTitleAttr = new(Color.White, Color.Transparent);
|
||||||
|
public static readonly Attribute EmbedDescAttr = new(new Color(160, 160, 160), Color.Transparent);
|
||||||
|
public static readonly Attribute EmbedUrlAttr = new(new Color(100, 100, 100), Color.Transparent);
|
||||||
|
public static readonly Attribute AudioAttr = new(new Color(180, 100, 255), Color.Transparent);
|
||||||
|
public static readonly Attribute FileAttr = new(new Color(100, 180, 255), Color.Transparent);
|
||||||
|
|
||||||
|
/// <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>
|
/// <summary>
|
||||||
@@ -270,7 +537,7 @@ public static class ColorHelper
|
|||||||
var r = Convert.ToInt32(hex[..2], 16);
|
var r = Convert.ToInt32(hex[..2], 16);
|
||||||
var g = Convert.ToInt32(hex[2..4], 16);
|
var g = Convert.ToInt32(hex[2..4], 16);
|
||||||
var b = Convert.ToInt32(hex[4..6], 16);
|
var b = Convert.ToInt32(hex[4..6], 16);
|
||||||
return new Attribute(new Color(r, g, b), Color.Black);
|
return new Attribute(new Color(r, g, b), Color.Transparent);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ namespace EchoHub.Client.UI;
|
|||||||
/// <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();
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ using Terminal.Gui.ViewBase;
|
|||||||
|
|
||||||
namespace EchoHub.Client.UI;
|
namespace EchoHub.Client.UI;
|
||||||
|
|
||||||
public record CreateChannelResult(string Name, string? Topic);
|
public record CreateChannelResult(string Name, string? Topic, bool IsPublic);
|
||||||
|
|
||||||
public sealed class CreateChannelDialog
|
public sealed class CreateChannelDialog
|
||||||
{
|
{
|
||||||
@@ -12,7 +12,7 @@ public sealed class CreateChannelDialog
|
|||||||
{
|
{
|
||||||
CreateChannelResult? result = null;
|
CreateChannelResult? result = null;
|
||||||
|
|
||||||
var dialog = new Dialog { Title = "Create Channel", Width = 50, Height = 12 };
|
var dialog = new Dialog { Title = "Create Channel", Width = 50, Height = 14 };
|
||||||
|
|
||||||
var nameLabel = new Label { Text = "Name:", X = 1, Y = 1 };
|
var nameLabel = new Label { Text = "Name:", X = 1, Y = 1 };
|
||||||
var nameField = new TextField { X = 10, Y = 1, Width = Dim.Fill(2) };
|
var nameField = new TextField { X = 10, Y = 1, Width = Dim.Fill(2) };
|
||||||
@@ -20,11 +20,19 @@ public sealed class CreateChannelDialog
|
|||||||
var topicLabel = new Label { Text = "Topic:", X = 1, Y = 3 };
|
var topicLabel = new Label { Text = "Topic:", X = 1, Y = 3 };
|
||||||
var topicField = new TextField { X = 10, Y = 3, Width = Dim.Fill(2) };
|
var topicField = new TextField { X = 10, Y = 3, Width = Dim.Fill(2) };
|
||||||
|
|
||||||
|
var publicCheckbox = new CheckBox
|
||||||
|
{
|
||||||
|
Text = "Public (visible to all users)",
|
||||||
|
X = 1,
|
||||||
|
Y = 5,
|
||||||
|
Value = CheckState.Checked
|
||||||
|
};
|
||||||
|
|
||||||
var hintLabel = new Label
|
var hintLabel = new Label
|
||||||
{
|
{
|
||||||
Text = "Lowercase letters, digits, hyphens, underscores (2-100 chars)",
|
Text = "Lowercase letters, digits, hyphens, underscores (2-100 chars)",
|
||||||
X = 1,
|
X = 1,
|
||||||
Y = 5,
|
Y = 7,
|
||||||
};
|
};
|
||||||
|
|
||||||
var createButton = new Button
|
var createButton = new Button
|
||||||
@@ -32,14 +40,14 @@ public sealed class CreateChannelDialog
|
|||||||
Text = "Create",
|
Text = "Create",
|
||||||
IsDefault = true,
|
IsDefault = true,
|
||||||
X = Pos.Center() - 10,
|
X = Pos.Center() - 10,
|
||||||
Y = 7
|
Y = 9
|
||||||
};
|
};
|
||||||
|
|
||||||
var cancelButton = new Button
|
var cancelButton = new Button
|
||||||
{
|
{
|
||||||
Text = "Cancel",
|
Text = "Cancel",
|
||||||
X = Pos.Center() + 5,
|
X = Pos.Center() + 5,
|
||||||
Y = 7
|
Y = 9
|
||||||
};
|
};
|
||||||
|
|
||||||
createButton.Accepting += (s, e) =>
|
createButton.Accepting += (s, e) =>
|
||||||
@@ -55,7 +63,8 @@ public sealed class CreateChannelDialog
|
|||||||
if (string.IsNullOrWhiteSpace(topic))
|
if (string.IsNullOrWhiteSpace(topic))
|
||||||
topic = null;
|
topic = null;
|
||||||
|
|
||||||
result = new CreateChannelResult(name, topic);
|
var isPublic = publicCheckbox.Value == CheckState.Checked;
|
||||||
|
result = new CreateChannelResult(name, topic, isPublic);
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
app.RequestStop();
|
app.RequestStop();
|
||||||
};
|
};
|
||||||
@@ -67,7 +76,7 @@ public sealed class CreateChannelDialog
|
|||||||
app.RequestStop();
|
app.RequestStop();
|
||||||
};
|
};
|
||||||
|
|
||||||
dialog.Add(nameLabel, nameField, topicLabel, topicField, hintLabel, createButton, cancelButton);
|
dialog.Add(nameLabel, nameField, topicLabel, topicField, publicCheckbox, hintLabel, createButton, cancelButton);
|
||||||
|
|
||||||
nameField.SetFocus();
|
nameField.SetFocus();
|
||||||
app.Run(dialog);
|
app.Run(dialog);
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts emoji grapheme clusters to text shortcodes for safe TUI rendering.
|
||||||
|
/// Terminal width calculations for emoji are unreliable across different terminals,
|
||||||
|
/// so we replace them with fixed-width ASCII shortcodes for display only.
|
||||||
|
/// </summary>
|
||||||
|
public static class EmojiHelper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Replace emoji graphemes in the text with :shortcode: equivalents.
|
||||||
|
/// Non-emoji text passes through unchanged.
|
||||||
|
/// </summary>
|
||||||
|
public static string ReplaceEmoji(string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text))
|
||||||
|
return text;
|
||||||
|
|
||||||
|
// Quick check: if no characters above BMP or supplementary emoji ranges, skip processing
|
||||||
|
bool hasEmoji = false;
|
||||||
|
foreach (var rune in text.EnumerateRunes())
|
||||||
|
{
|
||||||
|
if (IsEmojiRune(rune))
|
||||||
|
{
|
||||||
|
hasEmoji = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasEmoji)
|
||||||
|
return text;
|
||||||
|
|
||||||
|
var sb = new StringBuilder(text.Length);
|
||||||
|
var enumerator = StringInfo.GetTextElementEnumerator(text);
|
||||||
|
|
||||||
|
while (enumerator.MoveNext())
|
||||||
|
{
|
||||||
|
var grapheme = enumerator.GetTextElement();
|
||||||
|
|
||||||
|
// Check if this grapheme contains emoji runes
|
||||||
|
bool graphemeHasEmoji = false;
|
||||||
|
foreach (var rune in grapheme.EnumerateRunes())
|
||||||
|
{
|
||||||
|
if (IsEmojiRune(rune))
|
||||||
|
{
|
||||||
|
graphemeHasEmoji = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (graphemeHasEmoji)
|
||||||
|
{
|
||||||
|
// Try to find a shortcode for the whole grapheme first
|
||||||
|
if (EmojiShortcodes.TryGetValue(grapheme, out var shortcode))
|
||||||
|
{
|
||||||
|
sb.Append(shortcode);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Try the base emoji (first rune only, stripping modifiers/ZWJ)
|
||||||
|
var baseRune = GetBaseEmoji(grapheme);
|
||||||
|
if (baseRune is not null && EmojiShortcodes.TryGetValue(baseRune, out shortcode))
|
||||||
|
{
|
||||||
|
sb.Append(shortcode);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Unknown emoji — use generic placeholder
|
||||||
|
sb.Append("[emoji]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sb.Append(grapheme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsEmojiRune(Rune rune)
|
||||||
|
{
|
||||||
|
var value = rune.Value;
|
||||||
|
|
||||||
|
// Common emoji ranges
|
||||||
|
if (value >= 0x1F600 && value <= 0x1F64F) return true; // Emoticons
|
||||||
|
if (value >= 0x1F300 && value <= 0x1F5FF) return true; // Misc Symbols & Pictographs
|
||||||
|
if (value >= 0x1F680 && value <= 0x1F6FF) return true; // Transport & Map
|
||||||
|
if (value >= 0x1F900 && value <= 0x1F9FF) return true; // Supplemental Symbols
|
||||||
|
if (value >= 0x1FA00 && value <= 0x1FA6F) return true; // Chess Symbols
|
||||||
|
if (value >= 0x1FA70 && value <= 0x1FAFF) return true; // Symbols Extended-A
|
||||||
|
if (value >= 0x2600 && value <= 0x26FF) return true; // Misc Symbols
|
||||||
|
if (value >= 0x2700 && value <= 0x27BF) return true; // Dingbats
|
||||||
|
if (value >= 0xFE00 && value <= 0xFE0F) return true; // Variation Selectors
|
||||||
|
if (value >= 0x200D && value <= 0x200D) return true; // ZWJ
|
||||||
|
if (value >= 0x1F1E0 && value <= 0x1F1FF) return true; // Regional Indicators (flags)
|
||||||
|
if (value >= 0x231A && value <= 0x23F3) return true; // Misc Technical (watch, hourglass)
|
||||||
|
if (value >= 0x2934 && value <= 0x2935) return true; // Arrows
|
||||||
|
if (value >= 0x25AA && value <= 0x25FE) return true; // Geometric Shapes
|
||||||
|
if (value >= 0x2B05 && value <= 0x2B55) return true; // Misc Symbols & Arrows
|
||||||
|
if (value >= 0x3030 && value <= 0x303D) return true; // CJK Symbols
|
||||||
|
if (value == 0x00A9 || value == 0x00AE) return true; // © ®
|
||||||
|
if (value == 0x2122) return true; // ™
|
||||||
|
if (value >= 0x1F000 && value <= 0x1F02F) return true; // Mahjong & Dominos
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extract the base emoji string (first non-modifier, non-ZWJ rune) for lookup.
|
||||||
|
/// </summary>
|
||||||
|
private static string? GetBaseEmoji(string grapheme)
|
||||||
|
{
|
||||||
|
foreach (var rune in grapheme.EnumerateRunes())
|
||||||
|
{
|
||||||
|
// Skip ZWJ, variation selectors, skin tone modifiers
|
||||||
|
if (rune.Value == 0x200D) continue;
|
||||||
|
if (rune.Value >= 0xFE00 && rune.Value <= 0xFE0F) continue;
|
||||||
|
if (rune.Value >= 0x1F3FB && rune.Value <= 0x1F3FF) continue;
|
||||||
|
|
||||||
|
if (IsEmojiRune(rune))
|
||||||
|
return rune.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common emoji → shortcode mapping (display-only, covers most frequently used emoji)
|
||||||
|
private static readonly Dictionary<string, string> EmojiShortcodes = new()
|
||||||
|
{
|
||||||
|
// Smileys & Emotion
|
||||||
|
["\U0001F600"] = ":grinning:",
|
||||||
|
["\U0001F601"] = ":grin:",
|
||||||
|
["\U0001F602"] = ":joy:",
|
||||||
|
["\U0001F603"] = ":smiley:",
|
||||||
|
["\U0001F604"] = ":smile:",
|
||||||
|
["\U0001F605"] = ":sweat_smile:",
|
||||||
|
["\U0001F606"] = ":laughing:",
|
||||||
|
["\U0001F607"] = ":innocent:",
|
||||||
|
["\U0001F608"] = ":smiling_imp:",
|
||||||
|
["\U0001F609"] = ":wink:",
|
||||||
|
["\U0001F60A"] = ":blush:",
|
||||||
|
["\U0001F60B"] = ":yum:",
|
||||||
|
["\U0001F60C"] = ":relieved:",
|
||||||
|
["\U0001F60D"] = ":heart_eyes:",
|
||||||
|
["\U0001F60E"] = ":sunglasses:",
|
||||||
|
["\U0001F60F"] = ":smirk:",
|
||||||
|
["\U0001F610"] = ":neutral_face:",
|
||||||
|
["\U0001F611"] = ":expressionless:",
|
||||||
|
["\U0001F612"] = ":unamused:",
|
||||||
|
["\U0001F613"] = ":sweat:",
|
||||||
|
["\U0001F614"] = ":pensive:",
|
||||||
|
["\U0001F615"] = ":confused:",
|
||||||
|
["\U0001F616"] = ":confounded:",
|
||||||
|
["\U0001F617"] = ":kissing:",
|
||||||
|
["\U0001F618"] = ":kissing_heart:",
|
||||||
|
["\U0001F619"] = ":kissing_smiling_eyes:",
|
||||||
|
["\U0001F61A"] = ":kissing_closed_eyes:",
|
||||||
|
["\U0001F61B"] = ":stuck_out_tongue:",
|
||||||
|
["\U0001F61C"] = ":stuck_out_tongue_winking_eye:",
|
||||||
|
["\U0001F61D"] = ":stuck_out_tongue_closed_eyes:",
|
||||||
|
["\U0001F61E"] = ":disappointed:",
|
||||||
|
["\U0001F61F"] = ":worried:",
|
||||||
|
["\U0001F620"] = ":angry:",
|
||||||
|
["\U0001F621"] = ":rage:",
|
||||||
|
["\U0001F622"] = ":cry:",
|
||||||
|
["\U0001F623"] = ":persevere:",
|
||||||
|
["\U0001F624"] = ":triumph:",
|
||||||
|
["\U0001F625"] = ":disappointed_relieved:",
|
||||||
|
["\U0001F626"] = ":frowning:",
|
||||||
|
["\U0001F627"] = ":anguished:",
|
||||||
|
["\U0001F628"] = ":fearful:",
|
||||||
|
["\U0001F629"] = ":weary:",
|
||||||
|
["\U0001F62A"] = ":sleepy:",
|
||||||
|
["\U0001F62B"] = ":tired_face:",
|
||||||
|
["\U0001F62C"] = ":grimacing:",
|
||||||
|
["\U0001F62D"] = ":sob:",
|
||||||
|
["\U0001F62E"] = ":open_mouth:",
|
||||||
|
["\U0001F62F"] = ":hushed:",
|
||||||
|
["\U0001F630"] = ":cold_sweat:",
|
||||||
|
["\U0001F631"] = ":scream:",
|
||||||
|
["\U0001F632"] = ":astonished:",
|
||||||
|
["\U0001F633"] = ":flushed:",
|
||||||
|
["\U0001F634"] = ":sleeping:",
|
||||||
|
["\U0001F635"] = ":dizzy_face:",
|
||||||
|
["\U0001F636"] = ":no_mouth:",
|
||||||
|
["\U0001F637"] = ":mask:",
|
||||||
|
["\U0001F641"] = ":slightly_frowning_face:",
|
||||||
|
["\U0001F642"] = ":slightly_smiling_face:",
|
||||||
|
["\U0001F643"] = ":upside_down_face:",
|
||||||
|
["\U0001F644"] = ":roll_eyes:",
|
||||||
|
["\U0001F910"] = ":zipper_mouth:",
|
||||||
|
["\U0001F911"] = ":money_mouth:",
|
||||||
|
["\U0001F912"] = ":thermometer_face:",
|
||||||
|
["\U0001F913"] = ":nerd:",
|
||||||
|
["\U0001F914"] = ":thinking:",
|
||||||
|
["\U0001F915"] = ":head_bandage:",
|
||||||
|
["\U0001F920"] = ":cowboy:",
|
||||||
|
["\U0001F921"] = ":clown:",
|
||||||
|
["\U0001F922"] = ":nauseated:",
|
||||||
|
["\U0001F923"] = ":rofl:",
|
||||||
|
["\U0001F924"] = ":drooling:",
|
||||||
|
["\U0001F925"] = ":lying:",
|
||||||
|
["\U0001F929"] = ":star_struck:",
|
||||||
|
["\U0001F92A"] = ":zany:",
|
||||||
|
["\U0001F92B"] = ":shushing:",
|
||||||
|
["\U0001F92C"] = ":cursing:",
|
||||||
|
["\U0001F92D"] = ":hand_over_mouth:",
|
||||||
|
["\U0001F92E"] = ":vomiting:",
|
||||||
|
["\U0001F92F"] = ":exploding_head:",
|
||||||
|
["\U0001F970"] = ":smiling_face_with_hearts:",
|
||||||
|
["\U0001F971"] = ":yawning:",
|
||||||
|
["\U0001F972"] = ":smiling_with_tear:",
|
||||||
|
["\U0001F973"] = ":partying:",
|
||||||
|
["\U0001F974"] = ":woozy:",
|
||||||
|
["\U0001F975"] = ":hot_face:",
|
||||||
|
["\U0001F976"] = ":cold_face:",
|
||||||
|
["\U0001F979"] = ":holding_back_tears:",
|
||||||
|
["\U0001F97A"] = ":pleading:",
|
||||||
|
["\U0001FAE0"] = ":melting:",
|
||||||
|
["\U0001FAE1"] = ":saluting:",
|
||||||
|
["\U0001FAE2"] = ":face_with_open_eyes_hand_over_mouth:",
|
||||||
|
["\U0001FAE3"] = ":face_with_peeking_eye:",
|
||||||
|
["\U0001FAE4"] = ":face_with_diagonal_mouth:",
|
||||||
|
|
||||||
|
// Gestures
|
||||||
|
["\U0001F44D"] = ":+1:",
|
||||||
|
["\U0001F44E"] = ":-1:",
|
||||||
|
["\U0001F44B"] = ":wave:",
|
||||||
|
["\U0001F44C"] = ":ok_hand:",
|
||||||
|
["\U0001F44F"] = ":clap:",
|
||||||
|
["\U0001F44A"] = ":fist:",
|
||||||
|
["\U0001F91D"] = ":handshake:",
|
||||||
|
["\U0001F91E"] = ":crossed_fingers:",
|
||||||
|
["\U0001F91F"] = ":love_you:",
|
||||||
|
["\U0001F918"] = ":metal:",
|
||||||
|
["\U0001F919"] = ":call_me:",
|
||||||
|
["\U0001F590"] = ":raised_hand:",
|
||||||
|
["\U0001F4AA"] = ":muscle:",
|
||||||
|
["\U0001F926"] = ":facepalm:",
|
||||||
|
["\U0001F937"] = ":shrug:",
|
||||||
|
["\U0001F64F"] = ":pray:",
|
||||||
|
["\U0001F64C"] = ":raised_hands:",
|
||||||
|
["\U0001F64B"] = ":raising_hand:",
|
||||||
|
|
||||||
|
// Hearts & Symbols
|
||||||
|
["\u2764"] = "<3",
|
||||||
|
["\U0001F494"] = "</3",
|
||||||
|
["\U0001F495"] = ":two_hearts:",
|
||||||
|
["\U0001F496"] = ":sparkling_heart:",
|
||||||
|
["\U0001F497"] = ":heartpulse:",
|
||||||
|
["\U0001F498"] = ":cupid:",
|
||||||
|
["\U0001F499"] = ":blue_heart:",
|
||||||
|
["\U0001F49A"] = ":green_heart:",
|
||||||
|
["\U0001F49B"] = ":yellow_heart:",
|
||||||
|
["\U0001F49C"] = ":purple_heart:",
|
||||||
|
["\U0001F49D"] = ":gift_heart:",
|
||||||
|
["\U0001F49E"] = ":revolving_hearts:",
|
||||||
|
["\U0001F49F"] = ":heart_decoration:",
|
||||||
|
["\U0001F90D"] = ":white_heart:",
|
||||||
|
["\U0001F90E"] = ":brown_heart:",
|
||||||
|
["\U0001F5A4"] = ":black_heart:",
|
||||||
|
["\U0001F9E1"] = ":orange_heart:",
|
||||||
|
|
||||||
|
// Objects & Nature
|
||||||
|
["\U0001F525"] = ":fire:",
|
||||||
|
["\U0001F4A9"] = ":poop:",
|
||||||
|
["\U0001F480"] = ":skull:",
|
||||||
|
["\U0001F47B"] = ":ghost:",
|
||||||
|
["\U0001F47D"] = ":alien:",
|
||||||
|
["\U0001F916"] = ":robot:",
|
||||||
|
["\U0001F4AF"] = ":100:",
|
||||||
|
["\U0001F4A5"] = ":boom:",
|
||||||
|
["\U0001F4A4"] = ":zzz:",
|
||||||
|
["\U0001F4A2"] = ":anger:",
|
||||||
|
["\U0001F4AC"] = ":speech_balloon:",
|
||||||
|
["\U0001F440"] = ":eyes:",
|
||||||
|
["\U0001F3B5"] = ":musical_note:",
|
||||||
|
["\U0001F3B6"] = ":notes:",
|
||||||
|
["\U0001F389"] = ":tada:",
|
||||||
|
["\U0001F38A"] = ":confetti:",
|
||||||
|
["\U0001F381"] = ":gift:",
|
||||||
|
["\U0001F3C6"] = ":trophy:",
|
||||||
|
["\U0001F4B0"] = ":money_bag:",
|
||||||
|
["\U0001F4BB"] = ":computer:",
|
||||||
|
["\U0001F4F1"] = ":phone:",
|
||||||
|
["\U0001F4E7"] = ":email:",
|
||||||
|
["\U0001F511"] = ":key:",
|
||||||
|
["\U0001F512"] = ":lock:",
|
||||||
|
["\U0001F513"] = ":unlock:",
|
||||||
|
["\U0001F6A8"] = ":rotating_light:",
|
||||||
|
["\U0001F6AB"] = ":no_entry:",
|
||||||
|
|
||||||
|
// Animals
|
||||||
|
["\U0001F436"] = ":dog:",
|
||||||
|
["\U0001F431"] = ":cat:",
|
||||||
|
["\U0001F42D"] = ":mouse:",
|
||||||
|
["\U0001F430"] = ":rabbit:",
|
||||||
|
["\U0001F43B"] = ":bear:",
|
||||||
|
["\U0001F427"] = ":penguin:",
|
||||||
|
["\U0001F41D"] = ":bee:",
|
||||||
|
["\U0001F40D"] = ":snake:",
|
||||||
|
["\U0001F422"] = ":turtle:",
|
||||||
|
|
||||||
|
// Food & Drink
|
||||||
|
["\U0001F355"] = ":pizza:",
|
||||||
|
["\U0001F354"] = ":hamburger:",
|
||||||
|
["\U0001F37A"] = ":beer:",
|
||||||
|
["\U0001F377"] = ":wine:",
|
||||||
|
["\U0001F370"] = ":cake:",
|
||||||
|
["\u2615"] = ":coffee:",
|
||||||
|
["\U0001F382"] = ":birthday:",
|
||||||
|
|
||||||
|
// Misc symbols (BMP)
|
||||||
|
["\u2705"] = ":white_check_mark:",
|
||||||
|
["\u274C"] = ":x:",
|
||||||
|
["\u274E"] = ":negative_squared_cross_mark:",
|
||||||
|
["\u2714"] = ":heavy_check_mark:",
|
||||||
|
["\u2716"] = ":heavy_multiplication_x:",
|
||||||
|
["\u26A0"] = ":warning:",
|
||||||
|
["\u2B50"] = ":star:",
|
||||||
|
["\u2728"] = ":sparkles:",
|
||||||
|
["\u267B"] = ":recycle:",
|
||||||
|
["\u2611"] = ":ballot_box_with_check:",
|
||||||
|
["\u23F0"] = ":alarm_clock:",
|
||||||
|
["\u231A"] = ":watch:",
|
||||||
|
["\u231B"] = ":hourglass:",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
using System.Collections.ObjectModel;
|
using System.Text.RegularExpressions;
|
||||||
using EchoHub.Client.Themes;
|
using EchoHub.Client.Themes;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
using Terminal.Gui.App;
|
using Terminal.Gui.App;
|
||||||
using Terminal.Gui.Configuration;
|
using Terminal.Gui.Configuration;
|
||||||
|
using Terminal.Gui.Drawing;
|
||||||
using Terminal.Gui.Input;
|
using Terminal.Gui.Input;
|
||||||
|
using Terminal.Gui.Text;
|
||||||
using Terminal.Gui.ViewBase;
|
using Terminal.Gui.ViewBase;
|
||||||
using Terminal.Gui.Views;
|
using Terminal.Gui.Views;
|
||||||
using Attribute = Terminal.Gui.Drawing.Attribute;
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
@@ -21,14 +23,26 @@ public sealed class MainWindow : Runnable
|
|||||||
private readonly ListView _messageList;
|
private readonly ListView _messageList;
|
||||||
private readonly TextView _inputField;
|
private readonly TextView _inputField;
|
||||||
private readonly FrameView _chatFrame;
|
private readonly FrameView _chatFrame;
|
||||||
|
private readonly FrameView _inputFrame;
|
||||||
private readonly Label _statusLabel;
|
private readonly Label _statusLabel;
|
||||||
private readonly Label _topicLabel;
|
private readonly Label _topicLabel;
|
||||||
private MenuBar _menuBar;
|
private MenuBar _menuBar;
|
||||||
|
|
||||||
|
// Online users panel
|
||||||
|
private readonly FrameView _usersFrame;
|
||||||
|
private readonly ListView _usersList;
|
||||||
|
private readonly UserListSource _usersListSource;
|
||||||
|
private bool _usersPanelVisible = true;
|
||||||
|
private const int UsersPanelWidth = 22;
|
||||||
|
private static readonly Key F2Key = Key.F2;
|
||||||
|
|
||||||
|
internal static readonly string AppVersion =
|
||||||
|
typeof(MainWindow).Assembly.GetName().Version?.ToString(3) ?? "?";
|
||||||
|
|
||||||
// Cached Key constants — compare via .KeyCode to avoid Key.Equals (which also checks Handled)
|
// Cached Key constants — compare via .KeyCode to avoid Key.Equals (which also checks Handled)
|
||||||
private static readonly Key EnterKey = Key.Enter;
|
private static readonly Key EnterKey = Key.Enter;
|
||||||
private static readonly Key NewlineKey = Key.N.WithCtrl;
|
private static readonly Key NewlineKey = Key.N.WithCtrl;
|
||||||
private static readonly Key CtrlCKey = Key.C.WithCtrl;
|
private static readonly Key AltQKey = Key.Q.WithAlt;
|
||||||
private static readonly Key TabKey = Key.Tab;
|
private static readonly Key TabKey = Key.Tab;
|
||||||
|
|
||||||
// Available slash commands for Tab autocomplete
|
// Available slash commands for Tab autocomplete
|
||||||
@@ -36,15 +50,19 @@ public sealed class MainWindow : Runnable
|
|||||||
[
|
[
|
||||||
"/status", "/nick", "/color", "/theme", "/send",
|
"/status", "/nick", "/color", "/theme", "/send",
|
||||||
"/avatar", "/profile", "/servers", "/join", "/leave",
|
"/avatar", "/profile", "/servers", "/join", "/leave",
|
||||||
"/topic", "/users", "/quit", "/help"
|
"/topic", "/users", "/kick", "/ban", "/unban",
|
||||||
|
"/mute", "/unmute", "/role", "/nuke", "/test-sound", "/quit", "/help"
|
||||||
];
|
];
|
||||||
|
|
||||||
private readonly List<string> _channelNames = [];
|
private readonly List<string> _channelNames = [];
|
||||||
private readonly Dictionary<string, List<ChatLine>> _channelMessages = [];
|
private readonly Dictionary<string, List<ChatLine>> _channelMessages = [];
|
||||||
private readonly Dictionary<string, int> _channelUnread = [];
|
private readonly Dictionary<string, int> _channelUnread = [];
|
||||||
private readonly Dictionary<string, string?> _channelTopics = [];
|
private readonly Dictionary<string, string?> _channelTopics = [];
|
||||||
|
private readonly Dictionary<string, bool> _channelPublic = [];
|
||||||
|
private readonly ChannelListSource _channelListSource;
|
||||||
private string _currentChannel = string.Empty;
|
private string _currentChannel = string.Empty;
|
||||||
private string _currentUser = string.Empty;
|
private string _currentUser = string.Empty;
|
||||||
|
private string _connectionStatus = "Disconnected";
|
||||||
private int _lastChatWidth;
|
private int _lastChatWidth;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -67,6 +85,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>
|
||||||
@@ -92,6 +115,21 @@ public sealed class MainWindow : Runnable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public event Action? OnCreateChannelRequested;
|
public event Action? OnCreateChannelRequested;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired when the user requests to delete the current channel.
|
||||||
|
/// </summary>
|
||||||
|
public event Action? OnDeleteChannelRequested;
|
||||||
|
|
||||||
|
/// <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)
|
public MainWindow(IApplication app)
|
||||||
{
|
{
|
||||||
_app = app;
|
_app = app;
|
||||||
@@ -107,7 +145,7 @@ public sealed class MainWindow : Runnable
|
|||||||
Title = "Channels",
|
Title = "Channels",
|
||||||
X = 0,
|
X = 0,
|
||||||
Y = 1, // below menu bar
|
Y = 1, // below menu bar
|
||||||
Width = 25,
|
Width = 22,
|
||||||
Height = Dim.Fill(1) // leave room for status bar
|
Height = Dim.Fill(1) // leave room for status bar
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -118,7 +156,8 @@ public sealed class MainWindow : Runnable
|
|||||||
Width = Dim.Fill(),
|
Width = Dim.Fill(),
|
||||||
Height = Dim.Fill()
|
Height = Dim.Fill()
|
||||||
};
|
};
|
||||||
_channelList.SetSource(new ObservableCollection<string>(_channelNames));
|
_channelListSource = new ChannelListSource();
|
||||||
|
_channelList.Source = _channelListSource;
|
||||||
_channelList.ValueChanged += OnChannelListSelectionChanged;
|
_channelList.ValueChanged += OnChannelListSelectionChanged;
|
||||||
channelsFrame.Add(_channelList);
|
channelsFrame.Add(_channelList);
|
||||||
Add(channelsFrame);
|
Add(channelsFrame);
|
||||||
@@ -127,9 +166,9 @@ public sealed class MainWindow : Runnable
|
|||||||
_topicLabel = new Label
|
_topicLabel = new Label
|
||||||
{
|
{
|
||||||
Text = "",
|
Text = "",
|
||||||
X = 25,
|
X = 22,
|
||||||
Y = 1,
|
Y = 1,
|
||||||
Width = Dim.Fill(),
|
Width = Dim.Fill(UsersPanelWidth),
|
||||||
Height = 1,
|
Height = 1,
|
||||||
Visible = false
|
Visible = false
|
||||||
};
|
};
|
||||||
@@ -139,9 +178,9 @@ public sealed class MainWindow : Runnable
|
|||||||
_chatFrame = new FrameView
|
_chatFrame = new FrameView
|
||||||
{
|
{
|
||||||
Title = "Chat",
|
Title = "Chat",
|
||||||
X = 25,
|
X = 22,
|
||||||
Y = 1, // below menu bar (shifts to 2 when topic is visible)
|
Y = 1, // below menu bar (shifts to 2 when topic is visible)
|
||||||
Width = Dim.Fill(),
|
Width = Dim.Fill(UsersPanelWidth),
|
||||||
Height = Dim.Fill(6) // leave room for input area and status bar
|
Height = Dim.Fill(6) // leave room for input area and status bar
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -153,16 +192,17 @@ 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);
|
||||||
|
|
||||||
// Bottom input area
|
// Bottom input area
|
||||||
var inputFrame = new FrameView
|
_inputFrame = new FrameView
|
||||||
{
|
{
|
||||||
Title = "Message (Enter=send, Ctrl+N=newline, Tab=autocomplete)",
|
Title = "Message \u2502 Enter=send \u2502 Ctrl+N=newline \u2502 Tab=complete",
|
||||||
X = 25,
|
X = 22,
|
||||||
Y = Pos.Bottom(_chatFrame),
|
Y = Pos.Bottom(_chatFrame),
|
||||||
Width = Dim.Fill(),
|
Width = Dim.Fill(UsersPanelWidth),
|
||||||
Height = 5
|
Height = 5
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -175,19 +215,43 @@ public sealed class MainWindow : Runnable
|
|||||||
WordWrap = true
|
WordWrap = true
|
||||||
};
|
};
|
||||||
_inputField.KeyDown += OnInputKeyDown;
|
_inputField.KeyDown += OnInputKeyDown;
|
||||||
inputFrame.Add(_inputField);
|
_inputField.ContentsChanged += OnInputContentsChanged;
|
||||||
Add(inputFrame);
|
_inputFrame.Add(_inputField);
|
||||||
|
Add(_inputFrame);
|
||||||
|
|
||||||
// Status bar at the very bottom
|
// Right panel - online users
|
||||||
|
_usersFrame = new FrameView
|
||||||
|
{
|
||||||
|
Title = "Users",
|
||||||
|
X = Pos.AnchorEnd(UsersPanelWidth),
|
||||||
|
Y = 1,
|
||||||
|
Width = UsersPanelWidth,
|
||||||
|
Height = Dim.Fill(1)
|
||||||
|
};
|
||||||
|
|
||||||
|
_usersList = new ListView
|
||||||
|
{
|
||||||
|
X = 0,
|
||||||
|
Y = 0,
|
||||||
|
Width = Dim.Fill(),
|
||||||
|
Height = Dim.Fill()
|
||||||
|
};
|
||||||
|
_usersListSource = new UserListSource();
|
||||||
|
_usersList.Source = _usersListSource;
|
||||||
|
_usersFrame.Add(_usersList);
|
||||||
|
Add(_usersFrame);
|
||||||
|
|
||||||
|
// 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
|
||||||
@@ -198,7 +262,7 @@ public sealed class MainWindow : Runnable
|
|||||||
_messageList.ViewportChanged += (_, _) => OnChatViewportChanged();
|
_messageList.ViewportChanged += (_, _) => OnChatViewportChanged();
|
||||||
_chatFrame.ViewportChanged += (_, _) => OnChatViewportChanged();
|
_chatFrame.ViewportChanged += (_, _) => OnChatViewportChanged();
|
||||||
|
|
||||||
// Window-level key handling for Ctrl+C (quit)
|
// Window-level key handling for Alt+Q (quit), F2 (toggle users panel)
|
||||||
KeyDown += OnWindowKeyDown;
|
KeyDown += OnWindowKeyDown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,9 +327,14 @@ 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("_Saved Servers...", "View saved servers", () => OnSavedServersRequested?.Invoke(), Key.Empty)
|
new MenuItem("_Delete Channel", "Delete the current channel", () => OnDeleteChannelRequested?.Invoke(), Key.Empty),
|
||||||
|
new Line(),
|
||||||
|
new MenuItem("_Saved Servers...", "View saved servers", () => OnSavedServersRequested?.Invoke(), Key.Empty),
|
||||||
|
new Line(),
|
||||||
|
new MenuItem("Toggle _Users Panel", "Toggle online users (F2)", () => ToggleUsersPanel(), Key.Empty)
|
||||||
}),
|
}),
|
||||||
new MenuBarItem("_User", allUserItems)
|
new MenuBarItem("_User", allUserItems)
|
||||||
]);
|
]);
|
||||||
@@ -311,6 +380,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)
|
||||||
@@ -333,13 +427,38 @@ public sealed class MainWindow : Runnable
|
|||||||
}
|
}
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
}
|
}
|
||||||
else if (e.KeyCode == CtrlCKey.KeyCode)
|
else if (e.KeyCode == AltQKey.KeyCode)
|
||||||
{
|
{
|
||||||
_app.RequestStop();
|
_app.RequestStop();
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool _suppressEmojiReplace;
|
||||||
|
|
||||||
|
private void OnInputContentsChanged(object? sender, ContentsChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_suppressEmojiReplace)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var text = _inputField.Text;
|
||||||
|
if (string.IsNullOrEmpty(text))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var replaced = EmojiHelper.ReplaceEmoji(text);
|
||||||
|
if (replaced == text)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Calculate where cursor should land after replacement
|
||||||
|
var lengthDelta = replaced.Length - text.Length;
|
||||||
|
var newCol = Math.Max(0, _inputField.CurrentColumn + lengthDelta);
|
||||||
|
|
||||||
|
_suppressEmojiReplace = true;
|
||||||
|
_inputField.Text = replaced;
|
||||||
|
_inputField.InsertionPoint = new System.Drawing.Point(newCol, _inputField.CurrentRow);
|
||||||
|
_suppressEmojiReplace = false;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tab-complete slash commands in the input field.
|
/// Tab-complete slash commands in the input field.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -386,12 +505,16 @@ public sealed class MainWindow : Runnable
|
|||||||
|
|
||||||
private void OnWindowKeyDown(object? sender, Key e)
|
private void OnWindowKeyDown(object? sender, Key e)
|
||||||
{
|
{
|
||||||
// Ctrl+C quits from anywhere
|
if (e.KeyCode == AltQKey.KeyCode)
|
||||||
if (e.KeyCode == CtrlCKey.KeyCode)
|
|
||||||
{
|
{
|
||||||
_app.RequestStop();
|
_app.RequestStop();
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
}
|
}
|
||||||
|
else if (e.KeyCode == F2Key.KeyCode)
|
||||||
|
{
|
||||||
|
ToggleUsersPanel();
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -430,19 +553,33 @@ public sealed class MainWindow : Runnable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public void AddSystemMessage(string channelName, string text)
|
public void AddSystemMessage(string channelName, string text)
|
||||||
{
|
{
|
||||||
var time = DateTimeOffset.Now.ToString("HH:mm");
|
|
||||||
var segments = new List<ChatSegment>
|
|
||||||
{
|
|
||||||
new($"[{time}] ", ChatColors.TimestampAttr),
|
|
||||||
new($"** {text}", ChatColors.SystemAttr)
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!_channelMessages.TryGetValue(channelName, out var messages))
|
if (!_channelMessages.TryGetValue(channelName, out var messages))
|
||||||
{
|
{
|
||||||
messages = [];
|
messages = [];
|
||||||
_channelMessages[channelName] = messages;
|
_channelMessages[channelName] = messages;
|
||||||
}
|
}
|
||||||
messages.Add(new ChatLine(segments));
|
|
||||||
|
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)
|
if (channelName == _currentChannel)
|
||||||
{
|
{
|
||||||
@@ -475,6 +612,32 @@ public sealed class MainWindow : Runnable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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>
|
||||||
/// Set the list of available channels, storing topics, and refresh the channel list view.
|
/// Set the list of available channels, storing topics, and refresh the channel list view.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -482,16 +645,46 @@ 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;
|
||||||
|
_channelPublic[ch.Name] = ch.IsPublic;
|
||||||
if (!_channelMessages.ContainsKey(ch.Name))
|
if (!_channelMessages.ContainsKey(ch.Name))
|
||||||
_channelMessages[ch.Name] = [];
|
_channelMessages[ch.Name] = [];
|
||||||
}
|
}
|
||||||
RefreshChannelList();
|
RefreshChannelList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ensure a channel exists in the left panel list (used for private channels joined via /join).
|
||||||
|
/// </summary>
|
||||||
|
public void EnsureChannelInList(string channelName, bool? isPublic = null)
|
||||||
|
{
|
||||||
|
if (isPublic.HasValue)
|
||||||
|
_channelPublic[channelName] = isPublic.Value;
|
||||||
|
|
||||||
|
if (_channelNames.Contains(channelName))
|
||||||
|
return;
|
||||||
|
|
||||||
|
_channelNames.Add(channelName);
|
||||||
|
if (!_channelMessages.ContainsKey(channelName))
|
||||||
|
_channelMessages[channelName] = [];
|
||||||
|
RefreshChannelList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Remove a channel from the left panel list.
|
||||||
|
/// </summary>
|
||||||
|
public void RemoveChannel(string channelName)
|
||||||
|
{
|
||||||
|
_channelNames.Remove(channelName);
|
||||||
|
_channelTopics.Remove(channelName);
|
||||||
|
_channelPublic.Remove(channelName);
|
||||||
|
RefreshChannelList();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Update the topic for a specific channel.
|
/// Update the topic for a specific channel.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -515,9 +708,76 @@ public sealed class MainWindow : Runnable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public void UpdateStatusBar(string status)
|
public void UpdateStatusBar(string status)
|
||||||
{
|
{
|
||||||
var userPart = string.IsNullOrEmpty(_currentUser) ? "" : $" | User: {_currentUser}";
|
_connectionStatus = status;
|
||||||
var channelPart = string.IsNullOrEmpty(_currentChannel) ? "" : $" | #{_currentChannel}";
|
_statusLabel.SetNeedsDraw();
|
||||||
_statusLabel.Text = $" {status}{userPart}{channelPart}";
|
}
|
||||||
|
|
||||||
|
private static readonly Attribute StatusConnectedAttr = new(new Color(0, 200, 0), Color.Transparent);
|
||||||
|
private static readonly Attribute StatusDisconnectedAttr = new(new Color(220, 50, 50), Color.Transparent);
|
||||||
|
private static readonly Attribute StatusTransitionalAttr = new(new Color(220, 180, 0), Color.Transparent);
|
||||||
|
private static readonly Attribute StatusBrandAttr = new(new Color(218, 165, 32), Color.Transparent);
|
||||||
|
|
||||||
|
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 transparent background for colored segments
|
||||||
|
var bg = normalAttr.Background;
|
||||||
|
Attribute Resolve(Attribute a) => a.Background == Color.Transparent ? 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
|
||||||
|
if (!string.IsNullOrEmpty(_currentUser))
|
||||||
|
Write($" \u2502 User: {_currentUser}", normalAttr);
|
||||||
|
|
||||||
|
// Channel + type
|
||||||
|
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>
|
||||||
@@ -552,6 +812,7 @@ public sealed class MainWindow : Runnable
|
|||||||
|
|
||||||
RefreshMessages();
|
RefreshMessages();
|
||||||
UpdateTopicBar();
|
UpdateTopicBar();
|
||||||
|
_statusLabel.SetNeedsDraw();
|
||||||
|
|
||||||
// Update channel list selection
|
// Update channel list selection
|
||||||
var idx = _channelNames.IndexOf(channelName);
|
var idx = _channelNames.IndexOf(channelName);
|
||||||
@@ -583,12 +844,17 @@ public sealed class MainWindow : Runnable
|
|||||||
_channelMessages.Clear();
|
_channelMessages.Clear();
|
||||||
_channelUnread.Clear();
|
_channelUnread.Clear();
|
||||||
_channelTopics.Clear();
|
_channelTopics.Clear();
|
||||||
|
_channelPublic.Clear();
|
||||||
_currentChannel = string.Empty;
|
_currentChannel = string.Empty;
|
||||||
_currentUser = string.Empty;
|
_currentUser = string.Empty;
|
||||||
_channelList.SetSource(new ObservableCollection<string>(_channelNames));
|
_channelListSource.Update([], [], string.Empty);
|
||||||
|
_channelList.Source = _channelListSource;
|
||||||
_chatFrame.Title = "Chat";
|
_chatFrame.Title = "Chat";
|
||||||
_topicLabel.Visible = false;
|
_topicLabel.Visible = false;
|
||||||
_chatFrame.Y = 1;
|
_chatFrame.Y = 1;
|
||||||
|
_usersListSource.Update([]);
|
||||||
|
_usersList.Source = _usersListSource;
|
||||||
|
_usersFrame.Title = "Users";
|
||||||
RefreshMessages();
|
RefreshMessages();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -640,13 +906,8 @@ public sealed class MainWindow : Runnable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private void RefreshChannelList()
|
private void RefreshChannelList()
|
||||||
{
|
{
|
||||||
var displayNames = _channelNames.Select(name =>
|
_channelListSource.Update(_channelNames, _channelUnread, _currentChannel);
|
||||||
{
|
_channelList.Source = _channelListSource;
|
||||||
_channelUnread.TryGetValue(name, out var unread);
|
|
||||||
return unread > 0 ? $"#{name} ({unread})" : $"#{name}";
|
|
||||||
}).ToList();
|
|
||||||
|
|
||||||
_channelList.SetSource(new ObservableCollection<string>(displayNames));
|
|
||||||
|
|
||||||
// Restore selection to current channel
|
// Restore selection to current channel
|
||||||
var idx = _channelNames.IndexOf(_currentChannel);
|
var idx = _channelNames.IndexOf(_currentChannel);
|
||||||
@@ -673,11 +934,66 @@ public sealed class MainWindow : Runnable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adjusts widths of chat, topic, and input frames based on users panel visibility.
|
||||||
|
/// </summary>
|
||||||
|
private void UpdateLayout()
|
||||||
|
{
|
||||||
|
var rightMargin = _usersPanelVisible ? UsersPanelWidth : 0;
|
||||||
|
_chatFrame.Width = Dim.Fill(rightMargin);
|
||||||
|
_topicLabel.Width = Dim.Fill(rightMargin);
|
||||||
|
_inputFrame.Width = Dim.Fill(rightMargin);
|
||||||
|
_usersFrame.Visible = _usersPanelVisible;
|
||||||
|
SetNeedsDraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Toggle the online users panel visibility (F2).
|
||||||
|
/// </summary>
|
||||||
|
public void ToggleUsersPanel()
|
||||||
|
{
|
||||||
|
_usersPanelVisible = !_usersPanelVisible;
|
||||||
|
UpdateLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Update the online users list display.
|
||||||
|
/// </summary>
|
||||||
|
public void UpdateOnlineUsers(List<UserPresenceDto> users)
|
||||||
|
{
|
||||||
|
var displayItems = users.Select(u =>
|
||||||
|
{
|
||||||
|
var statusIcon = u.Status switch
|
||||||
|
{
|
||||||
|
UserStatus.Online => "\u25cf", // ●
|
||||||
|
UserStatus.Away => "\u25cb", // ○
|
||||||
|
UserStatus.DoNotDisturb => "\u25d0", // ◐
|
||||||
|
UserStatus.Invisible => "\u25cc", // ◌
|
||||||
|
_ => " "
|
||||||
|
};
|
||||||
|
var name = u.DisplayName ?? u.Username;
|
||||||
|
var roleTag = u.Role switch
|
||||||
|
{
|
||||||
|
ServerRole.Owner => "\u2605", // ★
|
||||||
|
ServerRole.Admin => "\u2666", // ♦
|
||||||
|
ServerRole.Mod => "\u2740", // ❀
|
||||||
|
_ => ""
|
||||||
|
};
|
||||||
|
var text = $"{statusIcon} {roleTag}{name}";
|
||||||
|
var nameColor = ColorHelper.ParseHexColor(u.NicknameColor);
|
||||||
|
return (text, nameColor);
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
_usersListSource.Update(displayItems);
|
||||||
|
_usersList.Source = _usersListSource;
|
||||||
|
_usersFrame.Title = $"Users ({users.Count})";
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Format a message DTO into one or more display lines based on its MessageType.
|
/// Format a message DTO into one or more display lines based on its MessageType.
|
||||||
/// Timestamps are dimmed and sender names are colored.
|
/// Timestamps are dimmed and sender names are colored.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static List<ChatLine> FormatMessage(MessageDto message)
|
private List<ChatLine> FormatMessage(MessageDto message)
|
||||||
{
|
{
|
||||||
var time = message.SentAt.ToLocalTime().ToString("HH:mm");
|
var time = message.SentAt.ToLocalTime().ToString("HH:mm");
|
||||||
var senderName = message.SenderUsername + ":";
|
var senderName = message.SenderUsername + ":";
|
||||||
@@ -694,36 +1010,77 @@ public sealed class MainWindow : Runnable
|
|||||||
{
|
{
|
||||||
foreach (var artLine in message.Content.Split('\n'))
|
foreach (var artLine in message.Content.Split('\n'))
|
||||||
{
|
{
|
||||||
// Parse ANSI color codes from colored ASCII art
|
// Parse color tags from colored ASCII art
|
||||||
var trimmed = artLine.TrimEnd('\r');
|
var trimmed = artLine.TrimEnd('\r');
|
||||||
if (trimmed.Contains('\x1b'))
|
if (ChatLine.HasColorTags(trimmed))
|
||||||
lines.Add(ChatLine.FromAnsi(" " + trimmed));
|
lines.Add(ChatLine.FromColoredText(" " + trimmed));
|
||||||
else
|
else
|
||||||
lines.Add(new ChatLine($" {trimmed}"));
|
lines.Add(new ChatLine($" {trimmed}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
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:
|
case MessageType.File:
|
||||||
var fileName = message.AttachmentFileName ?? "unknown";
|
var fileName = message.AttachmentFileName ?? "unknown";
|
||||||
var fileContent = !string.IsNullOrWhiteSpace(message.Content) ? $" {message.Content}" : "";
|
var fileSize = FormatFileSize(message.AttachmentFileSize);
|
||||||
lines.Add(BuildChatLine(time, senderName, senderColor, $" [File: {fileName}]{fileContent}"));
|
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;
|
break;
|
||||||
|
|
||||||
case MessageType.Text:
|
case MessageType.Text:
|
||||||
default:
|
default:
|
||||||
var contentLines = message.Content.Split('\n');
|
var displayContent = EmojiHelper.ReplaceEmoji(message.Content);
|
||||||
|
var contentLines = displayContent.Split('\n');
|
||||||
var firstLine = contentLines[0].TrimEnd('\r');
|
var firstLine = contentLines[0].TrimEnd('\r');
|
||||||
lines.Add(BuildChatLine(time, senderName, senderColor, $" {firstLine}"));
|
lines.Add(BuildChatLineWithMentions(time, senderName, senderColor, $" {firstLine}"));
|
||||||
// Continuation lines indented to align with first line's content
|
// Continuation lines indented to align with first line's content
|
||||||
var indent = new string(' ', $"[{time}] {senderName} ".Length);
|
var indent = new string(' ', $"[{time}] {senderName} ".Length);
|
||||||
for (int i = 1; i < contentLines.Length; i++)
|
for (int i = 1; i < contentLines.Length; i++)
|
||||||
{
|
{
|
||||||
lines.Add(new ChatLine($"{indent}{contentLines[i].TrimEnd('\r')}"));
|
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;
|
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;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -740,4 +1097,126 @@ public sealed class MainWindow : Runnable
|
|||||||
};
|
};
|
||||||
return new ChatLine(segments);
|
return new ChatLine(segments);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build a chat line with a colored suffix (used for audio/file indicators).
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private 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"
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ namespace EchoHub.Client.UI;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Result returned from the profile edit dialog.
|
/// Result returned from the profile edit dialog.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public record ProfileEditResult(string? DisplayName, string? Bio, string? NicknameColor);
|
public record ProfileEditResult(string? DisplayName, string? Bio, string? NicknameColor, string? AvatarPath, bool? NotificationSoundEnabled, byte? NotificationVolume);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A Terminal.Gui dialog for editing the user's profile (display name, bio, nickname color).
|
/// A Terminal.Gui dialog for editing the user's profile (display name, bio, nickname color).
|
||||||
@@ -19,11 +19,11 @@ public sealed class ProfileEditDialog
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Shows the profile edit dialog and returns the result, or null if cancelled.
|
/// Shows the profile edit dialog and returns the result, or null if cancelled.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static ProfileEditResult? Show(IApplication app, string? currentDisplayName, string? currentBio, string? currentColor)
|
public static ProfileEditResult? Show(IApplication app, string? currentDisplayName, string? currentBio, string? currentColor, bool notificationSoundEnabled = false, byte notificationVolume = 30)
|
||||||
{
|
{
|
||||||
ProfileEditResult? result = null;
|
ProfileEditResult? result = null;
|
||||||
|
|
||||||
var dialog = new Dialog { Title = "Edit Profile", Width = 60, Height = 18 };
|
var dialog = new Dialog { Title = "Edit Profile", Width = 60, Height = 26 };
|
||||||
|
|
||||||
// Display Name
|
// Display Name
|
||||||
var nameLabel = new Label
|
var nameLabel = new Label
|
||||||
@@ -102,20 +102,99 @@ public sealed class ProfileEditDialog
|
|||||||
UpdateColorPreview(colorPreview, colorField.Text);
|
UpdateColorPreview(colorPreview, colorField.Text);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Avatar
|
||||||
|
var avatarLabel = new Label
|
||||||
|
{
|
||||||
|
Text = "Avatar:",
|
||||||
|
X = 1,
|
||||||
|
Y = 10
|
||||||
|
};
|
||||||
|
var avatarField = new TextField
|
||||||
|
{
|
||||||
|
Text = "",
|
||||||
|
X = 17,
|
||||||
|
Y = 10,
|
||||||
|
Width = Dim.Fill(12)
|
||||||
|
};
|
||||||
|
var browseButton = new Button
|
||||||
|
{
|
||||||
|
Text = "Browse",
|
||||||
|
X = Pos.AnchorEnd(10),
|
||||||
|
Y = 10
|
||||||
|
};
|
||||||
|
var avatarHintLabel = new Label
|
||||||
|
{
|
||||||
|
Text = "(file path or URL)",
|
||||||
|
X = 17,
|
||||||
|
Y = 11
|
||||||
|
};
|
||||||
|
avatarHintLabel.SetScheme(new Scheme
|
||||||
|
{
|
||||||
|
Normal = new Attribute(Color.DarkGray, Color.Blue)
|
||||||
|
});
|
||||||
|
|
||||||
|
browseButton.Accepting += (s, e) =>
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
var openDialog = new OpenDialog
|
||||||
|
{
|
||||||
|
Title = "Select Avatar Image",
|
||||||
|
OpenMode = OpenMode.File,
|
||||||
|
};
|
||||||
|
app.Run(openDialog);
|
||||||
|
if (openDialog.FilePaths.Count > 0)
|
||||||
|
{
|
||||||
|
avatarField.Text = openDialog.FilePaths[0];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Notification Sound
|
||||||
|
var notifCheckbox = new CheckBox
|
||||||
|
{
|
||||||
|
Text = "Notification sound on @mention",
|
||||||
|
X = 1,
|
||||||
|
Y = 13,
|
||||||
|
Value = notificationSoundEnabled ? CheckState.Checked : CheckState.UnChecked
|
||||||
|
};
|
||||||
|
|
||||||
|
var volumeLabel = new Label
|
||||||
|
{
|
||||||
|
Text = "Volume:",
|
||||||
|
X = 1,
|
||||||
|
Y = 15
|
||||||
|
};
|
||||||
|
var volumeField = new TextField
|
||||||
|
{
|
||||||
|
Text = notificationVolume.ToString(),
|
||||||
|
X = 17,
|
||||||
|
Y = 15,
|
||||||
|
Width = 6
|
||||||
|
};
|
||||||
|
var volumeHintLabel = new Label
|
||||||
|
{
|
||||||
|
Text = "(0-100)",
|
||||||
|
X = 24,
|
||||||
|
Y = 15
|
||||||
|
};
|
||||||
|
volumeHintLabel.SetScheme(new Scheme
|
||||||
|
{
|
||||||
|
Normal = new Attribute(Color.DarkGray, Color.Blue)
|
||||||
|
});
|
||||||
|
|
||||||
// Buttons
|
// Buttons
|
||||||
var saveButton = new Button
|
var saveButton = new Button
|
||||||
{
|
{
|
||||||
Text = "Save",
|
Text = "Save",
|
||||||
IsDefault = true,
|
IsDefault = true,
|
||||||
X = Pos.Center() - 10,
|
X = Pos.Center() - 10,
|
||||||
Y = 10
|
Y = 18
|
||||||
};
|
};
|
||||||
|
|
||||||
var cancelButton = new Button
|
var cancelButton = new Button
|
||||||
{
|
{
|
||||||
Text = "Cancel",
|
Text = "Cancel",
|
||||||
X = Pos.Center() + 5,
|
X = Pos.Center() + 5,
|
||||||
Y = 10
|
Y = 18
|
||||||
};
|
};
|
||||||
|
|
||||||
saveButton.Accepting += (s, e) =>
|
saveButton.Accepting += (s, e) =>
|
||||||
@@ -123,8 +202,10 @@ public sealed class ProfileEditDialog
|
|||||||
var displayName = NullIfEmpty(nameField.Text?.Trim());
|
var displayName = NullIfEmpty(nameField.Text?.Trim());
|
||||||
var bio = NullIfEmpty(bioField.Text?.Trim());
|
var bio = NullIfEmpty(bioField.Text?.Trim());
|
||||||
var nicknameColor = NullIfEmpty(colorField.Text?.Trim());
|
var nicknameColor = NullIfEmpty(colorField.Text?.Trim());
|
||||||
|
var avatarPath = NullIfEmpty(avatarField.Text?.Trim());
|
||||||
|
|
||||||
result = new ProfileEditResult(displayName, bio, nicknameColor);
|
byte? volume = byte.TryParse(volumeField.Text, out var v) ? Math.Min(v, (byte)100) : null;
|
||||||
|
result = new ProfileEditResult(displayName, bio, nicknameColor, avatarPath, notifCheckbox.Value == CheckState.Checked, volume);
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
app.RequestStop();
|
app.RequestStop();
|
||||||
};
|
};
|
||||||
@@ -137,7 +218,10 @@ public sealed class ProfileEditDialog
|
|||||||
};
|
};
|
||||||
|
|
||||||
dialog.Add(nameLabel, nameField, bioLabel, bioField, colorLabel, colorField,
|
dialog.Add(nameLabel, nameField, bioLabel, bioField, colorLabel, colorField,
|
||||||
colorHintLabel, previewLabel, colorPreview, saveButton, cancelButton);
|
colorHintLabel, previewLabel, colorPreview,
|
||||||
|
avatarLabel, avatarField, browseButton, avatarHintLabel,
|
||||||
|
notifCheckbox, volumeLabel, volumeField, volumeHintLabel,
|
||||||
|
saveButton, cancelButton);
|
||||||
|
|
||||||
nameField.SetFocus();
|
nameField.SetFocus();
|
||||||
app.Run(dialog);
|
app.Run(dialog);
|
||||||
|
|||||||
@@ -140,12 +140,20 @@ public sealed class ProfileViewDialog
|
|||||||
dialog.Add(bioView);
|
dialog.Add(bioView);
|
||||||
row += 3;
|
row += 3;
|
||||||
|
|
||||||
// ASCII Avatar
|
// ASCII Avatar — render with color tags
|
||||||
if (!string.IsNullOrWhiteSpace(profile.AvatarAscii))
|
if (!string.IsNullOrWhiteSpace(profile.AvatarAscii))
|
||||||
{
|
{
|
||||||
row++;
|
row++;
|
||||||
var avatarLines = profile.AvatarAscii.Split('\n').Length;
|
var rawLines = profile.AvatarAscii.Split('\n');
|
||||||
var avatarHeight = Math.Min(avatarLines + 2, 6);
|
var avatarSource = new ChatListSource();
|
||||||
|
foreach (var line in rawLines)
|
||||||
|
{
|
||||||
|
avatarSource.Add(ChatLine.HasColorTags(line)
|
||||||
|
? ChatLine.FromColoredText(line)
|
||||||
|
: new ChatLine(line));
|
||||||
|
}
|
||||||
|
|
||||||
|
var avatarHeight = Math.Min(rawLines.Length + 2, 24);
|
||||||
var avatarFrame = new FrameView
|
var avatarFrame = new FrameView
|
||||||
{
|
{
|
||||||
Title = "Avatar",
|
Title = "Avatar",
|
||||||
@@ -154,9 +162,22 @@ public sealed class ProfileViewDialog
|
|||||||
Width = Dim.Fill(2),
|
Width = Dim.Fill(2),
|
||||||
Height = avatarHeight
|
Height = avatarHeight
|
||||||
};
|
};
|
||||||
avatarFrame.Add(new Label { Text = profile.AvatarAscii, X = 0, Y = 0 });
|
var avatarList = new ListView
|
||||||
|
{
|
||||||
|
X = 0,
|
||||||
|
Y = 0,
|
||||||
|
Width = Dim.Fill(),
|
||||||
|
Height = Dim.Fill(),
|
||||||
|
Source = avatarSource
|
||||||
|
};
|
||||||
|
avatarFrame.Add(avatarList);
|
||||||
dialog.Add(avatarFrame);
|
dialog.Add(avatarFrame);
|
||||||
// Grow dialog to fit avatar
|
|
||||||
|
// Grow dialog to fit avatar + widen for art
|
||||||
|
var artWidth = rawLines.Max(l => ChatLine.HasColorTags(l)
|
||||||
|
? ChatLine.FromColoredText(l).TextLength
|
||||||
|
: l.Length);
|
||||||
|
dialog.Width = Math.Max(50, artWidth + 6);
|
||||||
dialog.Height = row + avatarHeight + 4;
|
dialog.Height = row + avatarHeight + 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using Terminal.Gui.App;
|
||||||
|
using Terminal.Gui.Views;
|
||||||
|
using Terminal.Gui.ViewBase;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -4,10 +4,21 @@ public static class HubConstants
|
|||||||
{
|
{
|
||||||
public const string ChatHubPath = "/hubs/chat";
|
public const string ChatHubPath = "/hubs/chat";
|
||||||
public const string DefaultChannel = "general";
|
public const string DefaultChannel = "general";
|
||||||
public const int DefaultHistoryCount = 50;
|
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 MaxConsecutiveNewlines = 1;
|
||||||
public const int AsciiArtWidth = 80;
|
public const int AsciiArtWidth = 80;
|
||||||
public const int AsciiArtHeight = 40;
|
public const int AsciiArtHeight = 40;
|
||||||
|
public const int AsciiArtHeightHalfBlock = 80;
|
||||||
|
|
||||||
|
// Link embed constants
|
||||||
|
public const int EmbedMaxDescriptionLength = 500;
|
||||||
|
public const int EmbedMaxHtmlBytes = 64 * 1024; // 64 KB
|
||||||
|
public const int EmbedFetchTimeoutSeconds = 5;
|
||||||
|
public const int EmbedMaxUrlsPerMessage = 3;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -9,5 +9,10 @@ public interface IChatBroadcaster
|
|||||||
Task SendUserLeftAsync(string channelName, string username);
|
Task SendUserLeftAsync(string channelName, string username);
|
||||||
Task SendChannelUpdatedAsync(ChannelDto channel, string? channelName = null);
|
Task SendChannelUpdatedAsync(ChannelDto channel, string? channelName = null);
|
||||||
Task SendUserStatusChangedAsync(List<string> channelNames, UserPresenceDto presence);
|
Task SendUserStatusChangedAsync(List<string> channelNames, UserPresenceDto presence);
|
||||||
|
Task SendUserKickedAsync(string channelName, string username, string? reason);
|
||||||
|
Task SendUserBannedAsync(string username, string? reason);
|
||||||
|
Task SendMessageDeletedAsync(string channelName, Guid messageId);
|
||||||
|
Task SendChannelNukedAsync(string channelName);
|
||||||
Task SendErrorAsync(string connectionId, string message);
|
Task SendErrorAsync(string connectionId, string message);
|
||||||
|
Task ForceDisconnectUserAsync(List<string> connectionIds, string reason);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
|
|||||||
@@ -12,5 +12,10 @@ public interface IEchoHubClient
|
|||||||
Task UserLeft(string channelName, string username);
|
Task UserLeft(string channelName, string username);
|
||||||
Task ChannelUpdated(ChannelDto channel);
|
Task ChannelUpdated(ChannelDto channel);
|
||||||
Task UserStatusChanged(UserPresenceDto presence);
|
Task UserStatusChanged(UserPresenceDto presence);
|
||||||
|
Task UserKicked(string channelName, string username, string? reason);
|
||||||
|
Task UserBanned(string username, string? reason);
|
||||||
|
Task MessageDeleted(string channelName, Guid messageId);
|
||||||
|
Task ChannelNuked(string channelName);
|
||||||
|
Task ForceDisconnect(string reason);
|
||||||
Task Error(string message);
|
Task Error(string message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -11,12 +11,15 @@ public record MessageDto(
|
|||||||
MessageType Type,
|
MessageType Type,
|
||||||
string? AttachmentUrl,
|
string? AttachmentUrl,
|
||||||
string? AttachmentFileName,
|
string? AttachmentFileName,
|
||||||
DateTimeOffset SentAt);
|
DateTimeOffset SentAt,
|
||||||
|
long? AttachmentFileSize = null,
|
||||||
|
List<EmbedDto>? Embeds = null);
|
||||||
|
|
||||||
public record ChannelDto(
|
public record ChannelDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
string Name,
|
string Name,
|
||||||
string? Topic,
|
string? Topic,
|
||||||
|
bool IsPublic,
|
||||||
int MessageCount,
|
int MessageCount,
|
||||||
DateTimeOffset CreatedAt);
|
DateTimeOffset CreatedAt);
|
||||||
|
|
||||||
@@ -30,8 +33,15 @@ public record UserDto(
|
|||||||
|
|
||||||
public record SendMessageRequest(string ChannelName, string Content);
|
public record SendMessageRequest(string ChannelName, string Content);
|
||||||
|
|
||||||
public record CreateChannelRequest(string Name, string? Topic = null);
|
public record CreateChannelRequest(string Name, string? Topic = null, bool IsPublic = true);
|
||||||
|
|
||||||
public record UpdateTopicRequest(string? Topic);
|
public record UpdateTopicRequest(string? Topic);
|
||||||
|
|
||||||
public record SendUrlRequest(string Url);
|
public record SendUrlRequest(string Url);
|
||||||
|
|
||||||
|
public record EmbedDto(
|
||||||
|
string? SiteName,
|
||||||
|
string? Title,
|
||||||
|
string? Description,
|
||||||
|
string? ImageAscii,
|
||||||
|
string Url);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using EchoHub.Core.Models;
|
||||||
|
|
||||||
|
namespace EchoHub.Core.DTOs;
|
||||||
|
|
||||||
|
public record AssignRoleRequest(string Username, ServerRole Role);
|
||||||
|
public record MuteRequest(string? Reason = null, int? DurationMinutes = null);
|
||||||
|
public record BanRequest(string? Reason = null);
|
||||||
|
public record KickRequest(string? Reason = null);
|
||||||
@@ -11,6 +11,7 @@ public record UserProfileDto(
|
|||||||
string? AvatarAscii,
|
string? AvatarAscii,
|
||||||
UserStatus Status,
|
UserStatus Status,
|
||||||
string? StatusMessage,
|
string? StatusMessage,
|
||||||
|
ServerRole Role,
|
||||||
DateTimeOffset CreatedAt,
|
DateTimeOffset CreatedAt,
|
||||||
DateTimeOffset LastSeenAt);
|
DateTimeOffset LastSeenAt);
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ public record UserPresenceDto(
|
|||||||
string? DisplayName,
|
string? DisplayName,
|
||||||
string? NicknameColor,
|
string? NicknameColor,
|
||||||
UserStatus Status,
|
UserStatus Status,
|
||||||
string? StatusMessage);
|
string? StatusMessage,
|
||||||
|
ServerRole Role);
|
||||||
|
|
||||||
public record AvatarUploadResponse(string AvatarAscii);
|
public record AvatarUploadResponse(string AvatarAscii);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ public class Channel
|
|||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
public string? Topic { get; set; }
|
public string? Topic { get; set; }
|
||||||
|
public bool IsPublic { get; set; } = true;
|
||||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
public Guid CreatedByUserId { get; set; }
|
public Guid CreatedByUserId { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace EchoHub.Core.Models;
|
||||||
|
|
||||||
|
public class ChannelMembership
|
||||||
|
{
|
||||||
|
public Guid UserId { get; set; }
|
||||||
|
public Guid ChannelId { get; set; }
|
||||||
|
public DateTimeOffset JoinedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ 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 DateTimeOffset SentAt { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset SentAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
public Guid ChannelId { get; set; }
|
public Guid ChannelId { get; set; }
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ public enum MessageType
|
|||||||
{
|
{
|
||||||
Text,
|
Text,
|
||||||
Image,
|
Image,
|
||||||
File
|
File,
|
||||||
|
Audio
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace EchoHub.Core.Models;
|
||||||
|
|
||||||
|
public enum ServerRole
|
||||||
|
{
|
||||||
|
Member = 0,
|
||||||
|
Mod = 1,
|
||||||
|
Admin = 2,
|
||||||
|
Owner = 3
|
||||||
|
}
|
||||||
@@ -11,6 +11,10 @@ public class User
|
|||||||
public string? AvatarAscii { get; set; }
|
public string? AvatarAscii { get; set; }
|
||||||
public UserStatus Status { get; set; } = UserStatus.Online;
|
public UserStatus Status { get; set; } = UserStatus.Online;
|
||||||
public string? StatusMessage { get; set; }
|
public string? StatusMessage { get; set; }
|
||||||
|
public ServerRole Role { get; set; } = ServerRole.Member;
|
||||||
|
public bool IsMuted { get; set; }
|
||||||
|
public DateTimeOffset? MutedUntil { get; set; }
|
||||||
|
public bool IsBanned { get; set; }
|
||||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
public DateTimeOffset LastSeenAt { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset LastSeenAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,24 @@ using EchoHub.Core.DTOs;
|
|||||||
|
|
||||||
namespace EchoHub.Server.Irc;
|
namespace EchoHub.Server.Irc;
|
||||||
|
|
||||||
public class IrcBroadcaster(IrcGatewayService gateway) : IChatBroadcaster
|
public class IrcBroadcaster : IChatBroadcaster
|
||||||
{
|
{
|
||||||
|
private readonly IrcGatewayService _gateway;
|
||||||
|
private readonly IMessageEncryptionService _encryption;
|
||||||
|
|
||||||
|
public IrcBroadcaster(IrcGatewayService gateway, IMessageEncryptionService encryption)
|
||||||
|
{
|
||||||
|
_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))
|
||||||
{
|
{
|
||||||
// IRC convention: don't echo sender's own message
|
// IRC convention: don't echo sender's own message
|
||||||
if (conn.Nickname == message.SenderUsername)
|
if (conn.Nickname == message.SenderUsername)
|
||||||
@@ -22,7 +33,7 @@ public class IrcBroadcaster(IrcGatewayService gateway) : IChatBroadcaster
|
|||||||
|
|
||||||
public async Task SendUserJoinedAsync(string channelName, string username, string? excludeConnectionId = null)
|
public async Task SendUserJoinedAsync(string channelName, string username, string? excludeConnectionId = null)
|
||||||
{
|
{
|
||||||
foreach (var conn in gateway.GetConnectionsInChannel(channelName))
|
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
||||||
{
|
{
|
||||||
if (conn.ConnectionId == excludeConnectionId) continue;
|
if (conn.ConnectionId == excludeConnectionId) continue;
|
||||||
await conn.SendAsync($":{username}!{username}@echohub JOIN #{channelName}");
|
await conn.SendAsync($":{username}!{username}@echohub JOIN #{channelName}");
|
||||||
@@ -31,7 +42,7 @@ public class IrcBroadcaster(IrcGatewayService gateway) : IChatBroadcaster
|
|||||||
|
|
||||||
public async Task SendUserLeftAsync(string channelName, string username)
|
public async Task SendUserLeftAsync(string channelName, string username)
|
||||||
{
|
{
|
||||||
foreach (var conn in gateway.GetConnectionsInChannel(channelName))
|
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
||||||
{
|
{
|
||||||
if (conn.Nickname == username) continue;
|
if (conn.Nickname == username) continue;
|
||||||
await conn.SendAsync($":{username}!{username}@echohub PART #{channelName}");
|
await conn.SendAsync($":{username}!{username}@echohub PART #{channelName}");
|
||||||
@@ -43,9 +54,9 @@ public class IrcBroadcaster(IrcGatewayService gateway) : IChatBroadcaster
|
|||||||
var target = channelName ?? channel.Name;
|
var target = channelName ?? channel.Name;
|
||||||
if (channel.Topic is null) return;
|
if (channel.Topic is null) return;
|
||||||
|
|
||||||
foreach (var conn in gateway.GetConnectionsInChannel(target))
|
foreach (var conn in _gateway.GetConnectionsInChannel(target))
|
||||||
{
|
{
|
||||||
await conn.SendAsync($":{gateway.Options.ServerName} TOPIC #{channel.Name} :{channel.Topic}");
|
await conn.SendAsync($":{_gateway.Options.ServerName} TOPIC #{channel.Name} :{channel.Topic}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,13 +66,66 @@ public class IrcBroadcaster(IrcGatewayService gateway) : IChatBroadcaster
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task SendUserKickedAsync(string channelName, string username, string? reason)
|
||||||
|
{
|
||||||
|
var reasonText = reason is not null ? $" :{reason}" : "";
|
||||||
|
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
||||||
|
{
|
||||||
|
await conn.SendAsync($":{_gateway.Options.ServerName} KICK #{channelName} {username}{reasonText}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendUserBannedAsync(string username, string? reason)
|
||||||
|
{
|
||||||
|
var reasonText = reason ?? "You have been banned.";
|
||||||
|
foreach (var conn in _gateway.GetAllConnections())
|
||||||
|
{
|
||||||
|
if (conn.Nickname == username)
|
||||||
|
await conn.SendAsync($":{_gateway.Options.ServerName} NOTICE {username} :You have been banned: {reasonText}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendMessageDeletedAsync(string channelName, Guid messageId)
|
||||||
|
{
|
||||||
|
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
||||||
|
{
|
||||||
|
await conn.SendAsync($":{_gateway.Options.ServerName} NOTICE {conn.Nickname ?? "*"} :Message {messageId} was deleted in #{channelName}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendChannelNukedAsync(string channelName)
|
||||||
|
{
|
||||||
|
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
||||||
|
{
|
||||||
|
await conn.SendAsync($":{_gateway.Options.ServerName} NOTICE {conn.Nickname ?? "*"} :All messages in #{channelName} have been cleared");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task SendErrorAsync(string connectionId, string message)
|
public async Task SendErrorAsync(string connectionId, string message)
|
||||||
{
|
{
|
||||||
if (!connectionId.StartsWith("irc-")) return;
|
if (!connectionId.StartsWith("irc-")) return;
|
||||||
|
|
||||||
if (gateway.Connections.TryGetValue(connectionId, out var conn))
|
if (_gateway.Connections.TryGetValue(connectionId, out var conn))
|
||||||
{
|
{
|
||||||
await conn.SendAsync($":{gateway.Options.ServerName} NOTICE {conn.Nickname ?? "*"} :{message}");
|
await conn.SendAsync($":{_gateway.Options.ServerName} NOTICE {conn.Nickname ?? "*"} :{message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ForceDisconnectUserAsync(List<string> connectionIds, string reason)
|
||||||
|
{
|
||||||
|
foreach (var connId in connectionIds)
|
||||||
|
{
|
||||||
|
if (!connId.StartsWith("irc-")) continue;
|
||||||
|
|
||||||
|
if (_gateway.Connections.TryGetValue(connId, out var conn))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await conn.SendAsync($"ERROR :Closing Link: {reason}");
|
||||||
|
await conn.DisposeAsync();
|
||||||
|
}
|
||||||
|
catch { /* connection may already be closed */ }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using System.Net.Security;
|
|||||||
using System.Net.Sockets;
|
using System.Net.Sockets;
|
||||||
using System.Security.Cryptography.X509Certificates;
|
using System.Security.Cryptography.X509Certificates;
|
||||||
using EchoHub.Core.Contracts;
|
using EchoHub.Core.Contracts;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
@@ -13,7 +14,7 @@ namespace EchoHub.Server.Irc;
|
|||||||
public sealed class IrcGatewayService : BackgroundService
|
public sealed class IrcGatewayService : BackgroundService
|
||||||
{
|
{
|
||||||
private readonly IrcOptions _options;
|
private readonly IrcOptions _options;
|
||||||
private readonly IChatService _chatService;
|
private readonly IServiceProvider _services;
|
||||||
private readonly ILogger<IrcGatewayService> _logger;
|
private readonly ILogger<IrcGatewayService> _logger;
|
||||||
private readonly ConcurrentDictionary<string, IrcClientConnection> _connections = new();
|
private readonly ConcurrentDictionary<string, IrcClientConnection> _connections = new();
|
||||||
|
|
||||||
@@ -22,18 +23,23 @@ public sealed class IrcGatewayService : BackgroundService
|
|||||||
|
|
||||||
public IrcGatewayService(
|
public IrcGatewayService(
|
||||||
IOptions<IrcOptions> options,
|
IOptions<IrcOptions> options,
|
||||||
IChatService chatService,
|
IServiceProvider services,
|
||||||
ILogger<IrcGatewayService> logger)
|
ILogger<IrcGatewayService> logger)
|
||||||
{
|
{
|
||||||
_options = options.Value;
|
_options = options.Value;
|
||||||
_chatService = chatService;
|
_services = services;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
||||||
|
{
|
||||||
|
return _connections.Values.Where(c => c.IsAuthenticated);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
@@ -109,10 +115,15 @@ public sealed class IrcGatewayService : BackgroundService
|
|||||||
|
|
||||||
_logger.LogInformation("IRC client connected: {Id}", connection.ConnectionId);
|
_logger.LogInformation("IRC client connected: {Id}", connection.ConnectionId);
|
||||||
|
|
||||||
|
IChatService? chatService = null;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
@@ -124,12 +135,14 @@ public sealed class IrcGatewayService : BackgroundService
|
|||||||
{
|
{
|
||||||
if (connection.IsAuthenticated)
|
if (connection.IsAuthenticated)
|
||||||
{
|
{
|
||||||
foreach (var ch in connection.JoinedChannels.ToList())
|
foreach (var ch in connection.GetJoinedChannels())
|
||||||
{
|
{
|
||||||
await _chatService.LeaveChannelAsync(
|
if (chatService is null) break;
|
||||||
|
await chatService.LeaveChannelAsync(
|
||||||
connection.ConnectionId, connection.Nickname!, ch);
|
connection.ConnectionId, connection.Nickname!, ch);
|
||||||
}
|
}
|
||||||
await _chatService.UserDisconnectedAsync(connection.ConnectionId);
|
if (chatService is not null)
|
||||||
|
await chatService.UserDisconnectedAsync(connection.ConnectionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
_connections.TryRemove(connection.ConnectionId, out _);
|
_connections.TryRemove(connection.ConnectionId, out _);
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
|
|
||||||
namespace EchoHub.Server.Irc;
|
namespace EchoHub.Server.Irc;
|
||||||
|
|
||||||
public static class IrcMessageFormatter
|
public static partial class IrcMessageFormatter
|
||||||
{
|
{
|
||||||
private const int MaxIrcLineContentBytes = 400;
|
private const int MaxIrcLineContentBytes = 400;
|
||||||
|
|
||||||
@@ -22,6 +23,13 @@ public static class IrcMessageFormatter
|
|||||||
case MessageType.Text:
|
case MessageType.Text:
|
||||||
foreach (var chunk in SplitMessage(message.Content, MaxIrcLineContentBytes))
|
foreach (var chunk in SplitMessage(message.Content, MaxIrcLineContentBytes))
|
||||||
lines.Add($"{prefix} PRIVMSG {ircChannel} :{chunk}");
|
lines.Add($"{prefix} PRIVMSG {ircChannel} :{chunk}");
|
||||||
|
|
||||||
|
// Append embed previews if present
|
||||||
|
if (message.Embeds is { Count: > 0 })
|
||||||
|
{
|
||||||
|
foreach (var embed in message.Embeds)
|
||||||
|
lines.AddRange(FormatEmbed(prefix, ircChannel, embed));
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case MessageType.Image:
|
case MessageType.Image:
|
||||||
@@ -33,18 +41,78 @@ public static class IrcMessageFormatter
|
|||||||
{
|
{
|
||||||
var trimmed = line.TrimEnd('\r');
|
var trimmed = line.TrimEnd('\r');
|
||||||
if (trimmed.Length > 0)
|
if (trimmed.Length > 0)
|
||||||
lines.Add($"{prefix} PRIVMSG {ircChannel} :{trimmed}");
|
lines.Add($"{prefix} PRIVMSG {ircChannel} :{ColorTagsToAnsi(trimmed)}");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Format a link embed as IRC PRIVMSG lines (text-only, no ASCII thumbnail).
|
||||||
|
/// </summary>
|
||||||
|
private static List<string> FormatEmbed(string prefix, string ircChannel, EmbedDto embed)
|
||||||
|
{
|
||||||
|
var lines = new List<string>();
|
||||||
|
|
||||||
|
var header = new List<string>();
|
||||||
|
if (!string.IsNullOrWhiteSpace(embed.SiteName))
|
||||||
|
header.Add(embed.SiteName);
|
||||||
|
if (!string.IsNullOrWhiteSpace(embed.Title))
|
||||||
|
header.Add(embed.Title);
|
||||||
|
|
||||||
|
if (header.Count > 0)
|
||||||
|
lines.Add($"{prefix} PRIVMSG {ircChannel} :\u2502 {string.Join(" \u2014 ", header)}");
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(embed.Description))
|
||||||
|
{
|
||||||
|
var desc = embed.Description.Length > 200
|
||||||
|
? embed.Description[..197] + "..."
|
||||||
|
: embed.Description;
|
||||||
|
lines.Add($"{prefix} PRIVMSG {ircChannel} :\u2502 {desc}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Convert printable color tags ({F:RRGGBB}, {B:RRGGBB}, {X}) to ANSI escape codes for IRC clients.
|
||||||
|
/// Also passes through content that already uses ANSI codes unchanged.
|
||||||
|
/// </summary>
|
||||||
|
public static string ColorTagsToAnsi(string text)
|
||||||
|
{
|
||||||
|
if (!text.Contains('{'))
|
||||||
|
return text;
|
||||||
|
|
||||||
|
return ColorTagRegex().Replace(text, match =>
|
||||||
|
{
|
||||||
|
if (match.Groups[1].Success) // {X} reset
|
||||||
|
return "\x1b[0m";
|
||||||
|
if (match.Groups[2].Success) // {F:RRGGBB} or {B:RRGGBB}
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
var code = match.Groups[2].Value == "F" ? "38" : "48";
|
||||||
|
return $"\x1b[{code};2;{r};{g};{b}m";
|
||||||
|
}
|
||||||
|
return match.Value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex(@"\{(?:(X)|(?:(F|B):([0-9A-Fa-f]{6})))\}")]
|
||||||
|
private static partial Regex ColorTagRegex();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Split a message into chunks of approximately maxBytes (UTF-8), at word boundaries.
|
/// Split a message into chunks of approximately maxBytes (UTF-8), at word boundaries.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -15,8 +15,7 @@ public static class IrcServiceExtensions
|
|||||||
if (builder.Configuration.GetValue<bool>("Irc:Enabled"))
|
if (builder.Configuration.GetValue<bool>("Irc:Enabled"))
|
||||||
{
|
{
|
||||||
builder.Services.AddSingleton<IrcGatewayService>();
|
builder.Services.AddSingleton<IrcGatewayService>();
|
||||||
builder.Services.AddSingleton<IChatBroadcaster>(sp =>
|
builder.Services.AddSingleton<IChatBroadcaster, IrcBroadcaster>();
|
||||||
new IrcBroadcaster(sp.GetRequiredService<IrcGatewayService>()));
|
|
||||||
builder.Services.AddHostedService(sp =>
|
builder.Services.AddHostedService(sp =>
|
||||||
sp.GetRequiredService<IrcGatewayService>());
|
sp.GetRequiredService<IrcGatewayService>());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,18 +7,25 @@ using Microsoft.IdentityModel.Tokens;
|
|||||||
|
|
||||||
namespace EchoHub.Server.Auth;
|
namespace EchoHub.Server.Auth;
|
||||||
|
|
||||||
public class JwtTokenService(IConfiguration configuration)
|
public class JwtTokenService
|
||||||
{
|
{
|
||||||
private readonly string _secret = configuration["Jwt:Secret"]
|
private readonly string _secret;
|
||||||
?? throw new InvalidOperationException("Jwt:Secret is not configured.");
|
private readonly string _issuer;
|
||||||
private readonly string _issuer = configuration["Jwt:Issuer"]
|
private readonly string _audience;
|
||||||
?? throw new InvalidOperationException("Jwt:Issuer is not configured.");
|
|
||||||
private readonly string _audience = configuration["Jwt:Audience"]
|
|
||||||
?? throw new InvalidOperationException("Jwt:Audience is not configured.");
|
|
||||||
|
|
||||||
private static readonly TimeSpan AccessTokenLifetime = TimeSpan.FromMinutes(15);
|
private static readonly TimeSpan AccessTokenLifetime = TimeSpan.FromMinutes(15);
|
||||||
public static readonly TimeSpan RefreshTokenLifetime = TimeSpan.FromDays(30);
|
public static readonly TimeSpan RefreshTokenLifetime = TimeSpan.FromDays(30);
|
||||||
|
|
||||||
|
public JwtTokenService(IConfiguration configuration)
|
||||||
|
{
|
||||||
|
_secret = configuration["Jwt:Secret"]
|
||||||
|
?? throw new InvalidOperationException("Jwt:Secret is not configured.");
|
||||||
|
_issuer = configuration["Jwt:Issuer"]
|
||||||
|
?? throw new InvalidOperationException("Jwt:Issuer is not configured.");
|
||||||
|
_audience = configuration["Jwt:Audience"]
|
||||||
|
?? throw new InvalidOperationException("Jwt:Audience is not configured.");
|
||||||
|
}
|
||||||
|
|
||||||
public (string Token, DateTimeOffset ExpiresAt) GenerateAccessToken(User user)
|
public (string Token, DateTimeOffset ExpiresAt) GenerateAccessToken(User user)
|
||||||
{
|
{
|
||||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secret));
|
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secret));
|
||||||
@@ -30,6 +37,7 @@ public class JwtTokenService(IConfiguration configuration)
|
|||||||
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
|
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
|
||||||
new("username", user.Username),
|
new("username", user.Username),
|
||||||
new("display_name", user.DisplayName ?? user.Username),
|
new("display_name", user.DisplayName ?? user.Username),
|
||||||
|
new("role", user.Role.ToString()),
|
||||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,17 @@ namespace EchoHub.Server.Controllers;
|
|||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/auth")]
|
[Route("api/auth")]
|
||||||
[EnableRateLimiting("auth")]
|
[EnableRateLimiting("auth")]
|
||||||
public class AuthController(EchoHubDbContext db, JwtTokenService jwt) : ControllerBase
|
public class AuthController : ControllerBase
|
||||||
{
|
{
|
||||||
|
private readonly EchoHubDbContext _db;
|
||||||
|
private readonly JwtTokenService _jwt;
|
||||||
|
|
||||||
|
public AuthController(EchoHubDbContext db, JwtTokenService jwt)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_jwt = jwt;
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("register")]
|
[HttpPost("register")]
|
||||||
public async Task<IActionResult> Register([FromBody] RegisterRequest request)
|
public async Task<IActionResult> Register([FromBody] RegisterRequest request)
|
||||||
{
|
{
|
||||||
@@ -31,31 +40,35 @@ public class AuthController(EchoHubDbContext db, JwtTokenService jwt) : Controll
|
|||||||
|
|
||||||
var normalizedUsername = request.Username.ToLowerInvariant().Trim();
|
var normalizedUsername = request.Username.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
if (await db.Users.AnyAsync(u => u.Username == normalizedUsername))
|
if (await _db.Users.AnyAsync(u => u.Username == normalizedUsername))
|
||||||
return Conflict(new ErrorResponse("Username is already taken."));
|
return Conflict(new ErrorResponse("Username is already taken."));
|
||||||
|
|
||||||
|
// First registered user on the server becomes the Owner
|
||||||
|
var isFirstUser = !await _db.Users.AnyAsync();
|
||||||
|
|
||||||
var user = new User
|
var user = new User
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
Username = normalizedUsername,
|
Username = normalizedUsername,
|
||||||
PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password),
|
PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password),
|
||||||
DisplayName = request.DisplayName?.Trim(),
|
DisplayName = request.DisplayName?.Trim(),
|
||||||
|
Role = isFirstUser ? ServerRole.Owner : ServerRole.Member,
|
||||||
};
|
};
|
||||||
|
|
||||||
db.Users.Add(user);
|
_db.Users.Add(user);
|
||||||
await db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
var (accessToken, expiresAt) = jwt.GenerateAccessToken(user);
|
var (accessToken, expiresAt) = _jwt.GenerateAccessToken(user);
|
||||||
var refreshToken = JwtTokenService.GenerateRefreshToken();
|
var refreshToken = JwtTokenService.GenerateRefreshToken();
|
||||||
|
|
||||||
db.RefreshTokens.Add(new RefreshToken
|
_db.RefreshTokens.Add(new RefreshToken
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
TokenHash = JwtTokenService.HashToken(refreshToken),
|
TokenHash = JwtTokenService.HashToken(refreshToken),
|
||||||
UserId = user.Id,
|
UserId = user.Id,
|
||||||
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
|
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
|
||||||
});
|
});
|
||||||
await db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
return Ok(new LoginResponse(accessToken, refreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor));
|
return Ok(new LoginResponse(accessToken, refreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor));
|
||||||
}
|
}
|
||||||
@@ -67,25 +80,28 @@ public class AuthController(EchoHubDbContext db, JwtTokenService jwt) : Controll
|
|||||||
return BadRequest(new ErrorResponse("Username and password are required."));
|
return BadRequest(new ErrorResponse("Username and password are required."));
|
||||||
|
|
||||||
var normalizedUsername = request.Username.ToLowerInvariant().Trim();
|
var normalizedUsername = request.Username.ToLowerInvariant().Trim();
|
||||||
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername);
|
var user = await _db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername);
|
||||||
|
|
||||||
if (user is null || !BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash))
|
if (user is null || !BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash))
|
||||||
return Unauthorized(new ErrorResponse("Invalid username or password."));
|
return Unauthorized(new ErrorResponse("Invalid username or password."));
|
||||||
|
|
||||||
user.LastSeenAt = DateTimeOffset.UtcNow;
|
if (user.IsBanned)
|
||||||
await db.SaveChangesAsync();
|
return Unauthorized(new ErrorResponse("Your account has been banned."));
|
||||||
|
|
||||||
var (accessToken, expiresAt) = jwt.GenerateAccessToken(user);
|
user.LastSeenAt = DateTimeOffset.UtcNow;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var (accessToken, expiresAt) = _jwt.GenerateAccessToken(user);
|
||||||
var refreshToken = JwtTokenService.GenerateRefreshToken();
|
var refreshToken = JwtTokenService.GenerateRefreshToken();
|
||||||
|
|
||||||
db.RefreshTokens.Add(new RefreshToken
|
_db.RefreshTokens.Add(new RefreshToken
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
TokenHash = JwtTokenService.HashToken(refreshToken),
|
TokenHash = JwtTokenService.HashToken(refreshToken),
|
||||||
UserId = user.Id,
|
UserId = user.Id,
|
||||||
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
|
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
|
||||||
});
|
});
|
||||||
await db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
return Ok(new LoginResponse(accessToken, refreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor));
|
return Ok(new LoginResponse(accessToken, refreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor));
|
||||||
}
|
}
|
||||||
@@ -97,7 +113,7 @@ public class AuthController(EchoHubDbContext db, JwtTokenService jwt) : Controll
|
|||||||
return BadRequest(new ErrorResponse("Refresh token is required."));
|
return BadRequest(new ErrorResponse("Refresh token is required."));
|
||||||
|
|
||||||
var tokenHash = JwtTokenService.HashToken(request.RefreshToken);
|
var tokenHash = JwtTokenService.HashToken(request.RefreshToken);
|
||||||
var storedToken = await db.RefreshTokens
|
var storedToken = await _db.RefreshTokens
|
||||||
.Include(r => r.User)
|
.Include(r => r.User)
|
||||||
.FirstOrDefaultAsync(r => r.TokenHash == tokenHash);
|
.FirstOrDefaultAsync(r => r.TokenHash == tokenHash);
|
||||||
|
|
||||||
@@ -111,17 +127,17 @@ public class AuthController(EchoHubDbContext db, JwtTokenService jwt) : Controll
|
|||||||
user.LastSeenAt = DateTimeOffset.UtcNow;
|
user.LastSeenAt = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
// Issue new token pair
|
// Issue new token pair
|
||||||
var (accessToken, expiresAt) = jwt.GenerateAccessToken(user);
|
var (accessToken, expiresAt) = _jwt.GenerateAccessToken(user);
|
||||||
var newRefreshToken = JwtTokenService.GenerateRefreshToken();
|
var newRefreshToken = JwtTokenService.GenerateRefreshToken();
|
||||||
|
|
||||||
db.RefreshTokens.Add(new RefreshToken
|
_db.RefreshTokens.Add(new RefreshToken
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
TokenHash = JwtTokenService.HashToken(newRefreshToken),
|
TokenHash = JwtTokenService.HashToken(newRefreshToken),
|
||||||
UserId = user.Id,
|
UserId = user.Id,
|
||||||
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
|
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
|
||||||
});
|
});
|
||||||
await db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
return Ok(new LoginResponse(accessToken, newRefreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor));
|
return Ok(new LoginResponse(accessToken, newRefreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor));
|
||||||
}
|
}
|
||||||
@@ -133,12 +149,12 @@ public class AuthController(EchoHubDbContext db, JwtTokenService jwt) : Controll
|
|||||||
return BadRequest(new ErrorResponse("Refresh token is required."));
|
return BadRequest(new ErrorResponse("Refresh token is required."));
|
||||||
|
|
||||||
var tokenHash = JwtTokenService.HashToken(request.RefreshToken);
|
var tokenHash = JwtTokenService.HashToken(request.RefreshToken);
|
||||||
var storedToken = await db.RefreshTokens.FirstOrDefaultAsync(r => r.TokenHash == tokenHash);
|
var storedToken = await _db.RefreshTokens.FirstOrDefaultAsync(r => r.TokenHash == tokenHash);
|
||||||
|
|
||||||
if (storedToken is not null && storedToken.IsActive)
|
if (storedToken is not null && storedToken.IsActive)
|
||||||
{
|
{
|
||||||
storedToken.RevokedAt = DateTimeOffset.UtcNow;
|
storedToken.RevokedAt = DateTimeOffset.UtcNow;
|
||||||
await db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok();
|
return Ok();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -16,69 +15,64 @@ namespace EchoHub.Server.Controllers;
|
|||||||
[Route("api/channels")]
|
[Route("api/channels")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[EnableRateLimiting("general")]
|
[EnableRateLimiting("general")]
|
||||||
public class ChannelsController(
|
public class ChannelsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IChannelService _channelService;
|
||||||
|
private readonly EchoHubDbContext _db;
|
||||||
|
private readonly FileStorageService _fileStorage;
|
||||||
|
private readonly ImageToAsciiService _asciiService;
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
private readonly IChatService _chatService;
|
||||||
|
private readonly IMessageEncryptionService _encryption;
|
||||||
|
|
||||||
|
public ChannelsController(
|
||||||
|
IChannelService channelService,
|
||||||
EchoHubDbContext db,
|
EchoHubDbContext db,
|
||||||
FileStorageService fileStorage,
|
FileStorageService fileStorage,
|
||||||
ImageToAsciiService asciiService,
|
ImageToAsciiService asciiService,
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
IChatService chatService) : ControllerBase
|
IChatService chatService,
|
||||||
|
IMessageEncryptionService encryption)
|
||||||
{
|
{
|
||||||
|
_channelService = channelService;
|
||||||
|
_db = db;
|
||||||
|
_fileStorage = fileStorage;
|
||||||
|
_asciiService = asciiService;
|
||||||
|
_httpClientFactory = httpClientFactory;
|
||||||
|
_chatService = chatService;
|
||||||
|
_encryption = encryption;
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> GetChannels([FromQuery] int offset = 0, [FromQuery] int limit = 50)
|
public async Task<IActionResult> GetChannels([FromQuery] int offset = 0, [FromQuery] int limit = 50)
|
||||||
{
|
{
|
||||||
|
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (userIdClaim is null)
|
||||||
|
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||||
|
|
||||||
offset = Math.Max(0, offset);
|
offset = Math.Max(0, offset);
|
||||||
limit = Math.Clamp(limit, 1, 100);
|
limit = Math.Clamp(limit, 1, 100);
|
||||||
|
|
||||||
var total = await db.Channels.CountAsync();
|
var result = await _channelService.GetChannelsAsync(Guid.Parse(userIdClaim), offset, limit);
|
||||||
|
return Ok(result);
|
||||||
var channels = await db.Channels
|
|
||||||
.OrderBy(c => c.Name)
|
|
||||||
.Skip(offset)
|
|
||||||
.Take(limit)
|
|
||||||
.Select(c => new ChannelDto(
|
|
||||||
c.Id,
|
|
||||||
c.Name,
|
|
||||||
c.Topic,
|
|
||||||
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(),
|
|
||||||
CreatedByUserId = Guid.Parse(userIdClaim),
|
|
||||||
};
|
|
||||||
|
|
||||||
db.Channels.Add(channel);
|
if (result.Channel!.IsPublic)
|
||||||
await db.SaveChangesAsync();
|
await _chatService.BroadcastChannelUpdatedAsync(result.Channel);
|
||||||
|
|
||||||
var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, 0, channel.CreatedAt);
|
return Created($"/api/channels/{result.Channel.Name}", result.Channel);
|
||||||
await chatService.BroadcastChannelUpdatedAsync(dto);
|
|
||||||
|
|
||||||
return Created($"/api/channels/{channelName}", dto);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{channel}/topic")]
|
[HttpPut("{channel}/topic")]
|
||||||
@@ -88,26 +82,13 @@ public class ChannelsController(
|
|||||||
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, messageCount, dbChannel.CreatedAt);
|
|
||||||
await chatService.BroadcastChannelUpdatedAsync(dto, channelName);
|
|
||||||
|
|
||||||
return Ok(dto);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{channel}")]
|
[HttpDelete("{channel}")]
|
||||||
@@ -117,28 +98,18 @@ public class ChannelsController(
|
|||||||
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."));
|
|
||||||
|
|
||||||
if (dbChannel.CreatedByUserId != Guid.Parse(userIdClaim))
|
|
||||||
return StatusCode(403, new ErrorResponse("Only the channel creator 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")]
|
||||||
public async Task<IActionResult> Upload(string channel)
|
[RequestSizeLimit(HubConstants.MaxFileSizeBytes)]
|
||||||
|
[RequestFormLimits(MultipartBodyLengthLimit = HubConstants.MaxFileSizeBytes)]
|
||||||
|
public async Task<IActionResult> Upload(string channel, [FromQuery] string? size = null)
|
||||||
{
|
{
|
||||||
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
var usernameClaim = User.FindFirstValue("username");
|
var usernameClaim = User.FindFirstValue("username");
|
||||||
@@ -151,8 +122,8 @@ public class ChannelsController(
|
|||||||
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)
|
||||||
@@ -160,22 +131,30 @@ public class ChannelsController(
|
|||||||
|
|
||||||
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 (fileId, filePath) = await fileStorage.SaveFileAsync(stream, file.FileName);
|
var maxSize = isImage ? HubConstants.MaxImageSizeBytes
|
||||||
|
: isAudio ? HubConstants.MaxAudioFileSizeBytes
|
||||||
|
: HubConstants.MaxFileSizeBytes;
|
||||||
|
|
||||||
var messageType = isImage ? MessageType.Image : MessageType.File;
|
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 messageType = isImage ? MessageType.Image
|
||||||
|
: isAudio ? MessageType.Audio
|
||||||
|
: MessageType.File;
|
||||||
string content;
|
string content;
|
||||||
|
|
||||||
if (isImage)
|
if (isImage)
|
||||||
{
|
{
|
||||||
|
var (w, h) = ImageToAsciiService.GetDimensions(size);
|
||||||
using var imageStream = System.IO.File.OpenRead(filePath);
|
using var imageStream = System.IO.File.OpenRead(filePath);
|
||||||
content = asciiService.ConvertToAscii(imageStream);
|
content = _asciiService.ConvertToAscii(imageStream, w, h);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -183,43 +162,47 @@ public class ChannelsController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
};
|
};
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
return Ok(messageDto);
|
return Ok(messageDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{channel}/send-url")]
|
[HttpPost("{channel}/send-url")]
|
||||||
[EnableRateLimiting("upload")]
|
[EnableRateLimiting("upload")]
|
||||||
public async Task<IActionResult> SendUrl(string channel, [FromBody] SendUrlRequest request)
|
public async Task<IActionResult> SendUrl(string channel, [FromBody] SendUrlRequest request, [FromQuery] string? size = null)
|
||||||
{
|
{
|
||||||
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
var usernameClaim = User.FindFirstValue("username");
|
var usernameClaim = User.FindFirstValue("username");
|
||||||
@@ -232,8 +215,8 @@ public class ChannelsController(
|
|||||||
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))
|
||||||
@@ -248,18 +231,18 @@ public class ChannelsController(
|
|||||||
string fileName;
|
string fileName;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var client = httpClientFactory.CreateClient("ImageDownload");
|
using var client = _httpClientFactory.CreateClient("ImageDownload");
|
||||||
using var response = await client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead);
|
using var response = await client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead);
|
||||||
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('.'))
|
||||||
@@ -291,46 +274,61 @@ public class ChannelsController(
|
|||||||
return BadRequest(new ErrorResponse("The URL does not point to a valid image. Supported formats: JPEG, PNG, GIF, WebP."));
|
return BadRequest(new ErrorResponse("The URL does not point to a valid image. Supported formats: JPEG, PNG, GIF, WebP."));
|
||||||
|
|
||||||
// Save file and convert to ASCII
|
// Save file and convert to ASCII
|
||||||
var (fileId, filePath) = await fileStorage.SaveFileAsync(memoryStream, fileName);
|
var (fileId, filePath) = await _fileStorage.SaveFileAsync(memoryStream, fileName);
|
||||||
|
|
||||||
string content;
|
string content;
|
||||||
|
var (w, h) = ImageToAsciiService.GetDimensions(size);
|
||||||
using (var imageStream = System.IO.File.OpenRead(filePath))
|
using (var imageStream = System.IO.File.OpenRead(filePath))
|
||||||
{
|
{
|
||||||
content = asciiService.ConvertToAscii(imageStream);
|
content = _asciiService.ConvertToAscii(imageStream, w, h);
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
};
|
};
|
||||||
|
|
||||||
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.")),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,15 +10,22 @@ namespace EchoHub.Server.Controllers;
|
|||||||
[Route("api/files")]
|
[Route("api/files")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[EnableRateLimiting("general")]
|
[EnableRateLimiting("general")]
|
||||||
public class FilesController(FileStorageService fileStorage) : ControllerBase
|
public class FilesController : ControllerBase
|
||||||
{
|
{
|
||||||
|
private readonly FileStorageService _fileStorage;
|
||||||
|
|
||||||
|
public FilesController(FileStorageService fileStorage)
|
||||||
|
{
|
||||||
|
_fileStorage = fileStorage;
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("{fileId}")]
|
[HttpGet("{fileId}")]
|
||||||
public IActionResult GetFile(string fileId)
|
public IActionResult GetFile(string fileId)
|
||||||
{
|
{
|
||||||
if (!Guid.TryParse(fileId, out _))
|
if (!Guid.TryParse(fileId, out _))
|
||||||
return BadRequest(new ErrorResponse("Invalid file identifier."));
|
return BadRequest(new ErrorResponse("Invalid file identifier."));
|
||||||
|
|
||||||
var filePath = fileStorage.GetFilePath(fileId);
|
var filePath = _fileStorage.GetFilePath(fileId);
|
||||||
|
|
||||||
if (filePath is null)
|
if (filePath is null)
|
||||||
return NotFound(new ErrorResponse("File not found."));
|
return NotFound(new ErrorResponse("File not found."));
|
||||||
@@ -29,6 +36,13 @@ public class FilesController(FileStorageService fileStorage) : 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"
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using EchoHub.Core.Contracts;
|
||||||
|
using EchoHub.Core.DTOs;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
|
using EchoHub.Server.Data;
|
||||||
|
using EchoHub.Server.Services;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/moderation")]
|
||||||
|
[Authorize]
|
||||||
|
[EnableRateLimiting("general")]
|
||||||
|
public class ModerationController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly EchoHubDbContext _db;
|
||||||
|
private readonly IChatService _chatService;
|
||||||
|
private readonly PresenceTracker _presenceTracker;
|
||||||
|
private readonly IEnumerable<IChatBroadcaster> _broadcasters;
|
||||||
|
|
||||||
|
public ModerationController(
|
||||||
|
EchoHubDbContext db,
|
||||||
|
IChatService chatService,
|
||||||
|
PresenceTracker presenceTracker,
|
||||||
|
IEnumerable<IChatBroadcaster> broadcasters)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_chatService = chatService;
|
||||||
|
_presenceTracker = presenceTracker;
|
||||||
|
_broadcasters = broadcasters;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("role")]
|
||||||
|
public async Task<IActionResult> AssignRole([FromBody] AssignRoleRequest request)
|
||||||
|
{
|
||||||
|
var (caller, error) = await GetCallerAsync(ServerRole.Admin);
|
||||||
|
if (error is not null) return error;
|
||||||
|
|
||||||
|
if (request.Role == ServerRole.Owner)
|
||||||
|
return BadRequest(new ErrorResponse("Cannot assign the Owner role."));
|
||||||
|
|
||||||
|
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == request.Username.ToLowerInvariant());
|
||||||
|
if (target is null)
|
||||||
|
return NotFound(new ErrorResponse($"User '{request.Username}' not found."));
|
||||||
|
|
||||||
|
if (target.Role == ServerRole.Owner)
|
||||||
|
return BadRequest(new ErrorResponse("Cannot change the server owner's role."));
|
||||||
|
|
||||||
|
if (request.Role >= caller!.Role)
|
||||||
|
return BadRequest(new ErrorResponse("Cannot assign a role equal to or above your own."));
|
||||||
|
|
||||||
|
target.Role = request.Role;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(new { Message = $"{target.Username} is now {request.Role}." });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("kick/{username}")]
|
||||||
|
public async Task<IActionResult> KickUser(string username, [FromBody] KickRequest? request = null)
|
||||||
|
{
|
||||||
|
var (caller, error) = await GetCallerAsync(ServerRole.Mod);
|
||||||
|
if (error is not null) return error;
|
||||||
|
|
||||||
|
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant());
|
||||||
|
if (target is null)
|
||||||
|
return NotFound(new ErrorResponse($"User '{username}' not found."));
|
||||||
|
|
||||||
|
if (target.Role >= caller!.Role)
|
||||||
|
return BadRequest(new ErrorResponse("Cannot kick a user with equal or higher role."));
|
||||||
|
|
||||||
|
// Broadcast kick to all channels the user is in, then clean up presence
|
||||||
|
var channels = _presenceTracker.GetChannelsForUser(target.Username);
|
||||||
|
foreach (var channel in channels)
|
||||||
|
{
|
||||||
|
await BroadcastToAllAsync(b => b.SendUserKickedAsync(channel, target.Username, request?.Reason));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from presence tracker and force disconnect all connections
|
||||||
|
var reason = request?.Reason ?? "You have been kicked from the server.";
|
||||||
|
await ForceDisconnectAndCleanupAsync(target.Username, reason);
|
||||||
|
|
||||||
|
return Ok(new { Message = $"{target.Username} has been kicked." });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("ban/{username}")]
|
||||||
|
public async Task<IActionResult> BanUser(string username, [FromBody] BanRequest? request = null)
|
||||||
|
{
|
||||||
|
var (caller, error) = await GetCallerAsync(ServerRole.Admin);
|
||||||
|
if (error is not null) return error;
|
||||||
|
|
||||||
|
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant());
|
||||||
|
if (target is null)
|
||||||
|
return NotFound(new ErrorResponse($"User '{username}' not found."));
|
||||||
|
|
||||||
|
if (target.Role >= caller!.Role)
|
||||||
|
return BadRequest(new ErrorResponse("Cannot ban a user with equal or higher role."));
|
||||||
|
|
||||||
|
target.IsBanned = true;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Broadcast ban notification, then force disconnect
|
||||||
|
await BroadcastToAllAsync(b => b.SendUserBannedAsync(target.Username, request?.Reason));
|
||||||
|
|
||||||
|
var reason = request?.Reason ?? "You have been banned from this server.";
|
||||||
|
await ForceDisconnectAndCleanupAsync(target.Username, reason);
|
||||||
|
|
||||||
|
return Ok(new { Message = $"{target.Username} has been banned." });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("unban/{username}")]
|
||||||
|
public async Task<IActionResult> UnbanUser(string username)
|
||||||
|
{
|
||||||
|
var (_, error) = await GetCallerAsync(ServerRole.Admin);
|
||||||
|
if (error is not null) return error;
|
||||||
|
|
||||||
|
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant());
|
||||||
|
if (target is null)
|
||||||
|
return NotFound(new ErrorResponse($"User '{username}' not found."));
|
||||||
|
|
||||||
|
target.IsBanned = false;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(new { Message = $"{target.Username} has been unbanned." });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("mute/{username}")]
|
||||||
|
public async Task<IActionResult> MuteUser(string username, [FromBody] MuteRequest? request = null)
|
||||||
|
{
|
||||||
|
var (caller, error) = await GetCallerAsync(ServerRole.Mod);
|
||||||
|
if (error is not null) return error;
|
||||||
|
|
||||||
|
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant());
|
||||||
|
if (target is null)
|
||||||
|
return NotFound(new ErrorResponse($"User '{username}' not found."));
|
||||||
|
|
||||||
|
if (target.Role >= caller!.Role)
|
||||||
|
return BadRequest(new ErrorResponse("Cannot mute a user with equal or higher role."));
|
||||||
|
|
||||||
|
target.IsMuted = true;
|
||||||
|
target.MutedUntil = request?.DurationMinutes is > 0
|
||||||
|
? DateTimeOffset.UtcNow.AddMinutes(request.DurationMinutes.Value)
|
||||||
|
: null;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var durationText = request?.DurationMinutes is > 0 ? $" for {request.DurationMinutes} minutes" : "";
|
||||||
|
return Ok(new { Message = $"{target.Username} has been muted{durationText}." });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("unmute/{username}")]
|
||||||
|
public async Task<IActionResult> UnmuteUser(string username)
|
||||||
|
{
|
||||||
|
var (_, error) = await GetCallerAsync(ServerRole.Mod);
|
||||||
|
if (error is not null) return error;
|
||||||
|
|
||||||
|
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant());
|
||||||
|
if (target is null)
|
||||||
|
return NotFound(new ErrorResponse($"User '{username}' not found."));
|
||||||
|
|
||||||
|
target.IsMuted = false;
|
||||||
|
target.MutedUntil = null;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(new { Message = $"{target.Username} has been unmuted." });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("messages/{messageId:guid}")]
|
||||||
|
public async Task<IActionResult> DeleteMessage(Guid messageId)
|
||||||
|
{
|
||||||
|
var (_, error) = await GetCallerAsync(ServerRole.Mod);
|
||||||
|
if (error is not null) return error;
|
||||||
|
|
||||||
|
var message = await _db.Messages
|
||||||
|
.Include(m => m.Channel)
|
||||||
|
.FirstOrDefaultAsync(m => m.Id == messageId);
|
||||||
|
|
||||||
|
if (message is null)
|
||||||
|
return NotFound(new ErrorResponse("Message not found."));
|
||||||
|
|
||||||
|
var channelName = message.Channel!.Name;
|
||||||
|
_db.Messages.Remove(message);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await BroadcastToAllAsync(b => b.SendMessageDeletedAsync(channelName, messageId));
|
||||||
|
|
||||||
|
return Ok(new { Message = "Message deleted." });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("channels/{channel}/nuke")]
|
||||||
|
public async Task<IActionResult> NukeChannel(string channel)
|
||||||
|
{
|
||||||
|
var (_, error) = await GetCallerAsync(ServerRole.Mod);
|
||||||
|
if (error is not null) return error;
|
||||||
|
|
||||||
|
var channelName = channel.ToLowerInvariant().Trim();
|
||||||
|
var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||||
|
if (dbChannel is null)
|
||||||
|
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
||||||
|
|
||||||
|
var messages = await _db.Messages.Where(m => m.ChannelId == dbChannel.Id).ToListAsync();
|
||||||
|
_db.Messages.RemoveRange(messages);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await BroadcastToAllAsync(b => b.SendChannelNukedAsync(channelName));
|
||||||
|
|
||||||
|
return Ok(new { Message = $"All messages in #{channelName} have been cleared." });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(User? Caller, IActionResult? Error)> GetCallerAsync(ServerRole minimumRole)
|
||||||
|
{
|
||||||
|
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (userIdClaim is null)
|
||||||
|
return (null, Unauthorized(new ErrorResponse("Authentication required.")));
|
||||||
|
|
||||||
|
var caller = await _db.Users.FindAsync(Guid.Parse(userIdClaim));
|
||||||
|
if (caller is null)
|
||||||
|
return (null, Unauthorized(new ErrorResponse("User not found.")));
|
||||||
|
|
||||||
|
if (caller.Role < minimumRole)
|
||||||
|
return (null, StatusCode(403, new ErrorResponse($"Requires {minimumRole} role or higher.")));
|
||||||
|
|
||||||
|
return (caller, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Remove user from presence tracking, broadcast their departure from all channels,
|
||||||
|
/// send a ForceDisconnect signal, and update their DB status.
|
||||||
|
/// </summary>
|
||||||
|
private async Task ForceDisconnectAndCleanupAsync(string username, string reason)
|
||||||
|
{
|
||||||
|
var (connectionIds, channels) = _presenceTracker.ForceRemoveUser(username);
|
||||||
|
|
||||||
|
// Notify remaining users that this person left each channel
|
||||||
|
foreach (var channel in channels)
|
||||||
|
{
|
||||||
|
await BroadcastToAllAsync(b => b.SendUserLeftAsync(channel, username));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signal the user's clients to disconnect
|
||||||
|
if (connectionIds.Count > 0)
|
||||||
|
{
|
||||||
|
await BroadcastToAllAsync(b => b.ForceDisconnectUserAsync(connectionIds, reason));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark user offline in DB
|
||||||
|
var user = await _db.Users.FirstOrDefaultAsync(u => u.Username == username);
|
||||||
|
if (user is not null)
|
||||||
|
{
|
||||||
|
user.Status = UserStatus.Invisible;
|
||||||
|
user.LastSeenAt = DateTimeOffset.UtcNow;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task BroadcastToAllAsync(Func<IChatBroadcaster, Task> action)
|
||||||
|
{
|
||||||
|
foreach (var broadcaster in _broadcasters)
|
||||||
|
{
|
||||||
|
try { await action(broadcaster); }
|
||||||
|
catch { /* logged by broadcaster */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,26 +1,50 @@
|
|||||||
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;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/server")]
|
[Route("api/server")]
|
||||||
public class ServerController(EchoHubDbContext db, IConfiguration config) : ControllerBase
|
public class ServerController : ControllerBase
|
||||||
{
|
{
|
||||||
|
private readonly EchoHubDbContext _db;
|
||||||
|
private readonly IConfiguration _config;
|
||||||
|
|
||||||
|
public ServerController(EchoHubDbContext db, IConfiguration config)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_config = config;
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("info")]
|
[HttpGet("info")]
|
||||||
public async Task<IActionResult> GetInfo()
|
public async Task<IActionResult> GetInfo()
|
||||||
{
|
{
|
||||||
var userCount = await db.Users.CountAsync();
|
var userCount = await _db.Users.CountAsync();
|
||||||
var channelCount = await db.Channels.CountAsync();
|
var channelCount = await _db.Channels.CountAsync();
|
||||||
|
|
||||||
var status = new ServerStatusDto(
|
var status = new ServerStatusDto(
|
||||||
config["Server:Name"] ?? "EchoHub Server",
|
_config["Server:Name"] ?? "EchoHub Server",
|
||||||
config["Server:Description"],
|
_config["Server:Description"],
|
||||||
userCount,
|
userCount,
|
||||||
channelCount);
|
channelCount);
|
||||||
|
|
||||||
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,13 +14,22 @@ namespace EchoHub.Server.Controllers;
|
|||||||
[Route("api/users")]
|
[Route("api/users")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[EnableRateLimiting("general")]
|
[EnableRateLimiting("general")]
|
||||||
public class UsersController(EchoHubDbContext db, ImageToAsciiService asciiService) : ControllerBase
|
public class UsersController : ControllerBase
|
||||||
{
|
{
|
||||||
|
private readonly EchoHubDbContext _db;
|
||||||
|
private readonly ImageToAsciiService _asciiService;
|
||||||
|
|
||||||
|
public UsersController(EchoHubDbContext db, ImageToAsciiService asciiService)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_asciiService = asciiService;
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("{username}/profile")]
|
[HttpGet("{username}/profile")]
|
||||||
public async Task<IActionResult> GetProfile(string username)
|
public async Task<IActionResult> GetProfile(string username)
|
||||||
{
|
{
|
||||||
var normalizedUsername = username.ToLowerInvariant().Trim();
|
var normalizedUsername = username.ToLowerInvariant().Trim();
|
||||||
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername);
|
var user = await _db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername);
|
||||||
|
|
||||||
if (user is null)
|
if (user is null)
|
||||||
return NotFound(new ErrorResponse("User not found."));
|
return NotFound(new ErrorResponse("User not found."));
|
||||||
@@ -36,7 +45,7 @@ public class UsersController(EchoHubDbContext db, ImageToAsciiService asciiServi
|
|||||||
return Unauthorized(new ErrorResponse("Authentication required."));
|
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||||
|
|
||||||
var userId = Guid.Parse(userIdClaim);
|
var userId = Guid.Parse(userIdClaim);
|
||||||
var user = await db.Users.FindAsync(userId);
|
var user = await _db.Users.FindAsync(userId);
|
||||||
|
|
||||||
if (user is null)
|
if (user is null)
|
||||||
return NotFound(new ErrorResponse("User not found."));
|
return NotFound(new ErrorResponse("User not found."));
|
||||||
@@ -63,7 +72,7 @@ public class UsersController(EchoHubDbContext db, ImageToAsciiService asciiServi
|
|||||||
user.NicknameColor = color.Length > 0 ? color : null;
|
user.NicknameColor = color.Length > 0 ? color : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
return Ok(ToProfileDto(user));
|
return Ok(ToProfileDto(user));
|
||||||
}
|
}
|
||||||
@@ -77,7 +86,7 @@ public class UsersController(EchoHubDbContext db, ImageToAsciiService asciiServi
|
|||||||
return Unauthorized(new ErrorResponse("Authentication required."));
|
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||||
|
|
||||||
var userId = Guid.Parse(userIdClaim);
|
var userId = Guid.Parse(userIdClaim);
|
||||||
var user = await db.Users.FindAsync(userId);
|
var user = await _db.Users.FindAsync(userId);
|
||||||
|
|
||||||
if (user is null)
|
if (user is null)
|
||||||
return NotFound(new ErrorResponse("User not found."));
|
return NotFound(new ErrorResponse("User not found."));
|
||||||
@@ -95,10 +104,10 @@ public class UsersController(EchoHubDbContext db, ImageToAsciiService asciiServi
|
|||||||
if (!FileValidationHelper.IsValidImage(stream))
|
if (!FileValidationHelper.IsValidImage(stream))
|
||||||
return BadRequest(new ErrorResponse("File is not a valid image. Supported formats: JPEG, PNG, GIF, WebP."));
|
return BadRequest(new ErrorResponse("File is not a valid image. Supported formats: JPEG, PNG, GIF, WebP."));
|
||||||
|
|
||||||
var asciiArt = asciiService.ConvertToAscii(stream);
|
var asciiArt = _asciiService.ConvertToAscii(stream);
|
||||||
|
|
||||||
user.AvatarAscii = asciiArt;
|
user.AvatarAscii = asciiArt;
|
||||||
await db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
return Ok(new AvatarUploadResponse(asciiArt));
|
return Ok(new AvatarUploadResponse(asciiArt));
|
||||||
}
|
}
|
||||||
@@ -112,6 +121,7 @@ public class UsersController(EchoHubDbContext db, ImageToAsciiService asciiServi
|
|||||||
user.AvatarAscii,
|
user.AvatarAscii,
|
||||||
user.Status,
|
user.Status,
|
||||||
user.StatusMessage,
|
user.StatusMessage,
|
||||||
|
user.Role,
|
||||||
user.CreatedAt,
|
user.CreatedAt,
|
||||||
user.LastSeenAt);
|
user.LastSeenAt);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|||||||
|
|
||||||
namespace EchoHub.Server.Data;
|
namespace EchoHub.Server.Data;
|
||||||
|
|
||||||
public class EchoHubDbContext(DbContextOptions<EchoHubDbContext> options) : DbContext(options)
|
public class EchoHubDbContext : DbContext
|
||||||
{
|
{
|
||||||
|
public EchoHubDbContext(DbContextOptions<EchoHubDbContext> options) : base(options) { }
|
||||||
public DbSet<User> Users => Set<User>();
|
public DbSet<User> Users => Set<User>();
|
||||||
public DbSet<Channel> Channels => Set<Channel>();
|
public DbSet<Channel> Channels => Set<Channel>();
|
||||||
public DbSet<Message> Messages => Set<Message>();
|
public DbSet<Message> Messages => Set<Message>();
|
||||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||||
|
public DbSet<ChannelMembership> ChannelMemberships => Set<ChannelMembership>();
|
||||||
|
|
||||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
{
|
{
|
||||||
@@ -33,6 +35,7 @@ public class EchoHubDbContext(DbContextOptions<EchoHubDbContext> options) : DbCo
|
|||||||
entity.Property(u => u.NicknameColor).HasMaxLength(7);
|
entity.Property(u => u.NicknameColor).HasMaxLength(7);
|
||||||
entity.Property(u => u.AvatarAscii).HasMaxLength(10000);
|
entity.Property(u => u.AvatarAscii).HasMaxLength(10000);
|
||||||
entity.Property(u => u.StatusMessage).HasMaxLength(100);
|
entity.Property(u => u.StatusMessage).HasMaxLength(100);
|
||||||
|
entity.Property(u => u.Role).HasConversion<int>();
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<Channel>(entity =>
|
modelBuilder.Entity<Channel>(entity =>
|
||||||
@@ -52,10 +55,28 @@ public class EchoHubDbContext(DbContextOptions<EchoHubDbContext> options) : DbCo
|
|||||||
{
|
{
|
||||||
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(32000); // Increased for encrypted embed JSON
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<ChannelMembership>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(cm => new { cm.UserId, cm.ChannelId });
|
||||||
|
entity.HasIndex(cm => cm.UserId);
|
||||||
|
entity.HasIndex(cm => cm.ChannelId);
|
||||||
|
|
||||||
|
entity.HasOne<Channel>()
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(cm => cm.ChannelId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
entity.HasOne<User>()
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(cm => cm.UserId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<RefreshToken>(entity =>
|
modelBuilder.Entity<RefreshToken>(entity =>
|
||||||
|
|||||||
+222
@@ -0,0 +1,222 @@
|
|||||||
|
// <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("20260219162414_AddModerationRoles")]
|
||||||
|
partial class AddModerationRoles
|
||||||
|
{
|
||||||
|
/// <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<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.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(2000)
|
||||||
|
.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.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,61 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddModerationRoles : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsBanned",
|
||||||
|
table: "Users",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsMuted",
|
||||||
|
table: "Users",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<long>(
|
||||||
|
name: "MutedUntil",
|
||||||
|
table: "Users",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "Role",
|
||||||
|
table: "Users",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsBanned",
|
||||||
|
table: "Users");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsMuted",
|
||||||
|
table: "Users");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "MutedUntil",
|
||||||
|
table: "Users");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Role",
|
||||||
|
table: "Users");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+225
@@ -0,0 +1,225 @@
|
|||||||
|
// <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("20260219172834_AddChannelIsPublic")]
|
||||||
|
partial class AddChannelIsPublic
|
||||||
|
{
|
||||||
|
/// <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.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(2000)
|
||||||
|
.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.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,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddChannelIsPublic : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsPublic",
|
||||||
|
table: "Channels",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsPublic",
|
||||||
|
table: "Channels");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+260
@@ -0,0 +1,260 @@
|
|||||||
|
// <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("20260219181720_AddChannelMembership")]
|
||||||
|
partial class AddChannelMembership
|
||||||
|
{
|
||||||
|
/// <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(2000)
|
||||||
|
.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,57 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddChannelMembership : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ChannelMemberships",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
UserId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
ChannelId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
JoinedAt = table.Column<long>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ChannelMemberships", x => new { x.UserId, x.ChannelId });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ChannelMemberships_Channels_ChannelId",
|
||||||
|
column: x => x.ChannelId,
|
||||||
|
principalTable: "Channels",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ChannelMemberships_Users_UserId",
|
||||||
|
column: x => x.UserId,
|
||||||
|
principalTable: "Users",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ChannelMemberships_ChannelId",
|
||||||
|
table: "ChannelMemberships",
|
||||||
|
column: "ChannelId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ChannelMemberships_UserId",
|
||||||
|
table: "ChannelMemberships",
|
||||||
|
column: "UserId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ChannelMemberships");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+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("20260219201704_AddMessageEmbed")]
|
||||||
|
partial class AddMessageEmbed
|
||||||
|
{
|
||||||
|
/// <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(2000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("EmbedJson")
|
||||||
|
.HasMaxLength(8000)
|
||||||
|
.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,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddMessageEmbed : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "EmbedJson",
|
||||||
|
table: "Messages",
|
||||||
|
type: "TEXT",
|
||||||
|
maxLength: 8000,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "EmbedJson",
|
||||||
|
table: "Messages");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,9 @@ namespace EchoHub.Server.Data.Migrations
|
|||||||
b.Property<Guid>("CreatedByUserId")
|
b.Property<Guid>("CreatedByUserId")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPublic")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
@@ -46,6 +49,26 @@ namespace EchoHub.Server.Data.Migrations
|
|||||||
b.ToTable("Channels");
|
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 =>
|
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -56,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");
|
||||||
@@ -65,7 +91,11 @@ namespace EchoHub.Server.Data.Migrations
|
|||||||
|
|
||||||
b.Property<string>("Content")
|
b.Property<string>("Content")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(2000)
|
.HasMaxLength(16000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("EmbedJson")
|
||||||
|
.HasMaxLength(32000)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<Guid>("SenderUserId")
|
b.Property<Guid>("SenderUserId")
|
||||||
@@ -144,9 +174,18 @@ namespace EchoHub.Server.Data.Migrations
|
|||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsBanned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsMuted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<long>("LastSeenAt")
|
b.Property<long>("LastSeenAt")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("MutedUntil")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<string>("NicknameColor")
|
b.Property<string>("NicknameColor")
|
||||||
.HasMaxLength(7)
|
.HasMaxLength(7)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
@@ -155,6 +194,9 @@ namespace EchoHub.Server.Data.Migrations
|
|||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Role")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<int>("Status")
|
b.Property<int>("Status")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
@@ -175,6 +217,21 @@ namespace EchoHub.Server.Data.Migrations
|
|||||||
b.ToTable("Users");
|
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 =>
|
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("EchoHub.Core.Models.Channel", "Channel")
|
b.HasOne("EchoHub.Core.Models.Channel", "Channel")
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="hue_icon.ico" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" />
|
<ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" />
|
||||||
<ProjectReference Include="..\EchoHub.Server.Irc\EchoHub.Server.Irc.csproj" />
|
<ProjectReference Include="..\EchoHub.Server.Irc\EchoHub.Server.Irc.csproj" />
|
||||||
@@ -22,6 +26,8 @@
|
|||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<PackageIcon></PackageIcon>
|
||||||
|
<ApplicationIcon>hue_icon.ico</ApplicationIcon>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -9,8 +9,17 @@ using Microsoft.AspNetCore.SignalR;
|
|||||||
namespace EchoHub.Server.Hubs;
|
namespace EchoHub.Server.Hubs;
|
||||||
|
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public class ChatHub(IChatService chatService, ILogger<ChatHub> logger) : Hub<IEchoHubClient>
|
public class ChatHub : Hub<IEchoHubClient>
|
||||||
{
|
{
|
||||||
|
private readonly IChatService _chatService;
|
||||||
|
private readonly ILogger<ChatHub> _logger;
|
||||||
|
|
||||||
|
public ChatHub(IChatService chatService, ILogger<ChatHub> logger)
|
||||||
|
{
|
||||||
|
_chatService = chatService;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
private Guid CurrentUserId =>
|
private Guid CurrentUserId =>
|
||||||
Guid.Parse(Context.User?.FindFirstValue(ClaimTypes.NameIdentifier)
|
Guid.Parse(Context.User?.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||||
?? throw new HubException("User ID claim not found."));
|
?? throw new HubException("User ID claim not found."));
|
||||||
@@ -23,12 +32,12 @@ public class ChatHub(IChatService chatService, ILogger<ChatHub> logger) : Hub<IE
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await chatService.UserConnectedAsync(Context.ConnectionId, CurrentUserId, CurrentUsername);
|
await _chatService.UserConnectedAsync(Context.ConnectionId, CurrentUserId, CurrentUsername);
|
||||||
await base.OnConnectedAsync();
|
await base.OnConnectedAsync();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Error in OnConnectedAsync for {ConnectionId}", Context.ConnectionId);
|
_logger.LogError(ex, "Error in OnConnectedAsync for {ConnectionId}", Context.ConnectionId);
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,12 +46,12 @@ public class ChatHub(IChatService chatService, ILogger<ChatHub> logger) : Hub<IE
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await chatService.UserDisconnectedAsync(Context.ConnectionId);
|
await _chatService.UserDisconnectedAsync(Context.ConnectionId);
|
||||||
await base.OnDisconnectedAsync(exception);
|
await base.OnDisconnectedAsync(exception);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Error in OnDisconnectedAsync for {ConnectionId}", Context.ConnectionId);
|
_logger.LogError(ex, "Error in OnDisconnectedAsync for {ConnectionId}", Context.ConnectionId);
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -51,7 +60,7 @@ public class ChatHub(IChatService chatService, ILogger<ChatHub> logger) : Hub<IE
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var (history, error) = await chatService.JoinChannelAsync(
|
var (history, error) = await _chatService.JoinChannelAsync(
|
||||||
Context.ConnectionId, CurrentUserId, CurrentUsername, channelName);
|
Context.ConnectionId, CurrentUserId, CurrentUsername, channelName);
|
||||||
|
|
||||||
if (error is not null)
|
if (error is not null)
|
||||||
@@ -65,7 +74,7 @@ public class ChatHub(IChatService chatService, ILogger<ChatHub> logger) : Hub<IE
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Error joining channel '{Channel}' for {User}", channelName, CurrentUsername);
|
_logger.LogError(ex, "Error joining channel '{Channel}' for {User}", channelName, CurrentUsername);
|
||||||
await Clients.Caller.Error($"Failed to join channel: {ex.Message}");
|
await Clients.Caller.Error($"Failed to join channel: {ex.Message}");
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -76,12 +85,12 @@ public class ChatHub(IChatService chatService, ILogger<ChatHub> logger) : Hub<IE
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
channelName = channelName.ToLowerInvariant().Trim();
|
channelName = channelName.ToLowerInvariant().Trim();
|
||||||
await chatService.LeaveChannelAsync(Context.ConnectionId, CurrentUsername, channelName);
|
await _chatService.LeaveChannelAsync(Context.ConnectionId, CurrentUsername, channelName);
|
||||||
await Groups.RemoveFromGroupAsync(Context.ConnectionId, channelName);
|
await Groups.RemoveFromGroupAsync(Context.ConnectionId, channelName);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Error leaving channel '{Channel}' for {User}", channelName, CurrentUsername);
|
_logger.LogError(ex, "Error leaving channel '{Channel}' for {User}", channelName, CurrentUsername);
|
||||||
await Clients.Caller.Error($"Failed to leave channel: {ex.Message}");
|
await Clients.Caller.Error($"Failed to leave channel: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,13 +99,13 @@ public class ChatHub(IChatService chatService, ILogger<ChatHub> logger) : Hub<IE
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var error = await chatService.SendMessageAsync(CurrentUserId, CurrentUsername, channelName, content);
|
var error = await _chatService.SendMessageAsync(CurrentUserId, CurrentUsername, channelName, content);
|
||||||
if (error is not null)
|
if (error is not null)
|
||||||
await Clients.Caller.Error(error);
|
await Clients.Caller.Error(error);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Error sending message in '{Channel}' for {User}", channelName, CurrentUsername);
|
_logger.LogError(ex, "Error sending message in '{Channel}' for {User}", channelName, CurrentUsername);
|
||||||
await Clients.Caller.Error($"Failed to send message: {ex.Message}");
|
await Clients.Caller.Error($"Failed to send message: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,11 +114,11 @@ public class ChatHub(IChatService chatService, ILogger<ChatHub> logger) : Hub<IE
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return await chatService.GetChannelHistoryAsync(channelName, count);
|
return await _chatService.GetChannelHistoryAsync(channelName, count);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Error fetching history for '{Channel}'", channelName);
|
_logger.LogError(ex, "Error fetching history for '{Channel}'", channelName);
|
||||||
await Clients.Caller.Error($"Failed to load history: {ex.Message}");
|
await Clients.Caller.Error($"Failed to load history: {ex.Message}");
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -119,13 +128,13 @@ public class ChatHub(IChatService chatService, ILogger<ChatHub> logger) : Hub<IE
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var error = await chatService.UpdateStatusAsync(CurrentUserId, CurrentUsername, status, statusMessage);
|
var error = await _chatService.UpdateStatusAsync(CurrentUserId, CurrentUsername, status, statusMessage);
|
||||||
if (error is not null)
|
if (error is not null)
|
||||||
await Clients.Caller.Error(error);
|
await Clients.Caller.Error(error);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Error updating status for {User}", CurrentUsername);
|
_logger.LogError(ex, "Error updating status for {User}", CurrentUsername);
|
||||||
await Clients.Caller.Error($"Failed to update status: {ex.Message}");
|
await Clients.Caller.Error($"Failed to update status: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,11 +143,11 @@ public class ChatHub(IChatService chatService, ILogger<ChatHub> logger) : Hub<IE
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return await chatService.GetOnlineUsersAsync(channelName);
|
return await _chatService.GetOnlineUsersAsync(channelName);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Error listing users in '{Channel}'", channelName);
|
_logger.LogError(ex, "Error listing users in '{Channel}'", channelName);
|
||||||
await Clients.Caller.Error($"Failed to list users: {ex.Message}");
|
await Clients.Caller.Error($"Failed to list users: {ex.Message}");
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ while (true)
|
|||||||
{
|
{
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// ── Host options ────────────────────────────────────────────────────
|
||||||
|
builder.Services.Configure<HostOptions>(options =>
|
||||||
|
options.ShutdownTimeout = TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
// ── Serilog ──────────────────────────────────────────────────────────
|
// ── Serilog ──────────────────────────────────────────────────────────
|
||||||
builder.Host.UseSerilog((context, config) =>
|
builder.Host.UseSerilog((context, config) =>
|
||||||
config.ReadFrom.Configuration(context.Configuration));
|
config.ReadFrom.Configuration(context.Configuration));
|
||||||
@@ -102,10 +106,16 @@ while (true)
|
|||||||
builder.Services.AddSingleton<PresenceTracker>();
|
builder.Services.AddSingleton<PresenceTracker>();
|
||||||
builder.Services.AddSingleton<ImageToAsciiService>();
|
builder.Services.AddSingleton<ImageToAsciiService>();
|
||||||
builder.Services.AddSingleton<FileStorageService>();
|
builder.Services.AddSingleton<FileStorageService>();
|
||||||
|
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) ──────────────────────────────────────────
|
||||||
@@ -117,6 +127,13 @@ while (true)
|
|||||||
client.MaxResponseContentBufferSize = 10 * 1024 * 1024; // 10 MB
|
client.MaxResponseContentBufferSize = 10 * 1024 * 1024; // 10 MB
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder.Services.AddHttpClient("OgFetch", client =>
|
||||||
|
{
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(5);
|
||||||
|
client.MaxResponseContentBufferSize = 256 * 1024; // 256 KB
|
||||||
|
client.DefaultRequestHeaders.UserAgent.ParseAdd("EchoHub/1.0 (Link Preview Bot)");
|
||||||
|
});
|
||||||
|
|
||||||
// ── Rate Limiting ────────────────────────────────────────────────────
|
// ── Rate Limiting ────────────────────────────────────────────────────
|
||||||
builder.Services.AddRateLimiter(options =>
|
builder.Services.AddRateLimiter(options =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Text.Json;
|
||||||
using EchoHub.Core.Constants;
|
using EchoHub.Core.Constants;
|
||||||
using EchoHub.Core.Contracts;
|
using EchoHub.Core.Contracts;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
@@ -9,17 +10,39 @@ using Microsoft.Extensions.Logging;
|
|||||||
|
|
||||||
namespace EchoHub.Server.Services;
|
namespace EchoHub.Server.Services;
|
||||||
|
|
||||||
public class ChatService(
|
public class ChatService : IChatService
|
||||||
|
{
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly PresenceTracker _presenceTracker;
|
||||||
|
private readonly IEnumerable<IChatBroadcaster> _broadcasters;
|
||||||
|
private readonly LinkEmbedService _embedService;
|
||||||
|
private readonly IMessageEncryptionService _encryption;
|
||||||
|
private readonly IChannelService _channelService;
|
||||||
|
private readonly ILogger<ChatService> _logger;
|
||||||
|
|
||||||
|
public ChatService(
|
||||||
IServiceScopeFactory scopeFactory,
|
IServiceScopeFactory scopeFactory,
|
||||||
PresenceTracker presenceTracker,
|
PresenceTracker presenceTracker,
|
||||||
IEnumerable<IChatBroadcaster> broadcasters,
|
IEnumerable<IChatBroadcaster> broadcasters,
|
||||||
ILogger<ChatService> logger) : IChatService
|
LinkEmbedService embedService,
|
||||||
|
IMessageEncryptionService encryption,
|
||||||
|
IChannelService channelService,
|
||||||
|
ILogger<ChatService> logger)
|
||||||
{
|
{
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
_presenceTracker = presenceTracker;
|
||||||
|
_broadcasters = broadcasters;
|
||||||
|
_embedService = embedService;
|
||||||
|
_encryption = encryption;
|
||||||
|
_channelService = channelService;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task UserConnectedAsync(string connectionId, Guid userId, string username)
|
public async Task UserConnectedAsync(string connectionId, Guid userId, string username)
|
||||||
{
|
{
|
||||||
presenceTracker.UserConnected(connectionId, userId, username);
|
_presenceTracker.UserConnected(connectionId, userId, username);
|
||||||
|
|
||||||
using var scope = scopeFactory.CreateScope();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
var user = await db.Users.FindAsync(userId);
|
var user = await db.Users.FindAsync(userId);
|
||||||
@@ -30,21 +53,21 @@ public class ChatService(
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.LogInformation("{User} connected (ConnectionId: {ConnectionId})", username, connectionId);
|
_logger.LogInformation("{User} connected (ConnectionId: {ConnectionId})", username, connectionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string?> UserDisconnectedAsync(string connectionId)
|
public async Task<string?> UserDisconnectedAsync(string connectionId)
|
||||||
{
|
{
|
||||||
var preDisconnectUsername = presenceTracker.GetUsernameForConnection(connectionId);
|
var preDisconnectUsername = _presenceTracker.GetUsernameForConnection(connectionId);
|
||||||
var channelsBeforeDisconnect = preDisconnectUsername is not null
|
var channelsBeforeDisconnect = preDisconnectUsername is not null
|
||||||
? presenceTracker.GetChannelsForUser(preDisconnectUsername)
|
? _presenceTracker.GetChannelsForUser(preDisconnectUsername)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
var username = presenceTracker.UserDisconnected(connectionId);
|
var username = _presenceTracker.UserDisconnected(connectionId);
|
||||||
|
|
||||||
if (username is not null && !presenceTracker.IsOnline(username))
|
if (username is not null && !_presenceTracker.IsOnline(username))
|
||||||
{
|
{
|
||||||
using var scope = scopeFactory.CreateScope();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username);
|
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username);
|
||||||
@@ -59,13 +82,14 @@ public class ChatService(
|
|||||||
user.DisplayName,
|
user.DisplayName,
|
||||||
user.NicknameColor,
|
user.NicknameColor,
|
||||||
UserStatus.Invisible,
|
UserStatus.Invisible,
|
||||||
user.StatusMessage);
|
user.StatusMessage,
|
||||||
|
user.Role);
|
||||||
|
|
||||||
await BroadcastToAllAsync(b => b.SendUserStatusChangedAsync(channelsBeforeDisconnect, presence));
|
await BroadcastToAllAsync(b => b.SendUserStatusChangedAsync(channelsBeforeDisconnect, presence));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.LogInformation("{User} disconnected (ConnectionId: {ConnectionId})", username ?? "Unknown", connectionId);
|
_logger.LogInformation("{User} disconnected (ConnectionId: {ConnectionId})", username ?? "Unknown", connectionId);
|
||||||
return username;
|
return username;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,34 +98,29 @@ public class ChatService(
|
|||||||
{
|
{
|
||||||
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)
|
||||||
|
return ([], error);
|
||||||
|
|
||||||
using var scope = scopeFactory.CreateScope();
|
var isNewJoin = _presenceTracker.JoinChannel(username, channelName);
|
||||||
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.");
|
|
||||||
|
|
||||||
var isNewJoin = presenceTracker.JoinChannel(username, channelName);
|
|
||||||
|
|
||||||
if (isNewJoin)
|
if (isNewJoin)
|
||||||
{
|
{
|
||||||
await BroadcastToAllAsync(b => b.SendUserJoinedAsync(channelName, username, connectionId));
|
await BroadcastToAllAsync(b => b.SendUserJoinedAsync(channelName, username, connectionId));
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task LeaveChannelAsync(string connectionId, string username, string channelName)
|
public async Task LeaveChannelAsync(string connectionId, string username, string channelName)
|
||||||
{
|
{
|
||||||
channelName = channelName.ToLowerInvariant().Trim();
|
channelName = channelName.ToLowerInvariant().Trim();
|
||||||
presenceTracker.LeaveChannel(username, channelName);
|
_presenceTracker.LeaveChannel(username, channelName);
|
||||||
await BroadcastToAllAsync(b => b.SendUserLeftAsync(channelName, username));
|
await BroadcastToAllAsync(b => b.SendUserLeftAsync(channelName, username));
|
||||||
logger.LogInformation("{User} left channel '{Channel}'", username, channelName);
|
_logger.LogInformation("{User} left channel '{Channel}'", username, channelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content)
|
public async Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content)
|
||||||
@@ -111,13 +130,23 @@ public class ChatService(
|
|||||||
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.";
|
||||||
|
|
||||||
using var scope = scopeFactory.CreateScope();
|
// Sanitize on plaintext: collapse excessive newlines
|
||||||
|
plaintext = SanitizeNewlines(plaintext);
|
||||||
|
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||||
@@ -126,34 +155,69 @@ public class ChatService(
|
|||||||
|
|
||||||
var sender = await db.Users.FindAsync(userId);
|
var sender = await db.Users.FindAsync(userId);
|
||||||
|
|
||||||
|
// Check mute status
|
||||||
|
if (sender is not null && sender.IsMuted)
|
||||||
|
{
|
||||||
|
if (sender.MutedUntil.HasValue && sender.MutedUntil.Value <= DateTimeOffset.UtcNow)
|
||||||
|
{
|
||||||
|
sender.IsMuted = false;
|
||||||
|
sender.MutedUntil = null;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return "You are muted and cannot send messages.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to fetch link embeds for URLs in the plaintext message
|
||||||
|
List<EmbedDto>? embeds = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
embeds = await _embedService.TryGetEmbedsAsync(plaintext);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_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 = 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,
|
||||||
MessageType.Text,
|
MessageType.Text,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
message.SentAt);
|
message.SentAt,
|
||||||
|
Embeds: embeds);
|
||||||
|
|
||||||
await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto));
|
await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto));
|
||||||
|
|
||||||
logger.LogDebug("{User} sent message in '{Channel}'", username, channelName);
|
_logger.LogDebug("{User} sent message in '{Channel}'", username, channelName);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +226,7 @@ public class ChatService(
|
|||||||
channelName = channelName.ToLowerInvariant().Trim();
|
channelName = channelName.ToLowerInvariant().Trim();
|
||||||
count = Math.Clamp(count, 1, ValidationConstants.MaxHistoryCount);
|
count = Math.Clamp(count, 1, ValidationConstants.MaxHistoryCount);
|
||||||
|
|
||||||
using var scope = scopeFactory.CreateScope();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
return await GetChannelHistoryInternalAsync(db, channelName, count);
|
return await GetChannelHistoryInternalAsync(db, channelName, count);
|
||||||
@@ -173,7 +237,7 @@ public class ChatService(
|
|||||||
if (statusMessage is not null && statusMessage.Length > ValidationConstants.MaxStatusMessageLength)
|
if (statusMessage is not null && statusMessage.Length > ValidationConstants.MaxStatusMessageLength)
|
||||||
return $"Status message must not exceed {ValidationConstants.MaxStatusMessageLength} characters.";
|
return $"Status message must not exceed {ValidationConstants.MaxStatusMessageLength} characters.";
|
||||||
|
|
||||||
using var scope = scopeFactory.CreateScope();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
var user = await db.Users.FindAsync(userId);
|
var user = await db.Users.FindAsync(userId);
|
||||||
@@ -190,9 +254,10 @@ public class ChatService(
|
|||||||
user.DisplayName,
|
user.DisplayName,
|
||||||
user.NicknameColor,
|
user.NicknameColor,
|
||||||
status,
|
status,
|
||||||
statusMessage);
|
statusMessage,
|
||||||
|
user.Role);
|
||||||
|
|
||||||
var channels = presenceTracker.GetChannelsForUser(username);
|
var channels = _presenceTracker.GetChannelsForUser(username);
|
||||||
await BroadcastToAllAsync(b => b.SendUserStatusChangedAsync(channels, presence));
|
await BroadcastToAllAsync(b => b.SendUserStatusChangedAsync(channels, presence));
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -201,9 +266,9 @@ public class ChatService(
|
|||||||
public async Task<List<UserPresenceDto>> GetOnlineUsersAsync(string channelName)
|
public async Task<List<UserPresenceDto>> GetOnlineUsersAsync(string channelName)
|
||||||
{
|
{
|
||||||
channelName = channelName.ToLowerInvariant().Trim();
|
channelName = channelName.ToLowerInvariant().Trim();
|
||||||
var onlineUsernames = presenceTracker.GetOnlineUsersInChannel(channelName);
|
var onlineUsernames = _presenceTracker.GetOnlineUsersInChannel(channelName);
|
||||||
|
|
||||||
using var scope = scopeFactory.CreateScope();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
return await db.Users
|
return await db.Users
|
||||||
@@ -213,7 +278,8 @@ public class ChatService(
|
|||||||
u.DisplayName,
|
u.DisplayName,
|
||||||
u.NicknameColor,
|
u.NicknameColor,
|
||||||
u.Status,
|
u.Status,
|
||||||
u.StatusMessage))
|
u.StatusMessage,
|
||||||
|
u.Role))
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,7 +291,7 @@ public class ChatService(
|
|||||||
|
|
||||||
private async Task BroadcastToAllAsync(Func<IChatBroadcaster, Task> action)
|
private async Task BroadcastToAllAsync(Func<IChatBroadcaster, Task> action)
|
||||||
{
|
{
|
||||||
foreach (var broadcaster in broadcasters)
|
foreach (var broadcaster in _broadcasters)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -233,7 +299,7 @@ public class ChatService(
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Broadcaster {Type} failed", broadcaster.GetType().Name);
|
_logger.LogError(ex, "Broadcaster {Type} failed", broadcaster.GetType().Name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -242,7 +308,7 @@ public class ChatService(
|
|||||||
{
|
{
|
||||||
username = username.ToLowerInvariant();
|
username = username.ToLowerInvariant();
|
||||||
|
|
||||||
using var scope = scopeFactory.CreateScope();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username);
|
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username);
|
||||||
@@ -251,43 +317,17 @@ public class ChatService(
|
|||||||
return new UserProfileDto(
|
return new UserProfileDto(
|
||||||
user.Id, user.Username, user.DisplayName, user.Bio,
|
user.Id, user.Username, user.DisplayName, user.Bio,
|
||||||
user.NicknameColor, user.AvatarAscii, user.Status,
|
user.NicknameColor, user.AvatarAscii, user.Status,
|
||||||
user.StatusMessage, 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));
|
||||||
|
|
||||||
public async Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password)
|
public async Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password)
|
||||||
{
|
{
|
||||||
username = username.ToLowerInvariant();
|
username = username.ToLowerInvariant();
|
||||||
|
|
||||||
using var scope = scopeFactory.CreateScope();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username);
|
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username);
|
||||||
@@ -299,32 +339,85 @@ public class ChatService(
|
|||||||
return (user.Id, user.Username);
|
return (user.Id, user.Username);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<List<MessageDto>> GetChannelHistoryInternalAsync(EchoHubDbContext db, string channelName, int count)
|
/// <summary>
|
||||||
|
/// Collapse consecutive newlines and cap total line count to prevent newline spam.
|
||||||
|
/// </summary>
|
||||||
|
private static string SanitizeNewlines(string content)
|
||||||
|
{
|
||||||
|
// Normalize \r\n → \n
|
||||||
|
content = content.Replace("\r\n", "\n").Replace('\r', '\n');
|
||||||
|
|
||||||
|
// Collapse consecutive blank/whitespace-only lines into max 1 blank line
|
||||||
|
var lines = content.Split('\n');
|
||||||
|
var result = new List<string>(lines.Length);
|
||||||
|
int consecutiveBlanks = 0;
|
||||||
|
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(line))
|
||||||
|
{
|
||||||
|
consecutiveBlanks++;
|
||||||
|
if (consecutiveBlanks <= HubConstants.MaxConsecutiveNewlines)
|
||||||
|
result.Add(line);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
consecutiveBlanks = 0;
|
||||||
|
result.Add(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cap total lines
|
||||||
|
if (result.Count > HubConstants.MaxMessageNewlines)
|
||||||
|
result = result.Take(HubConstants.MaxMessageNewlines).ToList();
|
||||||
|
|
||||||
|
return string.Join('\n', result);
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
return [];
|
return [];
|
||||||
|
|
||||||
var messages = await db.Messages
|
var raw = await db.Messages
|
||||||
.Where(m => m.ChannelId == channel.Id)
|
.Where(m => m.ChannelId == channel.Id)
|
||||||
.OrderByDescending(m => m.SentAt)
|
.OrderByDescending(m => m.SentAt)
|
||||||
.Take(count)
|
.Take(count)
|
||||||
.Join(db.Users,
|
.Join(db.Users,
|
||||||
m => m.SenderUserId,
|
m => m.SenderUserId,
|
||||||
u => u.Id,
|
u => u.Id,
|
||||||
(m, u) => new MessageDto(
|
(m, u) => new { m, u.NicknameColor })
|
||||||
m.Id,
|
|
||||||
m.Content,
|
|
||||||
m.SenderUsername,
|
|
||||||
u.NicknameColor,
|
|
||||||
channelName,
|
|
||||||
m.Type,
|
|
||||||
m.AttachmentUrl,
|
|
||||||
m.AttachmentFileName,
|
|
||||||
m.SentAt))
|
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
messages.Reverse();
|
raw.Reverse();
|
||||||
return messages;
|
|
||||||
|
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;
|
||||||
|
if (embedJsonPlain is not null)
|
||||||
|
{
|
||||||
|
try { embeds = JsonSerializer.Deserialize<List<EmbedDto>>(embedJsonPlain); }
|
||||||
|
catch { /* ignore malformed JSON */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt for transport — client decrypts
|
||||||
|
return new MessageDto(
|
||||||
|
x.m.Id,
|
||||||
|
_encryption.Encrypt(plaintext),
|
||||||
|
x.m.SenderUsername,
|
||||||
|
x.NicknameColor,
|
||||||
|
channelName,
|
||||||
|
x.m.Type,
|
||||||
|
x.m.AttachmentUrl,
|
||||||
|
x.m.AttachmentFileName,
|
||||||
|
x.m.SentAt,
|
||||||
|
x.m.AttachmentFileSize,
|
||||||
|
embeds);
|
||||||
|
}).ToList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user