feat(phase3): threads backend + thread panel UI

This commit is contained in:
2026-06-30 10:36:18 -04:00
parent bd260183ef
commit f3f03df710
9 changed files with 634 additions and 122 deletions
+119 -115
View File
@@ -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<string>) {
// 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<string | null>(null);
const [showGifPicker, setShowGifPicker] = useState(false);
@@ -115,6 +117,8 @@ export function ChatArea() {
const [mentionQuery, setMentionQuery] = useState<string | null>(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<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
@@ -124,17 +128,12 @@ export function ChatArea() {
const selectedIds = useMessageStore((s) =>
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
? 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() {
</span>
{activeChannelId && (
<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
onClick={() => setSelectMode((prev) => !prev)}
className={`text-xs font-mono ${selectMode ? 'text-gb-orange' : 'text-gb-fg-f hover:text-gb-orange'}`}
@@ -275,112 +278,113 @@ export function ChatArea() {
onClose={() => setShowSearch(false)}
/>
)}
{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">
<span>{selectedIds.size} selected</span>
<div className="flex items-center gap-2">
<button
onClick={() => clearSelectedMessages(activeChannelId)}
className="text-gb-fg-f hover:text-gb-orange"
>
[CLEAR]
</button>
<button
onClick={async () => {
if (!activeChannelId || selectedIds.size === 0) return;
if (!confirm(`Delete ${selectedIds.size} messages?`)) return;
try {
await bulkDeleteMessages(activeChannelId, Array.from(selectedIds));
} catch (err) {
setError(err instanceof Error ? err.message : 'Bulk delete failed');
}
}}
disabled={selectedIds.size === 0 || !canBulkDelete}
className="text-gb-red hover:text-gb-orange disabled:opacity-50"
>
[DELETE]
</button>
</div>
{showThreads && activeChannelId && (
<ThreadListPanel
channelId={activeChannelId}
onSelect={(t) => setActiveThread(t)}
onClose={() => setShowThreads(false)}
/>
)}
{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">
<span>{selectedIds.size} selected</span>
<div className="flex items-center gap-2">
<button
onClick={() => clearSelectedMessages(activeChannelId)}
className="text-gb-fg-f hover:text-gb-orange"
>
[CLEAR]
</button>
<button
onClick={async () => {
if (!activeChannelId || selectedIds.size === 0) return;
if (!confirm(`Delete ${selectedIds.size} messages?`)) return;
try {
await bulkDeleteMessages(activeChannelId, Array.from(selectedIds));
} catch (err) {
setError(err instanceof Error ? err.message : 'Bulk delete failed');
}
}}
disabled={selectedIds.size === 0 || !canBulkDelete}
className="text-gb-red hover:text-gb-orange disabled:opacity-50"
>
[DELETE]
</button>
</div>
)}
<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 && messages.length === 0 && (
<p className="text-gb-fg-f">[no messages in this channel]</p>
)}
{messages.map((message) => (
<div
key={message.id}
onClick={() => 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 && (
<span className="text-gb-fg-f mr-2">
{selectedIds.has(message.id) ? '[x]' : '[ ]'}
</span>
</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">
{isLoading && <p className="text-gb-fg-f">[loading...]</p>}
{!isLoading && messages.length === 0 && (
<p className="text-gb-fg-f">[no messages in this channel]</p>
)}
<span className="text-gb-fg-f">
[{formatTime(message.created_at)}]
</span>{" "}
<span className="text-gb-aqua">
&lt;{message.author_username}&gt;
</span>{" "}
<span className="text-gb-fg">
{renderContent(message.content, memberUsernames)}
</span>
{renderEmbeds(message.embeds)}
{messages.map((message) => (
<div
key={message.id}
onClick={() => 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 && (
<span className="text-gb-fg-f mr-2">
{selectedIds.has(message.id) ? '[x]' : '[ ]'}
</span>
)}
<span className="text-gb-fg-f">[{formatTime(message.created_at)}]</span>{" "}
<span className="text-gb-aqua">&lt;{message.author_username}&gt;</span>{" "}
<span className="text-gb-fg">{renderContent(message.content, memberUsernames)}</span>
{renderEmbeds(message.embeds)}
</div>
))}
<div ref={bottomRef} />
</div>
))}
<div ref={bottomRef} />
</div>
{showGifPicker && (
<div className="px-3 pb-1">
<GiphyPicker
onSelect={handleGifSelect}
onClose={() => setShowGifPicker(false)}
/>
</div>
)}
{error && (
<div className="px-3 pb-1">
<p className="text-gb-red text-xs font-mono">ERR: {error}</p>
</div>
)}
{slowmodeRemaining > 0 && (
<div className="px-3 pt-1 text-xs text-gb-fg-f font-mono">
SLOWMODE: wait {slowmodeRemaining}s
</div>
)}
<form onSubmit={handleSubmit} className="p-3 flex items-center gap-2 relative">
<span className="text-gb-fg-f select-none shrink-0">{">"}</span>
<div className="flex-1 min-w-0 relative">
<input
ref={inputRef}
type="text"
value={input}
onChange={handleInputChange}
placeholder="type a message..."
className="terminal-input w-full"
disabled={!activeChannelId || slowmodeRemaining > 0}
/>
{mentionQuery !== null && (
<MentionDropdown
query={mentionQuery}
members={members}
onSelect={handleMentionSelect}
/>
{showGifPicker && (
<div className="px-3 pb-1">
<GiphyPicker onSelect={handleGifSelect} onClose={() => setShowGifPicker(false)} />
</div>
)}
{error && (
<div className="px-3 pb-1">
<p className="text-gb-red text-xs font-mono">ERR: {error}</p>
</div>
)}
{slowmodeRemaining > 0 && (
<div className="px-3 pt-1 text-xs text-gb-fg-f font-mono">
SLOWMODE: wait {slowmodeRemaining}s
</div>
)}
<form onSubmit={handleSubmit} className="p-3 flex items-center gap-2 relative">
<span className="text-gb-fg-f select-none shrink-0">{">"}</span>
<div className="flex-1 min-w-0 relative">
<input
ref={inputRef}
type="text"
value={input}
onChange={handleInputChange}
placeholder="type a message..."
className="terminal-input w-full"
disabled={!activeChannelId || slowmodeRemaining > 0}
/>
{mentionQuery !== null && (
<MentionDropdown query={mentionQuery} members={members} onSelect={handleMentionSelect} />
)}
</div>
<button
type="button"
onClick={() => setShowGifPicker((prev) => !prev)}
disabled={!activeChannelId}
className="text-gb-fg-f hover:text-gb-orange font-mono text-sm select-none disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
title="Toggle GIF picker"
>
[GIF]
</button>
</form>
</div>
<button
type="button"
onClick={() => setShowGifPicker((prev) => !prev)}
disabled={!activeChannelId}
className="text-gb-fg-f hover:text-gb-orange font-mono text-sm select-none disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
title="Toggle GIF picker"
>
[GIF]
</button>
</form>
{activeThread && (
<ThreadPanel threadId={activeThread.id} threadName={activeThread.name} onClose={() => setActiveThread(null)} />
)}
</div>
</div>
);
}
+69
View File
@@ -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>
);
}
+88
View File
@@ -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">&lt;{m.author_username}&gt;</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>
);
}
+116
View File
@@ -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] } };
}),
}));