import { useEffect } from "react"; import useSWR, { useSWRConfig } from "swr"; import { useAction } from "@/hooks/use-action"; import { messagesApi, voiceApi } from "@/lib/api"; import type { AttachmentRecord, Channel, MessageRecord } from "@/lib/types"; import type { WsHook } from "@/lib/ws-hook"; // ── Query keys factory ─────────────────────────── const msgKeys = { list: (guildId: string, channelId?: string) => ["messages", guildId, channelId ?? "__all__"] as const, images: (guildId: string) => ["messages-images", guildId] as const, review: (channelId?: string) => ["messages-review", channelId ?? "__all__"] as const, detail: (id: string) => ["message-detail", id] as const, search: (query: string) => ["messages-search", query] as const, }; type MessagePage = { data: MessageRecord[]; nextCursor: string | null }; /** * Single source of truth for the paginated message list. Both useMessages and * useMessagesHasMore derive from this one SWR key, so the cursor probe no * longer triggers a duplicate API call. */ function useMessagesPage( guildId: string, channelId?: string, initialPage?: MessagePage, ) { const key = guildId ? msgKeys.list(guildId, channelId) : null; return useSWR( key, () => messagesApi.list(guildId, 50, channelId || undefined), { fallbackData: initialPage }, ); } // ── Messages list (paginated, cursor-based) ────── export function useMessages( guildId: string, channelId?: string, initialPage?: MessagePage, ) { const page = useMessagesPage(guildId, channelId, initialPage); return { ...page, data: page.data?.data, refetch: () => page.mutate(), }; } export function useMessagesHasMore(guildId: string, channelId?: string) { const page = useMessagesPage(guildId, channelId); return { data: { cursor: page.data?.nextCursor ?? null, hasMore: page.data ? page.data.nextCursor !== null : undefined, }, }; } export function useLoadMore() { const { mutate } = useSWRConfig(); return useAction( async ({ guildId, channelId, cursor, }: { guildId: string; channelId?: string; cursor: string; }) => { const result = await messagesApi.list( guildId, 50, channelId || undefined, cursor, ); const key = msgKeys.list(guildId, channelId); await mutate( key, (old: MessagePage | undefined): MessagePage | undefined => old ? { data: [...old.data, ...result.data], nextCursor: result.nextCursor, } : result, { revalidate: false }, ); return result; }, ); } // ── Channels list ──────────────────────────────── export function useTextChannels(guildId: string) { return useSWR(guildId ? ["text-channels", guildId] : null, () => voiceApi.getTextChannels(guildId), ); } // ── Images ─────────────────────────────────────── export function useImages(guildId: string) { return useSWR( guildId ? msgKeys.images(guildId) : null, async () => { const result = await messagesApi.getImages(guildId, 50); return result.data; }, ); } // ── Review ─────────────────────────────────────── export function useReview(channelId?: string) { return useSWR( msgKeys.review(channelId), async () => { const result = await messagesApi.getReview(50, channelId || undefined); return result.results; }, { refreshInterval: 15_000, }, ); } // ── Detail ─────────────────────────────────────── export function useMessageDetail(id: string | null) { const detail = useSWR(id ? msgKeys.detail(id) : null, () => messagesApi.getDetail(id ?? ""), ); const channelId = id ? detail.data?.channel_id : undefined; const attachments = useSWR( channelId ? [...msgKeys.detail(id ?? ""), "attachments"] : null, async () => { // Guard: only fetch when we actually have a channel id — a revalidate // can race the detail load and see detail.data === undefined. const cid = detail.data?.channel_id; if (!cid) return []; // messageId filter: attachment list must show only this message's // images, not the latest images from everyone in the channel. const res = await messagesApi.getAttachments( cid, 10, undefined, id ?? "", ); return res.data; }, ); return { message: detail.data ?? null, attachments: attachments.data ?? [], loading: detail.isLoading || attachments.isLoading, error: detail.error, }; } // ── Search ─────────────────────────────────────── export function useMessageSearch(query: string, enabled: boolean) { return useSWR( enabled && query.trim().length >= 2 ? msgKeys.search(query) : null, async () => { const res = await messagesApi.search(query, 50); return res.results; }, ); } // ── WS sync helpers ────────────────────────────── export function useMessagesWsSync(ws: WsHook, guildId: string) { const { mutate } = useSWRConfig(); useEffect(() => { if (!guildId) return; // Patch every message-list key for this guild (all channels + "**filtered**"). // The updater receives the SWR key so we can honor its channel filter: // a live `message_created`/updated for channel B must NOT be prepended to // a list that is filtered down to channel A. const patchLists = ( matcher: (key: unknown, msg: { channel_id?: string }) => boolean, updater: (old: MessagePage | undefined) => MessagePage | undefined, msg: { channel_id?: string }, ) => { void mutate( (key) => Array.isArray(key) && key[0] === "messages" && key[1] === guildId && matcher(key, msg), updater, { revalidate: false }, ); }; // A list key [messages, guildId, channelId] is "channel N" when channelId // is a non-empty string and matches the incoming message; "__all__" (or // any non-channel) lists accept every message of the guild. const matchesFilter = (key: unknown[], msg: { channel_id?: string }) => { const channelId = key[2] as string | undefined; if (!channelId || channelId === "__all__") return true; return msg.channel_id === channelId; }; const unsub1 = ws.on("message_created", (data) => { const msg = data as MessageRecord; patchLists( (_k, m) => matchesFilter(_k as unknown[], m), (old) => (old ? { ...old, data: [msg, ...old.data] } : old), msg, ); }); const unsub2 = ws.on("message_updated", (data) => { const msg = data as Partial & { id: string }; // The gateway broadcasts a PARTIAL update ({ id, edited_content, // edited_at, ... }) — merge it over the existing record instead of // replacing it, or the card would lose username/content/channel/etc. patchLists( (_k, m) => (m as Partial).channel_id === undefined || matchesFilter(_k as unknown[], m), (old) => old ? { ...old, data: old.data.map((m) => m.id === msg.id ? { ...m, ...msg } : m, ), } : old, msg, ); void mutate( msgKeys.detail(msg.id), (old: MessageRecord | undefined) => (old ? { ...old, ...msg } : old), { revalidate: false }, ); }); const unsub3 = ws.on("message_deleted", (data) => { const { id } = data as { id: string }; patchLists( () => true, (old) => old ? { ...old, data: old.data.filter((m) => m.id !== id) } : old, { channel_id: undefined }, ); }); const unsub4 = ws.on("message_analyzed", (data) => { const msg = data as MessageRecord; // message_analyzed carries the FULL record — replace is fine. patchLists( (_k, m) => matchesFilter(_k as unknown[], m), (old) => old ? { ...old, data: old.data.map((m) => (m.id === msg.id ? msg : m)) } : old, msg, ); void mutate(msgKeys.detail(msg.id), msg, { revalidate: false }); }); return () => { unsub1(); unsub2(); unsub3(); unsub4(); }; }, [ws, guildId, mutate]); }