Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e343a9c9b | |||
| 9371616508 | |||
| 5951c91102 | |||
| ff431d7f81 | |||
| aa5fdbe8c4 | |||
| 4da08d91bc | |||
| 11b1089126 | |||
| fd7fa4a147 | |||
| 7bf1eaf845 | |||
| 13bd4478f6 | |||
| 53530ce6dd | |||
| f215f000b8 | |||
| 191fe2a89f | |||
| eb5f38de1c | |||
| 51eb2ed310 | |||
| bd73d79b56 | |||
| 4b32655e67 | |||
| c839e67c47 | |||
| 5bdb758d23 | |||
| 56af584ede | |||
| 87d7345155 | |||
| a7646481a4 | |||
| 5d37fb899d | |||
| af913b9923 | |||
| 9a6f15662b | |||
| 7d1bd02e31 | |||
| 8542243745 | |||
| 90bddc65d4 | |||
| 67a5a54244 | |||
| 33c9dc4f15 | |||
| e6dfe43926 | |||
| 62e8354d03 | |||
| 1139e90fc3 | |||
| 60edc0b5b4 | |||
| 836bcd8b8d | |||
| 8fd649356c | |||
| 4a02d054cc |
@@ -6,14 +6,20 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-linux:
|
build-linux:
|
||||||
runs-on: linux
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with: { node-version: 20 }
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: 'web/package-lock.json'
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: 'web/src-tauri'
|
||||||
- name: Install system deps
|
- name: Install system deps
|
||||||
run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libappindicator3-dev librsvg2-dev patchelf
|
run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libappindicator3-dev librsvg2-dev patchelf rpm
|
||||||
- name: Build frontend
|
- name: Build frontend
|
||||||
run: cd web && npm ci && npm run build
|
run: cd web && npm ci && npm run build
|
||||||
- name: Build Tauri bundles
|
- name: Build Tauri bundles
|
||||||
@@ -21,7 +27,7 @@ jobs:
|
|||||||
NO_STRIP: "true"
|
NO_STRIP: "true"
|
||||||
run: cd web && npx tauri build
|
run: cd web && npx tauri build
|
||||||
- name: Upload artifacts
|
- name: Upload artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: linux-bundles
|
name: linux-bundles
|
||||||
path: |
|
path: |
|
||||||
@@ -34,14 +40,30 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with: { node-version: 20 }
|
with:
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
node-version: 20
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: 'web/package-lock.json'
|
||||||
|
- name: Install Rust
|
||||||
|
run: |
|
||||||
|
curl.exe -sLO https://win.rustup.rs/x86_64/rustup-init.exe
|
||||||
|
rustup-init.exe -y --default-toolchain stable --profile minimal
|
||||||
|
set PATH=%USERPROFILE%\.cargo\bin;%PATH%
|
||||||
|
rustc --version
|
||||||
|
shell: cmd
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: 'web/src-tauri'
|
||||||
- name: Build frontend
|
- name: Build frontend
|
||||||
run: cd web; npm ci; npm run build
|
run: cd web && npm ci && npm run build
|
||||||
|
shell: cmd
|
||||||
- name: Build Tauri bundles
|
- name: Build Tauri bundles
|
||||||
run: cd web; npx tauri build
|
env:
|
||||||
|
CI: "true"
|
||||||
|
run: cd web && set PATH=%USERPROFILE%\.cargo\bin;%PATH% && npx tauri build
|
||||||
|
shell: cmd
|
||||||
- name: Upload artifacts
|
- name: Upload artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: windows-bundles
|
name: windows-bundles
|
||||||
path: |
|
path: |
|
||||||
@@ -50,13 +72,15 @@ jobs:
|
|||||||
|
|
||||||
release:
|
release:
|
||||||
needs: [build-linux, build-windows]
|
needs: [build-linux, build-windows]
|
||||||
runs-on: linux
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/download-artifact@v4
|
- uses: actions/download-artifact@v3
|
||||||
- name: Create release
|
- name: Create release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: actions/gitea-release-action@v3
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
with:
|
with:
|
||||||
|
tag_name: ${{ github.ref_name }}
|
||||||
files: |
|
files: |
|
||||||
linux-bundles/*
|
linux-bundles/*
|
||||||
windows-bundles/*
|
windows-bundles/*
|
||||||
generate_release_notes: true
|
|
||||||
|
|||||||
@@ -41,3 +41,4 @@ minio_data/
|
|||||||
/migrate
|
/migrate
|
||||||
/dumpster-server
|
/dumpster-server
|
||||||
keygen
|
keygen
|
||||||
|
steamfree
|
||||||
|
|||||||
+52
-79
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## vs Discord, Guilded (historical), TeamSpeak 6, Fluxer
|
## vs Discord, Guilded (historical), TeamSpeak 6, Fluxer
|
||||||
|
|
||||||
Compiled 2026-06-30. Updated through Phase 7 completion.
|
Compiled 2026-06-30. **Status refreshed 2026-07-15** (not a full re-audit of every row).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -21,22 +21,23 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
|
|||||||
|
|
||||||
| Feature | dumpsterChat | Discord | Guilded | TeamSpeak 6 | Fluxer |
|
| Feature | dumpsterChat | Discord | Guilded | TeamSpeak 6 | Fluxer |
|
||||||
|---------|:------------:|:-------:|:-------:|:-----------:|:------:|
|
|---------|:------------:|:-------:|:-------:|:-----------:|:------:|
|
||||||
|| Text channels | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Text channels | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|| Direct messages | ✅ | ✅ | ✅ | ❌ | ✅ |
|
| Direct messages | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
|| Markdown support | ✅ | ✅ full | ✅ full | ❌ basic | ✅ full |
|
| Markdown support | ✅ | ✅ full | ✅ full | ❌ basic | ✅ full |
|
||||||
|| Reactions | ✅ | ✅ | ✅ | ❌ | ✅ |
|
| Reactions | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
|| Replies | ✅ | ✅ | ✅ | ❌ | ✅ |
|
| Replies | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
|| Threads | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
| Threads | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
||||||
|| Forum channels | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
| Forum channels | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
||||||
|| Pinned messages | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Pinned messages | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|| Message search | ✅ | ✅ full | ✅ | ❌ | ✅ Meilisearch |
|
| Message search | ✅ | ✅ full | ✅ | ❌ | ✅ Meilisearch |
|
||||||
|| Edit / delete messages | ✅ | ✅ | ✅ | ❌ | ✅ |
|
| Edit / delete messages | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
|| Rich embeds / link unfurling | ✅ | ✅ | ✅ | ❌ | ✅ |
|
| Rich embeds / link unfurling | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
|| File uploads | ⚠️ MinIO | ✅ | ✅ | ✅ | ✅ S3-backed |
|
| File uploads | ⚠️ MinIO | ✅ | ✅ | ✅ | ✅ S3-backed |
|
||||||
|| GIF picker (Giphy) | ✅ | ✅ | ✅ | ❌ | ✅ |
|
| GIF picker (Giphy) | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
|| Typing indicators | ✅ | ✅ | ✅ | ❌ | ✅ |
|
| Typing indicators | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
|| Message history (pagination) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Message history (pagination) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|| Read receipts | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
| Read receipts | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
||||||
|
| @everyone / @channel | ✅ (perm gated) | ✅ | ✅ | ❌ | ✅ |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -68,7 +69,7 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
|
|||||||
| Badges | ❌ | ✅ | ✅ | ✅ | ✅ |
|
| Badges | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Usernames + discriminators | ✅ | ⚠️ handles | ❌ | ✅ UID | ✅ #0000 |
|
| Usernames + discriminators | ✅ | ⚠️ handles | ❌ | ✅ UID | ✅ #0000 |
|
||||||
| Friend requests | ❌ | ✅ | ✅ | ❌ | ✅ |
|
| Friend requests | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||||
|| Block list | ✅ | ✅ | ✅ | ❌ | ❌ |
|
| Block list | ✅ | ✅ | ✅ | ❌ | ❌ |
|
||||||
| Activity / game status | ❌ | ✅ | ✅ | ❌ | ❌ |
|
| Activity / game status | ❌ | ✅ | ✅ | ❌ | ❌ |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -80,11 +81,12 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
|
|||||||
| Roles | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Roles | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Hierarchical roles | ⚠️ basic | ✅ | ✅ | ✅ | ✅ |
|
| Hierarchical roles | ⚠️ basic | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Permission bitflags | ✅ | ✅ | ✅ | ✅ granular | ✅ |
|
| Permission bitflags | ✅ | ✅ | ✅ | ✅ granular | ✅ |
|
||||||
|| Per-channel permission overrides | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Per-channel permission overrides | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| @everyone default role | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| @everyone default role | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Role colors | ⚠️ DB ready | ✅ | ✅ | ❌ | ✅ |
|
| Role colors | ⚠️ DB ready | ✅ | ✅ | ❌ | ✅ |
|
||||||
| Role icons | ❌ | ✅ Nitro | ❌ | ❌ | ❌ |
|
| Role icons | ❌ | ✅ Nitro | ❌ | ❌ | ❌ |
|
||||||
| Administrator bypass | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Administrator bypass | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Client-side permission gates | ✅ (user roles + @everyone) | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
|
||||||
### dumpsterChat Permissions (current)
|
### dumpsterChat Permissions (current)
|
||||||
|
|
||||||
@@ -123,7 +125,7 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
|
|||||||
| Server invites | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Server invites | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Vanity URLs | ❌ | ✅ Nitro | ❌ | ❌ | ❌ |
|
| Vanity URLs | ❌ | ✅ Nitro | ❌ | ❌ | ❌ |
|
||||||
| Webhooks | ✅ | ✅ | ✅ | ❌ | ✅ |
|
| Webhooks | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
| Bots / API | ⚠️ slash cmds | ✅ huge | ✅ Flow Bots | ❌ plugins | 🔄 |
|
| Bots / API | ⚠️ store + runner | ✅ huge | ✅ Flow Bots | ❌ plugins | 🔄 |
|
||||||
| Server templates | ❌ | ✅ | ❌ | ❌ | ❌ |
|
| Server templates | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||||
| Server discovery | ❌ | ✅ | ✅ | ✅ | 🔄 |
|
| Server discovery | ❌ | ✅ | ✅ | ✅ | 🔄 |
|
||||||
| Server analytics | ❌ | ✅ | ✅ | ❌ | ❌ |
|
| Server analytics | ❌ | ✅ | ✅ | ❌ | ❌ |
|
||||||
@@ -139,10 +141,10 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
|
|||||||
| Desktop notifications | ⚠️ possible via SW | ✅ | ✅ | ✅ | ✅ |
|
| Desktop notifications | ⚠️ possible via SW | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Web push notifications | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Web push notifications | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| @mention push | ✅ | ✅ | ✅ | ❌ | ✅ |
|
| @mention push | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
| Channel-wide push | ✅ | ✅ | ✅ | ❌ | ✅ |
|
| Channel-wide push (@everyone/@channel) | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
| Email notifications | ❌ | ✅ | ✅ | ❌ | 🔄 |
|
| Email notifications | ❌ | ✅ | ✅ | ❌ | 🔄 |
|
||||||
| Mobile apps | ❌ | ✅ iOS/Android | ✅ | ✅ | 🔄 Flutter alpha |
|
| Mobile apps | ❌ (PWA is target) | ✅ iOS/Android | ✅ | ✅ | 🔄 Flutter alpha |
|
||||||
| Per-channel notification settings | ❌ | ✅ | ✅ | ✅ | ✅ |
|
| Per-channel notification settings | ⚠️ partial | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Do Not Disturb schedule | ❌ | ✅ | ❌ | ❌ | ❌ |
|
| Do Not Disturb schedule | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -156,6 +158,7 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
|
|||||||
| Slash commands | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
| Slash commands | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
||||||
| Command options / JSON schema | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
| Command options / JSON schema | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
||||||
| Bot mentions | ✅ | ✅ | ❌ | ❌ | 🔄 |
|
| Bot mentions | ✅ | ✅ | ❌ | ❌ | 🔄 |
|
||||||
|
| Built-in bot runner (anonConfess, leaderboard, steamfree) | ✅ | ❌ | ⚠️ | ❌ | ❌ |
|
||||||
| Third-party integrations (Twitch, YouTube, GitHub) | ❌ | ✅ | ✅ | ❌ | 🔄 |
|
| Third-party integrations (Twitch, YouTube, GitHub) | ❌ | ✅ | ✅ | ❌ | 🔄 |
|
||||||
| Webhook-driven bots | ✅ | ✅ | ✅ | ❌ | ✅ |
|
| Webhook-driven bots | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
|
|
||||||
@@ -168,40 +171,11 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
|
|||||||
- Terminal/Gruvbox aesthetic
|
- Terminal/Gruvbox aesthetic
|
||||||
- WebAuthn / passkey auth
|
- WebAuthn / passkey auth
|
||||||
- LiveKit voice integration
|
- LiveKit voice integration
|
||||||
- Built-in webhook execution for simple integrations
|
- Built-in bot store + managed runner
|
||||||
|
- PWA-first mobile (no native app planned)
|
||||||
|
|
||||||
### Discord
|
### Discord / Guilded / TeamSpeak / Fluxer
|
||||||
- Massive network effect (200M+ MAU)
|
See historical notes in git history if needed. Not the product roadmap.
|
||||||
- Nitro subscription perks (animated avatars, HD streaming, larger uploads)
|
|
||||||
- Activities / embedded apps in voice channels
|
|
||||||
- Server boosting tiers
|
|
||||||
- Stage channels
|
|
||||||
- Activities marketplace
|
|
||||||
|
|
||||||
### Guilded (historical)
|
|
||||||
- Built-in calendar with RSVP
|
|
||||||
- Scheduling + availability system
|
|
||||||
- Docs / forms
|
|
||||||
- Lists (task management)
|
|
||||||
- Tournaments
|
|
||||||
- Server Subs monetization
|
|
||||||
- Server Groups (sub-servers)
|
|
||||||
|
|
||||||
### TeamSpeak 6
|
|
||||||
- Self-hosted by design, free up to 32 slots
|
|
||||||
- Granular Power/Needed Power permission system
|
|
||||||
- Low resource client
|
|
||||||
- Plugin ecosystem
|
|
||||||
- Virtual servers
|
|
||||||
- Whisper / poke / channel commander
|
|
||||||
|
|
||||||
### Fluxer
|
|
||||||
- Fully open-source AGPL-3, Docker Compose deploy
|
|
||||||
- No paywalls / license keys
|
|
||||||
- Planned federation
|
|
||||||
- Multi-backend switching
|
|
||||||
- Erlang/OTP gateway for scale
|
|
||||||
- 34 locales
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -210,38 +184,37 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
|
|||||||
| Feature | dumpsterChat | Discord | Guilded | TeamSpeak 6 | Fluxer |
|
| Feature | dumpsterChat | Discord | Guilded | TeamSpeak 6 | Fluxer |
|
||||||
|---------|:------------:|:-------:|:-------:|:-----------:|:------:|
|
|---------|:------------:|:-------:|:-------:|:-----------:|:------:|
|
||||||
| Self-hostable | ✅ | ❌ | ❌ | ✅ | ✅ |
|
| Self-hostable | ✅ | ❌ | ❌ | ✅ | ✅ |
|
||||||
| Open source | ❌ | ❌ | ❌ | ❌ | ✅ AGPL-3 |
|
| Open source | ⚠️ private self-host | ❌ | ❌ | ❌ | ✅ AGPL-3 |
|
||||||
| PWA support | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| PWA support | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| REST API | ✅ | ✅ | ✅ | ❌ | ✅ |
|
| REST API | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
| WebSocket gateway | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| WebSocket gateway | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Swagger docs | ✅ localhost | ✅ | ✅ | ❌ | ✅ |
|
| Swagger docs | ✅ /docs | ✅ | ✅ | ❌ | ✅ |
|
||||||
| Docker Compose | ✅ | ❌ | ❌ | ❌ | ✅ |
|
| Docker Compose | ✅ | ❌ | ❌ | ❌ | ✅ |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Priority Recommendations
|
## Priority Recommendations (updated)
|
||||||
|
|
||||||
### Critical gaps (would block most Discord/Guilded users)
|
**Do not treat this table as a todo list.** For a ~12 person friend server, parity rows are optional.
|
||||||
|
|
||||||
1. **Direct Messages** — no way to message users outside servers
|
### Actual next polish (product)
|
||||||
2. **Mobile app** — major adoption blocker
|
1. Mobile PWA pain (input, notifs, safe areas)
|
||||||
3. **Voice push-to-talk** — important for voice-heavy communities
|
2. Role color UI / hierarchy polish
|
||||||
4. **Email notifications** — needed for async engagement
|
3. Voice PTT / screen share only if voice is used
|
||||||
5. **Read receipts / unread state** — channel-level read tracking
|
4. Small slash toys (`/roll`, `/choose`) if wanted
|
||||||
|
|
||||||
### High-value next features
|
### Real tech debt
|
||||||
|
1. More tests on hot paths (permissions DB checker, bot auth)
|
||||||
|
2. Keep client permission cache in sync after role edits (partially done)
|
||||||
|
3. Deploy docs must name the real unit: `dumpster.service`
|
||||||
|
|
||||||
1. **Screen share** — LiveKit supports it; mostly frontend work
|
### Explicitly not critical
|
||||||
2. **Custom emoji / reactions beyond unicode** — core Discord behavior
|
- Friend requests (DMs already exist among members)
|
||||||
3. **Server groups (sub-servers)** — channel organization
|
- Native mobile apps
|
||||||
4. **Do Not Disturb schedule** — notification control
|
- Discord bot ecosystem compatibility
|
||||||
5. **Third-party integrations** — Twitch, YouTube, GitHub
|
- Server discovery / monetization / federation
|
||||||
6. **Stage channels** — presentation-style voice
|
- AutoMod / Flow Bots
|
||||||
|
|
||||||
### Nice-to-have differentiators
|
### Stale claims removed
|
||||||
|
- ~~"Direct Messages missing"~~ — DMs exist
|
||||||
1. **No-code Flow Bots** (Guilded-style automations)
|
- ~~"Mobile app is the only path"~~ — PWA is the target client
|
||||||
2. **Server discovery / directory**
|
|
||||||
3. **Activities / embedded games**
|
|
||||||
4. **Federation** (Fluxer-style)
|
|
||||||
5. **Server analytics**
|
|
||||||
|
|||||||
@@ -58,10 +58,13 @@ For optional features, copy `.env.example` to `.env` and set Giphy, MinIO, LiveK
|
|||||||
- [x] Blocks (user-level blocking)
|
- [x] Blocks (user-level blocking)
|
||||||
- [x] Dark/light mode toggle
|
- [x] Dark/light mode toggle
|
||||||
- [x] Mobile-responsive layout (bottom nav, drawer)
|
- [x] Mobile-responsive layout (bottom nav, drawer)
|
||||||
- [x] Bot framework (token auth, CRUD, WebSocket gateway)
|
- [x] Bot framework (token auth, CRUD, WebSocket gateway, bot store)
|
||||||
|
- [x] Built-in bot runner (server-managed bots, no SSH needed)
|
||||||
|
- [x] Built-in bot: Steam Free Games (polls Steam API, auto-posts)
|
||||||
|
- [x] Bot message badges (green name + BOT tag in chat)
|
||||||
- [x] Slash commands (registration, autocomplete)
|
- [x] Slash commands (registration, autocomplete)
|
||||||
- [x] Incoming webhooks (create, execute)
|
- [x] Incoming webhooks (create, execute)
|
||||||
- [x] Example bots (modbot, welcome bot)
|
- [x] Example bots (modbot, welcome bot, steamfree)
|
||||||
- [x] TUI client (Bubbletea, vim-style, voice support)
|
- [x] TUI client (Bubbletea, vim-style, voice support)
|
||||||
- [x] Roles & permissions system
|
- [x] Roles & permissions system
|
||||||
- [x] Push notification backend (VAPID)
|
- [x] Push notification backend (VAPID)
|
||||||
@@ -97,7 +100,7 @@ dumpsterChat/
|
|||||||
│ ├── voice/ # LiveKit voice/video integration
|
│ ├── voice/ # LiveKit voice/video integration
|
||||||
│ ├── reaction/ # Message reactions
|
│ ├── reaction/ # Message reactions
|
||||||
│ ├── invite/ # Server invite links
|
│ ├── invite/ # Server invite links
|
||||||
│ ├── bot/ # Bot framework, auth, commands
|
│ ├── bot/ # Bot framework, auth, commands, runner
|
||||||
│ ├── webhook/ # Incoming webhooks
|
│ ├── webhook/ # Incoming webhooks
|
||||||
│ ├── dm/ # Direct messages (conversations)
|
│ ├── dm/ # Direct messages (conversations)
|
||||||
│ ├── push/ # Push notification sender (VAPID)
|
│ ├── push/ # Push notification sender (VAPID)
|
||||||
@@ -112,7 +115,8 @@ dumpsterChat/
|
|||||||
│ └── block/ # User blocking
|
│ └── block/ # User blocking
|
||||||
├── examples/ # Example bots
|
├── examples/ # Example bots
|
||||||
│ ├── modbot/ # Moderation bot
|
│ ├── modbot/ # Moderation bot
|
||||||
│ └── welcome/ # Welcome message bot
|
│ ├── welcome/ # Welcome message bot
|
||||||
|
│ └── steamfree/ # Steam free games bot (standalone)
|
||||||
├── web/ # React frontend (PWA)
|
├── web/ # React frontend (PWA)
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── components/ # Layout, ChatArea, LoginForm, UserSettings, GiphyPicker, VoiceChannel, VoicePanel, VoiceControls, TypingIndicator, ReactionBar, EmojiPicker, ReplyBar, MentionPopup, InviteModal, JoinServer, MobileNav, MobileDrawer, ThemeToggle, InstallPrompt, BotManager, CommandManager, SlashCommandPopup
|
│ │ ├── components/ # Layout, ChatArea, LoginForm, UserSettings, GiphyPicker, VoiceChannel, VoicePanel, VoiceControls, TypingIndicator, ReactionBar, EmojiPicker, ReplyBar, MentionPopup, InviteModal, JoinServer, MobileNav, MobileDrawer, ThemeToggle, InstallPrompt, BotManager, CommandManager, SlashCommandPopup
|
||||||
@@ -157,6 +161,30 @@ Users are prompted to enable notifications on login. On iOS, the prompt requires
|
|||||||
|
|
||||||
Full documentation: [dumpsterChat wiki](ssh://git@git.dustin.coffee:2222/hobokenchicken/dumpsterChat.wiki.git)
|
Full documentation: [dumpsterChat wiki](ssh://git@git.dustin.coffee:2222/hobokenchicken/dumpsterChat.wiki.git)
|
||||||
|
|
||||||
|
## Production Deploy (SBC / 172.20.0.125)
|
||||||
|
|
||||||
|
App lives at `/opt/dumpsterChat`. systemd unit name is **`dumpster.service`** (not `dumpsterChat`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build locally
|
||||||
|
CGO_ENABLED=0 go build -o dumpster-server ./cmd/server
|
||||||
|
(cd web && npm run build)
|
||||||
|
|
||||||
|
# Ship binary + web assets
|
||||||
|
scp dumpster-server root@172.20.0.125:/tmp/dumpster-server-new
|
||||||
|
rsync -av --delete web/dist/ root@172.20.0.125:/opt/dumpsterChat/web/dist/
|
||||||
|
|
||||||
|
ssh root@172.20.0.125 '
|
||||||
|
install -m 755 /tmp/dumpster-server-new /opt/dumpsterChat/dumpster-server
|
||||||
|
systemctl restart dumpster
|
||||||
|
systemctl is-active dumpster
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
|
Logs: `journalctl -u dumpster -f`
|
||||||
|
Health: `curl -s http://127.0.0.1:8080/` (or your API health route)
|
||||||
|
Public: Caddy → `dumpster.dustin.coffee` → `172.20.0.125:8080`
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
AGPLv3
|
AGPLv3
|
||||||
|
|||||||
+37
-7
@@ -80,6 +80,13 @@ func main() {
|
|||||||
hub := gateway.NewHub(database.DB, logger)
|
hub := gateway.NewHub(database.DB, logger)
|
||||||
go hub.Run()
|
go hub.Run()
|
||||||
|
|
||||||
|
// Built-in bot runner
|
||||||
|
botRunner := bot.NewRunner(database.DB, hub, logger)
|
||||||
|
botRunner.Register("steamfree", bot.SteamFreeBot)
|
||||||
|
botRunner.Register("confess", bot.ConfessBot)
|
||||||
|
botRunner.Register("leaderboard", bot.LeaderboardBot)
|
||||||
|
go botRunner.StartAll()
|
||||||
|
|
||||||
// Giphy client (nil if no API key)
|
// Giphy client (nil if no API key)
|
||||||
giphyClient := giphy.NewClient(cfg.Giphy.APIKey)
|
giphyClient := giphy.NewClient(cfg.Giphy.APIKey)
|
||||||
|
|
||||||
@@ -128,6 +135,11 @@ func main() {
|
|||||||
gateway.ServeWS(database.DB, hub, logger, w, r, cfg.Session.CookieName)
|
gateway.ServeWS(database.DB, hub, logger, w, r, cfg.Session.CookieName)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Bot WebSocket endpoint (auth via ?token= query param)
|
||||||
|
r.Get("/ws/bot", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gateway.ServeBotWS(database.DB, hub, logger, w, r)
|
||||||
|
})
|
||||||
|
|
||||||
// API routes
|
// API routes
|
||||||
r.Route("/api/v1", func(r chi.Router) {
|
r.Route("/api/v1", func(r chi.Router) {
|
||||||
// Auth (public: register, login, logout) with strict rate limiting
|
// Auth (public: register, login, logout) with strict rate limiting
|
||||||
@@ -223,7 +235,9 @@ func main() {
|
|||||||
|
|
||||||
// Messages (under channels)
|
// Messages (under channels)
|
||||||
r.Route("/channels/{channelID}/messages", func(r chi.Router) {
|
r.Route("/channels/{channelID}/messages", func(r chi.Router) {
|
||||||
message.NewHandler(database.DB, hub, pushHandler, logger, permissionsChecker).RegisterRoutes(r)
|
msgHandler := message.NewHandler(database.DB, hub, pushHandler, logger, permissionsChecker)
|
||||||
|
msgHandler.SetConfessHandler(botRunner)
|
||||||
|
msgHandler.RegisterRoutes(r)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Polls
|
// Polls
|
||||||
@@ -243,6 +257,26 @@ func main() {
|
|||||||
notification.NewHandler(database.DB, logger).RegisterRoutes(r)
|
notification.NewHandler(database.DB, logger).RegisterRoutes(r)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Calendar events
|
||||||
|
calHandler := channel.NewHandler(database.DB, permissionsChecker)
|
||||||
|
r.Route("/channels/{channelID}/events", func(r chi.Router) {
|
||||||
|
r.Get("/", calHandler.ListEvents)
|
||||||
|
r.Post("/", calHandler.CreateEvent)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Threads (forum posts)
|
||||||
|
threadHandler := channel.NewHandler(database.DB, permissionsChecker)
|
||||||
|
r.Route("/channels/{channelID}/threads", func(r chi.Router) {
|
||||||
|
r.Get("/", threadHandler.ListThreads)
|
||||||
|
r.Post("/", threadHandler.CreateThread)
|
||||||
|
})
|
||||||
|
r.Patch("/threads/{threadID}", threadHandler.UpdateThread)
|
||||||
|
|
||||||
|
// Forum tags
|
||||||
|
r.Get("/channels/{channelID}/forum-tags", threadHandler.ListForumTags)
|
||||||
|
r.Post("/channels/{channelID}/forum-tags", threadHandler.CreateForumTag)
|
||||||
|
r.Delete("/forum-tags/{tagID}", threadHandler.DeleteForumTag)
|
||||||
|
|
||||||
// Push notifications
|
// Push notifications
|
||||||
pushHandler.RegisterRoutes(r)
|
pushHandler.RegisterRoutes(r)
|
||||||
|
|
||||||
@@ -323,13 +357,9 @@ func main() {
|
|||||||
reaction.NewHandler(database.DB, hub).RegisterRoutes(r)
|
reaction.NewHandler(database.DB, hub).RegisterRoutes(r)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Bots
|
// Bots + slash commands
|
||||||
r.Route("/bots", func(r chi.Router) {
|
r.Route("/bots", func(r chi.Router) {
|
||||||
bot.NewHandler(database.DB).RegisterRoutes(r)
|
bot.NewHandler(database.DB, botRunner).RegisterRoutes(r)
|
||||||
})
|
|
||||||
|
|
||||||
// Bot slash commands
|
|
||||||
r.Route("/bots/{botID}/commands", func(r chi.Router) {
|
|
||||||
bot.NewCommandHandler(database.DB).RegisterCommandRoutes(r)
|
bot.NewCommandHandler(database.DB).RegisterCommandRoutes(r)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,198 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- config from env ----
|
||||||
|
|
||||||
|
var (
|
||||||
|
botToken string
|
||||||
|
host string
|
||||||
|
channelID string
|
||||||
|
pollMinutes int
|
||||||
|
seen = map[string]bool{} // appid -> posted
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- dumpsterChat WS protocol ----
|
||||||
|
|
||||||
|
type Event struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Payload json.RawMessage `json:"payload"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Steam API response ----
|
||||||
|
|
||||||
|
type FeaturedCategories struct {
|
||||||
|
Specials FeaturedList `json:"specials"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeaturedList struct {
|
||||||
|
Items []FeaturedItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeaturedItem struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
DiscountPct int `json:"discount_percent"`
|
||||||
|
FinalPrice int `json:"final_price"` // in cents
|
||||||
|
OriginalPrice int `json:"original_price"` // in cents
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
botToken = os.Getenv("BOT_TOKEN")
|
||||||
|
if botToken == "" {
|
||||||
|
log.Fatal("BOT_TOKEN required (create a bot in the dumpsterChat store)")
|
||||||
|
}
|
||||||
|
host = os.Getenv("DUMPSTER_HOST")
|
||||||
|
if host == "" {
|
||||||
|
host = "localhost:8080"
|
||||||
|
}
|
||||||
|
channelID = os.Getenv("CHANNEL_ID")
|
||||||
|
if channelID == "" {
|
||||||
|
log.Fatal("CHANNEL_ID required (the channel to post free games to)")
|
||||||
|
}
|
||||||
|
pollMinutes = 30
|
||||||
|
if m := os.Getenv("POLL_MINUTES"); m != "" {
|
||||||
|
if v, err := strconv.Atoi(m); err == nil && v > 0 {
|
||||||
|
pollMinutes = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to dumpsterChat
|
||||||
|
u := url.URL{
|
||||||
|
Scheme: "ws",
|
||||||
|
Host: host,
|
||||||
|
Path: "/ws/bot",
|
||||||
|
RawQuery: "token=" + botToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("SteamFree bot connecting to %s", u.String())
|
||||||
|
|
||||||
|
c, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("dial:", err)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
log.Println("SteamFree bot connected!")
|
||||||
|
|
||||||
|
// First poll immediately, then on ticker
|
||||||
|
poll(c)
|
||||||
|
|
||||||
|
ticker := time.NewTicker(time.Duration(pollMinutes) * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
// Keep connection alive by reading (we don't need to react to events)
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
_, _, err := c.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
log.Println("ws read:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for range ticker.C {
|
||||||
|
poll(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func poll(c *websocket.Conn) {
|
||||||
|
log.Println("Polling Steam for free games...")
|
||||||
|
|
||||||
|
games, err := fetchFreeGames()
|
||||||
|
if err != nil {
|
||||||
|
log.Println("fetch error:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newCount := 0
|
||||||
|
for _, g := range games {
|
||||||
|
if seen[strconv.Itoa(g.ID)] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[strconv.Itoa(g.ID)] = true
|
||||||
|
newCount++
|
||||||
|
|
||||||
|
msg := formatGame(g)
|
||||||
|
sendMessage(c, channelID, msg)
|
||||||
|
log.Printf("Posted: %s (was $%.2f, now FREE)", g.Name, float64(g.OriginalPrice)/100)
|
||||||
|
time.Sleep(500 * time.Millisecond) // be polite to the WS
|
||||||
|
}
|
||||||
|
|
||||||
|
if newCount == 0 {
|
||||||
|
log.Println("No new free games found")
|
||||||
|
} else {
|
||||||
|
log.Printf("Posted %d new free games", newCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchFreeGames() ([]FeaturedItem, error) {
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
resp, err := client.Get("https://store.steampowered.com/api/featuredcategories?cc=us&l=english")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("steam api: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("steam api: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cats FeaturedCategories
|
||||||
|
if err := json.Unmarshal(body, &cats); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse json: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter: 100% discount and actually had a price (not permanent free-to-play)
|
||||||
|
var free []FeaturedItem
|
||||||
|
for _, item := range cats.Specials.Items {
|
||||||
|
if item.DiscountPct == 100 && item.OriginalPrice > 0 {
|
||||||
|
free = append(free, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return free, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatGame(g FeaturedItem) string {
|
||||||
|
storeURL := fmt.Sprintf("https://store.steampowered.com/app/%d", g.ID)
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "🎮 **FREE ON STEAM** 🎮\n")
|
||||||
|
fmt.Fprintf(&b, "**%s**\n", g.Name)
|
||||||
|
fmt.Fprintf(&b, "~~$%.2f~~ → **FREE**\n", float64(g.OriginalPrice)/100)
|
||||||
|
fmt.Fprintf(&b, "%s", storeURL)
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendMessage(c *websocket.Conn, channelID, content string) {
|
||||||
|
msg := map[string]interface{}{
|
||||||
|
"type": "SEND_MESSAGE",
|
||||||
|
"payload": map[string]string{
|
||||||
|
"channel_id": channelID,
|
||||||
|
"content": content,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(msg)
|
||||||
|
c.WriteMessage(websocket.TextMessage, data)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package bot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConfessConfig: "channel_id" is where anonymous confessions land.
|
||||||
|
type ConfessConfig struct {
|
||||||
|
ChannelID string `json:"channel_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseConfessContent returns the confession body if content is a /confess command.
|
||||||
|
func parseConfessContent(content string) (string, bool) {
|
||||||
|
trimmed := strings.TrimSpace(content)
|
||||||
|
if len(trimmed) < 8 || !strings.EqualFold(trimmed[:8], "/confess") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
// Require word boundary after the command (space, end, or more text after optional space).
|
||||||
|
rest := strings.TrimSpace(trimmed[8:])
|
||||||
|
if rest == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return rest, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfessBot is a safety-net poller for any /confess that slipped past intercept.
|
||||||
|
// Primary path is Runner.TryConfess at message create (no original ever stored).
|
||||||
|
func ConfessBot(ctx context.Context, db *sql.DB, raw json.RawMessage, send SendMessageFunc) {
|
||||||
|
// Polling path is intentionally inert for normal operation when intercept works.
|
||||||
|
// Keep a lightweight no-op loop so the runner lifecycle stays consistent.
|
||||||
|
// Real cleanup of any leaked /confess rows is handled if deleteMsg is wired later.
|
||||||
|
_ = db
|
||||||
|
_ = raw
|
||||||
|
_ = send
|
||||||
|
|
||||||
|
ticker := time.NewTicker(30 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
// no-op: intercept handles live confessions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// confessFormat is shared with TryConfess.
|
||||||
|
func confessFormat(text string) string {
|
||||||
|
return fmt.Sprintf("🕵️ **anonymous confession:** %s", text)
|
||||||
|
}
|
||||||
+117
-11
@@ -13,15 +13,18 @@ import (
|
|||||||
// Handler handles bot CRUD and server-assignment routes.
|
// Handler handles bot CRUD and server-assignment routes.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
|
runner *Runner
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates a new bot Handler.
|
// NewHandler creates a new bot Handler.
|
||||||
func NewHandler(db *sql.DB) *Handler {
|
func NewHandler(db *sql.DB, runner *Runner) *Handler {
|
||||||
return &Handler{db: db}
|
return &Handler{db: db, runner: runner}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterRoutes registers authenticated bot routes under the given router.
|
// RegisterRoutes registers authenticated bot routes under the given router.
|
||||||
func (h *Handler) RegisterRoutes(r chi.Router) {
|
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||||
|
r.Get("/store", h.Store)
|
||||||
|
r.Get("/types", h.ListTypes)
|
||||||
r.Post("/", h.Create)
|
r.Post("/", h.Create)
|
||||||
r.Get("/", h.List)
|
r.Get("/", h.List)
|
||||||
r.Get("/{botID}", h.Get)
|
r.Get("/{botID}", h.Get)
|
||||||
@@ -39,6 +42,8 @@ type botResponse struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Avatar *string `json:"avatar"`
|
Avatar *string `json:"avatar"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
|
BotType string `json:"bot_type"`
|
||||||
|
Config json.RawMessage `json:"config"`
|
||||||
OwnerID string `json:"owner_id"`
|
OwnerID string `json:"owner_id"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
@@ -52,12 +57,16 @@ type botWithToken struct {
|
|||||||
type createBotRequest struct {
|
type createBotRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
|
BotType string `json:"bot_type"`
|
||||||
|
Config json.RawMessage `json:"config"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type updateBotRequest struct {
|
type updateBotRequest struct {
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
Avatar *string `json:"avatar"`
|
Avatar *string `json:"avatar"`
|
||||||
|
BotType *string `json:"bot_type"`
|
||||||
|
Config json.RawMessage `json:"config"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type addToServerRequest struct {
|
type addToServerRequest struct {
|
||||||
@@ -109,15 +118,21 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
token := GenerateToken()
|
token := GenerateToken()
|
||||||
tokenHash := HashToken(token)
|
tokenHash := HashToken(token)
|
||||||
|
|
||||||
|
configJSON := req.Config
|
||||||
|
if configJSON == nil {
|
||||||
|
configJSON = json.RawMessage(`{}`)
|
||||||
|
}
|
||||||
|
|
||||||
var bot botWithToken
|
var bot botWithToken
|
||||||
var avatar sql.NullString
|
var avatar sql.NullString
|
||||||
var createdAt sql.NullString
|
var createdAt sql.NullString
|
||||||
|
var configOut sql.NullString
|
||||||
err := h.db.QueryRowContext(r.Context(), `
|
err := h.db.QueryRowContext(r.Context(), `
|
||||||
INSERT INTO bots (name, description, owner_id, token)
|
INSERT INTO bots (name, description, owner_id, token, bot_type, config)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4, $5, $6::jsonb)
|
||||||
RETURNING id, name, avatar, description, owner_id, created_at::text
|
RETURNING id, name, avatar, description, bot_type, config::text, owner_id, created_at::text
|
||||||
`, req.Name, req.Description, userID, tokenHash).Scan(
|
`, req.Name, req.Description, userID, tokenHash, req.BotType, string(configJSON)).Scan(
|
||||||
&bot.ID, &bot.Name, &avatar, &bot.Description, &bot.OwnerID, &createdAt,
|
&bot.ID, &bot.Name, &avatar, &bot.Description, &bot.BotType, &configOut, &bot.OwnerID, &createdAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, http.StatusInternalServerError, "failed to create bot")
|
writeErr(w, http.StatusInternalServerError, "failed to create bot")
|
||||||
@@ -126,9 +141,17 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
if avatar.Valid {
|
if avatar.Valid {
|
||||||
bot.Avatar = &avatar.String
|
bot.Avatar = &avatar.String
|
||||||
}
|
}
|
||||||
|
if configOut.Valid {
|
||||||
|
bot.Config = json.RawMessage(configOut.String)
|
||||||
|
}
|
||||||
bot.CreatedAt = createdAt.String
|
bot.CreatedAt = createdAt.String
|
||||||
bot.Token = token
|
bot.Token = token
|
||||||
|
|
||||||
|
// Start built-in bot if type is set
|
||||||
|
if req.BotType != "" && h.runner != nil {
|
||||||
|
h.runner.Start(bot.ID, req.BotType, bot.Config)
|
||||||
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusCreated, bot)
|
writeJSON(w, http.StatusCreated, bot)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,15 +304,18 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
|||||||
var b botResponse
|
var b botResponse
|
||||||
var avatar sql.NullString
|
var avatar sql.NullString
|
||||||
var createdAt sql.NullString
|
var createdAt sql.NullString
|
||||||
|
var configOut sql.NullString
|
||||||
err = h.db.QueryRowContext(r.Context(), `
|
err = h.db.QueryRowContext(r.Context(), `
|
||||||
UPDATE bots
|
UPDATE bots
|
||||||
SET name = COALESCE($1, name),
|
SET name = COALESCE($1, name),
|
||||||
description = COALESCE($2, description),
|
description = COALESCE($2, description),
|
||||||
avatar = COALESCE($3, avatar)
|
avatar = COALESCE($3, avatar),
|
||||||
|
bot_type = COALESCE($5, bot_type),
|
||||||
|
config = COALESCE($6::jsonb, config)
|
||||||
WHERE id = $4
|
WHERE id = $4
|
||||||
RETURNING id, name, avatar, description, owner_id, created_at::text
|
RETURNING id, name, avatar, description, bot_type, config::text, owner_id, created_at::text
|
||||||
`, req.Name, req.Description, req.Avatar, botID).Scan(
|
`, req.Name, req.Description, req.Avatar, botID, req.BotType, string(req.Config)).Scan(
|
||||||
&b.ID, &b.Name, &avatar, &b.Description, &b.OwnerID, &createdAt,
|
&b.ID, &b.Name, &avatar, &b.Description, &b.BotType, &configOut, &b.OwnerID, &createdAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, http.StatusInternalServerError, "failed to update bot")
|
writeErr(w, http.StatusInternalServerError, "failed to update bot")
|
||||||
@@ -298,8 +324,22 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
|||||||
if avatar.Valid {
|
if avatar.Valid {
|
||||||
b.Avatar = &avatar.String
|
b.Avatar = &avatar.String
|
||||||
}
|
}
|
||||||
|
if configOut.Valid {
|
||||||
|
b.Config = json.RawMessage(configOut.String)
|
||||||
|
}
|
||||||
b.CreatedAt = createdAt.String
|
b.CreatedAt = createdAt.String
|
||||||
|
|
||||||
|
// Restart built-in bot if type/config changed
|
||||||
|
if req.BotType != nil && h.runner != nil {
|
||||||
|
h.runner.Stop(b.ID)
|
||||||
|
if *req.BotType != "" {
|
||||||
|
h.runner.Start(b.ID, *req.BotType, b.Config)
|
||||||
|
}
|
||||||
|
} else if req.Config != nil && h.runner != nil && b.BotType != "" {
|
||||||
|
h.runner.Stop(b.ID)
|
||||||
|
h.runner.Start(b.ID, b.BotType, b.Config)
|
||||||
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, b)
|
writeJSON(w, http.StatusOK, b)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,6 +385,11 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop built-in bot if running
|
||||||
|
if h.runner != nil {
|
||||||
|
h.runner.Stop(botID)
|
||||||
|
}
|
||||||
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,3 +592,64 @@ func (h *Handler) RegenerateToken(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
writeJSON(w, http.StatusOK, b)
|
writeJSON(w, http.StatusOK, b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Store (public listing) ----
|
||||||
|
|
||||||
|
type storeBotResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Avatar *string `json:"avatar"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
ServerCount int `json:"server_count"`
|
||||||
|
OwnerID string `json:"owner_id"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store returns all bots with their server count (visible to any authenticated user).
|
||||||
|
func (h *Handler) Store(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := h.db.QueryContext(r.Context(), `
|
||||||
|
SELECT b.id, b.name, b.avatar, b.description, b.owner_id, b.created_at::text,
|
||||||
|
COUNT(bs.server_id) AS server_count
|
||||||
|
FROM bots b
|
||||||
|
LEFT JOIN bot_servers bs ON bs.bot_id = b.id
|
||||||
|
GROUP BY b.id, b.name, b.avatar, b.description, b.owner_id, b.created_at
|
||||||
|
ORDER BY server_count DESC, b.name
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
bots := make([]storeBotResponse, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var b storeBotResponse
|
||||||
|
var avatar sql.NullString
|
||||||
|
var createdAt sql.NullString
|
||||||
|
if err := rows.Scan(&b.ID, &b.Name, &avatar, &b.Description, &b.OwnerID, &createdAt, &b.ServerCount); err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if avatar.Valid {
|
||||||
|
b.Avatar = &avatar.String
|
||||||
|
}
|
||||||
|
b.CreatedAt = createdAt.String
|
||||||
|
bots = append(bots, b)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, bots)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTypes returns available built-in bot types.
|
||||||
|
func (h *Handler) ListTypes(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.runner == nil {
|
||||||
|
writeJSON(w, http.StatusOK, []string{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, h.runner.RegisteredTypes())
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package bot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LeaderboardConfig: "channel_id" is where the daily recap lands.
|
||||||
|
type LeaderboardConfig struct {
|
||||||
|
ChannelID string `json:"channel_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LeaderboardBot posts a daily shitpost recap.
|
||||||
|
func LeaderboardBot(ctx context.Context, db *sql.DB, raw json.RawMessage, send SendMessageFunc) {
|
||||||
|
var cfg LeaderboardConfig
|
||||||
|
if err := json.Unmarshal(raw, &cfg); err != nil || cfg.ChannelID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(24 * time.Hour)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
// Post immediately on start, then daily
|
||||||
|
postLeaderboard(db, cfg.ChannelID, send)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
postLeaderboard(db, cfg.ChannelID, send)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func postLeaderboard(db *sql.DB, channelID string, send SendMessageFunc) {
|
||||||
|
// ponytail: global lock on count. per-user aggregates if throughput matters.
|
||||||
|
since := time.Now().Add(-24 * time.Hour)
|
||||||
|
|
||||||
|
rows, err := db.QueryContext(context.Background(), `
|
||||||
|
SELECT u.username, COUNT(*) AS msg_count
|
||||||
|
FROM messages m
|
||||||
|
JOIN users u ON m.author_id = u.id
|
||||||
|
WHERE m.created_at > $1 AND m.bot_id IS NULL
|
||||||
|
GROUP BY u.username
|
||||||
|
ORDER BY msg_count DESC
|
||||||
|
LIMIT 10
|
||||||
|
`, since)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("🏆 **24H SHITPOST LEADERBOARD** 🏆\n")
|
||||||
|
|
||||||
|
rank := 1
|
||||||
|
for rows.Next() {
|
||||||
|
var username string
|
||||||
|
var count int
|
||||||
|
if err := rows.Scan(&username, &count); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
medal := ""
|
||||||
|
switch rank {
|
||||||
|
case 1:
|
||||||
|
medal = "🥇"
|
||||||
|
case 2:
|
||||||
|
medal = "🥈"
|
||||||
|
case 3:
|
||||||
|
medal = "🥉"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "%s #%d **%s** — %d msgs\n", medal, rank, username, count)
|
||||||
|
rank++
|
||||||
|
}
|
||||||
|
if rank == 1 {
|
||||||
|
b.WriteString("*crickets*\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
send(channelID, strings.TrimSpace(b.String()))
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
package bot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BotFunc is the signature for a built-in bot type's run function.
|
||||||
|
// It blocks until ctx is cancelled. Use send to post messages, db to read.
|
||||||
|
type BotFunc func(ctx context.Context, db *sql.DB, config json.RawMessage, send SendMessageFunc)
|
||||||
|
|
||||||
|
// SendMessageFunc posts a message to a channel as this bot.
|
||||||
|
type SendMessageFunc func(channelID, content string)
|
||||||
|
|
||||||
|
// Runner manages server-side bot goroutines.
|
||||||
|
type Runner struct {
|
||||||
|
db *sql.DB
|
||||||
|
hub *gateway.Hub
|
||||||
|
logger *slog.Logger
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
bots map[string]context.CancelFunc // botID -> cancel
|
||||||
|
types map[string]BotFunc // type name -> runner
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRunner(db *sql.DB, hub *gateway.Hub, logger *slog.Logger) *Runner {
|
||||||
|
return &Runner{
|
||||||
|
db: db,
|
||||||
|
hub: hub,
|
||||||
|
logger: logger,
|
||||||
|
bots: make(map[string]context.CancelFunc),
|
||||||
|
types: make(map[string]BotFunc),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register adds a built-in bot type.
|
||||||
|
func (r *Runner) Register(name string, fn BotFunc) {
|
||||||
|
r.types[name] = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartAll loads all bots with a bot_type set and starts them.
|
||||||
|
func (r *Runner) StartAll() {
|
||||||
|
rows, err := r.db.QueryContext(context.Background(),
|
||||||
|
`SELECT id, bot_type, config::text FROM bots WHERE bot_type != ''`)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("failed to load bot configs", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var id, botType, configStr string
|
||||||
|
if err := rows.Scan(&id, &botType, &configStr); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r.Start(id, botType, json.RawMessage(configStr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts a single bot by ID. Safe to call multiple times (restarts).
|
||||||
|
func (r *Runner) Start(botID, botType string, config json.RawMessage) {
|
||||||
|
r.mu.Lock()
|
||||||
|
// Stop existing if running
|
||||||
|
if cancel, ok := r.bots[botID]; ok {
|
||||||
|
cancel()
|
||||||
|
delete(r.bots, botID)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn, ok := r.types[botType]
|
||||||
|
if !ok {
|
||||||
|
r.mu.Unlock()
|
||||||
|
r.logger.Warn("unknown bot type", "type", botType, "bot_id", botID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
r.bots[botID] = cancel
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
// Built-in bots pick a channel in config but users often skip "Add to Server".
|
||||||
|
// Resolve channel_id → server and ensure bot_servers so send() doesn't no-op.
|
||||||
|
r.ensureBotServerFromConfig(botID, config)
|
||||||
|
|
||||||
|
send := r.makeSender(botID)
|
||||||
|
r.logger.Info("starting built-in bot", "bot_id", botID, "type", botType)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if rec := recover(); rec != nil {
|
||||||
|
r.logger.Error("bot panic", "bot_id", botID, "type", botType, "panic", rec)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
fn(ctx, r.db, config, send)
|
||||||
|
r.logger.Info("bot stopped", "bot_id", botID, "type", botType)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureBotServerFromConfig joins the bot to the server that owns config.channel_id.
|
||||||
|
func (r *Runner) ensureBotServerFromConfig(botID string, config json.RawMessage) {
|
||||||
|
var cfg struct {
|
||||||
|
ChannelID string `json:"channel_id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(config, &cfg); err != nil || cfg.ChannelID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serverID, err := r.hub.ServerIDForChannel(context.Background(), cfg.ChannelID)
|
||||||
|
if err != nil || serverID == "" {
|
||||||
|
r.logger.Warn("bot start: channel not found for auto-join", "bot_id", botID, "channel_id", cfg.ChannelID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var ownerID string
|
||||||
|
if err := r.db.QueryRowContext(context.Background(),
|
||||||
|
`SELECT owner_id FROM bots WHERE id = $1`, botID,
|
||||||
|
).Scan(&ownerID); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := r.db.ExecContext(context.Background(), `
|
||||||
|
INSERT INTO bot_servers (bot_id, server_id, added_by)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
`, botID, serverID, ownerID); err != nil {
|
||||||
|
r.logger.Warn("bot start: auto-join server failed", "bot_id", botID, "server_id", serverID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.logger.Info("bot auto-joined server", "bot_id", botID, "server_id", serverID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops a single bot by ID.
|
||||||
|
func (r *Runner) Stop(botID string) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if cancel, ok := r.bots[botID]; ok {
|
||||||
|
cancel()
|
||||||
|
delete(r.bots, botID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRunning checks if a bot is currently running.
|
||||||
|
func (r *Runner) IsRunning(botID string) bool {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
_, ok := r.bots[botID]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeSender returns a SendMessageFunc that inserts into DB and broadcasts.
|
||||||
|
func (r *Runner) makeSender(botID string) SendMessageFunc {
|
||||||
|
return func(channelID, content string) {
|
||||||
|
if len(content) > 4000 {
|
||||||
|
content = content[:4000]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up bot info + server
|
||||||
|
var botName, ownerID string
|
||||||
|
err := r.db.QueryRowContext(context.Background(),
|
||||||
|
`SELECT name, owner_id FROM bots WHERE id = $1`, botID,
|
||||||
|
).Scan(&botName, &ownerID)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("send: bot not found", "bot_id", botID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
serverID, err := r.hub.ServerIDForChannel(context.Background(), channelID)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("send: channel not found", "channel_id", channelID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify bot is in this server
|
||||||
|
var inServer bool
|
||||||
|
err = r.db.QueryRowContext(context.Background(),
|
||||||
|
`SELECT EXISTS(SELECT 1 FROM bot_servers WHERE bot_id = $1 AND server_id = $2)`,
|
||||||
|
botID, serverID,
|
||||||
|
).Scan(&inServer)
|
||||||
|
if err != nil || !inServer {
|
||||||
|
r.logger.Warn("send: bot not in server", "bot_id", botID, "server_id", serverID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var msgID, createdAt string
|
||||||
|
err = r.db.QueryRowContext(context.Background(),
|
||||||
|
`INSERT INTO messages (channel_id, author_id, content, bot_id)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, created_at::text`,
|
||||||
|
channelID, ownerID, content, botID,
|
||||||
|
).Scan(&msgID, &createdAt)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("send: insert failed", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.hub.BroadcastToServer(serverID, gateway.Event{
|
||||||
|
Type: gateway.EventMessageCreate,
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"id": msgID,
|
||||||
|
"channel_id": channelID,
|
||||||
|
"author_id": ownerID,
|
||||||
|
"author_username": botName,
|
||||||
|
"author_display_name": nil,
|
||||||
|
"author_bot": true,
|
||||||
|
"bot_id": botID,
|
||||||
|
"bot_name": botName,
|
||||||
|
"content": content,
|
||||||
|
"reply_to": nil,
|
||||||
|
"edited_at": nil,
|
||||||
|
"pinned": false,
|
||||||
|
"created_at": createdAt,
|
||||||
|
"embeds": []interface{}{},
|
||||||
|
"reactions": []interface{}{},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisteredTypes returns the list of available bot type names.
|
||||||
|
func (r *Runner) RegisteredTypes() []string {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
names := make([]string, 0, len(r.types))
|
||||||
|
for k := range r.types {
|
||||||
|
names = append(names, k)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// TryConfess intercepts "/confess …" at message create time.
|
||||||
|
// On success the original is never stored or broadcast (true anonymity).
|
||||||
|
// Returns the anonymous bot message payload for the HTTP response when handled.
|
||||||
|
func (r *Runner) TryConfess(ctx context.Context, serverID, _authorID, content string) (map[string]interface{}, bool) {
|
||||||
|
text, ok := parseConfessContent(content)
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
var botID, botName, ownerID string
|
||||||
|
var rawConfig string
|
||||||
|
err := r.db.QueryRowContext(ctx, `
|
||||||
|
SELECT b.id, b.name, b.owner_id, COALESCE(b.config::text, '{}')
|
||||||
|
FROM bots b
|
||||||
|
JOIN bot_servers bs ON bs.bot_id = b.id
|
||||||
|
WHERE bs.server_id = $1 AND b.bot_type = 'confess'
|
||||||
|
LIMIT 1
|
||||||
|
`, serverID).Scan(&botID, &botName, &ownerID, &rawConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg ConfessConfig
|
||||||
|
if err := json.Unmarshal([]byte(rawConfig), &cfg); err != nil || cfg.ChannelID == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure bot can post to the confession channel's server.
|
||||||
|
r.ensureBotServerFromConfig(botID, json.RawMessage(rawConfig))
|
||||||
|
|
||||||
|
anonContent := fmt.Sprintf("🕵️ **anonymous confession:** %s", text)
|
||||||
|
if len(anonContent) > 4000 {
|
||||||
|
anonContent = anonContent[:4000]
|
||||||
|
}
|
||||||
|
|
||||||
|
var msgID, createdAt string
|
||||||
|
err = r.db.QueryRowContext(ctx, `
|
||||||
|
INSERT INTO messages (channel_id, author_id, content, bot_id)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, created_at::text
|
||||||
|
`, cfg.ChannelID, ownerID, anonContent, botID).Scan(&msgID, &createdAt)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("confess: insert failed", "error", err)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
targetServerID, err := r.hub.ServerIDForChannel(ctx, cfg.ChannelID)
|
||||||
|
if err != nil || targetServerID == "" {
|
||||||
|
targetServerID = serverID
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"id": msgID,
|
||||||
|
"channel_id": cfg.ChannelID,
|
||||||
|
"author_id": ownerID,
|
||||||
|
"author_username": botName,
|
||||||
|
"author_display_name": nil,
|
||||||
|
"author_bot": true,
|
||||||
|
"bot_id": botID,
|
||||||
|
"bot_name": botName,
|
||||||
|
"content": anonContent,
|
||||||
|
"reply_to": nil,
|
||||||
|
"edited_at": nil,
|
||||||
|
"pinned": false,
|
||||||
|
"created_at": createdAt,
|
||||||
|
"embeds": []interface{}{},
|
||||||
|
"reactions": []interface{}{},
|
||||||
|
}
|
||||||
|
|
||||||
|
r.hub.BroadcastToServer(targetServerID, gateway.Event{
|
||||||
|
Type: gateway.EventMessageCreate,
|
||||||
|
Data: payload,
|
||||||
|
})
|
||||||
|
|
||||||
|
return payload, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMessage removes a message and broadcasts MESSAGE_DELETE to live clients.
|
||||||
|
func (r *Runner) DeleteMessage(messageID string) {
|
||||||
|
var channelID string
|
||||||
|
err := r.db.QueryRowContext(context.Background(),
|
||||||
|
`DELETE FROM messages WHERE id = $1::uuid RETURNING channel_id`, messageID,
|
||||||
|
).Scan(&channelID)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serverID, err := r.hub.ServerIDForChannel(context.Background(), channelID)
|
||||||
|
if err != nil || serverID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.hub.BroadcastToServer(serverID, gateway.Event{
|
||||||
|
Type: gateway.EventMessageDelete,
|
||||||
|
Data: map[string]string{
|
||||||
|
"id": messageID,
|
||||||
|
"message_id": messageID,
|
||||||
|
"channel_id": channelID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package bot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SteamFreeConfig is the config shape for bot_type "steamfree".
|
||||||
|
type SteamFreeConfig struct {
|
||||||
|
ChannelID string `json:"channel_id"`
|
||||||
|
PollMinutes int `json:"poll_minutes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SteamFreeBot polls Steam's featured categories for 100%-off games.
|
||||||
|
func SteamFreeBot(ctx context.Context, _ *sql.DB, raw json.RawMessage, send SendMessageFunc) {
|
||||||
|
var cfg SteamFreeConfig
|
||||||
|
if err := json.Unmarshal(raw, &cfg); err != nil || cfg.ChannelID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cfg.PollMinutes <= 0 {
|
||||||
|
cfg.PollMinutes = 30
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := map[int]bool{}
|
||||||
|
ticker := time.NewTicker(time.Duration(cfg.PollMinutes) * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
// Poll immediately on start
|
||||||
|
pollSteam(cfg.ChannelID, seen, send)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
pollSteam(cfg.ChannelID, seen, send)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pollSteam(channelID string, seen map[int]bool, send SendMessageFunc) {
|
||||||
|
games, err := fetchFreeGames()
|
||||||
|
if err != nil {
|
||||||
|
return // ponytail: silent on error, logs add noise
|
||||||
|
}
|
||||||
|
for _, g := range games {
|
||||||
|
if seen[g.ID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[g.ID] = true
|
||||||
|
|
||||||
|
msg := fmt.Sprintf(
|
||||||
|
"🎮 **FREE ON STEAM** 🎮\n**%s**\n~~$%.2f~~ → **FREE**\nhttps://store.steampowered.com/app/%d",
|
||||||
|
g.Name, float64(g.OriginalPrice)/100, g.ID,
|
||||||
|
)
|
||||||
|
send(channelID, msg)
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type featuredCategories struct {
|
||||||
|
Specials struct {
|
||||||
|
Items []struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
DiscountPct int `json:"discount_percent"`
|
||||||
|
FinalPrice int `json:"final_price"`
|
||||||
|
OriginalPrice int `json:"original_price"`
|
||||||
|
} `json:"items"`
|
||||||
|
} `json:"specials"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchFreeGames() ([]struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
DiscountPct int `json:"discount_percent"`
|
||||||
|
FinalPrice int `json:"final_price"`
|
||||||
|
OriginalPrice int `json:"original_price"`
|
||||||
|
}, error) {
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
resp, err := client.Get("https://store.steampowered.com/api/featuredcategories?cc=us&l=english")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("steam: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var cats featuredCategories
|
||||||
|
if err := json.Unmarshal(body, &cats); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var free []struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
DiscountPct int `json:"discount_percent"`
|
||||||
|
FinalPrice int `json:"final_price"`
|
||||||
|
OriginalPrice int `json:"original_price"`
|
||||||
|
}
|
||||||
|
for _, item := range cats.Specials.Items {
|
||||||
|
if item.DiscountPct == 100 && item.OriginalPrice > 0 {
|
||||||
|
free = append(free, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return free, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ type createEventRequest struct {
|
|||||||
Color *string `json:"color"`
|
Color *string `json:"color"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) registerCalendarRoutes(r chi.Router) {
|
func (h *Handler) RegisterCalendarRoutes(r chi.Router) {
|
||||||
r.Get("/{channelID}/events", h.ListEvents)
|
r.Get("/{channelID}/events", h.ListEvents)
|
||||||
r.Post("/{channelID}/events", h.CreateEvent)
|
r.Post("/{channelID}/events", h.CreateEvent)
|
||||||
r.Patch("/events/{eventID}", h.UpdateEvent)
|
r.Patch("/events/{eventID}", h.UpdateEvent)
|
||||||
|
|||||||
@@ -27,11 +27,8 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
|
|||||||
r.Get("/{channelID}", h.Get)
|
r.Get("/{channelID}", h.Get)
|
||||||
r.Patch("/{channelID}", h.Update)
|
r.Patch("/{channelID}", h.Update)
|
||||||
r.Delete("/{channelID}", h.Delete)
|
r.Delete("/{channelID}", h.Delete)
|
||||||
r.Post("/{channelID}/threads", h.CreateThread)
|
// ponytail: thread + forum routes registered at top level in main.go to match frontend paths
|
||||||
r.Get("/{channelID}/threads", h.ListThreads)
|
h.RegisterCalendarRoutes(r)
|
||||||
r.Patch("/threads/{threadID}", h.UpdateThread)
|
|
||||||
h.registerForumRoutes(r)
|
|
||||||
h.registerCalendarRoutes(r)
|
|
||||||
h.registerDocRoutes(r)
|
h.registerDocRoutes(r)
|
||||||
h.registerListRoutes(r)
|
h.registerListRoutes(r)
|
||||||
h.registerOverrideRoutes(r)
|
h.registerOverrideRoutes(r)
|
||||||
|
|||||||
@@ -565,5 +565,14 @@ CREATE TABLE IF NOT EXISTS feature_request_votes (
|
|||||||
PRIMARY KEY (feature_request_id, user_id)
|
PRIMARY KEY (feature_request_id, user_id)
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_feature_request_votes_fr ON feature_request_votes(feature_request_id);
|
CREATE INDEX IF NOT EXISTS idx_feature_request_votes_fr ON feature_request_votes(feature_request_id);
|
||||||
|
|
||||||
|
-- Bot messages: track which bot authored a message
|
||||||
|
ALTER TABLE messages ADD COLUMN IF NOT EXISTS bot_id UUID REFERENCES bots(id) ON DELETE SET NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_bot ON messages(bot_id) WHERE bot_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Built-in bot types: bot_type + config for server-managed bots
|
||||||
|
ALTER TABLE bots ADD COLUMN IF NOT EXISTS bot_type VARCHAR(32) DEFAULT '';
|
||||||
|
ALTER TABLE bots ADD COLUMN IF NOT EXISTS config JSONB DEFAULT '{}';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bots_type ON bots(bot_type) WHERE bot_type != '';
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package gateway
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -106,6 +108,9 @@ type Client struct {
|
|||||||
Conn *websocket.Conn
|
Conn *websocket.Conn
|
||||||
UserID string
|
UserID string
|
||||||
Username string
|
Username string
|
||||||
|
IsBot bool
|
||||||
|
BotID string
|
||||||
|
BotName string
|
||||||
send chan []byte
|
send chan []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +160,18 @@ func (c *Client) readPump() {
|
|||||||
c.Hub.BroadcastEvent(Event{Type: event.Type, Data: data})
|
c.Hub.BroadcastEvent(Event{Type: event.Type, Data: data})
|
||||||
case EventPresenceUpdate:
|
case EventPresenceUpdate:
|
||||||
c.Hub.BroadcastEvent(event)
|
c.Hub.BroadcastEvent(event)
|
||||||
|
case EventVoiceJoin, EventVoiceLeave, EventVoiceMute, EventVoiceDeafen:
|
||||||
|
// Broadcast voice state changes to all clients with sender info
|
||||||
|
var vData map[string]interface{}
|
||||||
|
if raw, ok := event.Data.(json.RawMessage); ok {
|
||||||
|
json.Unmarshal(raw, &vData)
|
||||||
|
}
|
||||||
|
if vData == nil {
|
||||||
|
vData = make(map[string]interface{})
|
||||||
|
}
|
||||||
|
vData["user_id"] = c.UserID
|
||||||
|
vData["username"] = c.Username
|
||||||
|
c.Hub.BroadcastEvent(Event{Type: event.Type, Data: vData})
|
||||||
case EventVoiceWhisper:
|
case EventVoiceWhisper:
|
||||||
// Forward voice whispers only to the target user, not broadcast
|
// Forward voice whispers only to the target user, not broadcast
|
||||||
var whisperData struct {
|
var whisperData struct {
|
||||||
@@ -172,6 +189,18 @@ func (c *Client) readPump() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case BotSendMessage:
|
||||||
|
if !c.IsBot {
|
||||||
|
c.Hub.logger.Warn("non-bot client sent SEND_MESSAGE", "user_id", c.UserID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.handleBotSendMessage(event.Data)
|
||||||
|
case BotDeleteMessage:
|
||||||
|
if !c.IsBot {
|
||||||
|
c.Hub.logger.Warn("non-bot client sent DELETE_MESSAGE", "user_id", c.UserID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.handleBotDeleteMessage(event.Data)
|
||||||
default:
|
default:
|
||||||
c.Hub.logger.Info("received event from client", "type", event.Type, "user_id", c.UserID)
|
c.Hub.logger.Info("received event from client", "type", event.Type, "user_id", c.UserID)
|
||||||
}
|
}
|
||||||
@@ -288,3 +317,195 @@ func ServeWS(db *sql.DB, hub *Hub, logger *slog.Logger, w http.ResponseWriter, r
|
|||||||
go client.writePump()
|
go client.writePump()
|
||||||
go client.readPump()
|
go client.readPump()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ServeBotWS handles websocket requests from bot clients.
|
||||||
|
// Authenticates via ?token= query param (bot token, hashed lookup).
|
||||||
|
func ServeBotWS(db *sql.DB, hub *Hub, logger *slog.Logger, w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := r.URL.Query().Get("token")
|
||||||
|
if token == "" {
|
||||||
|
http.Error(w, `{"error":"token query param required"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash the token and look up the bot.
|
||||||
|
tokenHash := hashToken(token)
|
||||||
|
|
||||||
|
var botID, botName, ownerID string
|
||||||
|
err := db.QueryRowContext(r.Context(),
|
||||||
|
`SELECT id, name, owner_id FROM bots WHERE token = $1`,
|
||||||
|
tokenHash,
|
||||||
|
).Scan(&botID, &botName, &ownerID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("bot ws auth: invalid token")
|
||||||
|
http.Error(w, `{"error":"invalid bot token"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the bot is added to at least one server.
|
||||||
|
var serverCount int
|
||||||
|
err = db.QueryRowContext(r.Context(),
|
||||||
|
`SELECT COUNT(*) FROM bot_servers WHERE bot_id = $1`, botID,
|
||||||
|
).Scan(&serverCount)
|
||||||
|
if err != nil || serverCount == 0 {
|
||||||
|
logger.Warn("bot ws auth: bot not added to any server", "bot_id", botID)
|
||||||
|
http.Error(w, `{"error":"bot not added to any server"}`, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("bot websocket upgrade failed", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load bot server memberships into hub so BroadcastToServer works.
|
||||||
|
hub.RefreshUserServers(ownerID)
|
||||||
|
|
||||||
|
conn.SetReadDeadline(time.Time{})
|
||||||
|
conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"ready","bot_id":"`+botID+`"}`))
|
||||||
|
|
||||||
|
client := &Client{
|
||||||
|
Hub: hub,
|
||||||
|
Conn: conn,
|
||||||
|
UserID: ownerID,
|
||||||
|
Username: botName,
|
||||||
|
IsBot: true,
|
||||||
|
BotID: botID,
|
||||||
|
BotName: botName,
|
||||||
|
send: make(chan []byte, 256),
|
||||||
|
}
|
||||||
|
|
||||||
|
hub.Register(client)
|
||||||
|
|
||||||
|
go client.writePump()
|
||||||
|
go client.readPump()
|
||||||
|
}
|
||||||
|
|
||||||
|
// hashToken returns the SHA-256 hex digest of a token.
|
||||||
|
func hashToken(token string) string {
|
||||||
|
h := sha256.Sum256([]byte(token))
|
||||||
|
return hex.EncodeToString(h[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBotSendMessage processes a SEND_MESSAGE action from a bot client.
|
||||||
|
func (c *Client) handleBotSendMessage(data interface{}) {
|
||||||
|
var payload struct {
|
||||||
|
ChannelID string `json:"channel_id"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
raw, ok := data.(json.RawMessage)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &payload); err != nil || payload.ChannelID == "" || payload.Content == "" {
|
||||||
|
c.Hub.logger.Warn("bot SEND_MESSAGE: invalid payload")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(payload.Content) > 4000 {
|
||||||
|
payload.Content = payload.Content[:4000]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the bot is in the server that owns this channel.
|
||||||
|
serverID, err := c.Hub.ServerIDForChannel(context.Background(), payload.ChannelID)
|
||||||
|
if err != nil {
|
||||||
|
c.Hub.logger.Warn("bot SEND_MESSAGE: channel not found", "channel_id", payload.ChannelID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var inServer bool
|
||||||
|
err = c.Hub.db.QueryRowContext(context.Background(),
|
||||||
|
`SELECT EXISTS(SELECT 1 FROM bot_servers WHERE bot_id = $1 AND server_id = $2)`,
|
||||||
|
c.BotID, serverID,
|
||||||
|
).Scan(&inServer)
|
||||||
|
if err != nil || !inServer {
|
||||||
|
c.Hub.logger.Warn("bot SEND_MESSAGE: bot not in server", "bot_id", c.BotID, "server_id", serverID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert the message with bot_id set.
|
||||||
|
var msgID, createdAt string
|
||||||
|
err = c.Hub.db.QueryRowContext(context.Background(),
|
||||||
|
`INSERT INTO messages (channel_id, author_id, content, bot_id)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, created_at::text`,
|
||||||
|
payload.ChannelID, c.UserID, payload.Content, c.BotID,
|
||||||
|
).Scan(&msgID, &createdAt)
|
||||||
|
if err != nil {
|
||||||
|
c.Hub.logger.Error("bot SEND_MESSAGE: insert failed", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast MESSAGE_CREATE to the server.
|
||||||
|
c.Hub.BroadcastToServer(serverID, Event{
|
||||||
|
Type: EventMessageCreate,
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"id": msgID,
|
||||||
|
"channel_id": payload.ChannelID,
|
||||||
|
"author_id": c.UserID,
|
||||||
|
"author_username": c.BotName,
|
||||||
|
"author_display_name": nil,
|
||||||
|
"author_bot": true,
|
||||||
|
"bot_id": c.BotID,
|
||||||
|
"bot_name": c.BotName,
|
||||||
|
"content": payload.Content,
|
||||||
|
"reply_to": nil,
|
||||||
|
"edited_at": nil,
|
||||||
|
"pinned": false,
|
||||||
|
"created_at": createdAt,
|
||||||
|
"embeds": []interface{}{},
|
||||||
|
"reactions": []interface{}{},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBotDeleteMessage processes a DELETE_MESSAGE action from a bot client.
|
||||||
|
func (c *Client) handleBotDeleteMessage(data interface{}) {
|
||||||
|
var payload struct {
|
||||||
|
ChannelID string `json:"channel_id"`
|
||||||
|
MessageID string `json:"message_id"`
|
||||||
|
}
|
||||||
|
raw, ok := data.(json.RawMessage)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &payload); err != nil || payload.ChannelID == "" || payload.MessageID == "" {
|
||||||
|
c.Hub.logger.Warn("bot DELETE_MESSAGE: invalid payload")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the bot is in the server that owns this channel.
|
||||||
|
serverID, err := c.Hub.ServerIDForChannel(context.Background(), payload.ChannelID)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var inServer bool
|
||||||
|
err = c.Hub.db.QueryRowContext(context.Background(),
|
||||||
|
`SELECT EXISTS(SELECT 1 FROM bot_servers WHERE bot_id = $1 AND server_id = $2)`,
|
||||||
|
c.BotID, serverID,
|
||||||
|
).Scan(&inServer)
|
||||||
|
if err != nil || !inServer {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the message (only if it exists in this channel).
|
||||||
|
result, err := c.Hub.db.ExecContext(context.Background(),
|
||||||
|
`DELETE FROM messages WHERE id = $1 AND channel_id = $2`,
|
||||||
|
payload.MessageID, payload.ChannelID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.Hub.logger.Error("bot DELETE_MESSAGE: delete failed", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, _ := result.RowsAffected()
|
||||||
|
if rows == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast MESSAGE_DELETE.
|
||||||
|
c.Hub.BroadcastToServer(serverID, Event{
|
||||||
|
Type: EventMessageDelete,
|
||||||
|
Data: map[string]string{
|
||||||
|
"id": payload.MessageID,
|
||||||
|
"channel_id": payload.ChannelID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ const (
|
|||||||
EventVoiceMute = "VOICE_MUTE"
|
EventVoiceMute = "VOICE_MUTE"
|
||||||
EventVoiceDeafen = "VOICE_DEAFEN"
|
EventVoiceDeafen = "VOICE_DEAFEN"
|
||||||
EventVoiceWhisper = "VOICE_WHISPER"
|
EventVoiceWhisper = "VOICE_WHISPER"
|
||||||
|
|
||||||
|
// Bot action events (sent by bot clients)
|
||||||
|
BotSendMessage = "SEND_MESSAGE"
|
||||||
|
BotDeleteMessage = "DELETE_MESSAGE"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Event represents a WebSocket event sent to clients.
|
// Event represents a WebSocket event sent to clients.
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ type Handler struct {
|
|||||||
pushHandler *push.Handler
|
pushHandler *push.Handler
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
checker *permissions.Checker
|
checker *permissions.Checker
|
||||||
|
// Optional: intercepts /confess so the original message is never stored/broadcast.
|
||||||
|
confess ConfessHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfessHandler posts an anonymous confession and returns the bot message payload.
|
||||||
|
type ConfessHandler interface {
|
||||||
|
TryConfess(ctx context.Context, serverID, authorID, content string) (payload map[string]interface{}, handled bool)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(db *sql.DB, hub *gateway.Hub, pushHandler *push.Handler, logger *slog.Logger, checker *permissions.Checker) *Handler {
|
func NewHandler(db *sql.DB, hub *gateway.Hub, pushHandler *push.Handler, logger *slog.Logger, checker *permissions.Checker) *Handler {
|
||||||
@@ -40,6 +47,11 @@ func NewHandler(db *sql.DB, hub *gateway.Hub, pushHandler *push.Handler, logger
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetConfessHandler wires the built-in confess interceptor (optional).
|
||||||
|
func (h *Handler) SetConfessHandler(c ConfessHandler) {
|
||||||
|
h.confess = c
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) RegisterRoutes(r chi.Router) {
|
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||||
r.Get("/", h.List)
|
r.Get("/", h.List)
|
||||||
r.Post("/", h.Create)
|
r.Post("/", h.Create)
|
||||||
@@ -119,6 +131,7 @@ func (h *Handler) BulkDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
Type: gateway.EventMessageDelete,
|
Type: gateway.EventMessageDelete,
|
||||||
Data: map[string]string{
|
Data: map[string]string{
|
||||||
"id": id,
|
"id": id,
|
||||||
|
"message_id": id,
|
||||||
"channel_id": channelID,
|
"channel_id": channelID,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -159,6 +172,9 @@ type messageResponse struct {
|
|||||||
AuthorID string `json:"author_id"`
|
AuthorID string `json:"author_id"`
|
||||||
AuthorName string `json:"author_username"`
|
AuthorName string `json:"author_username"`
|
||||||
DisplayName *string `json:"author_display_name"`
|
DisplayName *string `json:"author_display_name"`
|
||||||
|
AuthorBot bool `json:"author_bot"`
|
||||||
|
BotID *string `json:"bot_id,omitempty"`
|
||||||
|
BotName *string `json:"bot_name,omitempty"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
ReplyTo *string `json:"reply_to,omitempty"`
|
ReplyTo *string `json:"reply_to,omitempty"`
|
||||||
EditedAt *string `json:"edited_at"`
|
EditedAt *string `json:"edited_at"`
|
||||||
@@ -240,6 +256,29 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gate @everyone / @channel on MENTION_EVERYONE (owner/admin always pass).
|
||||||
|
if hasBroadcastToken(req.Content, "everyone") || hasBroadcastToken(req.Content, "channel") {
|
||||||
|
allowed, permErr := h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MENTION_EVERYONE)
|
||||||
|
if permErr != nil {
|
||||||
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
http.Error(w, `{"error":"missing permission: MENTION_EVERYONE"}`, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anonymous confessions: never store/broadcast the original /confess message.
|
||||||
|
if h.confess != nil {
|
||||||
|
if payload, handled := h.confess.TryConfess(r.Context(), serverID, userID, req.Content); handled {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var msg messageResponse
|
var msg messageResponse
|
||||||
var editedAt sql.NullString
|
var editedAt sql.NullString
|
||||||
var createdAt sql.NullString
|
var createdAt sql.NullString
|
||||||
@@ -464,6 +503,7 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
|||||||
Type: gateway.EventMessageDelete,
|
Type: gateway.EventMessageDelete,
|
||||||
Data: map[string]string{
|
Data: map[string]string{
|
||||||
"id": messageID,
|
"id": messageID,
|
||||||
|
"message_id": messageID,
|
||||||
"channel_id": channelID,
|
"channel_id": channelID,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -505,18 +545,20 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
|||||||
var err error
|
var err error
|
||||||
if before != "" {
|
if before != "" {
|
||||||
rows, err = h.db.QueryContext(r.Context(), `
|
rows, err = h.db.QueryContext(r.Context(), `
|
||||||
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.pinned, m.created_at::text
|
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.pinned, m.created_at::text, m.bot_id, b.name
|
||||||
FROM messages m
|
FROM messages m
|
||||||
JOIN users u ON m.author_id = u.id
|
JOIN users u ON m.author_id = u.id
|
||||||
|
LEFT JOIN bots b ON m.bot_id = b.id
|
||||||
WHERE m.channel_id = $1 AND m.created_at < (SELECT created_at FROM messages WHERE id = $2)
|
WHERE m.channel_id = $1 AND m.created_at < (SELECT created_at FROM messages WHERE id = $2)
|
||||||
ORDER BY m.created_at DESC
|
ORDER BY m.created_at DESC
|
||||||
LIMIT $3
|
LIMIT $3
|
||||||
`, channelID, before, limit)
|
`, channelID, before, limit)
|
||||||
} else {
|
} else {
|
||||||
rows, err = h.db.QueryContext(r.Context(), `
|
rows, err = h.db.QueryContext(r.Context(), `
|
||||||
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.pinned, m.created_at::text
|
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.pinned, m.created_at::text, m.bot_id, b.name
|
||||||
FROM messages m
|
FROM messages m
|
||||||
JOIN users u ON m.author_id = u.id
|
JOIN users u ON m.author_id = u.id
|
||||||
|
LEFT JOIN bots b ON m.bot_id = b.id
|
||||||
WHERE m.channel_id = $1
|
WHERE m.channel_id = $1
|
||||||
ORDER BY m.created_at DESC
|
ORDER BY m.created_at DESC
|
||||||
LIMIT $2
|
LIMIT $2
|
||||||
@@ -534,7 +576,9 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
|||||||
var editedAt sql.NullString
|
var editedAt sql.NullString
|
||||||
var createdAt sql.NullString
|
var createdAt sql.NullString
|
||||||
var replyTo sql.NullString
|
var replyTo sql.NullString
|
||||||
err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt)
|
var botID sql.NullString
|
||||||
|
var botName sql.NullString
|
||||||
|
err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt, &botID, &botName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -544,6 +588,13 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
|||||||
if editedAt.Valid {
|
if editedAt.Valid {
|
||||||
msg.EditedAt = &editedAt.String
|
msg.EditedAt = &editedAt.String
|
||||||
}
|
}
|
||||||
|
if botID.Valid {
|
||||||
|
msg.BotID = &botID.String
|
||||||
|
msg.AuthorBot = true
|
||||||
|
}
|
||||||
|
if botName.Valid {
|
||||||
|
msg.BotName = &botName.String
|
||||||
|
}
|
||||||
msg.CreatedAt = createdAt.String
|
msg.CreatedAt = createdAt.String
|
||||||
messages = append(messages, msg)
|
messages = append(messages, msg)
|
||||||
}
|
}
|
||||||
@@ -802,9 +853,10 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
rows, err := h.db.QueryContext(r.Context(), `
|
rows, err := h.db.QueryContext(r.Context(), `
|
||||||
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.pinned, m.created_at::text,
|
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.pinned, m.created_at::text,
|
||||||
ts_rank(m.search_vector, plainto_tsquery('english', $2)) AS rank
|
ts_rank(m.search_vector, plainto_tsquery('english', $2)) AS rank, m.bot_id, b.name
|
||||||
FROM messages m
|
FROM messages m
|
||||||
JOIN users u ON m.author_id = u.id
|
JOIN users u ON m.author_id = u.id
|
||||||
|
LEFT JOIN bots b ON m.bot_id = b.id
|
||||||
WHERE m.channel_id = $1 AND m.search_vector @@ plainto_tsquery('english', $2)
|
WHERE m.channel_id = $1 AND m.search_vector @@ plainto_tsquery('english', $2)
|
||||||
ORDER BY rank DESC, m.created_at DESC
|
ORDER BY rank DESC, m.created_at DESC
|
||||||
LIMIT $3
|
LIMIT $3
|
||||||
@@ -822,7 +874,9 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
|
|||||||
var createdAt sql.NullString
|
var createdAt sql.NullString
|
||||||
var replyTo sql.NullString
|
var replyTo sql.NullString
|
||||||
var rank float64
|
var rank float64
|
||||||
err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt, &rank)
|
var botID sql.NullString
|
||||||
|
var botName sql.NullString
|
||||||
|
err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt, &rank, &botID, &botName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -832,6 +886,13 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
|
|||||||
if editedAt.Valid {
|
if editedAt.Valid {
|
||||||
msg.EditedAt = &editedAt.String
|
msg.EditedAt = &editedAt.String
|
||||||
}
|
}
|
||||||
|
if botID.Valid {
|
||||||
|
msg.BotID = &botID.String
|
||||||
|
msg.AuthorBot = true
|
||||||
|
}
|
||||||
|
if botName.Valid {
|
||||||
|
msg.BotName = &botName.String
|
||||||
|
}
|
||||||
msg.CreatedAt = createdAt.String
|
msg.CreatedAt = createdAt.String
|
||||||
messages = append(messages, msg)
|
messages = append(messages, msg)
|
||||||
}
|
}
|
||||||
@@ -1059,9 +1120,10 @@ func (h *Handler) ListPinned(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rows, err := h.db.QueryContext(r.Context(), `
|
rows, err := h.db.QueryContext(r.Context(), `
|
||||||
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.pinned, m.created_at::text
|
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.pinned, m.created_at::text, m.bot_id, b.name
|
||||||
FROM messages m
|
FROM messages m
|
||||||
JOIN users u ON m.author_id = u.id
|
JOIN users u ON m.author_id = u.id
|
||||||
|
LEFT JOIN bots b ON m.bot_id = b.id
|
||||||
WHERE m.channel_id = $1 AND m.pinned = TRUE
|
WHERE m.channel_id = $1 AND m.pinned = TRUE
|
||||||
ORDER BY m.created_at DESC
|
ORDER BY m.created_at DESC
|
||||||
`, channelID)
|
`, channelID)
|
||||||
@@ -1077,7 +1139,9 @@ func (h *Handler) ListPinned(w http.ResponseWriter, r *http.Request) {
|
|||||||
var editedAt sql.NullString
|
var editedAt sql.NullString
|
||||||
var createdAt sql.NullString
|
var createdAt sql.NullString
|
||||||
var replyTo sql.NullString
|
var replyTo sql.NullString
|
||||||
err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt)
|
var botID sql.NullString
|
||||||
|
var botName sql.NullString
|
||||||
|
err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt, &botID, &botName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1087,6 +1151,13 @@ func (h *Handler) ListPinned(w http.ResponseWriter, r *http.Request) {
|
|||||||
if editedAt.Valid {
|
if editedAt.Valid {
|
||||||
msg.EditedAt = &editedAt.String
|
msg.EditedAt = &editedAt.String
|
||||||
}
|
}
|
||||||
|
if botID.Valid {
|
||||||
|
msg.BotID = &botID.String
|
||||||
|
msg.AuthorBot = true
|
||||||
|
}
|
||||||
|
if botName.Valid {
|
||||||
|
msg.BotName = &botName.String
|
||||||
|
}
|
||||||
msg.CreatedAt = createdAt.String
|
msg.CreatedAt = createdAt.String
|
||||||
messages = append(messages, msg)
|
messages = append(messages, msg)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push"
|
||||||
)
|
)
|
||||||
|
|
||||||
var mentionRegex = regexp.MustCompile(`<@([0-9a-f-]+)>`)
|
var mentionRegex = regexp.MustCompile(`<@([0-9a-f-]+)>`)
|
||||||
var everyoneMention = "@everyone"
|
|
||||||
var roleMentionRegex = regexp.MustCompile(`<@&([0-9a-f-]+)>`)
|
var roleMentionRegex = regexp.MustCompile(`<@&([0-9a-f-]+)>`)
|
||||||
|
var plainUsernameMention = regexp.MustCompile(`@([a-zA-Z0-9_.-]+)`)
|
||||||
|
|
||||||
// MentionHandler dispatches push notifications for @mentions.
|
// MentionHandler dispatches push notifications for @mentions.
|
||||||
type MentionHandler struct {
|
type MentionHandler struct {
|
||||||
@@ -29,20 +30,40 @@ func NewMentionHandler(db *sql.DB, pushHandler *push.Handler, logger *slog.Logge
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hasBroadcastToken reports whether content contains @everyone / @channel as a whole token.
|
||||||
|
func hasBroadcastToken(content, token string) bool {
|
||||||
|
// token like "everyone" or "channel" (without @)
|
||||||
|
needle := "@" + token
|
||||||
|
idx := 0
|
||||||
|
for {
|
||||||
|
i := strings.Index(strings.ToLower(content[idx:]), needle)
|
||||||
|
if i < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
i += idx
|
||||||
|
end := i + len(needle)
|
||||||
|
if end >= len(content) || !isUsernameChar(rune(content[end])) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
idx = end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isUsernameChar(r rune) bool {
|
||||||
|
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '.' || r == '-'
|
||||||
|
}
|
||||||
|
|
||||||
// ParseAndNotify parses message content for mentions and sends push notifications.
|
// ParseAndNotify parses message content for mentions and sends push notifications.
|
||||||
func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID, content string) {
|
func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID, content string) {
|
||||||
// Find individual user mentions
|
|
||||||
userMatches := mentionRegex.FindAllStringSubmatch(content, -1)
|
|
||||||
mentionedUsers := make(map[string]bool)
|
mentionedUsers := make(map[string]bool)
|
||||||
for _, match := range userMatches {
|
|
||||||
|
// Discord-style ID mentions
|
||||||
|
for _, match := range mentionRegex.FindAllStringSubmatch(content, -1) {
|
||||||
if len(match) > 1 {
|
if len(match) > 1 {
|
||||||
mentionedUsers[match[1]] = true
|
mentionedUsers[match[1]] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for @everyone
|
|
||||||
isEveryone := strings.Contains(content, everyoneMention)
|
|
||||||
|
|
||||||
// Get channel info for notification
|
// Get channel info for notification
|
||||||
var serverID, channelName string
|
var serverID, channelName string
|
||||||
err := m.db.QueryRowContext(ctx,
|
err := m.db.QueryRowContext(ctx,
|
||||||
@@ -75,8 +96,9 @@ func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID
|
|||||||
"url": "/channels/" + channelID,
|
"url": "/channels/" + channelID,
|
||||||
}
|
}
|
||||||
|
|
||||||
if isEveryone {
|
// @everyone / @channel — fan out to server members (permission gated at create).
|
||||||
// Send to all server members except those who muted this channel
|
// ponytail: both use the same fanout; UI labels differ. Split if channel-private members matter.
|
||||||
|
if hasBroadcastToken(content, "everyone") || hasBroadcastToken(content, "channel") {
|
||||||
rows, err := m.db.QueryContext(ctx,
|
rows, err := m.db.QueryContext(ctx,
|
||||||
`SELECT m.user_id FROM members m
|
`SELECT m.user_id FROM members m
|
||||||
LEFT JOIN notification_settings ns ON ns.user_id = m.user_id AND ns.channel_id = $3
|
LEFT JOIN notification_settings ns ON ns.user_id = m.user_id AND ns.channel_id = $3
|
||||||
@@ -85,7 +107,7 @@ func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID
|
|||||||
serverID, authorID, channelID,
|
serverID, authorID, channelID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.logger.Error("failed to query server members for @everyone", "error", err)
|
m.logger.Error("failed to query server members for broadcast mention", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
@@ -100,13 +122,43 @@ func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for role mentions
|
// Plain @username mentions (what the frontend actually stores)
|
||||||
|
usernames := make([]string, 0)
|
||||||
|
seenUsernames := make(map[string]bool)
|
||||||
|
for _, match := range plainUsernameMention.FindAllStringSubmatch(content, -1) {
|
||||||
|
if len(match) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
u := strings.ToLower(match[1])
|
||||||
|
if u == "everyone" || u == "channel" || u == "here" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !seenUsernames[u] {
|
||||||
|
seenUsernames[u] = true
|
||||||
|
usernames = append(usernames, match[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(usernames) > 0 {
|
||||||
|
// Resolve usernames that are members of this server.
|
||||||
|
for _, uname := range usernames {
|
||||||
|
var uid string
|
||||||
|
err := m.db.QueryRowContext(ctx, `
|
||||||
|
SELECT u.id FROM users u
|
||||||
|
JOIN members m ON m.user_id = u.id
|
||||||
|
WHERE m.server_id = $1 AND LOWER(u.username) = LOWER($2)
|
||||||
|
`, serverID, uname).Scan(&uid)
|
||||||
|
if err == nil {
|
||||||
|
mentionedUsers[uid] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role mentions
|
||||||
roleMatches := roleMentionRegex.FindAllStringSubmatch(content, -1)
|
roleMatches := roleMentionRegex.FindAllStringSubmatch(content, -1)
|
||||||
if len(roleMatches) > 0 {
|
if len(roleMatches) > 0 {
|
||||||
for _, match := range roleMatches {
|
for _, match := range roleMatches {
|
||||||
if len(match) > 1 {
|
if len(match) > 1 {
|
||||||
roleID := match[1]
|
roleID := match[1]
|
||||||
// Get users with this role
|
|
||||||
rows, err := m.db.QueryContext(ctx,
|
rows, err := m.db.QueryContext(ctx,
|
||||||
`SELECT user_id FROM member_roles WHERE role_id = $1 AND user_id != $2`,
|
`SELECT user_id FROM member_roles WHERE role_id = $1 AND user_id != $2`,
|
||||||
roleID, authorID,
|
roleID, authorID,
|
||||||
@@ -127,12 +179,9 @@ func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the author from mentions
|
|
||||||
delete(mentionedUsers, authorID)
|
delete(mentionedUsers, authorID)
|
||||||
|
|
||||||
// Send push to individually mentioned users
|
|
||||||
for userID := range mentionedUsers {
|
for userID := range mentionedUsers {
|
||||||
// Check if user is in DND status
|
|
||||||
var status string
|
var status string
|
||||||
err := m.db.QueryRowContext(ctx,
|
err := m.db.QueryRowContext(ctx,
|
||||||
`SELECT COALESCE(status, 'online') FROM users WHERE id = $1`, userID,
|
`SELECT COALESCE(status, 'online') FROM users WHERE id = $1`, userID,
|
||||||
@@ -144,7 +193,6 @@ func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user muted this channel
|
|
||||||
var level string
|
var level string
|
||||||
err = m.db.QueryRowContext(ctx,
|
err = m.db.QueryRowContext(ctx,
|
||||||
`SELECT level FROM notification_settings WHERE user_id = $1 AND channel_id = $2`,
|
`SELECT level FROM notification_settings WHERE user_id = $1 AND channel_id = $2`,
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package message
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestHasBroadcastToken(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
content string
|
||||||
|
token string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"hello @everyone", "everyone", true},
|
||||||
|
{"@everyone hi", "everyone", true},
|
||||||
|
{"@EVERYONE", "everyone", true},
|
||||||
|
{"@everyone!", "everyone", true},
|
||||||
|
{"@everyoneelse", "everyone", false},
|
||||||
|
{"noteveryone", "everyone", false},
|
||||||
|
{"@channel", "channel", true},
|
||||||
|
{"ping @channel please", "channel", true},
|
||||||
|
{"@channeling", "channel", false},
|
||||||
|
{"", "everyone", false},
|
||||||
|
{"@", "everyone", false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got := hasBroadcastToken(tc.content, tc.token)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("hasBroadcastToken(%q, %q) = %v, want %v", tc.content, tc.token, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsUsernameChar(t *testing.T) {
|
||||||
|
if !isUsernameChar('a') || !isUsernameChar('9') || !isUsernameChar('_') {
|
||||||
|
t.Fatal("expected alnum/_")
|
||||||
|
}
|
||||||
|
if isUsernameChar(' ') || isUsernameChar('!') || isUsernameChar('@') {
|
||||||
|
t.Fatal("unexpected username chars")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package permissions
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestHas(t *testing.T) {
|
||||||
|
set := VIEW_CHANNEL | SEND_MESSAGES | MENTION_EVERYONE
|
||||||
|
|
||||||
|
if !Has(set, VIEW_CHANNEL) {
|
||||||
|
t.Fatal("expected VIEW_CHANNEL")
|
||||||
|
}
|
||||||
|
if !Has(set, SEND_MESSAGES) {
|
||||||
|
t.Fatal("expected SEND_MESSAGES")
|
||||||
|
}
|
||||||
|
if Has(set, KICK_MEMBERS) {
|
||||||
|
t.Fatal("did not expect KICK_MEMBERS")
|
||||||
|
}
|
||||||
|
if !Has(set, VIEW_CHANNEL|SEND_MESSAGES) {
|
||||||
|
t.Fatal("expected multi-bit all-present")
|
||||||
|
}
|
||||||
|
if Has(set, VIEW_CHANNEL|KICK_MEMBERS) {
|
||||||
|
t.Fatal("multi-bit should require all bits")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdministratorBypassSemantics(t *testing.T) {
|
||||||
|
// Client/backend convention: ADMINISTRATOR implies all gates when checked separately.
|
||||||
|
if !Has(ADMINISTRATOR, ADMINISTRATOR) {
|
||||||
|
t.Fatal("admin flag self")
|
||||||
|
}
|
||||||
|
// ADMINISTRATOR alone does not set other bits; Has is pure bit check.
|
||||||
|
if Has(ADMINISTRATOR, KICK_MEMBERS) {
|
||||||
|
t.Fatal("Has is not an admin-implies-all helper; CheckPermission does that")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultEveryoneDoesNotIncludeMentionEveryone(t *testing.T) {
|
||||||
|
if Has(DefaultEveryonePermissions, MENTION_EVERYONE) {
|
||||||
|
t.Fatal("@everyone default must not grant MENTION_EVERYONE")
|
||||||
|
}
|
||||||
|
if !Has(DefaultEveryonePermissions, SEND_MESSAGES) {
|
||||||
|
t.Fatal("@everyone default should grant SEND_MESSAGES")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddRemove(t *testing.T) {
|
||||||
|
p := int64(0)
|
||||||
|
p = Add(p, VIEW_CHANNEL)
|
||||||
|
p = Add(p, KICK_MEMBERS)
|
||||||
|
if !Has(p, VIEW_CHANNEL|KICK_MEMBERS) {
|
||||||
|
t.Fatal("Add failed")
|
||||||
|
}
|
||||||
|
p = Remove(p, KICK_MEMBERS)
|
||||||
|
if Has(p, KICK_MEMBERS) {
|
||||||
|
t.Fatal("Remove failed")
|
||||||
|
}
|
||||||
|
if !Has(p, VIEW_CHANNEL) {
|
||||||
|
t.Fatal("Remove cleared wrong bit")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,8 @@ type memberResponse struct {
|
|||||||
Avatar string `json:"avatar"`
|
Avatar string `json:"avatar"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
StatusText string `json:"status_text"`
|
StatusText string `json:"status_text"`
|
||||||
|
IsBot bool `json:"is_bot,omitempty"`
|
||||||
|
BotType string `json:"bot_type,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *MemberHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
|
func (h *MemberHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -82,6 +84,28 @@ func (h *MemberHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
|
|||||||
members = append(members, m)
|
members = append(members, m)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bots added to this server (separate BOTS section in the member list).
|
||||||
|
botRows, err := h.db.QueryContext(r.Context(), `
|
||||||
|
SELECT b.id, b.name, COALESCE(b.avatar, ''), COALESCE(b.bot_type, '')
|
||||||
|
FROM bot_servers bs
|
||||||
|
JOIN bots b ON b.id = bs.bot_id
|
||||||
|
WHERE bs.server_id = $1
|
||||||
|
ORDER BY b.name
|
||||||
|
`, serverID)
|
||||||
|
if err == nil {
|
||||||
|
defer botRows.Close()
|
||||||
|
for botRows.Next() {
|
||||||
|
var m memberResponse
|
||||||
|
if err := botRows.Scan(&m.ID, &m.Username, &m.Avatar, &m.BotType); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m.DisplayName = m.Username
|
||||||
|
m.Status = "online" // ponytail: no bot presence yet; always show online
|
||||||
|
m.IsBot = true
|
||||||
|
members = append(members, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(members)
|
json.NewEncoder(w).Encode(members)
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-2
@@ -1,13 +1,40 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en" data-theme="gruvbox">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<script>
|
||||||
|
// Apply saved theme before paint (migrates legacy light/dark).
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var t = localStorage.getItem('dumpster-theme');
|
||||||
|
if (t === 'light') t = 'gruvbox-light';
|
||||||
|
else if (t === 'dark' || !t) t = 'gruvbox';
|
||||||
|
var ok = {
|
||||||
|
gruvbox: 1,
|
||||||
|
'gruvbox-light': 1,
|
||||||
|
'one-dark': 1,
|
||||||
|
dracula: 1,
|
||||||
|
nord: 1,
|
||||||
|
'tokyo-night': 1,
|
||||||
|
catppuccin: 1,
|
||||||
|
'solarized-dark': 1,
|
||||||
|
monokai: 1,
|
||||||
|
'github-dark': 1,
|
||||||
|
};
|
||||||
|
if (!ok[t]) t = 'gruvbox';
|
||||||
|
document.documentElement.setAttribute('data-theme', t);
|
||||||
|
document.documentElement.classList.add(t === 'gruvbox-light' ? 'light' : 'dark');
|
||||||
|
} catch (e) {
|
||||||
|
document.documentElement.setAttribute('data-theme', 'gruvbox');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
<!-- PWA Meta Tags -->
|
<!-- PWA Meta Tags -->
|
||||||
<meta name="theme-color" content="#282828" media="(prefers-color-scheme: dark)" />
|
<meta name="theme-color" content="#282828" media="(prefers-color-scheme: dark)" />
|
||||||
<meta name="theme-color" content="#282828" />
|
<meta name="theme-color" content="#282828" />
|
||||||
<meta name="color-scheme" content="dark" />
|
<meta name="color-scheme" content="dark light" />
|
||||||
<meta name="description" content="A chaotic, self-hosted Discord-like platform" />
|
<meta name="description" content="A chaotic, self-hosted Discord-like platform" />
|
||||||
|
|
||||||
<!-- Apple -->
|
<!-- Apple -->
|
||||||
|
|||||||
Generated
+1
-1
@@ -77,7 +77,7 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "app"
|
name = "app"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
+15
-2
@@ -5,6 +5,7 @@ import { Layout } from './components/Layout.tsx';
|
|||||||
import { ChatArea } from './components/ChatArea.tsx';
|
import { ChatArea } from './components/ChatArea.tsx';
|
||||||
import { UserSettings } from './components/UserSettings.tsx';
|
import { UserSettings } from './components/UserSettings.tsx';
|
||||||
import { BotManager } from './components/BotManager.tsx';
|
import { BotManager } from './components/BotManager.tsx';
|
||||||
|
import { BotStore } from './components/BotStore.tsx';
|
||||||
import { CommandManager } from './components/CommandManager.tsx';
|
import { CommandManager } from './components/CommandManager.tsx';
|
||||||
import { RoleManager } from './components/RoleManager.tsx';
|
import { RoleManager } from './components/RoleManager.tsx';
|
||||||
import { JoinServer } from './components/JoinServer.tsx';
|
import { JoinServer } from './components/JoinServer.tsx';
|
||||||
@@ -69,11 +70,23 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/bots"
|
path="/bots"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<div className="h-full w-full flex flex-col bg-gb-bg">
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<BotStore />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/bots/manage"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<div className="h-full w-full flex flex-col bg-gb-bg">
|
<div className="h-full w-full flex flex-col bg-gb-bg">
|
||||||
<header className="flex items-center gap-4 px-4 py-2 border-b border-gb-bg-t bg-gb-bg-s">
|
<header className="flex items-center gap-4 px-4 py-2 border-b border-gb-bg-t bg-gb-bg-s">
|
||||||
<Link to="/" className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm">
|
<Link to="/bots" className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm">
|
||||||
← [BACK]
|
← [BACK]
|
||||||
</Link>
|
</Link>
|
||||||
<span className="text-gb-orange font-mono text-sm">BOT MANAGER</span>
|
<span className="text-gb-orange font-mono text-sm">BOT MANAGER</span>
|
||||||
@@ -91,7 +104,7 @@ function App() {
|
|||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<div className="h-full w-full flex flex-col bg-gb-bg">
|
<div className="h-full w-full flex flex-col bg-gb-bg">
|
||||||
<header className="flex items-center gap-4 px-4 py-2 border-b border-gb-bg-t bg-gb-bg-s">
|
<header className="flex items-center gap-4 px-4 py-2 border-b border-gb-bg-t bg-gb-bg-s">
|
||||||
<Link to="/bots" className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm">
|
<Link to="/bots/manage" className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm">
|
||||||
← [BACK]
|
← [BACK]
|
||||||
</Link>
|
</Link>
|
||||||
<span className="text-gb-orange font-mono text-sm">SLASH COMMANDS</span>
|
<span className="text-gb-orange font-mono text-sm">SLASH COMMANDS</span>
|
||||||
|
|||||||
@@ -1,8 +1,26 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useVoiceStore } from '../stores/voice.ts';
|
import { useVoiceStore } from '../stores/voice.ts';
|
||||||
import { Track } from 'livekit-client';
|
import { Track, RoomEvent } from 'livekit-client';
|
||||||
import type { Participant, TrackPublication, Room } from 'livekit-client';
|
import type { Participant, TrackPublication, Room } from 'livekit-client';
|
||||||
|
|
||||||
|
// ponytail: global set of audio elements blocked by autoplay policy
|
||||||
|
const pendingAudio = new Set<HTMLAudioElement>();
|
||||||
|
|
||||||
|
function flushPendingAudio() {
|
||||||
|
for (const el of pendingAudio) {
|
||||||
|
el.play().catch(() => {});
|
||||||
|
pendingAudio.delete(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let listenerAttached = false;
|
||||||
|
function ensureInteractionListener() {
|
||||||
|
if (listenerAttached) return;
|
||||||
|
listenerAttached = true;
|
||||||
|
document.addEventListener('click', flushPendingAudio, { once: false });
|
||||||
|
document.addEventListener('keydown', flushPendingAudio, { once: false });
|
||||||
|
}
|
||||||
|
|
||||||
function RemoteAudioTrack({ participant, room }: { participant: Participant; room: Room }) {
|
function RemoteAudioTrack({ participant, room }: { participant: Participant; room: Room }) {
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
|
||||||
@@ -12,44 +30,35 @@ function RemoteAudioTrack({ participant, room }: { participant: Participant; roo
|
|||||||
|
|
||||||
let pub: TrackPublication | undefined;
|
let pub: TrackPublication | undefined;
|
||||||
|
|
||||||
const attachTrack = () => {
|
const tryPlay = () => {
|
||||||
|
el.play().catch(() => {
|
||||||
|
pendingAudio.add(el);
|
||||||
|
ensureInteractionListener();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const attachIfReady = () => {
|
||||||
pub = participant.getTrackPublication(Track.Source.Microphone);
|
pub = participant.getTrackPublication(Track.Source.Microphone);
|
||||||
if (pub?.track && el) {
|
if (pub?.track && el) {
|
||||||
pub.track.attach(el);
|
pub.track.attach(el);
|
||||||
el.play().catch(() => {});
|
tryPlay();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const detachTrack = () => {
|
attachIfReady();
|
||||||
if (pub?.track && el) {
|
|
||||||
pub.track.detach(el);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
attachTrack();
|
const handleSubscribed = (track: Track, _pub: TrackPublication, p: Participant) => {
|
||||||
|
if (p.identity === participant.identity && _pub.source === Track.Source.Microphone) {
|
||||||
const handlePublished = (publication: TrackPublication) => {
|
|
||||||
if (publication.source === Track.Source.Microphone && publication.track) {
|
|
||||||
publication.track.attach(el);
|
|
||||||
el.play().catch(() => {});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
room.on('trackPublished' as any, handlePublished);
|
|
||||||
|
|
||||||
// If a track is subscribed later
|
|
||||||
const handleSubscribed = (track: Track, publication: TrackPublication, trackParticipant: Participant) => {
|
|
||||||
if (trackParticipant.identity === participant.identity && publication.source === Track.Source.Microphone) {
|
|
||||||
track.attach(el);
|
track.attach(el);
|
||||||
el.play().catch(() => {});
|
tryPlay();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
room.on('trackSubscribed' as any, handleSubscribed);
|
room.on(RoomEvent.TrackSubscribed, handleSubscribed);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
detachTrack();
|
if (pub?.track) pub.track.detach(el);
|
||||||
room.off('trackPublished' as any, handlePublished);
|
pendingAudio.delete(el);
|
||||||
room.off('trackSubscribed' as any, handleSubscribed);
|
room.off(RoomEvent.TrackSubscribed, handleSubscribed);
|
||||||
};
|
};
|
||||||
}, [participant, room]);
|
}, [participant, room]);
|
||||||
|
|
||||||
@@ -58,6 +67,24 @@ function RemoteAudioTrack({ participant, room }: { participant: Participant; roo
|
|||||||
|
|
||||||
export function AudioRenderers() {
|
export function AudioRenderers() {
|
||||||
const room = useVoiceStore((s) => s._room);
|
const room = useVoiceStore((s) => s._room);
|
||||||
|
// ponytail: use a counter that increments on every participant change
|
||||||
|
// zustand's Object.is check on the participants array wasn't triggering re-renders
|
||||||
|
const [, setTick] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!room) return;
|
||||||
|
const bump = () => setTick((t) => t + 1);
|
||||||
|
room.on(RoomEvent.ParticipantConnected, bump);
|
||||||
|
room.on(RoomEvent.ParticipantDisconnected, bump);
|
||||||
|
room.on(RoomEvent.TrackSubscribed, bump);
|
||||||
|
room.on(RoomEvent.TrackUnsubscribed, bump);
|
||||||
|
return () => {
|
||||||
|
room.off(RoomEvent.ParticipantConnected, bump);
|
||||||
|
room.off(RoomEvent.ParticipantDisconnected, bump);
|
||||||
|
room.off(RoomEvent.TrackSubscribed, bump);
|
||||||
|
room.off(RoomEvent.TrackUnsubscribed, bump);
|
||||||
|
};
|
||||||
|
}, [room]);
|
||||||
|
|
||||||
if (!room) return null;
|
if (!room) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,30 @@ import { useEffect, useState } from 'react';
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useBotStore, type Bot } from '../stores/bot.ts';
|
import { useBotStore, type Bot } from '../stores/bot.ts';
|
||||||
import { useServerStore } from '../stores/server.ts';
|
import { useServerStore } from '../stores/server.ts';
|
||||||
|
import { useChannelStore } from '../stores/channel.ts';
|
||||||
|
|
||||||
|
// ponytail: config fields per bot type, add new types here
|
||||||
|
const BOT_TYPE_CONFIGS: Record<string, { label: string; fields: { key: string; label: string; type: 'text' | 'number' | 'channel'; placeholder: string }[] }> = {
|
||||||
|
steamfree: {
|
||||||
|
label: 'Steam Free Games',
|
||||||
|
fields: [
|
||||||
|
{ key: 'channel_id', label: 'CHANNEL', type: 'channel', placeholder: 'select channel' },
|
||||||
|
{ key: 'poll_minutes', label: 'POLL INTERVAL (min)', type: 'number', placeholder: '30' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
confess: {
|
||||||
|
label: 'Anonymous Confessions',
|
||||||
|
fields: [
|
||||||
|
{ key: 'channel_id', label: 'CONFESSIONS CHANNEL', type: 'channel', placeholder: 'where confessions land' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
leaderboard: {
|
||||||
|
label: 'Shitpost Leaderboard',
|
||||||
|
fields: [
|
||||||
|
{ key: 'channel_id', label: 'CHANNEL', type: 'channel', placeholder: 'where recaps land' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export function BotManager() {
|
export function BotManager() {
|
||||||
const bots = useBotStore((s) => s.bots);
|
const bots = useBotStore((s) => s.bots);
|
||||||
@@ -16,10 +40,15 @@ export function BotManager() {
|
|||||||
|
|
||||||
const servers = useServerStore((s) => s.servers);
|
const servers = useServerStore((s) => s.servers);
|
||||||
const fetchServers = useServerStore((s) => s.fetchServers);
|
const fetchServers = useServerStore((s) => s.fetchServers);
|
||||||
|
const channelsByServer = useChannelStore((s) => s.channelsByServer);
|
||||||
|
const fetchChannels = useChannelStore((s) => s.fetchChannels);
|
||||||
|
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [createName, setCreateName] = useState('');
|
const [createName, setCreateName] = useState('');
|
||||||
const [createDesc, setCreateDesc] = useState('');
|
const [createDesc, setCreateDesc] = useState('');
|
||||||
|
const [createType, setCreateType] = useState('');
|
||||||
|
const [createConfig, setCreateConfig] = useState<Record<string, string>>({});
|
||||||
|
const [channelServerId, setChannelServerId] = useState('');
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [editName, setEditName] = useState('');
|
const [editName, setEditName] = useState('');
|
||||||
const [editDesc, setEditDesc] = useState('');
|
const [editDesc, setEditDesc] = useState('');
|
||||||
@@ -37,10 +66,20 @@ export function BotManager() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!createName.trim()) return;
|
if (!createName.trim()) return;
|
||||||
try {
|
try {
|
||||||
const result = await createBot(createName.trim(), createDesc.trim());
|
// Build config object with proper types
|
||||||
|
const cfg: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(createConfig)) {
|
||||||
|
if (v === '') continue;
|
||||||
|
const fieldDef = BOT_TYPE_CONFIGS[createType]?.fields.find((f) => f.key === k);
|
||||||
|
cfg[k] = fieldDef?.type === 'number' ? parseInt(v, 10) : v;
|
||||||
|
}
|
||||||
|
const result = await createBot(createName.trim(), createDesc.trim(), createType || undefined, Object.keys(cfg).length ? cfg : undefined);
|
||||||
setTokenDisplay({ botId: result.id, token: result.token });
|
setTokenDisplay({ botId: result.id, token: result.token });
|
||||||
setCreateName('');
|
setCreateName('');
|
||||||
setCreateDesc('');
|
setCreateDesc('');
|
||||||
|
setCreateType('');
|
||||||
|
setCreateConfig({});
|
||||||
|
setChannelServerId('');
|
||||||
setShowCreate(false);
|
setShowCreate(false);
|
||||||
} catch {
|
} catch {
|
||||||
// error handled in store
|
// error handled in store
|
||||||
@@ -102,6 +141,8 @@ export function BotManager() {
|
|||||||
setEditDesc(bot.description);
|
setEditDesc(bot.description);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const typeConfig = createType ? BOT_TYPE_CONFIGS[createType] : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full bg-gb-bg p-4 md:p-8 overflow-y-auto">
|
<div className="h-full w-full bg-gb-bg p-4 md:p-8 overflow-y-auto">
|
||||||
<div className="max-w-3xl mx-auto">
|
<div className="max-w-3xl mx-auto">
|
||||||
@@ -180,13 +221,70 @@ export function BotManager() {
|
|||||||
placeholder="what does this bot do?"
|
placeholder="what does this bot do?"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-gb-fg-f mb-1 font-mono text-xs">TYPE:</label>
|
||||||
|
<select
|
||||||
|
value={createType}
|
||||||
|
onChange={(e) => { setCreateType(e.target.value); setCreateConfig({}); }}
|
||||||
|
className="terminal-input w-full"
|
||||||
|
>
|
||||||
|
<option value="">External (connects via WebSocket)</option>
|
||||||
|
{Object.entries(BOT_TYPE_CONFIGS).map(([key, cfg]) => (
|
||||||
|
<option key={key} value={key}>{cfg.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{typeConfig && typeConfig.fields.map((field) => (
|
||||||
|
<div key={field.key}>
|
||||||
|
<label className="block text-gb-fg-f mb-1 font-mono text-xs">{field.label}:</label>
|
||||||
|
{field.type === 'channel' ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<select
|
||||||
|
value={channelServerId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setChannelServerId(e.target.value);
|
||||||
|
setCreateConfig((prev) => ({ ...prev, [field.key]: '' }));
|
||||||
|
if (e.target.value) fetchChannels(e.target.value);
|
||||||
|
}}
|
||||||
|
className="terminal-input w-full"
|
||||||
|
>
|
||||||
|
<option value="">-- select server first --</option>
|
||||||
|
{servers.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>{s.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={createConfig[field.key] || ''}
|
||||||
|
onChange={(e) => setCreateConfig((prev) => ({ ...prev, [field.key]: e.target.value }))}
|
||||||
|
className="terminal-input w-full"
|
||||||
|
disabled={!channelServerId}
|
||||||
|
>
|
||||||
|
<option value="">-- select channel --</option>
|
||||||
|
{(channelsByServer[channelServerId] || [])
|
||||||
|
.filter((c) => c.type === 'text')
|
||||||
|
.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>#{c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type={field.type}
|
||||||
|
value={createConfig[field.key] || ''}
|
||||||
|
onChange={(e) => setCreateConfig((prev) => ({ ...prev, [field.key]: e.target.value }))}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
className="terminal-input w-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button type="submit" className="terminal-button" disabled={loading || !createName.trim()}>
|
<button type="submit" className="terminal-button" disabled={loading || !createName.trim()}>
|
||||||
{loading ? '[CREATING...]' : '[SAVE]'}
|
{loading ? '[CREATING...]' : '[SAVE]'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setShowCreate(false); setCreateName(''); setCreateDesc(''); }}
|
onClick={() => { setShowCreate(false); setCreateName(''); setCreateDesc(''); setCreateType(''); setCreateConfig({}); setChannelServerId(''); }}
|
||||||
className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm"
|
className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm"
|
||||||
>
|
>
|
||||||
[CANCEL]
|
[CANCEL]
|
||||||
@@ -255,6 +353,9 @@ export function BotManager() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
[{bot.name}]
|
[{bot.name}]
|
||||||
|
{bot.bot_type && (
|
||||||
|
<span className="text-gb-aqua text-xs ml-2">({BOT_TYPE_CONFIGS[bot.bot_type]?.label || bot.bot_type})</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
{bot.description && (
|
{bot.description && (
|
||||||
<p className="text-gb-fg-f font-mono text-xs mt-1 truncate">
|
<p className="text-gb-fg-f font-mono text-xs mt-1 truncate">
|
||||||
@@ -348,8 +449,8 @@ export function BotManager() {
|
|||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="mt-6 pt-4 border-t border-gb-bg-t">
|
<div className="mt-6 pt-4 border-t border-gb-bg-t">
|
||||||
<Link to="/" className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm">
|
<Link to="/bots" className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm">
|
||||||
{'<'} [BACK TO CHAT]
|
{'<'} [BACK TO STORE]
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useBotStore, type StoreBot } from '../stores/bot.ts';
|
||||||
|
import { useServerStore } from '../stores/server.ts';
|
||||||
|
import { useAuthStore } from '../stores/auth.ts';
|
||||||
|
|
||||||
|
export function BotStore() {
|
||||||
|
const storeBots = useBotStore((s) => s.storeBots);
|
||||||
|
const loading = useBotStore((s) => s.loading);
|
||||||
|
const error = useBotStore((s) => s.error);
|
||||||
|
const fetchStoreBots = useBotStore((s) => s.fetchStoreBots);
|
||||||
|
const addToServer = useBotStore((s) => s.addToServer);
|
||||||
|
|
||||||
|
const servers = useServerStore((s) => s.servers);
|
||||||
|
const fetchServers = useServerStore((s) => s.fetchServers);
|
||||||
|
const currentUserId = useAuthStore((s) => s.user?.id);
|
||||||
|
|
||||||
|
const [addBotId, setAddBotId] = useState<string | null>(null);
|
||||||
|
const [selectedServer, setSelectedServer] = useState('');
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStoreBots();
|
||||||
|
fetchServers();
|
||||||
|
}, [fetchStoreBots, fetchServers]);
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
if (!addBotId || !selectedServer) return;
|
||||||
|
setAdding(true);
|
||||||
|
try {
|
||||||
|
await addToServer(addBotId, selectedServer);
|
||||||
|
setAddBotId(null);
|
||||||
|
setSelectedServer('');
|
||||||
|
fetchStoreBots(); // refresh counts
|
||||||
|
} catch {
|
||||||
|
// error in store
|
||||||
|
} finally {
|
||||||
|
setAdding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filtered = storeBots.filter((b) =>
|
||||||
|
b.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
b.description.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full bg-gb-bg p-4 md:p-8 overflow-y-auto">
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<div className="border border-gb-bg-t p-6">
|
||||||
|
<pre className="text-gb-orange font-mono text-center mb-2">
|
||||||
|
{'┌──────────────────────────────────┐\n'}
|
||||||
|
{'│ === BOT STORE === │\n'}
|
||||||
|
{'└──────────────────────────────────┘'}
|
||||||
|
</pre>
|
||||||
|
<p className="text-gb-fg-f font-mono text-xs text-center mb-6">
|
||||||
|
browse and add bots to your servers
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-gb-red text-sm font-mono mb-4">ERR: {error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="search bots..."
|
||||||
|
className="terminal-input w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bot list */}
|
||||||
|
{loading && storeBots.length === 0 && (
|
||||||
|
<p className="text-gb-fg-f font-mono text-sm">[loading...]</p>
|
||||||
|
)}
|
||||||
|
{!loading && filtered.length === 0 && (
|
||||||
|
<p className="text-gb-fg-f font-mono text-sm">[no bots found]</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filtered.map((bot) => (
|
||||||
|
<StoreBotCard
|
||||||
|
key={bot.id}
|
||||||
|
bot={bot}
|
||||||
|
isOwner={bot.owner_id === currentUserId}
|
||||||
|
onAdd={() => { setAddBotId(bot.id); setSelectedServer(''); }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add to server modal */}
|
||||||
|
{addBotId && (
|
||||||
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||||
|
<div className="border border-gb-bg-t bg-gb-bg p-6 max-w-md w-full mx-4">
|
||||||
|
<p className="text-gb-orange font-mono text-sm mb-4">
|
||||||
|
{'>'} ADD BOT TO SERVER
|
||||||
|
</p>
|
||||||
|
<select
|
||||||
|
value={selectedServer}
|
||||||
|
onChange={(e) => setSelectedServer(e.target.value)}
|
||||||
|
className="terminal-input w-full mb-4"
|
||||||
|
>
|
||||||
|
<option value="">-- select server --</option>
|
||||||
|
{servers.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>{s.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAdd}
|
||||||
|
className="terminal-button text-xs"
|
||||||
|
disabled={!selectedServer || adding}
|
||||||
|
>
|
||||||
|
{adding ? '[ADDING...]' : '[ADD]'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setAddBotId(null); setSelectedServer(''); }}
|
||||||
|
className="text-gb-fg-f hover:text-gb-aqua font-mono text-xs"
|
||||||
|
>
|
||||||
|
[CANCEL]
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="mt-6 pt-4 border-t border-gb-bg-t flex justify-between">
|
||||||
|
<Link to="/" className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm">
|
||||||
|
{'<'} [BACK TO CHAT]
|
||||||
|
</Link>
|
||||||
|
<Link to="/bots/manage" className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm">
|
||||||
|
[MY BOTS]
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StoreBotCard({ bot, isOwner, onAdd }: { bot: StoreBot; isOwner: boolean; onAdd: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="border border-gb-bg-t p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-gb-green font-mono text-sm">
|
||||||
|
{bot.avatar && (
|
||||||
|
<img
|
||||||
|
src={bot.avatar}
|
||||||
|
alt=""
|
||||||
|
className="inline w-5 h-5 mr-1 align-middle border border-gb-bg-t"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
[{bot.name}]
|
||||||
|
{isOwner && (
|
||||||
|
<span className="text-gb-fg-f text-xs ml-2">(yours)</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{bot.description && (
|
||||||
|
<p className="text-gb-fg-f font-mono text-xs mt-1">{bot.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<p className="text-gb-aqua font-mono text-xs">
|
||||||
|
{bot.server_count} {bot.server_count === 1 ? 'server' : 'servers'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAdd}
|
||||||
|
className="terminal-button text-xs"
|
||||||
|
>
|
||||||
|
[ADD TO SERVER]
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ export function CalendarView({ channelId, channelName }: CalendarViewProps) {
|
|||||||
const [end, setEnd] = useState('');
|
const [end, setEnd] = useState('');
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null);
|
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null);
|
||||||
|
const [view, setView] = useState<'grid' | 'list'>('list');
|
||||||
|
|
||||||
const monthStart = useMemo(() => new Date(date.getFullYear(), date.getMonth(), 1), [date]);
|
const monthStart = useMemo(() => new Date(date.getFullYear(), date.getMonth(), 1), [date]);
|
||||||
const monthEnd = useMemo(() => new Date(date.getFullYear(), date.getMonth() + 1, 0), [date]);
|
const monthEnd = useMemo(() => new Date(date.getFullYear(), date.getMonth() + 1, 0), [date]);
|
||||||
@@ -49,7 +50,11 @@ export function CalendarView({ channelId, channelName }: CalendarViewProps) {
|
|||||||
const to = `${date.getFullYear()}-${String(date.getMonth() + 2).padStart(2, '0')}-01T00:00:00Z`;
|
const to = `${date.getFullYear()}-${String(date.getMonth() + 2).padStart(2, '0')}-01T00:00:00Z`;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
api.get<CalendarEvent[]>(`/channels/${channelId}/events?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`)
|
api.get<CalendarEvent[]>(`/channels/${channelId}/events?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`)
|
||||||
.then((data) => setEvents(Array.isArray(data) ? data : []))
|
.then((data) => {
|
||||||
|
console.log('[calendar] loaded', data?.length, 'events for channel', channelId);
|
||||||
|
setEvents(Array.isArray(data) ? data : []);
|
||||||
|
})
|
||||||
|
.catch((err) => console.error('[calendar] fetch error:', err))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [channelId, date]);
|
}, [channelId, date]);
|
||||||
|
|
||||||
@@ -61,6 +66,29 @@ export function CalendarView({ channelId, channelName }: CalendarViewProps) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const weeks = useMemo(() => {
|
||||||
|
const sorted = [...events].sort((a, b) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime());
|
||||||
|
const grouped: { label: string; events: CalendarEvent[] }[] = [];
|
||||||
|
let currentKey = '';
|
||||||
|
for (const ev of sorted) {
|
||||||
|
const d = new Date(ev.start_time);
|
||||||
|
// week key = Monday of that week
|
||||||
|
const dayOfWeek = d.getDay();
|
||||||
|
const monday = new Date(d);
|
||||||
|
monday.setDate(d.getDate() - ((dayOfWeek + 6) % 7));
|
||||||
|
const key = monday.toISOString().slice(0, 10);
|
||||||
|
if (key !== currentKey) {
|
||||||
|
const sun = new Date(monday);
|
||||||
|
sun.setDate(monday.getDate() + 6);
|
||||||
|
const label = `${monday.toLocaleDateString('default', { month: 'short', day: 'numeric' })} \u2013 ${sun.toLocaleDateString('default', { month: 'short', day: 'numeric' })}`;
|
||||||
|
grouped.push({ label, events: [] });
|
||||||
|
currentKey = key;
|
||||||
|
}
|
||||||
|
grouped[grouped.length - 1].events.push(ev);
|
||||||
|
}
|
||||||
|
return grouped;
|
||||||
|
}, [events]);
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!title || !start) return;
|
if (!title || !start) return;
|
||||||
const payload = { title, start_time: start, end_time: end || undefined, description: description || undefined };
|
const payload = { title, start_time: start, end_time: end || undefined, description: description || undefined };
|
||||||
@@ -85,8 +113,12 @@ export function CalendarView({ channelId, channelName }: CalendarViewProps) {
|
|||||||
<div className="flex flex-col h-full bg-gb-bg">
|
<div className="flex flex-col h-full bg-gb-bg">
|
||||||
<div className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg-s flex items-center justify-between">
|
<div className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg-s flex items-center justify-between">
|
||||||
<span>○ {channelName}</span>
|
<span>○ {channelName}</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => setView('list')} className={`text-xs font-mono ${view === 'list' ? 'text-gb-orange' : 'text-gb-fg-f hover:text-gb-orange'}`}>[LIST]</button>
|
||||||
|
<button onClick={() => setView('grid')} className={`text-xs font-mono ${view === 'grid' ? 'text-gb-orange' : 'text-gb-fg-f hover:text-gb-orange'}`}>[GRID]</button>
|
||||||
<button onClick={() => setShowForm((p) => !p)} className="text-xs text-gb-fg-f hover:text-gb-orange font-mono">[NEW EVENT]</button>
|
<button onClick={() => setShowForm((p) => !p)} className="text-xs text-gb-fg-f hover:text-gb-orange font-mono">[NEW EVENT]</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<div className="p-3 border-b border-gb-bg-t space-y-2 font-mono text-xs">
|
<div className="p-3 border-b border-gb-bg-t space-y-2 font-mono text-xs">
|
||||||
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="title" className="terminal-input w-full" />
|
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="title" className="terminal-input w-full" />
|
||||||
@@ -103,6 +135,8 @@ export function CalendarView({ channelId, channelName }: CalendarViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
{loading && <div className="p-3 text-gb-fg-f text-xs font-mono">[loading...]</div>}
|
{loading && <div className="p-3 text-gb-fg-f text-xs font-mono">[loading...]</div>}
|
||||||
<div className="flex-1 overflow-y-auto p-3">
|
<div className="flex-1 overflow-y-auto p-3">
|
||||||
|
{view === 'grid' ? (
|
||||||
|
<>
|
||||||
<div className="grid grid-cols-7 gap-1 text-center text-xs font-mono text-gb-fg-s mb-1">
|
<div className="grid grid-cols-7 gap-1 text-center text-xs font-mono text-gb-fg-s mb-1">
|
||||||
{['S','M','T','W','T','F','S'].map((d) => <div key={d}>{d}</div>)}
|
{['S','M','T','W','T','F','S'].map((d) => <div key={d}>{d}</div>)}
|
||||||
</div>
|
</div>
|
||||||
@@ -127,6 +161,40 @@ export function CalendarView({ channelId, channelName }: CalendarViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4 font-mono text-xs">
|
||||||
|
{weeks.length === 0 && !loading && (
|
||||||
|
<div className="text-gb-fg-f">No events this month.</div>
|
||||||
|
)}
|
||||||
|
{weeks.map((week) => (
|
||||||
|
<div key={week.label}>
|
||||||
|
<div className="text-gb-orange font-bold mb-1 border-b border-gb-bg-t pb-1">
|
||||||
|
{week.label}
|
||||||
|
</div>
|
||||||
|
{week.events.map((ev) => {
|
||||||
|
const s = new Date(ev.start_time);
|
||||||
|
const e = ev.end_time ? new Date(ev.end_time) : null;
|
||||||
|
const dayName = s.toLocaleDateString('default', { weekday: 'short' });
|
||||||
|
const dateStr = s.toLocaleDateString('default', { month: 'short', day: 'numeric' });
|
||||||
|
const startStr = s.toLocaleTimeString('default', { hour: 'numeric', minute: '2-digit' });
|
||||||
|
const endStr = e ? e.toLocaleTimeString('default', { hour: 'numeric', minute: '2-digit' }) : '';
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={ev.id}
|
||||||
|
onClick={() => setSelectedEvent(ev)}
|
||||||
|
className="w-full text-left px-2 py-1 hover:bg-gb-bg-t rounded-sm flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<span className="text-gb-fg-f w-20 shrink-0">{dayName} {dateStr}</span>
|
||||||
|
<span className="text-gb-fg-s w-28 shrink-0">{startStr}{endStr ? ` \u2013 ${endStr}` : ''}</span>
|
||||||
|
<span className="text-gb-fg truncate">{ev.title}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{selectedEvent && (
|
{selectedEvent && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gb-bg/90 p-4" onClick={() => setSelectedEvent(null)}>
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gb-bg/90 p-4" onClick={() => setSelectedEvent(null)}>
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { usePermissionStore, PERMISSION_LABELS, PERMS, type ChannelOverride, hasPermission } from "../stores/permissions.ts";
|
import { api } from '../lib/api.ts';
|
||||||
import { useRoleStore } from "../stores/role.ts";
|
import {
|
||||||
import { useMemberStore } from "../stores/member.ts";
|
usePermissionStore,
|
||||||
import { useChannelStore } from "../stores/channel.ts";
|
PERMS,
|
||||||
import { api } from "../lib/api.ts";
|
PERM_CATEGORIES,
|
||||||
|
PERMISSION_LABELS,
|
||||||
|
type ChannelOverride,
|
||||||
|
type PermissionKey,
|
||||||
|
} from '../stores/permissions.ts';
|
||||||
|
import { useRoleStore, type Role } from '../stores/role.ts';
|
||||||
|
import { useMemberStore, type Member } from '../stores/member.ts';
|
||||||
|
|
||||||
interface ChannelSettingsModalProps {
|
interface ChannelSettingsModalProps {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -12,227 +18,626 @@ interface ChannelSettingsModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tab = 'overview' | 'permissions';
|
type TargetType = 'role' | 'user';
|
||||||
type TargetMode = 'role' | 'user';
|
type TriState = 'allow' | 'deny' | 'inherit';
|
||||||
|
|
||||||
interface OverrideFormData {
|
interface DraftOverride {
|
||||||
targetType: TargetMode;
|
target_type: TargetType;
|
||||||
targetId: string;
|
target_id: string;
|
||||||
allow: number;
|
allow: number;
|
||||||
deny: number;
|
deny: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function OverrideMatrix({ allow, deny, onChange }: { allow: number; deny: number; onChange: (allow: number, deny: number) => void }) {
|
// Channel-scoped perms only (admin / manage-server stay at role level).
|
||||||
const toggle = (flag: number, state: 'allow' | 'deny' | 'inherit') => {
|
const CHANNEL_PERM_KEYS = new Set<PermissionKey>([
|
||||||
let nextAllow = allow;
|
'VIEW_CHANNEL',
|
||||||
let nextDeny = deny;
|
'SEND_MESSAGES',
|
||||||
if (state === 'allow') {
|
'MANAGE_MESSAGES',
|
||||||
nextAllow |= flag;
|
'ADD_REACTIONS',
|
||||||
nextDeny &= ~flag;
|
'EMBED_LINKS',
|
||||||
} else if (state === 'deny') {
|
'ATTACH_FILES',
|
||||||
nextAllow &= ~flag;
|
'MENTION_EVERYONE',
|
||||||
nextDeny |= flag;
|
'USE_EXTERNAL_EMOJIS',
|
||||||
} else {
|
'CREATE_INSTANT_INVITE',
|
||||||
nextAllow &= ~flag;
|
'CONNECT_VOICE',
|
||||||
nextDeny &= ~flag;
|
'SPEAK_VOICE',
|
||||||
}
|
'SHARE_SCREEN',
|
||||||
onChange(nextAllow, nextDeny);
|
'MUTE_MEMBERS',
|
||||||
};
|
]);
|
||||||
|
|
||||||
const state = (flag: number): 'allow' | 'deny' | 'inherit' => {
|
const CHANNEL_PERM_CATEGORIES = PERM_CATEGORIES.map((cat) => ({
|
||||||
if (hasPermission(allow, flag)) return 'allow';
|
...cat,
|
||||||
if (hasPermission(deny, flag)) return 'deny';
|
keys: cat.keys.filter((k) => CHANNEL_PERM_KEYS.has(k)),
|
||||||
return 'inherit';
|
})).filter((cat) => cat.keys.length > 0);
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
function targetKey(type: TargetType, id: string) {
|
||||||
<div className="space-y-1 max-h-64 overflow-y-auto border border-gb-bg-t p-2">
|
return `${type}:${id}`;
|
||||||
{(Object.keys(PERMISSION_LABELS) as (keyof typeof PERMISSION_LABELS)[]).map((key) => {
|
|
||||||
const flag = PERMS[key];
|
|
||||||
const s = state(flag);
|
|
||||||
return (
|
|
||||||
<div key={key} className="flex items-center justify-between text-xs font-mono py-1">
|
|
||||||
<span className="text-gb-fg">{PERMISSION_LABELS[key]}</span>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{(['inherit', 'allow', 'deny'] as const).map((opt) => (
|
|
||||||
<button
|
|
||||||
key={opt}
|
|
||||||
onClick={() => toggle(flag, opt)}
|
|
||||||
className={`px-2 py-0.5 border ${
|
|
||||||
s === opt
|
|
||||||
? opt === 'allow' ? 'bg-gb-green text-gb-bg border-gb-green' :
|
|
||||||
opt === 'deny' ? 'bg-gb-red text-gb-bg border-gb-red' :
|
|
||||||
'bg-gb-orange text-gb-bg border-gb-orange'
|
|
||||||
: 'border-gb-bg-t text-gb-fg-f hover:border-gb-fg-t'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{opt.toUpperCase()}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChannelSettingsModal({ serverId, channelId, channelName, onClose }: ChannelSettingsModalProps) {
|
function draftFromOverride(o: ChannelOverride): DraftOverride {
|
||||||
const [tab, setTab] = useState<Tab>('overview');
|
return {
|
||||||
const [form, setForm] = useState<OverrideFormData>({ targetType: 'role', targetId: '', allow: 0, deny: 0 });
|
target_type: o.target_type,
|
||||||
const [saving, setSaving] = useState(false);
|
target_id: o.target_id,
|
||||||
const [error, setError] = useState<string | null>(null);
|
allow: o.allow_bitflags,
|
||||||
const [editName, setEditName] = useState(channelName);
|
deny: o.deny_bitflags,
|
||||||
const [savingName, setSavingName] = useState(false);
|
};
|
||||||
const updateChannel = useChannelStore((s) => s.updateChannel);
|
}
|
||||||
|
|
||||||
const overrides = usePermissionStore((s) => s.overridesByChannel[channelId] || []);
|
function emptyDraft(type: TargetType, id: string): DraftOverride {
|
||||||
|
return { target_type: type, target_id: id, allow: 0, deny: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDirty(saved: ChannelOverride | undefined, draft: DraftOverride): boolean {
|
||||||
|
if (!saved) return draft.allow !== 0 || draft.deny !== 0;
|
||||||
|
return saved.allow_bitflags !== draft.allow || saved.deny_bitflags !== draft.deny;
|
||||||
|
}
|
||||||
|
|
||||||
|
function triState(allow: number, deny: number, flag: number): TriState {
|
||||||
|
if ((deny & flag) === flag) return 'deny';
|
||||||
|
if ((allow & flag) === flag) return 'allow';
|
||||||
|
return 'inherit';
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTri(draft: DraftOverride, flag: number, state: TriState): DraftOverride {
|
||||||
|
let allow = draft.allow & ~flag;
|
||||||
|
let deny = draft.deny & ~flag;
|
||||||
|
if (state === 'allow') allow |= flag;
|
||||||
|
if (state === 'deny') deny |= flag;
|
||||||
|
return { ...draft, allow, deny };
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayName(member: Member): string {
|
||||||
|
return member.nickname || member.display_name || member.username;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChannelSettingsModal({
|
||||||
|
serverId,
|
||||||
|
channelId,
|
||||||
|
channelName,
|
||||||
|
onClose,
|
||||||
|
}: ChannelSettingsModalProps) {
|
||||||
|
const [name, setName] = useState(channelName);
|
||||||
|
const [savingName, setSavingName] = useState(false);
|
||||||
|
const [nameError, setNameError] = useState<string | null>(null);
|
||||||
|
const [tab, setTab] = useState<'general' | 'permissions'>('general');
|
||||||
|
|
||||||
|
const overridesByChannel = usePermissionStore((s) => s.overridesByChannel);
|
||||||
const fetchOverrides = usePermissionStore((s) => s.fetchOverrides);
|
const fetchOverrides = usePermissionStore((s) => s.fetchOverrides);
|
||||||
const setOverride = usePermissionStore((s) => s.setOverride);
|
const setOverride = usePermissionStore((s) => s.setOverride);
|
||||||
const deleteOverride = usePermissionStore((s) => s.deleteOverride);
|
const deleteOverride = usePermissionStore((s) => s.deleteOverride);
|
||||||
|
|
||||||
const roles = useRoleStore((s) => s.roles);
|
const allRoles = useRoleStore((s) => s.roles);
|
||||||
const fetchRoles = useRoleStore((s) => s.fetchRoles);
|
const fetchRoles = useRoleStore((s) => s.fetchRoles);
|
||||||
const members = useMemberStore((s) => s.membersByServer[serverId] || []);
|
const membersByServer = useMemberStore((s) => s.membersByServer);
|
||||||
const fetchMembers = useMemberStore((s) => s.fetchMembers);
|
const fetchMembers = useMemberStore((s) => s.fetchMembers);
|
||||||
|
|
||||||
|
const overrides = overridesByChannel[channelId] || [];
|
||||||
|
const roles = useMemo(
|
||||||
|
() =>
|
||||||
|
allRoles
|
||||||
|
.filter((r) => r.server_id === serverId)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => b.position - a.position || a.name.localeCompare(b.name)),
|
||||||
|
[allRoles, serverId],
|
||||||
|
);
|
||||||
|
const members = useMemo(
|
||||||
|
() => (membersByServer[serverId] || []).filter((m) => !m.is_bot),
|
||||||
|
[membersByServer, serverId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||||
|
const [draft, setDraft] = useState<DraftOverride | null>(null);
|
||||||
|
const [savingPerms, setSavingPerms] = useState(false);
|
||||||
|
const [permError, setPermError] = useState<string | null>(null);
|
||||||
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
|
const [addTab, setAddTab] = useState<'role' | 'user'>('role');
|
||||||
|
const [addQuery, setAddQuery] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchOverrides(channelId).catch(() => setError('Failed to load overrides'));
|
void fetchOverrides(channelId);
|
||||||
fetchRoles(serverId).catch(() => {});
|
void fetchRoles(serverId);
|
||||||
fetchMembers(serverId).catch(() => {});
|
void fetchMembers(serverId);
|
||||||
}, [channelId, serverId, fetchOverrides, fetchRoles, fetchMembers]);
|
}, [channelId, serverId, fetchOverrides, fetchRoles, fetchMembers]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') onClose();
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (addOpen) {
|
||||||
|
setAddOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [onClose]);
|
}, [onClose, addOpen]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
// Auto-select first override when list loads / selection invalid.
|
||||||
if (!form.targetId) {
|
useEffect(() => {
|
||||||
setError('Select a target');
|
if (tab !== 'permissions') return;
|
||||||
|
if (overrides.length === 0) {
|
||||||
|
if (selectedKey && !draft) {
|
||||||
|
// local-only draft without saved row is fine
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSaving(true);
|
if (!selectedKey) {
|
||||||
setError(null);
|
setSelectedKey(null);
|
||||||
|
setDraft(null);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stillThere =
|
||||||
|
selectedKey &&
|
||||||
|
(overrides.some((o) => targetKey(o.target_type, o.target_id) === selectedKey) ||
|
||||||
|
(draft && targetKey(draft.target_type, draft.target_id) === selectedKey));
|
||||||
|
if (!stillThere) {
|
||||||
|
const first = overrides[0];
|
||||||
|
setSelectedKey(targetKey(first.target_type, first.target_id));
|
||||||
|
setDraft(draftFromOverride(first));
|
||||||
|
}
|
||||||
|
}, [overrides, selectedKey, draft, tab]);
|
||||||
|
|
||||||
|
const savedForDraft = draft
|
||||||
|
? overrides.find(
|
||||||
|
(o) => o.target_type === draft.target_type && o.target_id === draft.target_id,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
const dirty = draft ? isDirty(savedForDraft, draft) : false;
|
||||||
|
|
||||||
|
const resolveLabel = (type: TargetType, id: string): { label: string; color?: string; kind: string } => {
|
||||||
|
if (type === 'role') {
|
||||||
|
const role = roles.find((r) => r.id === id);
|
||||||
|
return {
|
||||||
|
label: role?.name ?? id.slice(0, 8),
|
||||||
|
color: role?.color || '#ebdbb2',
|
||||||
|
kind: role?.is_default ? 'everyone' : 'role',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const m = members.find((x) => x.id === id);
|
||||||
|
return {
|
||||||
|
label: m ? displayName(m) : id.slice(0, 8),
|
||||||
|
kind: 'member',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectTarget = (type: TargetType, id: string, fromOverride?: ChannelOverride) => {
|
||||||
|
if (dirty && !window.confirm('Discard unsaved permission changes?')) return;
|
||||||
|
setSelectedKey(targetKey(type, id));
|
||||||
|
setDraft(fromOverride ? draftFromOverride(fromOverride) : emptyDraft(type, id));
|
||||||
|
setPermError(null);
|
||||||
|
setAddOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const existingKeys = useMemo(() => {
|
||||||
|
const set = new Set(overrides.map((o) => targetKey(o.target_type, o.target_id)));
|
||||||
|
if (draft) set.add(targetKey(draft.target_type, draft.target_id));
|
||||||
|
return set;
|
||||||
|
}, [overrides, draft]);
|
||||||
|
|
||||||
|
const availableRoles = roles.filter((r) => !existingKeys.has(targetKey('role', r.id)));
|
||||||
|
const availableMembers = members.filter((m) => !existingKeys.has(targetKey('user', m.id)));
|
||||||
|
|
||||||
|
const filteredRoles = availableRoles.filter((r) =>
|
||||||
|
r.name.toLowerCase().includes(addQuery.toLowerCase()),
|
||||||
|
);
|
||||||
|
const filteredMembers = availableMembers.filter((m) => {
|
||||||
|
const q = addQuery.toLowerCase();
|
||||||
|
return (
|
||||||
|
m.username.toLowerCase().includes(q) ||
|
||||||
|
displayName(m).toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAddRole = (role: Role) => {
|
||||||
|
selectTarget('role', role.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddMember = (member: Member) => {
|
||||||
|
selectTarget('user', member.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSavePerms = async () => {
|
||||||
|
if (!draft) return;
|
||||||
|
setSavingPerms(true);
|
||||||
|
setPermError(null);
|
||||||
try {
|
try {
|
||||||
await setOverride(channelId, form.targetType, form.targetId, form.allow, form.deny);
|
await setOverride(channelId, draft.target_type, draft.target_id, draft.allow, draft.deny);
|
||||||
setForm({ targetType: 'role', targetId: '', allow: 0, deny: 0 });
|
// Re-sync draft from store after save.
|
||||||
|
const list = usePermissionStore.getState().overridesByChannel[channelId] || [];
|
||||||
|
const saved = list.find(
|
||||||
|
(o) => o.target_type === draft.target_type && o.target_id === draft.target_id,
|
||||||
|
);
|
||||||
|
if (saved) setDraft(draftFromOverride(saved));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to save override');
|
setPermError(err instanceof Error ? err.message : 'Failed to save overrides');
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSavingPerms(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveName = async () => {
|
const handleRemove = async () => {
|
||||||
const name = editName.trim();
|
if (!draft) return;
|
||||||
if (!name || name === channelName) return;
|
const { label } = resolveLabel(draft.target_type, draft.target_id);
|
||||||
setSavingName(true);
|
if (!window.confirm(`Remove permission overrides for ${label}?`)) return;
|
||||||
setError(null);
|
setPermError(null);
|
||||||
try {
|
try {
|
||||||
const updated = await api.patch<{ id: string; server_id: string; name: string; type: string; category: string | null; position: number; group_id?: string | null }>(
|
const exists = overrides.some(
|
||||||
`/servers/${serverId}/channels/${channelId}`,
|
(o) => o.target_type === draft.target_type && o.target_id === draft.target_id,
|
||||||
{ name }
|
|
||||||
);
|
);
|
||||||
updateChannel(updated as any);
|
if (exists) {
|
||||||
|
await deleteOverride(channelId, draft.target_type, draft.target_id);
|
||||||
|
}
|
||||||
|
setSelectedKey(null);
|
||||||
|
setDraft(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to rename channel');
|
setPermError(err instanceof Error ? err.message : 'Failed to remove override');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveName = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!name.trim() || name.trim() === channelName) return;
|
||||||
|
setSavingName(true);
|
||||||
|
setNameError(null);
|
||||||
|
try {
|
||||||
|
await api.patch(`/servers/${serverId}/channels/${channelId}`, { name: name.trim() });
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setNameError(err instanceof Error ? err.message : 'Failed to update channel');
|
||||||
} finally {
|
} finally {
|
||||||
setSavingName(false);
|
setSavingName(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const targets = form.targetType === 'role'
|
// Sidebar entries: all saved overrides + unsaved new draft not yet in list.
|
||||||
? roles.filter((r) => !r.is_default).map((r) => ({ id: r.id, label: r.name }))
|
const sidebarEntries = useMemo(() => {
|
||||||
: members.map((m) => ({ id: m.id, label: m.username }));
|
const keys = new Set<string>();
|
||||||
|
const items: { type: TargetType; id: string; override?: ChannelOverride }[] = [];
|
||||||
|
for (const o of overrides) {
|
||||||
|
const k = targetKey(o.target_type, o.target_id);
|
||||||
|
keys.add(k);
|
||||||
|
items.push({ type: o.target_type, id: o.target_id, override: o });
|
||||||
|
}
|
||||||
|
if (draft) {
|
||||||
|
const k = targetKey(draft.target_type, draft.target_id);
|
||||||
|
if (!keys.has(k)) {
|
||||||
|
items.push({ type: draft.target_type, id: draft.target_id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Roles first (everyone, then by name), then members.
|
||||||
|
return items.sort((a, b) => {
|
||||||
|
if (a.type !== b.type) return a.type === 'role' ? -1 : 1;
|
||||||
|
const la = resolveLabel(a.type, a.id).label.toLowerCase();
|
||||||
|
const lb = resolveLabel(b.type, b.id).label.toLowerCase();
|
||||||
|
return la.localeCompare(lb);
|
||||||
|
});
|
||||||
|
// resolveLabel depends on roles/members; include those
|
||||||
|
}, [overrides, draft, roles, members]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={onClose}>
|
<div
|
||||||
<div className="bg-gb-bg border border-gb-bg-t w-[600px] max-h-[80vh] flex flex-col font-mono" onClick={(e) => e.stopPropagation()}>
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-gb-bg border border-gb-bg-t w-[min(920px,95vw)] max-h-[85vh] flex flex-col font-mono"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gb-bg-t">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-gb-bg-t">
|
||||||
<span className="text-sm text-gb-orange">CHANNEL SETTINGS: #{channelName}</span>
|
<span className="text-sm text-gb-orange">CHANNEL SETTINGS: #{channelName}</span>
|
||||||
<button onClick={onClose} className="text-xs text-gb-red">[x]</button>
|
<button type="button" onClick={onClose} className="text-xs text-gb-red">
|
||||||
|
[x]
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex border-b border-gb-bg-t">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab('general')}
|
||||||
|
className={`px-4 py-2 text-xs ${
|
||||||
|
tab === 'general' ? 'text-gb-orange bg-gb-bg-s' : 'text-gb-fg-f hover:text-gb-orange'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
[GENERAL]
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab('permissions')}
|
||||||
|
className={`px-4 py-2 text-xs ${
|
||||||
|
tab === 'permissions'
|
||||||
|
? 'text-gb-orange bg-gb-bg-s'
|
||||||
|
: 'text-gb-fg-f hover:text-gb-orange'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
[PERMISSIONS]
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'general' && (
|
||||||
|
<div className="p-4 overflow-y-auto">
|
||||||
|
<form onSubmit={handleSaveName} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-gb-fg-f text-xs mb-1">CHANNEL NAME</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="terminal-input w-full"
|
||||||
|
maxLength={100}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{nameError && <p className="text-gb-red text-xs">ERR: {nameError}</p>}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={savingName || !name.trim() || name.trim() === channelName}
|
||||||
|
className="terminal-button disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{savingName ? '[SAVING...]' : '[SAVE]'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onClose} className="text-xs text-gb-fg-f hover:text-gb-red">
|
||||||
|
[CANCEL]
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'permissions' && (
|
||||||
|
<div className="flex flex-1 min-h-[55vh] max-h-[70vh]">
|
||||||
|
{/* Left: targets */}
|
||||||
|
<div className="w-52 md:w-60 shrink-0 border-r border-gb-bg-t flex flex-col bg-gb-bg-s">
|
||||||
|
<div className="p-2 border-b border-gb-bg-t space-y-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setAddOpen(true);
|
||||||
|
setAddQuery('');
|
||||||
|
setAddTab('role');
|
||||||
|
}}
|
||||||
|
className="w-full terminal-button text-xs"
|
||||||
|
>
|
||||||
|
[+ ADD ROLE / MEMBER]
|
||||||
|
</button>
|
||||||
|
<p className="text-[10px] text-gb-fg-f px-0.5">
|
||||||
|
tip: deny View for @everyone, allow a role for private channels
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{sidebarEntries.length === 0 && (
|
||||||
|
<p className="text-gb-fg-f text-xs p-3">[no overrides yet]</p>
|
||||||
|
)}
|
||||||
|
{sidebarEntries.map((entry) => {
|
||||||
|
const k = targetKey(entry.type, entry.id);
|
||||||
|
const active = k === selectedKey;
|
||||||
|
const { label, color, kind } = resolveLabel(entry.type, entry.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={k}
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectTarget(entry.type, entry.id, entry.override)}
|
||||||
|
className={`w-full text-left px-3 py-2 text-xs border-b border-gb-bg-t flex items-center gap-2 ${
|
||||||
|
active ? 'bg-gb-bg text-gb-fg' : 'text-gb-fg-f hover:bg-gb-bg hover:text-gb-fg'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{entry.type === 'role' ? (
|
||||||
|
<span
|
||||||
|
className="w-2.5 h-2.5 shrink-0 border border-gb-bg-t"
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-gb-aqua shrink-0">@</span>
|
||||||
|
)}
|
||||||
|
<span className="truncate" style={entry.type === 'role' && active ? { color } : undefined}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto text-[10px] text-gb-fg-f shrink-0">
|
||||||
|
{kind === 'everyone' ? '@' : entry.type === 'role' ? 'R' : 'U'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: tri-state editor */}
|
||||||
|
<div className="flex-1 min-w-0 flex flex-col relative">
|
||||||
|
{!draft ? (
|
||||||
|
<div className="flex-1 flex items-center justify-center text-gb-fg-f text-xs p-6">
|
||||||
|
[add a role or member to set channel permissions]
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||||
|
{permError && <p className="text-gb-red text-xs">ERR: {permError}</p>}
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="text-gb-fg-f">editing:</span>
|
||||||
|
<span className="text-gb-aqua">
|
||||||
|
{resolveLabel(draft.target_type, draft.target_id).label}
|
||||||
|
</span>
|
||||||
|
<span className="text-gb-fg-f">
|
||||||
|
({draft.target_type === 'role' ? 'role' : 'member'})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-gb-fg-f">
|
||||||
|
✓ allow · ✗ deny · – inherit from roles
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{CHANNEL_PERM_CATEGORIES.map((cat) => (
|
||||||
|
<div key={cat.name} className="border border-gb-bg-t">
|
||||||
|
<div className="px-3 py-1.5 bg-gb-bg-s text-gb-aqua text-xs border-b border-gb-bg-t">
|
||||||
|
[{cat.name.toUpperCase()}]
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gb-bg-t">
|
||||||
|
{cat.keys.map((key) => {
|
||||||
|
const flag = PERMS[key];
|
||||||
|
const state = triState(draft.allow, draft.deny, flag);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="flex items-center justify-between gap-2 px-3 py-2 text-xs"
|
||||||
|
>
|
||||||
|
<span className="text-gb-fg truncate">{PERMISSION_LABELS[key]}</span>
|
||||||
|
<div className="flex shrink-0 border border-gb-bg-t">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Deny"
|
||||||
|
onClick={() => setDraft(applyTri(draft, flag, 'deny'))}
|
||||||
|
className={`px-2 py-0.5 ${
|
||||||
|
state === 'deny'
|
||||||
|
? 'bg-gb-red text-gb-bg'
|
||||||
|
: 'text-gb-fg-f hover:text-gb-red'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
✗
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Inherit"
|
||||||
|
onClick={() => setDraft(applyTri(draft, flag, 'inherit'))}
|
||||||
|
className={`px-2 py-0.5 border-x border-gb-bg-t ${
|
||||||
|
state === 'inherit'
|
||||||
|
? 'bg-gb-bg-s text-gb-fg'
|
||||||
|
: 'text-gb-fg-f hover:text-gb-fg'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
–
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Allow"
|
||||||
|
onClick={() => setDraft(applyTri(draft, flag, 'allow'))}
|
||||||
|
className={`px-2 py-0.5 ${
|
||||||
|
state === 'allow'
|
||||||
|
? 'bg-gb-green text-gb-bg'
|
||||||
|
: 'text-gb-fg-f hover:text-gb-green'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="shrink-0 border-t border-gb-bg-t px-4 py-3 flex items-center gap-2 flex-wrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleSavePerms()}
|
||||||
|
disabled={savingPerms || !dirty}
|
||||||
|
className="terminal-button text-xs disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{savingPerms ? '[SAVING...]' : '[SAVE CHANGES]'}
|
||||||
|
</button>
|
||||||
|
{dirty && <span className="text-gb-orange text-[10px]">unsaved</span>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleRemove()}
|
||||||
|
className="ml-auto text-xs text-gb-fg-f hover:text-gb-red"
|
||||||
|
>
|
||||||
|
[REMOVE]
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add role/member picker overlay */}
|
||||||
|
{addOpen && (
|
||||||
|
<div className="absolute inset-0 bg-gb-bg/95 z-10 flex flex-col">
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 border-b border-gb-bg-t">
|
||||||
|
<span className="text-xs text-gb-orange">ADD TO CHANNEL</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAddOpen(false)}
|
||||||
|
className="text-xs text-gb-fg-f hover:text-gb-red"
|
||||||
|
>
|
||||||
|
[close]
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex border-b border-gb-bg-t">
|
<div className="flex border-b border-gb-bg-t">
|
||||||
<button onClick={() => setTab('overview')} className={`px-4 py-2 text-xs ${tab === 'overview' ? 'text-gb-orange bg-gb-bg-s' : 'text-gb-fg-s hover:text-gb-fg'}`}>[OVERVIEW]</button>
|
<button
|
||||||
<button onClick={() => setTab('permissions')} className={`px-4 py-2 text-xs ${tab === 'permissions' ? 'text-gb-orange bg-gb-bg-s' : 'text-gb-fg-s hover:text-gb-fg'}`}>[PERMISSIONS]</button>
|
type="button"
|
||||||
|
onClick={() => setAddTab('role')}
|
||||||
|
className={`px-3 py-1.5 text-xs ${
|
||||||
|
addTab === 'role' ? 'text-gb-orange bg-gb-bg-s' : 'text-gb-fg-f'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
[ROLES / GROUPS]
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAddTab('user')}
|
||||||
|
className={`px-3 py-1.5 text-xs ${
|
||||||
|
addTab === 'user' ? 'text-gb-orange bg-gb-bg-s' : 'text-gb-fg-f'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
[MEMBERS]
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="p-2 border-b border-gb-bg-t">
|
||||||
{tab === 'overview' && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="text-xs text-gb-fg-f">Channel name</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
<input
|
||||||
value={editName}
|
type="text"
|
||||||
onChange={(e) => setEditName(e.target.value)}
|
value={addQuery}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSaveName(); }}
|
onChange={(e) => setAddQuery(e.target.value)}
|
||||||
className="terminal-input text-sm flex-1"
|
placeholder={addTab === 'role' ? 'filter roles...' : 'filter members...'}
|
||||||
|
className="terminal-input w-full text-xs"
|
||||||
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
onClick={handleSaveName}
|
|
||||||
disabled={savingName || !editName.trim() || editName.trim() === channelName}
|
|
||||||
className="px-3 py-1 bg-gb-orange text-gb-bg text-xs disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{savingName ? 'SAVING...' : '[SAVE]'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{error && <div className="text-xs text-gb-red">ERR: {error}</div>}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{addTab === 'role' && filteredRoles.length === 0 && (
|
||||||
|
<p className="text-gb-fg-f text-xs p-3">[no roles left to add]</p>
|
||||||
)}
|
)}
|
||||||
{tab === 'permissions' && (
|
{addTab === 'role' &&
|
||||||
<div className="space-y-3">
|
filteredRoles.map((role) => (
|
||||||
<div className="text-xs text-gb-fg-f">Existing overrides</div>
|
|
||||||
{overrides.length === 0 && <div className="text-xs text-gb-fg-f">[none]</div>}
|
|
||||||
{overrides.map((o: ChannelOverride) => (
|
|
||||||
<div key={o.id} className="flex items-center justify-between border border-gb-bg-t p-2 text-xs">
|
|
||||||
<span className="text-gb-fg">{o.target_type}:{o.target_id}</span>
|
|
||||||
<div className="flex gap-2 text-gb-fg-f">
|
|
||||||
<span>allow:{o.allow_bitflags}</span>
|
|
||||||
<span>deny:{o.deny_bitflags}</span>
|
|
||||||
<button onClick={() => deleteOverride(channelId, o.target_type, o.target_id)} className="text-gb-red hover:text-gb-orange">[x]</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div className="border-t border-gb-bg-t pt-3">
|
|
||||||
<div className="text-xs text-gb-fg-f mb-2">New override</div>
|
|
||||||
<div className="flex gap-2 mb-2">
|
|
||||||
<select
|
|
||||||
value={form.targetType}
|
|
||||||
onChange={(e) => setForm({ ...form, targetType: e.target.value as TargetMode, targetId: '' })}
|
|
||||||
className="terminal-input text-xs"
|
|
||||||
>
|
|
||||||
<option value="role">role</option>
|
|
||||||
<option value="user">user</option>
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
value={form.targetId}
|
|
||||||
onChange={(e) => setForm({ ...form, targetId: e.target.value })}
|
|
||||||
className="terminal-input text-xs flex-1"
|
|
||||||
>
|
|
||||||
<option value="">-- select --</option>
|
|
||||||
{targets.map((t) => (
|
|
||||||
<option key={t.id} value={t.id}>{t.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<OverrideMatrix allow={form.allow} deny={form.deny} onChange={(a, d) => setForm({ ...form, allow: a, deny: d })} />
|
|
||||||
{error && <div className="text-xs text-gb-red mt-2">ERR: {error}</div>}
|
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
key={role.id}
|
||||||
disabled={saving || !form.targetId}
|
type="button"
|
||||||
className="mt-2 px-3 py-1 bg-gb-orange text-gb-bg text-xs disabled:opacity-50"
|
onClick={() => handleAddRole(role)}
|
||||||
|
className="w-full text-left px-3 py-2 text-xs border-b border-gb-bg-t flex items-center gap-2 text-gb-fg-f hover:bg-gb-bg-s hover:text-gb-fg"
|
||||||
>
|
>
|
||||||
{saving ? 'SAVING...' : '[SAVE OVERRIDE]'}
|
<span
|
||||||
|
className="w-2.5 h-2.5 shrink-0 border border-gb-bg-t"
|
||||||
|
style={{ backgroundColor: role.color || '#ebdbb2' }}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{role.name}</span>
|
||||||
|
{role.is_default && (
|
||||||
|
<span className="ml-auto text-[10px] text-gb-fg-f">@everyone</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
))}
|
||||||
|
{addTab === 'user' && filteredMembers.length === 0 && (
|
||||||
|
<p className="text-gb-fg-f text-xs p-3">[no members left to add]</p>
|
||||||
|
)}
|
||||||
|
{addTab === 'user' &&
|
||||||
|
filteredMembers.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleAddMember(m)}
|
||||||
|
className="w-full text-left px-3 py-2 text-xs border-b border-gb-bg-t flex items-center gap-2 text-gb-fg-f hover:bg-gb-bg-s hover:text-gb-fg"
|
||||||
|
>
|
||||||
|
<span className="text-gb-aqua">@</span>
|
||||||
|
<span className="truncate">{displayName(m)}</span>
|
||||||
|
<span className="ml-auto text-[10px] text-gb-fg-f truncate max-w-[40%]">
|
||||||
|
{m.username}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import Picker, { Theme } from 'emoji-picker-react';
|
|||||||
import { CommandDropdown } from "./CommandDropdown";
|
import { CommandDropdown } from "./CommandDropdown";
|
||||||
import { findCommand, SLASH_COMMANDS } from "../lib/slashCommands";
|
import { findCommand, SLASH_COMMANDS } from "../lib/slashCommands";
|
||||||
import { PollDisplay, CreatePollModal } from "./Poll.tsx";
|
import { PollDisplay, CreatePollModal } from "./Poll.tsx";
|
||||||
import { MentionDropdown } from "./MentionDropdown";
|
import { MentionDropdown, buildMentionOptions } from "./MentionDropdown";
|
||||||
|
import { usePermissions } from "../lib/usePermissions.ts";
|
||||||
import { useReadStatesStore } from "../stores/readStates.ts";
|
import { useReadStatesStore } from "../stores/readStates.ts";
|
||||||
import { MessageSearch } from "./MessageSearch";
|
import { MessageSearch } from "./MessageSearch";
|
||||||
import { ThreadListPanel } from "./ThreadListPanel.tsx";
|
import { ThreadListPanel } from "./ThreadListPanel.tsx";
|
||||||
@@ -45,7 +46,7 @@ function formatTime(iso: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderContent(content: string, memberUsernames: Set<string>) {
|
function renderContent(content: string, memberUsernames: Set<string>) {
|
||||||
const segments: { type: "text" | "mention"; value: string }[] = [];
|
const segments: { type: "text" | "mention"; value: string; special?: boolean }[] = [];
|
||||||
const mentionRe = /@([a-zA-Z0-9_.-]+)/g;
|
const mentionRe = /@([a-zA-Z0-9_.-]+)/g;
|
||||||
let last = 0;
|
let last = 0;
|
||||||
let match: RegExpExecArray | null;
|
let match: RegExpExecArray | null;
|
||||||
@@ -54,8 +55,13 @@ function renderContent(content: string, memberUsernames: Set<string>) {
|
|||||||
segments.push({ type: "text", value: content.slice(last, match.index) });
|
segments.push({ type: "text", value: content.slice(last, match.index) });
|
||||||
}
|
}
|
||||||
const username = match[1];
|
const username = match[1];
|
||||||
if (memberUsernames.has(username)) {
|
const lower = username.toLowerCase();
|
||||||
segments.push({ type: "mention", value: username });
|
if (lower === "everyone" || lower === "channel" || lower === "here" || memberUsernames.has(username)) {
|
||||||
|
segments.push({
|
||||||
|
type: "mention",
|
||||||
|
value: username,
|
||||||
|
special: lower === "everyone" || lower === "channel" || lower === "here",
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
segments.push({ type: "text", value: match[0] });
|
segments.push({ type: "text", value: match[0] });
|
||||||
}
|
}
|
||||||
@@ -71,7 +77,10 @@ function renderContent(content: string, memberUsernames: Set<string>) {
|
|||||||
const nextSeg = segments[idx + 1];
|
const nextSeg = segments[idx + 1];
|
||||||
const needsSpace = !nextSeg || (nextSeg.type === "text" && !nextSeg.value.startsWith(" "));
|
const needsSpace = !nextSeg || (nextSeg.type === "text" && !nextSeg.value.startsWith(" "));
|
||||||
return (
|
return (
|
||||||
<span key={idx} className="text-gb-aqua">
|
<span
|
||||||
|
key={idx}
|
||||||
|
className={seg.special ? "text-gb-orange font-bold bg-gb-orange/15 px-0.5 rounded-sm" : "text-gb-aqua"}
|
||||||
|
>
|
||||||
@{seg.value}{needsSpace ? " " : ""}
|
@{seg.value}{needsSpace ? " " : ""}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -216,12 +225,14 @@ const MessageItem = memo(({
|
|||||||
)}
|
)}
|
||||||
<span className="text-gb-fg-f">{formatTime(message.created_at)}</span>{' '}
|
<span className="text-gb-fg-f">{formatTime(message.created_at)}</span>{' '}
|
||||||
{message.pinned && <span className="text-gb-orange font-bold mr-1">[PIN]</span>}
|
{message.pinned && <span className="text-gb-orange font-bold mr-1">[PIN]</span>}
|
||||||
<span className="text-gb-aqua hover:text-gb-orange cursor-pointer" onClick={(e) => {
|
<span className={`${message.author_bot ? 'text-gb-green' : 'text-gb-aqua'} hover:text-gb-orange cursor-pointer`} onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onAuthorClick(message.author_id);
|
if (!message.author_bot) onAuthorClick(message.author_id);
|
||||||
}}>
|
}}>
|
||||||
<{members.find((m) => m.id === message.author_id)?.nickname || message.author_username}>
|
<{message.author_bot ? (message.bot_name || message.author_username) : (members.find((m) => m.id === message.author_id)?.nickname || message.author_username)}>
|
||||||
</span>{" "}
|
</span>
|
||||||
|
{message.author_bot && <span className="text-gb-bg bg-gb-green px-0.5 font-mono text-[10px] ml-0.5 align-middle">BOT</span>}
|
||||||
|
{" "}
|
||||||
<span className="text-gb-fg">{renderContent(message.content, memberUsernames)}</span>
|
<span className="text-gb-fg">{renderContent(message.content, memberUsernames)}</span>
|
||||||
{renderEmbeds(message.embeds)}
|
{renderEmbeds(message.embeds)}
|
||||||
{message.poll && <PollDisplay poll={message.poll} channelId={message.channel_id} />}
|
{message.poll && <PollDisplay poll={message.poll} channelId={message.channel_id} />}
|
||||||
@@ -324,7 +335,10 @@ export function ChatArea() {
|
|||||||
const channels = activeServerId ? channelsByServer[activeServerId] || [] : [];
|
const channels = activeServerId ? channelsByServer[activeServerId] || [] : [];
|
||||||
const activeChannel = channels.find((c) => c.id === activeChannelId);
|
const activeChannel = channels.find((c) => c.id === activeChannelId);
|
||||||
const members = activeServerId ? membersByServer[activeServerId] || [] : [];
|
const members = activeServerId ? membersByServer[activeServerId] || [] : [];
|
||||||
const memberUsernames = useMemo(() => new Set(members.map((m) => m.username)), [members]);
|
// Humans only for mentions / nickname lookup (bots live in member list separately).
|
||||||
|
const humanMembers = useMemo(() => members.filter((m) => !m.is_bot), [members]);
|
||||||
|
const memberUsernames = useMemo(() => new Set(humanMembers.map((m) => m.username)), [humanMembers]);
|
||||||
|
const { canMentionEveryone } = usePermissions(activeServerId);
|
||||||
const markRead = useReadStatesStore((s) => s.markRead);
|
const markRead = useReadStatesStore((s) => s.markRead);
|
||||||
const readStates = useReadStatesStore((s) => s.states);
|
const readStates = useReadStatesStore((s) => s.states);
|
||||||
|
|
||||||
@@ -551,10 +565,7 @@ export function ChatArea() {
|
|||||||
if (!isDropdownOpen) return;
|
if (!isDropdownOpen) return;
|
||||||
|
|
||||||
const itemCount = mq !== null
|
const itemCount = mq !== null
|
||||||
? members.filter((m) =>
|
? buildMentionOptions(mq, humanMembers, canMentionEveryone).length
|
||||||
m.username.toLowerCase().includes(mq.toLowerCase()) ||
|
|
||||||
m.display_name?.toLowerCase().includes(mq.toLowerCase())
|
|
||||||
).slice(0, 6).length
|
|
||||||
: cq !== null
|
: cq !== null
|
||||||
? SLASH_COMMANDS.filter((c) => c.name.startsWith(cq.toLowerCase())).slice(0, 8).length
|
? SLASH_COMMANDS.filter((c) => c.name.startsWith(cq.toLowerCase())).slice(0, 8).length
|
||||||
: 0;
|
: 0;
|
||||||
@@ -571,13 +582,14 @@ export function ChatArea() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (mq !== null) {
|
if (mq !== null) {
|
||||||
const q = mq.toLowerCase();
|
const options = buildMentionOptions(mq, humanMembers, canMentionEveryone);
|
||||||
const filtered = members.filter((m) =>
|
const selected = options[di];
|
||||||
m.username.toLowerCase().includes(q) ||
|
if (selected) {
|
||||||
m.display_name?.toLowerCase().includes(q)
|
if (selected.kind === "special") {
|
||||||
).slice(0, 6);
|
handleMentionSelect(selected.label);
|
||||||
if (filtered[di]) {
|
} else {
|
||||||
handleMentionSelect(filtered[di].username);
|
handleMentionSelect(selected.member.username);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (cq !== null) {
|
} else if (cq !== null) {
|
||||||
const q = cq.toLowerCase();
|
const q = cq.toLowerCase();
|
||||||
@@ -616,7 +628,7 @@ export function ChatArea() {
|
|||||||
};
|
};
|
||||||
window.addEventListener('keydown', handler);
|
window.addEventListener('keydown', handler);
|
||||||
return () => window.removeEventListener('keydown', handler);
|
return () => window.removeEventListener('keydown', handler);
|
||||||
}, [members, currentUser, activeChannelId, sendMessage, replyToMessage, handleMentionSelect]);
|
}, [humanMembers, canMentionEveryone, currentUser, activeChannelId, sendMessage, replyToMessage, handleMentionSelect]);
|
||||||
|
|
||||||
const handleSubmit = useCallback(async () => {
|
const handleSubmit = useCallback(async () => {
|
||||||
if (mentionQuery !== null || commandQuery !== null) return;
|
if (mentionQuery !== null || commandQuery !== null) return;
|
||||||
@@ -792,7 +804,7 @@ export function ChatArea() {
|
|||||||
<ListView channelId={activeChannelId} channelName={activeChannel.name} />
|
<ListView channelId={activeChannelId} channelName={activeChannel.name} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex-1 flex flex-col min-w-0 relative">
|
<div className="flex-1 flex flex-col min-w-0 min-h-0 relative">
|
||||||
{showSearch && activeChannelId && activeChannel && (
|
{showSearch && activeChannelId && activeChannel && (
|
||||||
<MessageSearch
|
<MessageSearch
|
||||||
channelId={activeChannelId}
|
channelId={activeChannelId}
|
||||||
@@ -900,7 +912,13 @@ export function ChatArea() {
|
|||||||
)}
|
)}
|
||||||
<div className="p-3 relative">
|
<div className="p-3 relative">
|
||||||
{mentionQuery !== null && (
|
{mentionQuery !== null && (
|
||||||
<MentionDropdown query={mentionQuery} members={members} selectedIndex={dropdownIndex} onSelect={handleMentionSelect} />
|
<MentionDropdown
|
||||||
|
query={mentionQuery}
|
||||||
|
members={humanMembers}
|
||||||
|
selectedIndex={dropdownIndex}
|
||||||
|
onSelect={handleMentionSelect}
|
||||||
|
canMentionEveryone={canMentionEveryone}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{commandQuery !== null && (
|
{commandQuery !== null && (
|
||||||
<CommandDropdown
|
<CommandDropdown
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useVoiceStore } from '../stores/voice.ts';
|
import { useVoiceStore } from '../stores/voice.ts';
|
||||||
|
import { saveDevicePref } from '../stores/voice.ts';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -48,6 +49,7 @@ export function DeviceSettingsModal({ onClose }: Props) {
|
|||||||
if (!room) return;
|
if (!room) return;
|
||||||
try {
|
try {
|
||||||
await room.switchActiveDevice(kind, deviceId);
|
await room.switchActiveDevice(kind, deviceId);
|
||||||
|
saveDevicePref(kind, deviceId);
|
||||||
if (kind === 'audioinput') setActiveAudioInput(deviceId);
|
if (kind === 'audioinput') setActiveAudioInput(deviceId);
|
||||||
if (kind === 'audiooutput') setActiveAudioOutput(deviceId);
|
if (kind === 'audiooutput') setActiveAudioOutput(deviceId);
|
||||||
if (kind === 'videoinput') setActiveVideoInput(deviceId);
|
if (kind === 'videoinput') setActiveVideoInput(deviceId);
|
||||||
|
|||||||
+130
-121
@@ -6,6 +6,7 @@ import { useWebSocketStore } from '../stores/ws.ts';
|
|||||||
import { useServerStore } from '../stores/server.ts';
|
import { useServerStore } from '../stores/server.ts';
|
||||||
import { useChannelStore } from '../stores/channel.ts';
|
import { useChannelStore } from '../stores/channel.ts';
|
||||||
import { useLayoutStore } from '../stores/layout.ts';
|
import { useLayoutStore } from '../stores/layout.ts';
|
||||||
|
import { useRoleStore } from '../stores/role.ts';
|
||||||
import { ServerBar } from './ServerBar.tsx';
|
import { ServerBar } from './ServerBar.tsx';
|
||||||
import { ChannelList } from './ChannelList.tsx';
|
import { ChannelList } from './ChannelList.tsx';
|
||||||
import { ConversationList } from './ConversationList.tsx';
|
import { ConversationList } from './ConversationList.tsx';
|
||||||
@@ -13,22 +14,21 @@ import { NotificationPrompt } from './NotificationPrompt.tsx';
|
|||||||
import { MemberList } from './MemberList.tsx';
|
import { MemberList } from './MemberList.tsx';
|
||||||
import { VoicePanel } from './VoicePanel.tsx';
|
import { VoicePanel } from './VoicePanel.tsx';
|
||||||
import { ServerSettingsModal } from './ServerSettingsModal.tsx';
|
import { ServerSettingsModal } from './ServerSettingsModal.tsx';
|
||||||
|
import { ThemeToggle } from './ThemeToggle.tsx';
|
||||||
|
|
||||||
const STATUS_CYCLE: UserStatus[] = ['online', 'idle', 'dnd', 'offline'];
|
const STATUS_CYCLE: UserStatus[] = ['online', 'idle', 'dnd', 'offline'];
|
||||||
function statusColor(status: UserStatus): string {
|
function statusColor(status: UserStatus): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'online':
|
case 'online': return 'bg-gb-green';
|
||||||
return 'bg-gb-green';
|
case 'idle': return 'bg-gb-yellow';
|
||||||
case 'idle':
|
case 'dnd': return 'bg-gb-red';
|
||||||
return 'bg-gb-yellow';
|
default: return 'bg-gb-gray';
|
||||||
case 'dnd':
|
|
||||||
return 'bg-gb-red';
|
|
||||||
default:
|
|
||||||
return 'bg-gb-gray';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function statusLabel(status: UserStatus): string {
|
function statusLabel(status: UserStatus): string {
|
||||||
return status.toUpperCase();
|
return status.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||||
const isLoading = useAuthStore((state) => state.isLoading);
|
const isLoading = useAuthStore((state) => state.isLoading);
|
||||||
@@ -45,27 +45,35 @@ export function Layout() {
|
|||||||
const setMobileView = useLayoutStore((s) => s.setMobileView);
|
const setMobileView = useLayoutStore((s) => s.setMobileView);
|
||||||
const [showServerSettings, setShowServerSettings] = useState(false);
|
const [showServerSettings, setShowServerSettings] = useState(false);
|
||||||
const activeServerId = useServerStore((s) => s.activeServerId);
|
const activeServerId = useServerStore((s) => s.activeServerId);
|
||||||
|
const fetchRoles = useRoleStore((s) => s.fetchRoles);
|
||||||
|
const fetchMyRoles = useRoleStore((s) => s.fetchMyRoles);
|
||||||
const currentVoiceRoom = useVoiceStore((s) => s.currentRoom);
|
const currentVoiceRoom = useVoiceStore((s) => s.currentRoom);
|
||||||
const [activeTab, setActiveTab] = useState<'chat' | 'voice'>('chat');
|
const [activeTab, setActiveTab] = useState<'chat' | 'voice'>('chat');
|
||||||
|
|
||||||
|
// Load server roles + current user's role assignments for accurate client permission gates.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentVoiceRoom) {
|
if (!activeServerId || !user?.id) return;
|
||||||
setActiveTab('voice');
|
void fetchRoles(activeServerId);
|
||||||
} else {
|
void fetchMyRoles(activeServerId, user.id);
|
||||||
setActiveTab('chat');
|
}, [activeServerId, user?.id, fetchRoles, fetchMyRoles]);
|
||||||
}
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentVoiceRoom) setActiveTab('voice');
|
||||||
|
else setActiveTab('chat');
|
||||||
}, [currentVoiceRoom]);
|
}, [currentVoiceRoom]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
wsConnect();
|
wsConnect();
|
||||||
return () => { wsDisconnect(); };
|
return () => { wsDisconnect(); };
|
||||||
}, [wsConnect, wsDisconnect]);
|
}, [wsConnect, wsDisconnect]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading && !isAuthenticated && location.pathname !== '/login') {
|
if (!isLoading && !isAuthenticated && location.pathname !== '/login') {
|
||||||
navigate('/login', { replace: true });
|
navigate('/login', { replace: true });
|
||||||
}
|
}
|
||||||
}, [isLoading, isAuthenticated, location.pathname, navigate]);
|
}, [isLoading, isAuthenticated, location.pathname, navigate]);
|
||||||
// Navigate to chat when a channel/DM is selected on mobile
|
|
||||||
|
// Navigate to chat view on mobile when a channel/DM is selected
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsub = useChannelStore.subscribe((state, prev) => {
|
const unsub = useChannelStore.subscribe((state, prev) => {
|
||||||
if (state.activeChannelId !== prev.activeChannelId && state.activeChannelId) {
|
if (state.activeChannelId !== prev.activeChannelId && state.activeChannelId) {
|
||||||
@@ -74,68 +82,52 @@ export function Layout() {
|
|||||||
});
|
});
|
||||||
return unsub;
|
return unsub;
|
||||||
}, [setMobileView]);
|
}, [setMobileView]);
|
||||||
// Also switch to chat on mobile when navigating to a DM
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (location.pathname.startsWith('/dm/') && location.pathname !== '/dm') {
|
if (location.pathname.startsWith('/dm/') && location.pathname !== '/dm') {
|
||||||
setMobileView('chat');
|
setMobileView('chat');
|
||||||
}
|
}
|
||||||
}, [location.pathname, setMobileView]);
|
}, [location.pathname, setMobileView]);
|
||||||
|
|
||||||
const handleStatusChange = async (status: UserStatus) => {
|
const handleStatusChange = async (status: UserStatus) => {
|
||||||
setShowStatusMenu(false);
|
setShowStatusMenu(false);
|
||||||
try {
|
try { await updateProfile({ status }); } catch { /* store surfaces error */ }
|
||||||
await updateProfile({ status });
|
|
||||||
} catch (err) {
|
|
||||||
// Error is surfaced via auth store; menu closes optimistically.
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
const currentStatus = user?.status ?? 'offline';
|
const currentStatus = user?.status ?? 'offline';
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return <div className="h-full w-full flex items-center justify-center bg-gb-bg text-gb-fg-f font-mono">[booting...]</div>;
|
||||||
<div className="h-full w-full flex items-center justify-center bg-gb-bg text-gb-fg-f font-mono">
|
|
||||||
[booting...]
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
if (!isAuthenticated) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full bg-gb-bg text-gb-fg font-mono flex flex-col" style={{ padding: 'max(env(safe-area-inset-top), 0.5rem) max(env(safe-area-inset-right), 0.5rem) max(env(safe-area-inset-bottom), 0.5rem) max(env(safe-area-inset-left), 0.5rem)' }}>
|
<div className="h-full w-full bg-gb-bg text-gb-fg font-mono flex flex-col">
|
||||||
<div className="flex-1 terminal-border bg-gb-bg-h flex flex-col min-h-0">
|
{/* Outer terminal frame with safe area */}
|
||||||
{/* Top bar */}
|
<div className="flex-1 terminal-border bg-gb-bg-h flex flex-col min-h-0"
|
||||||
<div className="flex items-center justify-between px-2 md:px-3 py-1 border-b border-gb-bg-t bg-gb-bg-s gap-2">
|
style={{ padding: 'max(env(safe-area-inset-top), 0.25rem) max(env(safe-area-inset-right), 0) max(env(safe-area-inset-bottom), 0) max(env(safe-area-inset-left), 0)' }}>
|
||||||
|
|
||||||
|
{/* Top bar — minimal on mobile */}
|
||||||
|
<div className="flex items-center justify-between px-2 md:px-3 py-1 border-b border-gb-bg-t bg-gb-bg-s gap-1">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
{/* Mobile: hamburger to show sidebar */}
|
<span className="text-gb-orange font-bold shrink-0 text-sm md:text-base">DUMPSTER</span>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setMobileView(mobileView === 'sidebar' ? 'chat' : 'sidebar')}
|
|
||||||
className="md:hidden terminal-button text-xs px-2 py-0.5 shrink-0"
|
|
||||||
>
|
|
||||||
{mobileView === 'sidebar' ? '✕' : '☰'}
|
|
||||||
</button>
|
|
||||||
<span className="text-gb-orange font-bold shrink-0">DUMPSTER</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 md:gap-4 text-xs text-gb-fg-s min-w-0">
|
<div className="flex items-center gap-1 md:gap-4 text-xs text-gb-fg-s min-w-0">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowStatusMenu((prev) => !prev)}
|
onClick={() => setShowStatusMenu((prev) => !prev)}
|
||||||
className="flex items-center gap-1 md:gap-2 hover:text-gb-fg transition-colors"
|
className="flex items-center gap-1 hover:text-gb-fg transition-colors"
|
||||||
title="Change status"
|
title="Change status"
|
||||||
>
|
>
|
||||||
<span className={`w-2.5 h-2.5 rounded-full ${statusColor(currentStatus)}`} />
|
<span className={`w-2 h-2 rounded-full ${statusColor(currentStatus)} shrink-0`} />
|
||||||
<span className="text-gb-aqua hidden sm:inline">{user?.username || 'unknown'}</span>
|
<span className="text-gb-aqua hidden sm:inline truncate max-w-[80px]">{user?.username || 'unknown'}</span>
|
||||||
<span className="text-gb-fg-f hidden sm:inline">[{statusLabel(currentStatus)}]</span>
|
<span className="text-gb-fg-f hidden sm:inline">[{statusLabel(currentStatus)}]</span>
|
||||||
</button>
|
</button>
|
||||||
{showStatusMenu && (
|
{showStatusMenu && (
|
||||||
<div className="absolute right-0 top-full mt-1 z-50 w-32 bg-gb-bg-s border border-gb-bg-t shadow-lg">
|
<div className="absolute right-0 top-full mt-1 z-50 w-32 bg-gb-bg-s border border-gb-bg-t shadow-lg">
|
||||||
{STATUS_CYCLE.map((s) => (
|
{STATUS_CYCLE.map((s) => (
|
||||||
<button
|
<button key={s} type="button" onClick={() => handleStatusChange(s)}
|
||||||
key={s}
|
className="w-full px-2 py-1 text-left text-xs font-mono flex items-center gap-2 hover:bg-gb-orange hover:text-gb-bg transition-colors">
|
||||||
type="button"
|
|
||||||
onClick={() => handleStatusChange(s)}
|
|
||||||
className="w-full px-2 py-1 text-left text-xs font-mono flex items-center gap-2 hover:bg-gb-orange hover:text-gb-bg transition-colors"
|
|
||||||
>
|
|
||||||
<span className={`w-2 h-2 rounded-full ${statusColor(s)}`} />
|
<span className={`w-2 h-2 rounded-full ${statusColor(s)}`} />
|
||||||
<span>{statusLabel(s)}</span>
|
<span>{statusLabel(s)}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -143,76 +135,47 @@ export function Layout() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Link to="/settings" className="terminal-button text-xs hidden sm:inline-flex">[SETTINGS]</Link>
|
<div className="hidden sm:block">
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
<Link to="/settings" className="terminal-button text-xs px-1.5 py-0.5 hidden sm:inline-flex">[SETTINGS]</Link>
|
||||||
{activeServerId && (
|
{activeServerId && (
|
||||||
<button
|
<button onClick={() => setShowServerSettings(true)}
|
||||||
onClick={() => setShowServerSettings(true)}
|
className="terminal-button text-xs px-1.5 py-0.5 hidden md:inline-flex">
|
||||||
className="terminal-button text-xs hidden md:inline-flex"
|
|
||||||
>
|
|
||||||
[SERVER SETTINGS]
|
[SERVER SETTINGS]
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{/* Mobile: members toggle */}
|
<button onClick={() => logout().then(() => navigate('/login'))}
|
||||||
{!isDM && (
|
className="terminal-button text-xs px-1.5 py-0.5">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setMobileView(mobileView === 'members' ? 'chat' : 'members')}
|
|
||||||
className="md:hidden terminal-button text-xs px-2 py-0.5"
|
|
||||||
>
|
|
||||||
{mobileView === 'members' ? '✕' : '👤'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button onClick={() => logout().then(() => navigate('/login'))} className="terminal-button text-xs">
|
|
||||||
[LOGOUT]
|
[LOGOUT]
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main content area */}
|
{/* Main content */}
|
||||||
<div className="flex-1 flex min-h-0 relative">
|
<div className="flex-1 flex min-h-0 relative">
|
||||||
{/* Sidebar: ServerBar + ChannelList/ConversationList */}
|
{/* Sidebar — desktop: always visible, mobile: overlay when sidebar tab active */}
|
||||||
{/* Desktop: always visible as left columns */}
|
<div className={`
|
||||||
{/* Mobile: full-screen overlay when mobileView === 'sidebar' */}
|
${mobileView === 'sidebar' ? 'flex' : 'hidden'} md:flex
|
||||||
<div
|
absolute md:relative inset-0 md:inset-auto z-30 md:z-auto flex-shrink-0
|
||||||
className={`
|
`}>
|
||||||
${mobileView === 'sidebar' ? 'flex' : 'hidden'}
|
|
||||||
md:flex
|
|
||||||
absolute md:relative inset-0 md:inset-auto z-30 md:z-auto
|
|
||||||
flex-shrink-0
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<ServerBar />
|
<ServerBar />
|
||||||
{isDM ? <ConversationList /> : <ChannelList />}
|
{isDM ? <ConversationList /> : <ChannelList />}
|
||||||
{/* Close sidebar on mobile after selection */}
|
{/* Close overlay on mobile by tapping background */}
|
||||||
<div
|
<div className="flex-1 md:hidden" onClick={() => setMobileView('chat')} />
|
||||||
className="flex-1 md:hidden"
|
|
||||||
onClick={() => setMobileView('chat')}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chat area: always in DOM, hidden on mobile when sidebar/members shown */}
|
{/* Chat */}
|
||||||
<div
|
<div className={`
|
||||||
className={`
|
${mobileView === 'chat' ? 'flex' : 'hidden'} md:flex flex-1 min-w-0 flex-col
|
||||||
${mobileView === 'chat' ? 'flex' : 'hidden'}
|
`}>
|
||||||
md:flex
|
|
||||||
flex-1 min-w-0 flex-col
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||||
{currentVoiceRoom && (
|
{currentVoiceRoom && (
|
||||||
<div className="flex bg-gb-bg-s border-b border-gb-bg-t">
|
<div className="flex bg-gb-bg-s border-b border-gb-bg-t">
|
||||||
<button
|
<button className={`flex-1 py-1.5 text-xs text-center border-b-2 ${activeTab === 'chat' ? 'border-gb-fg-f text-gb-fg-f' : 'border-transparent text-gb-fg-s hover:text-gb-fg-f'}`}
|
||||||
className={`flex-1 py-2 text-xs text-center border-b-2 ${activeTab === 'chat' ? 'border-gb-fg-f text-gb-fg-f' : 'border-transparent text-gb-fg-s hover:text-gb-fg-f'}`}
|
onClick={() => setActiveTab('chat')}>[CHAT]</button>
|
||||||
onClick={() => setActiveTab('chat')}
|
<button className={`flex-1 py-1.5 text-xs text-center border-b-2 ${activeTab === 'voice' ? 'border-gb-fg-f text-gb-fg-f' : 'border-transparent text-gb-fg-s hover:text-gb-fg-f'}`}
|
||||||
>
|
onClick={() => setActiveTab('voice')}>[VOICE/VIDEO]</button>
|
||||||
[CHAT]
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`flex-1 py-2 text-xs text-center border-b-2 ${activeTab === 'voice' ? 'border-gb-fg-f text-gb-fg-f' : 'border-transparent text-gb-fg-s hover:text-gb-fg-f'}`}
|
|
||||||
onClick={() => setActiveTab('voice')}
|
|
||||||
>
|
|
||||||
[VOICE/VIDEO]
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={`flex-1 min-h-0 flex-col ${activeTab === 'chat' || !currentVoiceRoom ? 'flex' : 'hidden'}`}>
|
<div className={`flex-1 min-h-0 flex-col ${activeTab === 'chat' || !currentVoiceRoom ? 'flex' : 'hidden'}`}>
|
||||||
@@ -226,32 +189,31 @@ export function Layout() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Members panel */}
|
{/* Members — desktop: right column, mobile: overlay when members tab active */}
|
||||||
{/* Desktop: right column */}
|
|
||||||
{/* Mobile: full-screen overlay when mobileView === 'members' */}
|
|
||||||
{!isDM && (
|
{!isDM && (
|
||||||
<div
|
<div className={`
|
||||||
className={`
|
${mobileView === 'members' ? 'flex' : 'hidden'} md:flex
|
||||||
${mobileView === 'members' ? 'flex' : 'hidden'}
|
absolute md:relative inset-0 md:inset-auto z-30 md:z-auto flex-shrink-0
|
||||||
md:flex
|
`}>
|
||||||
absolute md:relative inset-0 md:inset-auto z-30 md:z-auto
|
<div className="flex-1 md:hidden" onClick={() => setMobileView('chat')} />
|
||||||
flex-shrink-0
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{/* Tap background to close on mobile */}
|
|
||||||
<div
|
|
||||||
className="flex-1 md:hidden"
|
|
||||||
onClick={() => setMobileView('chat')}
|
|
||||||
/>
|
|
||||||
<MemberList />
|
<MemberList />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom nav — mobile only */}
|
||||||
|
<MobileBottomNav
|
||||||
|
mobileView={mobileView}
|
||||||
|
onViewChange={setMobileView}
|
||||||
|
isDM={isDM}
|
||||||
|
/>
|
||||||
|
|
||||||
{showServerSettings && activeServerId && (
|
{showServerSettings && activeServerId && (
|
||||||
<ServerSettingsModal serverId={activeServerId} onClose={() => setShowServerSettings(false)} />
|
<ServerSettingsModal serverId={activeServerId} onClose={() => setShowServerSettings(false)} />
|
||||||
)}
|
)}
|
||||||
<div className="px-2 md:px-3 py-1 border-t border-gb-bg-t text-xs text-gb-fg-f flex justify-between bg-gb-bg-s">
|
|
||||||
|
{/* Status bar — desktop only */}
|
||||||
|
<div className="hidden md:flex px-3 py-1 border-t border-gb-bg-t text-xs text-gb-fg-f justify-between bg-gb-bg-s">
|
||||||
<span>TERM {__APP_VERSION__}</span>
|
<span>TERM {__APP_VERSION__}</span>
|
||||||
<span>{new Date().toISOString().slice(0, 10)}</span>
|
<span>{new Date().toISOString().slice(0, 10)}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -260,3 +222,50 @@ export function Layout() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MobileBottomNav — tab bar for switching between sidebar / chat / members
|
||||||
|
function MobileBottomNav({
|
||||||
|
mobileView,
|
||||||
|
onViewChange,
|
||||||
|
isDM,
|
||||||
|
}: {
|
||||||
|
mobileView: string;
|
||||||
|
onViewChange: (v: 'sidebar' | 'chat' | 'members') => void;
|
||||||
|
isDM: boolean;
|
||||||
|
}) {
|
||||||
|
const isConnected = useVoiceStore((s) => s.isConnected);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="md:hidden flex items-center bg-gb-bg-h border-t border-gb-bg-t shrink-0"
|
||||||
|
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}>
|
||||||
|
<NavTab active={mobileView === 'sidebar'} onClick={() => onViewChange('sidebar')}>
|
||||||
|
[SERVERS]
|
||||||
|
</NavTab>
|
||||||
|
<NavTab active={mobileView === 'chat'} onClick={() => onViewChange('chat')}>
|
||||||
|
[CHAT]
|
||||||
|
</NavTab>
|
||||||
|
{!isDM && (
|
||||||
|
<NavTab active={mobileView === 'members'} onClick={() => onViewChange('members')}>
|
||||||
|
[MEMBERS]
|
||||||
|
</NavTab>
|
||||||
|
)}
|
||||||
|
{isConnected && (
|
||||||
|
<div className="w-2 h-2 rounded-full bg-gb-green animate-pulse ml-auto mr-3" title="In voice" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavTab({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={`flex-1 py-2.5 text-xs font-mono text-center transition-colors border-t-2 ${
|
||||||
|
active ? 'border-gb-orange text-gb-orange bg-gb-bg-s' : 'border-transparent text-gb-fg-f hover:text-gb-fg hover:bg-gb-bg'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ interface MemberRowProps {
|
|||||||
|
|
||||||
function MemberRow({ member, onProfileClick }: MemberRowProps) {
|
function MemberRow({ member, onProfileClick }: MemberRowProps) {
|
||||||
const presence = usePresenceStore((s) => s.presences[member.id]);
|
const presence = usePresenceStore((s) => s.presences[member.id]);
|
||||||
const status = presence?.status ?? member.status;
|
const status = member.is_bot ? "online" : (presence?.status ?? member.status);
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const currentUser = useAuthStore((s) => s.user);
|
const currentUser = useAuthStore((s) => s.user);
|
||||||
const activeServerId = useServerStore((s) => s.activeServerId);
|
const activeServerId = useServerStore((s) => s.activeServerId);
|
||||||
@@ -72,14 +72,21 @@ function MemberRow({ member, onProfileClick }: MemberRowProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div
|
<div
|
||||||
onClick={() => onProfileClick(member.id)}
|
onClick={() => {
|
||||||
className="w-full flex items-center gap-2 px-2 py-1 text-left hover:bg-gb-bg-t cursor-pointer group"
|
if (!member.is_bot) onProfileClick(member.id);
|
||||||
|
}}
|
||||||
|
className={`w-full flex items-center gap-2 px-2 py-1 text-left group ${
|
||||||
|
member.is_bot ? "cursor-default" : "hover:bg-gb-bg-t cursor-pointer"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<span className={statusColor(status)}>{statusIcon(status)}</span>
|
<span className={statusColor(status)}>{statusIcon(status)}</span>
|
||||||
<span className={`truncate ${usernameColor(status)} flex-1`}>
|
<span className={`truncate ${member.is_bot ? "text-gb-green" : usernameColor(status)} flex-1`}>
|
||||||
{member.nickname || member.display_name || member.username}
|
{member.nickname || member.display_name || member.username}
|
||||||
</span>
|
</span>
|
||||||
{!isSelf && (
|
{member.is_bot && (
|
||||||
|
<span className="text-gb-bg bg-gb-green px-0.5 font-mono text-[10px] shrink-0">BOT</span>
|
||||||
|
)}
|
||||||
|
{!isSelf && !member.is_bot && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -92,7 +99,7 @@ function MemberRow({ member, onProfileClick }: MemberRowProps) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{menuOpen && !isSelf && activeServerId && (
|
{menuOpen && !isSelf && !member.is_bot && activeServerId && (
|
||||||
<MemberContextMenu
|
<MemberContextMenu
|
||||||
memberId={member.id}
|
memberId={member.id}
|
||||||
username={member.username}
|
username={member.username}
|
||||||
@@ -155,11 +162,13 @@ export function MemberList() {
|
|||||||
}
|
}
|
||||||
}, [activeServerId, fetchMembers]);
|
}, [activeServerId, fetchMembers]);
|
||||||
|
|
||||||
const online = members.filter((m) => {
|
const humans = members.filter((m) => !m.is_bot);
|
||||||
|
const bots = members.filter((m) => m.is_bot);
|
||||||
|
const online = humans.filter((m) => {
|
||||||
const status = presences[m.id]?.status ?? m.status;
|
const status = presences[m.id]?.status ?? m.status;
|
||||||
return status !== "offline";
|
return status !== "offline";
|
||||||
});
|
});
|
||||||
const offline = members.filter((m) => {
|
const offline = humans.filter((m) => {
|
||||||
const status = presences[m.id]?.status ?? m.status;
|
const status = presences[m.id]?.status ?? m.status;
|
||||||
return status === "offline";
|
return status === "offline";
|
||||||
});
|
});
|
||||||
@@ -178,7 +187,7 @@ export function MemberList() {
|
|||||||
)}
|
)}
|
||||||
{online.length > 0 && (
|
{online.length > 0 && (
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<div className="text-gb-fg-t text-xs uppercase mb-1">ONLINE</div>
|
<div className="text-gb-fg-t text-xs uppercase mb-1">ONLINE — {online.length}</div>
|
||||||
<div className="text-gb-fg-f text-xs mb-1">---</div>
|
<div className="text-gb-fg-f text-xs mb-1">---</div>
|
||||||
{online.map((m) => (
|
{online.map((m) => (
|
||||||
<MemberRow key={m.id} member={m} onProfileClick={setProfileUserId} />
|
<MemberRow key={m.id} member={m} onProfileClick={setProfileUserId} />
|
||||||
@@ -186,14 +195,23 @@ export function MemberList() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{offline.length > 0 && (
|
{offline.length > 0 && (
|
||||||
<div>
|
<div className="mb-3">
|
||||||
<div className="text-gb-fg-t text-xs uppercase mb-1">OFFLINE</div>
|
<div className="text-gb-fg-t text-xs uppercase mb-1">OFFLINE — {offline.length}</div>
|
||||||
<div className="text-gb-fg-f text-xs mb-1">---</div>
|
<div className="text-gb-fg-f text-xs mb-1">---</div>
|
||||||
{offline.map((m) => (
|
{offline.map((m) => (
|
||||||
<MemberRow key={m.id} member={m} onProfileClick={setProfileUserId} />
|
<MemberRow key={m.id} member={m} onProfileClick={setProfileUserId} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{bots.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-gb-fg-t text-xs uppercase mb-1">BOTS — {bots.length}</div>
|
||||||
|
<div className="text-gb-fg-f text-xs mb-1">---</div>
|
||||||
|
{bots.map((m) => (
|
||||||
|
<MemberRow key={m.id} member={m} onProfileClick={setProfileUserId} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{profileUserId && (
|
{profileUserId && (
|
||||||
<UserProfileModal userId={profileUserId} onClose={() => setProfileUserId(null)} />
|
<UserProfileModal userId={profileUserId} onClose={() => setProfileUserId(null)} />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useRoleStore, type Role } from '../stores/role.ts';
|
import { useRoleStore, type Role } from '../stores/role.ts';
|
||||||
import type { User } from '../stores/auth.ts';
|
import { useAuthStore, type User } from '../stores/auth.ts';
|
||||||
import { usePermissions } from '../lib/usePermissions.ts';
|
import { usePermissions } from '../lib/usePermissions.ts';
|
||||||
|
|
||||||
interface MemberRoleAssignProps {
|
interface MemberRoleAssignProps {
|
||||||
@@ -12,8 +12,10 @@ interface MemberRoleAssignProps {
|
|||||||
export function MemberRoleAssign({ serverId, member }: MemberRoleAssignProps) {
|
export function MemberRoleAssign({ serverId, member }: MemberRoleAssignProps) {
|
||||||
const roles = useRoleStore((s) => s.roles);
|
const roles = useRoleStore((s) => s.roles);
|
||||||
const fetchRoles = useRoleStore((s) => s.fetchRoles);
|
const fetchRoles = useRoleStore((s) => s.fetchRoles);
|
||||||
|
const fetchMyRoles = useRoleStore((s) => s.fetchMyRoles);
|
||||||
const setMemberRoles = useRoleStore((s) => s.setMemberRoles);
|
const setMemberRoles = useRoleStore((s) => s.setMemberRoles);
|
||||||
const getMemberRoles = useRoleStore((s) => s.getMemberRoles);
|
const getMemberRoles = useRoleStore((s) => s.getMemberRoles);
|
||||||
|
const currentUserId = useAuthStore((s) => s.user?.id);
|
||||||
|
|
||||||
const { isOwner, canManageRoles } = usePermissions(serverId);
|
const { isOwner, canManageRoles } = usePermissions(serverId);
|
||||||
const canEdit = isOwner || canManageRoles;
|
const canEdit = isOwner || canManageRoles;
|
||||||
@@ -56,6 +58,9 @@ export function MemberRoleAssign({ serverId, member }: MemberRoleAssignProps) {
|
|||||||
try {
|
try {
|
||||||
await setMemberRoles(serverId, member.id, Array.from(selectedIds));
|
await setMemberRoles(serverId, member.id, Array.from(selectedIds));
|
||||||
setMemberRolesState(roles.filter((r) => selectedIds.has(r.id)));
|
setMemberRolesState(roles.filter((r) => selectedIds.has(r.id)));
|
||||||
|
if (currentUserId && member.id === currentUserId) {
|
||||||
|
await fetchMyRoles(serverId, currentUserId);
|
||||||
|
}
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
} catch {
|
} catch {
|
||||||
// error in store
|
// error in store
|
||||||
|
|||||||
@@ -1,30 +1,91 @@
|
|||||||
import type { Member } from "../stores/member.ts";
|
import type { Member } from "../stores/member.ts";
|
||||||
|
|
||||||
|
export type MentionOption =
|
||||||
|
| { kind: "special"; id: string; label: string; description: string }
|
||||||
|
| { kind: "user"; member: Member };
|
||||||
|
|
||||||
interface MentionDropdownProps {
|
interface MentionDropdownProps {
|
||||||
query: string;
|
query: string;
|
||||||
members: Member[];
|
members: Member[];
|
||||||
selectedIndex: number;
|
selectedIndex: number;
|
||||||
onSelect: (username: string) => void;
|
onSelect: (username: string) => void;
|
||||||
|
canMentionEveryone?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MentionDropdown({ query, members, selectedIndex, onSelect }: MentionDropdownProps) {
|
const SPECIALS: { id: string; label: string; description: string }[] = [
|
||||||
|
{ id: "everyone", label: "everyone", description: "Notify the entire server" },
|
||||||
|
{ id: "channel", label: "channel", description: "Notify everyone in this channel" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function buildMentionOptions(
|
||||||
|
query: string,
|
||||||
|
members: Member[],
|
||||||
|
canMentionEveryone: boolean,
|
||||||
|
): MentionOption[] {
|
||||||
const q = query.toLowerCase();
|
const q = query.toLowerCase();
|
||||||
const filtered = members
|
const options: MentionOption[] = [];
|
||||||
|
|
||||||
|
if (canMentionEveryone) {
|
||||||
|
for (const s of SPECIALS) {
|
||||||
|
if (!q || s.id.startsWith(q) || s.label.startsWith(q)) {
|
||||||
|
options.push({ kind: "special", id: s.id, label: s.label, description: s.description });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = members
|
||||||
.filter(
|
.filter(
|
||||||
(m) =>
|
(m) =>
|
||||||
m.username.toLowerCase().includes(q) ||
|
m.username.toLowerCase().includes(q) ||
|
||||||
m.display_name?.toLowerCase().includes(q),
|
m.display_name?.toLowerCase().includes(q),
|
||||||
)
|
)
|
||||||
.slice(0, 6);
|
.slice(0, 6)
|
||||||
|
.map((m): MentionOption => ({ kind: "user", member: m }));
|
||||||
|
|
||||||
if (filtered.length === 0) return null;
|
return [...options, ...users].slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MentionDropdown({
|
||||||
|
query,
|
||||||
|
members,
|
||||||
|
selectedIndex,
|
||||||
|
onSelect,
|
||||||
|
canMentionEveryone = false,
|
||||||
|
}: MentionDropdownProps) {
|
||||||
|
const options = buildMentionOptions(query, members, canMentionEveryone);
|
||||||
|
|
||||||
|
if (options.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute bottom-full left-0 mb-1 z-50 w-64 max-h-48 overflow-y-auto bg-gb-bg-s border border-gb-bg-t shadow-lg">
|
<div className="absolute bottom-full left-0 mb-1 z-50 w-72 max-h-48 overflow-y-auto bg-gb-bg-s border border-gb-bg-t shadow-lg">
|
||||||
<div className="px-2 py-1 text-xs text-gb-fg-s font-mono border-b border-gb-bg-t">
|
<div className="px-2 py-1 text-xs text-gb-fg-s font-mono border-b border-gb-bg-t">
|
||||||
MENTION
|
MENTION
|
||||||
</div>
|
</div>
|
||||||
{filtered.map((m, i) => (
|
{options.map((opt, i) => {
|
||||||
|
const active = i === selectedIndex;
|
||||||
|
if (opt.kind === "special") {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.id}
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSelect(opt.label);
|
||||||
|
}}
|
||||||
|
className={`w-full px-2 py-1.5 text-left text-sm font-mono flex items-center gap-2 transition-colors ${
|
||||||
|
active ? "bg-gb-orange text-gb-bg" : "hover:bg-gb-orange hover:text-gb-bg"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={active ? "text-gb-bg" : "text-gb-orange"}>@</span>
|
||||||
|
<span className="font-bold">{opt.label}</span>
|
||||||
|
<span className={`text-xs truncate ${active ? "text-gb-bg" : "text-gb-fg-f"}`}>
|
||||||
|
{opt.description}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const m = opt.member;
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={m.id}
|
key={m.id}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -33,20 +94,19 @@ export function MentionDropdown({ query, members, selectedIndex, onSelect }: Men
|
|||||||
onSelect(m.username);
|
onSelect(m.username);
|
||||||
}}
|
}}
|
||||||
className={`w-full px-2 py-1 text-left text-sm font-mono flex items-center gap-2 transition-colors ${
|
className={`w-full px-2 py-1 text-left text-sm font-mono flex items-center gap-2 transition-colors ${
|
||||||
i === selectedIndex
|
active ? "bg-gb-orange text-gb-bg" : "hover:bg-gb-orange hover:text-gb-bg"
|
||||||
? "bg-gb-orange text-gb-bg"
|
|
||||||
: "hover:bg-gb-orange hover:text-gb-bg"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className={i === selectedIndex ? "text-gb-bg" : "text-gb-green"}>●</span>
|
<span className={active ? "text-gb-bg" : "text-gb-green"}>●</span>
|
||||||
<span className="truncate">{m.display_name || m.username}</span>
|
<span className="truncate">{m.display_name || m.username}</span>
|
||||||
{m.display_name && m.display_name !== m.username && (
|
{m.display_name && m.display_name !== m.username && (
|
||||||
<span className={`text-xs ${i === selectedIndex ? "text-gb-bg" : "text-gb-fg-f"}`}>
|
<span className={`text-xs ${active ? "text-gb-bg" : "text-gb-fg-f"}`}>
|
||||||
({m.username})
|
({m.username})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -546,7 +546,12 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(f
|
|||||||
placeholder={isDragOver ? "Drop file here..." : placeholder}
|
placeholder={isDragOver ? "Drop file here..." : placeholder}
|
||||||
rows={1}
|
rows={1}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={`w-full bg-transparent outline-none border-none resize-none overflow-y-auto max-h-32 text-[14px] leading-snug py-1 font-mono ${isDragOver ? "bg-gb-bg-t" : ""}`}
|
className={`w-full bg-transparent outline-none border-none resize-none overflow-hidden text-[14px] leading-snug py-1 font-mono ${isDragOver ? "bg-gb-bg-t" : ""}`}
|
||||||
|
onInput={(e) => {
|
||||||
|
const el = e.currentTarget;
|
||||||
|
el.style.height = 'auto';
|
||||||
|
el.style.height = el.scrollHeight + 'px';
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
@@ -561,7 +566,7 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(f
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
data-placeholder={isDragOver ? "Drop file here..." : placeholder}
|
data-placeholder={isDragOver ? "Drop file here..." : placeholder}
|
||||||
className={`w-full outline-none resize-none overflow-y-auto max-h-32 text-[14px] leading-snug py-1 font-mono
|
className={`w-full outline-none resize-none overflow-hidden text-[14px] leading-snug py-1 font-mono
|
||||||
before:content-[attr(data-placeholder)] before:text-gb-fg-f before:opacity-60
|
before:content-[attr(data-placeholder)] before:text-gb-fg-f before:opacity-60
|
||||||
empty:before:block before:hidden
|
empty:before:block before:hidden
|
||||||
${isDragOver ? "bg-gb-bg-t" : ""}
|
${isDragOver ? "bg-gb-bg-t" : ""}
|
||||||
@@ -685,6 +690,15 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(f
|
|||||||
|
|
||||||
<span className="flex-1" />
|
<span className="flex-1" />
|
||||||
|
|
||||||
|
{/* character counter */}
|
||||||
|
{value.length > 0 && (
|
||||||
|
<span className={`text-xs font-mono tabular-nums px-1 ${
|
||||||
|
value.length > 3800 ? "text-gb-orange" : "text-gb-fg-f"
|
||||||
|
}`}>
|
||||||
|
{4000 - value.length}/4000
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* mode toggle */}
|
{/* mode toggle */}
|
||||||
<button type="button" disabled={disabled} onClick={toggleMode}
|
<button type="button" disabled={disabled} onClick={toggleMode}
|
||||||
title={mode === "md" ? "Switch to rich text" : "Switch to markdown"}
|
title={mode === "md" ? "Switch to rich text" : "Switch to markdown"}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export function NewConversationModal({ onClose }: NewConversationModalProps) {
|
|||||||
const q = query.toLowerCase();
|
const q = query.toLowerCase();
|
||||||
return members.filter(
|
return members.filter(
|
||||||
(m) =>
|
(m) =>
|
||||||
|
!m.is_bot &&
|
||||||
m.id !== currentUserId &&
|
m.id !== currentUserId &&
|
||||||
(m.username.toLowerCase().includes(q) ||
|
(m.username.toLowerCase().includes(q) ||
|
||||||
(m.display_name || "").toLowerCase().includes(q)),
|
(m.display_name || "").toLowerCase().includes(q)),
|
||||||
|
|||||||
+311
-317
@@ -1,77 +1,13 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useParams, Link } from 'react-router-dom';
|
import { useParams, Link } from 'react-router-dom';
|
||||||
import { useRoleStore, type Role, type CreateRoleData, type UpdateRoleData } from '../stores/role.ts';
|
import { useRoleStore, type Role } from '../stores/role.ts';
|
||||||
|
import {
|
||||||
// Permission bitflags
|
PERMS,
|
||||||
const PERMS = {
|
PERM_CATEGORIES,
|
||||||
VIEW_CHANNEL: 1,
|
PERMISSION_LABELS,
|
||||||
SEND_MESSAGES: 2,
|
hasPermission,
|
||||||
MANAGE_MESSAGES: 4,
|
type PermissionKey,
|
||||||
KICK_MEMBERS: 8,
|
} from '../stores/permissions.ts';
|
||||||
BAN_MEMBERS: 16,
|
|
||||||
MANAGE_SERVER: 32,
|
|
||||||
MANAGE_CHANNELS: 64,
|
|
||||||
ADMINISTRATOR: 128,
|
|
||||||
CONNECT_VOICE: 256,
|
|
||||||
SPEAK_VOICE: 512,
|
|
||||||
SHARE_SCREEN: 1024,
|
|
||||||
MUTE_MEMBERS: 2048,
|
|
||||||
CREATE_INSTANT_INVITE: 4096,
|
|
||||||
CHANGE_NICKNAME: 8192,
|
|
||||||
MANAGE_NICKNAMES: 16384,
|
|
||||||
MANAGE_ROLES: 32768,
|
|
||||||
MANAGE_WEBHOOKS: 65536,
|
|
||||||
EMBED_LINKS: 131072,
|
|
||||||
ATTACH_FILES: 262144,
|
|
||||||
ADD_REACTIONS: 524288,
|
|
||||||
USE_EXTERNAL_EMOJIS: 1048576,
|
|
||||||
MENTION_EVERYONE: 2097152,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const PERM_CATEGORIES = [
|
|
||||||
{
|
|
||||||
name: 'General',
|
|
||||||
perms: [
|
|
||||||
{ key: 'VIEW_CHANNEL', label: 'View Channel', flag: PERMS.VIEW_CHANNEL },
|
|
||||||
{ key: 'SEND_MESSAGES', label: 'Send Messages', flag: PERMS.SEND_MESSAGES },
|
|
||||||
{ key: 'MANAGE_MESSAGES', label: 'Manage Messages', flag: PERMS.MANAGE_MESSAGES },
|
|
||||||
{ key: 'ADD_REACTIONS', label: 'Add Reactions', flag: PERMS.ADD_REACTIONS },
|
|
||||||
{ key: 'EMBED_LINKS', label: 'Embed Links', flag: PERMS.EMBED_LINKS },
|
|
||||||
{ key: 'ATTACH_FILES', label: 'Attach Files', flag: PERMS.ATTACH_FILES },
|
|
||||||
{ key: 'MENTION_EVERYONE', label: 'Mention @everyone', flag: PERMS.MENTION_EVERYONE },
|
|
||||||
{ key: 'USE_EXTERNAL_EMOJIS', label: 'Use External Emojis', flag: PERMS.USE_EXTERNAL_EMOJIS },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Moderation',
|
|
||||||
perms: [
|
|
||||||
{ key: 'KICK_MEMBERS', label: 'Kick Members', flag: PERMS.KICK_MEMBERS },
|
|
||||||
{ key: 'BAN_MEMBERS', label: 'Ban Members', flag: PERMS.BAN_MEMBERS },
|
|
||||||
{ key: 'MUTE_MEMBERS', label: 'Mute Members', flag: PERMS.MUTE_MEMBERS },
|
|
||||||
{ key: 'MANAGE_NICKNAMES', label: 'Manage Nicknames', flag: PERMS.MANAGE_NICKNAMES },
|
|
||||||
{ key: 'CHANGE_NICKNAME', label: 'Change Nickname', flag: PERMS.CHANGE_NICKNAME },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Server',
|
|
||||||
perms: [
|
|
||||||
{ key: 'MANAGE_SERVER', label: 'Manage Server', flag: PERMS.MANAGE_SERVER },
|
|
||||||
{ key: 'MANAGE_CHANNELS', label: 'Manage Channels', flag: PERMS.MANAGE_CHANNELS },
|
|
||||||
{ key: 'MANAGE_ROLES', label: 'Manage Roles', flag: PERMS.MANAGE_ROLES },
|
|
||||||
{ key: 'MANAGE_WEBHOOKS', label: 'Manage Webhooks', flag: PERMS.MANAGE_WEBHOOKS },
|
|
||||||
{ key: 'CREATE_INSTANT_INVITE', label: 'Create Instant Invite', flag: PERMS.CREATE_INSTANT_INVITE },
|
|
||||||
{ key: 'ADMINISTRATOR', label: 'Administrator', flag: PERMS.ADMINISTRATOR },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Voice',
|
|
||||||
perms: [
|
|
||||||
{ key: 'CONNECT_VOICE', label: 'Connect Voice', flag: PERMS.CONNECT_VOICE },
|
|
||||||
{ key: 'SPEAK_VOICE', label: 'Speak Voice', flag: PERMS.SPEAK_VOICE },
|
|
||||||
{ key: 'SHARE_SCREEN', label: 'Share Screen', flag: PERMS.SHARE_SCREEN },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const GRUVBOX_COLORS = [
|
const GRUVBOX_COLORS = [
|
||||||
'#fb4934', '#b8bb26', '#fabd2f', '#83a598',
|
'#fb4934', '#b8bb26', '#fabd2f', '#83a598',
|
||||||
@@ -79,207 +15,167 @@ const GRUVBOX_COLORS = [
|
|||||||
'#a89984', '#928374', '#282828',
|
'#a89984', '#928374', '#282828',
|
||||||
];
|
];
|
||||||
|
|
||||||
function hasPerm(permissions: number, flag: number): boolean {
|
// ponytail: full bitfield when admin is on; not every future flag needs a checkbox.
|
||||||
return (permissions & flag) !== 0;
|
const ALL_PERMS = Object.values(PERMS).reduce((a, b) => a | b, 0);
|
||||||
|
|
||||||
|
interface Draft {
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
position: number;
|
||||||
|
permissions: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function permSummary(permissions: number): string {
|
function draftFromRole(role: Role): Draft {
|
||||||
if (hasPerm(permissions, PERMS.ADMINISTRATOR)) return 'ADMINISTRATOR';
|
return {
|
||||||
const active: string[] = [];
|
name: role.name,
|
||||||
if (hasPerm(permissions, PERMS.MANAGE_SERVER)) active.push('MGR_SERVER');
|
color: role.color || '#ebdbb2',
|
||||||
if (hasPerm(permissions, PERMS.MANAGE_CHANNELS)) active.push('MGR_CHAN');
|
position: role.position,
|
||||||
if (hasPerm(permissions, PERMS.MANAGE_ROLES)) active.push('MGR_ROLES');
|
permissions: role.permissions,
|
||||||
if (hasPerm(permissions, PERMS.MANAGE_MESSAGES)) active.push('MGR_MSG');
|
|
||||||
if (hasPerm(permissions, PERMS.KICK_MEMBERS)) active.push('KICK');
|
|
||||||
if (hasPerm(permissions, PERMS.BAN_MEMBERS)) active.push('BAN');
|
|
||||||
if (hasPerm(permissions, PERMS.MUTE_MEMBERS)) active.push('MUTE');
|
|
||||||
return active.length > 0 ? active.join('+') : 'BASIC';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RoleFormProps {
|
|
||||||
serverId: string;
|
|
||||||
role?: Role;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function RoleForm({ serverId, role, onClose }: RoleFormProps) {
|
|
||||||
const createRole = useRoleStore((s) => s.createRole);
|
|
||||||
const updateRole = useRoleStore((s) => s.updateRole);
|
|
||||||
const loading = useRoleStore((s) => s.loading);
|
|
||||||
|
|
||||||
const [name, setName] = useState(role?.name ?? '');
|
|
||||||
const [color, setColor] = useState(role?.color ?? '#ebdbb2');
|
|
||||||
const [position, setPosition] = useState(role?.position ?? 0);
|
|
||||||
const [permissions, setPermissions] = useState(role?.permissions ?? 0);
|
|
||||||
|
|
||||||
const togglePerm = useCallback((flag: number) => {
|
|
||||||
setPermissions((prev) => prev ^ flag);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleAdminToggle = useCallback(() => {
|
|
||||||
setPermissions((prev) => {
|
|
||||||
if (hasPerm(prev, PERMS.ADMINISTRATOR)) return 0;
|
|
||||||
return 0xFFFFFFFF; // grant all
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!name.trim()) return;
|
|
||||||
try {
|
|
||||||
if (role) {
|
|
||||||
const data: UpdateRoleData = { name: name.trim(), color, permissions, position };
|
|
||||||
await updateRole(serverId, role.id, data);
|
|
||||||
} else {
|
|
||||||
const data: CreateRoleData = { name: name.trim(), color, permissions, position };
|
|
||||||
await createRole(serverId, data);
|
|
||||||
}
|
|
||||||
onClose();
|
|
||||||
} catch {
|
|
||||||
// error in store
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDirty(role: Role, draft: Draft): boolean {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
draft.name !== role.name ||
|
||||||
<div className="border border-gb-bg-t bg-gb-bg p-6 max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
|
draft.color !== (role.color || '#ebdbb2') ||
|
||||||
<p className="text-gb-orange font-mono text-sm mb-4">
|
draft.position !== role.position ||
|
||||||
{'>'} {role ? 'EDIT ROLE' : 'CREATE ROLE'}
|
draft.permissions !== role.permissions
|
||||||
</p>
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-gb-fg-f mb-1 font-mono text-xs">NAME:</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value.slice(0, 64))}
|
|
||||||
maxLength={64}
|
|
||||||
className="terminal-input w-full"
|
|
||||||
placeholder="role-name"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-gb-fg-f mb-1 font-mono text-xs">COLOR:</label>
|
|
||||||
<div className="flex flex-wrap gap-2 mb-2">
|
|
||||||
{GRUVBOX_COLORS.map((c) => (
|
|
||||||
<button
|
|
||||||
key={c}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setColor(c)}
|
|
||||||
className={`w-7 h-7 border-2 transition-all ${
|
|
||||||
color === c ? 'border-gb-fg scale-110' : 'border-gb-bg-t'
|
|
||||||
}`}
|
|
||||||
style={{ backgroundColor: c }}
|
|
||||||
title={c}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={color}
|
|
||||||
onChange={(e) => setColor(e.target.value)}
|
|
||||||
className="terminal-input w-full text-xs"
|
|
||||||
placeholder="#ebdbb2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-gb-fg-f mb-1 font-mono text-xs">POSITION:</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={position}
|
|
||||||
onChange={(e) => setPosition(parseInt(e.target.value) || 0)}
|
|
||||||
className="terminal-input w-24"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-gb-fg-f mb-2 font-mono text-xs">PERMISSIONS:</label>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{/* Administrator all-or-nothing */}
|
|
||||||
<div className="border border-gb-bg-t p-3">
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer font-mono text-xs">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={hasPerm(permissions, PERMS.ADMINISTRATOR)}
|
|
||||||
onChange={handleAdminToggle}
|
|
||||||
className="accent-gb-red"
|
|
||||||
/>
|
|
||||||
<span className="text-gb-red">Administrator (grants all permissions)</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Category groups */}
|
|
||||||
{!hasPerm(permissions, PERMS.ADMINISTRATOR) &&
|
|
||||||
PERM_CATEGORIES.filter((cat) => cat.name !== 'Server' || true).map((cat) => (
|
|
||||||
<div key={cat.name} className="border border-gb-bg-t p-3">
|
|
||||||
<p className="text-gb-aqua font-mono text-xs mb-2">[{cat.name.toUpperCase()}]</p>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{cat.perms
|
|
||||||
.filter((p) => p.key !== 'ADMINISTRATOR')
|
|
||||||
.map((p) => (
|
|
||||||
<label
|
|
||||||
key={p.key}
|
|
||||||
className="flex items-center gap-2 cursor-pointer font-mono text-xs"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={hasPerm(permissions, p.flag)}
|
|
||||||
onChange={() => togglePerm(p.flag)}
|
|
||||||
className="accent-gb-orange"
|
|
||||||
/>
|
|
||||||
<span className="text-gb-fg">{p.label}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 pt-2">
|
|
||||||
<button type="submit" className="terminal-button" disabled={loading || !name.trim()}>
|
|
||||||
{loading ? '[SAVING...]' : '[SAVE]'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm"
|
|
||||||
>
|
|
||||||
[CANCEL]
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RoleManager({ serverId: propServerId }: { serverId?: string } = {}) {
|
export function RoleManager({ serverId: propServerId }: { serverId?: string } = {}) {
|
||||||
const { serverId: paramServerId } = useParams<{ serverId: string }>();
|
const { serverId: paramServerId } = useParams<{ serverId: string }>();
|
||||||
const serverId = propServerId || paramServerId;
|
const serverId = propServerId || paramServerId;
|
||||||
const roles = useRoleStore((s) => s.roles);
|
const embedded = Boolean(propServerId);
|
||||||
|
|
||||||
|
const allRoles = useRoleStore((s) => s.roles);
|
||||||
const loading = useRoleStore((s) => s.loading);
|
const loading = useRoleStore((s) => s.loading);
|
||||||
const error = useRoleStore((s) => s.error);
|
const error = useRoleStore((s) => s.error);
|
||||||
const fetchRoles = useRoleStore((s) => s.fetchRoles);
|
const fetchRoles = useRoleStore((s) => s.fetchRoles);
|
||||||
|
const createRole = useRoleStore((s) => s.createRole);
|
||||||
|
const updateRole = useRoleStore((s) => s.updateRole);
|
||||||
const deleteRole = useRoleStore((s) => s.deleteRole);
|
const deleteRole = useRoleStore((s) => s.deleteRole);
|
||||||
|
|
||||||
const [showForm, setShowForm] = useState(false);
|
const roles = useMemo(
|
||||||
const [editingRole, setEditingRole] = useState<Role | null>(null);
|
() =>
|
||||||
|
allRoles
|
||||||
|
.filter((r) => !serverId || r.server_id === serverId)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => b.position - a.position || a.name.localeCompare(b.name)),
|
||||||
|
[allRoles, serverId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [draft, setDraft] = useState<Draft | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [localError, setLocalError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (serverId) fetchRoles(serverId);
|
if (serverId) void fetchRoles(serverId);
|
||||||
}, [serverId, fetchRoles]);
|
}, [serverId, fetchRoles]);
|
||||||
|
|
||||||
const handleDelete = async (role: Role) => {
|
// Keep selection valid when role list changes.
|
||||||
if (!serverId) return;
|
useEffect(() => {
|
||||||
if (!window.confirm(`Delete role "${role.name}"? This cannot be undone.`)) return;
|
if (roles.length === 0) {
|
||||||
|
setSelectedId(null);
|
||||||
|
setDraft(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stillThere = selectedId && roles.some((r) => r.id === selectedId);
|
||||||
|
if (!stillThere) {
|
||||||
|
const next = roles[0];
|
||||||
|
setSelectedId(next.id);
|
||||||
|
setDraft(draftFromRole(next));
|
||||||
|
}
|
||||||
|
}, [roles, selectedId]);
|
||||||
|
|
||||||
|
const selected = roles.find((r) => r.id === selectedId) ?? null;
|
||||||
|
const dirty = selected && draft ? isDirty(selected, draft) : false;
|
||||||
|
const isAdmin = draft ? hasPermission(draft.permissions, PERMS.ADMINISTRATOR) : false;
|
||||||
|
|
||||||
|
const selectRole = (role: Role) => {
|
||||||
|
if (dirty && !window.confirm('Discard unsaved changes?')) return;
|
||||||
|
setSelectedId(role.id);
|
||||||
|
setDraft(draftFromRole(role));
|
||||||
|
setLocalError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePerm = (flag: number) => {
|
||||||
|
setDraft((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
return { ...prev, permissions: prev.permissions ^ flag };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAdmin = () => {
|
||||||
|
setDraft((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
if (hasPermission(prev.permissions, PERMS.ADMINISTRATOR)) {
|
||||||
|
return { ...prev, permissions: 0 };
|
||||||
|
}
|
||||||
|
return { ...prev, permissions: ALL_PERMS };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!serverId || creating) return;
|
||||||
|
if (dirty && !window.confirm('Discard unsaved changes?')) return;
|
||||||
|
setCreating(true);
|
||||||
|
setLocalError(null);
|
||||||
try {
|
try {
|
||||||
await deleteRole(serverId, role.id);
|
const top = roles[0]?.position ?? 0;
|
||||||
} catch {
|
const role = await createRole(serverId, {
|
||||||
// error in store
|
name: 'new-role',
|
||||||
|
color: '#ebdbb2',
|
||||||
|
permissions: PERMS.VIEW_CHANNEL | PERMS.SEND_MESSAGES,
|
||||||
|
position: top + 1,
|
||||||
|
});
|
||||||
|
setSelectedId(role.id);
|
||||||
|
setDraft(draftFromRole(role));
|
||||||
|
} catch (err) {
|
||||||
|
setLocalError(err instanceof Error ? err.message : 'Failed to create role');
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!serverId || !selected || !draft) return;
|
||||||
|
const name = draft.name.trim();
|
||||||
|
if (!name) {
|
||||||
|
setLocalError('Name required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setLocalError(null);
|
||||||
|
try {
|
||||||
|
await updateRole(serverId, selected.id, {
|
||||||
|
name: selected.is_default ? selected.name : name,
|
||||||
|
color: draft.color,
|
||||||
|
permissions: draft.permissions,
|
||||||
|
position: selected.is_default ? selected.position : draft.position,
|
||||||
|
});
|
||||||
|
// Store updates selected role; re-sync draft from next render via roles.
|
||||||
|
const updated = useRoleStore.getState().roles.find((r) => r.id === selected.id);
|
||||||
|
if (updated) setDraft(draftFromRole(updated));
|
||||||
|
} catch (err) {
|
||||||
|
setLocalError(err instanceof Error ? err.message : 'Failed to save role');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!serverId || !selected || selected.is_default) return;
|
||||||
|
if (!window.confirm(`Delete role "${selected.name}"? This cannot be undone.`)) return;
|
||||||
|
setLocalError(null);
|
||||||
|
try {
|
||||||
|
await deleteRole(serverId, selected.id);
|
||||||
|
setSelectedId(null);
|
||||||
|
setDraft(null);
|
||||||
|
} catch (err) {
|
||||||
|
setLocalError(err instanceof Error ? err.message : 'Failed to delete role');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -292,97 +188,195 @@ export function RoleManager({ serverId: propServerId }: { serverId?: string } =
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full bg-gb-bg p-4 md:p-8 overflow-y-auto">
|
<div className={`bg-gb-bg font-mono flex flex-col ${embedded ? 'h-full min-h-[55vh]' : 'h-full w-full'}`}>
|
||||||
<div className="max-w-3xl mx-auto">
|
{!embedded && (
|
||||||
<div className="border border-gb-bg-t p-6">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-gb-bg-t">
|
||||||
<pre className="text-gb-orange font-mono text-center mb-6">
|
<span className="text-sm text-gb-orange">ROLE MANAGER</span>
|
||||||
{'┌──────────────────────────────────┐\n'}
|
<Link to="/" className="text-xs text-gb-fg-f hover:text-gb-aqua">
|
||||||
{'│ === ROLE MANAGER === │\n'}
|
{'<'} [BACK]
|
||||||
{'└──────────────────────────────────┘'}
|
</Link>
|
||||||
</pre>
|
</div>
|
||||||
|
|
||||||
{error && (
|
|
||||||
<p className="text-gb-red text-sm font-mono mb-4">ERR: {error}</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mb-6">
|
<div className="flex flex-1 min-h-0 border border-gb-bg-t">
|
||||||
|
{/* Left: role list */}
|
||||||
|
<div className="w-48 md:w-56 shrink-0 border-r border-gb-bg-t flex flex-col bg-gb-bg-s">
|
||||||
|
<div className="p-2 border-b border-gb-bg-t">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setEditingRole(null); setShowForm(true); }}
|
onClick={() => void handleCreate()}
|
||||||
className="terminal-button"
|
disabled={creating}
|
||||||
|
className="w-full terminal-button text-xs disabled:opacity-50"
|
||||||
>
|
>
|
||||||
[CREATE ROLE]
|
{creating ? '[...]' : '[+ CREATE ROLE]'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
{loading && roles.length === 0 && (
|
{loading && roles.length === 0 && (
|
||||||
<p className="text-gb-fg-f font-mono text-sm">[loading roles...]</p>
|
<p className="text-gb-fg-f text-xs p-3">[loading...]</p>
|
||||||
)}
|
)}
|
||||||
{!loading && roles.length === 0 && (
|
{!loading && roles.length === 0 && (
|
||||||
<p className="text-gb-fg-f font-mono text-sm">[no roles configured]</p>
|
<p className="text-gb-fg-f text-xs p-3">[no roles]</p>
|
||||||
)}
|
)}
|
||||||
|
{roles.map((role) => {
|
||||||
<div className="space-y-3">
|
const active = role.id === selectedId;
|
||||||
{roles.map((role) => (
|
return (
|
||||||
<div key={role.id} className="border border-gb-bg-t p-4">
|
<button
|
||||||
<div className="flex items-center gap-3 mb-2">
|
key={role.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectRole(role)}
|
||||||
|
className={`w-full text-left px-3 py-2 text-xs border-b border-gb-bg-t flex items-center gap-2 ${
|
||||||
|
active ? 'bg-gb-bg text-gb-fg' : 'text-gb-fg-f hover:bg-gb-bg hover:text-gb-fg'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
className="inline-block w-4 h-4 border border-gb-bg-t shrink-0"
|
className="w-2.5 h-2.5 shrink-0 border border-gb-bg-t"
|
||||||
style={{ backgroundColor: role.color || '#ebdbb2' }}
|
style={{ backgroundColor: role.color || '#ebdbb2' }}
|
||||||
/>
|
/>
|
||||||
<span
|
<span className="truncate" style={{ color: active ? (role.color || undefined) : undefined }}>
|
||||||
className="font-mono text-sm font-bold truncate"
|
|
||||||
style={{ color: role.color || '#ebdbb2' }}
|
|
||||||
>
|
|
||||||
{role.name}
|
{role.name}
|
||||||
</span>
|
</span>
|
||||||
{role.is_default && (
|
{role.is_default && (
|
||||||
<span className="text-gb-fg-f font-mono text-xs">[DEFAULT]</span>
|
<span className="ml-auto text-[10px] text-gb-fg-f shrink-0">@</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-gb-fg-f font-mono text-xs ml-auto shrink-0">
|
</button>
|
||||||
POS:{role.position}
|
);
|
||||||
</span>
|
})}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gb-fg-f font-mono text-xs mb-3">
|
</div>
|
||||||
perms: {permSummary(role.permissions)}
|
|
||||||
</p>
|
{/* Right: live editor */}
|
||||||
<div className="flex gap-2">
|
<div className="flex-1 min-w-0 flex flex-col">
|
||||||
<button
|
{!selected || !draft ? (
|
||||||
type="button"
|
<div className="flex-1 flex items-center justify-center text-gb-fg-f text-xs p-6">
|
||||||
onClick={() => { setEditingRole(role); setShowForm(true); }}
|
[select a role]
|
||||||
className="terminal-button text-xs"
|
</div>
|
||||||
>
|
) : (
|
||||||
[EDIT]
|
<>
|
||||||
</button>
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
{!role.is_default && (
|
{(error || localError) && (
|
||||||
<button
|
<p className="text-gb-red text-xs">ERR: {localError || error}</p>
|
||||||
type="button"
|
|
||||||
onClick={() => handleDelete(role)}
|
|
||||||
className="terminal-button text-xs hover:!text-gb-red"
|
|
||||||
>
|
|
||||||
[DELETE]
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-gb-fg-f text-xs mb-1">NAME</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={draft.name}
|
||||||
|
disabled={selected.is_default}
|
||||||
|
onChange={(e) => setDraft({ ...draft, name: e.target.value.slice(0, 64) })}
|
||||||
|
maxLength={64}
|
||||||
|
className="terminal-input w-full text-sm disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
{selected.is_default && (
|
||||||
|
<p className="text-gb-fg-f text-[10px] mt-1">@everyone name is fixed</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-gb-fg-f text-xs mb-1">COLOR</label>
|
||||||
|
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||||
|
{GRUVBOX_COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDraft({ ...draft, color: c })}
|
||||||
|
className={`w-6 h-6 border-2 ${
|
||||||
|
draft.color === c ? 'border-gb-fg scale-110' : 'border-gb-bg-t'
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: c }}
|
||||||
|
title={c}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={draft.color}
|
||||||
|
onChange={(e) => setDraft({ ...draft, color: e.target.value })}
|
||||||
|
className="terminal-input w-full text-xs"
|
||||||
|
placeholder="#ebdbb2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!selected.is_default && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-gb-fg-f text-xs mb-1">POSITION</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={draft.position}
|
||||||
|
onChange={(e) => setDraft({ ...draft, position: parseInt(e.target.value, 10) || 0 })}
|
||||||
|
className="terminal-input w-24 text-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-gb-fg-f text-[10px] mt-1">higher = listed first</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-gb-fg-f text-xs mb-2">PERMISSIONS</label>
|
||||||
|
<div className="border border-gb-bg-t p-3 mb-2">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer text-xs">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isAdmin}
|
||||||
|
onChange={toggleAdmin}
|
||||||
|
className="accent-gb-red"
|
||||||
|
/>
|
||||||
|
<span className="text-gb-red">Administrator (all permissions)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isAdmin &&
|
||||||
|
PERM_CATEGORIES.map((cat) => (
|
||||||
|
<div key={cat.name} className="border border-gb-bg-t p-3 mb-2">
|
||||||
|
<p className="text-gb-aqua text-xs mb-2">[{cat.name.toUpperCase()}]</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{cat.keys.map((key: PermissionKey) => {
|
||||||
|
const flag = PERMS[key];
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={key}
|
||||||
|
className="flex items-center gap-2 cursor-pointer text-xs"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={hasPermission(draft.permissions, flag)}
|
||||||
|
onChange={() => togglePerm(flag)}
|
||||||
|
className="accent-gb-orange"
|
||||||
|
/>
|
||||||
|
<span className="text-gb-fg">{PERMISSION_LABELS[key]}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 pt-4 border-t border-gb-bg-t">
|
|
||||||
<Link to="/" className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm">
|
|
||||||
{'<'} [BACK TO CHAT]
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showForm && (
|
<div className="shrink-0 border-t border-gb-bg-t px-4 py-3 flex items-center gap-2 flex-wrap">
|
||||||
<RoleForm
|
<button
|
||||||
serverId={serverId}
|
type="button"
|
||||||
role={editingRole ?? undefined}
|
onClick={() => void handleSave()}
|
||||||
onClose={() => { setShowForm(false); setEditingRole(null); }}
|
disabled={saving || !dirty}
|
||||||
/>
|
className="terminal-button text-xs disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{saving ? '[SAVING...]' : '[SAVE CHANGES]'}
|
||||||
|
</button>
|
||||||
|
{dirty && <span className="text-gb-orange text-[10px]">unsaved</span>}
|
||||||
|
{!selected.is_default && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleDelete()}
|
||||||
|
className="ml-auto text-xs text-gb-fg-f hover:text-gb-red"
|
||||||
|
>
|
||||||
|
[DELETE ROLE]
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useServerStore } from "../stores/server.ts";
|
import { useServerStore } from "../stores/server.ts";
|
||||||
import { useChannelStore } from "../stores/channel.ts";
|
import { useChannelStore } from "../stores/channel.ts";
|
||||||
import { useLayoutStore } from "../stores/layout.ts";
|
import { useLayoutStore } from "../stores/layout.ts";
|
||||||
|
import { useConversationStore } from "../stores/conversation.ts";
|
||||||
|
import { useReadStatesStore } from "../stores/readStates.ts";
|
||||||
import { CreateServerModal } from "./CreateServerModal.tsx";
|
import { CreateServerModal } from "./CreateServerModal.tsx";
|
||||||
import { JoinServerModal } from "./JoinServerModal.tsx";
|
import { JoinServerModal } from "./JoinServerModal.tsx";
|
||||||
import { ServerSettingsModal } from "./ServerSettingsModal.tsx";
|
import { ServerSettingsModal } from "./ServerSettingsModal.tsx";
|
||||||
@@ -33,6 +35,18 @@ export function ServerBar() {
|
|||||||
|
|
||||||
const { showMenu, MenuPortal } = useContextMenu();
|
const { showMenu, MenuPortal } = useContextMenu();
|
||||||
|
|
||||||
|
// ponytail: compute if any DM conversation has unread messages
|
||||||
|
const conversations = useConversationStore((s) => s.conversations);
|
||||||
|
const messagesByConv = useConversationStore((s) => s.messagesByConversation);
|
||||||
|
const hasConvUnread = useReadStatesStore((s) => s.hasConvUnread);
|
||||||
|
const hasAnyDMUnread = useMemo(() => {
|
||||||
|
return conversations.some((conv) => {
|
||||||
|
const msgs = messagesByConv[conv.id] || [];
|
||||||
|
const latestId = msgs.length > 0 ? msgs[msgs.length - 1].id : undefined;
|
||||||
|
return hasConvUnread(conv.id, latestId);
|
||||||
|
});
|
||||||
|
}, [conversations, messagesByConv, hasConvUnread]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchServers().then(() => {
|
fetchServers().then(() => {
|
||||||
// ponytail: restore last active server + channel from localStorage
|
// ponytail: restore last active server + channel from localStorage
|
||||||
@@ -94,6 +108,7 @@ export function ServerBar() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="h-full w-16 bg-gb-bg-h border-r border-gb-bg-t flex flex-col items-center py-3 gap-2 overflow-y-auto">
|
<div className="h-full w-16 bg-gb-bg-h border-r border-gb-bg-t flex flex-col items-center py-3 gap-2 overflow-y-auto">
|
||||||
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={handleDM}
|
onClick={handleDM}
|
||||||
className={'w-11 h-11 flex items-center justify-center font-mono text-sm border ' +
|
className={'w-11 h-11 flex items-center justify-center font-mono text-sm border ' +
|
||||||
@@ -104,10 +119,14 @@ export function ServerBar() {
|
|||||||
>
|
>
|
||||||
[@]
|
[@]
|
||||||
</button>
|
</button>
|
||||||
|
{hasAnyDMUnread && !isDM && (
|
||||||
|
<span className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 bg-gb-orange rounded-full border-2 border-gb-bg-h" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="w-8 h-px bg-gb-bg-t" />
|
<div className="w-8 h-px bg-gb-bg-t" />
|
||||||
{servers.map((server) => (
|
{servers.map((server) => (
|
||||||
|
<div key={server.id} className="relative">
|
||||||
<button
|
<button
|
||||||
key={server.id}
|
|
||||||
onClick={() => handleSelect(server.id)}
|
onClick={() => handleSelect(server.id)}
|
||||||
onContextMenu={(e) => handleServerContextMenu(e, server)}
|
onContextMenu={(e) => handleServerContextMenu(e, server)}
|
||||||
className={'w-11 h-11 flex items-center justify-center font-mono text-sm border ' +
|
className={'w-11 h-11 flex items-center justify-center font-mono text-sm border ' +
|
||||||
@@ -122,6 +141,10 @@ export function ServerBar() {
|
|||||||
'[' + getInitials(server.name) + ']'
|
'[' + getInitials(server.name) + ']'
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
{server.unread && server.id !== activeServerId && (
|
||||||
|
<span className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 bg-gb-orange rounded-full border-2 border-gb-bg-h" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
<div className="mt-2 flex flex-col gap-2 items-center">
|
<div className="mt-2 flex flex-col gap-2 items-center">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ export function ServerSettingsModal({ serverId, onClose }: ServerSettingsModalPr
|
|||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="bg-gb-bg border border-gb-bg-t w-[750px] max-h-[85vh] flex flex-col font-mono"
|
className="bg-gb-bg border border-gb-bg-t w-[min(920px,95vw)] max-h-[85vh] flex flex-col font-mono"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gb-bg-t">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-gb-bg-t">
|
||||||
@@ -342,7 +342,7 @@ export function ServerSettingsModal({ serverId, onClose }: ServerSettingsModalPr
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === 'roles' && (
|
{tab === 'roles' && (
|
||||||
<div className="h-[60vh] overflow-y-auto">
|
<div className="h-[60vh] -m-4">
|
||||||
<RoleManager serverId={serverId} />
|
<RoleManager serverId={serverId} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,36 +1,67 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
type Theme = 'dark' | 'light';
|
// ponytail: top IDE palettes as CSS data-theme tokens; gruvbox stays default
|
||||||
|
export const THEMES = [
|
||||||
|
{ id: 'gruvbox', label: 'Gruvbox' },
|
||||||
|
{ id: 'gruvbox-light', label: 'Gruvbox Light' },
|
||||||
|
{ id: 'one-dark', label: 'One Dark' },
|
||||||
|
{ id: 'dracula', label: 'Dracula' },
|
||||||
|
{ id: 'nord', label: 'Nord' },
|
||||||
|
{ id: 'tokyo-night', label: 'Tokyo Night' },
|
||||||
|
{ id: 'catppuccin', label: 'Catppuccin' },
|
||||||
|
{ id: 'solarized-dark', label: 'Solarized Dark' },
|
||||||
|
{ id: 'monokai', label: 'Monokai' },
|
||||||
|
{ id: 'github-dark', label: 'GitHub Dark' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ThemeId = (typeof THEMES)[number]['id'];
|
||||||
|
|
||||||
|
const THEME_IDS = new Set<string>(THEMES.map((t) => t.id));
|
||||||
|
|
||||||
|
export function normalizeTheme(raw: string | null): ThemeId {
|
||||||
|
if (!raw) return 'gruvbox';
|
||||||
|
// migrate old light/dark toggle
|
||||||
|
if (raw === 'light') return 'gruvbox-light';
|
||||||
|
if (raw === 'dark') return 'gruvbox';
|
||||||
|
if (THEME_IDS.has(raw)) return raw as ThemeId;
|
||||||
|
return 'gruvbox';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyTheme(theme: ThemeId) {
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.setAttribute('data-theme', theme);
|
||||||
|
root.classList.remove('light', 'dark');
|
||||||
|
if (theme === 'gruvbox-light') root.classList.add('light');
|
||||||
|
else root.classList.add('dark');
|
||||||
|
localStorage.setItem('dumpster-theme', theme);
|
||||||
|
|
||||||
|
const bg = getComputedStyle(root).getPropertyValue('--gb-bg').trim() || '#282828';
|
||||||
|
const meta = document.querySelector('meta[name="theme-color"]');
|
||||||
|
if (meta) meta.setAttribute('content', bg);
|
||||||
|
}
|
||||||
|
|
||||||
export function ThemeToggle() {
|
export function ThemeToggle() {
|
||||||
const [theme, setTheme] = useState<Theme>(() => {
|
const [theme, setTheme] = useState<ThemeId>(() =>
|
||||||
const stored = localStorage.getItem('dumpster-theme');
|
normalizeTheme(localStorage.getItem('dumpster-theme')),
|
||||||
return (stored === 'light' ? 'light' : 'dark');
|
);
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement;
|
applyTheme(theme);
|
||||||
if (theme === 'light') {
|
|
||||||
root.classList.add('light');
|
|
||||||
root.classList.remove('dark');
|
|
||||||
} else {
|
|
||||||
root.classList.add('dark');
|
|
||||||
root.classList.remove('light');
|
|
||||||
}
|
|
||||||
localStorage.setItem('dumpster-theme', theme);
|
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
const toggle = () => {
|
|
||||||
setTheme(prev => prev === 'dark' ? 'light' : 'dark');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<select
|
||||||
onClick={toggle}
|
value={theme}
|
||||||
className="px-2 py-1 text-xs font-mono text-gb-fg-f hover:text-gb-orange transition-colors"
|
onChange={(e) => setTheme(e.target.value as ThemeId)}
|
||||||
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
className="max-w-[9.5rem] px-1 py-0.5 text-[10px] md:text-xs font-mono text-gb-fg-f bg-gb-bg-s border border-gb-bg-t hover:text-gb-orange focus:text-gb-orange cursor-pointer"
|
||||||
|
title="Theme"
|
||||||
|
aria-label="Color theme"
|
||||||
>
|
>
|
||||||
[{theme === 'dark' ? 'light' : 'dark'}]
|
{THEMES.map((t) => (
|
||||||
</button>
|
<option key={t.id} value={t.id}>
|
||||||
|
{t.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useRef, useEffect } from 'react';
|
|||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { useAuthStore } from '../stores/auth.ts';
|
import { useAuthStore } from '../stores/auth.ts';
|
||||||
import { usePushStore } from '../stores/push.ts';
|
import { usePushStore } from '../stores/push.ts';
|
||||||
|
import { ThemeToggle } from './ThemeToggle.tsx';
|
||||||
|
|
||||||
export function UserSettings() {
|
export function UserSettings() {
|
||||||
const { user, updateProfile, changePassword, isLoading, error, clearError } = useAuthStore();
|
const { user, updateProfile, changePassword, isLoading, error, clearError } = useAuthStore();
|
||||||
@@ -316,6 +317,17 @@ export function UserSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Appearance */}
|
||||||
|
<div className="border border-gb-bg-t p-4">
|
||||||
|
<label className="block text-gb-fg-f mb-2 font-mono text-sm">
|
||||||
|
THEME:
|
||||||
|
</label>
|
||||||
|
<ThemeToggle />
|
||||||
|
<p className="text-gb-fg-f text-xs font-mono mt-2">
|
||||||
|
Default is Gruvbox. Choice is saved in this browser.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Push Notifications */}
|
{/* Push Notifications */}
|
||||||
{isSupported && (
|
{isSupported && (
|
||||||
<div className="border border-gb-bg-t p-4">
|
<div className="border border-gb-bg-t p-4">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useVoiceStore } from '../stores/voice.ts';
|
|||||||
import { useAuthStore } from '../stores/auth.ts';
|
import { useAuthStore } from '../stores/auth.ts';
|
||||||
import { usePermissions } from '../lib/usePermissions.ts';
|
import { usePermissions } from '../lib/usePermissions.ts';
|
||||||
import { useChannelStore } from '../stores/channel.ts';
|
import { useChannelStore } from '../stores/channel.ts';
|
||||||
|
import { useVoicePresenceStore } from '../stores/voicePresence.ts';
|
||||||
import { api } from '../lib/api.ts';
|
import { api } from '../lib/api.ts';
|
||||||
|
|
||||||
interface VoiceChannelProps {
|
interface VoiceChannelProps {
|
||||||
@@ -13,7 +14,8 @@ export function VoiceChannel({ channelId, channelName }: VoiceChannelProps) {
|
|||||||
const currentRoom = useVoiceStore((state) => state.currentRoom);
|
const currentRoom = useVoiceStore((state) => state.currentRoom);
|
||||||
const isConnected = useVoiceStore((state) => state.isConnected);
|
const isConnected = useVoiceStore((state) => state.isConnected);
|
||||||
const joinVoice = useVoiceStore((state) => state.joinVoice);
|
const joinVoice = useVoiceStore((state) => state.joinVoice);
|
||||||
const participants = useVoiceStore((state) => state.participants);
|
const liveParticipants = useVoiceStore((state) => state.participants);
|
||||||
|
const presenceParticipants = useVoicePresenceStore((s) => s.getParticipants(channelId));
|
||||||
|
|
||||||
const currentUserId = useAuthStore((state) => state.user?.id);
|
const currentUserId = useAuthStore((state) => state.user?.id);
|
||||||
const activeChannelId = useChannelStore((s) => s.activeChannelId);
|
const activeChannelId = useChannelStore((s) => s.activeChannelId);
|
||||||
@@ -40,6 +42,11 @@ export function VoiceChannel({ channelId, channelName }: VoiceChannelProps) {
|
|||||||
|
|
||||||
const isActive = currentRoom === channelId;
|
const isActive = currentRoom === channelId;
|
||||||
|
|
||||||
|
// Use live participants when in the room, otherwise fall back to presence
|
||||||
|
const displayParticipants = isActive && isConnected
|
||||||
|
? liveParticipants.map(p => ({ userId: p.identity, username: p.username, isMuted: p.isMuted, isSpeaking: p.isSpeaking, hasVideo: p.hasVideo, isScreenSharing: p.isScreenSharing }))
|
||||||
|
: presenceParticipants.map(p => ({ userId: p.userId, username: p.username, isMuted: true, isSpeaking: false, hasVideo: false, isScreenSharing: false }));
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
if (!isActive) {
|
if (!isActive) {
|
||||||
console.log("[VoiceChannel] joining voice:", channelId, channelName);
|
console.log("[VoiceChannel] joining voice:", channelId, channelName);
|
||||||
@@ -69,19 +76,19 @@ export function VoiceChannel({ channelId, channelName }: VoiceChannelProps) {
|
|||||||
🔊
|
🔊
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate">{channelName}</span>
|
<span className="truncate">{channelName}</span>
|
||||||
{isActive && participants.length > 0 && (
|
{displayParticipants.length > 0 && (
|
||||||
<span className="ml-auto text-xxs text-gb-fg-f">
|
<span className="ml-auto text-xxs text-gb-fg-f">
|
||||||
[{participants.length}]
|
[{displayParticipants.length}]
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{isActive && (
|
{displayParticipants.length > 0 && (
|
||||||
<div className="pl-7 py-0.5">
|
<div className="pl-7 py-0.5">
|
||||||
{participants.map((p) => {
|
{displayParticipants.map((p) => {
|
||||||
const isMe = p.identity === currentUserId;
|
const isMe = p.userId === currentUserId;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={p.identity}
|
key={p.userId}
|
||||||
className="text-xxs text-gb-fg-f flex items-center gap-1 group"
|
className="text-xxs text-gb-fg-f flex items-center gap-1 group"
|
||||||
>
|
>
|
||||||
<span className={p.isSpeaking ? 'text-gb-green' : 'text-gb-fg-f'}>
|
<span className={p.isSpeaking ? 'text-gb-green' : 'text-gb-fg-f'}>
|
||||||
@@ -93,12 +100,12 @@ export function VoiceChannel({ channelId, channelName }: VoiceChannelProps) {
|
|||||||
{p.isScreenSharing && <span className="text-gb-aqua" title="Sharing screen">🖥</span>}
|
{p.isScreenSharing && <span className="text-gb-aqua" title="Sharing screen">🖥</span>}
|
||||||
{p.hasVideo && <span className="text-gb-orange" title="Camera on">🎥</span>}
|
{p.hasVideo && <span className="text-gb-orange" title="Camera on">🎥</span>}
|
||||||
|
|
||||||
{!isMe && (
|
{!isMe && isActive && (
|
||||||
<div className="ml-auto opacity-0 group-hover:opacity-100 flex items-center gap-1">
|
<div className="ml-auto opacity-0 group-hover:opacity-100 flex items-center gap-1">
|
||||||
{canMute && !p.isMuted && (
|
{canMute && !p.isMuted && (
|
||||||
<button
|
<button
|
||||||
className="text-gb-fg-f hover:text-gb-red"
|
className="text-gb-fg-f hover:text-gb-red"
|
||||||
onClick={(e) => { e.stopPropagation(); handleMute(p.identity); }}
|
onClick={(e) => { e.stopPropagation(); handleMute(p.userId); }}
|
||||||
title={`Mute ${p.username}`}
|
title={`Mute ${p.username}`}
|
||||||
>
|
>
|
||||||
[mute]
|
[mute]
|
||||||
@@ -106,7 +113,7 @@ export function VoiceChannel({ channelId, channelName }: VoiceChannelProps) {
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
className="text-gb-fg-f hover:text-gb-orange"
|
className="text-gb-fg-f hover:text-gb-orange"
|
||||||
onClick={(e) => { e.stopPropagation(); whisperTo(p.identity, p.username); }}
|
onClick={(e) => { e.stopPropagation(); whisperTo(p.userId, p.username); }}
|
||||||
title={`Whisper to ${p.username}`}
|
title={`Whisper to ${p.username}`}
|
||||||
>
|
>
|
||||||
[whisper]
|
[whisper]
|
||||||
|
|||||||
@@ -1,47 +1,46 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
import { useServerStore } from '../stores/server.ts';
|
import { useServerStore } from '../stores/server.ts';
|
||||||
import { useAuthStore } from '../stores/auth.ts';
|
import { useAuthStore } from '../stores/auth.ts';
|
||||||
import { useRoleStore } from '../stores/role.ts';
|
import { useRoleStore } from '../stores/role.ts';
|
||||||
|
import { PERMS, hasPermission } from '../stores/permissions.ts';
|
||||||
const PERMS = {
|
|
||||||
VIEW_CHANNEL: 1,
|
|
||||||
SEND_MESSAGES: 2,
|
|
||||||
MANAGE_MESSAGES: 4,
|
|
||||||
KICK_MEMBERS: 8,
|
|
||||||
BAN_MEMBERS: 16,
|
|
||||||
MANAGE_SERVER: 32,
|
|
||||||
MANAGE_CHANNELS: 64,
|
|
||||||
ADMINISTRATOR: 128,
|
|
||||||
CONNECT_VOICE: 256,
|
|
||||||
SPEAK_VOICE: 512,
|
|
||||||
SHARE_SCREEN: 1024,
|
|
||||||
MUTE_MEMBERS: 2048,
|
|
||||||
CHANGE_NICKNAME: 8192,
|
|
||||||
MANAGE_NICKNAMES: 16384,
|
|
||||||
MANAGE_ROLES: 32768,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export { PERMS };
|
export { PERMS };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side permission gates for the current user on a server.
|
||||||
|
* Mirrors backend GetUserPermissions: OR of assigned roles + @everyone (is_default).
|
||||||
|
* Owner always passes. Backend remains authoritative.
|
||||||
|
*/
|
||||||
export function usePermissions(serverId: string | null) {
|
export function usePermissions(serverId: string | null) {
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const servers = useServerStore((s) => s.servers);
|
const servers = useServerStore((s) => s.servers);
|
||||||
const roles = useRoleStore((s) => s.roles);
|
const roles = useRoleStore((s) => s.roles);
|
||||||
|
const myRolesByServer = useRoleStore((s) => s.myRolesByServer);
|
||||||
|
|
||||||
const server = serverId ? servers.find((s) => s.id === serverId) : null;
|
const server = serverId ? servers.find((s) => s.id === serverId) : null;
|
||||||
const isOwner = Boolean(server && user && server.owner_id === user.id);
|
const isOwner = Boolean(server && user && server.owner_id === user.id);
|
||||||
|
|
||||||
const memberRoles = roles.filter((r) => r.server_id === serverId);
|
const myRoles = serverId ? myRolesByServer[serverId] || [] : [];
|
||||||
|
const everyoneRole = useMemo(
|
||||||
|
() => (serverId ? roles.find((r) => r.server_id === serverId && r.is_default) : undefined),
|
||||||
|
[roles, serverId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const effective = useMemo(() => {
|
||||||
|
let acc = 0;
|
||||||
|
for (const r of myRoles) acc |= r.permissions;
|
||||||
|
if (everyoneRole) acc |= everyoneRole.permissions;
|
||||||
|
return acc;
|
||||||
|
}, [myRoles, everyoneRole]);
|
||||||
|
|
||||||
const has = useCallback(
|
const has = useCallback(
|
||||||
(flag: number) => {
|
(flag: number) => {
|
||||||
if (!serverId || !user) return false;
|
if (!serverId || !user) return false;
|
||||||
if (isOwner) return true;
|
if (isOwner) return true;
|
||||||
const effective = memberRoles.reduce((acc, r) => acc | r.permissions, 0);
|
if (hasPermission(effective, PERMS.ADMINISTRATOR)) return true;
|
||||||
if ((effective & PERMS.ADMINISTRATOR) !== 0) return true;
|
return hasPermission(effective, flag);
|
||||||
return (effective & flag) === flag;
|
|
||||||
},
|
},
|
||||||
[serverId, user, isOwner, memberRoles],
|
[serverId, user, isOwner, effective],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -53,6 +52,8 @@ export function usePermissions(serverId: string | null) {
|
|||||||
canManageServer: has(PERMS.MANAGE_SERVER),
|
canManageServer: has(PERMS.MANAGE_SERVER),
|
||||||
canChangeNickname: has(PERMS.CHANGE_NICKNAME),
|
canChangeNickname: has(PERMS.CHANGE_NICKNAME),
|
||||||
canManageNicknames: has(PERMS.MANAGE_NICKNAMES),
|
canManageNicknames: has(PERMS.MANAGE_NICKNAMES),
|
||||||
|
canMentionEveryone: has(PERMS.MENTION_EVERYONE),
|
||||||
isOwner,
|
isOwner,
|
||||||
|
effectivePermissions: effective,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-5
@@ -6,6 +6,8 @@ export interface Bot {
|
|||||||
name: string;
|
name: string;
|
||||||
avatar: string | null;
|
avatar: string | null;
|
||||||
description: string;
|
description: string;
|
||||||
|
bot_type: string;
|
||||||
|
config: Record<string, unknown>;
|
||||||
owner_id: string;
|
owner_id: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
@@ -18,14 +20,28 @@ export interface SlashCommand {
|
|||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StoreBot {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
avatar: string | null;
|
||||||
|
description: string;
|
||||||
|
server_count: number;
|
||||||
|
owner_id: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface BotState {
|
interface BotState {
|
||||||
bots: Bot[];
|
bots: Bot[];
|
||||||
|
storeBots: StoreBot[];
|
||||||
|
botTypes: string[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
|
||||||
fetchBots: () => Promise<void>;
|
fetchBots: () => Promise<void>;
|
||||||
createBot: (name: string, description: string) => Promise<Bot & { token: string }>;
|
fetchStoreBots: () => Promise<void>;
|
||||||
updateBot: (id: string, data: { name?: string; description?: string; avatar?: string }) => Promise<Bot>;
|
fetchBotTypes: () => Promise<void>;
|
||||||
|
createBot: (name: string, description: string, botType?: string, config?: Record<string, unknown>) => Promise<Bot & { token: string }>;
|
||||||
|
updateBot: (id: string, data: { name?: string; description?: string; avatar?: string; bot_type?: string; config?: Record<string, unknown> }) => Promise<Bot>;
|
||||||
deleteBot: (id: string) => Promise<void>;
|
deleteBot: (id: string) => Promise<void>;
|
||||||
addToServer: (botId: string, serverId: string) => Promise<void>;
|
addToServer: (botId: string, serverId: string) => Promise<void>;
|
||||||
removeFromServer: (botId: string, serverId: string) => Promise<void>;
|
removeFromServer: (botId: string, serverId: string) => Promise<void>;
|
||||||
@@ -39,6 +55,8 @@ interface BotState {
|
|||||||
|
|
||||||
export const useBotStore = create<BotState>((set) => ({
|
export const useBotStore = create<BotState>((set) => ({
|
||||||
bots: [],
|
bots: [],
|
||||||
|
storeBots: [],
|
||||||
|
botTypes: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
@@ -55,10 +73,37 @@ export const useBotStore = create<BotState>((set) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
createBot: async (name, description) => {
|
fetchStoreBots: async () => {
|
||||||
set({ loading: true, error: null });
|
set({ loading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const result = await api.post<Bot & { token: string }>('/bots', { name, description });
|
const storeBots = await api.get<StoreBot[]>('/bots/store');
|
||||||
|
set({ storeBots, loading: false });
|
||||||
|
} catch (error) {
|
||||||
|
set({
|
||||||
|
loading: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to fetch bot store',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchBotTypes: async () => {
|
||||||
|
try {
|
||||||
|
const types = await api.get<string[]>('/bots/types');
|
||||||
|
set({ botTypes: types });
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
createBot: async (name, description, botType, config) => {
|
||||||
|
set({ loading: true, error: null });
|
||||||
|
try {
|
||||||
|
const result = await api.post<Bot & { token: string }>('/bots', {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
bot_type: botType || '',
|
||||||
|
config: config || {},
|
||||||
|
});
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
bots: [...state.bots, result],
|
bots: [...state.bots, result],
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -189,7 +234,7 @@ export const useBotStore = create<BotState>((set) => ({
|
|||||||
|
|
||||||
fetchServerCommands: async (serverId) => {
|
fetchServerCommands: async (serverId) => {
|
||||||
try {
|
try {
|
||||||
return await api.get<SlashCommand[]>(`/servers/${serverId}/commands`);
|
return await api.get<SlashCommand[]>(`/bots/servers/${serverId}/commands`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({
|
set({
|
||||||
error: error instanceof Error ? error.message : 'Failed to fetch server commands',
|
error: error instanceof Error ? error.message : 'Failed to fetch server commands',
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export interface Member {
|
|||||||
avatar: string;
|
avatar: string;
|
||||||
status: "online" | "idle" | "dnd" | "offline";
|
status: "online" | "idle" | "dnd" | "offline";
|
||||||
status_text: string;
|
status_text: string;
|
||||||
|
is_bot?: boolean;
|
||||||
|
bot_type?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MemberState {
|
interface MemberState {
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ export interface Message {
|
|||||||
author_id: string;
|
author_id: string;
|
||||||
author_username: string;
|
author_username: string;
|
||||||
author_display_name: string | null;
|
author_display_name: string | null;
|
||||||
|
author_bot?: boolean;
|
||||||
|
bot_id?: string | null;
|
||||||
|
bot_name?: string | null;
|
||||||
content: string;
|
content: string;
|
||||||
reply_to?: string | null;
|
reply_to?: string | null;
|
||||||
embeds?: MessageEmbed[];
|
embeds?: MessageEmbed[];
|
||||||
|
|||||||
@@ -54,8 +54,40 @@ export const PERMISSION_LABELS: Record<PermissionKey, string> = {
|
|||||||
MENTION_EVERYONE: 'Mention @everyone',
|
MENTION_EVERYONE: 'Mention @everyone',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Grouped for role/channel permission UIs. ADMINISTRATOR is special-cased in editors. */
|
||||||
|
export const PERM_CATEGORIES: { name: string; keys: PermissionKey[] }[] = [
|
||||||
|
{
|
||||||
|
name: 'General',
|
||||||
|
keys: [
|
||||||
|
'VIEW_CHANNEL',
|
||||||
|
'SEND_MESSAGES',
|
||||||
|
'MANAGE_MESSAGES',
|
||||||
|
'ADD_REACTIONS',
|
||||||
|
'EMBED_LINKS',
|
||||||
|
'ATTACH_FILES',
|
||||||
|
'MENTION_EVERYONE',
|
||||||
|
'USE_EXTERNAL_EMOJIS',
|
||||||
|
'CREATE_INSTANT_INVITE',
|
||||||
|
'CHANGE_NICKNAME',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Moderation',
|
||||||
|
keys: ['KICK_MEMBERS', 'BAN_MEMBERS', 'MUTE_MEMBERS', 'MANAGE_NICKNAMES'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Server',
|
||||||
|
keys: ['MANAGE_SERVER', 'MANAGE_CHANNELS', 'MANAGE_ROLES', 'MANAGE_WEBHOOKS'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Voice',
|
||||||
|
keys: ['CONNECT_VOICE', 'SPEAK_VOICE', 'SHARE_SCREEN'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export function hasPermission(perms: number, flag: number): boolean {
|
export function hasPermission(perms: number, flag: number): boolean {
|
||||||
return (perms & flag) !== 0;
|
// Match backend permissions.Has: all required bits must be present.
|
||||||
|
return (perms & flag) === flag;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChannelOverride {
|
export interface ChannelOverride {
|
||||||
|
|||||||
+53
-5
@@ -27,10 +27,13 @@ export interface UpdateRoleData {
|
|||||||
|
|
||||||
interface RoleState {
|
interface RoleState {
|
||||||
roles: Role[];
|
roles: Role[];
|
||||||
|
/** Assigned roles for the current user, keyed by server id. Does not include @everyone. */
|
||||||
|
myRolesByServer: Record<string, Role[]>;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
|
||||||
fetchRoles: (serverId: string) => Promise<void>;
|
fetchRoles: (serverId: string) => Promise<void>;
|
||||||
|
fetchMyRoles: (serverId: string, userId: string) => Promise<void>;
|
||||||
createRole: (serverId: string, data: CreateRoleData) => Promise<Role>;
|
createRole: (serverId: string, data: CreateRoleData) => Promise<Role>;
|
||||||
updateRole: (serverId: string, roleId: string, data: UpdateRoleData) => Promise<Role>;
|
updateRole: (serverId: string, roleId: string, data: UpdateRoleData) => Promise<Role>;
|
||||||
deleteRole: (serverId: string, roleId: string) => Promise<void>;
|
deleteRole: (serverId: string, roleId: string) => Promise<void>;
|
||||||
@@ -40,6 +43,7 @@ interface RoleState {
|
|||||||
|
|
||||||
export const useRoleStore = create<RoleState>((set) => ({
|
export const useRoleStore = create<RoleState>((set) => ({
|
||||||
roles: [],
|
roles: [],
|
||||||
|
myRolesByServer: {},
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
@@ -47,7 +51,14 @@ export const useRoleStore = create<RoleState>((set) => ({
|
|||||||
set({ loading: true, error: null });
|
set({ loading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const roles = await api.get<Role[]>(`/servers/${serverId}/roles`);
|
const roles = await api.get<Role[]>(`/servers/${serverId}/roles`);
|
||||||
set({ roles: roles.sort((a, b) => b.position - a.position), loading: false });
|
set((state) => ({
|
||||||
|
// Keep roles from other servers if mixed; replace same-server entries.
|
||||||
|
roles: [
|
||||||
|
...state.roles.filter((r) => r.server_id !== serverId),
|
||||||
|
...(Array.isArray(roles) ? roles : []),
|
||||||
|
].sort((a, b) => b.position - a.position),
|
||||||
|
loading: false,
|
||||||
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({
|
set({
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -56,6 +67,25 @@ export const useRoleStore = create<RoleState>((set) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
fetchMyRoles: async (serverId, userId) => {
|
||||||
|
try {
|
||||||
|
const roles = await api.get<Role[]>(`/servers/${serverId}/members/${userId}/roles`);
|
||||||
|
set((state) => ({
|
||||||
|
myRolesByServer: {
|
||||||
|
...state.myRolesByServer,
|
||||||
|
[serverId]: Array.isArray(roles) ? roles : [],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
set((state) => ({
|
||||||
|
myRolesByServer: {
|
||||||
|
...state.myRolesByServer,
|
||||||
|
[serverId]: [],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
createRole: async (serverId, data) => {
|
createRole: async (serverId, data) => {
|
||||||
set({ loading: true, error: null });
|
set({ loading: true, error: null });
|
||||||
try {
|
try {
|
||||||
@@ -78,10 +108,19 @@ export const useRoleStore = create<RoleState>((set) => ({
|
|||||||
set({ loading: true, error: null });
|
set({ loading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const role = await api.patch<Role>(`/servers/${serverId}/roles/${roleId}`, data);
|
const role = await api.patch<Role>(`/servers/${serverId}/roles/${roleId}`, data);
|
||||||
set((state) => ({
|
set((state) => {
|
||||||
|
const myRoles = state.myRolesByServer[serverId];
|
||||||
|
const nextMy = myRoles
|
||||||
|
? myRoles.map((r) => (r.id === roleId ? role : r))
|
||||||
|
: myRoles;
|
||||||
|
return {
|
||||||
roles: state.roles.map((r) => (r.id === roleId ? role : r)).sort((a, b) => b.position - a.position),
|
roles: state.roles.map((r) => (r.id === roleId ? role : r)).sort((a, b) => b.position - a.position),
|
||||||
|
myRolesByServer: nextMy
|
||||||
|
? { ...state.myRolesByServer, [serverId]: nextMy }
|
||||||
|
: state.myRolesByServer,
|
||||||
loading: false,
|
loading: false,
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
return role;
|
return role;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({
|
set({
|
||||||
@@ -96,10 +135,19 @@ export const useRoleStore = create<RoleState>((set) => ({
|
|||||||
set({ loading: true, error: null });
|
set({ loading: true, error: null });
|
||||||
try {
|
try {
|
||||||
await api.delete(`/servers/${serverId}/roles/${roleId}`);
|
await api.delete(`/servers/${serverId}/roles/${roleId}`);
|
||||||
set((state) => ({
|
set((state) => {
|
||||||
|
const myRoles = state.myRolesByServer[serverId];
|
||||||
|
return {
|
||||||
roles: state.roles.filter((r) => r.id !== roleId),
|
roles: state.roles.filter((r) => r.id !== roleId),
|
||||||
|
myRolesByServer: myRoles
|
||||||
|
? {
|
||||||
|
...state.myRolesByServer,
|
||||||
|
[serverId]: myRoles.filter((r) => r.id !== roleId),
|
||||||
|
}
|
||||||
|
: state.myRolesByServer,
|
||||||
loading: false,
|
loading: false,
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({
|
set({
|
||||||
loading: false,
|
loading: false,
|
||||||
|
|||||||
+72
-1
@@ -64,6 +64,42 @@ interface VoiceState {
|
|||||||
dismissWhisper: (timestamp: number) => void;
|
dismissWhisper: (timestamp: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEVICE_PREFS_KEY = 'dc_voice_device_prefs';
|
||||||
|
|
||||||
|
interface DevicePrefs {
|
||||||
|
audioInput?: string;
|
||||||
|
audioOutput?: string;
|
||||||
|
videoInput?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadDevicePrefs(): DevicePrefs {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(DEVICE_PREFS_KEY) || '{}');
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveDevicePrefs(prefs: DevicePrefs) {
|
||||||
|
localStorage.setItem(DEVICE_PREFS_KEY, JSON.stringify(prefs));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveDevicePref(kind: MediaDeviceKind, deviceId: string) {
|
||||||
|
const prefs = loadDevicePrefs();
|
||||||
|
if (kind === 'audioinput') prefs.audioInput = deviceId;
|
||||||
|
else if (kind === 'audiooutput') prefs.audioOutput = deviceId;
|
||||||
|
else if (kind === 'videoinput') prefs.videoInput = deviceId;
|
||||||
|
saveDevicePrefs(prefs);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSavedDeviceId(kind: MediaDeviceKind): string | undefined {
|
||||||
|
const prefs = loadDevicePrefs();
|
||||||
|
if (kind === 'audioinput') return prefs.audioInput;
|
||||||
|
if (kind === 'audiooutput') return prefs.audioOutput;
|
||||||
|
if (kind === 'videoinput') return prefs.videoInput;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function participantToVoice(p: Participant): VoiceParticipant {
|
function participantToVoice(p: Participant): VoiceParticipant {
|
||||||
const micPub = p.getTrackPublication(Track.Source.Microphone);
|
const micPub = p.getTrackPublication(Track.Source.Microphone);
|
||||||
const camPub = p.getTrackPublication(Track.Source.Camera);
|
const camPub = p.getTrackPublication(Track.Source.Camera);
|
||||||
@@ -138,7 +174,8 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
|
|||||||
syncParticipants();
|
syncParticipants();
|
||||||
});
|
});
|
||||||
|
|
||||||
room.on(RoomEvent.Disconnected, () => {
|
room.on(RoomEvent.Disconnected, (reason?: any) => {
|
||||||
|
console.warn('[voice] disconnected, reason:', reason);
|
||||||
set({
|
set({
|
||||||
isConnected: false,
|
isConnected: false,
|
||||||
currentRoom: null,
|
currentRoom: null,
|
||||||
@@ -151,6 +188,30 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
room.on(RoomEvent.Reconnecting, () => {
|
||||||
|
console.warn('[voice] reconnecting to LiveKit...');
|
||||||
|
set({ error: 'Reconnecting...' });
|
||||||
|
});
|
||||||
|
|
||||||
|
room.on(RoomEvent.Reconnected, () => {
|
||||||
|
console.log('[voice] reconnected to LiveKit');
|
||||||
|
set({ error: null });
|
||||||
|
syncParticipants();
|
||||||
|
});
|
||||||
|
|
||||||
|
room.on(RoomEvent.SignalConnected, () => {
|
||||||
|
console.log('[voice] signal channel connected');
|
||||||
|
});
|
||||||
|
|
||||||
|
room.on(RoomEvent.ConnectionQualityChanged, (_quality: any, participant: Participant) => {
|
||||||
|
if (participant.identity === room.localParticipant.identity) {
|
||||||
|
// if quality drops to poor, log it
|
||||||
|
if (_quality === 'poor') {
|
||||||
|
console.warn('[voice] poor connection quality');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
room.on(RoomEvent.ParticipantConnected, () => {
|
room.on(RoomEvent.ParticipantConnected, () => {
|
||||||
syncParticipants();
|
syncParticipants();
|
||||||
});
|
});
|
||||||
@@ -215,6 +276,16 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
|
|||||||
console.warn("[voice] failed to set initial mute state:", micErr);
|
console.warn("[voice] failed to set initial mute state:", micErr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restore saved device preferences
|
||||||
|
const prefs = loadDevicePrefs();
|
||||||
|
try {
|
||||||
|
if (prefs.audioInput) await room.switchActiveDevice('audioinput', prefs.audioInput);
|
||||||
|
if (prefs.audioOutput) await room.switchActiveDevice('audiooutput', prefs.audioOutput);
|
||||||
|
if (prefs.videoInput) await room.switchActiveDevice('videoinput', prefs.videoInput);
|
||||||
|
} catch (devErr) {
|
||||||
|
console.warn("[voice] failed to restore saved devices:", devErr);
|
||||||
|
}
|
||||||
|
|
||||||
set({ isJoining: false });
|
set({ isJoining: false });
|
||||||
|
|
||||||
// Notify WebSocket peers
|
// Notify WebSocket peers
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
export interface VoicePresenceEntry {
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
channelId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VoicePresenceState {
|
||||||
|
// channelId -> userId -> entry
|
||||||
|
presence: Record<string, Record<string, VoicePresenceEntry>>;
|
||||||
|
_handleJoin: (entry: VoicePresenceEntry) => void;
|
||||||
|
_handleLeave: (userId: string, channelId: string) => void;
|
||||||
|
getParticipants: (channelId: string) => VoicePresenceEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useVoicePresenceStore = create<VoicePresenceState>((set, get) => ({
|
||||||
|
presence: {},
|
||||||
|
|
||||||
|
_handleJoin: (entry) => {
|
||||||
|
set((state) => {
|
||||||
|
const room = { ...(state.presence[entry.channelId] || {}) };
|
||||||
|
room[entry.userId] = entry;
|
||||||
|
return { presence: { ...state.presence, [entry.channelId]: room } };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_handleLeave: (userId, channelId) => {
|
||||||
|
set((state) => {
|
||||||
|
const room = { ...(state.presence[channelId] || {}) };
|
||||||
|
delete room[userId];
|
||||||
|
const next = { ...state.presence, [channelId]: room };
|
||||||
|
if (Object.keys(room).length === 0) delete next[channelId];
|
||||||
|
return { presence: next };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getParticipants: (channelId) => {
|
||||||
|
const room = get().presence[channelId];
|
||||||
|
return room ? Object.values(room) : [];
|
||||||
|
},
|
||||||
|
}));
|
||||||
+30
-3
@@ -5,6 +5,7 @@ import { useServerStore } from './server.ts';
|
|||||||
import { usePresenceStore } from './presence.ts';
|
import { usePresenceStore } from './presence.ts';
|
||||||
import { useTypingStore } from './typing.ts';
|
import { useTypingStore } from './typing.ts';
|
||||||
import { useVoiceStore } from './voice.ts';
|
import { useVoiceStore } from './voice.ts';
|
||||||
|
import { useVoicePresenceStore } from './voicePresence.ts';
|
||||||
import { useAuthStore } from './auth.ts';
|
import { useAuthStore } from './auth.ts';
|
||||||
import { useConversationStore } from './conversation.ts';
|
import { useConversationStore } from './conversation.ts';
|
||||||
import { useReadStatesStore } from './readStates.ts';
|
import { useReadStatesStore } from './readStates.ts';
|
||||||
@@ -51,9 +52,17 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function extractIds(payload: UnknownPayload | undefined): { channel_id?: string; conversation_id?: string; message_id: string } | null {
|
function extractIds(payload: UnknownPayload | undefined): { channel_id?: string; conversation_id?: string; message_id: string } | null {
|
||||||
if (!payload || typeof payload.message_id !== 'string') return null;
|
if (!payload) return null;
|
||||||
if (typeof payload.channel_id === 'string') return { channel_id: payload.channel_id, message_id: payload.message_id };
|
// Backend MESSAGE_DELETE uses "id"; some other events use "message_id".
|
||||||
if (typeof payload.conversation_id === 'string') return { conversation_id: payload.conversation_id, message_id: payload.message_id };
|
const messageId =
|
||||||
|
typeof payload.message_id === 'string'
|
||||||
|
? payload.message_id
|
||||||
|
: typeof payload.id === 'string'
|
||||||
|
? payload.id
|
||||||
|
: null;
|
||||||
|
if (!messageId) return null;
|
||||||
|
if (typeof payload.channel_id === 'string') return { channel_id: payload.channel_id, message_id: messageId };
|
||||||
|
if (typeof payload.conversation_id === 'string') return { conversation_id: payload.conversation_id, message_id: messageId };
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,6 +283,24 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'VOICE_JOIN': {
|
||||||
|
const { room_name, user_id, username } = payload as Record<string, string>;
|
||||||
|
if (room_name && user_id && username) {
|
||||||
|
useVoicePresenceStore.getState()._handleJoin({
|
||||||
|
userId: user_id,
|
||||||
|
username,
|
||||||
|
channelId: room_name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'VOICE_LEAVE': {
|
||||||
|
const { room_name, user_id } = payload as Record<string, string>;
|
||||||
|
if (room_name && user_id) {
|
||||||
|
useVoicePresenceStore.getState()._handleLeave(user_id, room_name);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
+183
-5
@@ -2,8 +2,10 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* Theme token sets — applied via html[data-theme="..."]. Default = gruvbox. */
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root,
|
||||||
|
[data-theme="gruvbox"] {
|
||||||
--gb-bg-h: #1d2021;
|
--gb-bg-h: #1d2021;
|
||||||
--gb-bg: #282828;
|
--gb-bg: #282828;
|
||||||
--gb-bg-s: #3c3836;
|
--gb-bg-s: #3c3836;
|
||||||
@@ -24,8 +26,7 @@
|
|||||||
--gb-border: #665c54;
|
--gb-border: #665c54;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Light mode overrides */
|
[data-theme="gruvbox-light"] {
|
||||||
.light {
|
|
||||||
--gb-bg-h: #f9f5d7;
|
--gb-bg-h: #f9f5d7;
|
||||||
--gb-bg: #fbf1c7;
|
--gb-bg: #fbf1c7;
|
||||||
--gb-bg-s: #ebdbb2;
|
--gb-bg-s: #ebdbb2;
|
||||||
@@ -35,9 +36,187 @@
|
|||||||
--gb-fg-s: #3c3836;
|
--gb-fg-s: #3c3836;
|
||||||
--gb-fg-t: #504945;
|
--gb-fg-t: #504945;
|
||||||
--gb-fg-f: #7c6f64;
|
--gb-fg-f: #7c6f64;
|
||||||
|
--gb-red: #9d0006;
|
||||||
|
--gb-green: #79740e;
|
||||||
|
--gb-yellow: #b57614;
|
||||||
|
--gb-blue: #076678;
|
||||||
|
--gb-purple: #8f3f71;
|
||||||
|
--gb-aqua: #427b58;
|
||||||
|
--gb-orange: #af3a03;
|
||||||
|
--gb-gray: #928374;
|
||||||
--gb-border: #a89984;
|
--gb-border: #a89984;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Atom / VS Code One Dark Pro */
|
||||||
|
[data-theme="one-dark"] {
|
||||||
|
--gb-bg-h: #1e2127;
|
||||||
|
--gb-bg: #282c34;
|
||||||
|
--gb-bg-s: #21252b;
|
||||||
|
--gb-bg-t: #3e4451;
|
||||||
|
--gb-bg-f: #2c313a;
|
||||||
|
--gb-fg: #abb2bf;
|
||||||
|
--gb-fg-s: #9da5b4;
|
||||||
|
--gb-fg-t: #828997;
|
||||||
|
--gb-fg-f: #5c6370;
|
||||||
|
--gb-red: #e06c75;
|
||||||
|
--gb-green: #98c379;
|
||||||
|
--gb-yellow: #e5c07b;
|
||||||
|
--gb-blue: #61afef;
|
||||||
|
--gb-purple: #c678dd;
|
||||||
|
--gb-aqua: #56b6c2;
|
||||||
|
--gb-orange: #d19a66;
|
||||||
|
--gb-gray: #5c6370;
|
||||||
|
--gb-border: #3e4451;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dracula"] {
|
||||||
|
--gb-bg-h: #191a21;
|
||||||
|
--gb-bg: #282a36;
|
||||||
|
--gb-bg-s: #21222c;
|
||||||
|
--gb-bg-t: #44475a;
|
||||||
|
--gb-bg-f: #343746;
|
||||||
|
--gb-fg: #f8f8f2;
|
||||||
|
--gb-fg-s: #e2e2dc;
|
||||||
|
--gb-fg-t: #cfcfc2;
|
||||||
|
--gb-fg-f: #6272a4;
|
||||||
|
--gb-red: #ff5555;
|
||||||
|
--gb-green: #50fa7b;
|
||||||
|
--gb-yellow: #f1fa8c;
|
||||||
|
--gb-blue: #8be9fd;
|
||||||
|
--gb-purple: #bd93f9;
|
||||||
|
--gb-aqua: #8be9fd;
|
||||||
|
--gb-orange: #ffb86c;
|
||||||
|
--gb-gray: #6272a4;
|
||||||
|
--gb-border: #44475a;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="nord"] {
|
||||||
|
--gb-bg-h: #242933;
|
||||||
|
--gb-bg: #2e3440;
|
||||||
|
--gb-bg-s: #3b4252;
|
||||||
|
--gb-bg-t: #434c5e;
|
||||||
|
--gb-bg-f: #3b4252;
|
||||||
|
--gb-fg: #d8dee9;
|
||||||
|
--gb-fg-s: #e5e9f0;
|
||||||
|
--gb-fg-t: #eceff4;
|
||||||
|
--gb-fg-f: #4c566a;
|
||||||
|
--gb-red: #bf616a;
|
||||||
|
--gb-green: #a3be8c;
|
||||||
|
--gb-yellow: #ebcb8b;
|
||||||
|
--gb-blue: #81a1c1;
|
||||||
|
--gb-purple: #b48ead;
|
||||||
|
--gb-aqua: #88c0d0;
|
||||||
|
--gb-orange: #d08770;
|
||||||
|
--gb-gray: #616e88;
|
||||||
|
--gb-border: #4c566a;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="tokyo-night"] {
|
||||||
|
--gb-bg-h: #0f0f14;
|
||||||
|
--gb-bg: #1a1b26;
|
||||||
|
--gb-bg-s: #16161e;
|
||||||
|
--gb-bg-t: #292e42;
|
||||||
|
--gb-bg-f: #1f2335;
|
||||||
|
--gb-fg: #c0caf5;
|
||||||
|
--gb-fg-s: #a9b1d6;
|
||||||
|
--gb-fg-t: #9aa5ce;
|
||||||
|
--gb-fg-f: #565f89;
|
||||||
|
--gb-red: #f7768e;
|
||||||
|
--gb-green: #9ece6a;
|
||||||
|
--gb-yellow: #e0af68;
|
||||||
|
--gb-blue: #7aa2f7;
|
||||||
|
--gb-purple: #bb9af7;
|
||||||
|
--gb-aqua: #7dcfff;
|
||||||
|
--gb-orange: #ff9e64;
|
||||||
|
--gb-gray: #565f89;
|
||||||
|
--gb-border: #292e42;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="catppuccin"] {
|
||||||
|
--gb-bg-h: #11111b;
|
||||||
|
--gb-bg: #1e1e2e;
|
||||||
|
--gb-bg-s: #181825;
|
||||||
|
--gb-bg-t: #313244;
|
||||||
|
--gb-bg-f: #24273a;
|
||||||
|
--gb-fg: #cdd6f4;
|
||||||
|
--gb-fg-s: #bac2de;
|
||||||
|
--gb-fg-t: #a6adc8;
|
||||||
|
--gb-fg-f: #6c7086;
|
||||||
|
--gb-red: #f38ba8;
|
||||||
|
--gb-green: #a6e3a1;
|
||||||
|
--gb-yellow: #f9e2af;
|
||||||
|
--gb-blue: #89b4fa;
|
||||||
|
--gb-purple: #cba6f7;
|
||||||
|
--gb-aqua: #94e2d5;
|
||||||
|
--gb-orange: #fab387;
|
||||||
|
--gb-gray: #6c7086;
|
||||||
|
--gb-border: #313244;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="solarized-dark"] {
|
||||||
|
--gb-bg-h: #001f27;
|
||||||
|
--gb-bg: #002b36;
|
||||||
|
--gb-bg-s: #073642;
|
||||||
|
--gb-bg-t: #094959;
|
||||||
|
--gb-bg-f: #073642;
|
||||||
|
--gb-fg: #839496;
|
||||||
|
--gb-fg-s: #93a1a1;
|
||||||
|
--gb-fg-t: #eee8d5;
|
||||||
|
--gb-fg-f: #657b83;
|
||||||
|
--gb-red: #dc322f;
|
||||||
|
--gb-green: #859900;
|
||||||
|
--gb-yellow: #b58900;
|
||||||
|
--gb-blue: #268bd2;
|
||||||
|
--gb-purple: #d33682;
|
||||||
|
--gb-aqua: #2aa198;
|
||||||
|
--gb-orange: #cb4b16;
|
||||||
|
--gb-gray: #586e75;
|
||||||
|
--gb-border: #586e75;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="monokai"] {
|
||||||
|
--gb-bg-h: #1a1b16;
|
||||||
|
--gb-bg: #272822;
|
||||||
|
--gb-bg-s: #1e1f1c;
|
||||||
|
--gb-bg-t: #3e3d32;
|
||||||
|
--gb-bg-f: #2d2e27;
|
||||||
|
--gb-fg: #f8f8f2;
|
||||||
|
--gb-fg-s: #e6e6e0;
|
||||||
|
--gb-fg-t: #cfcfc2;
|
||||||
|
--gb-fg-f: #75715e;
|
||||||
|
--gb-red: #f92672;
|
||||||
|
--gb-green: #a6e22e;
|
||||||
|
--gb-yellow: #e6db74;
|
||||||
|
--gb-blue: #66d9ef;
|
||||||
|
--gb-purple: #ae81ff;
|
||||||
|
--gb-aqua: #a1efe4;
|
||||||
|
--gb-orange: #fd971f;
|
||||||
|
--gb-gray: #75715e;
|
||||||
|
--gb-border: #49483e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* VS Code / GitHub Dark default-ish */
|
||||||
|
[data-theme="github-dark"] {
|
||||||
|
--gb-bg-h: #010409;
|
||||||
|
--gb-bg: #0d1117;
|
||||||
|
--gb-bg-s: #161b22;
|
||||||
|
--gb-bg-t: #30363d;
|
||||||
|
--gb-bg-f: #21262d;
|
||||||
|
--gb-fg: #e6edf3;
|
||||||
|
--gb-fg-s: #c9d1d9;
|
||||||
|
--gb-fg-t: #8b949e;
|
||||||
|
--gb-fg-f: #6e7681;
|
||||||
|
--gb-red: #f85149;
|
||||||
|
--gb-green: #3fb950;
|
||||||
|
--gb-yellow: #d29922;
|
||||||
|
--gb-blue: #58a6ff;
|
||||||
|
--gb-purple: #bc8cff;
|
||||||
|
--gb-aqua: #39c5cf;
|
||||||
|
--gb-orange: #db6d28;
|
||||||
|
--gb-gray: #8b949e;
|
||||||
|
--gb-border: #30363d;
|
||||||
|
}
|
||||||
|
|
||||||
html, body, #root {
|
html, body, #root {
|
||||||
@apply h-full w-full bg-gb-bg text-gb-fg font-mono;
|
@apply h-full w-full bg-gb-bg text-gb-fg font-mono;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -50,7 +229,6 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Thin themed scrollbar for chat area */
|
|
||||||
*::-webkit-scrollbar {
|
*::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
@@ -98,7 +276,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.terminal-mention {
|
.terminal-mention {
|
||||||
background-color: rgba(142, 192, 124, 0.12);
|
background-color: color-mix(in srgb, var(--gb-aqua) 12%, transparent);
|
||||||
color: var(--gb-aqua);
|
color: var(--gb-aqua);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-27
@@ -8,33 +8,25 @@ export default {
|
|||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
'gb-bg-h': '#1d2021',
|
// ponytail: CSS vars so data-theme palettes apply without rewriting components
|
||||||
'gb-bg': '#282828',
|
'gb-bg-h': 'var(--gb-bg-h)',
|
||||||
'gb-bg-s': '#3c3836',
|
'gb-bg': 'var(--gb-bg)',
|
||||||
'gb-bg-t': '#504945',
|
'gb-bg-s': 'var(--gb-bg-s)',
|
||||||
'gb-bg-f': '#32302f',
|
'gb-bg-t': 'var(--gb-bg-t)',
|
||||||
'gb-fg': '#ebdbb2',
|
'gb-bg-f': 'var(--gb-bg-f)',
|
||||||
'gb-fg-s': '#d5c4a1',
|
'gb-fg': 'var(--gb-fg)',
|
||||||
'gb-fg-t': '#bdae93',
|
'gb-fg-s': 'var(--gb-fg-s)',
|
||||||
'gb-fg-f': '#a89984',
|
'gb-fg-t': 'var(--gb-fg-t)',
|
||||||
'gb-red': '#fb4934',
|
'gb-fg-f': 'var(--gb-fg-f)',
|
||||||
'gb-green': '#b8bb26',
|
'gb-red': 'var(--gb-red)',
|
||||||
'gb-yellow': '#fabd2f',
|
'gb-green': 'var(--gb-green)',
|
||||||
'gb-blue': '#83a598',
|
'gb-yellow': 'var(--gb-yellow)',
|
||||||
'gb-purple': '#d3869b',
|
'gb-blue': 'var(--gb-blue)',
|
||||||
'gb-aqua': '#8ec07c',
|
'gb-purple': 'var(--gb-purple)',
|
||||||
'gb-orange': '#fe8019',
|
'gb-aqua': 'var(--gb-aqua)',
|
||||||
'gb-gray': '#928374',
|
'gb-orange': 'var(--gb-orange)',
|
||||||
// Light mode overrides
|
'gb-gray': 'var(--gb-gray)',
|
||||||
'gb-light-bg-h': '#f9f5d7',
|
'gb-border': 'var(--gb-border)',
|
||||||
'gb-light-bg': '#fbf1c7',
|
|
||||||
'gb-light-bg-s': '#ebdbb2',
|
|
||||||
'gb-light-bg-t': '#d5c4a1',
|
|
||||||
'gb-light-bg-f': '#bdae93',
|
|
||||||
'gb-light-fg': '#282828',
|
|
||||||
'gb-light-fg-s': '#3c3836',
|
|
||||||
'gb-light-fg-t': '#504945',
|
|
||||||
'gb-light-fg-f': '#7c6f64',
|
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
mono: ['"JetBrains Mono"', '"Fira Code"', '"Cascadia Code"', '"SF Mono"', 'Consolas', '"Liberation Mono"', 'monospace'],
|
mono: ['"JetBrains Mono"', '"Fira Code"', '"Cascadia Code"', '"SF Mono"', 'Consolas', '"Liberation Mono"', 'monospace'],
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/AudioRenderers.tsx","./src/components/BotManager.tsx","./src/components/CalendarView.tsx","./src/components/ChannelList.tsx","./src/components/ChannelSettingsModal.tsx","./src/components/ChatArea.tsx","./src/components/CommandDropdown.tsx","./src/components/CommandManager.tsx","./src/components/ConnectionStatus.tsx","./src/components/ContextMenu.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/DeviceSettingsModal.tsx","./src/components/DocsView.tsx","./src/components/EmojiPicker.tsx","./src/components/ExpandableImage.tsx","./src/components/FeatureRequestsPanel.tsx","./src/components/ForgotPasswordPage.tsx","./src/components/FormatToolbar.tsx","./src/components/ForumView.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallBanner.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/JoinServerModal.tsx","./src/components/Layout.tsx","./src/components/ListView.tsx","./src/components/LoginForm.tsx","./src/components/MemberContextMenu.tsx","./src/components/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionDropdown.tsx","./src/components/MentionPopup.tsx","./src/components/MessageInput.tsx","./src/components/MessageSearch.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/NewConversationModal.tsx","./src/components/NotificationPrompt.tsx","./src/components/PinnedMessages.tsx","./src/components/Poll.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/ResetPasswordPage.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/ServerSettingsModal.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/ThreadListPanel.tsx","./src/components/ThreadPanel.tsx","./src/components/TypingIndicator.tsx","./src/components/UserProfileModal.tsx","./src/components/UserSettings.tsx","./src/components/VideoGrid.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/lib/kaomojiData.ts","./src/lib/slashCommands.ts","./src/lib/usePermissions.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/conversation.ts","./src/stores/featureRequest.ts","./src/stores/layout.ts","./src/stores/member.ts","./src/stores/message.ts","./src/stores/moderation.ts","./src/stores/notificationSettings.ts","./src/stores/permissions.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/readStates.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/thread.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"}
|
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/AudioRenderers.tsx","./src/components/BotManager.tsx","./src/components/BotStore.tsx","./src/components/CalendarView.tsx","./src/components/ChannelList.tsx","./src/components/ChannelSettingsModal.tsx","./src/components/ChatArea.tsx","./src/components/CommandDropdown.tsx","./src/components/CommandManager.tsx","./src/components/ConnectionStatus.tsx","./src/components/ContextMenu.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/DeviceSettingsModal.tsx","./src/components/DocsView.tsx","./src/components/EmojiPicker.tsx","./src/components/ExpandableImage.tsx","./src/components/FeatureRequestsPanel.tsx","./src/components/ForgotPasswordPage.tsx","./src/components/FormatToolbar.tsx","./src/components/ForumView.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallBanner.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/JoinServerModal.tsx","./src/components/Layout.tsx","./src/components/ListView.tsx","./src/components/LoginForm.tsx","./src/components/MemberContextMenu.tsx","./src/components/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionDropdown.tsx","./src/components/MentionPopup.tsx","./src/components/MessageInput.tsx","./src/components/MessageSearch.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/NewConversationModal.tsx","./src/components/NotificationPrompt.tsx","./src/components/PinnedMessages.tsx","./src/components/Poll.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/ResetPasswordPage.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/ServerSettingsModal.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/ThreadListPanel.tsx","./src/components/ThreadPanel.tsx","./src/components/TypingIndicator.tsx","./src/components/UserProfileModal.tsx","./src/components/UserSettings.tsx","./src/components/VideoGrid.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/lib/kaomojiData.ts","./src/lib/slashCommands.ts","./src/lib/usePermissions.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/conversation.ts","./src/stores/featureRequest.ts","./src/stores/layout.ts","./src/stores/member.ts","./src/stores/message.ts","./src/stores/moderation.ts","./src/stores/notificationSettings.ts","./src/stores/permissions.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/readStates.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/thread.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/voicePresence.ts","./src/stores/ws.ts"],"version":"5.9.3"}
|
||||||
Reference in New Issue
Block a user