456 lines
15 KiB
TypeScript
456 lines
15 KiB
TypeScript
"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>
|
|
);
|
|
}
|