Files
dumpsterChat/web/src/stores/thread.ts
T
hobokenchicken cb4df31f43 fix: DM message ordering, consolidate input toolbar, add rich text/WYSIWYG, file upload with drag-drop
- 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)
2026-07-06 17:33:20 +00:00

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