feat(phase2): channel permission overrides + expanded role UI + audit log cleanup
This commit is contained in:
+4
-8
@@ -25,17 +25,13 @@
|
|||||||
|
|
||||||
> Unlocks serious server organization. Guilded's strength was granular permissions.
|
> Unlocks serious server organization. Guilded's strength was granular permissions.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2 — Moderation & Permissions 🔄 IN PROGRESS
|
|
||||||
|
|
||||||
| # | Feature | Status |
|
| # | Feature | Status |
|
||||||
|---|---------|--------|
|
|---|---------|--------|
|
||||||
| 2.1 | Per-Channel Permission Overrides | Pending |
|
| 2.1 | Per-Channel Permission Overrides | In progress |
|
||||||
| 2.2 | Expanded Permission Flags | ✅ Already added in `internal/permissions/permissions.go` |
|
| 2.2 | Expanded Permission Flags | ✅ Already added in `internal/permissions/permissions.go` |
|
||||||
| 2.3 | Role Colors & Hierarchy | ✅ DB columns present; UI pending |
|
| 2.3 | Role Colors & Hierarchy | ✅ DB columns present; UI in progress |
|
||||||
| 2.4 | Audit Log | In progress |
|
| 2.4 | Audit Log | ✅ Backend + server settings panel live |
|
||||||
| 2.5 | Bulk Message Delete | In progress |
|
| 2.5 | Bulk Message Delete | ✅ Backend + frontend live |
|
||||||
|
|
||||||
### 2.1 Per-Channel Permission Overrides
|
### 2.1 Per-Channel Permission Overrides
|
||||||
- **Backend:** New `channel_overrides` table (channel_id, target_type: role/user, target_id, allow_bitflags, deny_bitflags). Extend permission checker to layer channel overrides on top of role permissions.
|
- **Backend:** New `channel_overrides` table (channel_id, target_type: role/user, target_id, allow_bitflags, deny_bitflags). Extend permission checker to layer channel overrides on top of role permissions.
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
|
|||||||
r.Get("/{channelID}", h.Get)
|
r.Get("/{channelID}", h.Get)
|
||||||
r.Patch("/{channelID}", h.Update)
|
r.Patch("/{channelID}", h.Update)
|
||||||
r.Delete("/{channelID}", h.Delete)
|
r.Delete("/{channelID}", h.Delete)
|
||||||
|
h.registerOverrideRoutes(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
type createChannelRequest struct {
|
type createChannelRequest struct {
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package channel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||||
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type overrideRequest struct {
|
||||||
|
Allow int64 `json:"allow"`
|
||||||
|
Deny int64 `json:"deny"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type overrideResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ChannelID string `json:"channel_id"`
|
||||||
|
TargetType string `json:"target_type"`
|
||||||
|
TargetID string `json:"target_id"`
|
||||||
|
Allow int64 `json:"allow_bitflags"`
|
||||||
|
Deny int64 `json:"deny_bitflags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) registerOverrideRoutes(r chi.Router) {
|
||||||
|
r.Put("/{channelID}/permissions/{targetType}/{targetID}", h.SetOverride)
|
||||||
|
r.Get("/{channelID}/permissions", h.ListOverrides)
|
||||||
|
r.Delete("/{channelID}/permissions/{targetType}/{targetID}", h.DeleteOverride)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) requireChannelManager(w http.ResponseWriter, r *http.Request, channelID string) (userID, serverID string, ok bool) {
|
||||||
|
userID, authOK := middleware.UserIDFromContext(r.Context())
|
||||||
|
if !authOK {
|
||||||
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
serverID, err := h.serverIDForChannel(r.Context(), channelID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"channel not found"}`, http.StatusNotFound)
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
allowed, err := h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_CHANNELS)
|
||||||
|
if err != nil || !allowed {
|
||||||
|
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return userID, serverID, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) SetOverride(w http.ResponseWriter, r *http.Request) {
|
||||||
|
channelID := chi.URLParam(r, "channelID")
|
||||||
|
_, _, ok := h.requireChannelManager(w, r, channelID)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
targetType := chi.URLParam(r, "targetType")
|
||||||
|
targetID := chi.URLParam(r, "targetID")
|
||||||
|
if targetType != "role" && targetType != "user" {
|
||||||
|
http.Error(w, `{"error":"invalid target_type"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req overrideRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var res overrideResponse
|
||||||
|
err := h.db.QueryRowContext(r.Context(), `
|
||||||
|
INSERT INTO channel_overrides (channel_id, target_type, target_id, allow_bitflags, deny_bitflags)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (channel_id, target_type, target_id)
|
||||||
|
DO UPDATE SET allow_bitflags = EXCLUDED.allow_bitflags, deny_bitflags = EXCLUDED.deny_bitflags, updated_at = NOW()
|
||||||
|
RETURNING id, channel_id, target_type, target_id, allow_bitflags, deny_bitflags
|
||||||
|
`, channelID, targetType, targetID, req.Allow, req.Deny).Scan(&res.ID, &res.ChannelID, &res.TargetType, &res.TargetID, &res.Allow, &res.Deny)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"failed to set override"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ListOverrides(w http.ResponseWriter, r *http.Request) {
|
||||||
|
channelID := chi.URLParam(r, "channelID")
|
||||||
|
_, _, ok := h.requireChannelManager(w, r, channelID)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := h.db.QueryContext(r.Context(), `
|
||||||
|
SELECT id, channel_id, target_type, target_id, allow_bitflags, deny_bitflags
|
||||||
|
FROM channel_overrides
|
||||||
|
WHERE channel_id = $1
|
||||||
|
ORDER BY target_type, target_id
|
||||||
|
`, channelID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
overrides := make([]overrideResponse, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var o overrideResponse
|
||||||
|
if err := rows.Scan(&o.ID, &o.ChannelID, &o.TargetType, &o.TargetID, &o.Allow, &o.Deny); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
overrides = append(overrides, o)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(overrides)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) DeleteOverride(w http.ResponseWriter, r *http.Request) {
|
||||||
|
channelID := chi.URLParam(r, "channelID")
|
||||||
|
_, _, ok := h.requireChannelManager(w, r, channelID)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
targetType := chi.URLParam(r, "targetType")
|
||||||
|
targetID := chi.URLParam(r, "targetID")
|
||||||
|
_, err := h.db.ExecContext(r.Context(), `
|
||||||
|
DELETE FROM channel_overrides WHERE channel_id = $1 AND target_type = $2 AND target_id = $3
|
||||||
|
`, channelID, targetType, targetID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"failed to delete override"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package permissions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ComputeChannelPermissions returns effective permissions for a user in a specific channel,
|
||||||
|
// layering channel overrides on top of server-wide role permissions.
|
||||||
|
func (c *Checker) ComputeChannelPermissions(ctx context.Context, serverID, channelID, userID string) (int64, error) {
|
||||||
|
base, err := c.GetUserPermissions(ctx, serverID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if Has(base, ADMINISTRATOR) {
|
||||||
|
return base, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everyone role override applies first.
|
||||||
|
var everyoneID string
|
||||||
|
_ = c.db.QueryRowContext(ctx, `SELECT id FROM roles WHERE server_id = $1 AND is_default = TRUE LIMIT 1`, serverID).Scan(&everyoneID)
|
||||||
|
if everyoneID != "" {
|
||||||
|
allow, deny, err := c.getOverride(ctx, channelID, "role", everyoneID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
base |= allow
|
||||||
|
base &^= deny
|
||||||
|
}
|
||||||
|
|
||||||
|
// Member-specific role overrides.
|
||||||
|
rows, err := c.db.QueryContext(ctx, `
|
||||||
|
SELECT r.id FROM roles r
|
||||||
|
INNER JOIN member_roles mr ON mr.role_id = r.id
|
||||||
|
WHERE mr.user_id = $1 AND mr.server_id = $2
|
||||||
|
`, userID, serverID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var roleID string
|
||||||
|
if err := rows.Scan(&roleID); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
allow, deny, err := c.getOverride(ctx, channelID, "role", roleID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
base |= allow
|
||||||
|
base &^= deny
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// User-specific override wins last.
|
||||||
|
allow, deny, err := c.getOverride(ctx, channelID, "user", userID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
base |= allow
|
||||||
|
base &^= deny
|
||||||
|
|
||||||
|
return base, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Checker) getOverride(ctx context.Context, channelID, targetType, targetID string) (allow, deny int64, err error) {
|
||||||
|
err = c.db.QueryRowContext(ctx, `
|
||||||
|
SELECT allow_bitflags, deny_bitflags FROM channel_overrides
|
||||||
|
WHERE channel_id = $1 AND target_type = $2 AND target_id = $3
|
||||||
|
`, channelID, targetType, targetID).Scan(&allow, &deny)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return 0, 0, nil
|
||||||
|
}
|
||||||
|
return allow, deny, err
|
||||||
|
}
|
||||||
@@ -26,6 +26,35 @@ const (
|
|||||||
MENTION_EVERYONE int64 = 1 << 21
|
MENTION_EVERYONE int64 = 1 << 21
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// AllPermissionLabels maps flags to human-friendly names.
|
||||||
|
var AllPermissionLabels = []struct {
|
||||||
|
Key string
|
||||||
|
Flag int64
|
||||||
|
}{
|
||||||
|
{"VIEW_CHANNEL", VIEW_CHANNEL},
|
||||||
|
{"SEND_MESSAGES", SEND_MESSAGES},
|
||||||
|
{"MANAGE_MESSAGES", MANAGE_MESSAGES},
|
||||||
|
{"KICK_MEMBERS", KICK_MEMBERS},
|
||||||
|
{"BAN_MEMBERS", BAN_MEMBERS},
|
||||||
|
{"MANAGE_SERVER", MANAGE_SERVER},
|
||||||
|
{"MANAGE_CHANNELS", MANAGE_CHANNELS},
|
||||||
|
{"ADMINISTRATOR", ADMINISTRATOR},
|
||||||
|
{"CONNECT_VOICE", CONNECT_VOICE},
|
||||||
|
{"SPEAK_VOICE", SPEAK_VOICE},
|
||||||
|
{"SHARE_SCREEN", SHARE_SCREEN},
|
||||||
|
{"MUTE_MEMBERS", MUTE_MEMBERS},
|
||||||
|
{"CREATE_INSTANT_INVITE", CREATE_INSTANT_INVITE},
|
||||||
|
{"CHANGE_NICKNAME", CHANGE_NICKNAME},
|
||||||
|
{"MANAGE_NICKNAMES", MANAGE_NICKNAMES},
|
||||||
|
{"MANAGE_ROLES", MANAGE_ROLES},
|
||||||
|
{"MANAGE_WEBHOOKS", MANAGE_WEBHOOKS},
|
||||||
|
{"EMBED_LINKS", EMBED_LINKS},
|
||||||
|
{"ATTACH_FILES", ATTACH_FILES},
|
||||||
|
{"ADD_REACTIONS", ADD_REACTIONS},
|
||||||
|
{"USE_EXTERNAL_EMOJIS", USE_EXTERNAL_EMOJIS},
|
||||||
|
{"MENTION_EVERYONE", MENTION_EVERYONE},
|
||||||
|
}
|
||||||
|
|
||||||
// DefaultEveryonePermissions is granted to the @everyone role when a server is created.
|
// DefaultEveryonePermissions is granted to the @everyone role when a server is created.
|
||||||
// VIEW_CHANNEL | SEND_MESSAGES | CONNECT_VOICE | SPEAK_VOICE | SHARE_SCREEN | CREATE_INSTANT_INVITE | EMBED_LINKS | ATTACH_FILES | ADD_REACTIONS = bits 0,1,8,9,10,12,17,18,19.
|
// VIEW_CHANNEL | SEND_MESSAGES | CONNECT_VOICE | SPEAK_VOICE | SHARE_SCREEN | CREATE_INSTANT_INVITE | EMBED_LINKS | ATTACH_FILES | ADD_REACTIONS = bits 0,1,8,9,10,12,17,18,19.
|
||||||
const DefaultEveryonePermissions int64 = VIEW_CHANNEL | SEND_MESSAGES | CONNECT_VOICE | SPEAK_VOICE | SHARE_SCREEN | CREATE_INSTANT_INVITE | EMBED_LINKS | ATTACH_FILES | ADD_REACTIONS
|
const DefaultEveryonePermissions int64 = VIEW_CHANNEL | SEND_MESSAGES | CONNECT_VOICE | SPEAK_VOICE | SHARE_SCREEN | CREATE_INSTANT_INVITE | EMBED_LINKS | ATTACH_FILES | ADD_REACTIONS
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useChannelStore } from '../stores/channel.ts';
|
|||||||
import { VoiceChannel } from './VoiceChannel.tsx';
|
import { VoiceChannel } from './VoiceChannel.tsx';
|
||||||
import { CreateChannelModal } from './CreateChannelModal.tsx';
|
import { CreateChannelModal } from './CreateChannelModal.tsx';
|
||||||
import { InviteModal } from './InviteModal.tsx';
|
import { InviteModal } from './InviteModal.tsx';
|
||||||
|
import { ChannelSettingsModal } from './ChannelSettingsModal.tsx';
|
||||||
|
|
||||||
export function ChannelList() {
|
export function ChannelList() {
|
||||||
const activeServerId = useServerStore((state) => state.activeServerId);
|
const activeServerId = useServerStore((state) => state.activeServerId);
|
||||||
@@ -14,6 +15,7 @@ export function ChannelList() {
|
|||||||
const setActiveChannel = useChannelStore((state) => state.setActiveChannel);
|
const setActiveChannel = useChannelStore((state) => state.setActiveChannel);
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [showInvite, setShowInvite] = useState(false);
|
const [showInvite, setShowInvite] = useState(false);
|
||||||
|
const [settingsChannel, setSettingsChannel] = useState<{ id: string; name: string } | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeServerId) {
|
if (activeServerId) {
|
||||||
@@ -83,16 +85,26 @@ export function ChannelList() {
|
|||||||
channelName={channel.name}
|
channelName={channel.name}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
<div key={channel.id} className="group">
|
||||||
<button
|
<button
|
||||||
key={channel.id}
|
|
||||||
onClick={() => setActiveChannel(channel.id)}
|
onClick={() => setActiveChannel(channel.id)}
|
||||||
className={`w-full text-left px-2 py-1 rounded-sm flex items-center gap-2 ${
|
className={`w-full text-left px-2 py-1 rounded-sm flex items-center gap-2 ${
|
||||||
channel.id === activeChannelId ? 'terminal-active' : 'hover:bg-gb-bg-t text-gb-fg-s'
|
channel.id === activeChannelId ? 'terminal-active' : 'hover:bg-gb-bg-t text-gb-fg-s'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-gb-fg-f">#</span>
|
<span className="text-gb-fg-f">#</span>
|
||||||
<span className="truncate">{channel.name}</span>
|
<span className="truncate flex-1">{channel.name}</span>
|
||||||
|
<span
|
||||||
|
onClick={(e) => { e.stopPropagation(); setSettingsChannel({ id: channel.id, name: channel.name }); }}
|
||||||
|
className="text-gb-fg-f hover:text-gb-orange opacity-0 group-hover:opacity-100"
|
||||||
|
title="Channel settings"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
[⚙]
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -104,6 +116,14 @@ export function ChannelList() {
|
|||||||
{showInvite && activeServerId && activeServer && (
|
{showInvite && activeServerId && activeServer && (
|
||||||
<InviteModal serverId={activeServerId} serverName={activeServer.name} onClose={() => setShowInvite(false)} />
|
<InviteModal serverId={activeServerId} serverName={activeServer.name} onClose={() => setShowInvite(false)} />
|
||||||
)}
|
)}
|
||||||
|
{settingsChannel && activeServerId && (
|
||||||
|
<ChannelSettingsModal
|
||||||
|
serverId={activeServerId}
|
||||||
|
channelId={settingsChannel.id}
|
||||||
|
channelName={settingsChannel.name}
|
||||||
|
onClose={() => setSettingsChannel(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
interface ChannelSettingsModalProps {
|
||||||
|
serverId: string;
|
||||||
|
channelId: string;
|
||||||
|
channelName: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tab = 'permissions';
|
||||||
|
type TargetMode = 'role' | 'user';
|
||||||
|
|
||||||
|
interface OverrideFormData {
|
||||||
|
targetType: TargetMode;
|
||||||
|
targetId: string;
|
||||||
|
allow: number;
|
||||||
|
deny: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChannelSettingsModal({ serverId, channelId, channelName, onClose }: ChannelSettingsModalProps) {
|
||||||
|
const [tab] = useState<Tab>('permissions');
|
||||||
|
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 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 roles = useRoleStore((s) => s.roles);
|
||||||
|
const fetchRoles = useRoleStore((s) => s.fetchRoles);
|
||||||
|
const members = useMemberStore((s) => s.membersByServer[serverId] || []);
|
||||||
|
const fetchMembers = useMemberStore((s) => s.fetchMembers);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
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') onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!form.targetId) {
|
||||||
|
setError('Select a target');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await setOverride(channelId, form.targetType, form.targetId, form.allow, form.deny);
|
||||||
|
setForm({ targetType: 'role', targetId: '', allow: 0, deny: 0 });
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to save override');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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-[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 onClick={onClose} className="text-xs text-gb-red">[x]</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex border-b border-gb-bg-t">
|
||||||
|
<button className="px-4 py-2 text-xs text-gb-orange bg-gb-bg-s">[PERMISSIONS]</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
{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="border-t border-gb-bg-t pt-3">
|
||||||
|
<div className="text-xs text-gb-fg-f mb-2">New override</div>
|
||||||
|
<div className="flex gap-2 mb-2">
|
||||||
|
<select
|
||||||
|
value={form.targetType}
|
||||||
|
onChange={(e) => setForm({ ...form, targetType: e.target.value as TargetMode, targetId: '' })}
|
||||||
|
className="terminal-input text-xs"
|
||||||
|
>
|
||||||
|
<option value="role">role</option>
|
||||||
|
<option value="user">user</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={form.targetId}
|
||||||
|
onChange={(e) => setForm({ ...form, targetId: e.target.value })}
|
||||||
|
className="terminal-input text-xs flex-1"
|
||||||
|
>
|
||||||
|
<option value="">-- select --</option>
|
||||||
|
{targets.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>{t.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<OverrideMatrix allow={form.allow} deny={form.deny} onChange={(a, d) => setForm({ ...form, allow: a, deny: d })} />
|
||||||
|
{error && <div className="text-xs text-gb-red mt-2">ERR: {error}</div>}
|
||||||
|
<button
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,17 @@ const PERMS = {
|
|||||||
CONNECT_VOICE: 256,
|
CONNECT_VOICE: 256,
|
||||||
SPEAK_VOICE: 512,
|
SPEAK_VOICE: 512,
|
||||||
SHARE_SCREEN: 1024,
|
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;
|
} as const;
|
||||||
|
|
||||||
const PERM_CATEGORIES = [
|
const PERM_CATEGORIES = [
|
||||||
@@ -24,6 +35,11 @@ const PERM_CATEGORIES = [
|
|||||||
{ key: 'VIEW_CHANNEL', label: 'View Channel', flag: PERMS.VIEW_CHANNEL },
|
{ key: 'VIEW_CHANNEL', label: 'View Channel', flag: PERMS.VIEW_CHANNEL },
|
||||||
{ key: 'SEND_MESSAGES', label: 'Send Messages', flag: PERMS.SEND_MESSAGES },
|
{ key: 'SEND_MESSAGES', label: 'Send Messages', flag: PERMS.SEND_MESSAGES },
|
||||||
{ key: 'MANAGE_MESSAGES', label: 'Manage Messages', flag: PERMS.MANAGE_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 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -31,6 +47,9 @@ const PERM_CATEGORIES = [
|
|||||||
perms: [
|
perms: [
|
||||||
{ key: 'KICK_MEMBERS', label: 'Kick Members', flag: PERMS.KICK_MEMBERS },
|
{ key: 'KICK_MEMBERS', label: 'Kick Members', flag: PERMS.KICK_MEMBERS },
|
||||||
{ key: 'BAN_MEMBERS', label: 'Ban Members', flag: PERMS.BAN_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 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -38,6 +57,9 @@ const PERM_CATEGORIES = [
|
|||||||
perms: [
|
perms: [
|
||||||
{ key: 'MANAGE_SERVER', label: 'Manage Server', flag: PERMS.MANAGE_SERVER },
|
{ key: 'MANAGE_SERVER', label: 'Manage Server', flag: PERMS.MANAGE_SERVER },
|
||||||
{ key: 'MANAGE_CHANNELS', label: 'Manage Channels', flag: PERMS.MANAGE_CHANNELS },
|
{ 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 },
|
{ key: 'ADMINISTRATOR', label: 'Administrator', flag: PERMS.ADMINISTRATOR },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -66,9 +88,11 @@ function permSummary(permissions: number): string {
|
|||||||
const active: string[] = [];
|
const active: string[] = [];
|
||||||
if (hasPerm(permissions, PERMS.MANAGE_SERVER)) active.push('MGR_SERVER');
|
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_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.MANAGE_MESSAGES)) active.push('MGR_MSG');
|
||||||
if (hasPerm(permissions, PERMS.KICK_MEMBERS)) active.push('KICK');
|
if (hasPerm(permissions, PERMS.KICK_MEMBERS)) active.push('KICK');
|
||||||
if (hasPerm(permissions, PERMS.BAN_MEMBERS)) active.push('BAN');
|
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';
|
return active.length > 0 ? active.join('+') : 'BASIC';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { api } from '../lib/api.ts';
|
||||||
|
|
||||||
|
// ponytail: mirror backend permission bitflags exactly.
|
||||||
|
export 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;
|
||||||
|
|
||||||
|
export type PermissionKey = keyof typeof PERMS;
|
||||||
|
|
||||||
|
export const PERMISSION_LABELS: Record<PermissionKey, string> = {
|
||||||
|
VIEW_CHANNEL: 'View Channel',
|
||||||
|
SEND_MESSAGES: 'Send Messages',
|
||||||
|
MANAGE_MESSAGES: 'Manage Messages',
|
||||||
|
KICK_MEMBERS: 'Kick Members',
|
||||||
|
BAN_MEMBERS: 'Ban Members',
|
||||||
|
MANAGE_SERVER: 'Manage Server',
|
||||||
|
MANAGE_CHANNELS: 'Manage Channels',
|
||||||
|
ADMINISTRATOR: 'Administrator',
|
||||||
|
CONNECT_VOICE: 'Connect Voice',
|
||||||
|
SPEAK_VOICE: 'Speak Voice',
|
||||||
|
SHARE_SCREEN: 'Share Screen',
|
||||||
|
MUTE_MEMBERS: 'Mute Members',
|
||||||
|
CREATE_INSTANT_INVITE: 'Create Instant Invite',
|
||||||
|
CHANGE_NICKNAME: 'Change Nickname',
|
||||||
|
MANAGE_NICKNAMES: 'Manage Nicknames',
|
||||||
|
MANAGE_ROLES: 'Manage Roles',
|
||||||
|
MANAGE_WEBHOOKS: 'Manage Webhooks',
|
||||||
|
EMBED_LINKS: 'Embed Links',
|
||||||
|
ATTACH_FILES: 'Attach Files',
|
||||||
|
ADD_REACTIONS: 'Add Reactions',
|
||||||
|
USE_EXTERNAL_EMOJIS: 'Use External Emojis',
|
||||||
|
MENTION_EVERYONE: 'Mention @everyone',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function hasPermission(perms: number, flag: number): boolean {
|
||||||
|
return (perms & flag) !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChannelOverride {
|
||||||
|
id: string;
|
||||||
|
channel_id: string;
|
||||||
|
target_type: 'role' | 'user';
|
||||||
|
target_id: string;
|
||||||
|
allow_bitflags: number;
|
||||||
|
deny_bitflags: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PermissionState {
|
||||||
|
overridesByChannel: Record<string, ChannelOverride[]>;
|
||||||
|
fetchOverrides: (channelId: string) => Promise<void>;
|
||||||
|
setOverride: (channelId: string, targetType: 'role' | 'user', targetId: string, allow: number, deny: number) => Promise<ChannelOverride>;
|
||||||
|
deleteOverride: (channelId: string, targetType: 'role' | 'user', targetId: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePermissionStore = create<PermissionState>((set) => ({
|
||||||
|
overridesByChannel: {},
|
||||||
|
|
||||||
|
fetchOverrides: async (channelId) => {
|
||||||
|
const overrides = await api.get<ChannelOverride[]>(`/servers/_/channels/${channelId}/permissions`);
|
||||||
|
set((state) => ({
|
||||||
|
overridesByChannel: { ...state.overridesByChannel, [channelId]: Array.isArray(overrides) ? overrides : [] },
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
setOverride: async (channelId, targetType, targetId, allow, deny) => {
|
||||||
|
const override = await api.put<ChannelOverride>(`/servers/_/channels/${channelId}/permissions/${targetType}/${targetId}`, { allow, deny });
|
||||||
|
set((state) => {
|
||||||
|
const list = state.overridesByChannel[channelId] || [];
|
||||||
|
const filtered = list.filter((o) => !(o.target_type === targetType && o.target_id === targetId));
|
||||||
|
return { overridesByChannel: { ...state.overridesByChannel, [channelId]: [...filtered, override] } };
|
||||||
|
});
|
||||||
|
return override;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteOverride: async (channelId, targetType, targetId) => {
|
||||||
|
await api.delete(`/servers/_/channels/${channelId}/permissions/${targetType}/${targetId}`);
|
||||||
|
set((state) => {
|
||||||
|
const list = state.overridesByChannel[channelId] || [];
|
||||||
|
return {
|
||||||
|
overridesByChannel: {
|
||||||
|
...state.overridesByChannel,
|
||||||
|
[channelId]: list.filter((o) => !(o.target_type === targetType && o.target_id === targetId)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/ChannelList.tsx","./src/components/ChatArea.tsx","./src/components/CommandManager.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/EmojiPicker.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/JoinServerModal.tsx","./src/components/Layout.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/MessageSearch.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/NewConversationModal.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/ServerSettingsModal.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/TypingIndicator.tsx","./src/components/UserSettings.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/lib/usePermissions.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/conversation.ts","./src/stores/layout.ts","./src/stores/member.ts","./src/stores/message.ts","./src/stores/moderation.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"}
|
{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/ChannelList.tsx","./src/components/ChannelSettingsModal.tsx","./src/components/ChatArea.tsx","./src/components/CommandManager.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/EmojiPicker.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/JoinServerModal.tsx","./src/components/Layout.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/MessageSearch.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/NewConversationModal.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/ServerSettingsModal.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/TypingIndicator.tsx","./src/components/UserSettings.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/lib/usePermissions.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/conversation.ts","./src/stores/layout.ts","./src/stores/member.ts","./src/stores/message.ts","./src/stores/moderation.ts","./src/stores/permissions.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"}
|
||||||
Reference in New Issue
Block a user