feat: chat

This commit is contained in:
gosangam
2026-08-29 03:49:58 +05:30
parent 3257efb06a
commit 523b6fa7e8
34 changed files with 5381 additions and 11 deletions
+559
View File
@@ -0,0 +1,559 @@
"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 { toast } from "sonner";
import { channelAuthorizer } from "@/lib/realtime";
import { cn } from "@/lib/utils";
import {
chat,
conversationTitle,
groupAvatarUrl,
isMuted,
senderName,
type ChatMessage,
type ConversationDetail,
type Receipts,
type UploadedAttachment,
} from "@/lib/chat";
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<ConversationDetail | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [receipts, setReceipts] = useState<Receipts | null>(null);
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
const [replyTo, setReplyTo] = useState<ChatMessage | null>(null);
const [menu, setMenu] = useState<{ message: ChatMessage; point: MenuPoint } | null>(null);
const [forwarding, setForwarding] = useState<ChatMessage | null>(null);
const [starredIds, setStarredIds] = useState<Set<string>>(new Set());
// 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[],
) => {
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: [],
created_at: new Date().toISOString(),
pending: true,
};
setMessages((prev) => [...prev, optimistic]);
chat
.send(id, body, { clientMsgId, replyToId, previewUrl, attachments })
.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. */
const markRead = useCallback(
(seq: number) => {
if (!seq) return;
chat.markRead(id, seq).catch(() => {});
},
[id],
);
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("receipt", () => {
chat.receipts(id).then(setReceipts).catch(() => {});
});
cleanup = () => {
channel.unbind_all();
pusher.unsubscribe(channelName);
pusher.disconnect();
};
});
return () => {
cancelled = true;
cleanup?.();
};
}, [id, user]);
if (loading || !user) {
return (
<main className="flex-1 px-4 py-6">
<p className="text-sm text-muted-foreground">Loading…</p>
</main>
);
}
const nameFor = (senderId: string | null | undefined) =>
senderName(senderId, meta?.participants ?? [], user.id);
const title = meta ? conversationTitle(meta) : "Chat";
const muted = meta ? isMuted(meta) : false;
return (
<main
className={cn(
// Match the inbox and profile widths — a thread stretched across a
// desktop monitor gives bubbles an unreadable line length, and the
// eye has to travel between the avatar and the timestamp.
"mx-auto flex w-full max-w-2xl flex-col",
// Side rules so the narrower column reads as a panel rather than
// text floating in space.
"md:border-x md:border-border",
// Fill the viewport MINUS the chrome around it: a sticky h-12/sm:h-14
// header above, and on small screens the h-16 bottom nav the app
// layout reserves with pb-16. Plain 100dvh ignores both and pushes the
// composer below the fold.
// NB: the underscores are load-bearing. Tailwind turns `_` into a
// space, and calc() requires whitespace around a binary `-` — written
// without it the whole declaration is invalid and dropped, which
// collapses this to height:auto.
"h-[calc(100dvh_-_3rem_-_4rem)] sm:h-[calc(100dvh_-_3.5rem_-_4rem)] md:h-[calc(100dvh_-_3.5rem)]",
)}
>
<header className="flex items-center gap-3 border-b border-border px-4 py-3">
<Link
href="/chats"
aria-label="Back to chats"
className="-ml-1 rounded-md p-1 text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-5" />
</Link>
{meta?.kind === "ai" ? (
<span
aria-hidden
className="inline-flex size-9 items-center justify-center rounded-full bg-primary/10 text-primary"
>
<KautilyaIcon className="size-4" />
</span>
) : (
<UserAvatar
src={
meta?.kind === "group"
? groupAvatarUrl(id, meta?.avatar_key)
: meta?.peer?.avatar_url
}
name={title}
className="size-9"
/>
)}
<Link
href={`/chats/${id}/profile`}
className="flex min-w-0 flex-1 flex-col text-left hover:opacity-80"
aria-label="Chat details"
>
<div className="flex items-center gap-2">
<h1 className="truncate text-sm font-semibold text-foreground">
{title}
</h1>
{muted && (
<BellOff
className="size-3.5 text-muted-foreground"
aria-label="Muted"
/>
)}
</div>
{meta?.peer?.handle && (
<p className="truncate text-xs text-muted-foreground">
@{meta.peer.handle}
</p>
)}
</Link>
</header>
{state === "error" ? (
<p className="p-4 text-sm text-destructive">
Could not load this conversation.
</p>
) : state === "loading" ? (
<p className="p-4 text-sm text-muted-foreground">Loading…</p>
) : (
<MessageList
messages={messages}
selfId={user.id}
peer={meta?.peer}
receipts={receipts}
isGroup={meta?.kind === "group"}
hasMore={hasMore}
loadingMore={loadingMore}
onLoadMore={loadOlder}
onReply={setReplyTo}
onMenu={(message, point) => setMenu({ message, point })}
starredIds={starredIds}
onRetry={retryMessage}
nameFor={nameFor}
/>
)}
{meta?.kind === "ai" ? (
<div className="border-t border-border px-4 py-3 text-center text-xs text-muted-foreground">
Kautilya is coming soon.
</div>
) : (
<ChatComposer
conversationId={id}
replyTo={replyTo}
replyToName={nameFor(replyTo?.sender_id)}
onCancelReply={() => setReplyTo(null)}
onSend={sendMessage}
disabled={state !== "ready"}
/>
)}
{menu && (
<MessageMenu
point={menu.point}
starred={starredIds.has(menu.message.id)}
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),
}}
/>
)}
{forwarding && (
<ForwardDialog
message={forwarding}
onClose={() => setForwarding(null)}
/>
)}
</main>
);
}
+455
View File
@@ -0,0 +1,455 @@
"use client";
/**
* /chats/[id]/profile — the chat profile.
*
* Shows who you're talking to and how you're connected, then three tabs over
* everything shared in the thread: media, links, and the messages you starred.
*
* The media and links tabs are one index scan each — `conversation_id` is
* denormalized onto attachments precisely so this screen doesn't join back
* through the message table.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import {
ArrowLeft,
BellOff,
Camera,
Link2,
Settings,
Star,
Images,
Users,
} from "lucide-react";
import { toast } from "sonner";
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 { MessageText } from "@/components/chat/message-text";
import { MessageAttachments } from "@/components/chat/message-attachments";
import { MediaLightbox } from "@/components/chat/media-lightbox";
import { ChatProfileHeader } from "@/components/chat/chat-profile-header";
import { GroupMembersTab } from "@/components/chat/group-members-tab";
import { GroupSettingsTab } from "@/components/chat/group-settings-tab";
import {
attachmentUrl,
chat,
conversationTitle,
groupAvatarUrl,
isGroupAdmin,
type Attachment,
type ChatMessage,
type ConversationDetail,
} from "@/lib/chat";
import { cn } from "@/lib/utils";
type Tab = "members" | "media" | "links" | "starred" | "settings";
/** Tabs that load their own data; the rest render from the thread detail. */
type FetchingTab = "media" | "links" | "starred";
const FETCHING_TABS = new Set<Tab>(["media", "links", "starred"]);
/** Type guard, so narrowing survives the Set lookup. */
function isFetchingTab(t: Tab): t is FetchingTab {
return FETCHING_TABS.has(t);
}
export default function ChatProfilePage() {
const { id } = useParams<{ id: string }>();
const { user, loading } = useAuth();
const { requireAuth } = useAuthGate();
const router = useRouter();
const [meta, setMeta] = useState<ConversationDetail | null>(null);
// Groups open on Members — who is in the room is the first question there.
// DMs have no member list, so they open on Media.
const [tab, setTab] = useState<Tab>("media");
const [items, setItems] = useState<Attachment[]>([]);
const [starred, setStarred] = useState<ChatMessage[]>([]);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
const [lightboxAt, setLightboxAt] = useState<number | null>(null);
// Set once the user picks a tab, so a metadata refresh never overrides them.
const tabChosen = 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]);
const loadMeta = useCallback(() => {
chat
.detail(id)
.then((d) => {
setMeta(d);
// Only on first load: don't yank the user off a tab they chose.
setTab((current) =>
current === "media" && d.kind === "group" && !tabChosen.current
? "members"
: current,
);
})
.catch(() => setState("error"));
}, [id]);
useEffect(() => {
if (!user) return;
loadMeta();
}, [user, loadMeta]);
// Switching tabs sets "loading" from the click handler rather than from the
// effect, so the effect only ever reports a result. It also means the old
// tab's rows are never briefly shown under the new tab's heading.
const changeTab = (next: Tab) => {
if (next === tab) return;
tabChosen.current = true;
if (FETCHING_TABS.has(next)) setState("loading");
setTab(next);
};
useEffect(() => {
if (!user) return;
// Members and Settings render straight from `meta` and fetch nothing, so
// they never enter the loading state to leave (see changeTab).
if (!isFetchingTab(tab)) return;
let cancelled = false;
const request =
tab === "starred"
? chat.starred(id).then((rows) => {
if (!cancelled) setStarred(rows ?? []);
})
: chat.gallery(id, tab).then((rows) => {
if (!cancelled) setItems(rows ?? []);
});
request
.then(() => !cancelled && setState("ready"))
.catch(() => !cancelled && setState("error"));
return () => {
cancelled = true;
};
}, [id, tab, user]);
const unstar = async (messageId: string) => {
try {
await chat.setStar(id, messageId, false);
setStarred((prev) => prev.filter((m) => m.id !== messageId));
} catch {
toast.error("Could not remove the star");
}
};
if (loading || !user) {
return (
<main className="flex-1 px-4 py-6">
<p className="text-sm text-muted-foreground">Loading…</p>
</main>
);
}
const isGroup = meta?.kind === "group";
const title = meta ? conversationTitle(meta) : "Chat";
return (
<main className="flex-1 w-full max-w-2xl mx-auto px-4 sm:px-6 py-6 space-y-6">
<div className="flex items-center gap-3">
<Link
href={`/chats/${id}`}
aria-label="Back to conversation"
className="-ml-1 rounded-md p-1 text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-5" />
</Link>
<h1 className="text-sm font-medium text-muted-foreground">
Chat details
</h1>
</div>
<div className="flex flex-col items-center gap-3 text-center">
{meta?.kind === "ai" ? (
<span
aria-hidden
className="inline-flex size-20 items-center justify-center rounded-full bg-primary/10 text-primary"
>
<KautilyaIcon className="size-9" />
</span>
) : (
<div className="relative">
<UserAvatar
src={
isGroup
? groupAvatarUrl(id, meta?.avatar_key)
: meta?.peer?.avatar_url
}
name={title}
className="size-20"
textClassName="text-xl"
/>
{/* Admins get a hint here; the control itself lives in Settings so
there is only one place that changes the photo. */}
{isGroup && isGroupAdmin(meta?.my_role) && (
<button
type="button"
onClick={() => changeTab("settings")}
aria-label="Change group photo"
className="absolute -bottom-1 -right-1 rounded-full border border-border bg-background p-1.5 text-muted-foreground shadow-sm hover:text-foreground"
>
<Camera className="size-3.5" />
</button>
)}
</div>
)}
<div>
<div className="flex items-center justify-center gap-2">
<h2 className="text-xl font-semibold text-foreground">{title}</h2>
{meta?.muted_until && (
<BellOff
className="size-4 text-muted-foreground"
aria-label="Muted"
/>
)}
</div>
{meta?.peer?.handle && (
<Link
href={`/u/${meta.peer.handle}`}
className="text-sm text-muted-foreground hover:underline"
>
@{meta.peer.handle}
</Link>
)}
</div>
</div>
{meta && !isGroup && (
<ChatProfileHeader
conversationId={id}
detail={meta}
onChanged={loadMeta}
/>
)}
{meta && isGroup && (
<p className="text-center text-sm text-muted-foreground">
{meta.participants.length}{" "}
{meta.participants.length === 1 ? "member" : "members"}
{meta.send_policy === "admins" && " · only admins can send"}
</p>
)}
<div className="flex gap-1 overflow-x-auto rounded-lg bg-muted p-1">
{isGroup && (
<TabButton
active={tab === "members"}
onClick={() => changeTab("members")}
>
<Users className="size-4" /> Members
</TabButton>
)}
<TabButton active={tab === "media"} onClick={() => changeTab("media")}>
<Images className="size-4" /> Media
</TabButton>
<TabButton active={tab === "links"} onClick={() => changeTab("links")}>
<Link2 className="size-4" /> Links
</TabButton>
<TabButton
active={tab === "starred"}
onClick={() => changeTab("starred")}
>
<Star className="size-4" /> Starred
</TabButton>
{isGroup && (
<TabButton
active={tab === "settings"}
onClick={() => changeTab("settings")}
>
<Settings className="size-4" /> Settings
</TabButton>
)}
</div>
{state === "error" ? (
<p className="text-sm text-destructive">Could not load this tab.</p>
) : tab === "members" && meta ? (
<GroupMembersTab
conversationId={id}
detail={meta}
selfId={user.id}
onChanged={loadMeta}
/>
) : tab === "settings" && meta ? (
<GroupSettingsTab
conversationId={id}
detail={meta}
selfHandle={user.handle}
onChanged={loadMeta}
/>
) : state === "loading" ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : tab === "starred" ? (
starred.length === 0 ? (
<Empty text="No starred messages yet. Star a message to keep it here." />
) : (
<ul className="divide-y divide-border rounded-2xl border border-border overflow-hidden">
{starred.map((m) => (
<li key={m.id} className="flex items-start gap-3 px-4 py-3">
<div className="min-w-0 flex-1 text-sm">
{m.body && (
<MessageText body={m.body} entities={m.entities} />
)}
<MessageAttachments attachments={m.attachments} />
<p className="mt-1 text-xs text-muted-foreground">
{new Date(m.created_at).toLocaleString()}
</p>
</div>
<button
type="button"
onClick={() => unstar(m.id)}
aria-label="Remove star"
className="shrink-0 rounded-md p-1 text-primary hover:opacity-80"
>
<Star className="size-4 fill-current" />
</button>
</li>
))}
</ul>
)
) : items.length === 0 ? (
<Empty
text={
tab === "media"
? "No photos, videos or files shared yet."
: "No links shared yet."
}
/>
) : tab === "media" ? (
<div className="grid grid-cols-3 gap-1 sm:grid-cols-4">
{items.map((a, i) =>
a.kind === "image" || a.kind === "video" ? (
<button
key={a.id}
type="button"
// Index into the media-only list, so the lightbox opens on the
// tile that was tapped.
onClick={() => setLightboxAt(viewableIndex(items, i))}
className="aspect-square overflow-hidden rounded-md"
>
{a.kind === "video" ? (
<video
src={attachmentUrl(a.id)}
preload="metadata"
muted
playsInline
className="size-full object-cover"
/>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
src={attachmentUrl(a.id)}
alt={a.file_name ?? "Shared media"}
loading="lazy"
className="size-full object-cover"
/>
)}
</button>
) : (
<a
key={a.id}
href={attachmentUrl(a.id)}
target="_blank"
rel="noopener noreferrer"
className="flex aspect-square items-center justify-center rounded-md bg-muted p-2 text-center text-[11px] text-muted-foreground hover:opacity-80"
>
<span className="line-clamp-3 break-all">
{a.file_name ?? a.kind}
</span>
</a>
),
)}
</div>
) : (
<ul className="divide-y divide-border rounded-2xl border border-border overflow-hidden">
{items.map((a) => (
<li key={a.id}>
<a
href={a.url ?? "#"}
target="_blank"
rel="noopener noreferrer"
className="block px-4 py-3 hover:bg-muted/60"
>
<p className="truncate text-sm font-medium text-foreground">
{a.title || a.url}
</p>
{a.site_name && (
<p className="truncate text-xs text-muted-foreground">
{a.site_name}
</p>
)}
</a>
</li>
))}
</ul>
)}
{lightboxAt !== null && (
<MediaLightbox
items={viewable(items)}
startIndex={lightboxAt}
onClose={() => setLightboxAt(null)}
/>
)}
</main>
);
}
/** The gallery mixes files in with media; only media is viewable. */
function viewable(items: Attachment[]): Attachment[] {
return items.filter((a) => a.kind === "image" || a.kind === "video");
}
/** Maps a position in the full gallery to its position among viewable items. */
function viewableIndex(items: Attachment[], galleryIndex: number): number {
return viewable(items.slice(0, galleryIndex)).length;
}
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-1.5 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({ text }: { text: string }) {
return (
<p className="py-12 text-center text-sm text-muted-foreground">{text}</p>
);
}
+238
View File
@@ -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>
);
}