Compare commits

..

36 Commits

Author SHA1 Message Date
hobokenchicken 3e343a9c9b chore(ci): add rust and npm caching to speed up builds
Release Desktop Apps / build-linux (push) Successful in 5m29s
Release Desktop Apps / build-windows (push) Successful in 3h31m6s
Release Desktop Apps / release (push) Failing after 3s
2026-07-16 11:02:23 -04:00
hobokenchicken 9371616508 fix(ci): downgrade artifact actions to v3 for gitea compatibility 2026-07-16 10:46:54 -04:00
hobokenchicken 5951c91102 fix(ci): use curl and CI=true to prevent Windows runner hang 2026-07-16 10:33:46 -04:00
hobokenchicken ff431d7f81 added rpm to apt get list 2026-07-16 14:08:08 +00:00
hobokenchicken aa5fdbe8c4 fix: message area scroll — add min-h-0 to flex column 2026-07-16 09:23:08 -04:00
hobokenchicken 4da08d91bc feat(ui): Discord-style roles/channel perms + IDE themes
Split-pane role editor with tri-state channel overrides (roles/members).
CSS-var themes (Gruvbox default + 9 IDE palettes) in top bar and settings.
2026-07-15 21:46:07 -04:00
hobokenchicken 11b1089126 fix: client perms, @everyone/@channel, docs, unit tests
- usePermissions ORs current user roles + @everyone only (not all server roles)
- cache myRolesByServer; load on active server; refresh after self role edit
- gate/notify @everyone and @channel; plain @username push; special mention UI
- refresh FEATURE_PARITY (DMs exist; drop stale critical gaps)
- README production deploy notes dumpster.service
- unit tests for permission bits and broadcast mention tokens
2026-07-15 20:56:53 -04:00
hobokenchicken fd7fa4a147 feat(ui): BOTS section in member list
Members API appends server bots (is_bot). Sidebar groups ONLINE / OFFLINE / BOTS.
Bots get green BOT badge, no kick menu or profile. Mentions and DMs skip bots.
2026-07-15 20:37:05 -04:00
hobokenchicken 7bf1eaf845 fix(bots): intercept /confess so original never hits chat
Root cause of "not anonymous":
1. Confess deleted via raw SQL with no MESSAGE_DELETE broadcast
2. Frontend extractIds only accepted message_id, but deletes send id
   so live clients never removed deleted messages without refresh

Fix:
- Intercept /confess at message create: never store or broadcast the
  original; post only the anonymous bot message
- Accept both id and message_id on MESSAGE_DELETE in the WS store
- Include both fields on delete broadcasts
2026-07-15 20:28:47 -04:00
hobokenchicken 13bd4478f6 fix(bots): auto-join server from channel_id so built-ins can post
Root cause: makeSender requires bot_servers membership, but create
flow never auto-added bots when users only picked a channel.

- Start() resolves config.channel_id → server and upserts bot_servers
- Confess cursor uses (created_at,id) so deletes don't stall polling
2026-07-15 20:17:23 -04:00
hobokenchicken 53530ce6dd feat(bots): anonConfess + shitpostLeaderboard built-in bots
- ConfessBot: polls for /confess messages, deletes original, reposts anonymous
- LeaderboardBot: daily top-10 message count recap from DB
- BotFunc extended with *sql.DB param for DB-reading bots
- Both types registered in runner + BotManager UI
2026-07-15 19:46:09 -04:00
hobokenchicken f215f000b8 fix(pwa): replace hamburger overlays with proper mobile bottom nav
- MobileBottomNav: [SERVERS] [CHAT] [MEMBERS] tab bar, always visible
- Servers tab opens sidebar overlay, chat/members switch views
- Removed hamburger + members toggle from mobile top bar
- Top bar compact on mobile (no redundant buttons)
- safe-area-inset-bottom on nav, clean inset on frame
- Desktop status bar hidden on mobile, preserved on desktop
- Dead MobileNav/MobileDrawer left in place (unused, can prune later)
2026-07-15 17:21:10 -04:00
hobokenchicken 191fe2a89f docs: update README for bot store, built-in runner, steamfree 2026-07-15 15:40:22 -04:00
hobokenchicken eb5f38de1c fix(bots): channel picker dropdown instead of ID text input
Server selector + channel dropdown for built-in bot config.
No more asking users to paste UUIDs.
2026-07-15 15:02:39 -04:00
hobokenchicken 51eb2ed310 chore: add steamfree binary to gitignore 2026-07-15 14:21:51 -04:00
hobokenchicken bd73d79b56 chore: remove committed binary 2026-07-15 14:16:57 -04:00
hobokenchicken 4b32655e67 feat(bots): built-in bot runner + steamfree from UI
- BotRunner: server-side goroutine manager for built-in bot types
- steamfree bot embedded in server (polls Steam API, posts free games)
- bot_type + config JSONB columns on bots table
- Create/Update/Delete handlers manage runner lifecycle
- GET /bots/types returns registered bot types
- BotManager: type selector dropdown + config fields on create
- No SSH needed: create a 'Steam Free Games' bot from /bots/manage
2026-07-15 14:14:02 -04:00
hobokenchicken c839e67c47 feat(bot): SteamFree bot — posts free Steam games
Polls Steam featured categories API every 30m, filters for 100%
discounts on games that had a real price, posts new finds to the
configured channel. Env: BOT_TOKEN, DUMPSTER_HOST, CHANNEL_ID,
POLL_MINUTES.
2026-07-15 13:05:28 -04:00
hobokenchicken 5bdb758d23 feat(bots): bot framework polish + store
- /ws/bot endpoint: bot token auth via query param, SHA-256 lookup
- Bot WS actions: SEND_MESSAGE + DELETE_MESSAGE handled in gateway
- Bot messages: bot_id on messages table, bot badge in chat (green + BOT tag)
- Bot store: /bots lists all bots with server count + add-to-server
- Bot manager moved to /bots/manage
- Fix: command routes were double-nested under /bots/{botID}/commands
- Fix: fetchServerCommands route corrected to /bots/servers/...
2026-07-15 12:45:44 -04:00
hobokenchicken 56af584ede fix: register forum/thread routes at top level + notification dots in ServerBar
Moved thread and forum-tag routes from nested /servers/{sid}/channels/ to
top-level /channels/{id}/... to match frontend API calls. Forum posts were
getting SPA HTML fallback instead of JSON.

Added orange notification dots to DM and server buttons in ServerBar.
2026-07-10 09:45:37 -04:00
hobokenchicken 87d7345155 feat(ui): character counter in message compose box 2026-07-09 16:00:28 -04:00
hobokenchicken a7646481a4 fix(ui): textarea auto-expands instead of scrolling 2026-07-09 15:55:00 -04:00
hobokenchicken 5d37fb899d feat(calendar): weekly list view with grid toggle, defaults to list 2026-07-09 15:13:40 -04:00
hobokenchicken af913b9923 fix(calendar): use r.Route subrouter to avoid chi path ambiguity with /channels/events/{eventID} 2026-07-09 15:02:40 -04:00
hobokenchicken 9a6f15662b fix(voice): add reconnect/disconnect event handlers + connection quality logging 2026-07-09 13:15:21 -04:00
hobokenchicken 7d1bd02e31 fix(voice): AudioRenderers listens to room events directly, not zustand 2026-07-09 13:08:02 -04:00
hobokenchicken 8542243745 fix(voice): remove unused import 2026-07-09 13:03:07 -04:00
hobokenchicken 90bddc65d4 fix(voice): handle browser autoplay block — flush pending audio on interaction 2026-07-09 13:02:40 -04:00
hobokenchicken 67a5a54244 fix(voice): simplify RemoteAudioTrack — remove stale listener race 2026-07-09 13:01:49 -04:00
hobokenchicken 33c9dc4f15 fix(voice): AudioRenderers re-renders on participant join 2026-07-09 12:55:36 -04:00
hobokenchicken e6dfe43926 fix(voice): broadcast VOICE_JOIN/LEAVE via ws, show participants in sidebar for all users 2026-07-09 12:47:42 -04:00
hobokenchicken 62e8354d03 feat(voice): persist A/V device preferences in localStorage 2026-07-09 12:36:43 -04:00
hobokenchicken 1139e90fc3 fix: use powershell -Command for download, keep shell:cmd for execution
Release Desktop Apps / build-linux (push) Failing after 3m29s
Release Desktop Apps / release (push) Has been cancelled
Release Desktop Apps / build-windows (push) Has been cancelled
2026-07-07 19:46:27 +00:00
hobokenchicken 60edc0b5b4 fix: Windows build — use cmd shell to avoid PowerShell execution policy
Release Desktop Apps / build-windows (push) Failing after 3h22m14s
Release Desktop Apps / build-linux (push) Has been cancelled
Release Desktop Apps / release (push) Has been cancelled
2026-07-07 19:19:55 +00:00
hobokenchicken 836bcd8b8d fix: use powershell instead of pwsh for Windows runner
Release Desktop Apps / build-windows (push) Failing after 3h0m34s
Release Desktop Apps / build-linux (push) Has been cancelled
Release Desktop Apps / release (push) Has been cancelled
2026-07-07 19:18:33 +00:00
hobokenchicken 8fd649356c fix: windows build — use pwsh instead of bash for Rust install 2026-07-07 19:17:42 +00:00
58 changed files with 3910 additions and 1028 deletions
+31 -9
View File
@@ -10,10 +10,16 @@ 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:
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: |
@@ -52,7 +74,7 @@ jobs:
needs: [build-linux, build-windows] needs: [build-linux, build-windows]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v3
- name: Create release - name: Create release
uses: actions/gitea-release-action@v3 uses: actions/gitea-release-action@v3
env: env:
+1
View File
@@ -41,3 +41,4 @@ minio_data/
/migrate /migrate
/dumpster-server /dumpster-server
keygen keygen
steamfree
+52 -79
View File
@@ -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**
+32 -4
View File
@@ -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
View File
@@ -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.
+198
View File
@@ -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)
}
+56
View File
@@ -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
View File
@@ -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())
}
+85
View File
@@ -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()))
}
+332
View File
@@ -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,
},
})
}
+117
View File
@@ -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
}
+1 -1
View File
@@ -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)
+2 -5
View File
@@ -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)
+9
View File
@@ -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 != '';
` `
+221
View File
@@ -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,
},
})
}
+4
View File
@@ -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.
+78 -7
View File
@@ -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)
} }
+64 -16
View File
@@ -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`,
+38
View File
@@ -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")
}
}
+59
View File
@@ -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")
}
}
+24
View File
@@ -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
View File
@@ -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 -->
+1 -1
View File
@@ -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
View File
@@ -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>
+55 -28
View File
@@ -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;
+105 -4
View File
@@ -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>
+187
View File
@@ -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>
);
}
+69 -1
View File
@@ -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)}>
+571 -166
View File
@@ -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)),
})).filter((cat) => cat.keys.length > 0);
function targetKey(type: TargetType, id: string) {
return `${type}:${id}`;
}
function draftFromOverride(o: ChannelOverride): DraftOverride {
return {
target_type: o.target_type,
target_id: o.target_id,
allow: o.allow_bitflags,
deny: o.deny_bitflags,
};
}
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'; return 'inherit';
};
return (
<div className="space-y-1 max-h-64 overflow-y-auto border border-gb-bg-t p-2">
{(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 applyTri(draft: DraftOverride, flag: number, state: TriState): DraftOverride {
const [tab, setTab] = useState<Tab>('overview'); let allow = draft.allow & ~flag;
const [form, setForm] = useState<OverrideFormData>({ targetType: 'role', targetId: '', allow: 0, deny: 0 }); let deny = draft.deny & ~flag;
const [saving, setSaving] = useState(false); if (state === 'allow') allow |= flag;
const [error, setError] = useState<string | null>(null); if (state === 'deny') deny |= flag;
const [editName, setEditName] = useState(channelName); return { ...draft, allow, deny };
const [savingName, setSavingName] = useState(false); }
const updateChannel = useChannelStore((s) => s.updateChannel);
const overrides = usePermissionStore((s) => s.overridesByChannel[channelId] || []); 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>
); );
} }
+42 -24
View File
@@ -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);
}}> }}>
&lt;{members.find((m) => m.id === message.author_id)?.nickname || message.author_username}&gt; &lt;{message.author_bot ? (message.bot_name || message.author_username) : (members.find((m) => m.id === message.author_id)?.nickname || message.author_username)}&gt;
</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
View File
@@ -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>
);
}
+29 -11
View File
@@ -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)} />
+6 -1
View File
@@ -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
+72 -12
View File
@@ -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>
); );
} }
+16 -2
View File
@@ -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
View File
@@ -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>
); );
} }
+25 -2
View File
@@ -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
+2 -2
View File
@@ -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>
)} )}
+56 -25
View File
@@ -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>
); );
} }
+12
View File
@@ -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">
+17 -10
View File
@@ -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]
+25 -24
View File
@@ -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
View File
@@ -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',
+2
View File
@@ -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 {
+3
View File
@@ -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[];
+33 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+42
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"}