feat: chat
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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