fix: client perms, @everyone/@channel, docs, unit tests

- usePermissions ORs current user roles + @everyone only (not all server roles)
- cache myRolesByServer; load on active server; refresh after self role edit
- gate/notify @everyone and @channel; plain @username push; special mention UI
- refresh FEATURE_PARITY (DMs exist; drop stale critical gaps)
- README production deploy notes dumpster.service
- unit tests for permission bits and broadcast mention tokens
This commit is contained in:
2026-07-15 20:56:53 -04:00
parent fd7fa4a147
commit 11b1089126
13 changed files with 470 additions and 176 deletions
+52 -79
View File
@@ -2,7 +2,7 @@
## vs Discord, Guilded (historical), TeamSpeak 6, Fluxer ## vs Discord, Guilded (historical), TeamSpeak 6, Fluxer
Compiled 2026-06-30. Updated through Phase 7 completion. Compiled 2026-06-30. **Status refreshed 2026-07-15** (not a full re-audit of every row).
--- ---
@@ -21,22 +21,23 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
| Feature | dumpsterChat | Discord | Guilded | TeamSpeak 6 | Fluxer | | Feature | dumpsterChat | Discord | Guilded | TeamSpeak 6 | Fluxer |
|---------|:------------:|:-------:|:-------:|:-----------:|:------:| |---------|:------------:|:-------:|:-------:|:-----------:|:------:|
|| Text channels | ✅ | ✅ | ✅ | ✅ | ✅ | | Text channels | ✅ | ✅ | ✅ | ✅ | ✅ |
|| Direct messages | ✅ | ✅ | ✅ | ❌ | ✅ | | Direct messages | ✅ | ✅ | ✅ | ❌ | ✅ |
|| Markdown support | ✅ | ✅ full | ✅ full | ❌ basic | ✅ full | | Markdown support | ✅ | ✅ full | ✅ full | ❌ basic | ✅ full |
|| Reactions | ✅ | ✅ | ✅ | ❌ | ✅ | | Reactions | ✅ | ✅ | ✅ | ❌ | ✅ |
|| Replies | ✅ | ✅ | ✅ | ❌ | ✅ | | Replies | ✅ | ✅ | ✅ | ❌ | ✅ |
|| Threads | ✅ | ✅ | ✅ | ❌ | 🔄 | | Threads | ✅ | ✅ | ✅ | ❌ | 🔄 |
|| Forum channels | ✅ | ✅ | ✅ | ❌ | 🔄 | | Forum channels | ✅ | ✅ | ✅ | ❌ | 🔄 |
|| Pinned messages | ✅ | ✅ | ✅ | ✅ | ✅ | | Pinned messages | ✅ | ✅ | ✅ | ✅ | ✅ |
|| Message search | ✅ | ✅ full | ✅ | ❌ | ✅ Meilisearch | | Message search | ✅ | ✅ full | ✅ | ❌ | ✅ Meilisearch |
|| Edit / delete messages | ✅ | ✅ | ✅ | ❌ | ✅ | | Edit / delete messages | ✅ | ✅ | ✅ | ❌ | ✅ |
|| Rich embeds / link unfurling | ✅ | ✅ | ✅ | ❌ | ✅ | | Rich embeds / link unfurling | ✅ | ✅ | ✅ | ❌ | ✅ |
|| File uploads | ⚠️ MinIO | ✅ | ✅ | ✅ | ✅ S3-backed | | File uploads | ⚠️ MinIO | ✅ | ✅ | ✅ | ✅ S3-backed |
|| GIF picker (Giphy) | ✅ | ✅ | ✅ | ❌ | ✅ | | GIF picker (Giphy) | ✅ | ✅ | ✅ | ❌ | ✅ |
|| Typing indicators | ✅ | ✅ | ✅ | ❌ | ✅ | | Typing indicators | ✅ | ✅ | ✅ | ❌ | ✅ |
|| Message history (pagination) | ✅ | ✅ | ✅ | ✅ | ✅ | | Message history (pagination) | ✅ | ✅ | ✅ | ✅ | ✅ |
|| Read receipts | ✅ | ✅ | ✅ | ❌ | 🔄 | | Read receipts | ✅ | ✅ | ✅ | ❌ | 🔄 |
| @everyone / @channel | ✅ (perm gated) | ✅ | ✅ | ❌ | ✅ |
--- ---
@@ -68,7 +69,7 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
| Badges | ❌ | ✅ | ✅ | ✅ | ✅ | | Badges | ❌ | ✅ | ✅ | ✅ | ✅ |
| Usernames + discriminators | ✅ | ⚠️ handles | ❌ | ✅ UID | ✅ #0000 | | Usernames + discriminators | ✅ | ⚠️ handles | ❌ | ✅ UID | ✅ #0000 |
| Friend requests | ❌ | ✅ | ✅ | ❌ | ✅ | | Friend requests | ❌ | ✅ | ✅ | ❌ | ✅ |
|| Block list | ✅ | ✅ | ✅ | ❌ | ❌ | | Block list | ✅ | ✅ | ✅ | ❌ | ❌ |
| Activity / game status | ❌ | ✅ | ✅ | ❌ | ❌ | | Activity / game status | ❌ | ✅ | ✅ | ❌ | ❌ |
--- ---
@@ -80,11 +81,12 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
| Roles | ✅ | ✅ | ✅ | ✅ | ✅ | | Roles | ✅ | ✅ | ✅ | ✅ | ✅ |
| Hierarchical roles | ⚠️ basic | ✅ | ✅ | ✅ | ✅ | | Hierarchical roles | ⚠️ basic | ✅ | ✅ | ✅ | ✅ |
| Permission bitflags | ✅ | ✅ | ✅ | ✅ granular | ✅ | | Permission bitflags | ✅ | ✅ | ✅ | ✅ granular | ✅ |
|| Per-channel permission overrides | ✅ | ✅ | ✅ | ✅ | ✅ | | Per-channel permission overrides | ✅ | ✅ | ✅ | ✅ | ✅ |
| @everyone default role | ✅ | ✅ | ✅ | ✅ | ✅ | | @everyone default role | ✅ | ✅ | ✅ | ✅ | ✅ |
| Role colors | ⚠️ DB ready | ✅ | ✅ | ❌ | ✅ | | Role colors | ⚠️ DB ready | ✅ | ✅ | ❌ | ✅ |
| Role icons | ❌ | ✅ Nitro | ❌ | ❌ | ❌ | | Role icons | ❌ | ✅ Nitro | ❌ | ❌ | ❌ |
| Administrator bypass | ✅ | ✅ | ✅ | ✅ | ✅ | | Administrator bypass | ✅ | ✅ | ✅ | ✅ | ✅ |
| Client-side permission gates | ✅ (user roles + @everyone) | ✅ | ✅ | ✅ | ✅ |
### dumpsterChat Permissions (current) ### dumpsterChat Permissions (current)
@@ -123,7 +125,7 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
| Server invites | ✅ | ✅ | ✅ | ✅ | ✅ | | Server invites | ✅ | ✅ | ✅ | ✅ | ✅ |
| Vanity URLs | ❌ | ✅ Nitro | ❌ | ❌ | ❌ | | Vanity URLs | ❌ | ✅ Nitro | ❌ | ❌ | ❌ |
| Webhooks | ✅ | ✅ | ✅ | ❌ | ✅ | | Webhooks | ✅ | ✅ | ✅ | ❌ | ✅ |
| Bots / API | ⚠️ slash cmds | ✅ huge | ✅ Flow Bots | ❌ plugins | 🔄 | | Bots / API | ⚠️ store + runner | ✅ huge | ✅ Flow Bots | ❌ plugins | 🔄 |
| Server templates | ❌ | ✅ | ❌ | ❌ | ❌ | | Server templates | ❌ | ✅ | ❌ | ❌ | ❌ |
| Server discovery | ❌ | ✅ | ✅ | ✅ | 🔄 | | Server discovery | ❌ | ✅ | ✅ | ✅ | 🔄 |
| Server analytics | ❌ | ✅ | ✅ | ❌ | ❌ | | Server analytics | ❌ | ✅ | ✅ | ❌ | ❌ |
@@ -139,10 +141,10 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
| Desktop notifications | ⚠️ possible via SW | ✅ | ✅ | ✅ | ✅ | | Desktop notifications | ⚠️ possible via SW | ✅ | ✅ | ✅ | ✅ |
| Web push notifications | ✅ | ✅ | ✅ | ✅ | ✅ | | Web push notifications | ✅ | ✅ | ✅ | ✅ | ✅ |
| @mention push | ✅ | ✅ | ✅ | ❌ | ✅ | | @mention push | ✅ | ✅ | ✅ | ❌ | ✅ |
| Channel-wide push | ✅ | ✅ | ✅ | ❌ | ✅ | | Channel-wide push (@everyone/@channel) | ✅ | ✅ | ✅ | ❌ | ✅ |
| Email notifications | ❌ | ✅ | ✅ | ❌ | 🔄 | | Email notifications | ❌ | ✅ | ✅ | ❌ | 🔄 |
| Mobile apps | ❌ | ✅ iOS/Android | ✅ | ✅ | 🔄 Flutter alpha | | Mobile apps | ❌ (PWA is target) | ✅ iOS/Android | ✅ | ✅ | 🔄 Flutter alpha |
| Per-channel notification settings | | ✅ | ✅ | ✅ | ✅ | | Per-channel notification settings | ⚠️ partial | ✅ | ✅ | ✅ | ✅ |
| Do Not Disturb schedule | ❌ | ✅ | ❌ | ❌ | ❌ | | Do Not Disturb schedule | ❌ | ✅ | ❌ | ❌ | ❌ |
--- ---
@@ -156,6 +158,7 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
| Slash commands | ✅ | ✅ | ✅ | ❌ | 🔄 | | Slash commands | ✅ | ✅ | ✅ | ❌ | 🔄 |
| Command options / JSON schema | ✅ | ✅ | ✅ | ❌ | 🔄 | | Command options / JSON schema | ✅ | ✅ | ✅ | ❌ | 🔄 |
| Bot mentions | ✅ | ✅ | ❌ | ❌ | 🔄 | | Bot mentions | ✅ | ✅ | ❌ | ❌ | 🔄 |
| Built-in bot runner (anonConfess, leaderboard, steamfree) | ✅ | ❌ | ⚠️ | ❌ | ❌ |
| Third-party integrations (Twitch, YouTube, GitHub) | ❌ | ✅ | ✅ | ❌ | 🔄 | | Third-party integrations (Twitch, YouTube, GitHub) | ❌ | ✅ | ✅ | ❌ | 🔄 |
| Webhook-driven bots | ✅ | ✅ | ✅ | ❌ | ✅ | | Webhook-driven bots | ✅ | ✅ | ✅ | ❌ | ✅ |
@@ -168,40 +171,11 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
- Terminal/Gruvbox aesthetic - Terminal/Gruvbox aesthetic
- WebAuthn / passkey auth - WebAuthn / passkey auth
- LiveKit voice integration - LiveKit voice integration
- Built-in webhook execution for simple integrations - Built-in bot store + managed runner
- PWA-first mobile (no native app planned)
### Discord ### Discord / Guilded / TeamSpeak / Fluxer
- Massive network effect (200M+ MAU) See historical notes in git history if needed. Not the product roadmap.
- Nitro subscription perks (animated avatars, HD streaming, larger uploads)
- Activities / embedded apps in voice channels
- Server boosting tiers
- Stage channels
- Activities marketplace
### Guilded (historical)
- Built-in calendar with RSVP
- Scheduling + availability system
- Docs / forms
- Lists (task management)
- Tournaments
- Server Subs monetization
- Server Groups (sub-servers)
### TeamSpeak 6
- Self-hosted by design, free up to 32 slots
- Granular Power/Needed Power permission system
- Low resource client
- Plugin ecosystem
- Virtual servers
- Whisper / poke / channel commander
### Fluxer
- Fully open-source AGPL-3, Docker Compose deploy
- No paywalls / license keys
- Planned federation
- Multi-backend switching
- Erlang/OTP gateway for scale
- 34 locales
--- ---
@@ -210,38 +184,37 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
| Feature | dumpsterChat | Discord | Guilded | TeamSpeak 6 | Fluxer | | Feature | dumpsterChat | Discord | Guilded | TeamSpeak 6 | Fluxer |
|---------|:------------:|:-------:|:-------:|:-----------:|:------:| |---------|:------------:|:-------:|:-------:|:-----------:|:------:|
| Self-hostable | ✅ | ❌ | ❌ | ✅ | ✅ | | Self-hostable | ✅ | ❌ | ❌ | ✅ | ✅ |
| Open source | | ❌ | ❌ | ❌ | ✅ AGPL-3 | | Open source | ⚠️ private self-host | ❌ | ❌ | ❌ | ✅ AGPL-3 |
| PWA support | ✅ | ✅ | ✅ | ✅ | ✅ | | PWA support | ✅ | ✅ | ✅ | ✅ | ✅ |
| REST API | ✅ | ✅ | ✅ | ❌ | ✅ | | REST API | ✅ | ✅ | ✅ | ❌ | ✅ |
| WebSocket gateway | ✅ | ✅ | ✅ | ✅ | ✅ | | WebSocket gateway | ✅ | ✅ | ✅ | ✅ | ✅ |
| Swagger docs | ✅ localhost | ✅ | ✅ | ❌ | ✅ | | Swagger docs | ✅ /docs | ✅ | ✅ | ❌ | ✅ |
| Docker Compose | ✅ | ❌ | ❌ | ❌ | ✅ | | Docker Compose | ✅ | ❌ | ❌ | ❌ | ✅ |
--- ---
## Priority Recommendations ## Priority Recommendations (updated)
### Critical gaps (would block most Discord/Guilded users) **Do not treat this table as a todo list.** For a ~12 person friend server, parity rows are optional.
1. **Direct Messages** — no way to message users outside servers ### Actual next polish (product)
2. **Mobile app** — major adoption blocker 1. Mobile PWA pain (input, notifs, safe areas)
3. **Voice push-to-talk** — important for voice-heavy communities 2. Role color UI / hierarchy polish
4. **Email notifications** — needed for async engagement 3. Voice PTT / screen share only if voice is used
5. **Read receipts / unread state** — channel-level read tracking 4. Small slash toys (`/roll`, `/choose`) if wanted
### High-value next features ### Real tech debt
1. More tests on hot paths (permissions DB checker, bot auth)
2. Keep client permission cache in sync after role edits (partially done)
3. Deploy docs must name the real unit: `dumpster.service`
1. **Screen share** — LiveKit supports it; mostly frontend work ### Explicitly not critical
2. **Custom emoji / reactions beyond unicode** — core Discord behavior - Friend requests (DMs already exist among members)
3. **Server groups (sub-servers)** — channel organization - Native mobile apps
4. **Do Not Disturb schedule** — notification control - Discord bot ecosystem compatibility
5. **Third-party integrations** — Twitch, YouTube, GitHub - Server discovery / monetization / federation
6. **Stage channels** — presentation-style voice - AutoMod / Flow Bots
### Nice-to-have differentiators ### Stale claims removed
- ~~"Direct Messages missing"~~ — DMs exist
1. **No-code Flow Bots** (Guilded-style automations) - ~~"Mobile app is the only path"~~ — PWA is the target client
2. **Server discovery / directory**
3. **Activities / embedded games**
4. **Federation** (Fluxer-style)
5. **Server analytics**
+24
View File
@@ -161,6 +161,30 @@ Users are prompted to enable notifications on login. On iOS, the prompt requires
Full documentation: [dumpsterChat wiki](ssh://git@git.dustin.coffee:2222/hobokenchicken/dumpsterChat.wiki.git) Full documentation: [dumpsterChat wiki](ssh://git@git.dustin.coffee:2222/hobokenchicken/dumpsterChat.wiki.git)
## Production Deploy (SBC / 172.20.0.125)
App lives at `/opt/dumpsterChat`. systemd unit name is **`dumpster.service`** (not `dumpsterChat`).
```bash
# Build locally
CGO_ENABLED=0 go build -o dumpster-server ./cmd/server
(cd web && npm run build)
# Ship binary + web assets
scp dumpster-server root@172.20.0.125:/tmp/dumpster-server-new
rsync -av --delete web/dist/ root@172.20.0.125:/opt/dumpsterChat/web/dist/
ssh root@172.20.0.125 '
install -m 755 /tmp/dumpster-server-new /opt/dumpsterChat/dumpster-server
systemctl restart dumpster
systemctl is-active dumpster
'
```
Logs: `journalctl -u dumpster -f`
Health: `curl -s http://127.0.0.1:8080/` (or your API health route)
Public: Caddy → `dumpster.dustin.coffee``172.20.0.125:8080`
## License ## License
AGPLv3 AGPLv3
+13
View File
@@ -256,6 +256,19 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
return return
} }
// Gate @everyone / @channel on MENTION_EVERYONE (owner/admin always pass).
if hasBroadcastToken(req.Content, "everyone") || hasBroadcastToken(req.Content, "channel") {
allowed, permErr := h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MENTION_EVERYONE)
if permErr != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
if !allowed {
http.Error(w, `{"error":"missing permission: MENTION_EVERYONE"}`, http.StatusForbidden)
return
}
}
// Anonymous confessions: never store/broadcast the original /confess message. // Anonymous confessions: never store/broadcast the original /confess message.
if h.confess != nil { if h.confess != nil {
if payload, handled := h.confess.TryConfess(r.Context(), serverID, userID, req.Content); handled { if payload, handled := h.confess.TryConfess(r.Context(), serverID, userID, req.Content); handled {
+64 -16
View File
@@ -6,13 +6,14 @@ import (
"log/slog" "log/slog"
"regexp" "regexp"
"strings" "strings"
"unicode"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push"
) )
var mentionRegex = regexp.MustCompile(`<@([0-9a-f-]+)>`) var mentionRegex = regexp.MustCompile(`<@([0-9a-f-]+)>`)
var everyoneMention = "@everyone"
var roleMentionRegex = regexp.MustCompile(`<@&([0-9a-f-]+)>`) var roleMentionRegex = regexp.MustCompile(`<@&([0-9a-f-]+)>`)
var plainUsernameMention = regexp.MustCompile(`@([a-zA-Z0-9_.-]+)`)
// MentionHandler dispatches push notifications for @mentions. // MentionHandler dispatches push notifications for @mentions.
type MentionHandler struct { type MentionHandler struct {
@@ -29,20 +30,40 @@ func NewMentionHandler(db *sql.DB, pushHandler *push.Handler, logger *slog.Logge
} }
} }
// hasBroadcastToken reports whether content contains @everyone / @channel as a whole token.
func hasBroadcastToken(content, token string) bool {
// token like "everyone" or "channel" (without @)
needle := "@" + token
idx := 0
for {
i := strings.Index(strings.ToLower(content[idx:]), needle)
if i < 0 {
return false
}
i += idx
end := i + len(needle)
if end >= len(content) || !isUsernameChar(rune(content[end])) {
return true
}
idx = end
}
}
func isUsernameChar(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '.' || r == '-'
}
// ParseAndNotify parses message content for mentions and sends push notifications. // ParseAndNotify parses message content for mentions and sends push notifications.
func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID, content string) { func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID, content string) {
// Find individual user mentions
userMatches := mentionRegex.FindAllStringSubmatch(content, -1)
mentionedUsers := make(map[string]bool) mentionedUsers := make(map[string]bool)
for _, match := range userMatches {
// Discord-style ID mentions
for _, match := range mentionRegex.FindAllStringSubmatch(content, -1) {
if len(match) > 1 { if len(match) > 1 {
mentionedUsers[match[1]] = true mentionedUsers[match[1]] = true
} }
} }
// Check for @everyone
isEveryone := strings.Contains(content, everyoneMention)
// Get channel info for notification // Get channel info for notification
var serverID, channelName string var serverID, channelName string
err := m.db.QueryRowContext(ctx, err := m.db.QueryRowContext(ctx,
@@ -75,8 +96,9 @@ func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID
"url": "/channels/" + channelID, "url": "/channels/" + channelID,
} }
if isEveryone { // @everyone / @channel — fan out to server members (permission gated at create).
// Send to all server members except those who muted this channel // ponytail: both use the same fanout; UI labels differ. Split if channel-private members matter.
if hasBroadcastToken(content, "everyone") || hasBroadcastToken(content, "channel") {
rows, err := m.db.QueryContext(ctx, rows, err := m.db.QueryContext(ctx,
`SELECT m.user_id FROM members m `SELECT m.user_id FROM members m
LEFT JOIN notification_settings ns ON ns.user_id = m.user_id AND ns.channel_id = $3 LEFT JOIN notification_settings ns ON ns.user_id = m.user_id AND ns.channel_id = $3
@@ -85,7 +107,7 @@ func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID
serverID, authorID, channelID, serverID, authorID, channelID,
) )
if err != nil { if err != nil {
m.logger.Error("failed to query server members for @everyone", "error", err) m.logger.Error("failed to query server members for broadcast mention", "error", err)
return return
} }
defer rows.Close() defer rows.Close()
@@ -100,13 +122,43 @@ func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID
return return
} }
// Check for role mentions // Plain @username mentions (what the frontend actually stores)
usernames := make([]string, 0)
seenUsernames := make(map[string]bool)
for _, match := range plainUsernameMention.FindAllStringSubmatch(content, -1) {
if len(match) < 2 {
continue
}
u := strings.ToLower(match[1])
if u == "everyone" || u == "channel" || u == "here" {
continue
}
if !seenUsernames[u] {
seenUsernames[u] = true
usernames = append(usernames, match[1])
}
}
if len(usernames) > 0 {
// Resolve usernames that are members of this server.
for _, uname := range usernames {
var uid string
err := m.db.QueryRowContext(ctx, `
SELECT u.id FROM users u
JOIN members m ON m.user_id = u.id
WHERE m.server_id = $1 AND LOWER(u.username) = LOWER($2)
`, serverID, uname).Scan(&uid)
if err == nil {
mentionedUsers[uid] = true
}
}
}
// Role mentions
roleMatches := roleMentionRegex.FindAllStringSubmatch(content, -1) roleMatches := roleMentionRegex.FindAllStringSubmatch(content, -1)
if len(roleMatches) > 0 { if len(roleMatches) > 0 {
for _, match := range roleMatches { for _, match := range roleMatches {
if len(match) > 1 { if len(match) > 1 {
roleID := match[1] roleID := match[1]
// Get users with this role
rows, err := m.db.QueryContext(ctx, rows, err := m.db.QueryContext(ctx,
`SELECT user_id FROM member_roles WHERE role_id = $1 AND user_id != $2`, `SELECT user_id FROM member_roles WHERE role_id = $1 AND user_id != $2`,
roleID, authorID, roleID, authorID,
@@ -127,12 +179,9 @@ func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID
} }
} }
// Remove the author from mentions
delete(mentionedUsers, authorID) delete(mentionedUsers, authorID)
// Send push to individually mentioned users
for userID := range mentionedUsers { for userID := range mentionedUsers {
// Check if user is in DND status
var status string var status string
err := m.db.QueryRowContext(ctx, err := m.db.QueryRowContext(ctx,
`SELECT COALESCE(status, 'online') FROM users WHERE id = $1`, userID, `SELECT COALESCE(status, 'online') FROM users WHERE id = $1`, userID,
@@ -144,7 +193,6 @@ func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID
continue continue
} }
// Check if user muted this channel
var level string var level string
err = m.db.QueryRowContext(ctx, err = m.db.QueryRowContext(ctx,
`SELECT level FROM notification_settings WHERE user_id = $1 AND channel_id = $2`, `SELECT level FROM notification_settings WHERE user_id = $1 AND channel_id = $2`,
+38
View File
@@ -0,0 +1,38 @@
package message
import "testing"
func TestHasBroadcastToken(t *testing.T) {
cases := []struct {
content string
token string
want bool
}{
{"hello @everyone", "everyone", true},
{"@everyone hi", "everyone", true},
{"@EVERYONE", "everyone", true},
{"@everyone!", "everyone", true},
{"@everyoneelse", "everyone", false},
{"noteveryone", "everyone", false},
{"@channel", "channel", true},
{"ping @channel please", "channel", true},
{"@channeling", "channel", false},
{"", "everyone", false},
{"@", "everyone", false},
}
for _, tc := range cases {
got := hasBroadcastToken(tc.content, tc.token)
if got != tc.want {
t.Errorf("hasBroadcastToken(%q, %q) = %v, want %v", tc.content, tc.token, got, tc.want)
}
}
}
func TestIsUsernameChar(t *testing.T) {
if !isUsernameChar('a') || !isUsernameChar('9') || !isUsernameChar('_') {
t.Fatal("expected alnum/_")
}
if isUsernameChar(' ') || isUsernameChar('!') || isUsernameChar('@') {
t.Fatal("unexpected username chars")
}
}
+59
View File
@@ -0,0 +1,59 @@
package permissions
import "testing"
func TestHas(t *testing.T) {
set := VIEW_CHANNEL | SEND_MESSAGES | MENTION_EVERYONE
if !Has(set, VIEW_CHANNEL) {
t.Fatal("expected VIEW_CHANNEL")
}
if !Has(set, SEND_MESSAGES) {
t.Fatal("expected SEND_MESSAGES")
}
if Has(set, KICK_MEMBERS) {
t.Fatal("did not expect KICK_MEMBERS")
}
if !Has(set, VIEW_CHANNEL|SEND_MESSAGES) {
t.Fatal("expected multi-bit all-present")
}
if Has(set, VIEW_CHANNEL|KICK_MEMBERS) {
t.Fatal("multi-bit should require all bits")
}
}
func TestAdministratorBypassSemantics(t *testing.T) {
// Client/backend convention: ADMINISTRATOR implies all gates when checked separately.
if !Has(ADMINISTRATOR, ADMINISTRATOR) {
t.Fatal("admin flag self")
}
// ADMINISTRATOR alone does not set other bits; Has is pure bit check.
if Has(ADMINISTRATOR, KICK_MEMBERS) {
t.Fatal("Has is not an admin-implies-all helper; CheckPermission does that")
}
}
func TestDefaultEveryoneDoesNotIncludeMentionEveryone(t *testing.T) {
if Has(DefaultEveryonePermissions, MENTION_EVERYONE) {
t.Fatal("@everyone default must not grant MENTION_EVERYONE")
}
if !Has(DefaultEveryonePermissions, SEND_MESSAGES) {
t.Fatal("@everyone default should grant SEND_MESSAGES")
}
}
func TestAddRemove(t *testing.T) {
p := int64(0)
p = Add(p, VIEW_CHANNEL)
p = Add(p, KICK_MEMBERS)
if !Has(p, VIEW_CHANNEL|KICK_MEMBERS) {
t.Fatal("Add failed")
}
p = Remove(p, KICK_MEMBERS)
if Has(p, KICK_MEMBERS) {
t.Fatal("Remove failed")
}
if !Has(p, VIEW_CHANNEL) {
t.Fatal("Remove cleared wrong bit")
}
}
+32 -18
View File
@@ -13,7 +13,8 @@ import Picker, { Theme } from 'emoji-picker-react';
import { CommandDropdown } from "./CommandDropdown"; import { CommandDropdown } from "./CommandDropdown";
import { findCommand, SLASH_COMMANDS } from "../lib/slashCommands"; import { findCommand, SLASH_COMMANDS } from "../lib/slashCommands";
import { PollDisplay, CreatePollModal } from "./Poll.tsx"; import { PollDisplay, CreatePollModal } from "./Poll.tsx";
import { MentionDropdown } from "./MentionDropdown"; import { MentionDropdown, buildMentionOptions } from "./MentionDropdown";
import { usePermissions } from "../lib/usePermissions.ts";
import { useReadStatesStore } from "../stores/readStates.ts"; import { useReadStatesStore } from "../stores/readStates.ts";
import { MessageSearch } from "./MessageSearch"; import { MessageSearch } from "./MessageSearch";
import { ThreadListPanel } from "./ThreadListPanel.tsx"; import { ThreadListPanel } from "./ThreadListPanel.tsx";
@@ -45,7 +46,7 @@ function formatTime(iso: string): string {
} }
function renderContent(content: string, memberUsernames: Set<string>) { function renderContent(content: string, memberUsernames: Set<string>) {
const segments: { type: "text" | "mention"; value: string }[] = []; const segments: { type: "text" | "mention"; value: string; special?: boolean }[] = [];
const mentionRe = /@([a-zA-Z0-9_.-]+)/g; const mentionRe = /@([a-zA-Z0-9_.-]+)/g;
let last = 0; let last = 0;
let match: RegExpExecArray | null; let match: RegExpExecArray | null;
@@ -54,8 +55,13 @@ function renderContent(content: string, memberUsernames: Set<string>) {
segments.push({ type: "text", value: content.slice(last, match.index) }); segments.push({ type: "text", value: content.slice(last, match.index) });
} }
const username = match[1]; const username = match[1];
if (memberUsernames.has(username)) { const lower = username.toLowerCase();
segments.push({ type: "mention", value: username }); if (lower === "everyone" || lower === "channel" || lower === "here" || memberUsernames.has(username)) {
segments.push({
type: "mention",
value: username,
special: lower === "everyone" || lower === "channel" || lower === "here",
});
} else { } else {
segments.push({ type: "text", value: match[0] }); segments.push({ type: "text", value: match[0] });
} }
@@ -71,7 +77,10 @@ function renderContent(content: string, memberUsernames: Set<string>) {
const nextSeg = segments[idx + 1]; const nextSeg = segments[idx + 1];
const needsSpace = !nextSeg || (nextSeg.type === "text" && !nextSeg.value.startsWith(" ")); const needsSpace = !nextSeg || (nextSeg.type === "text" && !nextSeg.value.startsWith(" "));
return ( return (
<span key={idx} className="text-gb-aqua"> <span
key={idx}
className={seg.special ? "text-gb-orange font-bold bg-gb-orange/15 px-0.5 rounded-sm" : "text-gb-aqua"}
>
@{seg.value}{needsSpace ? " " : ""} @{seg.value}{needsSpace ? " " : ""}
</span> </span>
); );
@@ -329,6 +338,7 @@ export function ChatArea() {
// Humans only for mentions / nickname lookup (bots live in member list separately). // Humans only for mentions / nickname lookup (bots live in member list separately).
const humanMembers = useMemo(() => members.filter((m) => !m.is_bot), [members]); const humanMembers = useMemo(() => members.filter((m) => !m.is_bot), [members]);
const memberUsernames = useMemo(() => new Set(humanMembers.map((m) => m.username)), [humanMembers]); const memberUsernames = useMemo(() => new Set(humanMembers.map((m) => m.username)), [humanMembers]);
const { canMentionEveryone } = usePermissions(activeServerId);
const markRead = useReadStatesStore((s) => s.markRead); const markRead = useReadStatesStore((s) => s.markRead);
const readStates = useReadStatesStore((s) => s.states); const readStates = useReadStatesStore((s) => s.states);
@@ -555,10 +565,7 @@ export function ChatArea() {
if (!isDropdownOpen) return; if (!isDropdownOpen) return;
const itemCount = mq !== null const itemCount = mq !== null
? humanMembers.filter((m) => ? buildMentionOptions(mq, humanMembers, canMentionEveryone).length
m.username.toLowerCase().includes(mq.toLowerCase()) ||
m.display_name?.toLowerCase().includes(mq.toLowerCase())
).slice(0, 6).length
: cq !== null : cq !== null
? SLASH_COMMANDS.filter((c) => c.name.startsWith(cq.toLowerCase())).slice(0, 8).length ? SLASH_COMMANDS.filter((c) => c.name.startsWith(cq.toLowerCase())).slice(0, 8).length
: 0; : 0;
@@ -575,13 +582,14 @@ export function ChatArea() {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (mq !== null) { if (mq !== null) {
const q = mq.toLowerCase(); const options = buildMentionOptions(mq, humanMembers, canMentionEveryone);
const filtered = humanMembers.filter((m) => const selected = options[di];
m.username.toLowerCase().includes(q) || if (selected) {
m.display_name?.toLowerCase().includes(q) if (selected.kind === "special") {
).slice(0, 6); handleMentionSelect(selected.label);
if (filtered[di]) { } else {
handleMentionSelect(filtered[di].username); handleMentionSelect(selected.member.username);
}
} }
} else if (cq !== null) { } else if (cq !== null) {
const q = cq.toLowerCase(); const q = cq.toLowerCase();
@@ -620,7 +628,7 @@ export function ChatArea() {
}; };
window.addEventListener('keydown', handler); window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler);
}, [humanMembers, currentUser, activeChannelId, sendMessage, replyToMessage, handleMentionSelect]); }, [humanMembers, canMentionEveryone, currentUser, activeChannelId, sendMessage, replyToMessage, handleMentionSelect]);
const handleSubmit = useCallback(async () => { const handleSubmit = useCallback(async () => {
if (mentionQuery !== null || commandQuery !== null) return; if (mentionQuery !== null || commandQuery !== null) return;
@@ -904,7 +912,13 @@ export function ChatArea() {
)} )}
<div className="p-3 relative"> <div className="p-3 relative">
{mentionQuery !== null && ( {mentionQuery !== null && (
<MentionDropdown query={mentionQuery} members={humanMembers} selectedIndex={dropdownIndex} onSelect={handleMentionSelect} /> <MentionDropdown
query={mentionQuery}
members={humanMembers}
selectedIndex={dropdownIndex}
onSelect={handleMentionSelect}
canMentionEveryone={canMentionEveryone}
/>
)} )}
{commandQuery !== null && ( {commandQuery !== null && (
<CommandDropdown <CommandDropdown
+10
View File
@@ -6,6 +6,7 @@ import { useWebSocketStore } from '../stores/ws.ts';
import { useServerStore } from '../stores/server.ts'; import { useServerStore } from '../stores/server.ts';
import { useChannelStore } from '../stores/channel.ts'; import { useChannelStore } from '../stores/channel.ts';
import { useLayoutStore } from '../stores/layout.ts'; import { useLayoutStore } from '../stores/layout.ts';
import { useRoleStore } from '../stores/role.ts';
import { ServerBar } from './ServerBar.tsx'; import { ServerBar } from './ServerBar.tsx';
import { ChannelList } from './ChannelList.tsx'; import { ChannelList } from './ChannelList.tsx';
import { ConversationList } from './ConversationList.tsx'; import { ConversationList } from './ConversationList.tsx';
@@ -43,9 +44,18 @@ export function Layout() {
const setMobileView = useLayoutStore((s) => s.setMobileView); const setMobileView = useLayoutStore((s) => s.setMobileView);
const [showServerSettings, setShowServerSettings] = useState(false); const [showServerSettings, setShowServerSettings] = useState(false);
const activeServerId = useServerStore((s) => s.activeServerId); const activeServerId = useServerStore((s) => s.activeServerId);
const fetchRoles = useRoleStore((s) => s.fetchRoles);
const fetchMyRoles = useRoleStore((s) => s.fetchMyRoles);
const currentVoiceRoom = useVoiceStore((s) => s.currentRoom); const currentVoiceRoom = useVoiceStore((s) => s.currentRoom);
const [activeTab, setActiveTab] = useState<'chat' | 'voice'>('chat'); const [activeTab, setActiveTab] = useState<'chat' | 'voice'>('chat');
// Load server roles + current user's role assignments for accurate client permission gates.
useEffect(() => {
if (!activeServerId || !user?.id) return;
void fetchRoles(activeServerId);
void fetchMyRoles(activeServerId, user.id);
}, [activeServerId, user?.id, fetchRoles, fetchMyRoles]);
useEffect(() => { useEffect(() => {
if (currentVoiceRoom) setActiveTab('voice'); if (currentVoiceRoom) setActiveTab('voice');
else setActiveTab('chat'); else setActiveTab('chat');
+6 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useRoleStore, type Role } from '../stores/role.ts'; import { useRoleStore, type Role } from '../stores/role.ts';
import type { User } from '../stores/auth.ts'; import { useAuthStore, type User } from '../stores/auth.ts';
import { usePermissions } from '../lib/usePermissions.ts'; import { usePermissions } from '../lib/usePermissions.ts';
interface MemberRoleAssignProps { interface MemberRoleAssignProps {
@@ -12,8 +12,10 @@ interface MemberRoleAssignProps {
export function MemberRoleAssign({ serverId, member }: MemberRoleAssignProps) { export function MemberRoleAssign({ serverId, member }: MemberRoleAssignProps) {
const roles = useRoleStore((s) => s.roles); const roles = useRoleStore((s) => s.roles);
const fetchRoles = useRoleStore((s) => s.fetchRoles); const fetchRoles = useRoleStore((s) => s.fetchRoles);
const fetchMyRoles = useRoleStore((s) => s.fetchMyRoles);
const setMemberRoles = useRoleStore((s) => s.setMemberRoles); const setMemberRoles = useRoleStore((s) => s.setMemberRoles);
const getMemberRoles = useRoleStore((s) => s.getMemberRoles); const getMemberRoles = useRoleStore((s) => s.getMemberRoles);
const currentUserId = useAuthStore((s) => s.user?.id);
const { isOwner, canManageRoles } = usePermissions(serverId); const { isOwner, canManageRoles } = usePermissions(serverId);
const canEdit = isOwner || canManageRoles; const canEdit = isOwner || canManageRoles;
@@ -56,6 +58,9 @@ export function MemberRoleAssign({ serverId, member }: MemberRoleAssignProps) {
try { try {
await setMemberRoles(serverId, member.id, Array.from(selectedIds)); await setMemberRoles(serverId, member.id, Array.from(selectedIds));
setMemberRolesState(roles.filter((r) => selectedIds.has(r.id))); setMemberRolesState(roles.filter((r) => selectedIds.has(r.id)));
if (currentUserId && member.id === currentUserId) {
await fetchMyRoles(serverId, currentUserId);
}
setOpen(false); setOpen(false);
} catch { } catch {
// error in store // error in store
+72 -12
View File
@@ -1,30 +1,91 @@
import type { Member } from "../stores/member.ts"; import type { Member } from "../stores/member.ts";
export type MentionOption =
| { kind: "special"; id: string; label: string; description: string }
| { kind: "user"; member: Member };
interface MentionDropdownProps { interface MentionDropdownProps {
query: string; query: string;
members: Member[]; members: Member[];
selectedIndex: number; selectedIndex: number;
onSelect: (username: string) => void; onSelect: (username: string) => void;
canMentionEveryone?: boolean;
} }
export function MentionDropdown({ query, members, selectedIndex, onSelect }: MentionDropdownProps) { const SPECIALS: { id: string; label: string; description: string }[] = [
{ id: "everyone", label: "everyone", description: "Notify the entire server" },
{ id: "channel", label: "channel", description: "Notify everyone in this channel" },
];
export function buildMentionOptions(
query: string,
members: Member[],
canMentionEveryone: boolean,
): MentionOption[] {
const q = query.toLowerCase(); const q = query.toLowerCase();
const filtered = members const options: MentionOption[] = [];
if (canMentionEveryone) {
for (const s of SPECIALS) {
if (!q || s.id.startsWith(q) || s.label.startsWith(q)) {
options.push({ kind: "special", id: s.id, label: s.label, description: s.description });
}
}
}
const users = members
.filter( .filter(
(m) => (m) =>
m.username.toLowerCase().includes(q) || m.username.toLowerCase().includes(q) ||
m.display_name?.toLowerCase().includes(q), m.display_name?.toLowerCase().includes(q),
) )
.slice(0, 6); .slice(0, 6)
.map((m): MentionOption => ({ kind: "user", member: m }));
if (filtered.length === 0) return null; return [...options, ...users].slice(0, 8);
}
export function MentionDropdown({
query,
members,
selectedIndex,
onSelect,
canMentionEveryone = false,
}: MentionDropdownProps) {
const options = buildMentionOptions(query, members, canMentionEveryone);
if (options.length === 0) return null;
return ( return (
<div className="absolute bottom-full left-0 mb-1 z-50 w-64 max-h-48 overflow-y-auto bg-gb-bg-s border border-gb-bg-t shadow-lg"> <div className="absolute bottom-full left-0 mb-1 z-50 w-72 max-h-48 overflow-y-auto bg-gb-bg-s border border-gb-bg-t shadow-lg">
<div className="px-2 py-1 text-xs text-gb-fg-s font-mono border-b border-gb-bg-t"> <div className="px-2 py-1 text-xs text-gb-fg-s font-mono border-b border-gb-bg-t">
MENTION MENTION
</div> </div>
{filtered.map((m, i) => ( {options.map((opt, i) => {
const active = i === selectedIndex;
if (opt.kind === "special") {
return (
<button
key={opt.id}
type="button"
onMouseDown={(e) => {
e.preventDefault();
onSelect(opt.label);
}}
className={`w-full px-2 py-1.5 text-left text-sm font-mono flex items-center gap-2 transition-colors ${
active ? "bg-gb-orange text-gb-bg" : "hover:bg-gb-orange hover:text-gb-bg"
}`}
>
<span className={active ? "text-gb-bg" : "text-gb-orange"}>@</span>
<span className="font-bold">{opt.label}</span>
<span className={`text-xs truncate ${active ? "text-gb-bg" : "text-gb-fg-f"}`}>
{opt.description}
</span>
</button>
);
}
const m = opt.member;
return (
<button <button
key={m.id} key={m.id}
type="button" type="button"
@@ -33,20 +94,19 @@ export function MentionDropdown({ query, members, selectedIndex, onSelect }: Men
onSelect(m.username); onSelect(m.username);
}} }}
className={`w-full px-2 py-1 text-left text-sm font-mono flex items-center gap-2 transition-colors ${ className={`w-full px-2 py-1 text-left text-sm font-mono flex items-center gap-2 transition-colors ${
i === selectedIndex active ? "bg-gb-orange text-gb-bg" : "hover:bg-gb-orange hover:text-gb-bg"
? "bg-gb-orange text-gb-bg"
: "hover:bg-gb-orange hover:text-gb-bg"
}`} }`}
> >
<span className={i === selectedIndex ? "text-gb-bg" : "text-gb-green"}></span> <span className={active ? "text-gb-bg" : "text-gb-green"}></span>
<span className="truncate">{m.display_name || m.username}</span> <span className="truncate">{m.display_name || m.username}</span>
{m.display_name && m.display_name !== m.username && ( {m.display_name && m.display_name !== m.username && (
<span className={`text-xs ${i === selectedIndex ? "text-gb-bg" : "text-gb-fg-f"}`}> <span className={`text-xs ${active ? "text-gb-bg" : "text-gb-fg-f"}`}>
({m.username}) ({m.username})
</span> </span>
)} )}
</button> </button>
))} );
})}
</div> </div>
); );
} }
+25 -24
View File
@@ -1,47 +1,46 @@
import { useCallback } from 'react'; import { useCallback, useMemo } from 'react';
import { useServerStore } from '../stores/server.ts'; import { useServerStore } from '../stores/server.ts';
import { useAuthStore } from '../stores/auth.ts'; import { useAuthStore } from '../stores/auth.ts';
import { useRoleStore } from '../stores/role.ts'; import { useRoleStore } from '../stores/role.ts';
import { PERMS, hasPermission } from '../stores/permissions.ts';
const PERMS = {
VIEW_CHANNEL: 1,
SEND_MESSAGES: 2,
MANAGE_MESSAGES: 4,
KICK_MEMBERS: 8,
BAN_MEMBERS: 16,
MANAGE_SERVER: 32,
MANAGE_CHANNELS: 64,
ADMINISTRATOR: 128,
CONNECT_VOICE: 256,
SPEAK_VOICE: 512,
SHARE_SCREEN: 1024,
MUTE_MEMBERS: 2048,
CHANGE_NICKNAME: 8192,
MANAGE_NICKNAMES: 16384,
MANAGE_ROLES: 32768,
} as const;
export { PERMS }; export { PERMS };
/**
* Client-side permission gates for the current user on a server.
* Mirrors backend GetUserPermissions: OR of assigned roles + @everyone (is_default).
* Owner always passes. Backend remains authoritative.
*/
export function usePermissions(serverId: string | null) { export function usePermissions(serverId: string | null) {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const servers = useServerStore((s) => s.servers); const servers = useServerStore((s) => s.servers);
const roles = useRoleStore((s) => s.roles); const roles = useRoleStore((s) => s.roles);
const myRolesByServer = useRoleStore((s) => s.myRolesByServer);
const server = serverId ? servers.find((s) => s.id === serverId) : null; const server = serverId ? servers.find((s) => s.id === serverId) : null;
const isOwner = Boolean(server && user && server.owner_id === user.id); const isOwner = Boolean(server && user && server.owner_id === user.id);
const memberRoles = roles.filter((r) => r.server_id === serverId); const myRoles = serverId ? myRolesByServer[serverId] || [] : [];
const everyoneRole = useMemo(
() => (serverId ? roles.find((r) => r.server_id === serverId && r.is_default) : undefined),
[roles, serverId],
);
const effective = useMemo(() => {
let acc = 0;
for (const r of myRoles) acc |= r.permissions;
if (everyoneRole) acc |= everyoneRole.permissions;
return acc;
}, [myRoles, everyoneRole]);
const has = useCallback( const has = useCallback(
(flag: number) => { (flag: number) => {
if (!serverId || !user) return false; if (!serverId || !user) return false;
if (isOwner) return true; if (isOwner) return true;
const effective = memberRoles.reduce((acc, r) => acc | r.permissions, 0); if (hasPermission(effective, PERMS.ADMINISTRATOR)) return true;
if ((effective & PERMS.ADMINISTRATOR) !== 0) return true; return hasPermission(effective, flag);
return (effective & flag) === flag;
}, },
[serverId, user, isOwner, memberRoles], [serverId, user, isOwner, effective],
); );
return { return {
@@ -53,6 +52,8 @@ export function usePermissions(serverId: string | null) {
canManageServer: has(PERMS.MANAGE_SERVER), canManageServer: has(PERMS.MANAGE_SERVER),
canChangeNickname: has(PERMS.CHANGE_NICKNAME), canChangeNickname: has(PERMS.CHANGE_NICKNAME),
canManageNicknames: has(PERMS.MANAGE_NICKNAMES), canManageNicknames: has(PERMS.MANAGE_NICKNAMES),
canMentionEveryone: has(PERMS.MENTION_EVERYONE),
isOwner, isOwner,
effectivePermissions: effective,
}; };
} }
+2 -1
View File
@@ -55,7 +55,8 @@ export const PERMISSION_LABELS: Record<PermissionKey, string> = {
}; };
export function hasPermission(perms: number, flag: number): boolean { export function hasPermission(perms: number, flag: number): boolean {
return (perms & flag) !== 0; // Match backend permissions.Has: all required bits must be present.
return (perms & flag) === flag;
} }
export interface ChannelOverride { export interface ChannelOverride {
+53 -5
View File
@@ -27,10 +27,13 @@ export interface UpdateRoleData {
interface RoleState { interface RoleState {
roles: Role[]; roles: Role[];
/** Assigned roles for the current user, keyed by server id. Does not include @everyone. */
myRolesByServer: Record<string, Role[]>;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
fetchRoles: (serverId: string) => Promise<void>; fetchRoles: (serverId: string) => Promise<void>;
fetchMyRoles: (serverId: string, userId: string) => Promise<void>;
createRole: (serverId: string, data: CreateRoleData) => Promise<Role>; createRole: (serverId: string, data: CreateRoleData) => Promise<Role>;
updateRole: (serverId: string, roleId: string, data: UpdateRoleData) => Promise<Role>; updateRole: (serverId: string, roleId: string, data: UpdateRoleData) => Promise<Role>;
deleteRole: (serverId: string, roleId: string) => Promise<void>; deleteRole: (serverId: string, roleId: string) => Promise<void>;
@@ -40,6 +43,7 @@ interface RoleState {
export const useRoleStore = create<RoleState>((set) => ({ export const useRoleStore = create<RoleState>((set) => ({
roles: [], roles: [],
myRolesByServer: {},
loading: false, loading: false,
error: null, error: null,
@@ -47,7 +51,14 @@ export const useRoleStore = create<RoleState>((set) => ({
set({ loading: true, error: null }); set({ loading: true, error: null });
try { try {
const roles = await api.get<Role[]>(`/servers/${serverId}/roles`); const roles = await api.get<Role[]>(`/servers/${serverId}/roles`);
set({ roles: roles.sort((a, b) => b.position - a.position), loading: false }); set((state) => ({
// Keep roles from other servers if mixed; replace same-server entries.
roles: [
...state.roles.filter((r) => r.server_id !== serverId),
...(Array.isArray(roles) ? roles : []),
].sort((a, b) => b.position - a.position),
loading: false,
}));
} catch (error) { } catch (error) {
set({ set({
loading: false, loading: false,
@@ -56,6 +67,25 @@ export const useRoleStore = create<RoleState>((set) => ({
} }
}, },
fetchMyRoles: async (serverId, userId) => {
try {
const roles = await api.get<Role[]>(`/servers/${serverId}/members/${userId}/roles`);
set((state) => ({
myRolesByServer: {
...state.myRolesByServer,
[serverId]: Array.isArray(roles) ? roles : [],
},
}));
} catch {
set((state) => ({
myRolesByServer: {
...state.myRolesByServer,
[serverId]: [],
},
}));
}
},
createRole: async (serverId, data) => { createRole: async (serverId, data) => {
set({ loading: true, error: null }); set({ loading: true, error: null });
try { try {
@@ -78,10 +108,19 @@ export const useRoleStore = create<RoleState>((set) => ({
set({ loading: true, error: null }); set({ loading: true, error: null });
try { try {
const role = await api.patch<Role>(`/servers/${serverId}/roles/${roleId}`, data); const role = await api.patch<Role>(`/servers/${serverId}/roles/${roleId}`, data);
set((state) => ({ set((state) => {
const myRoles = state.myRolesByServer[serverId];
const nextMy = myRoles
? myRoles.map((r) => (r.id === roleId ? role : r))
: myRoles;
return {
roles: state.roles.map((r) => (r.id === roleId ? role : r)).sort((a, b) => b.position - a.position), roles: state.roles.map((r) => (r.id === roleId ? role : r)).sort((a, b) => b.position - a.position),
myRolesByServer: nextMy
? { ...state.myRolesByServer, [serverId]: nextMy }
: state.myRolesByServer,
loading: false, loading: false,
})); };
});
return role; return role;
} catch (error) { } catch (error) {
set({ set({
@@ -96,10 +135,19 @@ export const useRoleStore = create<RoleState>((set) => ({
set({ loading: true, error: null }); set({ loading: true, error: null });
try { try {
await api.delete(`/servers/${serverId}/roles/${roleId}`); await api.delete(`/servers/${serverId}/roles/${roleId}`);
set((state) => ({ set((state) => {
const myRoles = state.myRolesByServer[serverId];
return {
roles: state.roles.filter((r) => r.id !== roleId), roles: state.roles.filter((r) => r.id !== roleId),
myRolesByServer: myRoles
? {
...state.myRolesByServer,
[serverId]: myRoles.filter((r) => r.id !== roleId),
}
: state.myRolesByServer,
loading: false, loading: false,
})); };
});
} catch (error) { } catch (error) {
set({ set({
loading: false, loading: false,