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 a277c78e2c
commit 3c7b8278ce
13 changed files with 470 additions and 176 deletions
+52 -79
View File
@@ -2,7 +2,7 @@
## 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 |
|---------|:------------:|:-------:|:-------:|:-----------:|:------:|
|| 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 | ✅ | ✅ | ✅ | ❌ | 🔄 |
| 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) | ✅ | ✅ | ❌ | ✅ |
---
@@ -68,7 +69,7 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
| Badges | ❌ | ✅ | ✅ | ✅ | ✅ |
| Usernames + discriminators | ✅ | ⚠️ handles | ❌ | ✅ UID | ✅ #0000 |
| Friend requests | ❌ | ✅ | ✅ | ❌ | ✅ |
|| Block list | ✅ | ✅ | ✅ | ❌ | ❌ |
| Block list | ✅ | ✅ | ✅ | ❌ | ❌ |
| Activity / game status | ❌ | ✅ | ✅ | ❌ | ❌ |
---
@@ -80,11 +81,12 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
| 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)
@@ -123,7 +125,7 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
| Server invites | ✅ | ✅ | ✅ | ✅ | ✅ |
| Vanity URLs | ❌ | ✅ Nitro | ❌ | ❌ | ❌ |
| Webhooks | ✅ | ✅ | ✅ | ❌ | ✅ |
| Bots / API | ⚠️ slash cmds | ✅ huge | ✅ Flow Bots | ❌ plugins | 🔄 |
| Bots / API | ⚠️ store + runner | ✅ huge | ✅ Flow Bots | ❌ plugins | 🔄 |
| Server templates | ❌ | ✅ | ❌ | ❌ | ❌ |
| Server discovery | ❌ | ✅ | ✅ | ✅ | 🔄 |
| Server analytics | ❌ | ✅ | ✅ | ❌ | ❌ |
@@ -139,10 +141,10 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
| Desktop notifications | ⚠️ possible via SW | ✅ | ✅ | ✅ | ✅ |
| Web push notifications | ✅ | ✅ | ✅ | ✅ | ✅ |
| @mention push | ✅ | ✅ | ✅ | ❌ | ✅ |
| Channel-wide push | ✅ | ✅ | ✅ | ❌ | ✅ |
| Channel-wide push (@everyone/@channel) | ✅ | ✅ | ✅ | ❌ | ✅ |
| Email notifications | ❌ | ✅ | ✅ | ❌ | 🔄 |
| Mobile apps | ❌ | ✅ iOS/Android | ✅ | ✅ | 🔄 Flutter alpha |
| Per-channel notification settings | | ✅ | ✅ | ✅ | ✅ |
| Mobile apps | ❌ (PWA is target) | ✅ iOS/Android | ✅ | ✅ | 🔄 Flutter alpha |
| Per-channel notification settings | ⚠️ partial | ✅ | ✅ | ✅ | ✅ |
| Do Not Disturb schedule | ❌ | ✅ | ❌ | ❌ | ❌ |
---
@@ -156,6 +158,7 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
| Slash commands | ✅ | ✅ | ✅ | ❌ | 🔄 |
| Command options / JSON schema | ✅ | ✅ | ✅ | ❌ | 🔄 |
| Bot mentions | ✅ | ✅ | ❌ | ❌ | 🔄 |
| Built-in bot runner (anonConfess, leaderboard, steamfree) | ✅ | ❌ | ⚠️ | ❌ | ❌ |
| Third-party integrations (Twitch, YouTube, GitHub) | ❌ | ✅ | ✅ | ❌ | 🔄 |
| Webhook-driven bots | ✅ | ✅ | ✅ | ❌ | ✅ |
@@ -168,40 +171,11 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
- Terminal/Gruvbox aesthetic
- WebAuthn / passkey auth
- LiveKit voice integration
- Built-in webhook execution for simple integrations
- Built-in bot store + managed runner
- PWA-first mobile (no native app planned)
### 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
### Discord / Guilded / TeamSpeak / Fluxer
See historical notes in git history if needed. Not the product roadmap.
---
@@ -210,38 +184,37 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
| Feature | dumpsterChat | Discord | Guilded | TeamSpeak 6 | Fluxer |
|---------|:------------:|:-------:|:-------:|:-----------:|:------:|
| Self-hostable | ✅ | ❌ | ❌ | ✅ | ✅ |
| Open source | | ❌ | ❌ | ❌ | ✅ AGPL-3 |
| Open source | ⚠️ private self-host | ❌ | ❌ | ❌ | ✅ AGPL-3 |
| PWA support | ✅ | ✅ | ✅ | ✅ | ✅ |
| REST API | ✅ | ✅ | ✅ | ❌ | ✅ |
| WebSocket gateway | ✅ | ✅ | ✅ | ✅ | ✅ |
| Swagger docs | ✅ localhost | ✅ | ✅ | ❌ | ✅ |
| Swagger docs | ✅ /docs | ✅ | ✅ | ❌ | ✅ |
| 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
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
### 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
### 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
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
### Explicitly not critical
- Friend requests (DMs already exist among members)
- Native mobile apps
- Discord bot ecosystem compatibility
- Server discovery / monetization / federation
- AutoMod / Flow Bots
### 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**
### Stale claims removed
- ~~"Direct Messages missing"~~ — DMs exist
- ~~"Mobile app is the only path"~~ — PWA is the target client
+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)
## 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
+13
View File
@@ -256,6 +256,19 @@ 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 {
+64 -16
View File
@@ -6,13 +6,14 @@ 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 {
@@ -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.
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)
for _, match := range userMatches {
// Discord-style ID mentions
for _, match := range mentionRegex.FindAllStringSubmatch(content, -1) {
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,
@@ -75,8 +96,9 @@ func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID
"url": "/channels/" + channelID,
}
if isEveryone {
// Send to all server members except those who muted this channel
// @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") {
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
@@ -85,7 +107,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 @everyone", "error", err)
m.logger.Error("failed to query server members for broadcast mention", "error", err)
return
}
defer rows.Close()
@@ -100,13 +122,43 @@ func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID
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)
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,
@@ -127,12 +179,9 @@ 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,
@@ -144,7 +193,6 @@ 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`,
+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 { findCommand, SLASH_COMMANDS } from "../lib/slashCommands";
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 { MessageSearch } from "./MessageSearch";
import { ThreadListPanel } from "./ThreadListPanel.tsx";
@@ -45,7 +46,7 @@ function formatTime(iso: string): 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;
let last = 0;
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) });
}
const username = match[1];
if (memberUsernames.has(username)) {
segments.push({ type: "mention", value: username });
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",
});
} else {
segments.push({ type: "text", value: match[0] });
}
@@ -71,7 +77,10 @@ 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="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 ? " " : ""}
</span>
);
@@ -329,6 +338,7 @@ export function ChatArea() {
// Humans only for mentions / nickname lookup (bots live in member list separately).
const humanMembers = useMemo(() => members.filter((m) => !m.is_bot), [members]);
const memberUsernames = useMemo(() => new Set(humanMembers.map((m) => m.username)), [humanMembers]);
const { canMentionEveryone } = usePermissions(activeServerId);
const markRead = useReadStatesStore((s) => s.markRead);
const readStates = useReadStatesStore((s) => s.states);
@@ -555,10 +565,7 @@ export function ChatArea() {
if (!isDropdownOpen) return;
const itemCount = mq !== null
? humanMembers.filter((m) =>
m.username.toLowerCase().includes(mq.toLowerCase()) ||
m.display_name?.toLowerCase().includes(mq.toLowerCase())
).slice(0, 6).length
? buildMentionOptions(mq, humanMembers, canMentionEveryone).length
: cq !== null
? SLASH_COMMANDS.filter((c) => c.name.startsWith(cq.toLowerCase())).slice(0, 8).length
: 0;
@@ -575,13 +582,14 @@ export function ChatArea() {
e.preventDefault();
e.stopPropagation();
if (mq !== null) {
const q = mq.toLowerCase();
const filtered = humanMembers.filter((m) =>
m.username.toLowerCase().includes(q) ||
m.display_name?.toLowerCase().includes(q)
).slice(0, 6);
if (filtered[di]) {
handleMentionSelect(filtered[di].username);
const options = buildMentionOptions(mq, humanMembers, canMentionEveryone);
const selected = options[di];
if (selected) {
if (selected.kind === "special") {
handleMentionSelect(selected.label);
} else {
handleMentionSelect(selected.member.username);
}
}
} else if (cq !== null) {
const q = cq.toLowerCase();
@@ -620,7 +628,7 @@ export function ChatArea() {
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [humanMembers, currentUser, activeChannelId, sendMessage, replyToMessage, handleMentionSelect]);
}, [humanMembers, canMentionEveryone, currentUser, activeChannelId, sendMessage, replyToMessage, handleMentionSelect]);
const handleSubmit = useCallback(async () => {
if (mentionQuery !== null || commandQuery !== null) return;
@@ -904,7 +912,13 @@ export function ChatArea() {
)}
<div className="p-3 relative">
{mentionQuery !== null && (
<MentionDropdown query={mentionQuery} members={humanMembers} selectedIndex={dropdownIndex} onSelect={handleMentionSelect} />
<MentionDropdown
query={mentionQuery}
members={humanMembers}
selectedIndex={dropdownIndex}
onSelect={handleMentionSelect}
canMentionEveryone={canMentionEveryone}
/>
)}
{commandQuery !== null && (
<CommandDropdown
+10
View File
@@ -6,6 +6,7 @@ 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';
@@ -43,9 +44,18 @@ 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');
+6 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
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';
interface MemberRoleAssignProps {
@@ -12,8 +12,10 @@ 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;
@@ -56,6 +58,9 @@ 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
+88 -28
View File
@@ -1,52 +1,112 @@
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;
}
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 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(
(m) =>
m.username.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 (
<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">
MENTION
</div>
{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>
))}
{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>
);
})}
</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 { useAuthStore } from '../stores/auth.ts';
import { useRoleStore } from '../stores/role.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;
import { PERMS, hasPermission } from '../stores/permissions.ts';
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 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(
(flag: number) => {
if (!serverId || !user) return false;
if (isOwner) return true;
const effective = memberRoles.reduce((acc, r) => acc | r.permissions, 0);
if ((effective & PERMS.ADMINISTRATOR) !== 0) return true;
return (effective & flag) === flag;
if (hasPermission(effective, PERMS.ADMINISTRATOR)) return true;
return hasPermission(effective, flag);
},
[serverId, user, isOwner, memberRoles],
[serverId, user, isOwner, effective],
);
return {
@@ -53,6 +52,8 @@ 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,
};
}
+2 -1
View File
@@ -55,7 +55,8 @@ export const PERMISSION_LABELS: Record<PermissionKey, string> = {
};
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 {
+57 -9
View File
@@ -27,10 +27,13 @@ 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>;
@@ -40,6 +43,7 @@ interface RoleState {
export const useRoleStore = create<RoleState>((set) => ({
roles: [],
myRolesByServer: {},
loading: false,
error: null,
@@ -47,7 +51,14 @@ export const useRoleStore = create<RoleState>((set) => ({
set({ loading: true, error: null });
try {
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) {
set({
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) => {
set({ loading: true, error: null });
try {
@@ -78,10 +108,19 @@ 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) => ({
roles: state.roles.map((r) => (r.id === roleId ? role : r)).sort((a, b) => b.position - a.position),
loading: false,
}));
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,
};
});
return role;
} catch (error) {
set({
@@ -96,10 +135,19 @@ export const useRoleStore = create<RoleState>((set) => ({
set({ loading: true, error: null });
try {
await api.delete(`/servers/${serverId}/roles/${roleId}`);
set((state) => ({
roles: state.roles.filter((r) => r.id !== roleId),
loading: false,
}));
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,
};
});
} catch (error) {
set({
loading: false,