Files
spsays-training/app/(app)/chats/page.tsx
T
2026-08-30 10:57:28 +05:30

308 lines
10 KiB
TypeScript

"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<InboxTab>("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<InboxItem[]>([]);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
const [highlighted, setHighlighted] = useState<Set<string>>(new Set());
const [creatingGroup, setCreatingGroup] = useState(false);
const timers = useRef<Map<string, ReturnType<typeof setTimeout>>>(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 (
<main className="flex-1 max-w-2xl w-full mx-auto px-4 sm:px-6 py-6">
<p className="text-sm text-muted-foreground">Loading…</p>
</main>
);
}
// 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 (
<main className="flex-1 max-w-2xl w-full mx-auto px-4 sm:px-6 py-6 space-y-6">
{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.
<div className="flex items-center gap-2 rounded-lg border border-border bg-muted/40 px-3 py-2">
<Search className="size-4 shrink-0 text-muted-foreground" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search chats"
className="flex-1 bg-transparent text-sm outline-none pointer-coarse:text-base placeholder:text-muted-foreground"
/>
<button
type="button"
onClick={closeSearch}
aria-label="Close search"
className="shrink-0 rounded-md p-1 text-muted-foreground hover:text-foreground"
>
<X className="size-4" />
</button>
</div>
) : (
<div className="flex items-center justify-between gap-3">
<h1 className="font-[family-name:var(--font-title)] text-3xl font-semibold text-foreground">
Chats
</h1>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setSearching(true)}
aria-label="Search chats"
className="inline-flex size-9 items-center justify-center rounded-lg border border-border text-foreground hover:bg-muted/60"
>
<Search className="size-4" />
</button>
{tab === "chats" && (
<button
type="button"
onClick={() => setCreatingGroup(true)}
className="inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-foreground hover:bg-muted/60"
>
<Users className="size-4" /> New group
</button>
)}
</div>
</div>
)}
<div className="flex gap-1 rounded-lg bg-muted p-1">
<TabButton active={tab === "chats"} onClick={() => setTab("chats")}>
Chats
</TabButton>
<TabButton
active={tab === "kautilya"}
onClick={() => setTab("kautilya")}
>
Kautilya
</TabButton>
</div>
{state === "error" ? (
<p className="text-sm text-destructive">
Could not load your chats. Please try again.
</p>
) : state === "loading" ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : 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 ? (
<Empty
icon={<Search className="size-8" />}
title="No matches"
text={`Nothing in ${tab === "kautilya" ? "Kautilya" : "your chats"} matches “${query.trim()}”.`}
/>
) : tab === "kautilya" ? (
<Empty
icon={<KautilyaIcon className="size-8" />}
title="Kautilya is coming soon"
text="An AI study companion tuned on Śrīla Prabhupāda's books, letters, and lectures."
/>
) : (
<Empty
icon={<MessageCircle className="size-8" />}
title="No chats yet"
text="Open someone's profile to start a conversation."
/>
)
) : (
<ul className="divide-y divide-border rounded-2xl border border-border overflow-hidden">
{visible.map((item) => (
<ChatListItem
key={item.id}
item={item}
selfId={user.id}
highlighted={highlighted.has(item.id)}
/>
))}
</ul>
)}
{creatingGroup && (
<NewGroupDialog onClose={() => setCreatingGroup(false)} />
)}
</main>
);
}
function TabButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex-1 inline-flex items-center justify-center gap-2 h-9 rounded-md text-sm font-medium transition-colors",
active
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{children}
</button>
);
}
function Empty({
icon,
title,
text,
}: {
icon: React.ReactNode;
title: string;
text: string;
}) {
return (
<div className="flex flex-col items-center gap-3 py-16 text-center">
<span
aria-hidden
className="inline-flex size-14 items-center justify-center rounded-full bg-primary/10 text-primary"
>
{icon}
</span>
<p className="text-base font-medium text-foreground">{title}</p>
<p className="max-w-sm text-sm text-muted-foreground">{text}</p>
</div>
);
}