cb4df31f43
- Fix DM backend ListMessages to use DESC + reverse (match channel handler) - Remove spurious .reverse() from frontend message/conversation stores - Create shared MessageInput component with Slack-style single toolbar row - Add file upload via + button with progress bar and drag-and-drop - Add markdown/rich text toggle with full WYSIWYG block formatting (lists, blockquotes, links, headings, code blocks) - Add frontend+backend security for file uploads (extension + content-type guards)
161 lines
5.4 KiB
TypeScript
161 lines
5.4 KiB
TypeScript
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;
|
|
tag_ids?: string[];
|
|
}
|
|
|
|
export interface ForumTag {
|
|
id: string;
|
|
tag_id: string;
|
|
name: string;
|
|
emoji: string | null;
|
|
color: string | null;
|
|
}
|
|
|
|
export interface CreateThreadData {
|
|
name: string;
|
|
message_id?: string;
|
|
tag_ids?: string[];
|
|
}
|
|
|
|
interface ThreadState {
|
|
threadsByParent: Record<string, Thread[]>;
|
|
tagsByForum: Record<string, ForumTag[]>;
|
|
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;
|
|
fetchForumTags: (forumChannelId: string) => Promise<void>;
|
|
createForumTag: (forumChannelId: string, data: { name: string; emoji?: string; color?: string }) => Promise<ForumTag>;
|
|
deleteForumTag: (tagId: string) => Promise<void>;
|
|
}
|
|
|
|
export const useThreadStore = create<ThreadState>((set) => ({
|
|
threadsByParent: {},
|
|
tagsByForum: {},
|
|
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 raw = await api.get<Message[]>(`/channels/${threadId}/messages${params}`);
|
|
// ponytail: API returns DESC (newest first), reverse to oldest-first
|
|
const msgs = (Array.isArray(raw) ? raw : []).reverse();
|
|
set((state) => ({
|
|
messagesByThread: { ...state.messagesByThread, [threadId]: msgs },
|
|
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] } };
|
|
}),
|
|
|
|
fetchForumTags: async (forumChannelId) => {
|
|
const tags = await api.get<ForumTag[]>(`/channels/${forumChannelId}/forum-tags`);
|
|
set((state) => ({
|
|
tagsByForum: { ...state.tagsByForum, [forumChannelId]: Array.isArray(tags) ? tags : [] },
|
|
}));
|
|
},
|
|
|
|
createForumTag: async (forumChannelId, data) => {
|
|
const tag = await api.post<ForumTag>(`/channels/${forumChannelId}/forum-tags`, data);
|
|
set((state) => {
|
|
const list = state.tagsByForum[forumChannelId] || [];
|
|
return { tagsByForum: { ...state.tagsByForum, [forumChannelId]: [...list, tag] } };
|
|
});
|
|
return tag;
|
|
},
|
|
|
|
deleteForumTag: async (tagId) => {
|
|
await api.delete(`/forum-tags/${tagId}`);
|
|
set((state) => {
|
|
const next: Record<string, ForumTag[]> = {};
|
|
for (const forumId of Object.keys(state.tagsByForum)) {
|
|
next[forumId] = state.tagsByForum[forumId].filter((t) => t.id !== tagId && t.tag_id !== tagId);
|
|
}
|
|
return { tagsByForum: next };
|
|
});
|
|
},
|
|
}));
|