From f3f03df710376f2f07ac95c1058feaf2aad1523b Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Tue, 30 Jun 2026 10:36:18 -0400 Subject: [PATCH] feat(phase3): threads backend + thread panel UI --- GUILDED_ROADMAP.md | 23 ++- internal/channel/handlers.go | 3 + internal/channel/threads.go | 193 ++++++++++++++++++++ internal/db/db.go | 28 +++ web/src/components/ChatArea.tsx | 234 +++++++++++++------------ web/src/components/ThreadListPanel.tsx | 69 ++++++++ web/src/components/ThreadPanel.tsx | 88 ++++++++++ web/src/stores/thread.ts | 116 ++++++++++++ web/tsconfig.app.tsbuildinfo | 2 +- 9 files changed, 634 insertions(+), 122 deletions(-) create mode 100644 internal/channel/threads.go create mode 100644 web/src/components/ThreadListPanel.tsx create mode 100644 web/src/components/ThreadPanel.tsx create mode 100644 web/src/stores/thread.ts diff --git a/GUILDED_ROADMAP.md b/GUILDED_ROADMAP.md index e309c17..719fb64 100644 --- a/GUILDED_ROADMAP.md +++ b/GUILDED_ROADMAP.md @@ -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. | # | Feature | Status | |---|---------|--------| -| 2.1 | Per-Channel Permission Overrides | In progress | -| 2.2 | Expanded Permission Flags | ✅ Already added in `internal/permissions/permissions.go` | -| 2.3 | Role Colors & Hierarchy | ✅ DB columns present; UI in progress | -| 2.4 | Audit Log | ✅ Backend + server settings panel live | -| 2.5 | Bulk Message Delete | ✅ Backend + frontend live | +| 2.1 | Per-Channel Permission Overrides | ✅ Backend routes + frontend matrix | +| 2.2 | Expanded Permission Flags | ✅ Backend bits + RoleManager UI | +| 2.3 | Role Colors & Hierarchy | ✅ DB + editor; chat rendering in Phase 7 | +| 2.4 | Audit Log | ✅ Backend + server settings panel | +| 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 - **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. diff --git a/internal/channel/handlers.go b/internal/channel/handlers.go index 0cb6051..4110ba4 100644 --- a/internal/channel/handlers.go +++ b/internal/channel/handlers.go @@ -27,6 +27,9 @@ func (h *Handler) RegisterRoutes(r chi.Router) { r.Get("/{channelID}", h.Get) r.Patch("/{channelID}", h.Update) 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) } diff --git a/internal/channel/threads.go b/internal/channel/threads.go new file mode 100644 index 0000000..d05223c --- /dev/null +++ b/internal/channel/threads.go @@ -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) +} diff --git a/internal/db/db.go b/internal/db/db.go index 14fdba1..ea10f2e 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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_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 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); diff --git a/web/src/components/ChatArea.tsx b/web/src/components/ChatArea.tsx index f2921d9..acb2f67 100644 --- a/web/src/components/ChatArea.tsx +++ b/web/src/components/ChatArea.tsx @@ -3,9 +3,12 @@ import { useMessageStore } from "../stores/message.ts"; import { useChannelStore } from "../stores/channel.ts"; import { useServerStore } from "../stores/server.ts"; import { useMemberStore } from "../stores/member.ts"; +import { useThreadStore } from "../stores/thread.ts"; import { GiphyPicker, type Gif } from "./GiphyPicker"; import { MentionDropdown } from "./MentionDropdown"; import { MessageSearch } from "./MessageSearch"; +import { ThreadListPanel } from "./ThreadListPanel.tsx"; +import { ThreadPanel } from "./ThreadPanel.tsx"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; @@ -16,9 +19,7 @@ function formatTime(iso: string): string { return `${hours}:${minutes}`; } - function renderContent(content: string, memberUsernames: Set) { - // Split on @username, preserving mentions; render plain text segments as markdown. const segments: { type: "text" | "mention"; value: string }[] = []; const mentionRe = /@([a-zA-Z0-9_.-]+)/g; let last = 0; @@ -108,6 +109,7 @@ export function ChatArea() { const fetchMessages = useMessageStore((s) => s.fetchMessages); const sendMessage = useMessageStore((s) => s.sendMessage); const membersByServer = useMemberStore((s) => s.membersByServer); + const threads = useThreadStore((s) => activeChannelId ? s.threadsByParent[activeChannelId] || [] : []); const [input, setInput] = useState(""); const [error, setError] = useState(null); const [showGifPicker, setShowGifPicker] = useState(false); @@ -115,6 +117,8 @@ export function ChatArea() { const [mentionQuery, setMentionQuery] = useState(null); const [slowmodeRemaining, setSlowmodeRemaining] = useState(0); const [selectMode, setSelectMode] = useState(false); + const [showThreads, setShowThreads] = useState(false); + const [activeThread, setActiveThread] = useState<{ id: string; name: string } | null>(null); const bottomRef = useRef(null); const inputRef = useRef(null); @@ -124,17 +128,12 @@ export function ChatArea() { const selectedIds = useMessageStore((s) => activeChannelId ? s.selectedMessageIds[activeChannelId] || new Set() : new Set(), ); - const canBulkDelete = true; // ponytail: permission enforced server-side; server-side is the source of truth + const canBulkDelete = true; - const channels = activeServerId - ? channelsByServer[activeServerId] || [] - : []; + const channels = activeServerId ? channelsByServer[activeServerId] || [] : []; const activeChannel = channels.find((c) => c.id === activeChannelId); const members = activeServerId ? membersByServer[activeServerId] || [] : []; - const memberUsernames = useMemo( - () => new Set(members.map((m) => m.username)), - [members], - ); + const memberUsernames = useMemo(() => new Set(members.map((m) => m.username)), [members]); useEffect(() => { if (activeChannelId) { @@ -165,8 +164,6 @@ export function ChatArea() { const value = e.target.value; const cursor = e.target.selectionStart ?? value.length; setInput(value); - - // Detect an unfinished mention at cursor position. const beforeCursor = value.slice(0, cursor); const atIndex = beforeCursor.lastIndexOf("@"); if (atIndex === -1) { @@ -198,7 +195,7 @@ export function ChatArea() { setInput(next); setMentionQuery(null); requestAnimationFrame(() => { - const pos = atIndex + username.length + 2; // @username + space + const pos = atIndex + username.length + 2; inputEl.focus(); inputEl.setSelectionRange(pos, pos); }); @@ -214,7 +211,6 @@ export function ChatArea() { setMentionQuery(null); } catch (err) { const msg = err instanceof Error ? err.message : "Failed to send"; - // Parse slowmode error from API. try { const parsed = JSON.parse(msg); if (parsed.error === "slowmode" && typeof parsed.retry_after === "number") { @@ -251,6 +247,13 @@ export function ChatArea() { {activeChannelId && (
+ - -
+ {showThreads && activeChannelId && ( + setActiveThread(t)} + onClose={() => setShowThreads(false)} + /> + )} + {selectMode && activeChannelId && ( +
+ {selectedIds.size} selected +
+ +
- )} -
- {isLoading &&

[loading...]

} - {!isLoading && messages.length === 0 && ( -

[no messages in this channel]

- )} - {messages.map((message) => ( -
selectMode && activeChannelId && toggleSelectedMessage(activeChannelId, message.id)} - className={`break-words ${selectMode ? 'cursor-pointer hover:bg-gb-bg-s' : ''} ${selectedIds.has(message.id) ? 'bg-gb-bg-s' : ''}`} - > - {selectMode && activeChannelId && ( - - {selectedIds.has(message.id) ? '[x]' : '[ ]'} - +
+ )} +
+
+
+ {isLoading &&

[loading...]

} + {!isLoading && messages.length === 0 && ( +

[no messages in this channel]

)} - - [{formatTime(message.created_at)}] - {" "} - - <{message.author_username}> - {" "} - - {renderContent(message.content, memberUsernames)} - - {renderEmbeds(message.embeds)} + {messages.map((message) => ( +
selectMode && activeChannelId && toggleSelectedMessage(activeChannelId, message.id)} + className={`break-words ${selectMode ? 'cursor-pointer hover:bg-gb-bg-s' : ''} ${selectedIds.has(message.id) ? 'bg-gb-bg-s' : ''}`} + > + {selectMode && activeChannelId && ( + + {selectedIds.has(message.id) ? '[x]' : '[ ]'} + + )} + [{formatTime(message.created_at)}]{" "} + <{message.author_username}>{" "} + {renderContent(message.content, memberUsernames)} + {renderEmbeds(message.embeds)} +
+ ))} +
- ))} -
-
- {showGifPicker && ( -
- setShowGifPicker(false)} - /> -
- )} - {error && ( -
-

ERR: {error}

-
- )} - {slowmodeRemaining > 0 && ( -
- SLOWMODE: wait {slowmodeRemaining}s -
- )} -
- {">"} -
- 0} - /> - {mentionQuery !== null && ( - + {showGifPicker && ( +
+ setShowGifPicker(false)} /> +
)} + {error && ( +
+

ERR: {error}

+
+ )} + {slowmodeRemaining > 0 && ( +
+ SLOWMODE: wait {slowmodeRemaining}s +
+ )} + + {">"} +
+ 0} + /> + {mentionQuery !== null && ( + + )} +
+ +
- - + {activeThread && ( + setActiveThread(null)} /> + )} +
); } diff --git a/web/src/components/ThreadListPanel.tsx b/web/src/components/ThreadListPanel.tsx new file mode 100644 index 0000000..d81d724 --- /dev/null +++ b/web/src/components/ThreadListPanel.tsx @@ -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 ( +
+
+ THREADS + +
+
+ setName(e.target.value)} + placeholder="new thread name" + className="terminal-input flex-1 text-xs" + /> + +
+ {threads.length === 0 &&
[no threads]
} +
+ {threads.map((t: Thread) => ( + + ))} +
+
+ ); +} diff --git a/web/src/components/ThreadPanel.tsx b/web/src/components/ThreadPanel.tsx new file mode 100644 index 0000000..af11a9f --- /dev/null +++ b/web/src/components/ThreadPanel.tsx @@ -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(null); + const inputRef = useRef(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 ( +
+
+ → {threadName} + +
+
+ {messages.map((m) => ( +
+ [{formatTime(m.created_at)}]{' '} + <{m.author_username}>{' '} + + }}> + {m.content} + + +
+ ))} +
+
+
+ {'>'} + +
+
+ ); +} diff --git a/web/src/stores/thread.ts b/web/src/stores/thread.ts new file mode 100644 index 0000000..e90b566 --- /dev/null +++ b/web/src/stores/thread.ts @@ -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; + activeThreadId: string | null; + messagesByThread: Record; + isLoading: boolean; + error: string | null; + fetchThreads: (parentChannelId: string) => Promise; + createThread: (parentChannelId: string, data: CreateThreadData) => Promise; + archiveThread: (threadId: string) => Promise; + setActiveThread: (id: string | null) => void; + fetchThreadMessages: (threadId: string, before?: string) => Promise; + sendThreadMessage: (threadId: string, content: string) => Promise; + addThreadMessage: (message: Message) => void; +} + +export const useThreadStore = create((set) => ({ + threadsByParent: {}, + activeThreadId: null, + messagesByThread: {}, + isLoading: false, + error: null, + + fetchThreads: async (parentChannelId) => { + set({ isLoading: true, error: null }); + try { + const threads = await api.get(`/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(`/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 = {}; + 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(`/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(`/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] } }; + }), +})); diff --git a/web/tsconfig.app.tsbuildinfo b/web/tsconfig.app.tsbuildinfo index bcde1d6..819484a 100644 --- a/web/tsconfig.app.tsbuildinfo +++ b/web/tsconfig.app.tsbuildinfo @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file