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