239 lines
7.3 KiB
TypeScript
239 lines
7.3 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, Users } 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, 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 [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>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<main className="flex-1 max-w-2xl w-full mx-auto px-4 sm:px-6 py-6 space-y-6">
|
|
<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>
|
|
{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 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>
|
|
) : items.length === 0 ? (
|
|
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">
|
|
{items.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>
|
|
);
|
|
}
|