"use client"; /** * /chats — the chat inbox, split into two tabs: * * • "Chats" — DMs and groups. * • "Kautilya" — threads with the AI study companion. The companion itself is * still coming soon, so the tab renders its own empty state. * * Both tabs read the same endpoint with a different `tab` filter: server-side * they are one pipeline over `conversations.kind`, not two implementations. * * New messages arrive as realtime `chat_activity` and highlight the affected * row in place. They deliberately never reach the notification tray. */ import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { MessageCircle, Search, Users, X } from "lucide-react"; import { useAuth } from "@/components/auth/auth-provider"; import { useAuthGate } from "@/components/auth/auth-gate"; import { useNotifications } from "@/components/notifications/notifications-provider"; import { KautilyaIcon } from "@/components/selection-toolbar/shared"; import { ChatListItem } from "@/components/chat/chat-list-item"; import { NewGroupDialog } from "@/components/chat/new-group-dialog"; import { chat, conversationTitle, type InboxItem, type InboxTab, } from "@/lib/chat"; import { cn } from "@/lib/utils"; /** How long a row stays highlighted after a message lands. */ const HIGHLIGHT_MS = 4000; export default function ChatsPage() { const { user, loading } = useAuth(); const { requireAuth } = useAuthGate(); const { lastChatActivity } = useNotifications(); const router = useRouter(); const [tab, setTab] = useState("chats"); const [searching, setSearching] = useState(false); const [query, setQuery] = useState(""); // Closing clears the term too: leaving a filter applied behind a collapsed // field is how a chat goes missing and looks like a bug. const closeSearch = useCallback(() => { setSearching(false); setQuery(""); }, []); const [items, setItems] = useState([]); const [state, setState] = useState<"loading" | "ready" | "error">("loading"); const [highlighted, setHighlighted] = useState>(new Set()); const [creatingGroup, setCreatingGroup] = useState(false); const timers = useRef>>(new Map()); // Auth gate — same pattern as /notifications: offer the modal in place, and // only leave the page if they dismiss it. 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]); const load = useCallback( (which: InboxTab) => { if (!user) return; setState("loading"); chat .inbox(which) .then((rows) => { setItems(rows ?? []); setState("ready"); }) .catch(() => setState("error")); }, [user], ); useEffect(() => { load(tab); }, [tab, load]); // Clear pending highlight timers on unmount so they can't fire into an // unmounted tree. useEffect(() => { const pending = timers.current; return () => { pending.forEach(clearTimeout); pending.clear(); }; }, []); // A message landed: refresh the list so its preview, order and unread count // are authoritative, and flash the row it belongs to. useEffect(() => { if (!lastChatActivity) return; const { conversation_id } = lastChatActivity; load(tab); setHighlighted((prev) => new Set(prev).add(conversation_id)); const existing = timers.current.get(conversation_id); if (existing) clearTimeout(existing); timers.current.set( conversation_id, setTimeout(() => { setHighlighted((prev) => { const next = new Set(prev); next.delete(conversation_id); return next; }); timers.current.delete(conversation_id); }, HIGHLIGHT_MS), ); // `tab`/`load` are intentionally excluded: this should run when activity // arrives, not when the tab changes (which reloads on its own). // eslint-disable-next-line react-hooks/exhaustive-deps }, [lastChatActivity]); if (loading || !user) { return (

Loading…

); } // Filtered here rather than on the server: the inbox is already loaded in // full, so this is instant and keeps working offline. Matches the thread's // name and, for a DM, the peer's handle — the two things you actually // remember someone by. const term = query.trim().toLowerCase(); const visible = term ? items.filter((item) => { const title = conversationTitle(item).toLowerCase(); const handle = item.peer?.handle?.toLowerCase() ?? ""; return title.includes(term) || handle.includes(term); }) : items; return (
{searching ? ( // The search field REPLACES the header rather than sitting under it: // the title and New group are not useful while filtering, and stacking // a second row would push the list down every time it opens.
setQuery(e.target.value)} placeholder="Search chats" className="flex-1 bg-transparent text-sm outline-none pointer-coarse:text-base placeholder:text-muted-foreground" />
) : (

Chats

{tab === "chats" && ( )}
)}
setTab("chats")}> Chats setTab("kautilya")} > Kautilya
{state === "error" ? (

Could not load your chats. Please try again.

) : state === "loading" ? (

Loading…

) : visible.length === 0 ? ( // A search that matches nothing is NOT an empty inbox: saying "no // chats yet" there reads as data loss rather than a narrow filter. term ? ( } title="No matches" text={`Nothing in ${tab === "kautilya" ? "Kautilya" : "your chats"} matches “${query.trim()}”.`} /> ) : tab === "kautilya" ? ( } title="Kautilya is coming soon" text="An AI study companion tuned on Śrīla Prabhupāda's books, letters, and lectures." /> ) : ( } title="No chats yet" text="Open someone's profile to start a conversation." /> ) ) : (
    {visible.map((item) => ( ))}
)} {creatingGroup && ( setCreatingGroup(false)} /> )}
); } function TabButton({ active, onClick, children, }: { active: boolean; onClick: () => void; children: React.ReactNode; }) { return ( ); } function Empty({ icon, title, text, }: { icon: React.ReactNode; title: string; text: string; }) { return (
{icon}

{title}

{text}

); }