"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(["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(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("media"); const [items, setItems] = useState([]); const [starred, setStarred] = useState([]); const [state, setState] = useState<"loading" | "ready" | "error">("loading"); const [lightboxAt, setLightboxAt] = useState(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 (

Loading…

); } const isGroup = meta?.kind === "group"; const title = meta ? conversationTitle(meta) : "Chat"; return (

Chat details

{meta?.kind === "ai" ? ( ) : (
{/* 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) && ( )}
)}

{title}

{meta?.muted_until && ( )}
{meta?.peer?.handle && ( @{meta.peer.handle} )}
{meta && !isGroup && ( )} {meta && isGroup && (

{meta.participants.length}{" "} {meta.participants.length === 1 ? "member" : "members"} {meta.send_policy === "admins" && " · only admins can send"}

)}
{isGroup && ( changeTab("members")} > Members )} changeTab("media")}> Media changeTab("links")}> Links changeTab("starred")} > Starred {isGroup && ( changeTab("settings")} > Settings )}
{state === "error" ? (

Could not load this tab.

) : tab === "members" && meta ? ( ) : tab === "settings" && meta ? ( ) : state === "loading" ? (

Loading…

) : tab === "starred" ? ( starred.length === 0 ? ( ) : (
    {starred.map((m) => (
  • {m.body && ( )}

    {new Date(m.created_at).toLocaleString()}

  • ))}
) ) : items.length === 0 ? ( ) : tab === "media" ? (
{items.map((a, i) => a.kind === "image" || a.kind === "video" ? ( ) : ( {a.file_name ?? a.kind} ), )}
) : ( )} {lightboxAt !== null && ( setLightboxAt(null)} /> )}
); } /** 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 ( ); } function Empty({ text }: { text: string }) { return (

{text}

); }