feat: chat
This commit is contained in:
+562
@@ -0,0 +1,562 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
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[];
|
||||
},
|
||||
) =>
|
||||
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 ?? [],
|
||||
}),
|
||||
}),
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
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";
|
||||
}
|
||||
Reference in New Issue
Block a user