Files
spsays-training/lib/chat.ts
T
2026-08-29 11:58:33 +05:30

579 lines
17 KiB
TypeScript

/**
* 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<T> = {
success: boolean;
data?: T;
error?: { code: number; message: string };
};
async function call<T>(path: string, init?: RequestInit): Promise<T> {
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<T>;
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"
| "quote";
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<InboxItem[]>(`/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<ConversationDetail>(`/chat/conversations/${conversationId}`),
messages: (conversationId: string, beforeSeq = 0, limit = 30) =>
call<ChatMessage[]>(
`/chat/conversations/${conversationId}/messages?before_seq=${beforeSeq}&limit=${limit}`,
),
send: (
conversationId: string,
body: string,
opts?: {
clientMsgId?: string;
replyToId?: string;
previewUrl?: string;
attachments?: UploadedAttachment[];
quote?: QuotePayload;
},
) =>
call<ChatMessage>(`/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 ?? [],
quote: opts?.quote ?? null,
}),
}),
/**
* 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<void>(`/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<void>(
`/chat/conversations/${conversationId}/members/${encodeURIComponent(handle)}`,
{ method: "DELETE" },
),
setMemberRole: (conversationId: string, handle: string, role: GroupRole) =>
call<void>(
`/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<void>(`/chat/conversations/${conversationId}`, {
method: "PATCH",
body: JSON.stringify(patch),
}),
settings: () => call<ChatSettings>("/chat/settings"),
/** Partial: anything omitted keeps its current value. */
updateSettings: (patch: Partial<Omit<ChatSettings, "gender_set">>) =>
call<ChatSettings>("/chat/settings", {
method: "PATCH",
body: JSON.stringify(patch),
}),
gallery: (conversationId: string, tab: "media" | "links", before?: string) =>
call<Attachment[]>(
`/chat/conversations/${conversationId}/gallery?tab=${tab}` +
(before ? `&before=${encodeURIComponent(before)}` : ""),
),
starred: (conversationId: string) =>
call<ChatMessage[]>(`/chat/conversations/${conversationId}/starred`),
deleteMessage: (
conversationId: string,
messageId: string,
scope: "me" | "everyone",
) =>
call<void>(
`/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<ChatMessage>(`/chat/conversations/${toConversationId}/forward`, {
method: "POST",
body: JSON.stringify({ message_id: messageId }),
}),
report: (
conversationId: string,
messageId: string,
category: string,
reason: string,
) =>
call<void>(
`/chat/conversations/${conversationId}/messages/${messageId}/report`,
{ method: "POST", body: JSON.stringify({ category, reason }) },
),
setStar: (conversationId: string, messageId: string, starred: boolean) =>
call<void>(
`/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<LinkPreview>("/chat/link-preview", {
method: "POST",
body: JSON.stringify({ url }),
}),
markRead: (conversationId: string, seq: number) =>
call<void>(`/chat/conversations/${conversationId}/read`, {
method: "POST",
body: JSON.stringify({ seq }),
}),
markDelivered: (conversationId: string, seq: number) =>
call<void>(`/chat/conversations/${conversationId}/delivered`, {
method: "POST",
body: JSON.stringify({ seq }),
}),
receipts: (conversationId: string) =>
call<Receipts>(`/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. */
/**
* A passage shared from the library.
*
* `path` is app-relative on purpose — the BFF refuses anything absolute or
* protocol-relative, because a quote renders as trusted in-app content and
* must not be able to carry the reader off-site.
*/
export type QuotePayload = {
path: string;
title: string;
text: string;
};
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<UploadedAttachment> {
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<string> {
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<InboxItem, "muted_until">): 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<InboxItem, "kind" | "peer" | "title">,
): 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";
}