"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 { ReactionsDrawer } from "@/components/chat/reactions-drawer"; import { toast } from "sonner"; import { channelAuthorizer } from "@/lib/realtime"; import { cn } from "@/lib/utils"; import { useVisualViewport } from "@/lib/use-visual-viewport"; import { useNotifications } from "@/components/notifications/notifications-provider"; import { chat, conversationTitle, groupAvatarUrl, isMuted, senderName, senderPeer, type ChatMessage, type ConversationDetail, type Receipts, type UploadedAttachment, } from "@/lib/chat"; /** How long a typing indicator survives its last ping. */ const TYPING_TTL_MS = 5000; /** How often we tell the thread we are typing, while typing. */ const TYPING_PING_MS = 3000; 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()); // user id → when they last typed. Kept as timestamps rather than a boolean // so the indicator expires on its own: there is no "stopped typing" event, // and inventing one would still be lost on a dropped connection. const [typingAt, setTypingAt] = useState>({}); // 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 { refreshChatUnread } = useNotifications(); 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[], stickerKey?: string, ) => { 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: [], reactions: [], created_at: new Date().toISOString(), pending: true, }; setMessages((prev) => [...prev, optimistic]); chat .send(id, body, { clientMsgId, replyToId, previewUrl, attachments, stickerKey, }) .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. */ // Expire stale typers. A ticking clock rather than a timer per person: one // interval regardless of how many are in the room, and it stops entirely // once nobody is typing. const [now, setNow] = useState(0); useEffect(() => { if (Object.keys(typingAt).length === 0) return; const t = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(t); }, [typingAt]); // Ids only. Resolving names here would call nameFor before it is declared // further down — a temporal-dead-zone crash that TypeScript does not catch, // because the call would sit inside a .map callback. // `now || Date.now()` keeps the first render correct while still depending // on the ticking clock above. const typingIds = Object.entries(typingAt) .filter(([, at]) => (now || Date.now()) - at < TYPING_TTL_MS) .map(([uid]) => uid); /** Tell the thread we are composing, at most once every few seconds. */ const typingSentAt = useRef(0); const notifyTyping = useCallback(() => { const t = Date.now(); if (t - typingSentAt.current < TYPING_PING_MS) return; typingSentAt.current = t; chat.typing(id).catch(() => {}); }, [id]); /** Replace one message in place, after its reactions changed. */ const refreshMessage = useCallback( async (messageId: string) => { try { const fresh = await chat.message(id, messageId); setMessages((prev) => prev.map((m) => (m.id === messageId ? { ...m, ...fresh } : m)), ); } catch { /* a stale chip is not worth an error surface */ } }, [id], ); /** * Set or toggle a reaction. Picking the emoji you already chose removes it, * which is what makes the chip and the double-tap heart both behave as * toggles. */ const react = useCallback( async (message: ChatMessage, emoji: string) => { const mine = message.reactions?.find((r) => r.mine)?.emoji; try { if (mine === emoji) await chat.unreact(id, message.id); else await chat.react(id, message.id, emoji); // The server publishes a `reaction` event, but the actor is on the // same channel and would otherwise wait a round trip to see it. await refreshMessage(message.id); } catch { toast.error("Could not react"); } }, [id, refreshMessage], ); // Which message's reaction list is open, if any. const [reactionsFor, setReactionsFor] = useState(null); /** * The trail does one of two things, never both: retract your reaction if * you have one, otherwise show who reacted. Rolling both into one control * keeps the bubble uncluttered, at the cost that seeing the list means * removing your own reaction first. */ const onTrailClick = useCallback( (message: ChatMessage) => { const mine = message.reactions?.find((r) => r.mine)?.emoji; if (mine) void react(message, mine); else setReactionsFor(message.id); }, [react], ); const markRead = useCallback( (seq: number) => { if (!seq) return; // Refresh the nav badge afterwards: without this it stays lit until the // next incoming message, since nothing else tells it this thread was // read. chat .markRead(id, seq) .then(refreshChatUnread) .catch(() => {}); }, [id, refreshChatUnread], ); 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("reaction", ({ message_id }: { message_id: string }) => { void refreshMessage(message_id); }); channel.bind("typing", ({ user_id }: { user_id: string }) => { // Ignore your own keystrokes echoing back off the channel. if (!user_id || user_id === user?.id) return; setTypingAt((prev) => ({ ...prev, [user_id]: Date.now() })); }); channel.bind("receipt", () => { chat.receipts(id).then(setReceipts).catch(() => {}); }); cleanup = () => { channel.unbind_all(); pusher.unsubscribe(channelName); pusher.disconnect(); }; }); return () => { cancelled = true; cleanup?.(); }; }, [id, user, refreshMessage]); if (loading || !user) { return (

Loading…

); } const nameFor = (senderId: string | null | undefined) => senderName(senderId, meta?.participants ?? [], user.id); // Groups need the actual sender, not the conversation's `peer` — `peer` is // only set on DMs, so a group has no single counterpart to read from. const senderFor = (senderId: string | null | undefined) => senderPeer(senderId, meta?.participants ?? []); 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} typingNames={typingIds.map(nameFor).filter(Boolean)} onReact={react} onTrailClick={onTrailClick} /> )} {meta?.kind === "ai" ? (
Kautilya is coming soon.
) : ( setReplyTo(null)} onSend={sendMessage} onTyping={notifyTyping} disabled={state !== "ready"} /> )} {menu && ( r.mine)?.emoji} onReact={(emoji) => react(menu.message, emoji)} onClose={() => 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), }} /> )} {reactionsFor && ( setReactionsFor(null)} /> )} {forwarding && ( setForwarding(null)} /> )}
); }