feat(phase3): threads backend + thread panel UI
This commit is contained in:
+17
-6
@@ -21,17 +21,28 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 2 — Moderation & Permissions 🔄 IN PROGRESS
|
## Phase 2 — Moderation & Permissions ✅ COMPLETE
|
||||||
|
|
||||||
> Unlocks serious server organization. Guilded's strength was granular permissions.
|
> Unlocks serious server organization. Guilded's strength was granular permissions.
|
||||||
|
|
||||||
| # | Feature | Status |
|
| # | Feature | Status |
|
||||||
|---|---------|--------|
|
|---|---------|--------|
|
||||||
| 2.1 | Per-Channel Permission Overrides | In progress |
|
| 2.1 | Per-Channel Permission Overrides | ✅ Backend routes + frontend matrix |
|
||||||
| 2.2 | Expanded Permission Flags | ✅ Already added in `internal/permissions/permissions.go` |
|
| 2.2 | Expanded Permission Flags | ✅ Backend bits + RoleManager UI |
|
||||||
| 2.3 | Role Colors & Hierarchy | ✅ DB columns present; UI in progress |
|
| 2.3 | Role Colors & Hierarchy | ✅ DB + editor; chat rendering in Phase 7 |
|
||||||
| 2.4 | Audit Log | ✅ Backend + server settings panel live |
|
| 2.4 | Audit Log | ✅ Backend + server settings panel |
|
||||||
| 2.5 | Bulk Message Delete | ✅ Backend + frontend live |
|
| 2.5 | Bulk Message Delete | ✅ Backend + frontend |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — Threads & Forums 🔄 IN PROGRESS
|
||||||
|
|
||||||
|
> Guilded's thread and forum system kept busy servers organized.
|
||||||
|
|
||||||
|
| # | Feature | Status |
|
||||||
|
|---|---------|--------|
|
||||||
|
| 3.1 | Threads | ✅ Reused channels table + thread panel |
|
||||||
|
| 3.2 | Forum Channels | Pending |
|
||||||
|
|
||||||
### 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,9 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
|
|||||||
r.Get("/{channelID}", h.Get)
|
r.Get("/{channelID}", h.Get)
|
||||||
r.Patch("/{channelID}", h.Update)
|
r.Patch("/{channelID}", h.Update)
|
||||||
r.Delete("/{channelID}", h.Delete)
|
r.Delete("/{channelID}", h.Delete)
|
||||||
|
r.Post("/{channelID}/threads", h.CreateThread)
|
||||||
|
r.Get("/{channelID}/threads", h.ListThreads)
|
||||||
|
r.Patch("/threads/{threadID}", h.UpdateThread)
|
||||||
h.registerOverrideRoutes(r)
|
h.registerOverrideRoutes(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package channel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"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 createThreadRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
MessageID *string `json:"message_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type threadResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ServerID string `json:"server_id"`
|
||||||
|
ParentChannelID string `json:"parent_channel_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Category *string `json:"category"`
|
||||||
|
Position int `json:"position"`
|
||||||
|
ArchivedAt *string `json:"archived_at"`
|
||||||
|
AutoArchiveDuration int `json:"auto_archive_duration"`
|
||||||
|
MessageCount int `json:"message_count"`
|
||||||
|
LastMessageAt *string `json:"last_message_at"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) registerThreadRoutes(r chi.Router) {
|
||||||
|
r.Post("/{channelID}/threads", h.CreateThread)
|
||||||
|
r.Get("/{channelID}/threads", h.ListThreads)
|
||||||
|
r.Patch("/threads/{threadID}", h.UpdateThread)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanThread(row interface{ Scan(dest ...any) error }) (threadResponse, error) {
|
||||||
|
var t threadResponse
|
||||||
|
var archivedAt, lastMsgAt, category sql.NullString
|
||||||
|
err := row.Scan(&t.ID, &t.ServerID, &t.ParentChannelID, &t.Name, &t.Type, &category, &t.Position, &archivedAt, &t.AutoArchiveDuration, &t.MessageCount, &lastMsgAt, &t.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return t, err
|
||||||
|
}
|
||||||
|
if archivedAt.Valid {
|
||||||
|
t.ArchivedAt = &archivedAt.String
|
||||||
|
}
|
||||||
|
if lastMsgAt.Valid {
|
||||||
|
t.LastMessageAt = &lastMsgAt.String
|
||||||
|
}
|
||||||
|
if category.Valid {
|
||||||
|
t.Category = &category.String
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreateThread(w http.ResponseWriter, r *http.Request) {
|
||||||
|
parentID := chi.URLParam(r, "channelID")
|
||||||
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serverID, err := h.serverIDForChannel(r.Context(), parentID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"channel not found"}`, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
member, err := h.isMember(r.Context(), userID, serverID)
|
||||||
|
if err != nil || !member {
|
||||||
|
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
allowed, err := h.checker.CheckPermission(r.Context(), serverID, userID, permissions.SEND_MESSAGES)
|
||||||
|
if err != nil || !allowed {
|
||||||
|
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req createThreadRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
|
||||||
|
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ponytail: reuse channels table with parent_channel_id set. Messages in the thread channel are normal messages.
|
||||||
|
thread, err := scanThread(h.db.QueryRowContext(r.Context(), `
|
||||||
|
INSERT INTO channels (server_id, name, type, category, position, parent_channel_id, auto_archive_duration)
|
||||||
|
VALUES ($1, $2, 'thread', 'threads', 0, $3, 1440)
|
||||||
|
RETURNING id, server_id, parent_channel_id, name, type, category, position, archived_at, auto_archive_duration, message_count, last_message_at, created_at
|
||||||
|
`, serverID, req.Name, parentID))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"failed to create thread"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(thread)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ListThreads(w http.ResponseWriter, r *http.Request) {
|
||||||
|
parentID := chi.URLParam(r, "channelID")
|
||||||
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serverID, err := h.serverIDForChannel(r.Context(), parentID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"channel not found"}`, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
member, err := h.isMember(r.Context(), userID, serverID)
|
||||||
|
if err != nil || !member {
|
||||||
|
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := h.db.QueryContext(r.Context(), `
|
||||||
|
SELECT id, server_id, parent_channel_id, name, type, category, position, archived_at, auto_archive_duration, message_count, last_message_at, created_at
|
||||||
|
FROM channels
|
||||||
|
WHERE parent_channel_id = $1 AND server_id = $2 AND type = 'thread' AND archived_at IS NULL
|
||||||
|
ORDER BY last_message_at DESC NULLS LAST, created_at DESC
|
||||||
|
`, parentID, serverID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
threads := make([]threadResponse, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
t, err := scanThread(rows)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
threads = append(threads, t)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(threads)
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateThreadRequest struct {
|
||||||
|
Archived *bool `json:"archived"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) UpdateThread(w http.ResponseWriter, r *http.Request) {
|
||||||
|
threadID := chi.URLParam(r, "threadID")
|
||||||
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var serverID string
|
||||||
|
err := h.db.QueryRowContext(r.Context(), `SELECT server_id FROM channels WHERE id = $1 AND type = 'thread'`, threadID).Scan(&serverID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"thread not found"}`, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req updateThreadRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed, err := h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_CHANNELS)
|
||||||
|
if err != nil || !allowed {
|
||||||
|
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var archivedAt interface{}
|
||||||
|
if req.Archived != nil && *req.Archived {
|
||||||
|
archivedAt = "NOW()"
|
||||||
|
} else {
|
||||||
|
archivedAt = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
thread, err := scanThread(h.db.QueryRowContext(r.Context(), `
|
||||||
|
UPDATE channels SET archived_at = COALESCE($1, archived_at) WHERE id = $2
|
||||||
|
RETURNING id, server_id, parent_channel_id, name, type, category, position, archived_at, auto_archive_duration, message_count, last_message_at, created_at
|
||||||
|
`, archivedAt, threadID))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"failed to update thread"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(thread)
|
||||||
|
}
|
||||||
@@ -316,6 +316,34 @@ CREATE TABLE IF NOT EXISTS channel_overrides (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_channel_overrides_channel ON channel_overrides(channel_id);
|
CREATE INDEX IF NOT EXISTS idx_channel_overrides_channel ON channel_overrides(channel_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_channel_overrides_target ON channel_overrides(channel_id, target_type, target_id);
|
CREATE INDEX IF NOT EXISTS idx_channel_overrides_target ON channel_overrides(channel_id, target_type, target_id);
|
||||||
|
|
||||||
|
-- Threads: reuse channels table by adding parent_channel_id; thread messages are still messages scoped to the thread channel.
|
||||||
|
ALTER TABLE channels ADD COLUMN IF NOT EXISTS parent_channel_id UUID REFERENCES channels(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE channels ADD COLUMN IF NOT EXISTS archived_at TIMESTAMPTZ;
|
||||||
|
ALTER TABLE channels ADD COLUMN IF NOT EXISTS auto_archive_duration INTEGER NOT NULL DEFAULT 1440;
|
||||||
|
ALTER TABLE channels ADD COLUMN IF NOT EXISTS message_count INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE channels ADD COLUMN IF NOT EXISTS last_message_at TIMESTAMPTZ;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_channels_parent ON channels(parent_channel_id);
|
||||||
|
|
||||||
|
-- Forum tags
|
||||||
|
CREATE TABLE IF NOT EXISTS forum_tags (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(64) NOT NULL,
|
||||||
|
emoji VARCHAR(32),
|
||||||
|
color VARCHAR(7),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (channel_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_forum_tags_channel ON forum_tags(channel_id);
|
||||||
|
|
||||||
|
-- Thread tag assignments (for forum posts)
|
||||||
|
CREATE TABLE IF NOT EXISTS channel_tags (
|
||||||
|
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||||
|
tag_id UUID NOT NULL REFERENCES forum_tags(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (channel_id, tag_id)
|
||||||
|
);
|
||||||
|
|
||||||
-- Message full-text search
|
-- Message full-text search
|
||||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS search_vector tsvector;
|
ALTER TABLE messages ADD COLUMN IF NOT EXISTS search_vector tsvector;
|
||||||
CREATE INDEX IF NOT EXISTS idx_messages_search ON messages USING GIN(search_vector);
|
CREATE INDEX IF NOT EXISTS idx_messages_search ON messages USING GIN(search_vector);
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import { useMessageStore } from "../stores/message.ts";
|
|||||||
import { useChannelStore } from "../stores/channel.ts";
|
import { useChannelStore } from "../stores/channel.ts";
|
||||||
import { useServerStore } from "../stores/server.ts";
|
import { useServerStore } from "../stores/server.ts";
|
||||||
import { useMemberStore } from "../stores/member.ts";
|
import { useMemberStore } from "../stores/member.ts";
|
||||||
|
import { useThreadStore } from "../stores/thread.ts";
|
||||||
import { GiphyPicker, type Gif } from "./GiphyPicker";
|
import { GiphyPicker, type Gif } from "./GiphyPicker";
|
||||||
import { MentionDropdown } from "./MentionDropdown";
|
import { MentionDropdown } from "./MentionDropdown";
|
||||||
import { MessageSearch } from "./MessageSearch";
|
import { MessageSearch } from "./MessageSearch";
|
||||||
|
import { ThreadListPanel } from "./ThreadListPanel.tsx";
|
||||||
|
import { ThreadPanel } from "./ThreadPanel.tsx";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
|
|
||||||
@@ -16,9 +19,7 @@ function formatTime(iso: string): string {
|
|||||||
return `${hours}:${minutes}`;
|
return `${hours}:${minutes}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function renderContent(content: string, memberUsernames: Set<string>) {
|
function renderContent(content: string, memberUsernames: Set<string>) {
|
||||||
// Split on @username, preserving mentions; render plain text segments as markdown.
|
|
||||||
const segments: { type: "text" | "mention"; value: string }[] = [];
|
const segments: { type: "text" | "mention"; value: string }[] = [];
|
||||||
const mentionRe = /@([a-zA-Z0-9_.-]+)/g;
|
const mentionRe = /@([a-zA-Z0-9_.-]+)/g;
|
||||||
let last = 0;
|
let last = 0;
|
||||||
@@ -108,6 +109,7 @@ export function ChatArea() {
|
|||||||
const fetchMessages = useMessageStore((s) => s.fetchMessages);
|
const fetchMessages = useMessageStore((s) => s.fetchMessages);
|
||||||
const sendMessage = useMessageStore((s) => s.sendMessage);
|
const sendMessage = useMessageStore((s) => s.sendMessage);
|
||||||
const membersByServer = useMemberStore((s) => s.membersByServer);
|
const membersByServer = useMemberStore((s) => s.membersByServer);
|
||||||
|
const threads = useThreadStore((s) => activeChannelId ? s.threadsByParent[activeChannelId] || [] : []);
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showGifPicker, setShowGifPicker] = useState(false);
|
const [showGifPicker, setShowGifPicker] = useState(false);
|
||||||
@@ -115,6 +117,8 @@ export function ChatArea() {
|
|||||||
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
|
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
|
||||||
const [slowmodeRemaining, setSlowmodeRemaining] = useState(0);
|
const [slowmodeRemaining, setSlowmodeRemaining] = useState(0);
|
||||||
const [selectMode, setSelectMode] = useState(false);
|
const [selectMode, setSelectMode] = useState(false);
|
||||||
|
const [showThreads, setShowThreads] = useState(false);
|
||||||
|
const [activeThread, setActiveThread] = useState<{ id: string; name: string } | null>(null);
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -124,17 +128,12 @@ export function ChatArea() {
|
|||||||
const selectedIds = useMessageStore((s) =>
|
const selectedIds = useMessageStore((s) =>
|
||||||
activeChannelId ? s.selectedMessageIds[activeChannelId] || new Set<string>() : new Set<string>(),
|
activeChannelId ? s.selectedMessageIds[activeChannelId] || new Set<string>() : new Set<string>(),
|
||||||
);
|
);
|
||||||
const canBulkDelete = true; // ponytail: permission enforced server-side; server-side is the source of truth
|
const canBulkDelete = true;
|
||||||
|
|
||||||
const channels = activeServerId
|
const channels = activeServerId ? channelsByServer[activeServerId] || [] : [];
|
||||||
? channelsByServer[activeServerId] || []
|
|
||||||
: [];
|
|
||||||
const activeChannel = channels.find((c) => c.id === activeChannelId);
|
const activeChannel = channels.find((c) => c.id === activeChannelId);
|
||||||
const members = activeServerId ? membersByServer[activeServerId] || [] : [];
|
const members = activeServerId ? membersByServer[activeServerId] || [] : [];
|
||||||
const memberUsernames = useMemo(
|
const memberUsernames = useMemo(() => new Set(members.map((m) => m.username)), [members]);
|
||||||
() => new Set(members.map((m) => m.username)),
|
|
||||||
[members],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeChannelId) {
|
if (activeChannelId) {
|
||||||
@@ -165,8 +164,6 @@ export function ChatArea() {
|
|||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
const cursor = e.target.selectionStart ?? value.length;
|
const cursor = e.target.selectionStart ?? value.length;
|
||||||
setInput(value);
|
setInput(value);
|
||||||
|
|
||||||
// Detect an unfinished mention at cursor position.
|
|
||||||
const beforeCursor = value.slice(0, cursor);
|
const beforeCursor = value.slice(0, cursor);
|
||||||
const atIndex = beforeCursor.lastIndexOf("@");
|
const atIndex = beforeCursor.lastIndexOf("@");
|
||||||
if (atIndex === -1) {
|
if (atIndex === -1) {
|
||||||
@@ -198,7 +195,7 @@ export function ChatArea() {
|
|||||||
setInput(next);
|
setInput(next);
|
||||||
setMentionQuery(null);
|
setMentionQuery(null);
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const pos = atIndex + username.length + 2; // @username + space
|
const pos = atIndex + username.length + 2;
|
||||||
inputEl.focus();
|
inputEl.focus();
|
||||||
inputEl.setSelectionRange(pos, pos);
|
inputEl.setSelectionRange(pos, pos);
|
||||||
});
|
});
|
||||||
@@ -214,7 +211,6 @@ export function ChatArea() {
|
|||||||
setMentionQuery(null);
|
setMentionQuery(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : "Failed to send";
|
const msg = err instanceof Error ? err.message : "Failed to send";
|
||||||
// Parse slowmode error from API.
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(msg);
|
const parsed = JSON.parse(msg);
|
||||||
if (parsed.error === "slowmode" && typeof parsed.retry_after === "number") {
|
if (parsed.error === "slowmode" && typeof parsed.retry_after === "number") {
|
||||||
@@ -251,6 +247,13 @@ export function ChatArea() {
|
|||||||
</span>
|
</span>
|
||||||
{activeChannelId && (
|
{activeChannelId && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowThreads((prev) => !prev)}
|
||||||
|
className={`text-xs font-mono ${showThreads ? 'text-gb-orange' : 'text-gb-fg-f hover:text-gb-orange'}`}
|
||||||
|
title="Toggle threads panel"
|
||||||
|
>
|
||||||
|
[THREADS{threads.length > 0 ? `:${threads.length}` : ''}]
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectMode((prev) => !prev)}
|
onClick={() => setSelectMode((prev) => !prev)}
|
||||||
className={`text-xs font-mono ${selectMode ? 'text-gb-orange' : 'text-gb-fg-f hover:text-gb-orange'}`}
|
className={`text-xs font-mono ${selectMode ? 'text-gb-orange' : 'text-gb-fg-f hover:text-gb-orange'}`}
|
||||||
@@ -275,6 +278,13 @@ export function ChatArea() {
|
|||||||
onClose={() => setShowSearch(false)}
|
onClose={() => setShowSearch(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{showThreads && activeChannelId && (
|
||||||
|
<ThreadListPanel
|
||||||
|
channelId={activeChannelId}
|
||||||
|
onSelect={(t) => setActiveThread(t)}
|
||||||
|
onClose={() => setShowThreads(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{selectMode && activeChannelId && (
|
{selectMode && activeChannelId && (
|
||||||
<div className="px-3 py-1 text-xs font-mono text-gb-fg-f flex items-center justify-between bg-gb-bg-s border-b border-gb-bg-t">
|
<div className="px-3 py-1 text-xs font-mono text-gb-fg-f flex items-center justify-between bg-gb-bg-s border-b border-gb-bg-t">
|
||||||
<span>{selectedIds.size} selected</span>
|
<span>{selectedIds.size} selected</span>
|
||||||
@@ -303,6 +313,8 @@ export function ChatArea() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="flex flex-1 min-h-0">
|
||||||
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
<div className="flex-1 overflow-y-auto p-3 space-y-1 font-mono text-sm">
|
<div className="flex-1 overflow-y-auto p-3 space-y-1 font-mono text-sm">
|
||||||
{isLoading && <p className="text-gb-fg-f">[loading...]</p>}
|
{isLoading && <p className="text-gb-fg-f">[loading...]</p>}
|
||||||
{!isLoading && messages.length === 0 && (
|
{!isLoading && messages.length === 0 && (
|
||||||
@@ -319,15 +331,9 @@ export function ChatArea() {
|
|||||||
{selectedIds.has(message.id) ? '[x]' : '[ ]'}
|
{selectedIds.has(message.id) ? '[x]' : '[ ]'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-gb-fg-f">
|
<span className="text-gb-fg-f">[{formatTime(message.created_at)}]</span>{" "}
|
||||||
[{formatTime(message.created_at)}]
|
<span className="text-gb-aqua"><{message.author_username}></span>{" "}
|
||||||
</span>{" "}
|
<span className="text-gb-fg">{renderContent(message.content, memberUsernames)}</span>
|
||||||
<span className="text-gb-aqua">
|
|
||||||
<{message.author_username}>
|
|
||||||
</span>{" "}
|
|
||||||
<span className="text-gb-fg">
|
|
||||||
{renderContent(message.content, memberUsernames)}
|
|
||||||
</span>
|
|
||||||
{renderEmbeds(message.embeds)}
|
{renderEmbeds(message.embeds)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -335,10 +341,7 @@ export function ChatArea() {
|
|||||||
</div>
|
</div>
|
||||||
{showGifPicker && (
|
{showGifPicker && (
|
||||||
<div className="px-3 pb-1">
|
<div className="px-3 pb-1">
|
||||||
<GiphyPicker
|
<GiphyPicker onSelect={handleGifSelect} onClose={() => setShowGifPicker(false)} />
|
||||||
onSelect={handleGifSelect}
|
|
||||||
onClose={() => setShowGifPicker(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
@@ -364,11 +367,7 @@ export function ChatArea() {
|
|||||||
disabled={!activeChannelId || slowmodeRemaining > 0}
|
disabled={!activeChannelId || slowmodeRemaining > 0}
|
||||||
/>
|
/>
|
||||||
{mentionQuery !== null && (
|
{mentionQuery !== null && (
|
||||||
<MentionDropdown
|
<MentionDropdown query={mentionQuery} members={members} onSelect={handleMentionSelect} />
|
||||||
query={mentionQuery}
|
|
||||||
members={members}
|
|
||||||
onSelect={handleMentionSelect}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -382,5 +381,10 @@ export function ChatArea() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
{activeThread && (
|
||||||
|
<ThreadPanel threadId={activeThread.id} threadName={activeThread.name} onClose={() => setActiveThread(null)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useThreadStore, type Thread } from '../stores/thread.ts';
|
||||||
|
|
||||||
|
interface ThreadListPanelProps {
|
||||||
|
channelId: string;
|
||||||
|
onSelect: (thread: { id: string; name: string }) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThreadListPanel({ channelId, onSelect, onClose }: ThreadListPanelProps) {
|
||||||
|
const threads = useThreadStore((s) => s.threadsByParent[channelId] || []);
|
||||||
|
const fetchThreads = useThreadStore((s) => s.fetchThreads);
|
||||||
|
const createThread = useThreadStore((s) => s.createThread);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchThreads(channelId);
|
||||||
|
}, [channelId, fetchThreads]);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!name.trim()) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const t = await createThread(channelId, { name: name.trim() });
|
||||||
|
onSelect({ id: t.id, name: t.name });
|
||||||
|
setName('');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gb-bg-s border-b border-gb-bg-t p-3 font-mono text-sm">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-gb-orange text-xs">THREADS</span>
|
||||||
|
<button onClick={onClose} className="text-xs text-gb-fg-f hover:text-gb-orange">[x]</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mb-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="new thread name"
|
||||||
|
className="terminal-input flex-1 text-xs"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={loading || !name.trim()}
|
||||||
|
className="text-xs bg-gb-orange text-gb-bg px-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
[+]
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{threads.length === 0 && <div className="text-gb-fg-f text-xs">[no threads]</div>}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{threads.map((t: Thread) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => onSelect({ id: t.id, name: t.name })}
|
||||||
|
className="w-full text-left text-xs text-gb-fg hover:text-gb-orange truncate"
|
||||||
|
>
|
||||||
|
→ {t.name} <span className="text-gb-fg-f">({t.message_count})</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useThreadStore } from '../stores/thread.ts';
|
||||||
|
import { useMessageStore } from '../stores/message.ts';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
|
||||||
|
interface ThreadPanelProps {
|
||||||
|
threadId: string;
|
||||||
|
threadName: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso: string): string {
|
||||||
|
const date = new Date(iso);
|
||||||
|
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThreadPanel({ threadId, threadName, onClose }: ThreadPanelProps) {
|
||||||
|
const messages = useThreadStore((s) => s.messagesByThread[threadId] || []);
|
||||||
|
const fetchMessages = useThreadStore((s) => s.fetchThreadMessages);
|
||||||
|
const sendMessage = useThreadStore((s) => s.sendThreadMessage);
|
||||||
|
const addMessage = useThreadStore((s) => s.addThreadMessage);
|
||||||
|
const removeMessage = useMessageStore((s) => s.removeMessage);
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMessages(threadId);
|
||||||
|
}, [threadId, fetchMessages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: 'auto' });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
// Listen for gateway messages scoped to this thread channel.
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
const event = JSON.parse(e.data);
|
||||||
|
if (event.type === 'MESSAGE_CREATE' && event.data?.channel_id === threadId) {
|
||||||
|
addMessage(event.data);
|
||||||
|
}
|
||||||
|
if (event.type === 'MESSAGE_DELETE' && event.data?.channel_id === threadId) {
|
||||||
|
removeMessage(threadId, event.data.id);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('message', handler);
|
||||||
|
return () => window.removeEventListener('message', handler);
|
||||||
|
}, [threadId, addMessage, removeMessage]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const value = inputRef.current?.value.trim();
|
||||||
|
if (!value) return;
|
||||||
|
await sendMessage(threadId, value);
|
||||||
|
if (inputRef.current) inputRef.current.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-80 bg-gb-bg-s border-l border-gb-bg-t flex flex-col h-full">
|
||||||
|
<div className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg-s flex items-center justify-between">
|
||||||
|
<span>→ {threadName}</span>
|
||||||
|
<button onClick={onClose} className="text-gb-fg-f hover:text-gb-orange">[x]</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-3 space-y-1 font-mono text-sm">
|
||||||
|
{messages.map((m) => (
|
||||||
|
<div key={m.id} className="break-words">
|
||||||
|
<span className="text-gb-fg-f">[{formatTime(m.created_at)}]</span>{' '}
|
||||||
|
<span className="text-gb-aqua"><{m.author_username}></span>{' '}
|
||||||
|
<span className="text-gb-fg">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]} components={{ p: ({ ...props }) => <span {...props} className="inline" /> }}>
|
||||||
|
{m.content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit} className="p-3 flex items-center gap-2">
|
||||||
|
<span className="text-gb-fg-f select-none shrink-0">{'>'}</span>
|
||||||
|
<input ref={inputRef} type="text" placeholder="reply..." className="terminal-input w-full" />
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { api } from '../lib/api.ts';
|
||||||
|
import type { Message } from './message.ts';
|
||||||
|
|
||||||
|
export interface Thread {
|
||||||
|
id: string;
|
||||||
|
server_id: string;
|
||||||
|
parent_channel_id: string;
|
||||||
|
name: string;
|
||||||
|
type: 'thread';
|
||||||
|
category: string | null;
|
||||||
|
position: number;
|
||||||
|
archived_at: string | null;
|
||||||
|
auto_archive_duration: number;
|
||||||
|
message_count: number;
|
||||||
|
last_message_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateThreadData {
|
||||||
|
name: string;
|
||||||
|
message_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThreadState {
|
||||||
|
threadsByParent: Record<string, Thread[]>;
|
||||||
|
activeThreadId: string | null;
|
||||||
|
messagesByThread: Record<string, Message[]>;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
fetchThreads: (parentChannelId: string) => Promise<void>;
|
||||||
|
createThread: (parentChannelId: string, data: CreateThreadData) => Promise<Thread>;
|
||||||
|
archiveThread: (threadId: string) => Promise<void>;
|
||||||
|
setActiveThread: (id: string | null) => void;
|
||||||
|
fetchThreadMessages: (threadId: string, before?: string) => Promise<void>;
|
||||||
|
sendThreadMessage: (threadId: string, content: string) => Promise<Message>;
|
||||||
|
addThreadMessage: (message: Message) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useThreadStore = create<ThreadState>((set) => ({
|
||||||
|
threadsByParent: {},
|
||||||
|
activeThreadId: null,
|
||||||
|
messagesByThread: {},
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
fetchThreads: async (parentChannelId) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
const threads = await api.get<Thread[]>(`/channels/${parentChannelId}/threads`);
|
||||||
|
set((state) => ({
|
||||||
|
threadsByParent: { ...state.threadsByParent, [parentChannelId]: Array.isArray(threads) ? threads : [] },
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
set({ isLoading: false, error: error instanceof Error ? error.message : 'Failed to fetch threads' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
createThread: async (parentChannelId, data) => {
|
||||||
|
const thread = await api.post<Thread>(`/channels/${parentChannelId}/threads`, data);
|
||||||
|
set((state) => {
|
||||||
|
const list = state.threadsByParent[parentChannelId] || [];
|
||||||
|
return {
|
||||||
|
threadsByParent: { ...state.threadsByParent, [parentChannelId]: [...list, thread] },
|
||||||
|
activeThreadId: thread.id,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return thread;
|
||||||
|
},
|
||||||
|
|
||||||
|
archiveThread: async (threadId) => {
|
||||||
|
await api.patch(`/threads/${threadId}`, { archived: true });
|
||||||
|
set((state) => {
|
||||||
|
const next: Record<string, Thread[]> = {};
|
||||||
|
for (const parentId of Object.keys(state.threadsByParent)) {
|
||||||
|
next[parentId] = state.threadsByParent[parentId].map((t) =>
|
||||||
|
t.id === threadId ? { ...t, archived_at: new Date().toISOString() } : t,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { threadsByParent: next };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setActiveThread: (id) => set({ activeThreadId: id }),
|
||||||
|
|
||||||
|
fetchThreadMessages: async (threadId, before) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
const params = before ? `?before=${encodeURIComponent(before)}` : '';
|
||||||
|
const messages = await api.get<Message[]>(`/channels/${threadId}/messages${params}`);
|
||||||
|
set((state) => ({
|
||||||
|
messagesByThread: { ...state.messagesByThread, [threadId]: Array.isArray(messages) ? messages : [] },
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
set({ isLoading: false, error: error instanceof Error ? error.message : 'Failed to fetch thread messages' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
sendThreadMessage: async (threadId, content) => {
|
||||||
|
const message = await api.post<Message>(`/channels/${threadId}/messages`, { content });
|
||||||
|
set((state) => {
|
||||||
|
const list = state.messagesByThread[threadId] || [];
|
||||||
|
return { messagesByThread: { ...state.messagesByThread, [threadId]: [...list, message] } };
|
||||||
|
});
|
||||||
|
return message;
|
||||||
|
},
|
||||||
|
|
||||||
|
addThreadMessage: (message) =>
|
||||||
|
set((state) => {
|
||||||
|
const list = state.messagesByThread[message.channel_id] || [];
|
||||||
|
if (list.some((m) => m.id === message.id)) return state;
|
||||||
|
return { messagesByThread: { ...state.messagesByThread, [message.channel_id]: [...list, message] } };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
@@ -1 +1 @@
|
|||||||
{"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"}
|
{"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/ThreadListPanel.tsx","./src/components/ThreadPanel.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/thread.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"}
|
||||||
Reference in New Issue
Block a user