671 lines
20 KiB
TypeScript
671 lines
20 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[];
|
|
/** Emoji chips, grouped with counts. `mine` marks the viewer's own. */
|
|
reactions: Reaction[];
|
|
/**
|
|
* 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";
|
|
|
|
/** One emoji on a message, grouped across everyone who chose it. */
|
|
export type Reaction = {
|
|
emoji: string;
|
|
count: number;
|
|
/** The viewer's own choice — highlight the chip, and tapping it removes. */
|
|
mine: boolean;
|
|
};
|
|
|
|
/** One person's reaction, for the "who reacted" list. */
|
|
export type ReactionDetail = {
|
|
user: ChatPeer;
|
|
emoji: string;
|
|
created_at: string;
|
|
};
|
|
|
|
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;
|
|
|
|
/** Shared entity. `entity` is resolved per viewer on every read, so it can
|
|
* be absent even though the message still references a post. */
|
|
entity_type?: "post" | null;
|
|
entity_id?: string | null;
|
|
entity?: SharedPost | null;
|
|
/** The post was deleted, hidden, or is no longer visible to this viewer. */
|
|
entity_unavailable?: boolean;
|
|
};
|
|
|
|
/** The post card the BFF resolves for a shared-post attachment. */
|
|
export type SharedPost = {
|
|
id: string;
|
|
author_handle: string;
|
|
author_name: string;
|
|
author_avatar_url?: string | null;
|
|
caption: string;
|
|
image_url?: string | null;
|
|
source_title?: string;
|
|
};
|
|
|
|
/** 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.
|
|
*/
|
|
/** Threads waiting on the caller. Muted ones are excluded — see the BFF. */
|
|
unreadCount: () =>
|
|
call<{ count: number }>("/chat/unread").then((r) => r.count),
|
|
|
|
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;
|
|
/** Share a post as a card. Only the reference — the BFF resolves it. */
|
|
entity?: { type: "post"; id: string };
|
|
},
|
|
) =>
|
|
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,
|
|
entity: opts?.entity ?? 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 }),
|
|
}),
|
|
|
|
/** One hydrated message — reaction counts are per viewer, so a client told
|
|
* "message X changed" has to ask rather than compute. */
|
|
message: (conversationId: string, messageId: string) =>
|
|
call<ChatMessage>(
|
|
`/chat/conversations/${conversationId}/messages/${messageId}`,
|
|
),
|
|
|
|
/** Tell the thread someone is composing. Ephemeral — nothing is stored. */
|
|
typing: (conversationId: string) =>
|
|
call<void>(`/chat/conversations/${conversationId}/typing`, {
|
|
method: "POST",
|
|
}),
|
|
|
|
/** Who reacted, and with what. Only fetched when the list is opened —
|
|
* the thread renders from the grouped counts on each message. */
|
|
reactionDetails: (conversationId: string, messageId: string) =>
|
|
call<ReactionDetail[]>(
|
|
`/chat/conversations/${conversationId}/messages/${messageId}/reactions`,
|
|
),
|
|
|
|
/** Set the caller's reaction, replacing any previous one. */
|
|
react: (conversationId: string, messageId: string, emoji: string) =>
|
|
call<void>(
|
|
`/chat/conversations/${conversationId}/messages/${messageId}/reaction`,
|
|
{ method: "PUT", body: JSON.stringify({ emoji }) },
|
|
),
|
|
|
|
/** Remove the caller's reaction. Idempotent. */
|
|
unreact: (conversationId: string, messageId: string) =>
|
|
call<void>(
|
|
`/chat/conversations/${conversationId}/messages/${messageId}/reaction`,
|
|
{ method: "DELETE" },
|
|
),
|
|
|
|
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. */
|
|
/**
|
|
* The participant who sent a message, or undefined for Kautilya, a system
|
|
* message, or someone who has since been removed.
|
|
*
|
|
* Groups need this rather than the conversation's `peer`: `peer` is only set
|
|
* on DMs, so a group has no single counterpart to read an avatar from.
|
|
*/
|
|
export function senderPeer(
|
|
senderId: string | null | undefined,
|
|
participants: ChatPeer[],
|
|
): ChatPeer | undefined {
|
|
if (!senderId) return undefined;
|
|
return participants.find((p) => p.id === senderId);
|
|
}
|
|
|
|
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";
|
|
}
|