"use client"; /** * /chats/[id] — a single thread. * * History pages upward from the newest message using `before_seq` (a keyset * cursor, not an offset), so paging stays stable while new messages keep * arriving at the top. * * Live updates come from the thread's own private channel. Subscribing is * gated by the BFF: it only signs `private-conversation.{id}` for an actual * participant, so an unauthorised subscription is refused at the server. * * Composer, attachments and the message context menu land in later phases — * this screen is read + receipts. */ import { useCallback, useEffect, useRef, useState } from "react"; import { useParams, useRouter } from "next/navigation"; import Link from "next/link"; import { ArrowLeft, BellOff } from "lucide-react"; import { useAuth } from "@/components/auth/auth-provider"; import { useAuthGate } from "@/components/auth/auth-gate"; import { UserAvatar } from "@/components/ui/user-avatar"; import { KautilyaIcon } from "@/components/selection-toolbar/shared"; import { MessageList } from "@/components/chat/message-list"; import { ChatComposer } from "@/components/chat/chat-composer"; import { MessageMenu, type MenuPoint, } from "@/components/chat/message-menu"; import { ForwardDialog } from "@/components/chat/forward-dialog"; import { toast } from "sonner"; import { channelAuthorizer } from "@/lib/realtime"; import { cn } from "@/lib/utils"; import { useVisualViewport } from "@/lib/use-visual-viewport"; import { chat, conversationTitle, groupAvatarUrl, isMuted, senderName, type ChatMessage, type ConversationDetail, type Receipts, type UploadedAttachment, } from "@/lib/chat"; const PAGE_SIZE = 30; export default function ChatThreadPage() { const { id } = useParams<{ id: string }>(); const { user, loading } = useAuth(); const { requireAuth } = useAuthGate(); const router = useRouter(); const [meta, setMeta] = useState(null); const [messages, setMessages] = useState([]); const [receipts, setReceipts] = useState(null); const [hasMore, setHasMore] = useState(true); const [loadingMore, setLoadingMore] = useState(false); const [state, setState] = useState<"loading" | "ready" | "error">("loading"); const [replyTo, setReplyTo] = useState(null); const [menu, setMenu] = useState<{ message: ChatMessage; point: MenuPoint } | null>(null); const [forwarding, setForwarding] = useState(null); const [starredIds, setStarredIds] = useState>(new Set()); // While the keyboard is up the thread is sized from the visual viewport // instead of dvh — see useVisualViewport for why dvh can't do this on iOS. // Declared with the other hooks: the early return below means anything // placed after it would run conditionally. const viewport = useVisualViewport(); const keyboardOpen = viewport?.keyboardOpen ?? false; // Guards against two loads racing (scroll handler + the short-page effect). const loadingRef = useRef(false); useEffect(() => { if (loading || user) return; let cancelled = false; (async () => { const ok = await requireAuth(); if (!ok && !cancelled) router.replace("/"); })(); return () => { cancelled = true; }; }, [loading, user, requireAuth, router]); /** Newest page first; messages are held ascending for rendering. */ const loadNewest = useCallback(async () => { try { const page = await chat.messages(id, 0, PAGE_SIZE); setMessages([...page].reverse()); setHasMore(page.length === PAGE_SIZE); setState("ready"); } catch { setState("error"); } }, [id]); const loadOlder = useCallback(async () => { if (loadingRef.current || !hasMore) return; const oldest = messages[0]?.seq; if (!oldest) return; loadingRef.current = true; setLoadingMore(true); try { const page = await chat.messages(id, oldest, PAGE_SIZE); if (page.length > 0) { setMessages((prev) => [...[...page].reverse(), ...prev]); } setHasMore(page.length === PAGE_SIZE); } catch { // Leave hasMore alone — a transient failure shouldn't permanently end // history; the next scroll retries. } finally { loadingRef.current = false; setLoadingMore(false); } }, [id, hasMore, messages]); // One request for the thread: title, avatar, mute state, and the // participant list the reply banner and quoted lines need to name people. useEffect(() => { if (!user) return; let cancelled = false; chat .detail(id) .then((d) => { if (!cancelled) setMeta(d); }) .catch(() => { /* the message list surfaces the failure */ }); return () => { cancelled = true; }; }, [id, user]); useEffect(() => { if (!user) return; void loadNewest(); chat.receipts(id).then(setReceipts).catch(() => {}); }, [id, user, loadNewest]); /** * Send optimistically: the bubble appears immediately keyed by a * client-generated id, then the server's version replaces it. That id is * also what makes a retry idempotent server-side, so a flaky connection * can't produce two bubbles. */ const sendMessage = useCallback( ( body: string, replyToId?: string, previewUrl?: string, attachments?: UploadedAttachment[], ) => { const clientMsgId = crypto.randomUUID(); const optimistic: ChatMessage = { id: clientMsgId, conversation_id: id, seq: Number.MAX_SAFE_INTEGER, sender_id: user?.id ?? null, kind: "text", body, entities: [], client_msg_id: clientMsgId, reply_to_id: replyToId ?? null, attachments: [], created_at: new Date().toISOString(), pending: true, }; setMessages((prev) => [...prev, optimistic]); chat .send(id, body, { clientMsgId, replyToId, previewUrl, attachments }) .then((saved) => { setMessages((prev) => { // Drop the placeholder and any realtime echo of the same message. const without = prev.filter( (m) => m.id !== clientMsgId && m.id !== saved.id, ); return [...without, saved]; }); }) .catch(() => { // Keep the bubble and its text — discarding it loses what the user // wrote. Marking it failed offers a retry instead. setMessages((prev) => prev.map((m) => m.id === clientMsgId ? { ...m, pending: false, failed: true } : m, ), ); }); }, [id, user?.id], ); /** * Retry a failed send, REUSING its client_msg_id. If the original actually * reached the server and only the response was lost, the server returns the * stored message instead of writing a second one. */ const retryMessage = useCallback( (failed: ChatMessage) => { const clientMsgId = failed.client_msg_id ?? failed.id; setMessages((prev) => prev.map((m) => m.id === failed.id ? { ...m, pending: true, failed: false } : m, ), ); chat .send(id, failed.body, { clientMsgId, replyToId: failed.reply_to_id ?? undefined, }) .then((saved) => { setMessages((prev) => [ ...prev.filter((m) => m.id !== failed.id && m.id !== saved.id), saved, ]); }) .catch(() => { setMessages((prev) => prev.map((m) => m.id === failed.id ? { ...m, pending: false, failed: true } : m, ), ); }); }, [id], ); // Which messages the caller has starred, so bubbles can show it. const loadStars = useCallback(() => { chat .starred(id) .then((rows) => setStarredIds(new Set((rows ?? []).map((m) => m.id)))) .catch(() => {}); }, [id]); useEffect(() => { if (!user) return; loadStars(); }, [user, loadStars]); const toggleStar = useCallback( async (m: ChatMessage) => { const starred = starredIds.has(m.id); try { await chat.setStar(id, m.id, !starred); setStarredIds((prev) => { const next = new Set(prev); if (starred) next.delete(m.id); else next.add(m.id); return next; }); } catch { toast.error("Could not update the star"); } }, [id, starredIds], ); const copyMessage = useCallback((m: ChatMessage) => { // The stored body is clean text — no markers, no markup — so this is // exactly what the reader sees. navigator.clipboard .writeText(m.body) .then(() => toast.success("Copied")) .catch(() => toast.error("Could not copy")); }, []); const deleteMessage = useCallback( async (m: ChatMessage, scope: "me" | "everyone") => { if ( scope === "everyone" && !window.confirm("Delete this message for everyone?") ) { return; } try { await chat.deleteMessage(id, m.id, scope); if (scope === "me") { setMessages((prev) => prev.filter((x) => x.id !== m.id)); } else { // Tombstone in place — the realtime event does the same for others. setMessages((prev) => prev.map((x) => x.id === m.id ? { ...x, deleted_at: new Date().toISOString(), body: "", attachments: [] } : x, ), ); } } catch { toast.error("Could not delete the message"); } }, [id], ); const reportMessage = useCallback( async (m: ChatMessage) => { const reason = window.prompt( "Report this message. What's wrong with it?", "", ); if (reason === null) return; try { await chat.report(id, m.id, "abuse", reason); toast.success("Reported. Our moderators will review it."); } catch { toast.error("Could not send the report"); } }, [id], ); /** Mark everything visible as read, so the sender's ticks turn blue. */ const markRead = useCallback( (seq: number) => { if (!seq) return; chat.markRead(id, seq).catch(() => {}); }, [id], ); useEffect(() => { // Skip the optimistic sentinel — only a real server seq is worth acking. const real = messages.filter((m) => !m.pending && !m.failed); const newest = real[real.length - 1]?.seq; if (newest) markRead(newest); }, [messages, markRead]); // Live thread updates. useEffect(() => { if (!user) return; const key = process.env.NEXT_PUBLIC_SOKETI_KEY; const host = process.env.NEXT_PUBLIC_SOKETI_HOST; if (!key || !host) return; const port = Number(process.env.NEXT_PUBLIC_SOKETI_PORT || "443"); const forceTLS = (process.env.NEXT_PUBLIC_SOKETI_FORCE_TLS ?? "true") !== "false"; const channelName = `private-conversation.${id}`; let cancelled = false; let cleanup: (() => void) | undefined; void import("pusher-js").then(({ default: Pusher }) => { if (cancelled) return; const pusher = new Pusher(key, { wsHost: host, wsPort: port, wssPort: port, forceTLS, enabledTransports: ["ws", "wss"], disableStats: true, cluster: "", channelAuthorization: { customHandler: channelAuthorizer }, }); const channel = pusher.subscribe(channelName); channel.bind("message", (m: ChatMessage) => { setMessages((prev) => { // The echo of our own send may arrive before or after the HTTP // response, so match on both ids: the server's and the one we // generated for the optimistic bubble. if ( prev.some( (x) => x.id === m.id || (!!m.client_msg_id && x.id === m.client_msg_id), ) ) { return prev.map((x) => m.client_msg_id && x.id === m.client_msg_id ? m : x, ); } return [...prev, m]; }); }); channel.bind("message_deleted", ({ message_id }: { message_id: string }) => { setMessages((prev) => prev.map((x) => x.id === message_id ? { ...x, deleted_at: new Date().toISOString(), body: "", attachments: [] } : x, ), ); }); channel.bind("receipt", () => { chat.receipts(id).then(setReceipts).catch(() => {}); }); cleanup = () => { channel.unbind_all(); pusher.unsubscribe(channelName); pusher.disconnect(); }; }); return () => { cancelled = true; cleanup?.(); }; }, [id, user]); if (loading || !user) { return (

Loading…

); } const nameFor = (senderId: string | null | undefined) => senderName(senderId, meta?.participants ?? [], user.id); const title = meta ? conversationTitle(meta) : "Chat"; const muted = meta ? isMuted(meta) : false; return (
{meta?.kind === "ai" ? ( ) : ( )}

{title}

{muted && ( )}
{meta?.peer?.handle && (

@{meta.peer.handle}

)}
{state === "error" ? (

Could not load this conversation.

) : state === "loading" ? (

Loading…

) : ( setMenu({ message, point })} starredIds={starredIds} onRetry={retryMessage} nameFor={nameFor} /> )} {meta?.kind === "ai" ? (
Kautilya is coming soon.
) : ( setReplyTo(null)} onSend={sendMessage} disabled={state !== "ready"} /> )} {menu && ( setMenu(null)} actions={{ onReply: () => setReplyTo(menu.message), onCopy: () => copyMessage(menu.message), onStar: () => void toggleStar(menu.message), onForward: () => setForwarding(menu.message), onDeleteForMe: () => void deleteMessage(menu.message, "me"), // Only the sender may erase a message for everyone. onDeleteForEveryone: menu.message.sender_id === user.id ? () => void deleteMessage(menu.message, "everyone") : undefined, // Reporting your own message is meaningless. onReport: menu.message.sender_id === user.id ? undefined : () => void reportMessage(menu.message), }} /> )} {forwarding && ( setForwarding(null)} /> )}
); }