From 523b6fa7e8cbef221b770b97ccb9ad6b80be8807 Mon Sep 17 00:00:00 2001 From: gosangam Date: Sat, 29 Aug 2026 03:49:58 +0530 Subject: [PATCH] feat: chat --- app/(app)/chats/[id]/page.tsx | 559 +++++++++++++++++ app/(app)/chats/[id]/profile/page.tsx | 455 ++++++++++++++ app/(app)/chats/page.tsx | 238 ++++++++ app/(app)/settings/chat/page.tsx | 240 ++++++++ app/(app)/settings/page.tsx | 7 + components/chat/add-members-dialog.tsx | 184 ++++++ components/chat/chat-composer.tsx | 294 +++++++++ components/chat/chat-list-item.tsx | 129 ++++ components/chat/chat-profile-header.tsx | 153 +++++ components/chat/composer-attachments.tsx | 85 +++ components/chat/forward-dialog.tsx | 135 +++++ components/chat/group-members-tab.tsx | 163 +++++ components/chat/group-settings-tab.tsx | 268 +++++++++ components/chat/link-preview-card.tsx | 89 +++ components/chat/media-grid.tsx | 103 ++++ components/chat/media-lightbox.tsx | 204 +++++++ components/chat/message-attachments.tsx | 118 ++++ components/chat/message-gestures.tsx | 177 ++++++ components/chat/message-group.tsx | 241 ++++++++ components/chat/message-list.tsx | 178 ++++++ components/chat/message-menu.tsx | 173 ++++++ components/chat/message-text.tsx | 68 +++ components/chat/message-ticks.tsx | 41 ++ components/chat/new-group-dialog.tsx | 188 ++++++ components/layout/site-bottom-nav.tsx | 9 +- components/layout/site-sidebar.tsx | 7 +- .../notifications/notification-matrix.tsx | 2 +- .../notifications/notifications-provider.tsx | 36 +- components/profile/message-button.tsx | 92 +++ components/profile/profile-actions.tsx | 6 +- lib/chat-format.ts | 101 ++++ lib/chat-grouping.ts | 80 +++ lib/chat.ts | 562 ++++++++++++++++++ lib/social.ts | 7 + 34 files changed, 5381 insertions(+), 11 deletions(-) create mode 100644 app/(app)/chats/[id]/page.tsx create mode 100644 app/(app)/chats/[id]/profile/page.tsx create mode 100644 app/(app)/chats/page.tsx create mode 100644 app/(app)/settings/chat/page.tsx create mode 100644 components/chat/add-members-dialog.tsx create mode 100644 components/chat/chat-composer.tsx create mode 100644 components/chat/chat-list-item.tsx create mode 100644 components/chat/chat-profile-header.tsx create mode 100644 components/chat/composer-attachments.tsx create mode 100644 components/chat/forward-dialog.tsx create mode 100644 components/chat/group-members-tab.tsx create mode 100644 components/chat/group-settings-tab.tsx create mode 100644 components/chat/link-preview-card.tsx create mode 100644 components/chat/media-grid.tsx create mode 100644 components/chat/media-lightbox.tsx create mode 100644 components/chat/message-attachments.tsx create mode 100644 components/chat/message-gestures.tsx create mode 100644 components/chat/message-group.tsx create mode 100644 components/chat/message-list.tsx create mode 100644 components/chat/message-menu.tsx create mode 100644 components/chat/message-text.tsx create mode 100644 components/chat/message-ticks.tsx create mode 100644 components/chat/new-group-dialog.tsx create mode 100644 components/profile/message-button.tsx create mode 100644 lib/chat-format.ts create mode 100644 lib/chat-grouping.ts create mode 100644 lib/chat.ts diff --git a/app/(app)/chats/[id]/page.tsx b/app/(app)/chats/[id]/page.tsx new file mode 100644 index 00000000..5edbcb98 --- /dev/null +++ b/app/(app)/chats/[id]/page.tsx @@ -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(null); + const [messages, setMessages] = useState([]); + const [receipts, setReceipts] = useState(null); + const [hasMore, setHasMore] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [state, setState] = useState<"loading" | "ready" | "error">("loading"); + const [replyTo, setReplyTo] = useState(null); + const [menu, setMenu] = useState<{ message: ChatMessage; point: MenuPoint } | null>(null); + const [forwarding, setForwarding] = useState(null); + const [starredIds, setStarredIds] = useState>(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 ( +
+

Loading…

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

+ {title} +

+ {muted && ( + + )} +
+ {meta?.peer?.handle && ( +

+ @{meta.peer.handle} +

+ )} + +
+ + {state === "error" ? ( +

+ Could not load this conversation. +

+ ) : state === "loading" ? ( +

Loading…

+ ) : ( + setMenu({ message, point })} + starredIds={starredIds} + onRetry={retryMessage} + nameFor={nameFor} + /> + )} + + {meta?.kind === "ai" ? ( +
+ Kautilya is coming soon. +
+ ) : ( + setReplyTo(null)} + onSend={sendMessage} + disabled={state !== "ready"} + /> + )} + + {menu && ( + 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 && ( + setForwarding(null)} + /> + )} +
+ ); +} diff --git a/app/(app)/chats/[id]/profile/page.tsx b/app/(app)/chats/[id]/profile/page.tsx new file mode 100644 index 00000000..2c755899 --- /dev/null +++ b/app/(app)/chats/[id]/profile/page.tsx @@ -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(["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}

+ ); +} diff --git a/app/(app)/chats/page.tsx b/app/(app)/chats/page.tsx new file mode 100644 index 00000000..680a164e --- /dev/null +++ b/app/(app)/chats/page.tsx @@ -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("chats"); + const [items, setItems] = useState([]); + const [state, setState] = useState<"loading" | "ready" | "error">("loading"); + const [highlighted, setHighlighted] = useState>(new Set()); + const [creatingGroup, setCreatingGroup] = useState(false); + const timers = useRef>>(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 ( +
+

Loading…

+
+ ); + } + + return ( +
+
+

+ Chats +

+ {tab === "chats" && ( + + )} +
+ +
+ setTab("chats")}> + Chats + + setTab("kautilya")} + > + Kautilya + +
+ + {state === "error" ? ( +

+ Could not load your chats. Please try again. +

+ ) : state === "loading" ? ( +

Loading…

+ ) : items.length === 0 ? ( + tab === "kautilya" ? ( + } + title="Kautilya is coming soon" + text="An AI study companion tuned on Śrīla Prabhupāda's books, letters, and lectures." + /> + ) : ( + } + title="No chats yet" + text="Open someone's profile to start a conversation." + /> + ) + ) : ( +
    + {items.map((item) => ( + + ))} +
+ )} + + {creatingGroup && ( + setCreatingGroup(false)} /> + )} +
+ ); +} + +function TabButton({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} + +function Empty({ + icon, + title, + text, +}: { + icon: React.ReactNode; + title: string; + text: string; +}) { + return ( +
+ + {icon} + +

{title}

+

{text}

+
+ ); +} diff --git a/app/(app)/settings/chat/page.tsx b/app/(app)/settings/chat/page.tsx new file mode 100644 index 00000000..fd36d02c --- /dev/null +++ b/app/(app)/settings/chat/page.tsx @@ -0,0 +1,240 @@ +"use client"; + +/** + * /settings/chat — chat preferences. + * + * Each control saves on change rather than behind a Save button: these are + * independent switches, and a half-applied form is worse than an immediate + * write that can be toggled back. + */ + +import { useCallback, useEffect, useState } from "react"; +import Link from "next/link"; +import { toast } from "sonner"; +import { SettingsShell, Section } from "@/components/settings/settings-ui"; +import { SettingRow, ToggleSwitch } from "@/components/settings/setting-row"; +import { chat, type ChatSettings } from "@/lib/chat"; +import { cn } from "@/lib/utils"; + +const AGE_CHOICES = [1, 3, 6, 12] as const; + +export default function ChatSettingsPage() { + const [settings, setSettings] = useState(null); + const [state, setState] = useState<"loading" | "ready" | "error">("loading"); + const [busy, setBusy] = useState(false); + + useEffect(() => { + let cancelled = false; + chat + .settings() + .then((s) => { + if (cancelled) return; + setSettings(s); + setState("ready"); + }) + .catch(() => !cancelled && setState("error")); + return () => { + cancelled = true; + }; + }, []); + + const save = useCallback( + async (patch: Partial>) => { + setBusy(true); + try { + setSettings(await chat.updateSettings(patch)); + } catch (err) { + // The BFF explains *why* — notably "add your gender in profile + // settings" — so surface its message rather than a generic failure. + toast.error( + err instanceof Error && err.message + ? err.message + : "Could not save that setting", + ); + } finally { + setBusy(false); + } + }, + [], + ); + + return ( + + {state === "error" ? ( +

+ Could not load your chat settings. +

+ ) : state === "loading" || !settings ? ( +

Loading…

+ ) : ( +
+
+ + save({ read_receipts: !settings.read_receipts }) + } + /> + } + /> +
+ +
+
+ save({ who_can_message: "anyone" })} + title="Anyone" + description="No restriction." + /> + save({ who_can_message: "friends" })} + title="Your friends" + description="Only people you follow, or who follow you." + /> + save({ who_can_message: "account_age" })} + title="Established accounts" + description="Only accounts older than a chosen age." + > + {settings.who_can_message === "account_age" && ( +
+ {AGE_CHOICES.map((months) => ( + + ))} +
+ )} +
+ save({ who_can_message: "same_gender" })} + title="Only accounts of your gender" + description={ + settings.gender_set + ? "Only accounts whose gender matches yours." + : undefined + } + > + {!settings.gender_set && ( +

+ Add your gender in{" "} + + profile settings + {" "} + to use this option. +

+ )} +
+
+
+ +
+ + save({ train_kautilya: !settings.train_kautilya }) + } + /> + } + /> +
+
+ )} +
+ ); +} + +function Choice({ + active, + disabled, + onClick, + title, + description, + children, +}: { + active: boolean; + disabled?: boolean; + onClick: () => void; + title: string; + description?: string; + children?: React.ReactNode; +}) { + return ( +
+ + {children} +
+ ); +} diff --git a/app/(app)/settings/page.tsx b/app/(app)/settings/page.tsx index b7e7282d..f7def54f 100644 --- a/app/(app)/settings/page.tsx +++ b/app/(app)/settings/page.tsx @@ -22,6 +22,7 @@ import { DownloadCloud, LogOut, MessageSquareText, + MessagesSquare, Scale, ShieldCheck, UserRound, @@ -49,6 +50,12 @@ export default function SettingsPage() { label="Reading preferences" description="Theme, palette, font, and size — synced across devices." /> + } + label="Chat" + description="Read receipts, who can message you, and Kautilya training." + /> } diff --git a/components/chat/add-members-dialog.tsx b/components/chat/add-members-dialog.tsx new file mode 100644 index 00000000..a67fcf06 --- /dev/null +++ b/components/chat/add-members-dialog.tsx @@ -0,0 +1,184 @@ +"use client"; + +/** + * Search for people and add them to a group. + * + * Search runs through the same-origin /api/search/users proxy the search page + * uses. It returns handles, which is exactly what the member API takes — no id + * plumbing needed. + */ + +import { useEffect, useState } from "react"; +import { Check, Search, X } from "lucide-react"; +import { toast } from "sonner"; +import { useAuth } from "@/components/auth/auth-provider"; +import { UserAvatar } from "@/components/ui/user-avatar"; +import { Button } from "@/components/ui/button"; +import { chat } from "@/lib/chat"; + +type Candidate = { handle: string; name: string; avatar_url: string | null }; + +export function AddMembersDialog({ + conversationId, + existing, + onClose, + onAdded, +}: { + conversationId: string; + /** Handles already in the group. Filtered out of results entirely. */ + existing: string[]; + onClose: () => void; + onAdded: () => void; +}) { + const { user } = useAuth(); + const [query, setQuery] = useState(""); + // Results are stored with the query they belong to, so what's displayed can + // be derived: a stale page from a previous query simply stops matching, and + // nothing has to be cleared from an effect. + const [results, setResults] = useState<{ q: string; rows: Candidate[] }>({ + q: "", + rows: [], + }); + const [picked, setPicked] = useState>(new Set()); + const [busy, setBusy] = useState(false); + + const q = query.trim(); + const searchable = q.length >= 2; + // Current members and the caller are dropped rather than shown greyed out: + // this list exists to answer "who can I add?", and anyone already here is + // not an answer to that. + const alreadyIn = new Set(existing); + const visible = + results.q === q + ? results.rows.filter( + (u) => u.handle !== user?.handle && !alreadyIn.has(u.handle), + ) + : []; + + useEffect(() => { + if (!searchable) return; + const timer = setTimeout(() => { + fetch(`/api/search/users?q=${encodeURIComponent(q)}&limit=15`) + .then((r) => r.json()) + .then((data: { results?: Candidate[] }) => + setResults({ q, rows: data.results ?? [] }), + ) + .catch(() => setResults({ q, rows: [] })); + }, 300); + return () => clearTimeout(timer); + }, [q, searchable]); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose(); + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onClose]); + + const toggle = (handle: string) => + setPicked((prev) => { + const next = new Set(prev); + if (next.has(handle)) next.delete(handle); + else next.add(handle); + return next; + }); + + const submit = async () => { + if (picked.size === 0) return; + setBusy(true); + try { + await chat.addMembers(conversationId, [...picked]); + toast.success( + picked.size === 1 ? "Member added" : `${picked.size} members added`, + ); + onAdded(); + onClose(); + } catch { + toast.error("Could not add members"); + } finally { + setBusy(false); + } + }; + + return ( +
+
+
+
+

Add members

+ +
+ +
+
+ + setQuery(e.target.value)} + placeholder="Search by name or handle" + className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" + /> +
+
+ +
+ {!searchable ? ( +

+ Type at least two characters to search. +

+ ) : visible.length === 0 ? ( +

No one found.

+ ) : ( +
    + {visible.map((u) => ( +
  • + +
  • + ))} +
+ )} +
+ +
+ +
+
+
+ ); +} diff --git a/components/chat/chat-composer.tsx b/components/chat/chat-composer.tsx new file mode 100644 index 00000000..6be47ec2 --- /dev/null +++ b/components/chat/chat-composer.tsx @@ -0,0 +1,294 @@ +"use client"; + +/** + * The message composer. + * + * Formatting is deliberately NOT parsed here — the user types `*bold*` and the + * BFF turns markers into clean text plus spans. Keeping one parser on the + * server means every client renders identically and no client can dictate how + * its text is decorated in someone else's thread. + * + * Link previews and attachments layer onto this in the next phase; nothing here + * needs to change to accommodate them. + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { Paperclip, SendHorizonal, X } from "lucide-react"; +import { LinkPreviewCard } from "@/components/chat/link-preview-card"; +import { + ComposerAttachments, + type StagedAttachment, +} from "@/components/chat/composer-attachments"; +import { compressImage } from "@/lib/compress-image"; +import { cn } from "@/lib/utils"; +import { + ACCEPTED_ATTACHMENTS, + chat, + firstUrl, + uploadAttachment, + type ChatMessage, + type LinkPreview, + type UploadedAttachment, +} from "@/lib/chat"; + +const MAX_ROWS_PX = 140; + +export function ChatComposer({ + replyTo, + onCancelReply, + onSend, + conversationId, + disabled = false, + placeholder = "Message…", + replyToName, +}: { + conversationId: string; + replyTo: ChatMessage | null; + onCancelReply: () => void; + onSend: ( + body: string, + replyToId?: string, + previewUrl?: string, + attachments?: UploadedAttachment[], + ) => void; + disabled?: boolean; + placeholder?: string; + /** Display name of whoever wrote the message being replied to. */ + replyToName: string; +}) { + const [value, setValue] = useState(""); + const [preview, setPreview] = useState(null); + // URLs the sender dismissed. Kept so re-typing doesn't resurrect a card + // they've already said no to. + const [dismissed, setDismissed] = useState>(new Set()); + const [staged, setStaged] = useState([]); + const ref = useRef(null); + const fileRef = useRef(null); + const lookupSeq = useRef(0); + + // Grow with the content, up to a cap. + useEffect(() => { + const el = ref.current; + if (!el) return; + el.style.height = "auto"; + el.style.height = `${Math.min(el.scrollHeight, MAX_ROWS_PX)}px`; + }, [value]); + + // Which link, if any, the draft currently wants a card for. + const activeUrl = firstUrl(value); + const wantsPreview = !!activeUrl && !dismissed.has(activeUrl); + + // Whether to SHOW a card is derived during render rather than cleared from + // an effect: a stale card for a link the user has since edited away simply + // stops matching, so there is no state to reset. + const visiblePreview = + wantsPreview && preview?.url === activeUrl ? preview : null; + + // The effect only fetches. Requests are sequenced so a slow earlier response + // cannot overwrite a newer one, and debounced so a lookup doesn't fire on + // every keystroke. + useEffect(() => { + if (!wantsPreview || !activeUrl) return; + const seq = ++lookupSeq.current; + const timer = setTimeout(() => { + chat + .linkPreview(activeUrl) + .then((p) => { + if (seq === lookupSeq.current) setPreview(p); + }) + // No card is a normal outcome — many pages carry no metadata. + .catch(() => {}); + }, 500); + return () => clearTimeout(timer); + }, [activeUrl, wantsPreview]); + + const dismissPreview = useCallback(() => { + if (activeUrl) setDismissed((prev) => new Set(prev).add(activeUrl)); + setPreview(null); + }, [activeUrl]); + + // Starting a reply should put the cursor where the user is about to type. + useEffect(() => { + if (replyTo) ref.current?.focus(); + }, [replyTo]); + + /** + * Uploads each pick straight to the private bucket, so by the time the user + * hits send the bytes are already there and sending is just metadata. + * Images are compressed first, which also strips EXIF — location data + * shouldn't ride along into someone else's chat. + */ + const addFiles = useCallback( + async (files: FileList) => { + for (const original of Array.from(files)) { + const id = crypto.randomUUID(); + const file = original.type.startsWith("image/") + ? await compressImage(original).catch(() => original) + : original; + const previewUrl = file.type.startsWith("image/") + ? URL.createObjectURL(file) + : undefined; + + setStaged((prev) => [ + ...prev, + { id, file, previewUrl, status: "uploading" }, + ]); + + uploadAttachment(conversationId, file) + .then((uploaded) => { + setStaged((prev) => + prev.map((s) => + s.id === id ? { ...s, status: "ready", uploaded } : s, + ), + ); + }) + .catch(() => { + // One failure shouldn't discard the others — mark it and let the + // user remove it. + setStaged((prev) => + prev.map((s) => (s.id === id ? { ...s, status: "failed" } : s)), + ); + }); + } + }, + [conversationId], + ); + + const removeStaged = useCallback((id: string) => { + setStaged((prev) => { + const target = prev.find((s) => s.id === id); + if (target?.previewUrl) URL.revokeObjectURL(target.previewUrl); + return prev.filter((s) => s.id !== id); + }); + }, []); + + // Object URLs are a leak if the composer unmounts mid-compose. + useEffect(() => { + return () => { + staged.forEach((s) => s.previewUrl && URL.revokeObjectURL(s.previewUrl)); + }; + // Intentionally on unmount only; removeStaged handles the per-chip case. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const uploading = staged.some((s) => s.status === "uploading"); + const ready = staged.filter((s) => s.status === "ready" && s.uploaded); + const canSend = + !disabled && !uploading && (value.trim() !== "" || ready.length > 0); + + const submit = () => { + if (!canSend) return; + const body = value.trim(); + onSend( + body, + replyTo?.id, + visiblePreview?.url, + ready.map((s) => s.uploaded as UploadedAttachment), + ); + staged.forEach((s) => s.previewUrl && URL.revokeObjectURL(s.previewUrl)); + setStaged([]); + setValue(""); + setPreview(null); + setDismissed(new Set()); + onCancelReply(); + }; + + return ( +
+ {replyTo && ( +
+
+

+ Replying to {replyToName} +

+

+ {replyTo.body} +

+
+ +
+ )} + + {visiblePreview && ( +
+ +
+ )} + + + +
+ { + if (e.target.files?.length) void addFiles(e.target.files); + // Reset so picking the same file twice still fires a change. + e.target.value = ""; + }} + /> + + +