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
+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] } };
}),
}));