diff --git a/GUILDED_ROADMAP.md b/GUILDED_ROADMAP.md index 719fb64..8149369 100644 --- a/GUILDED_ROADMAP.md +++ b/GUILDED_ROADMAP.md @@ -35,14 +35,14 @@ --- -## Phase 3 — Threads & Forums 🔄 IN PROGRESS +## Phase 3 — Threads & Forums ✅ COMPLETE > 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 | +| 3.2 | Forum Channels | ✅ Forum posts as threads + tags + card view | ### 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/forum.go b/internal/channel/forum.go new file mode 100644 index 0000000..c394799 --- /dev/null +++ b/internal/channel/forum.go @@ -0,0 +1,156 @@ +package channel + +import ( + "database/sql" + "encoding/json" + "net/http" + + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" + "github.com/go-chi/chi/v5" +) + +type forumTag struct { + ID string `json:"id"` + Name string `json:"name"` + Emoji *string `json:"emoji"` + Color *string `json:"color"` + TagID string `json:"tag_id"` +} + +type forumPost struct { + threadResponse + TagIDs []string `json:"tag_ids"` +} + +func (h *Handler) registerForumRoutes(r chi.Router) { + r.Get("/{channelID}/forum-tags", h.ListForumTags) + r.Post("/{channelID}/forum-tags", h.CreateForumTag) + r.Delete("/forum-tags/{tagID}", h.DeleteForumTag) +} + +// CreateForumTag creates a tag for a forum channel. +func (h *Handler) CreateForumTag(w http.ResponseWriter, r *http.Request) { + channelID := 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(), channelID) + 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 + } + + var req struct { + Name string `json:"name"` + Emoji *string `json:"emoji"` + Color *string `json:"color"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" { + http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) + return + } + + var tag forumTag + err = h.db.QueryRowContext(r.Context(), ` + INSERT INTO forum_tags (channel_id, name, emoji, color) + VALUES ($1, $2, $3, $4) + RETURNING id, name, emoji, color + `, channelID, req.Name, req.Emoji, req.Color).Scan(&tag.ID, &tag.Name, &tag.Emoji, &tag.Color) + if err != nil { + http.Error(w, `{"error":"failed to create tag"}`, http.StatusInternalServerError) + return + } + tag.TagID = tag.ID + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(tag) +} + +// ListForumTags lists tags for a forum channel. +func (h *Handler) ListForumTags(w http.ResponseWriter, r *http.Request) { + channelID := 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(), channelID) + 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, name, emoji, color FROM forum_tags WHERE channel_id = $1 ORDER BY name + `, channelID) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + defer rows.Close() + + tags := make([]forumTag, 0) + for rows.Next() { + var t forumTag + var emoji, color sql.NullString + if err := rows.Scan(&t.ID, &t.Name, &emoji, &color); err != nil { + continue + } + if emoji.Valid { + t.Emoji = &emoji.String + } + if color.Valid { + t.Color = &color.String + } + t.TagID = t.ID + tags = append(tags, t) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(tags) +} + +// DeleteForumTag deletes a forum tag. +func (h *Handler) DeleteForumTag(w http.ResponseWriter, r *http.Request) { + tagID := chi.URLParam(r, "tagID") + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + var channelID string + err := h.db.QueryRowContext(r.Context(), `SELECT channel_id FROM forum_tags WHERE id = $1`, tagID).Scan(&channelID) + if err != nil { + http.Error(w, `{"error":"tag not found"}`, http.StatusNotFound) + return + } + serverID, err := h.serverIDForChannel(r.Context(), channelID) + 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 + } + + _, err = h.db.ExecContext(r.Context(), `DELETE FROM forum_tags WHERE id = $1`, tagID) + if err != nil { + http.Error(w, `{"error":"failed to delete tag"}`, http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/channel/handlers.go b/internal/channel/handlers.go index 4110ba4..cfa3033 100644 --- a/internal/channel/handlers.go +++ b/internal/channel/handlers.go @@ -30,6 +30,7 @@ func (h *Handler) RegisterRoutes(r chi.Router) { r.Post("/{channelID}/threads", h.CreateThread) r.Get("/{channelID}/threads", h.ListThreads) r.Patch("/threads/{threadID}", h.UpdateThread) + h.registerForumRoutes(r) h.registerOverrideRoutes(r) } diff --git a/internal/channel/threads.go b/internal/channel/threads.go index d05223c..e6645a2 100644 --- a/internal/channel/threads.go +++ b/internal/channel/threads.go @@ -11,8 +11,9 @@ import ( ) type createThreadRequest struct { - Name string `json:"name"` - MessageID *string `json:"message_id,omitempty"` + Name string `json:"name"` + MessageID *string `json:"message_id,omitempty"` + TagIDs []string `json:"tag_ids,omitempty"` } type threadResponse struct { @@ -95,6 +96,16 @@ func (h *Handler) CreateThread(w http.ResponseWriter, r *http.Request) { return } + // Attach optional tags for forum posts. + if len(req.TagIDs) > 0 { + // ponytail: ignore tag errors; best-effort assignment + for _, tagID := range req.TagIDs { + _, _ = h.db.ExecContext(r.Context(), ` + INSERT INTO channel_tags (channel_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING + `, thread.ID, tagID) + } + } + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(thread) diff --git a/internal/db/db.go b/internal/db/db.go index ea10f2e..c881359 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -316,14 +316,6 @@ 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(), @@ -344,6 +336,14 @@ CREATE TABLE IF NOT EXISTS channel_tags ( PRIMARY KEY (channel_id, tag_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); + -- 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/ChannelList.tsx b/web/src/components/ChannelList.tsx index 27bdfc3..3de8de8 100644 --- a/web/src/components/ChannelList.tsx +++ b/web/src/components/ChannelList.tsx @@ -92,7 +92,7 @@ export function ChannelList() { channel.id === activeChannelId ? 'terminal-active' : 'hover:bg-gb-bg-t text-gb-fg-s' }`} > - # + {channel.type === 'forum' ? '■' : '#'} {channel.name} { e.stopPropagation(); setSettingsChannel({ id: channel.id, name: channel.name }); }} diff --git a/web/src/components/ChatArea.tsx b/web/src/components/ChatArea.tsx index acb2f67..b001302 100644 --- a/web/src/components/ChatArea.tsx +++ b/web/src/components/ChatArea.tsx @@ -9,6 +9,7 @@ import { MentionDropdown } from "./MentionDropdown"; import { MessageSearch } from "./MessageSearch"; import { ThreadListPanel } from "./ThreadListPanel.tsx"; import { ThreadPanel } from "./ThreadPanel.tsx"; +import { ForumView } from "./ForumView.tsx"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; @@ -240,12 +241,14 @@ export function ChatArea() {
{activeChannel - ? `# ${activeChannel.name}` + ? activeChannel.type === 'forum' + ? `■ ${activeChannel.name}` + : `# ${activeChannel.name}` : activeChannelId ? `#${activeChannelId}` : "[NO CHANNEL SELECTED]"} - {activeChannelId && ( + {activeChannelId && activeChannel?.type !== 'forum' && (
+
- - -
- {activeThread && ( - setActiveThread(null)} /> + {activeThread && ( + setActiveThread(null)} /> + )} + )} diff --git a/web/src/components/CreateChannelModal.tsx b/web/src/components/CreateChannelModal.tsx index 38c6b6b..5394337 100644 --- a/web/src/components/CreateChannelModal.tsx +++ b/web/src/components/CreateChannelModal.tsx @@ -11,9 +11,10 @@ interface ChannelApiResponse { id: string; server_id: string; name: string; - type: 'text' | 'voice'; + type: 'text' | 'voice' | 'forum'; category: string; position: number; + slowmode_seconds: number; } const SLOWMODE_OPTIONS = [ @@ -28,7 +29,7 @@ const SLOWMODE_OPTIONS = [ export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProps) { const [name, setName] = useState(''); - const [type, setType] = useState<'text' | 'voice'>('text'); + const [type, setType] = useState<'text' | 'voice' | 'forum'>('text'); const [category, setCategory] = useState('general'); const [slowmodeSeconds, setSlowmodeSeconds] = useState(0); const [loading, setLoading] = useState(false); @@ -65,6 +66,7 @@ export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProp type: result.type, category: result.category || null, position: result.position, + slowmode_seconds: result.slowmode_seconds, }; useChannelStore.getState().addChannel(newChannel); onClose(); @@ -105,11 +107,12 @@ export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProp
diff --git a/web/src/components/ForumView.tsx b/web/src/components/ForumView.tsx new file mode 100644 index 0000000..2f49680 --- /dev/null +++ b/web/src/components/ForumView.tsx @@ -0,0 +1,97 @@ +import { useEffect, useState } from 'react'; +import { useThreadStore, type Thread, type ForumTag } from '../stores/thread.ts'; + +interface ForumViewProps { + channelId: string; + channelName: string; + onOpenThread: (thread: { id: string; name: string }) => void; +} + +export function ForumView({ channelId, channelName, onOpenThread }: ForumViewProps) { + const threads = useThreadStore((s) => s.threadsByParent[channelId] || []); + const tags = useThreadStore((s) => s.tagsByForum[channelId] || []); + const fetchThreads = useThreadStore((s) => s.fetchThreads); + const fetchForumTags = useThreadStore((s) => s.fetchForumTags); + const createThread = useThreadStore((s) => s.createThread); + const createForumTag = useThreadStore((s) => s.createForumTag); + const [title, setTitle] = useState(''); + const [body, setBody] = useState(''); + const [selectedTagIds, setSelectedTagIds] = useState([]); + const [tagName, setTagName] = useState(''); + const [showForm, setShowForm] = useState(false); + + useEffect(() => { + fetchThreads(channelId); + fetchForumTags(channelId); + }, [channelId, fetchThreads, fetchForumTags]); + + const handlePost = async () => { + if (!title.trim()) return; + const t = await createThread(channelId, { name: title.trim(), tag_ids: selectedTagIds }); + if (body.trim()) { + // ponytail: send first message via thread store after creation + const { sendThreadMessage } = useThreadStore.getState(); + await sendThreadMessage(t.id, body.trim()); + } + setTitle(''); + setBody(''); + setSelectedTagIds([]); + setShowForm(false); + }; + + const toggleTag = (id: string) => { + setSelectedTagIds((prev) => prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]); + }; + + const handleCreateTag = async () => { + if (!tagName.trim()) return; + await createForumTag(channelId, { name: tagName.trim() }); + setTagName(''); + }; + + return ( +
+
+ ■ {channelName} + +
+ {showForm && ( +
+ setTitle(e.target.value)} placeholder="post title" className="terminal-input w-full" /> +