feat: chat
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user