Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa8cb1b0ca |
@@ -6,27 +6,14 @@ on:
|
||||
|
||||
jobs:
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: linux
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 20 }
|
||||
- name: Cache npm
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('web/package-lock.json') }}
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Cache Rust target and registry
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
web/src-tauri/target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('web/src-tauri/Cargo.lock') }}
|
||||
- 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 rpm
|
||||
run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
- name: Build frontend
|
||||
run: cd web && npm ci && npm run build
|
||||
- name: Build Tauri bundles
|
||||
@@ -34,7 +21,7 @@ jobs:
|
||||
NO_STRIP: "true"
|
||||
run: cd web && npx tauri build
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-bundles
|
||||
path: |
|
||||
@@ -48,36 +35,22 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 20 }
|
||||
- name: Cache npm
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('web/package-lock.json') }}
|
||||
- name: Install Rust
|
||||
run: |
|
||||
curl.exe -sLo rustup-init.exe https://win.rustup.rs/x86_64
|
||||
rustup-init.exe -y --default-toolchain stable --profile minimal
|
||||
set PATH=%USERPROFILE%\.cargo\bin;%PATH%
|
||||
if (-not (Get-Command rustc -ErrorAction SilentlyContinue)) {
|
||||
Invoke-WebRequest -Uri https://win.rustup.rs/x86_64 -OutFile rustup-init.exe
|
||||
.\rustup-init.exe -y --default-toolchain stable
|
||||
$env:Path += ";$env:USERPROFILE\.cargo\bin"
|
||||
}
|
||||
rustc --version
|
||||
shell: cmd
|
||||
- name: Cache Rust target and registry
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
web/src-tauri/target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('web/src-tauri/Cargo.lock') }}
|
||||
shell: pwsh
|
||||
- name: Build frontend
|
||||
run: cd web && npm ci && npm run build
|
||||
shell: cmd
|
||||
run: cd web; npm ci; npm run build
|
||||
- name: Build Tauri bundles
|
||||
env:
|
||||
CI: "true"
|
||||
run: cd web && set PATH=%USERPROFILE%\.cargo\bin;%PATH% && npx tauri build
|
||||
shell: cmd
|
||||
run: cd web; $env:Path += ";$env:USERPROFILE\.cargo\bin"; npx tauri build
|
||||
shell: pwsh
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-bundles
|
||||
path: |
|
||||
@@ -86,18 +59,13 @@ jobs:
|
||||
|
||||
release:
|
||||
needs: [build-linux, build-windows]
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: linux
|
||||
steps:
|
||||
- uses: actions/download-artifact@v3
|
||||
- uses: actions/download-artifact@v4
|
||||
- name: Create release
|
||||
uses: softprops/action-gh-release@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
files: |
|
||||
linux-bundles/**/*.AppImage
|
||||
linux-bundles/**/*.deb
|
||||
linux-bundles/**/*.rpm
|
||||
windows-bundles/**/*.msi
|
||||
windows-bundles/**/*.exe
|
||||
linux-bundles/*
|
||||
windows-bundles/*
|
||||
generate_release_notes: true
|
||||
|
||||
@@ -41,4 +41,3 @@ minio_data/
|
||||
/migrate
|
||||
/dumpster-server
|
||||
keygen
|
||||
steamfree
|
||||
|
||||
+79
-52
@@ -2,7 +2,7 @@
|
||||
|
||||
## vs Discord, Guilded (historical), TeamSpeak 6, Fluxer
|
||||
|
||||
Compiled 2026-06-30. **Status refreshed 2026-07-15** (not a full re-audit of every row).
|
||||
Compiled 2026-06-30. Updated through Phase 7 completion.
|
||||
|
||||
---
|
||||
|
||||
@@ -21,23 +21,22 @@ Compiled 2026-06-30. **Status refreshed 2026-07-15** (not a full re-audit of eve
|
||||
|
||||
| Feature | dumpsterChat | Discord | Guilded | TeamSpeak 6 | Fluxer |
|
||||
|---------|:------------:|:-------:|:-------:|:-----------:|:------:|
|
||||
| Text channels | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Direct messages | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
| Markdown support | ✅ | ✅ full | ✅ full | ❌ basic | ✅ full |
|
||||
| Reactions | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
| Replies | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
| Threads | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
||||
| Forum channels | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
||||
| Pinned messages | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Message search | ✅ | ✅ full | ✅ | ❌ | ✅ Meilisearch |
|
||||
| Edit / delete messages | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
| Rich embeds / link unfurling | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
| File uploads | ⚠️ MinIO | ✅ | ✅ | ✅ | ✅ S3-backed |
|
||||
| GIF picker (Giphy) | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
| Typing indicators | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
| Message history (pagination) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Read receipts | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
||||
| @everyone / @channel | ✅ (perm gated) | ✅ | ✅ | ❌ | ✅ |
|
||||
|| Text channels | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
|| Direct messages | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
|| Markdown support | ✅ | ✅ full | ✅ full | ❌ basic | ✅ full |
|
||||
|| Reactions | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
|| Replies | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
|| Threads | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
||||
|| Forum channels | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
||||
|| Pinned messages | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
|| Message search | ✅ | ✅ full | ✅ | ❌ | ✅ Meilisearch |
|
||||
|| Edit / delete messages | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
|| Rich embeds / link unfurling | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
|| File uploads | ⚠️ MinIO | ✅ | ✅ | ✅ | ✅ S3-backed |
|
||||
|| GIF picker (Giphy) | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
|| Typing indicators | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
|| Message history (pagination) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
|| Read receipts | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
||||
|
||||
---
|
||||
|
||||
@@ -69,7 +68,7 @@ Compiled 2026-06-30. **Status refreshed 2026-07-15** (not a full re-audit of eve
|
||||
| Badges | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Usernames + discriminators | ✅ | ⚠️ handles | ❌ | ✅ UID | ✅ #0000 |
|
||||
| Friend requests | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
| Block list | ✅ | ✅ | ✅ | ❌ | ❌ |
|
||||
|| Block list | ✅ | ✅ | ✅ | ❌ | ❌ |
|
||||
| Activity / game status | ❌ | ✅ | ✅ | ❌ | ❌ |
|
||||
|
||||
---
|
||||
@@ -81,12 +80,11 @@ Compiled 2026-06-30. **Status refreshed 2026-07-15** (not a full re-audit of eve
|
||||
| Roles | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Hierarchical roles | ⚠️ basic | ✅ | ✅ | ✅ | ✅ |
|
||||
| Permission bitflags | ✅ | ✅ | ✅ | ✅ granular | ✅ |
|
||||
| Per-channel permission overrides | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
|| Per-channel permission overrides | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| @everyone default role | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Role colors | ⚠️ DB ready | ✅ | ✅ | ❌ | ✅ |
|
||||
| Role icons | ❌ | ✅ Nitro | ❌ | ❌ | ❌ |
|
||||
| Administrator bypass | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Client-side permission gates | ✅ (user roles + @everyone) | ✅ | ✅ | ✅ | ✅ |
|
||||
|
||||
### dumpsterChat Permissions (current)
|
||||
|
||||
@@ -125,7 +123,7 @@ Compiled 2026-06-30. **Status refreshed 2026-07-15** (not a full re-audit of eve
|
||||
| Server invites | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Vanity URLs | ❌ | ✅ Nitro | ❌ | ❌ | ❌ |
|
||||
| Webhooks | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
| Bots / API | ⚠️ store + runner | ✅ huge | ✅ Flow Bots | ❌ plugins | 🔄 |
|
||||
| Bots / API | ⚠️ slash cmds | ✅ huge | ✅ Flow Bots | ❌ plugins | 🔄 |
|
||||
| Server templates | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||
| Server discovery | ❌ | ✅ | ✅ | ✅ | 🔄 |
|
||||
| Server analytics | ❌ | ✅ | ✅ | ❌ | ❌ |
|
||||
@@ -141,10 +139,10 @@ Compiled 2026-06-30. **Status refreshed 2026-07-15** (not a full re-audit of eve
|
||||
| Desktop notifications | ⚠️ possible via SW | ✅ | ✅ | ✅ | ✅ |
|
||||
| Web push notifications | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| @mention push | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
| Channel-wide push (@everyone/@channel) | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
| Channel-wide push | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
| Email notifications | ❌ | ✅ | ✅ | ❌ | 🔄 |
|
||||
| Mobile apps | ❌ (PWA is target) | ✅ iOS/Android | ✅ | ✅ | 🔄 Flutter alpha |
|
||||
| Per-channel notification settings | ⚠️ partial | ✅ | ✅ | ✅ | ✅ |
|
||||
| Mobile apps | ❌ | ✅ iOS/Android | ✅ | ✅ | 🔄 Flutter alpha |
|
||||
| Per-channel notification settings | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Do Not Disturb schedule | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||
|
||||
---
|
||||
@@ -158,7 +156,6 @@ Compiled 2026-06-30. **Status refreshed 2026-07-15** (not a full re-audit of eve
|
||||
| Slash commands | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
||||
| Command options / JSON schema | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
||||
| Bot mentions | ✅ | ✅ | ❌ | ❌ | 🔄 |
|
||||
| Built-in bot runner (anonConfess, leaderboard, steamfree) | ✅ | ❌ | ⚠️ | ❌ | ❌ |
|
||||
| Third-party integrations (Twitch, YouTube, GitHub) | ❌ | ✅ | ✅ | ❌ | 🔄 |
|
||||
| Webhook-driven bots | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
|
||||
@@ -171,11 +168,40 @@ Compiled 2026-06-30. **Status refreshed 2026-07-15** (not a full re-audit of eve
|
||||
- Terminal/Gruvbox aesthetic
|
||||
- WebAuthn / passkey auth
|
||||
- LiveKit voice integration
|
||||
- Built-in bot store + managed runner
|
||||
- PWA-first mobile (no native app planned)
|
||||
- Built-in webhook execution for simple integrations
|
||||
|
||||
### Discord / Guilded / TeamSpeak / Fluxer
|
||||
See historical notes in git history if needed. Not the product roadmap.
|
||||
### Discord
|
||||
- Massive network effect (200M+ MAU)
|
||||
- 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
|
||||
|
||||
---
|
||||
|
||||
@@ -184,37 +210,38 @@ See historical notes in git history if needed. Not the product roadmap.
|
||||
| Feature | dumpsterChat | Discord | Guilded | TeamSpeak 6 | Fluxer |
|
||||
|---------|:------------:|:-------:|:-------:|:-----------:|:------:|
|
||||
| Self-hostable | ✅ | ❌ | ❌ | ✅ | ✅ |
|
||||
| Open source | ⚠️ private self-host | ❌ | ❌ | ❌ | ✅ AGPL-3 |
|
||||
| Open source | ❌ | ❌ | ❌ | ❌ | ✅ AGPL-3 |
|
||||
| PWA support | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| REST API | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
| WebSocket gateway | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Swagger docs | ✅ /docs | ✅ | ✅ | ❌ | ✅ |
|
||||
| Swagger docs | ✅ localhost | ✅ | ✅ | ❌ | ✅ |
|
||||
| Docker Compose | ✅ | ❌ | ❌ | ❌ | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## Priority Recommendations (updated)
|
||||
## Priority Recommendations
|
||||
|
||||
**Do not treat this table as a todo list.** For a ~12 person friend server, parity rows are optional.
|
||||
### Critical gaps (would block most Discord/Guilded users)
|
||||
|
||||
### Actual next polish (product)
|
||||
1. Mobile PWA pain (input, notifs, safe areas)
|
||||
2. Role color UI / hierarchy polish
|
||||
3. Voice PTT / screen share only if voice is used
|
||||
4. Small slash toys (`/roll`, `/choose`) if wanted
|
||||
1. **Direct Messages** — no way to message users outside servers
|
||||
2. **Mobile app** — major adoption blocker
|
||||
3. **Voice push-to-talk** — important for voice-heavy communities
|
||||
4. **Email notifications** — needed for async engagement
|
||||
5. **Read receipts / unread state** — channel-level read tracking
|
||||
|
||||
### 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`
|
||||
### High-value next features
|
||||
|
||||
### Explicitly not critical
|
||||
- Friend requests (DMs already exist among members)
|
||||
- Native mobile apps
|
||||
- Discord bot ecosystem compatibility
|
||||
- Server discovery / monetization / federation
|
||||
- AutoMod / Flow Bots
|
||||
1. **Screen share** — LiveKit supports it; mostly frontend work
|
||||
2. **Custom emoji / reactions beyond unicode** — core Discord behavior
|
||||
3. **Server groups (sub-servers)** — channel organization
|
||||
4. **Do Not Disturb schedule** — notification control
|
||||
5. **Third-party integrations** — Twitch, YouTube, GitHub
|
||||
6. **Stage channels** — presentation-style voice
|
||||
|
||||
### Stale claims removed
|
||||
- ~~"Direct Messages missing"~~ — DMs exist
|
||||
- ~~"Mobile app is the only path"~~ — PWA is the target client
|
||||
### Nice-to-have differentiators
|
||||
|
||||
1. **No-code Flow Bots** (Guilded-style automations)
|
||||
2. **Server discovery / directory**
|
||||
3. **Activities / embedded games**
|
||||
4. **Federation** (Fluxer-style)
|
||||
5. **Server analytics**
|
||||
|
||||
@@ -58,13 +58,10 @@ For optional features, copy `.env.example` to `.env` and set Giphy, MinIO, LiveK
|
||||
- [x] Blocks (user-level blocking)
|
||||
- [x] Dark/light mode toggle
|
||||
- [x] Mobile-responsive layout (bottom nav, drawer)
|
||||
- [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] Bot framework (token auth, CRUD, WebSocket gateway)
|
||||
- [x] Slash commands (registration, autocomplete)
|
||||
- [x] Incoming webhooks (create, execute)
|
||||
- [x] Example bots (modbot, welcome bot, steamfree)
|
||||
- [x] Example bots (modbot, welcome bot)
|
||||
- [x] TUI client (Bubbletea, vim-style, voice support)
|
||||
- [x] Roles & permissions system
|
||||
- [x] Push notification backend (VAPID)
|
||||
@@ -100,7 +97,7 @@ dumpsterChat/
|
||||
│ ├── voice/ # LiveKit voice/video integration
|
||||
│ ├── reaction/ # Message reactions
|
||||
│ ├── invite/ # Server invite links
|
||||
│ ├── bot/ # Bot framework, auth, commands, runner
|
||||
│ ├── bot/ # Bot framework, auth, commands
|
||||
│ ├── webhook/ # Incoming webhooks
|
||||
│ ├── dm/ # Direct messages (conversations)
|
||||
│ ├── push/ # Push notification sender (VAPID)
|
||||
@@ -115,8 +112,7 @@ dumpsterChat/
|
||||
│ └── block/ # User blocking
|
||||
├── examples/ # Example bots
|
||||
│ ├── modbot/ # Moderation bot
|
||||
│ ├── welcome/ # Welcome message bot
|
||||
│ └── steamfree/ # Steam free games bot (standalone)
|
||||
│ └── welcome/ # Welcome message bot
|
||||
├── web/ # React frontend (PWA)
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # Layout, ChatArea, LoginForm, UserSettings, GiphyPicker, VoiceChannel, VoicePanel, VoiceControls, TypingIndicator, ReactionBar, EmojiPicker, ReplyBar, MentionPopup, InviteModal, JoinServer, MobileNav, MobileDrawer, ThemeToggle, InstallPrompt, BotManager, CommandManager, SlashCommandPopup
|
||||
@@ -161,30 +157,6 @@ 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)
|
||||
|
||||
## 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
|
||||
|
||||
AGPLv3
|
||||
|
||||
+7
-37
@@ -80,13 +80,6 @@ func main() {
|
||||
hub := gateway.NewHub(database.DB, logger)
|
||||
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)
|
||||
giphyClient := giphy.NewClient(cfg.Giphy.APIKey)
|
||||
|
||||
@@ -135,11 +128,6 @@ func main() {
|
||||
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
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
// Auth (public: register, login, logout) with strict rate limiting
|
||||
@@ -235,9 +223,7 @@ func main() {
|
||||
|
||||
// Messages (under channels)
|
||||
r.Route("/channels/{channelID}/messages", func(r chi.Router) {
|
||||
msgHandler := message.NewHandler(database.DB, hub, pushHandler, logger, permissionsChecker)
|
||||
msgHandler.SetConfessHandler(botRunner)
|
||||
msgHandler.RegisterRoutes(r)
|
||||
message.NewHandler(database.DB, hub, pushHandler, logger, permissionsChecker).RegisterRoutes(r)
|
||||
})
|
||||
|
||||
// Polls
|
||||
@@ -257,26 +243,6 @@ func main() {
|
||||
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
|
||||
pushHandler.RegisterRoutes(r)
|
||||
|
||||
@@ -357,9 +323,13 @@ func main() {
|
||||
reaction.NewHandler(database.DB, hub).RegisterRoutes(r)
|
||||
})
|
||||
|
||||
// Bots + slash commands
|
||||
// Bots
|
||||
r.Route("/bots", func(r chi.Router) {
|
||||
bot.NewHandler(database.DB, botRunner).RegisterRoutes(r)
|
||||
bot.NewHandler(database.DB).RegisterRoutes(r)
|
||||
})
|
||||
|
||||
// Bot slash commands
|
||||
r.Route("/bots/{botID}/commands", func(r chi.Router) {
|
||||
bot.NewCommandHandler(database.DB).RegisterCommandRoutes(r)
|
||||
})
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,198 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
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)
|
||||
}
|
||||
+23
-129
@@ -12,19 +12,16 @@ import (
|
||||
|
||||
// Handler handles bot CRUD and server-assignment routes.
|
||||
type Handler struct {
|
||||
db *sql.DB
|
||||
runner *Runner
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewHandler creates a new bot Handler.
|
||||
func NewHandler(db *sql.DB, runner *Runner) *Handler {
|
||||
return &Handler{db: db, runner: runner}
|
||||
func NewHandler(db *sql.DB) *Handler {
|
||||
return &Handler{db: db}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers authenticated bot routes under the given router.
|
||||
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||
r.Get("/store", h.Store)
|
||||
r.Get("/types", h.ListTypes)
|
||||
r.Post("/", h.Create)
|
||||
r.Get("/", h.List)
|
||||
r.Get("/{botID}", h.Get)
|
||||
@@ -38,14 +35,12 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||
// ---- response / request types ----
|
||||
|
||||
type botResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Avatar *string `json:"avatar"`
|
||||
Description string `json:"description"`
|
||||
BotType string `json:"bot_type"`
|
||||
Config json.RawMessage `json:"config"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Avatar *string `json:"avatar"`
|
||||
Description string `json:"description"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// botWithToken is returned only on create / regenerate-token.
|
||||
@@ -55,18 +50,14 @@ type botWithToken struct {
|
||||
}
|
||||
|
||||
type createBotRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
BotType string `json:"bot_type"`
|
||||
Config json.RawMessage `json:"config"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type updateBotRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Avatar *string `json:"avatar"`
|
||||
BotType *string `json:"bot_type"`
|
||||
Config json.RawMessage `json:"config"`
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Avatar *string `json:"avatar"`
|
||||
}
|
||||
|
||||
type addToServerRequest struct {
|
||||
@@ -118,21 +109,15 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
token := GenerateToken()
|
||||
tokenHash := HashToken(token)
|
||||
|
||||
configJSON := req.Config
|
||||
if configJSON == nil {
|
||||
configJSON = json.RawMessage(`{}`)
|
||||
}
|
||||
|
||||
var bot botWithToken
|
||||
var avatar sql.NullString
|
||||
var createdAt sql.NullString
|
||||
var configOut sql.NullString
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
INSERT INTO bots (name, description, owner_id, token, bot_type, config)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::jsonb)
|
||||
RETURNING id, name, avatar, description, bot_type, config::text, owner_id, created_at::text
|
||||
`, req.Name, req.Description, userID, tokenHash, req.BotType, string(configJSON)).Scan(
|
||||
&bot.ID, &bot.Name, &avatar, &bot.Description, &bot.BotType, &configOut, &bot.OwnerID, &createdAt,
|
||||
INSERT INTO bots (name, description, owner_id, token)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, name, avatar, description, owner_id, created_at::text
|
||||
`, req.Name, req.Description, userID, tokenHash).Scan(
|
||||
&bot.ID, &bot.Name, &avatar, &bot.Description, &bot.OwnerID, &createdAt,
|
||||
)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "failed to create bot")
|
||||
@@ -141,17 +126,9 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
if avatar.Valid {
|
||||
bot.Avatar = &avatar.String
|
||||
}
|
||||
if configOut.Valid {
|
||||
bot.Config = json.RawMessage(configOut.String)
|
||||
}
|
||||
bot.CreatedAt = createdAt.String
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -304,18 +281,15 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
var b botResponse
|
||||
var avatar sql.NullString
|
||||
var createdAt sql.NullString
|
||||
var configOut sql.NullString
|
||||
err = h.db.QueryRowContext(r.Context(), `
|
||||
UPDATE bots
|
||||
SET name = COALESCE($1, name),
|
||||
description = COALESCE($2, description),
|
||||
avatar = COALESCE($3, avatar),
|
||||
bot_type = COALESCE($5, bot_type),
|
||||
config = COALESCE($6::jsonb, config)
|
||||
avatar = COALESCE($3, avatar)
|
||||
WHERE id = $4
|
||||
RETURNING id, name, avatar, description, bot_type, config::text, owner_id, created_at::text
|
||||
`, req.Name, req.Description, req.Avatar, botID, req.BotType, string(req.Config)).Scan(
|
||||
&b.ID, &b.Name, &avatar, &b.Description, &b.BotType, &configOut, &b.OwnerID, &createdAt,
|
||||
RETURNING id, name, avatar, description, owner_id, created_at::text
|
||||
`, req.Name, req.Description, req.Avatar, botID).Scan(
|
||||
&b.ID, &b.Name, &avatar, &b.Description, &b.OwnerID, &createdAt,
|
||||
)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "failed to update bot")
|
||||
@@ -324,22 +298,8 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
if avatar.Valid {
|
||||
b.Avatar = &avatar.String
|
||||
}
|
||||
if configOut.Valid {
|
||||
b.Config = json.RawMessage(configOut.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)
|
||||
}
|
||||
|
||||
@@ -385,11 +345,6 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Stop built-in bot if running
|
||||
if h.runner != nil {
|
||||
h.runner.Stop(botID)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -592,64 +547,3 @@ func (h *Handler) RegenerateToken(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
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()))
|
||||
}
|
||||
@@ -1,332 +0,0 @@
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SteamFreeConfig is the config shape for bot_type "steamfree".
|
||||
type SteamFreeConfig struct {
|
||||
ChannelID string `json:"channel_id"`
|
||||
PollMinutes int `json:"poll_minutes"`
|
||||
}
|
||||
|
||||
// SteamFreeBot polls Steam's featured categories for 100%-off games.
|
||||
func SteamFreeBot(ctx context.Context, _ *sql.DB, raw json.RawMessage, send SendMessageFunc) {
|
||||
var cfg SteamFreeConfig
|
||||
if err := json.Unmarshal(raw, &cfg); err != nil || cfg.ChannelID == "" {
|
||||
return
|
||||
}
|
||||
if cfg.PollMinutes <= 0 {
|
||||
cfg.PollMinutes = 30
|
||||
}
|
||||
|
||||
seen := map[int]bool{}
|
||||
ticker := time.NewTicker(time.Duration(cfg.PollMinutes) * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Poll immediately on start
|
||||
pollSteam(cfg.ChannelID, seen, send)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
pollSteam(cfg.ChannelID, seen, send)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func pollSteam(channelID string, seen map[int]bool, send SendMessageFunc) {
|
||||
games, err := fetchFreeGames()
|
||||
if err != nil {
|
||||
return // ponytail: silent on error, logs add noise
|
||||
}
|
||||
for _, g := range games {
|
||||
if seen[g.ID] {
|
||||
continue
|
||||
}
|
||||
seen[g.ID] = true
|
||||
|
||||
msg := fmt.Sprintf(
|
||||
"🎮 **FREE ON STEAM** 🎮\n**%s**\n~~$%.2f~~ → **FREE**\nhttps://store.steampowered.com/app/%d",
|
||||
g.Name, float64(g.OriginalPrice)/100, g.ID,
|
||||
)
|
||||
send(channelID, msg)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
type featuredCategories struct {
|
||||
Specials struct {
|
||||
Items []struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DiscountPct int `json:"discount_percent"`
|
||||
FinalPrice int `json:"final_price"`
|
||||
OriginalPrice int `json:"original_price"`
|
||||
} `json:"items"`
|
||||
} `json:"specials"`
|
||||
}
|
||||
|
||||
func fetchFreeGames() ([]struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DiscountPct int `json:"discount_percent"`
|
||||
FinalPrice int `json:"final_price"`
|
||||
OriginalPrice int `json:"original_price"`
|
||||
}, error) {
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Get("https://store.steampowered.com/api/featuredcategories?cc=us&l=english")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("steam: %d", resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var cats featuredCategories
|
||||
if err := json.Unmarshal(body, &cats); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var free []struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DiscountPct int `json:"discount_percent"`
|
||||
FinalPrice int `json:"final_price"`
|
||||
OriginalPrice int `json:"original_price"`
|
||||
}
|
||||
for _, item := range cats.Specials.Items {
|
||||
if item.DiscountPct == 100 && item.OriginalPrice > 0 {
|
||||
free = append(free, item)
|
||||
}
|
||||
}
|
||||
return free, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ type createEventRequest struct {
|
||||
Color *string `json:"color"`
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterCalendarRoutes(r chi.Router) {
|
||||
func (h *Handler) registerCalendarRoutes(r chi.Router) {
|
||||
r.Get("/{channelID}/events", h.ListEvents)
|
||||
r.Post("/{channelID}/events", h.CreateEvent)
|
||||
r.Patch("/events/{eventID}", h.UpdateEvent)
|
||||
|
||||
@@ -27,8 +27,11 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||
r.Get("/{channelID}", h.Get)
|
||||
r.Patch("/{channelID}", h.Update)
|
||||
r.Delete("/{channelID}", h.Delete)
|
||||
// ponytail: thread + forum routes registered at top level in main.go to match frontend paths
|
||||
h.RegisterCalendarRoutes(r)
|
||||
r.Post("/{channelID}/threads", h.CreateThread)
|
||||
r.Get("/{channelID}/threads", h.ListThreads)
|
||||
r.Patch("/threads/{threadID}", h.UpdateThread)
|
||||
h.registerForumRoutes(r)
|
||||
h.registerCalendarRoutes(r)
|
||||
h.registerDocRoutes(r)
|
||||
h.registerListRoutes(r)
|
||||
h.registerOverrideRoutes(r)
|
||||
|
||||
@@ -565,14 +565,5 @@ CREATE TABLE IF NOT EXISTS feature_request_votes (
|
||||
PRIMARY KEY (feature_request_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_feature_request_votes_fr ON feature_request_votes(feature_request_id);
|
||||
|
||||
-- Bot messages: track which bot authored a message
|
||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS bot_id UUID REFERENCES bots(id) ON DELETE SET NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_bot ON messages(bot_id) WHERE bot_id IS NOT NULL;
|
||||
|
||||
-- Built-in bot types: bot_type + config for server-managed bots
|
||||
ALTER TABLE bots ADD COLUMN IF NOT EXISTS bot_type VARCHAR(32) DEFAULT '';
|
||||
ALTER TABLE bots ADD COLUMN IF NOT EXISTS config JSONB DEFAULT '{}';
|
||||
CREATE INDEX IF NOT EXISTS idx_bots_type ON bots(bot_type) WHERE bot_type != '';
|
||||
`
|
||||
|
||||
|
||||
@@ -2,9 +2,7 @@ package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -108,9 +106,6 @@ type Client struct {
|
||||
Conn *websocket.Conn
|
||||
UserID string
|
||||
Username string
|
||||
IsBot bool
|
||||
BotID string
|
||||
BotName string
|
||||
send chan []byte
|
||||
}
|
||||
|
||||
@@ -160,18 +155,6 @@ func (c *Client) readPump() {
|
||||
c.Hub.BroadcastEvent(Event{Type: event.Type, Data: data})
|
||||
case EventPresenceUpdate:
|
||||
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:
|
||||
// Forward voice whispers only to the target user, not broadcast
|
||||
var whisperData struct {
|
||||
@@ -189,18 +172,6 @@ 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:
|
||||
c.Hub.logger.Info("received event from client", "type", event.Type, "user_id", c.UserID)
|
||||
}
|
||||
@@ -317,195 +288,3 @@ func ServeWS(db *sql.DB, hub *Hub, logger *slog.Logger, w http.ResponseWriter, r
|
||||
go client.writePump()
|
||||
go client.readPump()
|
||||
}
|
||||
|
||||
// ServeBotWS handles websocket requests from bot clients.
|
||||
// Authenticates via ?token= query param (bot token, hashed lookup).
|
||||
func ServeBotWS(db *sql.DB, hub *Hub, logger *slog.Logger, w http.ResponseWriter, r *http.Request) {
|
||||
token := r.URL.Query().Get("token")
|
||||
if token == "" {
|
||||
http.Error(w, `{"error":"token query param required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Hash the token and look up the bot.
|
||||
tokenHash := hashToken(token)
|
||||
|
||||
var botID, botName, ownerID string
|
||||
err := db.QueryRowContext(r.Context(),
|
||||
`SELECT id, name, owner_id FROM bots WHERE token = $1`,
|
||||
tokenHash,
|
||||
).Scan(&botID, &botName, &ownerID)
|
||||
if err != nil {
|
||||
logger.Warn("bot ws auth: invalid token")
|
||||
http.Error(w, `{"error":"invalid bot token"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the bot is added to at least one server.
|
||||
var serverCount int
|
||||
err = db.QueryRowContext(r.Context(),
|
||||
`SELECT COUNT(*) FROM bot_servers WHERE bot_id = $1`, botID,
|
||||
).Scan(&serverCount)
|
||||
if err != nil || serverCount == 0 {
|
||||
logger.Warn("bot ws auth: bot not added to any server", "bot_id", botID)
|
||||
http.Error(w, `{"error":"bot not added to any server"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
logger.Error("bot websocket upgrade failed", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Load bot server memberships into hub so BroadcastToServer works.
|
||||
hub.RefreshUserServers(ownerID)
|
||||
|
||||
conn.SetReadDeadline(time.Time{})
|
||||
conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"ready","bot_id":"`+botID+`"}`))
|
||||
|
||||
client := &Client{
|
||||
Hub: hub,
|
||||
Conn: conn,
|
||||
UserID: ownerID,
|
||||
Username: botName,
|
||||
IsBot: true,
|
||||
BotID: botID,
|
||||
BotName: botName,
|
||||
send: make(chan []byte, 256),
|
||||
}
|
||||
|
||||
hub.Register(client)
|
||||
|
||||
go client.writePump()
|
||||
go client.readPump()
|
||||
}
|
||||
|
||||
// hashToken returns the SHA-256 hex digest of a token.
|
||||
func hashToken(token string) string {
|
||||
h := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// handleBotSendMessage processes a SEND_MESSAGE action from a bot client.
|
||||
func (c *Client) handleBotSendMessage(data interface{}) {
|
||||
var payload struct {
|
||||
ChannelID string `json:"channel_id"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
raw, ok := data.(json.RawMessage)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := json.Unmarshal(raw, &payload); err != nil || payload.ChannelID == "" || payload.Content == "" {
|
||||
c.Hub.logger.Warn("bot SEND_MESSAGE: invalid payload")
|
||||
return
|
||||
}
|
||||
if len(payload.Content) > 4000 {
|
||||
payload.Content = payload.Content[:4000]
|
||||
}
|
||||
|
||||
// Verify the bot is in the server that owns this channel.
|
||||
serverID, err := c.Hub.ServerIDForChannel(context.Background(), payload.ChannelID)
|
||||
if err != nil {
|
||||
c.Hub.logger.Warn("bot SEND_MESSAGE: channel not found", "channel_id", payload.ChannelID)
|
||||
return
|
||||
}
|
||||
var inServer bool
|
||||
err = c.Hub.db.QueryRowContext(context.Background(),
|
||||
`SELECT EXISTS(SELECT 1 FROM bot_servers WHERE bot_id = $1 AND server_id = $2)`,
|
||||
c.BotID, serverID,
|
||||
).Scan(&inServer)
|
||||
if err != nil || !inServer {
|
||||
c.Hub.logger.Warn("bot SEND_MESSAGE: bot not in server", "bot_id", c.BotID, "server_id", serverID)
|
||||
return
|
||||
}
|
||||
|
||||
// Insert the message with bot_id set.
|
||||
var msgID, createdAt string
|
||||
err = c.Hub.db.QueryRowContext(context.Background(),
|
||||
`INSERT INTO messages (channel_id, author_id, content, bot_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, created_at::text`,
|
||||
payload.ChannelID, c.UserID, payload.Content, c.BotID,
|
||||
).Scan(&msgID, &createdAt)
|
||||
if err != nil {
|
||||
c.Hub.logger.Error("bot SEND_MESSAGE: insert failed", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Broadcast MESSAGE_CREATE to the server.
|
||||
c.Hub.BroadcastToServer(serverID, Event{
|
||||
Type: EventMessageCreate,
|
||||
Data: map[string]interface{}{
|
||||
"id": msgID,
|
||||
"channel_id": payload.ChannelID,
|
||||
"author_id": c.UserID,
|
||||
"author_username": c.BotName,
|
||||
"author_display_name": nil,
|
||||
"author_bot": true,
|
||||
"bot_id": c.BotID,
|
||||
"bot_name": c.BotName,
|
||||
"content": payload.Content,
|
||||
"reply_to": nil,
|
||||
"edited_at": nil,
|
||||
"pinned": false,
|
||||
"created_at": createdAt,
|
||||
"embeds": []interface{}{},
|
||||
"reactions": []interface{}{},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// handleBotDeleteMessage processes a DELETE_MESSAGE action from a bot client.
|
||||
func (c *Client) handleBotDeleteMessage(data interface{}) {
|
||||
var payload struct {
|
||||
ChannelID string `json:"channel_id"`
|
||||
MessageID string `json:"message_id"`
|
||||
}
|
||||
raw, ok := data.(json.RawMessage)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := json.Unmarshal(raw, &payload); err != nil || payload.ChannelID == "" || payload.MessageID == "" {
|
||||
c.Hub.logger.Warn("bot DELETE_MESSAGE: invalid payload")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the bot is in the server that owns this channel.
|
||||
serverID, err := c.Hub.ServerIDForChannel(context.Background(), payload.ChannelID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var inServer bool
|
||||
err = c.Hub.db.QueryRowContext(context.Background(),
|
||||
`SELECT EXISTS(SELECT 1 FROM bot_servers WHERE bot_id = $1 AND server_id = $2)`,
|
||||
c.BotID, serverID,
|
||||
).Scan(&inServer)
|
||||
if err != nil || !inServer {
|
||||
return
|
||||
}
|
||||
|
||||
// Delete the message (only if it exists in this channel).
|
||||
result, err := c.Hub.db.ExecContext(context.Background(),
|
||||
`DELETE FROM messages WHERE id = $1 AND channel_id = $2`,
|
||||
payload.MessageID, payload.ChannelID,
|
||||
)
|
||||
if err != nil {
|
||||
c.Hub.logger.Error("bot DELETE_MESSAGE: delete failed", "error", err)
|
||||
return
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Broadcast MESSAGE_DELETE.
|
||||
c.Hub.BroadcastToServer(serverID, Event{
|
||||
Type: EventMessageDelete,
|
||||
Data: map[string]string{
|
||||
"id": payload.MessageID,
|
||||
"channel_id": payload.ChannelID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -23,10 +23,6 @@ const (
|
||||
EventVoiceMute = "VOICE_MUTE"
|
||||
EventVoiceDeafen = "VOICE_DEAFEN"
|
||||
EventVoiceWhisper = "VOICE_WHISPER"
|
||||
|
||||
// Bot action events (sent by bot clients)
|
||||
BotSendMessage = "SEND_MESSAGE"
|
||||
BotDeleteMessage = "DELETE_MESSAGE"
|
||||
)
|
||||
|
||||
// Event represents a WebSocket event sent to clients.
|
||||
|
||||
@@ -27,13 +27,6 @@ type Handler struct {
|
||||
pushHandler *push.Handler
|
||||
logger *slog.Logger
|
||||
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 {
|
||||
@@ -47,11 +40,6 @@ 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) {
|
||||
r.Get("/", h.List)
|
||||
r.Post("/", h.Create)
|
||||
@@ -131,7 +119,6 @@ func (h *Handler) BulkDelete(w http.ResponseWriter, r *http.Request) {
|
||||
Type: gateway.EventMessageDelete,
|
||||
Data: map[string]string{
|
||||
"id": id,
|
||||
"message_id": id,
|
||||
"channel_id": channelID,
|
||||
},
|
||||
})
|
||||
@@ -172,9 +159,6 @@ type messageResponse struct {
|
||||
AuthorID string `json:"author_id"`
|
||||
AuthorName string `json:"author_username"`
|
||||
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"`
|
||||
ReplyTo *string `json:"reply_to,omitempty"`
|
||||
EditedAt *string `json:"edited_at"`
|
||||
@@ -256,29 +240,6 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
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 editedAt sql.NullString
|
||||
var createdAt sql.NullString
|
||||
@@ -503,7 +464,6 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
Type: gateway.EventMessageDelete,
|
||||
Data: map[string]string{
|
||||
"id": messageID,
|
||||
"message_id": messageID,
|
||||
"channel_id": channelID,
|
||||
},
|
||||
})
|
||||
@@ -545,20 +505,18 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
var err error
|
||||
if before != "" {
|
||||
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, m.bot_id, b.name
|
||||
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
|
||||
FROM messages m
|
||||
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)
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT $3
|
||||
`, channelID, before, limit)
|
||||
} else {
|
||||
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, m.bot_id, b.name
|
||||
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
|
||||
FROM messages m
|
||||
JOIN users u ON m.author_id = u.id
|
||||
LEFT JOIN bots b ON m.bot_id = b.id
|
||||
WHERE m.channel_id = $1
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT $2
|
||||
@@ -576,9 +534,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
var editedAt sql.NullString
|
||||
var createdAt sql.NullString
|
||||
var replyTo sql.NullString
|
||||
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)
|
||||
err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -588,13 +544,6 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
if editedAt.Valid {
|
||||
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
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
@@ -853,10 +802,9 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
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,
|
||||
ts_rank(m.search_vector, plainto_tsquery('english', $2)) AS rank, m.bot_id, b.name
|
||||
ts_rank(m.search_vector, plainto_tsquery('english', $2)) AS rank
|
||||
FROM messages m
|
||||
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)
|
||||
ORDER BY rank DESC, m.created_at DESC
|
||||
LIMIT $3
|
||||
@@ -874,9 +822,7 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
|
||||
var createdAt sql.NullString
|
||||
var replyTo sql.NullString
|
||||
var rank float64
|
||||
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)
|
||||
err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt, &rank)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -886,13 +832,6 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
|
||||
if editedAt.Valid {
|
||||
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
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
@@ -1120,10 +1059,9 @@ func (h *Handler) ListPinned(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
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, m.bot_id, b.name
|
||||
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
|
||||
FROM messages m
|
||||
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
|
||||
ORDER BY m.created_at DESC
|
||||
`, channelID)
|
||||
@@ -1139,9 +1077,7 @@ func (h *Handler) ListPinned(w http.ResponseWriter, r *http.Request) {
|
||||
var editedAt sql.NullString
|
||||
var createdAt sql.NullString
|
||||
var replyTo sql.NullString
|
||||
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)
|
||||
err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -1151,13 +1087,6 @@ func (h *Handler) ListPinned(w http.ResponseWriter, r *http.Request) {
|
||||
if editedAt.Valid {
|
||||
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
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
|
||||
@@ -6,14 +6,13 @@ import (
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push"
|
||||
)
|
||||
|
||||
var mentionRegex = regexp.MustCompile(`<@([0-9a-f-]+)>`)
|
||||
var everyoneMention = "@everyone"
|
||||
var roleMentionRegex = regexp.MustCompile(`<@&([0-9a-f-]+)>`)
|
||||
var plainUsernameMention = regexp.MustCompile(`@([a-zA-Z0-9_.-]+)`)
|
||||
|
||||
// MentionHandler dispatches push notifications for @mentions.
|
||||
type MentionHandler struct {
|
||||
@@ -30,40 +29,20 @@ 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.
|
||||
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)
|
||||
|
||||
// Discord-style ID mentions
|
||||
for _, match := range mentionRegex.FindAllStringSubmatch(content, -1) {
|
||||
for _, match := range userMatches {
|
||||
if len(match) > 1 {
|
||||
mentionedUsers[match[1]] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check for @everyone
|
||||
isEveryone := strings.Contains(content, everyoneMention)
|
||||
|
||||
// Get channel info for notification
|
||||
var serverID, channelName string
|
||||
err := m.db.QueryRowContext(ctx,
|
||||
@@ -96,9 +75,8 @@ func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID
|
||||
"url": "/channels/" + channelID,
|
||||
}
|
||||
|
||||
// @everyone / @channel — fan out to server members (permission gated at create).
|
||||
// ponytail: both use the same fanout; UI labels differ. Split if channel-private members matter.
|
||||
if hasBroadcastToken(content, "everyone") || hasBroadcastToken(content, "channel") {
|
||||
if isEveryone {
|
||||
// Send to all server members except those who muted this channel
|
||||
rows, err := m.db.QueryContext(ctx,
|
||||
`SELECT m.user_id FROM members m
|
||||
LEFT JOIN notification_settings ns ON ns.user_id = m.user_id AND ns.channel_id = $3
|
||||
@@ -107,7 +85,7 @@ func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID
|
||||
serverID, authorID, channelID,
|
||||
)
|
||||
if err != nil {
|
||||
m.logger.Error("failed to query server members for broadcast mention", "error", err)
|
||||
m.logger.Error("failed to query server members for @everyone", "error", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
@@ -122,43 +100,13 @@ func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID
|
||||
return
|
||||
}
|
||||
|
||||
// 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
|
||||
// Check for role mentions
|
||||
roleMatches := roleMentionRegex.FindAllStringSubmatch(content, -1)
|
||||
if len(roleMatches) > 0 {
|
||||
for _, match := range roleMatches {
|
||||
if len(match) > 1 {
|
||||
roleID := match[1]
|
||||
// Get users with this role
|
||||
rows, err := m.db.QueryContext(ctx,
|
||||
`SELECT user_id FROM member_roles WHERE role_id = $1 AND user_id != $2`,
|
||||
roleID, authorID,
|
||||
@@ -179,9 +127,12 @@ func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the author from mentions
|
||||
delete(mentionedUsers, authorID)
|
||||
|
||||
// Send push to individually mentioned users
|
||||
for userID := range mentionedUsers {
|
||||
// Check if user is in DND status
|
||||
var status string
|
||||
err := m.db.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(status, 'online') FROM users WHERE id = $1`, userID,
|
||||
@@ -193,6 +144,7 @@ func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if user muted this channel
|
||||
var level string
|
||||
err = m.db.QueryRowContext(ctx,
|
||||
`SELECT level FROM notification_settings WHERE user_id = $1 AND channel_id = $2`,
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package permissions
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHas(t *testing.T) {
|
||||
set := VIEW_CHANNEL | SEND_MESSAGES | MENTION_EVERYONE
|
||||
|
||||
if !Has(set, VIEW_CHANNEL) {
|
||||
t.Fatal("expected VIEW_CHANNEL")
|
||||
}
|
||||
if !Has(set, SEND_MESSAGES) {
|
||||
t.Fatal("expected SEND_MESSAGES")
|
||||
}
|
||||
if Has(set, KICK_MEMBERS) {
|
||||
t.Fatal("did not expect KICK_MEMBERS")
|
||||
}
|
||||
if !Has(set, VIEW_CHANNEL|SEND_MESSAGES) {
|
||||
t.Fatal("expected multi-bit all-present")
|
||||
}
|
||||
if Has(set, VIEW_CHANNEL|KICK_MEMBERS) {
|
||||
t.Fatal("multi-bit should require all bits")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdministratorBypassSemantics(t *testing.T) {
|
||||
// Client/backend convention: ADMINISTRATOR implies all gates when checked separately.
|
||||
if !Has(ADMINISTRATOR, ADMINISTRATOR) {
|
||||
t.Fatal("admin flag self")
|
||||
}
|
||||
// ADMINISTRATOR alone does not set other bits; Has is pure bit check.
|
||||
if Has(ADMINISTRATOR, KICK_MEMBERS) {
|
||||
t.Fatal("Has is not an admin-implies-all helper; CheckPermission does that")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultEveryoneDoesNotIncludeMentionEveryone(t *testing.T) {
|
||||
if Has(DefaultEveryonePermissions, MENTION_EVERYONE) {
|
||||
t.Fatal("@everyone default must not grant MENTION_EVERYONE")
|
||||
}
|
||||
if !Has(DefaultEveryonePermissions, SEND_MESSAGES) {
|
||||
t.Fatal("@everyone default should grant SEND_MESSAGES")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddRemove(t *testing.T) {
|
||||
p := int64(0)
|
||||
p = Add(p, VIEW_CHANNEL)
|
||||
p = Add(p, KICK_MEMBERS)
|
||||
if !Has(p, VIEW_CHANNEL|KICK_MEMBERS) {
|
||||
t.Fatal("Add failed")
|
||||
}
|
||||
p = Remove(p, KICK_MEMBERS)
|
||||
if Has(p, KICK_MEMBERS) {
|
||||
t.Fatal("Remove failed")
|
||||
}
|
||||
if !Has(p, VIEW_CHANNEL) {
|
||||
t.Fatal("Remove cleared wrong bit")
|
||||
}
|
||||
}
|
||||
@@ -35,8 +35,6 @@ type memberResponse struct {
|
||||
Avatar string `json:"avatar"`
|
||||
Status string `json:"status"`
|
||||
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) {
|
||||
@@ -84,28 +82,6 @@ func (h *MemberHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
|
||||
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")
|
||||
json.NewEncoder(w).Encode(members)
|
||||
}
|
||||
|
||||
+6
-42
@@ -1,40 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="gruvbox">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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 -->
|
||||
<meta name="theme-color" content="#282828" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="theme-color" content="#282828" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="description" content="A chaotic, self-hosted Discord-like platform" />
|
||||
|
||||
<!-- Apple -->
|
||||
@@ -60,20 +33,11 @@
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script>
|
||||
// Register service worker (only for web, avoid in Tauri to prevent 404 cache bugs)
|
||||
// Register service worker
|
||||
if ('serviceWorker' in navigator) {
|
||||
if (!('__TAURI_INTERNALS__' in window) && !('__TAURI__' in window)) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
});
|
||||
} else {
|
||||
// In Tauri, aggressively unregister any old service workers that might be causing 404s
|
||||
navigator.serviceWorker.getRegistrations().then((registrations) => {
|
||||
for (let registration of registrations) {
|
||||
registration.unregister();
|
||||
}
|
||||
});
|
||||
}
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Generated
+1
-1
@@ -77,7 +77,7 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
||||
|
||||
[[package]]
|
||||
name = "app"
|
||||
version = "0.2.0"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
|
||||
+2
-15
@@ -5,7 +5,6 @@ import { Layout } from './components/Layout.tsx';
|
||||
import { ChatArea } from './components/ChatArea.tsx';
|
||||
import { UserSettings } from './components/UserSettings.tsx';
|
||||
import { BotManager } from './components/BotManager.tsx';
|
||||
import { BotStore } from './components/BotStore.tsx';
|
||||
import { CommandManager } from './components/CommandManager.tsx';
|
||||
import { RoleManager } from './components/RoleManager.tsx';
|
||||
import { JoinServer } from './components/JoinServer.tsx';
|
||||
@@ -70,23 +69,11 @@ function App() {
|
||||
/>
|
||||
<Route
|
||||
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={
|
||||
<ProtectedRoute>
|
||||
<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">
|
||||
<Link to="/bots" className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm">
|
||||
<Link to="/" className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm">
|
||||
← [BACK]
|
||||
</Link>
|
||||
<span className="text-gb-orange font-mono text-sm">BOT MANAGER</span>
|
||||
@@ -104,7 +91,7 @@ function App() {
|
||||
<ProtectedRoute>
|
||||
<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">
|
||||
<Link to="/bots/manage" 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]
|
||||
</Link>
|
||||
<span className="text-gb-orange font-mono text-sm">SLASH COMMANDS</span>
|
||||
|
||||
@@ -1,26 +1,8 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useVoiceStore } from '../stores/voice.ts';
|
||||
import { Track, RoomEvent } from 'livekit-client';
|
||||
import { Track } 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 }) {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
|
||||
@@ -30,35 +12,44 @@ function RemoteAudioTrack({ participant, room }: { participant: Participant; roo
|
||||
|
||||
let pub: TrackPublication | undefined;
|
||||
|
||||
const tryPlay = () => {
|
||||
el.play().catch(() => {
|
||||
pendingAudio.add(el);
|
||||
ensureInteractionListener();
|
||||
});
|
||||
};
|
||||
|
||||
const attachIfReady = () => {
|
||||
const attachTrack = () => {
|
||||
pub = participant.getTrackPublication(Track.Source.Microphone);
|
||||
if (pub?.track && el) {
|
||||
pub.track.attach(el);
|
||||
tryPlay();
|
||||
el.play().catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
attachIfReady();
|
||||
const detachTrack = () => {
|
||||
if (pub?.track && el) {
|
||||
pub.track.detach(el);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubscribed = (track: Track, _pub: TrackPublication, p: Participant) => {
|
||||
if (p.identity === participant.identity && _pub.source === Track.Source.Microphone) {
|
||||
attachTrack();
|
||||
|
||||
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);
|
||||
tryPlay();
|
||||
el.play().catch(() => {});
|
||||
}
|
||||
};
|
||||
room.on(RoomEvent.TrackSubscribed, handleSubscribed);
|
||||
room.on('trackSubscribed' as any, handleSubscribed);
|
||||
|
||||
return () => {
|
||||
if (pub?.track) pub.track.detach(el);
|
||||
pendingAudio.delete(el);
|
||||
room.off(RoomEvent.TrackSubscribed, handleSubscribed);
|
||||
detachTrack();
|
||||
room.off('trackPublished' as any, handlePublished);
|
||||
room.off('trackSubscribed' as any, handleSubscribed);
|
||||
};
|
||||
}, [participant, room]);
|
||||
|
||||
@@ -67,25 +58,7 @@ function RemoteAudioTrack({ participant, room }: { participant: Participant; roo
|
||||
|
||||
export function AudioRenderers() {
|
||||
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;
|
||||
|
||||
const remoteParticipants = Array.from(room.remoteParticipants.values());
|
||||
|
||||
@@ -2,30 +2,6 @@ import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useBotStore, type Bot } from '../stores/bot.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() {
|
||||
const bots = useBotStore((s) => s.bots);
|
||||
@@ -40,15 +16,10 @@ export function BotManager() {
|
||||
|
||||
const servers = useServerStore((s) => s.servers);
|
||||
const fetchServers = useServerStore((s) => s.fetchServers);
|
||||
const channelsByServer = useChannelStore((s) => s.channelsByServer);
|
||||
const fetchChannels = useChannelStore((s) => s.fetchChannels);
|
||||
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [createName, setCreateName] = 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 [editName, setEditName] = useState('');
|
||||
const [editDesc, setEditDesc] = useState('');
|
||||
@@ -66,20 +37,10 @@ export function BotManager() {
|
||||
e.preventDefault();
|
||||
if (!createName.trim()) return;
|
||||
try {
|
||||
// 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);
|
||||
const result = await createBot(createName.trim(), createDesc.trim());
|
||||
setTokenDisplay({ botId: result.id, token: result.token });
|
||||
setCreateName('');
|
||||
setCreateDesc('');
|
||||
setCreateType('');
|
||||
setCreateConfig({});
|
||||
setChannelServerId('');
|
||||
setShowCreate(false);
|
||||
} catch {
|
||||
// error handled in store
|
||||
@@ -141,8 +102,6 @@ export function BotManager() {
|
||||
setEditDesc(bot.description);
|
||||
};
|
||||
|
||||
const typeConfig = createType ? BOT_TYPE_CONFIGS[createType] : null;
|
||||
|
||||
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">
|
||||
@@ -221,70 +180,13 @@ export function BotManager() {
|
||||
placeholder="what does this bot do?"
|
||||
/>
|
||||
</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">
|
||||
<button type="submit" className="terminal-button" disabled={loading || !createName.trim()}>
|
||||
{loading ? '[CREATING...]' : '[SAVE]'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowCreate(false); setCreateName(''); setCreateDesc(''); setCreateType(''); setCreateConfig({}); setChannelServerId(''); }}
|
||||
onClick={() => { setShowCreate(false); setCreateName(''); setCreateDesc(''); }}
|
||||
className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm"
|
||||
>
|
||||
[CANCEL]
|
||||
@@ -353,9 +255,6 @@ export function BotManager() {
|
||||
/>
|
||||
)}
|
||||
[{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>
|
||||
{bot.description && (
|
||||
<p className="text-gb-fg-f font-mono text-xs mt-1 truncate">
|
||||
@@ -449,8 +348,8 @@ export function BotManager() {
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-6 pt-4 border-t border-gb-bg-t">
|
||||
<Link to="/bots" className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm">
|
||||
{'<'} [BACK TO STORE]
|
||||
<Link to="/" className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm">
|
||||
{'<'} [BACK TO CHAT]
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useBotStore, type StoreBot } from '../stores/bot.ts';
|
||||
import { useServerStore } from '../stores/server.ts';
|
||||
import { useAuthStore } from '../stores/auth.ts';
|
||||
|
||||
export function BotStore() {
|
||||
const storeBots = useBotStore((s) => s.storeBots);
|
||||
const loading = useBotStore((s) => s.loading);
|
||||
const error = useBotStore((s) => s.error);
|
||||
const fetchStoreBots = useBotStore((s) => s.fetchStoreBots);
|
||||
const addToServer = useBotStore((s) => s.addToServer);
|
||||
|
||||
const servers = useServerStore((s) => s.servers);
|
||||
const fetchServers = useServerStore((s) => s.fetchServers);
|
||||
const currentUserId = useAuthStore((s) => s.user?.id);
|
||||
|
||||
const [addBotId, setAddBotId] = useState<string | null>(null);
|
||||
const [selectedServer, setSelectedServer] = useState('');
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchStoreBots();
|
||||
fetchServers();
|
||||
}, [fetchStoreBots, fetchServers]);
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!addBotId || !selectedServer) return;
|
||||
setAdding(true);
|
||||
try {
|
||||
await addToServer(addBotId, selectedServer);
|
||||
setAddBotId(null);
|
||||
setSelectedServer('');
|
||||
fetchStoreBots(); // refresh counts
|
||||
} catch {
|
||||
// error in store
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filtered = storeBots.filter((b) =>
|
||||
b.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
b.description.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full bg-gb-bg p-4 md:p-8 overflow-y-auto">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="border border-gb-bg-t p-6">
|
||||
<pre className="text-gb-orange font-mono text-center mb-2">
|
||||
{'┌──────────────────────────────────┐\n'}
|
||||
{'│ === BOT STORE === │\n'}
|
||||
{'└──────────────────────────────────┘'}
|
||||
</pre>
|
||||
<p className="text-gb-fg-f font-mono text-xs text-center mb-6">
|
||||
browse and add bots to your servers
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<p className="text-gb-red text-sm font-mono mb-4">ERR: {error}</p>
|
||||
)}
|
||||
|
||||
{/* Search */}
|
||||
<div className="mb-6">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="search bots..."
|
||||
className="terminal-input w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bot list */}
|
||||
{loading && storeBots.length === 0 && (
|
||||
<p className="text-gb-fg-f font-mono text-sm">[loading...]</p>
|
||||
)}
|
||||
{!loading && filtered.length === 0 && (
|
||||
<p className="text-gb-fg-f font-mono text-sm">[no bots found]</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{filtered.map((bot) => (
|
||||
<StoreBotCard
|
||||
key={bot.id}
|
||||
bot={bot}
|
||||
isOwner={bot.owner_id === currentUserId}
|
||||
onAdd={() => { setAddBotId(bot.id); setSelectedServer(''); }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add to server modal */}
|
||||
{addBotId && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div className="border border-gb-bg-t bg-gb-bg p-6 max-w-md w-full mx-4">
|
||||
<p className="text-gb-orange font-mono text-sm mb-4">
|
||||
{'>'} ADD BOT TO SERVER
|
||||
</p>
|
||||
<select
|
||||
value={selectedServer}
|
||||
onChange={(e) => setSelectedServer(e.target.value)}
|
||||
className="terminal-input w-full mb-4"
|
||||
>
|
||||
<option value="">-- select server --</option>
|
||||
{servers.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAdd}
|
||||
className="terminal-button text-xs"
|
||||
disabled={!selectedServer || adding}
|
||||
>
|
||||
{adding ? '[ADDING...]' : '[ADD]'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setAddBotId(null); setSelectedServer(''); }}
|
||||
className="text-gb-fg-f hover:text-gb-aqua font-mono text-xs"
|
||||
>
|
||||
[CANCEL]
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-6 pt-4 border-t border-gb-bg-t flex justify-between">
|
||||
<Link to="/" className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm">
|
||||
{'<'} [BACK TO CHAT]
|
||||
</Link>
|
||||
<Link to="/bots/manage" className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm">
|
||||
[MY BOTS]
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StoreBotCard({ bot, isOwner, onAdd }: { bot: StoreBot; isOwner: boolean; onAdd: () => void }) {
|
||||
return (
|
||||
<div className="border border-gb-bg-t p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-gb-green font-mono text-sm">
|
||||
{bot.avatar && (
|
||||
<img
|
||||
src={bot.avatar}
|
||||
alt=""
|
||||
className="inline w-5 h-5 mr-1 align-middle border border-gb-bg-t"
|
||||
/>
|
||||
)}
|
||||
[{bot.name}]
|
||||
{isOwner && (
|
||||
<span className="text-gb-fg-f text-xs ml-2">(yours)</span>
|
||||
)}
|
||||
</p>
|
||||
{bot.description && (
|
||||
<p className="text-gb-fg-f font-mono text-xs mt-1">{bot.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<p className="text-gb-aqua font-mono text-xs">
|
||||
{bot.server_count} {bot.server_count === 1 ? 'server' : 'servers'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAdd}
|
||||
className="terminal-button text-xs"
|
||||
>
|
||||
[ADD TO SERVER]
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -33,7 +33,6 @@ export function CalendarView({ channelId, channelName }: CalendarViewProps) {
|
||||
const [end, setEnd] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
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 monthEnd = useMemo(() => new Date(date.getFullYear(), date.getMonth() + 1, 0), [date]);
|
||||
@@ -50,11 +49,7 @@ export function CalendarView({ channelId, channelName }: CalendarViewProps) {
|
||||
const to = `${date.getFullYear()}-${String(date.getMonth() + 2).padStart(2, '0')}-01T00:00:00Z`;
|
||||
setLoading(true);
|
||||
api.get<CalendarEvent[]>(`/channels/${channelId}/events?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`)
|
||||
.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))
|
||||
.then((data) => setEvents(Array.isArray(data) ? data : []))
|
||||
.finally(() => setLoading(false));
|
||||
}, [channelId, date]);
|
||||
|
||||
@@ -66,29 +61,6 @@ 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 () => {
|
||||
if (!title || !start) return;
|
||||
const payload = { title, start_time: start, end_time: end || undefined, description: description || undefined };
|
||||
@@ -113,11 +85,7 @@ export function CalendarView({ channelId, channelName }: CalendarViewProps) {
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
<button onClick={() => setShowForm((p) => !p)} className="text-xs text-gb-fg-f hover:text-gb-orange font-mono">[NEW EVENT]</button>
|
||||
</div>
|
||||
{showForm && (
|
||||
<div className="p-3 border-b border-gb-bg-t space-y-2 font-mono text-xs">
|
||||
@@ -135,66 +103,30 @@ export function CalendarView({ channelId, channelName }: CalendarViewProps) {
|
||||
</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">
|
||||
{view === 'grid' ? (
|
||||
<>
|
||||
<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>)}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{days.map((day, idx) => (
|
||||
<div key={idx} className="min-h-16 border border-gb-bg-t p-1 text-xs">
|
||||
{day !== null && (
|
||||
<>
|
||||
<div className="text-gb-fg-f font-mono">{day}</div>
|
||||
{eventsForDay(day).map((ev) => (
|
||||
<button
|
||||
key={ev.id}
|
||||
onClick={() => setSelectedEvent(ev)}
|
||||
className="w-full text-left mt-1 px-1 py-0.5 truncate text-gb-bg font-mono"
|
||||
style={{ backgroundColor: ev.color || '#b8bb26' }}
|
||||
>
|
||||
{ev.title}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</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 (
|
||||
<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>)}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{days.map((day, idx) => (
|
||||
<div key={idx} className="min-h-16 border border-gb-bg-t p-1 text-xs">
|
||||
{day !== null && (
|
||||
<>
|
||||
<div className="text-gb-fg-f font-mono">{day}</div>
|
||||
{eventsForDay(day).map((ev) => (
|
||||
<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"
|
||||
className="w-full text-left mt-1 px-1 py-0.5 truncate text-gb-bg font-mono"
|
||||
style={{ backgroundColor: ev.color || '#b8bb26' }}
|
||||
>
|
||||
<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>
|
||||
{ev.title}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{selectedEvent && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gb-bg/90 p-4" onClick={() => setSelectedEvent(null)}>
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../lib/api.ts';
|
||||
import {
|
||||
usePermissionStore,
|
||||
PERMS,
|
||||
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';
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePermissionStore, PERMISSION_LABELS, PERMS, type ChannelOverride, hasPermission } from "../stores/permissions.ts";
|
||||
import { useRoleStore } from "../stores/role.ts";
|
||||
import { useMemberStore } from "../stores/member.ts";
|
||||
import { useChannelStore } from "../stores/channel.ts";
|
||||
import { api } from "../lib/api.ts";
|
||||
|
||||
interface ChannelSettingsModalProps {
|
||||
serverId: string;
|
||||
@@ -18,625 +12,226 @@ interface ChannelSettingsModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type TargetType = 'role' | 'user';
|
||||
type TriState = 'allow' | 'deny' | 'inherit';
|
||||
type Tab = 'overview' | 'permissions';
|
||||
type TargetMode = 'role' | 'user';
|
||||
|
||||
interface DraftOverride {
|
||||
target_type: TargetType;
|
||||
target_id: string;
|
||||
interface OverrideFormData {
|
||||
targetType: TargetMode;
|
||||
targetId: string;
|
||||
allow: number;
|
||||
deny: number;
|
||||
}
|
||||
|
||||
// Channel-scoped perms only (admin / manage-server stay at role level).
|
||||
const CHANNEL_PERM_KEYS = new Set<PermissionKey>([
|
||||
'VIEW_CHANNEL',
|
||||
'SEND_MESSAGES',
|
||||
'MANAGE_MESSAGES',
|
||||
'ADD_REACTIONS',
|
||||
'EMBED_LINKS',
|
||||
'ATTACH_FILES',
|
||||
'MENTION_EVERYONE',
|
||||
'USE_EXTERNAL_EMOJIS',
|
||||
'CREATE_INSTANT_INVITE',
|
||||
'CONNECT_VOICE',
|
||||
'SPEAK_VOICE',
|
||||
'SHARE_SCREEN',
|
||||
'MUTE_MEMBERS',
|
||||
]);
|
||||
|
||||
const CHANNEL_PERM_CATEGORIES = PERM_CATEGORIES.map((cat) => ({
|
||||
...cat,
|
||||
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 OverrideMatrix({ allow, deny, onChange }: { allow: number; deny: number; onChange: (allow: number, deny: number) => void }) {
|
||||
const toggle = (flag: number, state: 'allow' | 'deny' | 'inherit') => {
|
||||
let nextAllow = allow;
|
||||
let nextDeny = deny;
|
||||
if (state === 'allow') {
|
||||
nextAllow |= flag;
|
||||
nextDeny &= ~flag;
|
||||
} else if (state === 'deny') {
|
||||
nextAllow &= ~flag;
|
||||
nextDeny |= flag;
|
||||
} else {
|
||||
nextAllow &= ~flag;
|
||||
nextDeny &= ~flag;
|
||||
}
|
||||
onChange(nextAllow, nextDeny);
|
||||
};
|
||||
|
||||
const state = (flag: number): 'allow' | 'deny' | 'inherit' => {
|
||||
if (hasPermission(allow, flag)) return 'allow';
|
||||
if (hasPermission(deny, flag)) return 'deny';
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function emptyDraft(type: TargetType, id: string): DraftOverride {
|
||||
return { target_type: type, target_id: id, allow: 0, deny: 0 };
|
||||
}
|
||||
|
||||
function isDirty(saved: ChannelOverride | undefined, draft: DraftOverride): boolean {
|
||||
if (!saved) return draft.allow !== 0 || draft.deny !== 0;
|
||||
return saved.allow_bitflags !== draft.allow || saved.deny_bitflags !== draft.deny;
|
||||
}
|
||||
|
||||
function triState(allow: number, deny: number, flag: number): TriState {
|
||||
if ((deny & flag) === flag) return 'deny';
|
||||
if ((allow & flag) === flag) return 'allow';
|
||||
return 'inherit';
|
||||
}
|
||||
|
||||
function applyTri(draft: DraftOverride, flag: number, state: TriState): DraftOverride {
|
||||
let allow = draft.allow & ~flag;
|
||||
let deny = draft.deny & ~flag;
|
||||
if (state === 'allow') allow |= flag;
|
||||
if (state === 'deny') deny |= flag;
|
||||
return { ...draft, allow, deny };
|
||||
}
|
||||
|
||||
function displayName(member: Member): string {
|
||||
return member.nickname || member.display_name || member.username;
|
||||
}
|
||||
|
||||
export function ChannelSettingsModal({
|
||||
serverId,
|
||||
channelId,
|
||||
channelName,
|
||||
onClose,
|
||||
}: ChannelSettingsModalProps) {
|
||||
const [name, setName] = useState(channelName);
|
||||
export function ChannelSettingsModal({ serverId, channelId, channelName, onClose }: ChannelSettingsModalProps) {
|
||||
const [tab, setTab] = useState<Tab>('overview');
|
||||
const [form, setForm] = useState<OverrideFormData>({ targetType: 'role', targetId: '', allow: 0, deny: 0 });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState(channelName);
|
||||
const [savingName, setSavingName] = useState(false);
|
||||
const [nameError, setNameError] = useState<string | null>(null);
|
||||
const [tab, setTab] = useState<'general' | 'permissions'>('general');
|
||||
const updateChannel = useChannelStore((s) => s.updateChannel);
|
||||
|
||||
const overridesByChannel = usePermissionStore((s) => s.overridesByChannel);
|
||||
const overrides = usePermissionStore((s) => s.overridesByChannel[channelId] || []);
|
||||
const fetchOverrides = usePermissionStore((s) => s.fetchOverrides);
|
||||
const setOverride = usePermissionStore((s) => s.setOverride);
|
||||
const deleteOverride = usePermissionStore((s) => s.deleteOverride);
|
||||
|
||||
const allRoles = useRoleStore((s) => s.roles);
|
||||
const roles = useRoleStore((s) => s.roles);
|
||||
const fetchRoles = useRoleStore((s) => s.fetchRoles);
|
||||
const membersByServer = useMemberStore((s) => s.membersByServer);
|
||||
const members = useMemberStore((s) => s.membersByServer[serverId] || []);
|
||||
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(() => {
|
||||
void fetchOverrides(channelId);
|
||||
void fetchRoles(serverId);
|
||||
void fetchMembers(serverId);
|
||||
fetchOverrides(channelId).catch(() => setError('Failed to load overrides'));
|
||||
fetchRoles(serverId).catch(() => {});
|
||||
fetchMembers(serverId).catch(() => {});
|
||||
}, [channelId, serverId, fetchOverrides, fetchRoles, fetchMembers]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
if (addOpen) {
|
||||
setAddOpen(false);
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose, addOpen]);
|
||||
}, [onClose]);
|
||||
|
||||
// Auto-select first override when list loads / selection invalid.
|
||||
useEffect(() => {
|
||||
if (tab !== 'permissions') return;
|
||||
if (overrides.length === 0) {
|
||||
if (selectedKey && !draft) {
|
||||
// local-only draft without saved row is fine
|
||||
return;
|
||||
}
|
||||
if (!selectedKey) {
|
||||
setSelectedKey(null);
|
||||
setDraft(null);
|
||||
}
|
||||
const handleSave = async () => {
|
||||
if (!form.targetId) {
|
||||
setError('Select a target');
|
||||
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);
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await setOverride(channelId, draft.target_type, draft.target_id, draft.allow, draft.deny);
|
||||
// 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));
|
||||
await setOverride(channelId, form.targetType, form.targetId, form.allow, form.deny);
|
||||
setForm({ targetType: 'role', targetId: '', allow: 0, deny: 0 });
|
||||
} catch (err) {
|
||||
setPermError(err instanceof Error ? err.message : 'Failed to save overrides');
|
||||
setError(err instanceof Error ? err.message : 'Failed to save override');
|
||||
} finally {
|
||||
setSavingPerms(false);
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async () => {
|
||||
if (!draft) return;
|
||||
const { label } = resolveLabel(draft.target_type, draft.target_id);
|
||||
if (!window.confirm(`Remove permission overrides for ${label}?`)) return;
|
||||
setPermError(null);
|
||||
try {
|
||||
const exists = overrides.some(
|
||||
(o) => o.target_type === draft.target_type && o.target_id === draft.target_id,
|
||||
);
|
||||
if (exists) {
|
||||
await deleteOverride(channelId, draft.target_type, draft.target_id);
|
||||
}
|
||||
setSelectedKey(null);
|
||||
setDraft(null);
|
||||
} catch (err) {
|
||||
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;
|
||||
const handleSaveName = async () => {
|
||||
const name = editName.trim();
|
||||
if (!name || name === channelName) return;
|
||||
setSavingName(true);
|
||||
setNameError(null);
|
||||
setError(null);
|
||||
try {
|
||||
await api.patch(`/servers/${serverId}/channels/${channelId}`, { name: name.trim() });
|
||||
onClose();
|
||||
const updated = await api.patch<{ id: string; server_id: string; name: string; type: string; category: string | null; position: number; group_id?: string | null }>(
|
||||
`/servers/${serverId}/channels/${channelId}`,
|
||||
{ name }
|
||||
);
|
||||
updateChannel(updated as any);
|
||||
} catch (err) {
|
||||
setNameError(err instanceof Error ? err.message : 'Failed to update channel');
|
||||
setError(err instanceof Error ? err.message : 'Failed to rename channel');
|
||||
} finally {
|
||||
setSavingName(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Sidebar entries: all saved overrides + unsaved new draft not yet in list.
|
||||
const sidebarEntries = useMemo(() => {
|
||||
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]);
|
||||
const targets = form.targetType === 'role'
|
||||
? roles.filter((r) => !r.is_default).map((r) => ({ id: r.id, label: r.name }))
|
||||
: members.map((m) => ({ id: m.id, label: m.username }));
|
||||
|
||||
return (
|
||||
<div
|
||||
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="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-[600px] max-h-[80vh] 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">
|
||||
<span className="text-sm text-gb-orange">CHANNEL SETTINGS: #{channelName}</span>
|
||||
<button type="button" onClick={onClose} className="text-xs text-gb-red">
|
||||
[x]
|
||||
</button>
|
||||
<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>
|
||||
<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 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>
|
||||
</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-1 overflow-y-auto p-4">
|
||||
{tab === 'overview' && (
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs text-gb-fg-f">Channel name</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSaveName(); }}
|
||||
className="terminal-input text-sm flex-1"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={savingName || !name.trim() || name.trim() === channelName}
|
||||
className="terminal-button disabled:opacity-50"
|
||||
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>
|
||||
<button type="button" onClick={onClose} className="text-xs text-gb-fg-f hover:text-gb-red">
|
||||
[CANCEL]
|
||||
{savingName ? 'SAVING...' : '[SAVE]'}
|
||||
</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>
|
||||
{error && <div className="text-xs text-gb-red">ERR: {error}</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]
|
||||
)}
|
||||
{tab === 'permissions' && (
|
||||
<div className="space-y-3">
|
||||
<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="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 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>
|
||||
))}
|
||||
</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 className="flex border-b border-gb-bg-t">
|
||||
<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 className="p-2 border-b border-gb-bg-t">
|
||||
<input
|
||||
type="text"
|
||||
value={addQuery}
|
||||
onChange={(e) => setAddQuery(e.target.value)}
|
||||
placeholder={addTab === 'role' ? 'filter roles...' : 'filter members...'}
|
||||
className="terminal-input w-full text-xs"
|
||||
autoFocus
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
{addTab === 'role' &&
|
||||
filteredRoles.map((role) => (
|
||||
<button
|
||||
key={role.id}
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
{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>
|
||||
</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
|
||||
onClick={handleSave}
|
||||
disabled={saving || !form.targetId}
|
||||
className="mt-2 px-3 py-1 bg-gb-orange text-gb-bg text-xs disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'SAVING...' : '[SAVE OVERRIDE]'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -13,8 +13,7 @@ import Picker, { Theme } from 'emoji-picker-react';
|
||||
import { CommandDropdown } from "./CommandDropdown";
|
||||
import { findCommand, SLASH_COMMANDS } from "../lib/slashCommands";
|
||||
import { PollDisplay, CreatePollModal } from "./Poll.tsx";
|
||||
import { MentionDropdown, buildMentionOptions } from "./MentionDropdown";
|
||||
import { usePermissions } from "../lib/usePermissions.ts";
|
||||
import { MentionDropdown } from "./MentionDropdown";
|
||||
import { useReadStatesStore } from "../stores/readStates.ts";
|
||||
import { MessageSearch } from "./MessageSearch";
|
||||
import { ThreadListPanel } from "./ThreadListPanel.tsx";
|
||||
@@ -46,7 +45,7 @@ function formatTime(iso: string): string {
|
||||
}
|
||||
|
||||
function renderContent(content: string, memberUsernames: Set<string>) {
|
||||
const segments: { type: "text" | "mention"; value: string; special?: boolean }[] = [];
|
||||
const segments: { type: "text" | "mention"; value: string }[] = [];
|
||||
const mentionRe = /@([a-zA-Z0-9_.-]+)/g;
|
||||
let last = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
@@ -55,13 +54,8 @@ function renderContent(content: string, memberUsernames: Set<string>) {
|
||||
segments.push({ type: "text", value: content.slice(last, match.index) });
|
||||
}
|
||||
const username = match[1];
|
||||
const lower = username.toLowerCase();
|
||||
if (lower === "everyone" || lower === "channel" || lower === "here" || memberUsernames.has(username)) {
|
||||
segments.push({
|
||||
type: "mention",
|
||||
value: username,
|
||||
special: lower === "everyone" || lower === "channel" || lower === "here",
|
||||
});
|
||||
if (memberUsernames.has(username)) {
|
||||
segments.push({ type: "mention", value: username });
|
||||
} else {
|
||||
segments.push({ type: "text", value: match[0] });
|
||||
}
|
||||
@@ -77,10 +71,7 @@ function renderContent(content: string, memberUsernames: Set<string>) {
|
||||
const nextSeg = segments[idx + 1];
|
||||
const needsSpace = !nextSeg || (nextSeg.type === "text" && !nextSeg.value.startsWith(" "));
|
||||
return (
|
||||
<span
|
||||
key={idx}
|
||||
className={seg.special ? "text-gb-orange font-bold bg-gb-orange/15 px-0.5 rounded-sm" : "text-gb-aqua"}
|
||||
>
|
||||
<span key={idx} className="text-gb-aqua">
|
||||
@{seg.value}{needsSpace ? " " : ""}
|
||||
</span>
|
||||
);
|
||||
@@ -225,14 +216,12 @@ const MessageItem = memo(({
|
||||
)}
|
||||
<span className="text-gb-fg-f">{formatTime(message.created_at)}</span>{' '}
|
||||
{message.pinned && <span className="text-gb-orange font-bold mr-1">[PIN]</span>}
|
||||
<span className={`${message.author_bot ? 'text-gb-green' : 'text-gb-aqua'} hover:text-gb-orange cursor-pointer`} onClick={(e) => {
|
||||
<span className="text-gb-aqua hover:text-gb-orange cursor-pointer" onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!message.author_bot) onAuthorClick(message.author_id);
|
||||
onAuthorClick(message.author_id);
|
||||
}}>
|
||||
<{message.author_bot ? (message.bot_name || message.author_username) : (members.find((m) => m.id === message.author_id)?.nickname || message.author_username)}>
|
||||
</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>}
|
||||
{" "}
|
||||
<{members.find((m) => m.id === message.author_id)?.nickname || message.author_username}>
|
||||
</span>{" "}
|
||||
<span className="text-gb-fg">{renderContent(message.content, memberUsernames)}</span>
|
||||
{renderEmbeds(message.embeds)}
|
||||
{message.poll && <PollDisplay poll={message.poll} channelId={message.channel_id} />}
|
||||
@@ -335,10 +324,7 @@ export function ChatArea() {
|
||||
const channels = activeServerId ? channelsByServer[activeServerId] || [] : [];
|
||||
const activeChannel = channels.find((c) => c.id === activeChannelId);
|
||||
const members = activeServerId ? membersByServer[activeServerId] || [] : [];
|
||||
// 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 memberUsernames = useMemo(() => new Set(members.map((m) => m.username)), [members]);
|
||||
const markRead = useReadStatesStore((s) => s.markRead);
|
||||
const readStates = useReadStatesStore((s) => s.states);
|
||||
|
||||
@@ -565,7 +551,10 @@ export function ChatArea() {
|
||||
if (!isDropdownOpen) return;
|
||||
|
||||
const itemCount = mq !== null
|
||||
? buildMentionOptions(mq, humanMembers, canMentionEveryone).length
|
||||
? members.filter((m) =>
|
||||
m.username.toLowerCase().includes(mq.toLowerCase()) ||
|
||||
m.display_name?.toLowerCase().includes(mq.toLowerCase())
|
||||
).slice(0, 6).length
|
||||
: cq !== null
|
||||
? SLASH_COMMANDS.filter((c) => c.name.startsWith(cq.toLowerCase())).slice(0, 8).length
|
||||
: 0;
|
||||
@@ -582,14 +571,13 @@ export function ChatArea() {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (mq !== null) {
|
||||
const options = buildMentionOptions(mq, humanMembers, canMentionEveryone);
|
||||
const selected = options[di];
|
||||
if (selected) {
|
||||
if (selected.kind === "special") {
|
||||
handleMentionSelect(selected.label);
|
||||
} else {
|
||||
handleMentionSelect(selected.member.username);
|
||||
}
|
||||
const q = mq.toLowerCase();
|
||||
const filtered = members.filter((m) =>
|
||||
m.username.toLowerCase().includes(q) ||
|
||||
m.display_name?.toLowerCase().includes(q)
|
||||
).slice(0, 6);
|
||||
if (filtered[di]) {
|
||||
handleMentionSelect(filtered[di].username);
|
||||
}
|
||||
} else if (cq !== null) {
|
||||
const q = cq.toLowerCase();
|
||||
@@ -628,7 +616,7 @@ export function ChatArea() {
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [humanMembers, canMentionEveryone, currentUser, activeChannelId, sendMessage, replyToMessage, handleMentionSelect]);
|
||||
}, [members, currentUser, activeChannelId, sendMessage, replyToMessage, handleMentionSelect]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (mentionQuery !== null || commandQuery !== null) return;
|
||||
@@ -804,7 +792,7 @@ export function ChatArea() {
|
||||
<ListView channelId={activeChannelId} channelName={activeChannel.name} />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex-1 flex flex-col min-w-0 min-h-0 relative">
|
||||
<div className="flex-1 flex flex-col min-w-0 relative">
|
||||
{showSearch && activeChannelId && activeChannel && (
|
||||
<MessageSearch
|
||||
channelId={activeChannelId}
|
||||
@@ -912,13 +900,7 @@ export function ChatArea() {
|
||||
)}
|
||||
<div className="p-3 relative">
|
||||
{mentionQuery !== null && (
|
||||
<MentionDropdown
|
||||
query={mentionQuery}
|
||||
members={humanMembers}
|
||||
selectedIndex={dropdownIndex}
|
||||
onSelect={handleMentionSelect}
|
||||
canMentionEveryone={canMentionEveryone}
|
||||
/>
|
||||
<MentionDropdown query={mentionQuery} members={members} selectedIndex={dropdownIndex} onSelect={handleMentionSelect} />
|
||||
)}
|
||||
{commandQuery !== null && (
|
||||
<CommandDropdown
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useVoiceStore } from '../stores/voice.ts';
|
||||
import { saveDevicePref } from '../stores/voice.ts';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
@@ -49,7 +48,6 @@ export function DeviceSettingsModal({ onClose }: Props) {
|
||||
if (!room) return;
|
||||
try {
|
||||
await room.switchActiveDevice(kind, deviceId);
|
||||
saveDevicePref(kind, deviceId);
|
||||
if (kind === 'audioinput') setActiveAudioInput(deviceId);
|
||||
if (kind === 'audiooutput') setActiveAudioOutput(deviceId);
|
||||
if (kind === 'videoinput') setActiveVideoInput(deviceId);
|
||||
|
||||
+121
-130
@@ -6,7 +6,6 @@ import { useWebSocketStore } from '../stores/ws.ts';
|
||||
import { useServerStore } from '../stores/server.ts';
|
||||
import { useChannelStore } from '../stores/channel.ts';
|
||||
import { useLayoutStore } from '../stores/layout.ts';
|
||||
import { useRoleStore } from '../stores/role.ts';
|
||||
import { ServerBar } from './ServerBar.tsx';
|
||||
import { ChannelList } from './ChannelList.tsx';
|
||||
import { ConversationList } from './ConversationList.tsx';
|
||||
@@ -14,21 +13,22 @@ import { NotificationPrompt } from './NotificationPrompt.tsx';
|
||||
import { MemberList } from './MemberList.tsx';
|
||||
import { VoicePanel } from './VoicePanel.tsx';
|
||||
import { ServerSettingsModal } from './ServerSettingsModal.tsx';
|
||||
import { ThemeToggle } from './ThemeToggle.tsx';
|
||||
|
||||
const STATUS_CYCLE: UserStatus[] = ['online', 'idle', 'dnd', 'offline'];
|
||||
function statusColor(status: UserStatus): string {
|
||||
switch (status) {
|
||||
case 'online': return 'bg-gb-green';
|
||||
case 'idle': return 'bg-gb-yellow';
|
||||
case 'dnd': return 'bg-gb-red';
|
||||
default: return 'bg-gb-gray';
|
||||
case 'online':
|
||||
return 'bg-gb-green';
|
||||
case 'idle':
|
||||
return 'bg-gb-yellow';
|
||||
case 'dnd':
|
||||
return 'bg-gb-red';
|
||||
default:
|
||||
return 'bg-gb-gray';
|
||||
}
|
||||
}
|
||||
function statusLabel(status: UserStatus): string {
|
||||
return status.toUpperCase();
|
||||
}
|
||||
|
||||
export function Layout() {
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||
const isLoading = useAuthStore((state) => state.isLoading);
|
||||
@@ -45,35 +45,27 @@ export function Layout() {
|
||||
const setMobileView = useLayoutStore((s) => s.setMobileView);
|
||||
const [showServerSettings, setShowServerSettings] = useState(false);
|
||||
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 [activeTab, setActiveTab] = useState<'chat' | 'voice'>('chat');
|
||||
|
||||
// Load server roles + current user's role assignments for accurate client permission gates.
|
||||
useEffect(() => {
|
||||
if (!activeServerId || !user?.id) return;
|
||||
void fetchRoles(activeServerId);
|
||||
void fetchMyRoles(activeServerId, user.id);
|
||||
}, [activeServerId, user?.id, fetchRoles, fetchMyRoles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentVoiceRoom) setActiveTab('voice');
|
||||
else setActiveTab('chat');
|
||||
if (currentVoiceRoom) {
|
||||
setActiveTab('voice');
|
||||
} else {
|
||||
setActiveTab('chat');
|
||||
}
|
||||
}, [currentVoiceRoom]);
|
||||
|
||||
useEffect(() => {
|
||||
wsConnect();
|
||||
return () => { wsDisconnect(); };
|
||||
}, [wsConnect, wsDisconnect]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated && location.pathname !== '/login') {
|
||||
navigate('/login', { replace: true });
|
||||
}
|
||||
}, [isLoading, isAuthenticated, location.pathname, navigate]);
|
||||
|
||||
// Navigate to chat view on mobile when a channel/DM is selected
|
||||
// Navigate to chat when a channel/DM is selected on mobile
|
||||
useEffect(() => {
|
||||
const unsub = useChannelStore.subscribe((state, prev) => {
|
||||
if (state.activeChannelId !== prev.activeChannelId && state.activeChannelId) {
|
||||
@@ -82,52 +74,68 @@ export function Layout() {
|
||||
});
|
||||
return unsub;
|
||||
}, [setMobileView]);
|
||||
|
||||
// Also switch to chat on mobile when navigating to a DM
|
||||
useEffect(() => {
|
||||
if (location.pathname.startsWith('/dm/') && location.pathname !== '/dm') {
|
||||
setMobileView('chat');
|
||||
}
|
||||
}, [location.pathname, setMobileView]);
|
||||
|
||||
const handleStatusChange = async (status: UserStatus) => {
|
||||
setShowStatusMenu(false);
|
||||
try { await updateProfile({ status }); } catch { /* store surfaces error */ }
|
||||
try {
|
||||
await updateProfile({ status });
|
||||
} catch (err) {
|
||||
// Error is surfaced via auth store; menu closes optimistically.
|
||||
}
|
||||
};
|
||||
const currentStatus = user?.status ?? 'offline';
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="h-full w-full flex items-center justify-center bg-gb-bg text-gb-fg-f font-mono">[booting...]</div>;
|
||||
return (
|
||||
<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 (
|
||||
<div className="h-full w-full bg-gb-bg text-gb-fg font-mono flex flex-col">
|
||||
{/* Outer terminal frame with safe area */}
|
||||
<div className="flex-1 terminal-border bg-gb-bg-h flex flex-col min-h-0"
|
||||
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="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="flex-1 terminal-border bg-gb-bg-h flex flex-col min-h-0">
|
||||
{/* Top bar */}
|
||||
<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">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-gb-orange font-bold shrink-0 text-sm md:text-base">DUMPSTER</span>
|
||||
{/* Mobile: hamburger to show sidebar */}
|
||||
<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 className="flex items-center gap-1 md:gap-4 text-xs text-gb-fg-s min-w-0">
|
||||
<div className="flex items-center gap-2 md:gap-4 text-xs text-gb-fg-s min-w-0">
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowStatusMenu((prev) => !prev)}
|
||||
className="flex items-center gap-1 hover:text-gb-fg transition-colors"
|
||||
className="flex items-center gap-1 md:gap-2 hover:text-gb-fg transition-colors"
|
||||
title="Change status"
|
||||
>
|
||||
<span className={`w-2 h-2 rounded-full ${statusColor(currentStatus)} shrink-0`} />
|
||||
<span className="text-gb-aqua hidden sm:inline truncate max-w-[80px]">{user?.username || 'unknown'}</span>
|
||||
<span className={`w-2.5 h-2.5 rounded-full ${statusColor(currentStatus)}`} />
|
||||
<span className="text-gb-aqua hidden sm:inline">{user?.username || 'unknown'}</span>
|
||||
<span className="text-gb-fg-f hidden sm:inline">[{statusLabel(currentStatus)}]</span>
|
||||
</button>
|
||||
{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">
|
||||
{STATUS_CYCLE.map((s) => (
|
||||
<button key={s} 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">
|
||||
<button
|
||||
key={s}
|
||||
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>{statusLabel(s)}</span>
|
||||
</button>
|
||||
@@ -135,47 +143,76 @@ export function Layout() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
<Link to="/settings" className="terminal-button text-xs hidden sm:inline-flex">[SETTINGS]</Link>
|
||||
{activeServerId && (
|
||||
<button onClick={() => setShowServerSettings(true)}
|
||||
className="terminal-button text-xs px-1.5 py-0.5 hidden md:inline-flex">
|
||||
<button
|
||||
onClick={() => setShowServerSettings(true)}
|
||||
className="terminal-button text-xs hidden md:inline-flex"
|
||||
>
|
||||
[SERVER SETTINGS]
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => logout().then(() => navigate('/login'))}
|
||||
className="terminal-button text-xs px-1.5 py-0.5">
|
||||
{/* Mobile: members toggle */}
|
||||
{!isDM && (
|
||||
<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]
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
{/* Main content area */}
|
||||
<div className="flex-1 flex min-h-0 relative">
|
||||
{/* Sidebar — desktop: always visible, mobile: overlay when sidebar tab active */}
|
||||
<div className={`
|
||||
${mobileView === 'sidebar' ? 'flex' : 'hidden'} md:flex
|
||||
absolute md:relative inset-0 md:inset-auto z-30 md:z-auto flex-shrink-0
|
||||
`}>
|
||||
{/* Sidebar: ServerBar + ChannelList/ConversationList */}
|
||||
{/* Desktop: always visible as left columns */}
|
||||
{/* Mobile: full-screen overlay when mobileView === 'sidebar' */}
|
||||
<div
|
||||
className={`
|
||||
${mobileView === 'sidebar' ? 'flex' : 'hidden'}
|
||||
md:flex
|
||||
absolute md:relative inset-0 md:inset-auto z-30 md:z-auto
|
||||
flex-shrink-0
|
||||
`}
|
||||
>
|
||||
<ServerBar />
|
||||
{isDM ? <ConversationList /> : <ChannelList />}
|
||||
{/* Close overlay on mobile by tapping background */}
|
||||
<div className="flex-1 md:hidden" onClick={() => setMobileView('chat')} />
|
||||
{/* Close sidebar on mobile after selection */}
|
||||
<div
|
||||
className="flex-1 md:hidden"
|
||||
onClick={() => setMobileView('chat')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Chat */}
|
||||
<div className={`
|
||||
${mobileView === 'chat' ? 'flex' : 'hidden'} md:flex flex-1 min-w-0 flex-col
|
||||
`}>
|
||||
{/* Chat area: always in DOM, hidden on mobile when sidebar/members shown */}
|
||||
<div
|
||||
className={`
|
||||
${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">
|
||||
{currentVoiceRoom && (
|
||||
<div className="flex bg-gb-bg-s border-b border-gb-bg-t">
|
||||
<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'}`}
|
||||
onClick={() => setActiveTab('chat')}>[CHAT]</button>
|
||||
<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>
|
||||
<button
|
||||
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>
|
||||
<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 className={`flex-1 min-h-0 flex-col ${activeTab === 'chat' || !currentVoiceRoom ? 'flex' : 'hidden'}`}>
|
||||
@@ -189,31 +226,32 @@ export function Layout() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Members — desktop: right column, mobile: overlay when members tab active */}
|
||||
{/* Members panel */}
|
||||
{/* Desktop: right column */}
|
||||
{/* Mobile: full-screen overlay when mobileView === 'members' */}
|
||||
{!isDM && (
|
||||
<div className={`
|
||||
${mobileView === 'members' ? 'flex' : 'hidden'} md:flex
|
||||
absolute md:relative inset-0 md:inset-auto z-30 md:z-auto flex-shrink-0
|
||||
`}>
|
||||
<div className="flex-1 md:hidden" onClick={() => setMobileView('chat')} />
|
||||
<div
|
||||
className={`
|
||||
${mobileView === 'members' ? 'flex' : 'hidden'}
|
||||
md:flex
|
||||
absolute md:relative inset-0 md:inset-auto z-30 md:z-auto
|
||||
flex-shrink-0
|
||||
`}
|
||||
>
|
||||
{/* Tap background to close on mobile */}
|
||||
<div
|
||||
className="flex-1 md:hidden"
|
||||
onClick={() => setMobileView('chat')}
|
||||
/>
|
||||
<MemberList />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom nav — mobile only */}
|
||||
<MobileBottomNav
|
||||
mobileView={mobileView}
|
||||
onViewChange={setMobileView}
|
||||
isDM={isDM}
|
||||
/>
|
||||
|
||||
{showServerSettings && activeServerId && (
|
||||
<ServerSettingsModal serverId={activeServerId} onClose={() => setShowServerSettings(false)} />
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
<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">
|
||||
<span>TERM {__APP_VERSION__}</span>
|
||||
<span>{new Date().toISOString().slice(0, 10)}</span>
|
||||
</div>
|
||||
@@ -222,50 +260,3 @@ export function Layout() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// MobileBottomNav — tab bar for switching between sidebar / chat / members
|
||||
function MobileBottomNav({
|
||||
mobileView,
|
||||
onViewChange,
|
||||
isDM,
|
||||
}: {
|
||||
mobileView: string;
|
||||
onViewChange: (v: 'sidebar' | 'chat' | 'members') => void;
|
||||
isDM: boolean;
|
||||
}) {
|
||||
const isConnected = useVoiceStore((s) => s.isConnected);
|
||||
|
||||
return (
|
||||
<div className="md:hidden flex items-center bg-gb-bg-h border-t border-gb-bg-t shrink-0"
|
||||
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}>
|
||||
<NavTab active={mobileView === 'sidebar'} onClick={() => onViewChange('sidebar')}>
|
||||
[SERVERS]
|
||||
</NavTab>
|
||||
<NavTab active={mobileView === 'chat'} onClick={() => onViewChange('chat')}>
|
||||
[CHAT]
|
||||
</NavTab>
|
||||
{!isDM && (
|
||||
<NavTab active={mobileView === 'members'} onClick={() => onViewChange('members')}>
|
||||
[MEMBERS]
|
||||
</NavTab>
|
||||
)}
|
||||
{isConnected && (
|
||||
<div className="w-2 h-2 rounded-full bg-gb-green animate-pulse ml-auto mr-3" title="In voice" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavTab({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`flex-1 py-2.5 text-xs font-mono text-center transition-colors border-t-2 ${
|
||||
active ? 'border-gb-orange text-gb-orange bg-gb-bg-s' : 'border-transparent text-gb-fg-f hover:text-gb-fg hover:bg-gb-bg'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ interface MemberRowProps {
|
||||
|
||||
function MemberRow({ member, onProfileClick }: MemberRowProps) {
|
||||
const presence = usePresenceStore((s) => s.presences[member.id]);
|
||||
const status = member.is_bot ? "online" : (presence?.status ?? member.status);
|
||||
const status = presence?.status ?? member.status;
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
const activeServerId = useServerStore((s) => s.activeServerId);
|
||||
@@ -72,21 +72,14 @@ function MemberRow({ member, onProfileClick }: MemberRowProps) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<div
|
||||
onClick={() => {
|
||||
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"
|
||||
}`}
|
||||
onClick={() => onProfileClick(member.id)}
|
||||
className="w-full flex items-center gap-2 px-2 py-1 text-left hover:bg-gb-bg-t cursor-pointer group"
|
||||
>
|
||||
<span className={statusColor(status)}>{statusIcon(status)}</span>
|
||||
<span className={`truncate ${member.is_bot ? "text-gb-green" : usernameColor(status)} flex-1`}>
|
||||
<span className={`truncate ${usernameColor(status)} flex-1`}>
|
||||
{member.nickname || member.display_name || member.username}
|
||||
</span>
|
||||
{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 && (
|
||||
{!isSelf && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -99,7 +92,7 @@ function MemberRow({ member, onProfileClick }: MemberRowProps) {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{menuOpen && !isSelf && !member.is_bot && activeServerId && (
|
||||
{menuOpen && !isSelf && activeServerId && (
|
||||
<MemberContextMenu
|
||||
memberId={member.id}
|
||||
username={member.username}
|
||||
@@ -162,13 +155,11 @@ export function MemberList() {
|
||||
}
|
||||
}, [activeServerId, fetchMembers]);
|
||||
|
||||
const humans = members.filter((m) => !m.is_bot);
|
||||
const bots = members.filter((m) => m.is_bot);
|
||||
const online = humans.filter((m) => {
|
||||
const online = members.filter((m) => {
|
||||
const status = presences[m.id]?.status ?? m.status;
|
||||
return status !== "offline";
|
||||
});
|
||||
const offline = humans.filter((m) => {
|
||||
const offline = members.filter((m) => {
|
||||
const status = presences[m.id]?.status ?? m.status;
|
||||
return status === "offline";
|
||||
});
|
||||
@@ -187,7 +178,7 @@ export function MemberList() {
|
||||
)}
|
||||
{online.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="text-gb-fg-t text-xs uppercase mb-1">ONLINE — {online.length}</div>
|
||||
<div className="text-gb-fg-t text-xs uppercase mb-1">ONLINE</div>
|
||||
<div className="text-gb-fg-f text-xs mb-1">---</div>
|
||||
{online.map((m) => (
|
||||
<MemberRow key={m.id} member={m} onProfileClick={setProfileUserId} />
|
||||
@@ -195,23 +186,14 @@ export function MemberList() {
|
||||
</div>
|
||||
)}
|
||||
{offline.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="text-gb-fg-t text-xs uppercase mb-1">OFFLINE — {offline.length}</div>
|
||||
<div>
|
||||
<div className="text-gb-fg-t text-xs uppercase mb-1">OFFLINE</div>
|
||||
<div className="text-gb-fg-f text-xs mb-1">---</div>
|
||||
{offline.map((m) => (
|
||||
<MemberRow key={m.id} member={m} onProfileClick={setProfileUserId} />
|
||||
))}
|
||||
</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>
|
||||
{profileUserId && (
|
||||
<UserProfileModal userId={profileUserId} onClose={() => setProfileUserId(null)} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRoleStore, type Role } from '../stores/role.ts';
|
||||
import { useAuthStore, type User } from '../stores/auth.ts';
|
||||
import type { User } from '../stores/auth.ts';
|
||||
import { usePermissions } from '../lib/usePermissions.ts';
|
||||
|
||||
interface MemberRoleAssignProps {
|
||||
@@ -12,10 +12,8 @@ interface MemberRoleAssignProps {
|
||||
export function MemberRoleAssign({ serverId, member }: MemberRoleAssignProps) {
|
||||
const roles = useRoleStore((s) => s.roles);
|
||||
const fetchRoles = useRoleStore((s) => s.fetchRoles);
|
||||
const fetchMyRoles = useRoleStore((s) => s.fetchMyRoles);
|
||||
const setMemberRoles = useRoleStore((s) => s.setMemberRoles);
|
||||
const getMemberRoles = useRoleStore((s) => s.getMemberRoles);
|
||||
const currentUserId = useAuthStore((s) => s.user?.id);
|
||||
|
||||
const { isOwner, canManageRoles } = usePermissions(serverId);
|
||||
const canEdit = isOwner || canManageRoles;
|
||||
@@ -58,9 +56,6 @@ export function MemberRoleAssign({ serverId, member }: MemberRoleAssignProps) {
|
||||
try {
|
||||
await setMemberRoles(serverId, member.id, Array.from(selectedIds));
|
||||
setMemberRolesState(roles.filter((r) => selectedIds.has(r.id)));
|
||||
if (currentUserId && member.id === currentUserId) {
|
||||
await fetchMyRoles(serverId, currentUserId);
|
||||
}
|
||||
setOpen(false);
|
||||
} catch {
|
||||
// error in store
|
||||
|
||||
@@ -1,112 +1,52 @@
|
||||
import type { Member } from "../stores/member.ts";
|
||||
|
||||
export type MentionOption =
|
||||
| { kind: "special"; id: string; label: string; description: string }
|
||||
| { kind: "user"; member: Member };
|
||||
|
||||
interface MentionDropdownProps {
|
||||
query: string;
|
||||
members: Member[];
|
||||
selectedIndex: number;
|
||||
onSelect: (username: string) => void;
|
||||
canMentionEveryone?: boolean;
|
||||
}
|
||||
|
||||
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[] {
|
||||
export function MentionDropdown({ query, members, selectedIndex, onSelect }: MentionDropdownProps) {
|
||||
const q = query.toLowerCase();
|
||||
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
|
||||
const filtered = members
|
||||
.filter(
|
||||
(m) =>
|
||||
m.username.toLowerCase().includes(q) ||
|
||||
m.display_name?.toLowerCase().includes(q),
|
||||
)
|
||||
.slice(0, 6)
|
||||
.map((m): MentionOption => ({ kind: "user", member: m }));
|
||||
.slice(0, 6);
|
||||
|
||||
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;
|
||||
if (filtered.length === 0) return null;
|
||||
|
||||
return (
|
||||
<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="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="px-2 py-1 text-xs text-gb-fg-s font-mono border-b border-gb-bg-t">
|
||||
MENTION
|
||||
</div>
|
||||
{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
|
||||
key={m.id}
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onSelect(m.username);
|
||||
}}
|
||||
className={`w-full px-2 py-1 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-green"}>●</span>
|
||||
<span className="truncate">{m.display_name || m.username}</span>
|
||||
{m.display_name && m.display_name !== m.username && (
|
||||
<span className={`text-xs ${active ? "text-gb-bg" : "text-gb-fg-f"}`}>
|
||||
({m.username})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{filtered.map((m, i) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onSelect(m.username);
|
||||
}}
|
||||
className={`w-full px-2 py-1 text-left text-sm font-mono flex items-center gap-2 transition-colors ${
|
||||
i === selectedIndex
|
||||
? "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="truncate">{m.display_name || m.username}</span>
|
||||
{m.display_name && m.display_name !== m.username && (
|
||||
<span className={`text-xs ${i === selectedIndex ? "text-gb-bg" : "text-gb-fg-f"}`}>
|
||||
({m.username})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -546,12 +546,7 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(f
|
||||
placeholder={isDragOver ? "Drop file here..." : placeholder}
|
||||
rows={1}
|
||||
disabled={disabled}
|
||||
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';
|
||||
}}
|
||||
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" : ""}`}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
@@ -566,7 +561,7 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(f
|
||||
}
|
||||
}}
|
||||
data-placeholder={isDragOver ? "Drop file here..." : placeholder}
|
||||
className={`w-full outline-none resize-none overflow-hidden text-[14px] leading-snug py-1 font-mono
|
||||
className={`w-full outline-none resize-none overflow-y-auto max-h-32 text-[14px] leading-snug py-1 font-mono
|
||||
before:content-[attr(data-placeholder)] before:text-gb-fg-f before:opacity-60
|
||||
empty:before:block before:hidden
|
||||
${isDragOver ? "bg-gb-bg-t" : ""}
|
||||
@@ -690,15 +685,6 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(f
|
||||
|
||||
<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 */}
|
||||
<button type="button" disabled={disabled} onClick={toggleMode}
|
||||
title={mode === "md" ? "Switch to rich text" : "Switch to markdown"}
|
||||
|
||||
@@ -33,7 +33,6 @@ export function NewConversationModal({ onClose }: NewConversationModalProps) {
|
||||
const q = query.toLowerCase();
|
||||
return members.filter(
|
||||
(m) =>
|
||||
!m.is_bot &&
|
||||
m.id !== currentUserId &&
|
||||
(m.username.toLowerCase().includes(q) ||
|
||||
(m.display_name || "").toLowerCase().includes(q)),
|
||||
|
||||
+323
-317
@@ -1,13 +1,77 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useRoleStore, type Role } from '../stores/role.ts';
|
||||
import {
|
||||
PERMS,
|
||||
PERM_CATEGORIES,
|
||||
PERMISSION_LABELS,
|
||||
hasPermission,
|
||||
type PermissionKey,
|
||||
} from '../stores/permissions.ts';
|
||||
import { useRoleStore, type Role, type CreateRoleData, type UpdateRoleData } from '../stores/role.ts';
|
||||
|
||||
// Permission bitflags
|
||||
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,
|
||||
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 = [
|
||||
'#fb4934', '#b8bb26', '#fabd2f', '#83a598',
|
||||
@@ -15,167 +79,207 @@ const GRUVBOX_COLORS = [
|
||||
'#a89984', '#928374', '#282828',
|
||||
];
|
||||
|
||||
// ponytail: full bitfield when admin is on; not every future flag needs a checkbox.
|
||||
const ALL_PERMS = Object.values(PERMS).reduce((a, b) => a | b, 0);
|
||||
|
||||
interface Draft {
|
||||
name: string;
|
||||
color: string;
|
||||
position: number;
|
||||
permissions: number;
|
||||
function hasPerm(permissions: number, flag: number): boolean {
|
||||
return (permissions & flag) !== 0;
|
||||
}
|
||||
|
||||
function draftFromRole(role: Role): Draft {
|
||||
return {
|
||||
name: role.name,
|
||||
color: role.color || '#ebdbb2',
|
||||
position: role.position,
|
||||
permissions: role.permissions,
|
||||
function permSummary(permissions: number): string {
|
||||
if (hasPerm(permissions, PERMS.ADMINISTRATOR)) return 'ADMINISTRATOR';
|
||||
const active: string[] = [];
|
||||
if (hasPerm(permissions, PERMS.MANAGE_SERVER)) active.push('MGR_SERVER');
|
||||
if (hasPerm(permissions, PERMS.MANAGE_CHANNELS)) active.push('MGR_CHAN');
|
||||
if (hasPerm(permissions, PERMS.MANAGE_ROLES)) active.push('MGR_ROLES');
|
||||
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 (
|
||||
draft.name !== role.name ||
|
||||
draft.color !== (role.color || '#ebdbb2') ||
|
||||
draft.position !== role.position ||
|
||||
draft.permissions !== role.permissions
|
||||
<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-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<p className="text-gb-orange font-mono text-sm mb-4">
|
||||
{'>'} {role ? 'EDIT ROLE' : 'CREATE ROLE'}
|
||||
</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 } = {}) {
|
||||
const { serverId: paramServerId } = useParams<{ serverId: string }>();
|
||||
const serverId = propServerId || paramServerId;
|
||||
const embedded = Boolean(propServerId);
|
||||
|
||||
const allRoles = useRoleStore((s) => s.roles);
|
||||
const roles = useRoleStore((s) => s.roles);
|
||||
const loading = useRoleStore((s) => s.loading);
|
||||
const error = useRoleStore((s) => s.error);
|
||||
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 roles = useMemo(
|
||||
() =>
|
||||
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);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingRole, setEditingRole] = useState<Role | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (serverId) void fetchRoles(serverId);
|
||||
if (serverId) fetchRoles(serverId);
|
||||
}, [serverId, fetchRoles]);
|
||||
|
||||
// Keep selection valid when role list changes.
|
||||
useEffect(() => {
|
||||
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);
|
||||
const handleDelete = async (role: Role) => {
|
||||
if (!serverId) return;
|
||||
if (!window.confirm(`Delete role "${role.name}"? This cannot be undone.`)) return;
|
||||
try {
|
||||
const top = roles[0]?.position ?? 0;
|
||||
const role = await createRole(serverId, {
|
||||
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');
|
||||
await deleteRole(serverId, role.id);
|
||||
} catch {
|
||||
// error in store
|
||||
}
|
||||
};
|
||||
|
||||
@@ -188,195 +292,97 @@ export function RoleManager({ serverId: propServerId }: { serverId?: string } =
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-gb-bg font-mono flex flex-col ${embedded ? 'h-full min-h-[55vh]' : 'h-full w-full'}`}>
|
||||
{!embedded && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gb-bg-t">
|
||||
<span className="text-sm text-gb-orange">ROLE MANAGER</span>
|
||||
<Link to="/" className="text-xs text-gb-fg-f hover:text-gb-aqua">
|
||||
{'<'} [BACK]
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<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-6">
|
||||
{'┌──────────────────────────────────┐\n'}
|
||||
{'│ === ROLE MANAGER === │\n'}
|
||||
{'└──────────────────────────────────┘'}
|
||||
</pre>
|
||||
|
||||
<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">
|
||||
{error && (
|
||||
<p className="text-gb-red text-sm font-mono mb-4">ERR: {error}</p>
|
||||
)}
|
||||
|
||||
<div className="mb-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCreate()}
|
||||
disabled={creating}
|
||||
className="w-full terminal-button text-xs disabled:opacity-50"
|
||||
onClick={() => { setEditingRole(null); setShowForm(true); }}
|
||||
className="terminal-button"
|
||||
>
|
||||
{creating ? '[...]' : '[+ CREATE ROLE]'}
|
||||
[CREATE ROLE]
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading && roles.length === 0 && (
|
||||
<p className="text-gb-fg-f text-xs p-3">[loading...]</p>
|
||||
)}
|
||||
{!loading && roles.length === 0 && (
|
||||
<p className="text-gb-fg-f text-xs p-3">[no roles]</p>
|
||||
)}
|
||||
{roles.map((role) => {
|
||||
const active = role.id === selectedId;
|
||||
return (
|
||||
<button
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
|
||||
{loading && roles.length === 0 && (
|
||||
<p className="text-gb-fg-f font-mono text-sm">[loading roles...]</p>
|
||||
)}
|
||||
{!loading && roles.length === 0 && (
|
||||
<p className="text-gb-fg-f font-mono text-sm">[no roles configured]</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{roles.map((role) => (
|
||||
<div key={role.id} className="border border-gb-bg-t p-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span
|
||||
className="w-2.5 h-2.5 shrink-0 border border-gb-bg-t"
|
||||
className="inline-block w-4 h-4 border border-gb-bg-t shrink-0"
|
||||
style={{ backgroundColor: role.color || '#ebdbb2' }}
|
||||
/>
|
||||
<span className="truncate" style={{ color: active ? (role.color || undefined) : undefined }}>
|
||||
<span
|
||||
className="font-mono text-sm font-bold truncate"
|
||||
style={{ color: role.color || '#ebdbb2' }}
|
||||
>
|
||||
{role.name}
|
||||
</span>
|
||||
{role.is_default && (
|
||||
<span className="ml-auto text-[10px] text-gb-fg-f shrink-0">@</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: live editor */}
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
{!selected || !draft ? (
|
||||
<div className="flex-1 flex items-center justify-center text-gb-fg-f text-xs p-6">
|
||||
[select a role]
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{(error || localError) && (
|
||||
<p className="text-gb-red text-xs">ERR: {localError || error}</p>
|
||||
)}
|
||||
|
||||
<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>
|
||||
<span className="text-gb-fg-f font-mono text-xs">[DEFAULT]</span>
|
||||
)}
|
||||
<span className="text-gb-fg-f font-mono text-xs ml-auto shrink-0">
|
||||
POS:{role.position}
|
||||
</span>
|
||||
</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 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 handleSave()}
|
||||
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 && (
|
||||
<p className="text-gb-fg-f font-mono text-xs mb-3">
|
||||
perms: {permSummary(role.permissions)}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDelete()}
|
||||
className="ml-auto text-xs text-gb-fg-f hover:text-gb-red"
|
||||
onClick={() => { setEditingRole(role); setShowForm(true); }}
|
||||
className="terminal-button text-xs"
|
||||
>
|
||||
[DELETE ROLE]
|
||||
[EDIT]
|
||||
</button>
|
||||
)}
|
||||
{!role.is_default && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(role)}
|
||||
className="terminal-button text-xs hover:!text-gb-red"
|
||||
>
|
||||
[DELETE]
|
||||
</button>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{showForm && (
|
||||
<RoleForm
|
||||
serverId={serverId}
|
||||
role={editingRole ?? undefined}
|
||||
onClose={() => { setShowForm(false); setEditingRole(null); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useServerStore } from "../stores/server.ts";
|
||||
import { useChannelStore } from "../stores/channel.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 { JoinServerModal } from "./JoinServerModal.tsx";
|
||||
import { ServerSettingsModal } from "./ServerSettingsModal.tsx";
|
||||
@@ -35,18 +33,6 @@ export function ServerBar() {
|
||||
|
||||
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(() => {
|
||||
fetchServers().then(() => {
|
||||
// ponytail: restore last active server + channel from localStorage
|
||||
@@ -108,43 +94,34 @@ export function ServerBar() {
|
||||
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="relative">
|
||||
<button
|
||||
onClick={handleDM}
|
||||
className={'w-11 h-11 flex items-center justify-center font-mono text-sm border ' +
|
||||
(isDM
|
||||
? 'bg-gb-bg-t text-gb-orange border-gb-orange'
|
||||
: 'bg-gb-bg-s text-gb-fg border-gb-bg-t hover:border-gb-fg-t hover:text-gb-aqua')}
|
||||
title="Direct messages"
|
||||
>
|
||||
[@]
|
||||
</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>
|
||||
<button
|
||||
onClick={handleDM}
|
||||
className={'w-11 h-11 flex items-center justify-center font-mono text-sm border ' +
|
||||
(isDM
|
||||
? 'bg-gb-bg-t text-gb-orange border-gb-orange'
|
||||
: 'bg-gb-bg-s text-gb-fg border-gb-bg-t hover:border-gb-fg-t hover:text-gb-aqua')}
|
||||
title="Direct messages"
|
||||
>
|
||||
[@]
|
||||
</button>
|
||||
<div className="w-8 h-px bg-gb-bg-t" />
|
||||
{servers.map((server) => (
|
||||
<div key={server.id} className="relative">
|
||||
<button
|
||||
onClick={() => handleSelect(server.id)}
|
||||
onContextMenu={(e) => handleServerContextMenu(e, server)}
|
||||
className={'w-11 h-11 flex items-center justify-center font-mono text-sm border ' +
|
||||
(server.id === activeServerId && !isDM
|
||||
? 'bg-gb-bg-t text-gb-orange border-gb-orange'
|
||||
: 'bg-gb-bg-s text-gb-fg border-gb-bg-t hover:border-gb-fg-t hover:text-gb-aqua')}
|
||||
title={server.name}
|
||||
>
|
||||
{server.unread && server.id !== activeServerId ? (
|
||||
<span className="text-gb-aqua">[{getInitials(server.name)}]</span>
|
||||
) : (
|
||||
'[' + getInitials(server.name) + ']'
|
||||
)}
|
||||
</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" />
|
||||
<button
|
||||
key={server.id}
|
||||
onClick={() => handleSelect(server.id)}
|
||||
onContextMenu={(e) => handleServerContextMenu(e, server)}
|
||||
className={'w-11 h-11 flex items-center justify-center font-mono text-sm border ' +
|
||||
(server.id === activeServerId && !isDM
|
||||
? 'bg-gb-bg-t text-gb-orange border-gb-orange'
|
||||
: 'bg-gb-bg-s text-gb-fg border-gb-bg-t hover:border-gb-fg-t hover:text-gb-aqua')}
|
||||
title={server.name}
|
||||
>
|
||||
{server.unread && server.id !== activeServerId ? (
|
||||
<span className="text-gb-aqua">[{getInitials(server.name)}]</span>
|
||||
) : (
|
||||
'[' + getInitials(server.name) + ']'
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
<div className="mt-2 flex flex-col gap-2 items-center">
|
||||
<button
|
||||
|
||||
@@ -210,7 +210,7 @@ export function ServerSettingsModal({ serverId, onClose }: ServerSettingsModalPr
|
||||
onClick={handleClose}
|
||||
>
|
||||
<div
|
||||
className="bg-gb-bg border border-gb-bg-t w-[min(920px,95vw)] max-h-[85vh] flex flex-col font-mono"
|
||||
className="bg-gb-bg border border-gb-bg-t w-[750px] 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">
|
||||
@@ -342,7 +342,7 @@ export function ServerSettingsModal({ serverId, onClose }: ServerSettingsModalPr
|
||||
)}
|
||||
|
||||
{tab === 'roles' && (
|
||||
<div className="h-[60vh] -m-4">
|
||||
<div className="h-[60vh] overflow-y-auto">
|
||||
<RoleManager serverId={serverId} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,67 +1,36 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
// 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);
|
||||
}
|
||||
type Theme = 'dark' | 'light';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const [theme, setTheme] = useState<ThemeId>(() =>
|
||||
normalizeTheme(localStorage.getItem('dumpster-theme')),
|
||||
);
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
const stored = localStorage.getItem('dumpster-theme');
|
||||
return (stored === 'light' ? 'light' : 'dark');
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(theme);
|
||||
const root = document.documentElement;
|
||||
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]);
|
||||
|
||||
const toggle = () => {
|
||||
setTheme(prev => prev === 'dark' ? 'light' : 'dark');
|
||||
};
|
||||
|
||||
return (
|
||||
<select
|
||||
value={theme}
|
||||
onChange={(e) => setTheme(e.target.value as ThemeId)}
|
||||
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"
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="px-2 py-1 text-xs font-mono text-gb-fg-f hover:text-gb-orange transition-colors"
|
||||
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||
>
|
||||
{THEMES.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
[{theme === 'dark' ? 'light' : 'dark'}]
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useState, useRef, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../stores/auth.ts';
|
||||
import { usePushStore } from '../stores/push.ts';
|
||||
import { ThemeToggle } from './ThemeToggle.tsx';
|
||||
|
||||
export function UserSettings() {
|
||||
const { user, updateProfile, changePassword, isLoading, error, clearError } = useAuthStore();
|
||||
@@ -317,17 +316,6 @@ export function UserSettings() {
|
||||
</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 */}
|
||||
{isSupported && (
|
||||
<div className="border border-gb-bg-t p-4">
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useVoiceStore } from '../stores/voice.ts';
|
||||
import { useAuthStore } from '../stores/auth.ts';
|
||||
import { usePermissions } from '../lib/usePermissions.ts';
|
||||
import { useChannelStore } from '../stores/channel.ts';
|
||||
import { useVoicePresenceStore } from '../stores/voicePresence.ts';
|
||||
import { api } from '../lib/api.ts';
|
||||
|
||||
interface VoiceChannelProps {
|
||||
@@ -14,8 +13,7 @@ export function VoiceChannel({ channelId, channelName }: VoiceChannelProps) {
|
||||
const currentRoom = useVoiceStore((state) => state.currentRoom);
|
||||
const isConnected = useVoiceStore((state) => state.isConnected);
|
||||
const joinVoice = useVoiceStore((state) => state.joinVoice);
|
||||
const liveParticipants = useVoiceStore((state) => state.participants);
|
||||
const presenceParticipants = useVoicePresenceStore((s) => s.getParticipants(channelId));
|
||||
const participants = useVoiceStore((state) => state.participants);
|
||||
|
||||
const currentUserId = useAuthStore((state) => state.user?.id);
|
||||
const activeChannelId = useChannelStore((s) => s.activeChannelId);
|
||||
@@ -42,11 +40,6 @@ export function VoiceChannel({ channelId, channelName }: VoiceChannelProps) {
|
||||
|
||||
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 = () => {
|
||||
if (!isActive) {
|
||||
console.log("[VoiceChannel] joining voice:", channelId, channelName);
|
||||
@@ -76,19 +69,19 @@ export function VoiceChannel({ channelId, channelName }: VoiceChannelProps) {
|
||||
🔊
|
||||
</span>
|
||||
<span className="truncate">{channelName}</span>
|
||||
{displayParticipants.length > 0 && (
|
||||
{isActive && participants.length > 0 && (
|
||||
<span className="ml-auto text-xxs text-gb-fg-f">
|
||||
[{displayParticipants.length}]
|
||||
[{participants.length}]
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{displayParticipants.length > 0 && (
|
||||
{isActive && (
|
||||
<div className="pl-7 py-0.5">
|
||||
{displayParticipants.map((p) => {
|
||||
const isMe = p.userId === currentUserId;
|
||||
{participants.map((p) => {
|
||||
const isMe = p.identity === currentUserId;
|
||||
return (
|
||||
<div
|
||||
key={p.userId}
|
||||
key={p.identity}
|
||||
className="text-xxs text-gb-fg-f flex items-center gap-1 group"
|
||||
>
|
||||
<span className={p.isSpeaking ? 'text-gb-green' : 'text-gb-fg-f'}>
|
||||
@@ -100,12 +93,12 @@ export function VoiceChannel({ channelId, channelName }: VoiceChannelProps) {
|
||||
{p.isScreenSharing && <span className="text-gb-aqua" title="Sharing screen">🖥</span>}
|
||||
{p.hasVideo && <span className="text-gb-orange" title="Camera on">🎥</span>}
|
||||
|
||||
{!isMe && isActive && (
|
||||
{!isMe && (
|
||||
<div className="ml-auto opacity-0 group-hover:opacity-100 flex items-center gap-1">
|
||||
{canMute && !p.isMuted && (
|
||||
<button
|
||||
className="text-gb-fg-f hover:text-gb-red"
|
||||
onClick={(e) => { e.stopPropagation(); handleMute(p.userId); }}
|
||||
onClick={(e) => { e.stopPropagation(); handleMute(p.identity); }}
|
||||
title={`Mute ${p.username}`}
|
||||
>
|
||||
[mute]
|
||||
@@ -113,7 +106,7 @@ export function VoiceChannel({ channelId, channelName }: VoiceChannelProps) {
|
||||
)}
|
||||
<button
|
||||
className="text-gb-fg-f hover:text-gb-orange"
|
||||
onClick={(e) => { e.stopPropagation(); whisperTo(p.userId, p.username); }}
|
||||
onClick={(e) => { e.stopPropagation(); whisperTo(p.identity, p.username); }}
|
||||
title={`Whisper to ${p.username}`}
|
||||
>
|
||||
[whisper]
|
||||
|
||||
@@ -1,46 +1,47 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useServerStore } from '../stores/server.ts';
|
||||
import { useAuthStore } from '../stores/auth.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 };
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const servers = useServerStore((s) => s.servers);
|
||||
const roles = useRoleStore((s) => s.roles);
|
||||
const myRolesByServer = useRoleStore((s) => s.myRolesByServer);
|
||||
|
||||
const server = serverId ? servers.find((s) => s.id === serverId) : null;
|
||||
const isOwner = Boolean(server && user && server.owner_id === user.id);
|
||||
|
||||
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 memberRoles = roles.filter((r) => r.server_id === serverId);
|
||||
|
||||
const has = useCallback(
|
||||
(flag: number) => {
|
||||
if (!serverId || !user) return false;
|
||||
if (isOwner) return true;
|
||||
if (hasPermission(effective, PERMS.ADMINISTRATOR)) return true;
|
||||
return hasPermission(effective, flag);
|
||||
const effective = memberRoles.reduce((acc, r) => acc | r.permissions, 0);
|
||||
if ((effective & PERMS.ADMINISTRATOR) !== 0) return true;
|
||||
return (effective & flag) === flag;
|
||||
},
|
||||
[serverId, user, isOwner, effective],
|
||||
[serverId, user, isOwner, memberRoles],
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -52,8 +53,6 @@ export function usePermissions(serverId: string | null) {
|
||||
canManageServer: has(PERMS.MANAGE_SERVER),
|
||||
canChangeNickname: has(PERMS.CHANGE_NICKNAME),
|
||||
canManageNicknames: has(PERMS.MANAGE_NICKNAMES),
|
||||
canMentionEveryone: has(PERMS.MENTION_EVERYONE),
|
||||
isOwner,
|
||||
effectivePermissions: effective,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,35 +3,6 @@ import { createRoot } from 'react-dom/client';
|
||||
import './styles/index.css';
|
||||
import App from './App.tsx';
|
||||
|
||||
const isTauri = 'window' in globalThis && '__TAURI_INTERNALS__' in window;
|
||||
if (isTauri) {
|
||||
const TARGET = 'https://dumpster.dustin.coffee';
|
||||
const WS_TARGET = 'wss://dumpster.dustin.coffee';
|
||||
|
||||
const originalFetch = window.fetch;
|
||||
window.fetch = async (input, init) => {
|
||||
if (typeof input === 'string' && input.startsWith('/')) {
|
||||
input = TARGET + input;
|
||||
}
|
||||
return originalFetch(input, init);
|
||||
};
|
||||
|
||||
const OriginalWebSocket = window.WebSocket;
|
||||
window.WebSocket = function(url: string | URL, protocols?: string | string[]) {
|
||||
let urlStr = typeof url === 'string' ? url : url.toString();
|
||||
if (urlStr.includes('tauri.localhost') || urlStr.includes('tauri://localhost')) {
|
||||
urlStr = urlStr.replace(/^(wss?|https?|tauri):\/\/tauri\.localhost/, WS_TARGET);
|
||||
urlStr = urlStr.replace(/^tauri:\/\/localhost/, WS_TARGET);
|
||||
} else if (urlStr.startsWith('ws://localhost') && isTauri) {
|
||||
urlStr = urlStr.replace('ws://localhost', WS_TARGET);
|
||||
} else if (urlStr.startsWith('/')) {
|
||||
urlStr = WS_TARGET + urlStr;
|
||||
}
|
||||
return new OriginalWebSocket(urlStr, protocols);
|
||||
} as unknown as typeof WebSocket;
|
||||
window.WebSocket.prototype = OriginalWebSocket.prototype;
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
|
||||
+5
-50
@@ -6,8 +6,6 @@ export interface Bot {
|
||||
name: string;
|
||||
avatar: string | null;
|
||||
description: string;
|
||||
bot_type: string;
|
||||
config: Record<string, unknown>;
|
||||
owner_id: string;
|
||||
created_at: string;
|
||||
}
|
||||
@@ -20,28 +18,14 @@ export interface SlashCommand {
|
||||
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 {
|
||||
bots: Bot[];
|
||||
storeBots: StoreBot[];
|
||||
botTypes: string[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
fetchBots: () => Promise<void>;
|
||||
fetchStoreBots: () => Promise<void>;
|
||||
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>;
|
||||
createBot: (name: string, description: string) => Promise<Bot & { token: string }>;
|
||||
updateBot: (id: string, data: { name?: string; description?: string; avatar?: string }) => Promise<Bot>;
|
||||
deleteBot: (id: string) => Promise<void>;
|
||||
addToServer: (botId: string, serverId: string) => Promise<void>;
|
||||
removeFromServer: (botId: string, serverId: string) => Promise<void>;
|
||||
@@ -55,8 +39,6 @@ interface BotState {
|
||||
|
||||
export const useBotStore = create<BotState>((set) => ({
|
||||
bots: [],
|
||||
storeBots: [],
|
||||
botTypes: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
@@ -73,37 +55,10 @@ export const useBotStore = create<BotState>((set) => ({
|
||||
}
|
||||
},
|
||||
|
||||
fetchStoreBots: async () => {
|
||||
createBot: async (name, description) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
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 || {},
|
||||
});
|
||||
const result = await api.post<Bot & { token: string }>('/bots', { name, description });
|
||||
set((state) => ({
|
||||
bots: [...state.bots, result],
|
||||
loading: false,
|
||||
@@ -234,7 +189,7 @@ export const useBotStore = create<BotState>((set) => ({
|
||||
|
||||
fetchServerCommands: async (serverId) => {
|
||||
try {
|
||||
return await api.get<SlashCommand[]>(`/bots/servers/${serverId}/commands`);
|
||||
return await api.get<SlashCommand[]>(`/servers/${serverId}/commands`);
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch server commands',
|
||||
|
||||
@@ -9,8 +9,6 @@ export interface Member {
|
||||
avatar: string;
|
||||
status: "online" | "idle" | "dnd" | "offline";
|
||||
status_text: string;
|
||||
is_bot?: boolean;
|
||||
bot_type?: string;
|
||||
}
|
||||
|
||||
interface MemberState {
|
||||
|
||||
@@ -38,9 +38,6 @@ export interface Message {
|
||||
author_id: string;
|
||||
author_username: string;
|
||||
author_display_name: string | null;
|
||||
author_bot?: boolean;
|
||||
bot_id?: string | null;
|
||||
bot_name?: string | null;
|
||||
content: string;
|
||||
reply_to?: string | null;
|
||||
embeds?: MessageEmbed[];
|
||||
|
||||
@@ -54,40 +54,8 @@ export const PERMISSION_LABELS: Record<PermissionKey, string> = {
|
||||
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 {
|
||||
// Match backend permissions.Has: all required bits must be present.
|
||||
return (perms & flag) === flag;
|
||||
return (perms & flag) !== 0;
|
||||
}
|
||||
|
||||
export interface ChannelOverride {
|
||||
|
||||
+9
-57
@@ -27,13 +27,10 @@ export interface UpdateRoleData {
|
||||
|
||||
interface RoleState {
|
||||
roles: Role[];
|
||||
/** Assigned roles for the current user, keyed by server id. Does not include @everyone. */
|
||||
myRolesByServer: Record<string, Role[]>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
fetchRoles: (serverId: string) => Promise<void>;
|
||||
fetchMyRoles: (serverId: string, userId: string) => Promise<void>;
|
||||
createRole: (serverId: string, data: CreateRoleData) => Promise<Role>;
|
||||
updateRole: (serverId: string, roleId: string, data: UpdateRoleData) => Promise<Role>;
|
||||
deleteRole: (serverId: string, roleId: string) => Promise<void>;
|
||||
@@ -43,7 +40,6 @@ interface RoleState {
|
||||
|
||||
export const useRoleStore = create<RoleState>((set) => ({
|
||||
roles: [],
|
||||
myRolesByServer: {},
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
@@ -51,14 +47,7 @@ export const useRoleStore = create<RoleState>((set) => ({
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const roles = await api.get<Role[]>(`/servers/${serverId}/roles`);
|
||||
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,
|
||||
}));
|
||||
set({ roles: roles.sort((a, b) => b.position - a.position), loading: false });
|
||||
} catch (error) {
|
||||
set({
|
||||
loading: false,
|
||||
@@ -67,25 +56,6 @@ 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) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
@@ -108,19 +78,10 @@ export const useRoleStore = create<RoleState>((set) => ({
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const role = await api.patch<Role>(`/servers/${serverId}/roles/${roleId}`, data);
|
||||
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),
|
||||
myRolesByServer: nextMy
|
||||
? { ...state.myRolesByServer, [serverId]: nextMy }
|
||||
: state.myRolesByServer,
|
||||
loading: false,
|
||||
};
|
||||
});
|
||||
set((state) => ({
|
||||
roles: state.roles.map((r) => (r.id === roleId ? role : r)).sort((a, b) => b.position - a.position),
|
||||
loading: false,
|
||||
}));
|
||||
return role;
|
||||
} catch (error) {
|
||||
set({
|
||||
@@ -135,19 +96,10 @@ export const useRoleStore = create<RoleState>((set) => ({
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
await api.delete(`/servers/${serverId}/roles/${roleId}`);
|
||||
set((state) => {
|
||||
const myRoles = state.myRolesByServer[serverId];
|
||||
return {
|
||||
roles: state.roles.filter((r) => r.id !== roleId),
|
||||
myRolesByServer: myRoles
|
||||
? {
|
||||
...state.myRolesByServer,
|
||||
[serverId]: myRoles.filter((r) => r.id !== roleId),
|
||||
}
|
||||
: state.myRolesByServer,
|
||||
loading: false,
|
||||
};
|
||||
});
|
||||
set((state) => ({
|
||||
roles: state.roles.filter((r) => r.id !== roleId),
|
||||
loading: false,
|
||||
}));
|
||||
} catch (error) {
|
||||
set({
|
||||
loading: false,
|
||||
|
||||
+1
-72
@@ -64,42 +64,6 @@ interface VoiceState {
|
||||
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 {
|
||||
const micPub = p.getTrackPublication(Track.Source.Microphone);
|
||||
const camPub = p.getTrackPublication(Track.Source.Camera);
|
||||
@@ -174,8 +138,7 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
|
||||
syncParticipants();
|
||||
});
|
||||
|
||||
room.on(RoomEvent.Disconnected, (reason?: any) => {
|
||||
console.warn('[voice] disconnected, reason:', reason);
|
||||
room.on(RoomEvent.Disconnected, () => {
|
||||
set({
|
||||
isConnected: false,
|
||||
currentRoom: null,
|
||||
@@ -188,30 +151,6 @@ 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, () => {
|
||||
syncParticipants();
|
||||
});
|
||||
@@ -276,16 +215,6 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
|
||||
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 });
|
||||
|
||||
// Notify WebSocket peers
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
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) : [];
|
||||
},
|
||||
}));
|
||||
+3
-30
@@ -5,7 +5,6 @@ import { useServerStore } from './server.ts';
|
||||
import { usePresenceStore } from './presence.ts';
|
||||
import { useTypingStore } from './typing.ts';
|
||||
import { useVoiceStore } from './voice.ts';
|
||||
import { useVoicePresenceStore } from './voicePresence.ts';
|
||||
import { useAuthStore } from './auth.ts';
|
||||
import { useConversationStore } from './conversation.ts';
|
||||
import { useReadStatesStore } from './readStates.ts';
|
||||
@@ -52,17 +51,9 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
}
|
||||
|
||||
function extractIds(payload: UnknownPayload | undefined): { channel_id?: string; conversation_id?: string; message_id: string } | null {
|
||||
if (!payload) return null;
|
||||
// Backend MESSAGE_DELETE uses "id"; some other events use "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 };
|
||||
if (!payload || typeof payload.message_id !== 'string') return null;
|
||||
if (typeof payload.channel_id === 'string') return { channel_id: payload.channel_id, message_id: payload.message_id };
|
||||
if (typeof payload.conversation_id === 'string') return { conversation_id: payload.conversation_id, message_id: payload.message_id };
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -283,24 +274,6 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
|
||||
});
|
||||
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:
|
||||
break;
|
||||
}
|
||||
|
||||
+5
-183
@@ -2,10 +2,8 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Theme token sets — applied via html[data-theme="..."]. Default = gruvbox. */
|
||||
@layer base {
|
||||
:root,
|
||||
[data-theme="gruvbox"] {
|
||||
:root {
|
||||
--gb-bg-h: #1d2021;
|
||||
--gb-bg: #282828;
|
||||
--gb-bg-s: #3c3836;
|
||||
@@ -26,7 +24,8 @@
|
||||
--gb-border: #665c54;
|
||||
}
|
||||
|
||||
[data-theme="gruvbox-light"] {
|
||||
/* Light mode overrides */
|
||||
.light {
|
||||
--gb-bg-h: #f9f5d7;
|
||||
--gb-bg: #fbf1c7;
|
||||
--gb-bg-s: #ebdbb2;
|
||||
@@ -36,187 +35,9 @@
|
||||
--gb-fg-s: #3c3836;
|
||||
--gb-fg-t: #504945;
|
||||
--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;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
@apply h-full w-full bg-gb-bg text-gb-fg font-mono;
|
||||
font-size: 14px;
|
||||
@@ -229,6 +50,7 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Thin themed scrollbar for chat area */
|
||||
*::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
@@ -276,7 +98,7 @@
|
||||
}
|
||||
|
||||
.terminal-mention {
|
||||
background-color: color-mix(in srgb, var(--gb-aqua) 12%, transparent);
|
||||
background-color: rgba(142, 192, 124, 0.12);
|
||||
color: var(--gb-aqua);
|
||||
}
|
||||
}
|
||||
|
||||
+27
-19
@@ -8,25 +8,33 @@ export default {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// ponytail: CSS vars so data-theme palettes apply without rewriting components
|
||||
'gb-bg-h': 'var(--gb-bg-h)',
|
||||
'gb-bg': 'var(--gb-bg)',
|
||||
'gb-bg-s': 'var(--gb-bg-s)',
|
||||
'gb-bg-t': 'var(--gb-bg-t)',
|
||||
'gb-bg-f': 'var(--gb-bg-f)',
|
||||
'gb-fg': 'var(--gb-fg)',
|
||||
'gb-fg-s': 'var(--gb-fg-s)',
|
||||
'gb-fg-t': 'var(--gb-fg-t)',
|
||||
'gb-fg-f': 'var(--gb-fg-f)',
|
||||
'gb-red': 'var(--gb-red)',
|
||||
'gb-green': 'var(--gb-green)',
|
||||
'gb-yellow': 'var(--gb-yellow)',
|
||||
'gb-blue': 'var(--gb-blue)',
|
||||
'gb-purple': 'var(--gb-purple)',
|
||||
'gb-aqua': 'var(--gb-aqua)',
|
||||
'gb-orange': 'var(--gb-orange)',
|
||||
'gb-gray': 'var(--gb-gray)',
|
||||
'gb-border': 'var(--gb-border)',
|
||||
'gb-bg-h': '#1d2021',
|
||||
'gb-bg': '#282828',
|
||||
'gb-bg-s': '#3c3836',
|
||||
'gb-bg-t': '#504945',
|
||||
'gb-bg-f': '#32302f',
|
||||
'gb-fg': '#ebdbb2',
|
||||
'gb-fg-s': '#d5c4a1',
|
||||
'gb-fg-t': '#bdae93',
|
||||
'gb-fg-f': '#a89984',
|
||||
'gb-red': '#fb4934',
|
||||
'gb-green': '#b8bb26',
|
||||
'gb-yellow': '#fabd2f',
|
||||
'gb-blue': '#83a598',
|
||||
'gb-purple': '#d3869b',
|
||||
'gb-aqua': '#8ec07c',
|
||||
'gb-orange': '#fe8019',
|
||||
'gb-gray': '#928374',
|
||||
// Light mode overrides
|
||||
'gb-light-bg-h': '#f9f5d7',
|
||||
'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: {
|
||||
mono: ['"JetBrains Mono"', '"Fira Code"', '"Cascadia Code"', '"SF Mono"', 'Consolas', '"Liberation Mono"', 'monospace'],
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/AudioRenderers.tsx","./src/components/BotManager.tsx","./src/components/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"}
|
||||
{"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"}
|
||||
Reference in New Issue
Block a user