/** * Browser-side BFF client for chat — the inbox, threads and messages. * * Mirrors lib/social.ts: `credentials: "include"` so the auth cookie travels, * and the {success, data} envelope is unwrapped here so callers see plain data. */ const BFF_BASE = process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080"; export class ChatError extends Error { constructor( public status: number, message: string, ) { super(message); this.name = "ChatError"; } } type Envelope = { success: boolean; data?: T; error?: { code: number; message: string }; }; async function call(path: string, init?: RequestInit): Promise { const res = await fetch(`${BFF_BASE}/api/v1${path}`, { credentials: "include", headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) }, ...init, }); if (res.status === 204) return undefined as T; const body = (await res.json().catch(() => ({}))) as Envelope; if (!res.ok || body.success === false) { throw new ChatError(res.status, body.error?.message || res.statusText); } return (body.data ?? (undefined as unknown as T)) as T; } // ─── Types ─────────────────────────────────────────────────────────────── /** Which inbox tab a thread belongs to. `ai` is Kautilya. */ export type ConversationKind = "dm" | "group" | "ai"; /** Roles inside a group, most privileged first. */ export type GroupRole = "owner" | "admin" | "member"; /** Who may post in a group. */ export type SendPolicy = "everyone" | "admins"; export type ChatPeer = { id: string; handle: string; name: string; avatar_url?: string | null; /** Present on participant lists; absent on a DM's `peer`. */ role?: GroupRole; joined_at?: string | null; }; export type MessagePreview = { seq: number; sender_id?: string | null; sender_name: string; kind: string; body: string; deleted_at?: string | null; }; export type InboxItem = { id: string; kind: ConversationKind; title?: string | null; avatar_key?: string | null; send_policy: SendPolicy; last_seq: number; last_message_at?: string | null; created_at: string; unread: number; /** Set while muted. Unread still accrues — mute silences alerts only. */ muted_until?: string | null; peer?: ChatPeer | null; last_message?: MessagePreview | null; }; /** A formatting span. Offsets are UTF-16 code units, matching String.slice. */ export type MessageEntity = { type: "bold" | "strike" | "code" | "url"; offset: number; length: number; url?: string; }; /** The quoted snippet shown above a reply. */ export type ReplyPreview = { id: string; seq: number; sender_id?: string | null; sender_name: string; body: string; deleted: boolean; }; export type ChatMessage = { id: string; conversation_id: string; seq: number; sender_id?: string | null; kind: "text" | "system" | "assistant"; body: string; entities: MessageEntity[]; client_msg_id?: string | null; reply_to_id?: string | null; forwarded_from_id?: string | null; edited_at?: string | null; deleted_at?: string | null; created_at: string; /** Present when this message replies to another in the same thread. */ reply_to?: ReplyPreview | null; /** Always an array — media, link cards and shared entities. */ attachments: Attachment[]; /** * Client-only. Set on a message shown optimistically before the server has * acknowledged it; never sent by the BFF. */ pending?: boolean; /** * Client-only. Set when the send failed. The bubble stays put so the text * isn't lost and the user can retry — retrying reuses the same * `client_msg_id`, which is what makes it safe if the original actually * landed. */ failed?: boolean; }; /** One thread plus who is in it — everything the thread screen needs. */ export type ConversationDetail = { id: string; kind: ConversationKind; title?: string | null; avatar_key?: string | null; send_policy: SendPolicy; /** The caller's own role, so admin controls can be shown without deriving it. */ my_role: GroupRole; last_seq: number; last_message_at?: string | null; created_at: string; peer?: ChatPeer | null; participants: ChatPeer[]; unread: number; muted_until?: string | null; /** DMs only — a group has no single peer to stand in a relationship with. */ relationship?: ChatRelationship | null; }; /** Link preview metadata. Always fetched by the BFF, never composed here. */ export type LinkPreview = { url: string; title?: string; description?: string; site_name?: string; image_url?: string; }; export type AttachmentKind = | "image" | "video" | "audio" | "file" | "link" | "entity"; export type Attachment = { id: string; message_id: string; ord: number; kind: AttachmentKind; mime?: string | null; size_bytes?: number | null; width?: number | null; height?: number | null; duration_ms?: number | null; blurhash?: string | null; file_name?: string | null; url?: string | null; title?: string | null; description?: string | null; site_name?: string | null; image_url?: string | null; }; /** How two people in a DM stand with each other. */ export type ChatRelationship = { following: boolean; followed_by: boolean; blocked: boolean; blocked_by: boolean; /** When the first follow between them was accepted. */ friends_since?: string | null; }; export type Receipts = { delivered_seq: number; read_seq: number }; export type MuteDuration = "8h" | "1w" | "always" | "off"; /** Who may START a conversation with you. */ export type WhoCanMessage = | "anyone" | "friends" | "account_age" | "same_gender"; export type ChatSettings = { read_receipts: boolean; who_can_message: WhoCanMessage; min_account_age_months: number; train_kautilya: boolean; /** Whether a gender is set on the profile — same_gender needs one. */ gender_set: boolean; }; export type InboxTab = "chats" | "kautilya"; // ─── API ───────────────────────────────────────────────────────────────── export const chat = { inbox: (tab: InboxTab) => call(`/chat/conversations?tab=${tab}`), /** * Opens (or returns) the DM with a profile. Idempotent, and keyed by handle * because that is what a profile page has. */ openDM: (handle: string) => call<{ id: string; kind: ConversationKind }>("/chat/conversations/dm", { method: "POST", body: JSON.stringify({ handle }), }), createGroup: (title: string, handles: string[]) => call<{ id: string }>("/chat/conversations/group", { method: "POST", body: JSON.stringify({ title, member_handles: handles }), }), detail: (conversationId: string) => call(`/chat/conversations/${conversationId}`), messages: (conversationId: string, beforeSeq = 0, limit = 30) => call( `/chat/conversations/${conversationId}/messages?before_seq=${beforeSeq}&limit=${limit}`, ), send: ( conversationId: string, body: string, opts?: { clientMsgId?: string; replyToId?: string; previewUrl?: string; attachments?: UploadedAttachment[]; }, ) => call(`/chat/conversations/${conversationId}/messages`, { method: "POST", body: JSON.stringify({ body, client_msg_id: opts?.clientMsgId ?? null, reply_to_id: opts?.replyToId ?? null, // Only the URL: the BFF fetches the card's contents itself. preview_url: opts?.previewUrl ?? null, attachments: opts?.attachments ?? [], }), }), /** * Members are named by handle, not id: search results and follow lists * identify people by handle, and it matches /u/{handle}/block elsewhere. */ addMembers: (conversationId: string, handles: string[]) => call(`/chat/conversations/${conversationId}/members`, { method: "POST", body: JSON.stringify({ member_handles: handles }), }), /** Passing your own handle is how you leave. */ removeMember: (conversationId: string, handle: string) => call( `/chat/conversations/${conversationId}/members/${encodeURIComponent(handle)}`, { method: "DELETE" }, ), setMemberRole: (conversationId: string, handle: string, role: GroupRole) => call( `/chat/conversations/${conversationId}/members/${encodeURIComponent(handle)}`, { method: "PATCH", body: JSON.stringify({ role }) }, ), updateGroup: ( conversationId: string, patch: { title?: string; send_policy?: SendPolicy; avatar_key?: string }, ) => call(`/chat/conversations/${conversationId}`, { method: "PATCH", body: JSON.stringify(patch), }), settings: () => call("/chat/settings"), /** Partial: anything omitted keeps its current value. */ updateSettings: (patch: Partial>) => call("/chat/settings", { method: "PATCH", body: JSON.stringify(patch), }), gallery: (conversationId: string, tab: "media" | "links", before?: string) => call( `/chat/conversations/${conversationId}/gallery?tab=${tab}` + (before ? `&before=${encodeURIComponent(before)}` : ""), ), starred: (conversationId: string) => call(`/chat/conversations/${conversationId}/starred`), deleteMessage: ( conversationId: string, messageId: string, scope: "me" | "everyone", ) => call( `/chat/conversations/${conversationId}/messages/${messageId}?scope=${scope}`, { method: "DELETE" }, ), /** The path names the destination; the body names the message copied in. */ forward: (toConversationId: string, messageId: string) => call(`/chat/conversations/${toConversationId}/forward`, { method: "POST", body: JSON.stringify({ message_id: messageId }), }), report: ( conversationId: string, messageId: string, category: string, reason: string, ) => call( `/chat/conversations/${conversationId}/messages/${messageId}/report`, { method: "POST", body: JSON.stringify({ category, reason }) }, ), setStar: (conversationId: string, messageId: string, starred: boolean) => call( `/chat/conversations/${conversationId}/messages/${messageId}/star`, { method: starred ? "POST" : "DELETE" }, ), presignGroupAvatar: (conversationId: string, contentType: string) => call<{ upload_url: string; key: string }>( `/chat/conversations/${conversationId}/avatar/presign`, { method: "POST", body: JSON.stringify({ content_type: contentType }) }, ), presignAttachment: (conversationId: string, contentType: string) => call<{ upload_url: string; key: string }>( `/chat/conversations/${conversationId}/attachments/presign`, { method: "POST", body: JSON.stringify({ content_type: contentType }) }, ), /** Metadata for a link the user is typing, so they can dismiss the card. */ linkPreview: (url: string) => call("/chat/link-preview", { method: "POST", body: JSON.stringify({ url }), }), markRead: (conversationId: string, seq: number) => call(`/chat/conversations/${conversationId}/read`, { method: "POST", body: JSON.stringify({ seq }), }), markDelivered: (conversationId: string, seq: number) => call(`/chat/conversations/${conversationId}/delivered`, { method: "POST", body: JSON.stringify({ seq }), }), receipts: (conversationId: string) => call(`/chat/conversations/${conversationId}/receipts`), setMute: (conversationId: string, duration: MuteDuration) => call<{ muted_until: string | null }>( `/chat/conversations/${conversationId}/mute`, { method: "POST", body: JSON.stringify({ duration }) }, ), }; /** A file already uploaded to the private bucket, ready to attach on send. */ export type UploadedAttachment = { key: string; mime: string; size_bytes?: number; width?: number; height?: number; file_name?: string; }; /** Natural dimensions of an image file, or nothing if it can't be decoded. */ async function imageSize( file: File, ): Promise<{ width: number; height: number } | null> { if (!file.type.startsWith("image/")) return null; try { const bitmap = await createImageBitmap(file); const size = { width: bitmap.width, height: bitmap.height }; bitmap.close(); return size; } catch { return null; } } /** * Uploads a file straight to the private bucket and returns what `send` needs. * * The bytes never touch the BFF — it only signs the URL — which is the same * path avatars and post images already take. Dimensions travel with the * attachment so the bubble can reserve the right space before the image * loads, instead of reflowing the thread when it arrives. */ export async function uploadAttachment( conversationId: string, file: File, ): Promise { const size = await imageSize(file); const { upload_url, key } = await chat.presignAttachment( conversationId, file.type, ); const res = await fetch(upload_url, { method: "PUT", body: file, headers: { "Content-Type": file.type }, }); if (!res.ok) { throw new ChatError(res.status, "Upload failed"); } return { key, mime: file.type, size_bytes: file.size, width: size?.width, height: size?.height, file_name: file.name, }; } /** * Stable URL for a group's photo. Like message attachments it redirects to a * short-lived signed URL after re-checking membership, so the object itself * stays private. `v` busts the browser cache when the photo changes. */ export function groupAvatarUrl( conversationId: string, avatarKey?: string | null, ): string | undefined { if (!avatarKey) return undefined; return `${BFF_BASE}/api/v1/chat/conversations/${conversationId}/avatar?v=${encodeURIComponent( avatarKey.slice(-12), )}`; } /** * Uploads a new group photo and returns its key for `updateGroup`. Compressed * first, which also strips EXIF. */ export async function uploadGroupAvatar( conversationId: string, file: File, ): Promise { const { upload_url, key } = await chat.presignGroupAvatar( conversationId, file.type, ); const res = await fetch(upload_url, { method: "PUT", body: file, headers: { "Content-Type": file.type }, }); if (!res.ok) throw new ChatError(res.status, "Upload failed"); return key; } /** MIME types the BFF accepts as chat attachments. */ export const ACCEPTED_ATTACHMENTS = "image/*,video/*,audio/*,application/pdf"; /** * Stable URL for a chat attachment. The BFF re-checks membership and redirects * to a short-lived signed URL, so this can be cached in markup safely while the * underlying object stays private. */ export function attachmentUrl(attachmentId: string): string { return `${BFF_BASE}/api/v1/chat/attachments/${attachmentId}`; } /** First http(s) URL in a string, or null. Mirrors the server's matcher. */ export function firstUrl(text: string): string | null { const m = text.match(/(?:https?:\/\/|www\.)[^\s<>()[\]{}"']+/i); if (!m) return null; const raw = m[0].replace(/[.,;:!?'"]+$/, ""); return raw.includes("://") ? raw : `https://${raw}`; } /** Whether a role may administer a group. */ export function isGroupAdmin(role?: GroupRole | null): boolean { return role === "owner" || role === "admin"; } /** True while `muted_until` is in the future. */ export function isMuted(item: Pick): boolean { return !!item.muted_until && new Date(item.muted_until).getTime() > Date.now(); } /** Display name for a thread: the peer for a DM, the title otherwise. */ export function conversationTitle( item: Pick, ): string { if (item.kind === "ai") return "Kautilya"; if (item.kind === "dm") return item.peer?.name || "Unknown"; return item.title || "Group"; } /** * How to describe a DM's follow state in one line. Deliberately explicit about * one-way follows rather than collapsing everything into "friends". */ export function followSummary( rel: ChatRelationship | null | undefined, peerName: string, ): string { if (!rel) return ""; if (rel.following && rel.followed_by) return "You follow each other"; if (rel.following) return `You follow ${peerName}`; if (rel.followed_by) return `${peerName} follows you`; return "You don't follow each other"; } /** Resolves a sender id to a display name using a thread's participants. */ export function senderName( senderId: string | null | undefined, participants: ChatPeer[], selfId: string, ): string { if (!senderId) return "Kautilya"; if (senderId === selfId) return "You"; return participants.find((p) => p.id === senderId)?.name ?? "Unknown"; }