Chat messenger UI
Messaging for apps. Chats with replies, reactions, photos, files and voice, typing and read status, channels with threads, a floating messenger and search.
Conversations that stay in your product
A messenger keeps conversations inside your product instead of somewhere else. These screens carry replies, reactions, photos, files, voice messages, typing and read status, the details people expect from any chat they use every day.
List and chat puts conversations with search, an unread filter and typing next to the open chat, phones switch between both with a back button. Conversation shows one chat with day dividers, a pinned message, a mark for new messages, a jump to the latest and the shared files beside it. Channel with threads lays a team channel out as rows with names and roles, answers in a thread beside it and members by presence. Floating messenger starts from a launcher with the unread count, opens a small panel with the chats and closes with Escape, phones get the full screen. Message search looks through every conversation by words, person, place and attachments and shows each hit in its context.
Choose the shape from the relationship, two pane for support and sales, channels for teams, the floating messenger for a quick question on any page. Show read status quietly, a grey tick is enough. Keep the composer fixed at the bottom so the keyboard never covers it on phones.
Variants
Details
What Messenger brings along, read from the registry and the source.
- Category
- Team and collaboration
- Area
- App
- Variants
- 5
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/messenger.tsx
- npm packages
- None
- Languages
- English and German, 92 texts of its own
- Studio
- Adjust in code
- In collections
- SaaS app screens
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The MessengerProps interface from components/ui/messenger.tsx, with the comments from the code.
variantoptionalMessengerVarianttwo-panethreadchannelwidgetsearch
sidebaroptionalOmit<SidebarProps, 'children'> | nullThe app frame. Leave it out (or null) for a standalone screen.
titleoptionalstringsubtitleoptionalstringmerequiredstringYour person id
peoplerequiredMessengerPerson[]conversationsrequiredMessengerConversation[]selectedoptionalstringId of the open conversation
nowoptionalstringISO date and time that counts as now
repliesoptionalstring[]Answers the other side sends after yours, in turn. Without them nobody answers.
mobileViewoptional'list' | 'detail'two-pane and search: which pane a phone shows first
positionoptional'contained' | 'fixed'widget: contained keeps it inside its parent (previews), fixed pins it to the window
backdropoptionalReact.ReactNodewidget: the page behind it in contained mode
defaultOpenoptionalbooleanwidget: open on load
queryoptionalstringsearch: the starting query
onActionoptional(action: string, data: Record<string, unknown>) => void | Promise<void>Called on send, react, download and open. Without it the block simulates the other side.
Built-in texts
Messenger writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 92 texts
| Key | English | German |
|---|---|---|
| messages | English Messages | German Nachrichten |
| conversations | English Conversations | German Unterhaltungen |
| search | English Search conversations | German Unterhaltungen durchsuchen |
| newMessage | English New message | German Neue Nachricht |
| all | English All | German Alle |
| unread | English Unread | German Ungelesen |
| pinned | English Pinned | German Angeheftet |
| chats | English Chats | German Chats |
| noChats | English Every conversation is read. | German Alle Unterhaltungen sind gelesen. |
| back | English Back | German Zurück |
| you | English You | German Du |
| online | English Online | German Online |
| away | English Away | German Abwesend |
| offline | English Offline | German Offline |
| members | English {n} members | German {n} Mitglieder |
| membersTitle | English Members | German Mitglieder |
| onlineN | English Online, {n} | German Online, {n} |
| offlineN | English Offline, {n} | German Offline, {n} |
| typing | English {name} is typing | German {name} schreibt |
| write | English Message {name} | German Nachricht an {name} |
| send | English Send | German Senden |
| attach | English Attach files | German Dateien anhängen |
| record | English Record a voice message | German Sprachnachricht aufnehmen |
| stopRecord | English Stop and add the recording | German Aufnahme beenden und anhängen |
| cancelRecord | English Discard the recording | German Aufnahme verwerfen |
| recording | English Recording | German Aufnahme läuft |
| remove | English Remove {name} | German {name} entfernen |
| replyingTo | English Replying to {name} | German Antwort an {name} |
| cancelReply | English Cancel the reply | German Antwort abbrechen |
| react | English Add a reaction | German Reaktion hinzufügen |
| reply | English Reply | German Antworten |
| copy | English Copy text | German Text kopieren |
| copied | English Text copied. | German Text kopiert. |
| reactedYou | English you reacted | German du hast reagiert |
| reaction | English {emoji} {n} | German {emoji} {n} |
| toggleReaction | English React with {emoji} | German Mit {emoji} reagieren |
| sent | English Sent | German Gesendet |
| delivered | English Delivered | German Zugestellt |
| read | English Read | German Gelesen |
| today | English Today | German Heute |
| yesterday | English Yesterday | German Gestern |
| newSince | English New messages | German Neue Nachrichten |
| jumpLatest | English Jump to the latest message | German Zur neuesten Nachricht |
| newBelow | English {n} new | German {n} neu |
| photo | English Photo | German Foto |
| file | English File | German Datei |
| voice | English Voice message | German Sprachnachricht |
| voiceN | English Voice message, {d} | German Sprachnachricht, {d} |
| play | English Play the voice message | German Sprachnachricht abspielen |
| pause | English Pause the voice message | German Sprachnachricht anhalten |
| seek | English Position in the voice message | German Stelle in der Sprachnachricht |
| download | English Download {name} | German {name} herunterladen |
| downloading | English Download of {name} started. | German Download von {name} gestartet. |
| hint | English Enter sends, Shift and Enter add a line | German Enter sendet, Umschalt und Enter für eine neue Zeile |
| call | English Start a call | German Anruf starten |
| video | English Start a video call | German Videoanruf starten |
| info | English Shared files | German Geteilte Dateien |
| hideInfo | English Hide shared files | German Geteilte Dateien ausblenden |
| shared | English Shared in this chat | German In diesem Chat geteilt |
| noShared | English Photos, files and voice messages land here. | German Fotos, Dateien und Sprachnachrichten landen hier. |
| replies | English {n} replies | German {n} Antworten |
| oneReply | English 1 reply | German 1 Antwort |
| lastReply | English last {when} | German zuletzt {when} |
| thread | English Thread | German Thread |
| closeThread | English Close the thread | German Thread schließen |
| replyThread | English Reply in the thread | German Im Thread antworten |
| alsoChannel | English Also send to #{name} | German Auch an #{name} senden |
| showMembers | English Show members | German Mitglieder zeigen |
| hideMembers | English Hide members | German Mitglieder ausblenden |
| openChat | English Open messages | German Nachrichten öffnen |
| closeChat | English Close messages | German Nachrichten schließen |
| minimize | English Minimize | German Minimieren |
| widgetHello | English Hi {name}, pick up where you left off. | German Hallo {name}, mach da weiter, wo du warst. |
| searchMessages | English Search messages | German Nachrichten durchsuchen |
| searchHint | English Words, names or file names | German Wörter, Namen oder Dateinamen |
| from | English From | German Von |
| in | English In | German In |
| anyone | English Anyone | German Alle Personen |
| anywhere | English All conversations | German Alle Unterhaltungen |
| has | English Contains | German Enthält |
| hasFile | English Files | German Dateien |
| hasImage | English Photos | German Fotos |
| hasVoice | English Voice | German Sprache |
| results | English {n} results for “{q}” | German {n} Treffer für „{q}“ |
| oneResult | English 1 result for “{q}” | German 1 Treffer für „{q}“ |
| noResults | English Nothing matches yet. Try fewer words or another person. | German Noch kein Treffer. Versuche weniger Wörter oder eine andere Person. |
| typeToSearch | English Type a word to search every conversation. | German Gib ein Wort ein, um alle Unterhaltungen zu durchsuchen. |
| context | English In context | German Im Zusammenhang |
| openInChat | English Open in the conversation | German In der Unterhaltung öffnen |
| resultsLabel | English Results | German Treffer |
| sentNote | English Message sent to {name}. | German Nachricht an {name} gesendet. |
| from_ | English {name}: {text} | German {name}: {text} |
Accessibility
Read from the source of the component.
- Messenger handles the keys Escape, Enter, Arrow up, Arrow down, Arrow left, Arrow right, Home, End and Space itself.
- Sets aria-activedescendant, aria-checked, aria-controls, aria-current, aria-expanded, aria-label, aria-live, aria-pressed, aria-relevant, aria-selected, aria-valuemax, aria-valuemin, aria-valuenow and aria-valuetext, so screen readers announce purpose and state.
- Uses the roles dialog, group, listbox, log, option, radio, radiogroup, separator, slider and status.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 2,074 lines
'use client';
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { cn, type Locale, prefersReducedMotion, round, useLocale, useTexts } from '@/lib/kit';
import { Avatar } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { IconButton } from '@/components/ui/icon-button';
import { Scene, type SceneKind } from '@/components/ui/photos';
import { Sidebar, type SidebarProps } from '@/components/ui/sidebar';
export type MessengerVariant = 'two-pane' | 'thread' | 'channel' | 'widget' | 'search';
export interface MessengerPerson {
id: string;
name: string;
/** Job or a short note, shown in headers and the member list */
role?: string;
status?: 'online' | 'away' | 'offline';
}
export type MessengerAttachment =
| { kind: 'image'; name: string; scene?: SceneKind; seed?: number }
| { kind: 'file'; name: string; /** Bytes */ size: number }
| { kind: 'voice'; /** Seconds */ duration: number; /** Bar heights from 0 to 1 */ wave?: number[] };
export interface MessengerReaction {
emoji: string;
/** Person ids */
by: string[];
}
export interface MessengerMessage {
id: string;
/** Person id */
from: string;
/** ISO date and time */
time: string;
text?: string;
attachments?: MessengerAttachment[];
reactions?: MessengerReaction[];
/** Id of the message this one answers */
replyTo?: string;
/** Own messages: how far they got */
status?: 'sent' | 'delivered' | 'read';
/** channel: answers in the thread */
replies?: MessengerMessage[];
pinned?: boolean;
}
export interface MessengerConversation {
id: string;
/** Group and channel name. Direct chats take the other person's name. */
name?: string;
kind?: 'direct' | 'group' | 'channel';
/** Person ids, including you */
members: string[];
messages: MessengerMessage[];
unread?: number;
muted?: boolean;
pinned?: boolean;
topic?: string;
/** Person id of someone typing right now */
typing?: string;
/** thread: the last message you read, a divider marks what came after */
lastRead?: string;
}
export interface MessengerProps {
variant?: MessengerVariant;
/** The app frame. Leave it out (or null) for a standalone screen. */
sidebar?: Omit<SidebarProps, 'children'> | null;
title?: string;
subtitle?: string;
/** Your person id */
me: string;
people: MessengerPerson[];
conversations: MessengerConversation[];
/** Id of the open conversation */
selected?: string;
/** ISO date and time that counts as now */
now?: string;
/** Answers the other side sends after yours, in turn. Without them nobody answers. */
replies?: string[];
/** two-pane and search: which pane a phone shows first */
mobileView?: 'list' | 'detail';
/** widget: contained keeps it inside its parent (previews), fixed pins it to the window */
position?: 'contained' | 'fixed';
/** widget: the page behind it in contained mode */
backdrop?: React.ReactNode;
/** widget: open on load */
defaultOpen?: boolean;
/** search: the starting query */
query?: string;
/** Called on send, react, download and open. Without it the block simulates the other side. */
onAction?: (action: string, data: Record<string, unknown>) => void | Promise<void>;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TEXTS = {
en: {
messages: 'Messages',
conversations: 'Conversations',
search: 'Search conversations',
newMessage: 'New message',
all: 'All',
unread: 'Unread',
pinned: 'Pinned',
chats: 'Chats',
noChats: 'Every conversation is read.',
back: 'Back',
you: 'You',
online: 'Online',
away: 'Away',
offline: 'Offline',
members: '{n} members',
membersTitle: 'Members',
onlineN: 'Online, {n}',
offlineN: 'Offline, {n}',
typing: '{name} is typing',
write: 'Message {name}',
send: 'Send',
attach: 'Attach files',
record: 'Record a voice message',
stopRecord: 'Stop and add the recording',
cancelRecord: 'Discard the recording',
recording: 'Recording',
remove: 'Remove {name}',
replyingTo: 'Replying to {name}',
cancelReply: 'Cancel the reply',
react: 'Add a reaction',
reply: 'Reply',
copy: 'Copy text',
copied: 'Text copied.',
reactedYou: 'you reacted',
reaction: '{emoji} {n}',
toggleReaction: 'React with {emoji}',
sent: 'Sent',
delivered: 'Delivered',
read: 'Read',
today: 'Today',
yesterday: 'Yesterday',
newSince: 'New messages',
jumpLatest: 'Jump to the latest message',
newBelow: '{n} new',
photo: 'Photo',
file: 'File',
voice: 'Voice message',
voiceN: 'Voice message, {d}',
play: 'Play the voice message',
pause: 'Pause the voice message',
seek: 'Position in the voice message',
download: 'Download {name}',
downloading: 'Download of {name} started.',
hint: 'Enter sends, Shift and Enter add a line',
call: 'Start a call',
video: 'Start a video call',
info: 'Shared files',
hideInfo: 'Hide shared files',
shared: 'Shared in this chat',
noShared: 'Photos, files and voice messages land here.',
replies: '{n} replies',
oneReply: '1 reply',
lastReply: 'last {when}',
thread: 'Thread',
closeThread: 'Close the thread',
replyThread: 'Reply in the thread',
alsoChannel: 'Also send to #{name}',
showMembers: 'Show members',
hideMembers: 'Hide members',
openChat: 'Open messages',
closeChat: 'Close messages',
minimize: 'Minimize',
widgetHello: 'Hi {name}, pick up where you left off.',
searchMessages: 'Search messages',
searchHint: 'Words, names or file names',
from: 'From',
in: 'In',
anyone: 'Anyone',
anywhere: 'All conversations',
has: 'Contains',
hasFile: 'Files',
hasImage: 'Photos',
hasVoice: 'Voice',
results: '{n} results for “{q}”',
oneResult: '1 result for “{q}”',
noResults: 'Nothing matches yet. Try fewer words or another person.',
typeToSearch: 'Type a word to search every conversation.',
context: 'In context',
openInChat: 'Open in the conversation',
resultsLabel: 'Results',
sentNote: 'Message sent to {name}.',
from_: '{name}: {text}',
},
de: {
messages: 'Nachrichten',
conversations: 'Unterhaltungen',
search: 'Unterhaltungen durchsuchen',
newMessage: 'Neue Nachricht',
all: 'Alle',
unread: 'Ungelesen',
pinned: 'Angeheftet',
chats: 'Chats',
noChats: 'Alle Unterhaltungen sind gelesen.',
back: 'Zurück',
you: 'Du',
online: 'Online',
away: 'Abwesend',
offline: 'Offline',
members: '{n} Mitglieder',
membersTitle: 'Mitglieder',
onlineN: 'Online, {n}',
offlineN: 'Offline, {n}',
typing: '{name} schreibt',
write: 'Nachricht an {name}',
send: 'Senden',
attach: 'Dateien anhängen',
record: 'Sprachnachricht aufnehmen',
stopRecord: 'Aufnahme beenden und anhängen',
cancelRecord: 'Aufnahme verwerfen',
recording: 'Aufnahme läuft',
remove: '{name} entfernen',
replyingTo: 'Antwort an {name}',
cancelReply: 'Antwort abbrechen',
react: 'Reaktion hinzufügen',
reply: 'Antworten',
copy: 'Text kopieren',
copied: 'Text kopiert.',
reactedYou: 'du hast reagiert',
reaction: '{emoji} {n}',
toggleReaction: 'Mit {emoji} reagieren',
sent: 'Gesendet',
delivered: 'Zugestellt',
read: 'Gelesen',
today: 'Heute',
yesterday: 'Gestern',
newSince: 'Neue Nachrichten',
jumpLatest: 'Zur neuesten Nachricht',
newBelow: '{n} neu',
photo: 'Foto',
file: 'Datei',
voice: 'Sprachnachricht',
voiceN: 'Sprachnachricht, {d}',
play: 'Sprachnachricht abspielen',
pause: 'Sprachnachricht anhalten',
seek: 'Stelle in der Sprachnachricht',
download: '{name} herunterladen',
downloading: 'Download von {name} gestartet.',
hint: 'Enter sendet, Umschalt und Enter für eine neue Zeile',
call: 'Anruf starten',
video: 'Videoanruf starten',
info: 'Geteilte Dateien',
hideInfo: 'Geteilte Dateien ausblenden',
shared: 'In diesem Chat geteilt',
noShared: 'Fotos, Dateien und Sprachnachrichten landen hier.',
replies: '{n} Antworten',
oneReply: '1 Antwort',
lastReply: 'zuletzt {when}',
thread: 'Thread',
closeThread: 'Thread schließen',
replyThread: 'Im Thread antworten',
alsoChannel: 'Auch an #{name} senden',
showMembers: 'Mitglieder zeigen',
hideMembers: 'Mitglieder ausblenden',
openChat: 'Nachrichten öffnen',
closeChat: 'Nachrichten schließen',
minimize: 'Minimieren',
widgetHello: 'Hallo {name}, mach da weiter, wo du warst.',
searchMessages: 'Nachrichten durchsuchen',
searchHint: 'Wörter, Namen oder Dateinamen',
from: 'Von',
in: 'In',
anyone: 'Alle Personen',
anywhere: 'Alle Unterhaltungen',
has: 'Enthält',
hasFile: 'Dateien',
hasImage: 'Fotos',
hasVoice: 'Sprache',
results: '{n} Treffer für „{q}“',
oneResult: '1 Treffer für „{q}“',
noResults: 'Noch kein Treffer. Versuche weniger Wörter oder eine andere Person.',
typeToSearch: 'Gib ein Wort ein, um alle Unterhaltungen zu durchsuchen.',
context: 'Im Zusammenhang',
openInChat: 'In der Unterhaltung öffnen',
resultsLabel: 'Treffer',
sentNote: 'Nachricht an {name} gesendet.',
from_: '{name}: {text}',
},
};
type Texts = (typeof TEXTS)['en'];
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
/* ------------------------------------------------------------------ */
/* Motion */
/* ------------------------------------------------------------------ */
/*
* b5m-in a new message rises in
* b5m-pop a reaction chip pops
* b5m-flash the message a reply or a search hit points at lights up
* b5m-dot typing dots
* b5m-rise entrance of the panes on load
* Only transform, opacity and background change. Reduced motion keeps all still.
*/
const CSS =
'@keyframes b5m-in{from{opacity:0;transform:translateY(10px) scale(.98)}}@keyframes b5m-pop{from{opacity:0;transform:scale(.6)}}' +
'@keyframes b5m-flash{from{background-color:color-mix(in oklab,var(--primary) 18%,transparent)}}@keyframes b5m-dot{0%,60%,100%{opacity:.35;transform:none}30%{opacity:1;transform:translateY(-3px)}}' +
'@keyframes b5m-rise{from{opacity:0;transform:translateY(12px)}}@keyframes b5m-rec{50%{opacity:.25}}@keyframes b5m-panel{from{opacity:0;transform:translateY(16px) scale(.97)}}' +
'@media (prefers-reduced-motion:no-preference){.b5m-in{animation:b5m-in .45s var(--ease-kit) both}.b5m-pop{animation:b5m-pop .35s cubic-bezier(.34,1.56,.64,1) both}' +
'.b5m-flash{animation:b5m-flash 1.8s ease-out both}.b5m-dot{animation:b5m-dot 1.2s ease-in-out infinite}.b5m-rise{animation:b5m-rise .6s var(--ease-kit) var(--b5m-d,0ms) both}' +
'.b5m-rec{animation:b5m-rec 1.1s steps(2) infinite}.b5m-panel{animation:b5m-panel .4s var(--ease-kit) both;transform-origin:bottom right}}';
function Style() {
return (
<style href="kitb5-messenger" precedence="default">
{CSS}
</style>
);
}
/* ------------------------------------------------------------------ */
/* Time and numbers */
/* ------------------------------------------------------------------ */
const MIN = 60000;
const toMs = (iso: string) => Date.parse(iso.length <= 10 ? iso + 'T00:00:00Z' : iso + (iso.endsWith('Z') ? '' : 'Z'));
const intl = (l: Locale) => (l === 'de' ? 'de-DE' : 'en-US');
const dayKey = (iso: string) => iso.slice(0, 10);
function clock(iso: string, l: Locale) {
return new Intl.DateTimeFormat(intl(l), { hour: l === 'de' ? '2-digit' : 'numeric', minute: '2-digit', timeZone: 'UTC' }).format(new Date(toMs(iso)));
}
function dayLabel(iso: string, now: string, l: Locale, t: Texts) {
const d = Math.round((toMs(dayKey(now)) - toMs(dayKey(iso))) / (1440 * MIN));
if (d === 0) return t.today;
if (d === 1) return t.yesterday;
return new Intl.DateTimeFormat(intl(l), d < 7 ? { weekday: 'long', day: 'numeric', month: 'long', timeZone: 'UTC' } : { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' }).format(new Date(toMs(iso)));
}
/** Short stamp for lists: the time today, then yesterday, a weekday, a date */
function stamp(iso: string, now: string, l: Locale, t: Texts) {
const d = Math.round((toMs(dayKey(now)) - toMs(dayKey(iso))) / (1440 * MIN));
if (d === 0) return clock(iso, l);
if (d === 1) return t.yesterday;
if (d < 7) return new Intl.DateTimeFormat(intl(l), { weekday: 'short', timeZone: 'UTC' }).format(new Date(toMs(iso)));
return new Intl.DateTimeFormat(intl(l), { day: 'numeric', month: 'short', timeZone: 'UTC' }).format(new Date(toMs(iso)));
}
function ago(iso: string, now: string, l: Locale) {
const rtf = new Intl.RelativeTimeFormat(intl(l), { numeric: 'auto' });
const m = Math.round((toMs(iso) - toMs(now)) / MIN);
const a = Math.abs(m);
if (a < 60) return rtf.format(m, 'minute');
if (a < 1440) return rtf.format(Math.round(m / 60), 'hour');
return rtf.format(Math.round(m / 1440), 'day');
}
const duration = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
function bytes(n: number, l: Locale) {
const kb = n / 1024;
const [v, unit] = kb >= 1024 ? [kb / 1024, 'megabyte'] : [kb, 'kilobyte'];
return new Intl.NumberFormat(intl(l), { style: 'unit', unit, unitDisplay: 'short', maximumFractionDigits: v < 10 ? 1 : 0 }).format(v);
}
const ext = (name: string) => (name.includes('.') ? name.split('.').pop()!.slice(0, 4).toUpperCase() : 'FILE');
/** A steady waveform from a seed, so server and browser draw the same bars */
function wave(seed: number, n = 36) {
return Array.from({ length: n }, (_, i) => round(0.25 + 0.75 * Math.abs(Math.sin(seed * 1.7 + i * 0.9) * Math.cos(i * 0.37 + seed)), 2));
}
/* ------------------------------------------------------------------ */
/* Conversation state with the simulated other side */
/* ------------------------------------------------------------------ */
type Ctx = {
t: Texts;
locale: Locale;
me: string;
now: string;
person: (id: string) => MessengerPerson;
onAction?: MessengerProps['onAction'];
};
const wait = (ms: number) => new Promise((r) => setTimeout(r, prefersReducedMotion() ? Math.min(ms, 300) : ms));
function convName(c: MessengerConversation, ctx: Ctx) {
if (c.name) return c.name;
const other = c.members.find((m) => m !== ctx.me);
return other ? ctx.person(other).name : ctx.t.you;
}
function preview(m: MessengerMessage | undefined, ctx: Ctx) {
if (!m) return '';
const a = m.attachments?.[0];
const body = m.text || (a ? (a.kind === 'image' ? ctx.t.photo : a.kind === 'voice' ? fill(ctx.t.voiceN, { d: duration(a.duration) }) : a.name) : '');
return m.from === ctx.me ? `${ctx.t.you}: ${body}` : body;
}
function useChats(conversations: MessengerConversation[], ctx: Ctx, replies: string[] = []) {
const [msgs, setMsgs] = useState<Record<string, MessengerMessage[]>>(() => Object.fromEntries(conversations.map((c) => [c.id, c.messages])));
const [typing, setTyping] = useState<Record<string, string | undefined>>(() => Object.fromEntries(conversations.map((c) => [c.id, c.typing])));
const [announce, setAnnounce] = useState('');
const turn = useRef(0);
const seq = useRef(0);
const alive = useRef(true);
useEffect(() => {
alive.current = true;
return () => {
alive.current = false;
};
}, []);
// The Studio edits the props: take new conversations over
const sig = JSON.stringify(conversations.map((c) => [c.id, c.messages.length, c.typing]));
const first = useRef(true);
useEffect(() => {
if (first.current) return void (first.current = false);
setMsgs(Object.fromEntries(conversations.map((c) => [c.id, c.messages])));
setTyping(Object.fromEntries(conversations.map((c) => [c.id, c.typing])));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sig]);
const patch = (cid: string, id: string, fn: (m: MessengerMessage) => MessengerMessage) =>
setMsgs((all) => ({ ...all, [cid]: (all[cid] ?? []).map((m) => (m.id === id ? fn(m) : m)) }));
const send = async (cid: string, draft: { text: string; attachments: MessengerAttachment[]; replyTo?: string }, parent?: string) => {
const conv = conversations.find((c) => c.id === cid);
const id = `n${Date.now()}-${seq.current++}`;
const msg: MessengerMessage = { id, from: ctx.me, time: ctx.now, text: draft.text || undefined, attachments: draft.attachments.length ? draft.attachments : undefined, replyTo: draft.replyTo, status: 'sent' };
if (parent) {
patch(cid, parent, (m) => ({ ...m, replies: [...(m.replies ?? []), msg] }));
} else setMsgs((all) => ({ ...all, [cid]: [...(all[cid] ?? []), msg] }));
setAnnounce(fill(ctx.t.sentNote, { name: conv ? convName(conv, ctx) : '' }));
if (ctx.onAction) return void (await ctx.onAction('send', { conversation: cid, parent, message: msg }));
const other = conv?.members.find((m) => m !== ctx.me && ctx.person(m).status !== 'offline') ?? conv?.members.find((m) => m !== ctx.me);
const status = (s: MessengerMessage['status']) => (parent ? patch(cid, parent, (m) => ({ ...m, replies: m.replies?.map((r) => (r.id === id ? { ...r, status: s } : r)) })) : patch(cid, id, (m) => ({ ...m, status: s })));
await wait(500);
if (!alive.current) return;
status('delivered');
if (!other || !replies.length) return;
await wait(800);
if (!alive.current) return;
status('read');
if (!parent) setTyping((x) => ({ ...x, [cid]: other }));
await wait(1700);
if (!alive.current) return;
const text = replies[turn.current++ % replies.length];
const answer: MessengerMessage = { id: `r${Date.now()}-${seq.current++}`, from: other, time: ctx.now, text };
if (parent) patch(cid, parent, (m) => ({ ...m, replies: [...(m.replies ?? []), answer] }));
else {
setTyping((x) => ({ ...x, [cid]: undefined }));
setMsgs((all) => ({ ...all, [cid]: [...(all[cid] ?? []), answer] }));
}
setAnnounce(fill(ctx.t.from_, { name: ctx.person(other).name, text }));
};
const react = (cid: string, mid: string, emoji: string, parent?: string) => {
const toggle = (m: MessengerMessage): MessengerMessage => {
const list = m.reactions ?? [];
const have = list.find((r) => r.emoji === emoji);
const mine = have?.by.includes(ctx.me);
const next = have ? list.map((r) => (r.emoji === emoji ? { ...r, by: mine ? r.by.filter((b) => b !== ctx.me) : [...r.by, ctx.me] } : r)).filter((r) => r.by.length) : [...list, { emoji, by: [ctx.me] }];
return { ...m, reactions: next };
};
if (parent) patch(cid, parent, (m) => ({ ...m, replies: m.replies?.map((r) => (r.id === mid ? toggle(r) : r)) }));
else patch(cid, mid, toggle);
void ctx.onAction?.('react', { conversation: cid, message: mid, emoji });
};
return { msgs, typing, send, react, announce, setAnnounce };
}
/* ------------------------------------------------------------------ */
/* Parts */
/* ------------------------------------------------------------------ */
function Live({ text }: { text: string }) {
return (
<p role="status" aria-live="polite" className="sr-only">
{text}
</p>
);
}
const ring = 'outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-[color-mix(in_oklab,var(--primary)_70%,transparent)]';
const ringIn = 'outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-[color-mix(in_oklab,var(--primary)_70%,transparent)]';
function Person({ p, size = 'sm', status = true, className }: { p: MessengerPerson; size?: 'xs' | 'sm' | 'md'; status?: boolean; className?: string }) {
return <Avatar name={p.name} size={size} alt="" status={status && p.status && p.status !== 'offline' ? p.status : undefined} className={className} />;
}
/** Two stacked avatars for groups */
function GroupMark({ c, ctx, size = 'md' }: { c: MessengerConversation; ctx: Ctx; size?: 'sm' | 'md' }) {
if (c.kind === 'channel')
return (
<span className={cn('grid shrink-0 place-items-center rounded-lg bg-[color-mix(in_oklab,var(--primary)_12%,var(--surface-2))] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]', size === 'md' ? 'size-10' : 'size-8')}>
<Icon name="hash" size={size === 'md' ? 18 : 15} />
</span>
);
const others = c.members.filter((m) => m !== ctx.me);
if (c.kind !== 'group' || others.length < 2) {
const p = ctx.person(others[0] ?? ctx.me);
return <Person p={p} size={size} />;
}
return (
<span className={cn('relative shrink-0', size === 'md' ? 'size-10' : 'size-8')} aria-hidden>
<Avatar name={ctx.person(others[0]).name} size="xs" alt="" className={cn('absolute left-0 top-0', size === 'md' && 'size-[1.625rem] text-[9.5px]')} />
<Avatar name={ctx.person(others[1]).name} size="xs" alt="" ring className={cn('absolute bottom-0 right-0', size === 'md' && 'size-[1.625rem] text-[9.5px]')} />
</span>
);
}
function TypingRow({ p, t, compact }: { p: MessengerPerson; t: Texts; compact?: boolean }) {
return (
<div className="b5m-in mt-3 flex items-end gap-2">
{!compact && <Person p={p} size="sm" status={false} />}
<span aria-hidden className="flex h-9 items-center gap-1 rounded-2xl rounded-bl-md bg-surface-2 px-3.5">
{[0, 1, 2].map((i) => (
<span key={i} className="b5m-dot size-1.5 rounded-full bg-muted-foreground" style={{ animationDelay: `${i * 160}ms` }} />
))}
</span>
<span className="pb-2 text-[12px] text-subtle-foreground">{fill(t.typing, { name: p.name.split(' ')[0] })}</span>
</div>
);
}
function DayDivider({ label }: { label: string }) {
return (
<div role="separator" aria-label={label} className="my-5 flex items-center gap-3 text-[12px] font-medium text-subtle-foreground first:mt-1">
<span className="h-px flex-1 bg-border" />
<span className="rounded-full border border-border bg-background px-2.5 py-0.5">{label}</span>
<span className="h-px flex-1 bg-border" />
</div>
);
}
/* Attachments --------------------------------------------------------- */
function VoicePlayer({ a, t, mine, id }: { a: Extract<MessengerAttachment, { kind: 'voice' }>; t: Texts; mine?: boolean; id: string }) {
const bars = a.wave?.length ? a.wave : wave(a.duration + id.length);
const [pos, setPos] = useState(0);
const [playing, setPlaying] = useState(false);
const raf = useRef(0);
useEffect(() => {
if (!playing) return;
let last = performance.now();
const tick = (now: number) => {
const dt = (now - last) / 1000;
last = now;
setPos((p) => {
const n = p + dt;
if (n >= a.duration) {
setPlaying(false);
return 0;
}
return n;
});
raf.current = requestAnimationFrame(tick);
};
raf.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf.current);
}, [playing, a.duration]);
const share = pos / a.duration;
const seek = (v: number) => setPos(Math.max(0, Math.min(a.duration - 0.01, v)));
return (
<div className={cn('flex w-64 max-w-full items-center gap-2.5 rounded-2xl px-2 py-1.5', mine ? 'bg-[color-mix(in_oklab,var(--primary-foreground)_14%,transparent)]' : 'bg-surface-2')}>
<button
type="button"
aria-label={playing ? t.pause : t.play}
onClick={() => setPlaying((p) => !p)}
className={cn('relative grid size-9 shrink-0 place-items-center rounded-full transition-transform duration-200 after:absolute after:-inset-1 hover:scale-105 active:scale-95', mine ? 'bg-primary-foreground text-primary' : 'bg-primary text-primary-foreground', ring)}
>
<Icon name={playing ? 'pause' : 'play'} size={15} className={cn(!playing && 'translate-x-px')} />
</button>
<div
role="slider"
tabIndex={0}
aria-label={t.seek}
aria-valuemin={0}
aria-valuemax={Math.round(a.duration)}
aria-valuenow={Math.round(pos)}
aria-valuetext={`${duration(pos)} / ${duration(a.duration)}`}
onKeyDown={(e) => {
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') (e.preventDefault(), seek(pos + 1));
if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') (e.preventDefault(), seek(pos - 1));
if (e.key === 'Home') (e.preventDefault(), seek(0));
if (e.key === ' ' || e.key === 'Enter') (e.preventDefault(), setPlaying((p) => !p));
}}
onPointerDown={(e) => {
const r = e.currentTarget.getBoundingClientRect();
seek(((e.clientX - r.left) / r.width) * a.duration);
}}
className={cn('flex h-11 min-w-0 flex-1 cursor-pointer items-center gap-[2px] rounded-md sm:h-9', ring)}
>
{bars.map((h, i) => (
<span
key={i}
aria-hidden
className={cn('w-[3px] shrink-0 rounded-full transition-colors duration-150', i / bars.length < share ? (mine ? 'bg-primary-foreground' : 'bg-primary') : mine ? 'bg-[color-mix(in_oklab,var(--primary-foreground)_40%,transparent)]' : 'bg-[color-mix(in_oklab,var(--foreground)_22%,transparent)]')}
style={{ height: `${round(18 + h * 62)}%` }}
/>
))}
</div>
<span className={cn('w-9 shrink-0 text-right text-[12px] tabular-nums', mine ? 'text-primary-foreground/85' : 'text-muted-foreground')}>{duration(playing || pos ? pos : a.duration)}</span>
</div>
);
}
function FileTile({ a, ctx, onDownload, mine }: { a: Extract<MessengerAttachment, { kind: 'file' }>; ctx: Ctx; onDownload?: () => void; mine?: boolean }) {
return (
<div className={cn('flex w-64 max-w-full items-center gap-3 rounded-xl border p-2 pe-1.5', mine ? 'border-[color-mix(in_oklab,var(--primary-foreground)_22%,transparent)] bg-[color-mix(in_oklab,var(--primary-foreground)_10%,transparent)]' : 'border-border bg-card')}>
<span className={cn('grid size-10 shrink-0 place-items-center rounded-lg text-[10px] font-bold tracking-[0.04em]', mine ? 'bg-primary-foreground text-primary' : 'bg-[color-mix(in_oklab,var(--primary)_12%,var(--surface-2))] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]')}>{ext(a.name)}</span>
<span className="min-w-0 flex-1">
<span className={cn('block truncate text-[13.5px] font-medium', mine ? 'text-primary-foreground' : 'text-foreground')}>{a.name}</span>
<span className={cn('block text-[12px] tabular-nums', mine ? 'text-primary-foreground/80' : 'text-muted-foreground')}>{bytes(a.size, ctx.locale)}</span>
</span>
<IconButton icon="download" size="sm" label={fill(ctx.t.download, { name: a.name })} onClick={onDownload} className={mine ? 'text-primary-foreground hover:bg-[color-mix(in_oklab,var(--primary-foreground)_16%,transparent)] hover:text-primary-foreground' : ''} />
</div>
);
}
function ImageTile({ a, wide }: { a: Extract<MessengerAttachment, { kind: 'image' }>; wide?: boolean }) {
return (
<figure className={cn('group/img relative overflow-hidden rounded-2xl border border-border bg-muted', wide ? 'w-72' : 'w-60', 'max-w-full')}>
<Scene kind={a.scene ?? 'architecture'} seed={a.seed ?? 3} className="block aspect-[4/3] w-full transition-transform duration-700 ease-[var(--ease-kit)] group-hover/img:scale-[1.03] motion-reduce:transition-none" />
<figcaption className="absolute inset-x-0 bottom-0 truncate bg-[linear-gradient(to_top,rgb(0_0_0/0.55),transparent)] px-3 pb-2 pt-6 text-[12px] font-medium text-white">{a.name}</figcaption>
</figure>
);
}
function Attachments({ list, ctx, mine, id, onDownload }: { list: MessengerAttachment[]; ctx: Ctx; mine?: boolean; id: string; onDownload: (name: string) => void }) {
return (
<div className={cn('flex flex-col gap-1.5', mine ? 'items-end' : 'items-start')}>
{list.map((a, i) =>
a.kind === 'image' ? <ImageTile key={i} a={a} /> : a.kind === 'voice' ? <VoicePlayer key={i} a={a} t={ctx.t} id={`${id}-${i}`} /> : <FileTile key={i} a={a} ctx={ctx} onDownload={() => onDownload(a.name)} />,
)}
</div>
);
}
/* One message ---------------------------------------------------------- */
const EMOJI = ['👍', '❤️', '🎉', '😄', '👀', '✅'];
type MsgHandlers = {
onReact: (id: string, emoji: string) => void;
onReply?: (m: MessengerMessage) => void;
onJump?: (id: string) => void;
onThread?: (m: MessengerMessage) => void;
onDownload: (name: string) => void;
onCopy: (text: string) => void;
};
function Reactions({ m, ctx, onReact, mine }: { m: MessengerMessage; ctx: Ctx; onReact: (emoji: string) => void; mine?: boolean }) {
if (!m.reactions?.length) return null;
return (
<div className={cn('mt-1 flex flex-wrap gap-1', mine && 'justify-end')}>
{m.reactions.map((r) => {
const own = r.by.includes(ctx.me);
const names = r.by.map((b) => (b === ctx.me ? ctx.t.you : ctx.person(b).name.split(' ')[0])).join(', ');
return (
<button
key={r.emoji}
type="button"
aria-pressed={own}
aria-label={`${fill(ctx.t.reaction, { emoji: r.emoji, n: r.by.length })}${own ? `, ${ctx.t.reactedYou}` : ''}`}
title={names}
onClick={() => onReact(r.emoji)}
className={cn(
'b5m-pop relative inline-flex h-7 items-center gap-1 rounded-full border px-2 text-[12.5px] tabular-nums transition-[background-color,border-color,transform] duration-200 after:absolute after:-inset-y-2 after:inset-x-0 hover:-translate-y-px',
own ? 'border-[color-mix(in_oklab,var(--primary)_45%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_12%,var(--background))] text-foreground' : 'border-border bg-background text-muted-foreground hover:border-border-strong',
ring,
)}
>
<span aria-hidden className="text-[13px] leading-none">
{r.emoji}
</span>
<span aria-hidden className="font-medium">
{r.by.length}
</span>
</button>
);
})}
</div>
);
}
/** Hover and focus toolbar of a message: react, reply, copy */
function MsgTools({ m, ctx, h, side, open, setOpen }: { m: MessengerMessage; ctx: Ctx; h: MsgHandlers; side: 'start' | 'end'; open: boolean; setOpen: (v: boolean) => void }) {
const [picker, setPicker] = useState(false);
useEffect(() => {
if (!open) setPicker(false);
}, [open]);
const btn = cn('relative grid size-8 place-items-center rounded-md text-muted-foreground transition-colors after:absolute after:-inset-1.5 hover:bg-accent hover:text-foreground', ring);
return (
<div
className={cn(
'absolute -top-4 z-10 flex items-center gap-0.5 rounded-lg border border-border bg-popover p-0.5 shadow-[var(--highlight),var(--shadow-md)] transition-[opacity,visibility,translate] duration-200',
side === 'end' ? 'end-2' : 'start-2',
open ? 'visible translate-y-0 opacity-100' : 'invisible translate-y-1 opacity-0 group-hover/msg:visible group-hover/msg:translate-y-0 group-hover/msg:opacity-100 group-focus-within/msg:visible group-focus-within/msg:translate-y-0 group-focus-within/msg:opacity-100',
)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.stopPropagation();
setPicker(false);
setOpen(false);
(e.currentTarget.closest('[data-msg]') as HTMLElement | null)?.focus();
}
}}
>
{picker ? (
<div role="group" aria-label={ctx.t.react} className="flex items-center">
{EMOJI.map((e, i) => (
<button
key={e}
type="button"
autoFocus={i === 0}
aria-label={fill(ctx.t.toggleReaction, { emoji: e })}
onClick={() => {
h.onReact(m.id, e);
setPicker(false);
setOpen(false);
}}
className={cn('b5m-pop relative grid size-8 place-items-center rounded-md text-[16px] transition-transform after:absolute after:-inset-1.5 hover:scale-125 hover:bg-accent', ring)}
style={{ animationDelay: `${i * 25}ms` }}
>
<span aria-hidden>{e}</span>
</button>
))}
</div>
) : (
<>
<button type="button" aria-label={ctx.t.react} title={ctx.t.react} onClick={() => setPicker(true)} className={btn}>
<Icon name="smile-plus" size={16} />
</button>
{h.onReply && (
<button type="button" aria-label={ctx.t.reply} title={ctx.t.reply} onClick={() => (h.onReply?.(m), setOpen(false))} className={btn}>
<Icon name="reply" size={16} />
</button>
)}
{m.text && (
<button type="button" aria-label={ctx.t.copy} title={ctx.t.copy} onClick={() => (h.onCopy(m.text ?? ''), setOpen(false))} className={btn}>
<Icon name="copy" size={15} />
</button>
)}
</>
)}
</div>
);
}
function StatusMark({ s, t }: { s?: MessengerMessage['status']; t: Texts }) {
if (!s) return null;
return (
<span className={cn('inline-flex items-center', s === 'read' ? 'text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]' : 'text-subtle-foreground')} title={t[s]}>
<Icon key={s} name={s === 'sent' ? 'check' : 'check-check'} size={14} strokeWidth={2} className="b5m-pop" />
<span className="sr-only">{t[s]}</span>
</span>
);
}
function Quote({ m, ctx, mine, onJump }: { m?: MessengerMessage; ctx: Ctx; mine?: boolean; onJump?: (id: string) => void }) {
if (!m) return null;
const p = ctx.person(m.from);
return (
<button
type="button"
onClick={() => onJump?.(m.id)}
className={cn(
'mb-1 block w-full max-w-[18rem] rounded-xl border-s-[3px] px-3 py-1.5 text-left transition-colors',
mine ? 'border-primary-foreground/70 bg-[color-mix(in_oklab,var(--primary-foreground)_14%,transparent)] hover:bg-[color-mix(in_oklab,var(--primary-foreground)_20%,transparent)]' : 'border-primary bg-[color-mix(in_oklab,var(--foreground)_5%,transparent)] hover:bg-[color-mix(in_oklab,var(--foreground)_8%,transparent)]',
ring,
)}
>
<span className={cn('block text-[12px] font-semibold', mine ? 'text-primary-foreground' : 'text-foreground')}>{m.from === ctx.me ? ctx.t.you : p.name}</span>
<span className={cn('block truncate text-[12.5px]', mine ? 'text-primary-foreground/85' : 'text-muted-foreground')}>{preview({ ...m, from: '' }, ctx)}</span>
</button>
);
}
/** A message as a bubble: own ones on the right in the accent */
function Bubble({ m, ctx, h, first, last, all, fresh, flash, index, active, compact, showName }: { m: MessengerMessage; ctx: Ctx; h: MsgHandlers; first: boolean; last: boolean; all: MessengerMessage[]; fresh?: boolean; flash?: boolean; index: number; active: boolean; compact?: boolean; showName?: boolean }) {
const mine = m.from === ctx.me;
const p = ctx.person(m.from);
const [open, setOpen] = useState(false);
const quoted = m.replyTo ? all.find((x) => x.id === m.replyTo) : undefined;
return (
<div
data-msg={index}
data-id={m.id}
tabIndex={active ? 0 : -1}
onClick={(e) => {
if ((e.target as HTMLElement).closest('button,[role=slider]')) return;
setOpen((o) => !o);
}}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) setOpen(false);
}}
className={cn('group/msg relative flex gap-2 rounded-xl px-1 py-0.5', mine ? 'justify-end' : 'justify-start', first ? 'mt-3' : 'mt-0.5', fresh && 'b5m-in', flash && 'b5m-flash', ringIn)}
>
{!mine && !compact && <span className="w-8 shrink-0 self-end">{last && <Person p={p} size="sm" status={false} />}</span>}
<div className={cn('flex min-w-0 max-w-[min(32rem,80%)] flex-col', mine ? 'items-end' : 'items-start')}>
{first && !mine && showName && <span className="mb-1 ms-1 text-[12px] font-medium text-muted-foreground">{p.name}</span>}
{(m.text || quoted) && (
<div
className={cn(
'w-fit max-w-full rounded-[1.25rem] px-3.5 py-2 text-[14px] leading-relaxed',
mine ? 'bg-primary text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.14)]' : 'bg-surface-2 text-foreground',
mine ? (last ? 'rounded-br-md' : 'rounded-r-md') : last ? 'rounded-bl-md' : 'rounded-l-md',
quoted && 'pt-1.5 ps-1.5',
)}
>
<Quote m={quoted} ctx={ctx} mine={mine} onJump={h.onJump} />
{m.text && <p className={cn('whitespace-pre-line text-pretty [overflow-wrap:anywhere]', quoted && 'ps-2')}>{m.text}</p>}
</div>
)}
{m.attachments?.length ? (
<div className={cn(m.text && 'mt-1')}>
<Attachments list={m.attachments} ctx={ctx} mine={mine} id={m.id} onDownload={h.onDownload} />
</div>
) : null}
<Reactions m={m} ctx={ctx} mine={mine} onReact={(e) => h.onReact(m.id, e)} />
{last && (
<span className={cn('mt-1 flex items-center gap-1 text-[11.5px] tabular-nums text-subtle-foreground', mine ? 'me-1' : 'ms-1')}>
{clock(m.time, ctx.locale)}
{mine && <StatusMark s={m.status} t={ctx.t} />}
</span>
)}
</div>
<MsgTools m={m} ctx={ctx} h={h} side={mine ? 'end' : 'start'} open={open} setOpen={setOpen} />
</div>
);
}
/** A message as a row with avatar and name, for channels */
function Row({ m, ctx, h, first, fresh, index, active, flash, inThread }: { m: MessengerMessage; ctx: Ctx; h: MsgHandlers; first: boolean; fresh?: boolean; index: number; active: boolean; flash?: boolean; inThread?: boolean }) {
const p = ctx.person(m.from);
const [open, setOpen] = useState(false);
const n = m.replies?.length ?? 0;
const lastReply = m.replies?.[n - 1];
const people = [...new Set(m.replies?.map((r) => r.from) ?? [])].slice(0, 3);
return (
<div
data-msg={index}
data-id={m.id}
tabIndex={active ? 0 : -1}
onClick={(e) => {
if ((e.target as HTMLElement).closest('button,[role=slider]')) return;
setOpen((o) => !o);
}}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) setOpen(false);
}}
className={cn('group/msg relative flex gap-3 rounded-lg px-2 transition-colors hover:bg-[color-mix(in_oklab,var(--foreground)_3%,transparent)] sm:px-3', first ? 'mt-3 pt-1.5 pb-1' : 'py-1', fresh && 'b5m-in', flash && 'b5m-flash', ringIn)}
>
<span className="w-9 shrink-0">{first ? <Person p={p} size="sm" status={false} className="size-9" /> : <span className="hidden pt-1 text-right text-[11px] tabular-nums text-subtle-foreground group-hover/msg:block">{clock(m.time, ctx.locale)}</span>}</span>
<div className="min-w-0 flex-1">
{first && (
<p className="flex flex-wrap items-baseline gap-x-2">
<span className="text-[14px] font-semibold text-foreground">{m.from === ctx.me ? `${p.name}` : p.name}</span>
{p.role && <span className="text-[12px] text-subtle-foreground max-sm:hidden">{p.role}</span>}
<span className="text-[12px] tabular-nums text-subtle-foreground">{clock(m.time, ctx.locale)}</span>
</p>
)}
{m.text && <p className="whitespace-pre-line text-pretty text-[14px] leading-relaxed text-foreground [overflow-wrap:anywhere]">{m.text}</p>}
{m.attachments?.length ? (
<div className="mt-1.5">
<Attachments list={m.attachments} ctx={ctx} id={m.id} onDownload={h.onDownload} />
</div>
) : null}
<Reactions m={m} ctx={ctx} onReact={(e) => h.onReact(m.id, e)} />
{!inThread && n > 0 && h.onThread && (
<button type="button" onClick={() => h.onThread?.(m)} className={cn('group/th mt-1.5 -ms-1.5 inline-flex min-h-11 items-center gap-2 rounded-lg px-1.5 text-[13px] transition-colors hover:bg-card hover:shadow-[var(--shadow-sm)] sm:min-h-9', ring)}>
<span className="flex -space-x-1.5">
{people.map((id) => (
<Avatar key={id} name={ctx.person(id).name} size="xs" alt="" ring className="size-5 text-[8px]" />
))}
</span>
<span className="font-semibold text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]">{n === 1 ? ctx.t.oneReply : fill(ctx.t.replies, { n })}</span>
{lastReply && <span className="text-subtle-foreground">{fill(ctx.t.lastReply, { when: ago(lastReply.time, ctx.now, ctx.locale) })}</span>}
<Icon name="chevron-right" size={14} className="text-subtle-foreground transition-transform group-hover/th:translate-x-0.5" />
</button>
)}
</div>
<MsgTools m={m} ctx={ctx} h={{ ...h, onReply: inThread ? undefined : h.onThread }} side="end" open={open} setOpen={setOpen} />
</div>
);
}
/* The message log ------------------------------------------------------ */
function useCopy(t: Texts, setAnnounce: (s: string) => void) {
return (text: string) => {
void navigator.clipboard?.writeText(text).catch(() => undefined);
setAnnounce(t.copied);
};
}
/**
* The scrolling log. Arrow keys move between messages (one tab stop),
* a focused message shows its toolbar, Tab reaches it.
*/
function Log({ messages, ctx, h, typing, style = 'bubble', label, compact, showNames, lastRead, onScrollState, scrollRef, className, flashId, inThread }: { messages: MessengerMessage[]; ctx: Ctx; h: MsgHandlers; typing?: string; style?: 'bubble' | 'row'; label: string; compact?: boolean; showNames?: boolean; lastRead?: string; onScrollState?: (atBottom: boolean) => void; scrollRef?: React.RefObject<HTMLDivElement | null>; className?: string; flashId?: string; inThread?: boolean }) {
const own = useRef<HTMLDivElement>(null);
const ref = scrollRef ?? own;
const [active, setActive] = useState(messages.length - 1);
const initial = useRef(new Set(messages.map((m) => m.id)));
const count = messages.length;
const lastMine = messages[count - 1]?.from === ctx.me;
// Stick to the newest message on load, on own messages and while at the bottom
const atBottom = useRef(true);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
if (atBottom.current || lastMine) el.scrollTop = el.scrollHeight;
setActive((a) => (a >= count - 2 ? count - 1 : a));
}, [count, typing, lastMine, ref]);
const move = (e: React.KeyboardEvent<HTMLDivElement>) => {
if ((e.target as HTMLElement).closest('[data-msg] button,[data-msg] [role=slider]') && e.key !== 'Escape') return;
const k = e.key;
let next = active;
if (k === 'ArrowDown') next = Math.min(count - 1, active + 1);
else if (k === 'ArrowUp') next = Math.max(0, active - 1);
else if (k === 'Home') next = 0;
else if (k === 'End') next = count - 1;
else return;
e.preventDefault();
setActive(next);
ref.current?.querySelector<HTMLElement>(`[data-msg="${next}"]`)?.focus();
};
return (
<div
ref={ref}
role="log"
aria-live="polite"
aria-relevant="additions"
aria-label={label}
onKeyDown={move}
onFocus={(e) => {
const i = (e.target as HTMLElement).closest('[data-msg]')?.getAttribute('data-msg');
if (i !== null && i !== undefined) setActive(Number(i));
}}
onScroll={(e) => {
const el = e.currentTarget;
const b = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
if (b !== atBottom.current) onScrollState?.(b);
atBottom.current = b;
}}
className={cn('kit-scrollbar relative min-h-0 flex-1 overflow-y-auto overscroll-contain', className)}
>
{messages.map((m, i) => {
const prev = messages[i - 1];
const next = messages[i + 1];
const newDay = !prev || dayKey(prev.time) !== dayKey(m.time);
const gap = (a?: MessengerMessage, b?: MessengerMessage) => !a || !b || a.from !== b.from || dayKey(a.time) !== dayKey(b.time) || toMs(b.time) - toMs(a.time) > 10 * MIN;
const first = gap(prev, m) || prev?.id === lastRead;
const last = gap(m, next) || m.id === lastRead;
const fresh = !initial.current.has(m.id);
const props = { m, ctx, h, first, index: i, active: i === active, fresh, flash: flashId === m.id };
return (
<div key={m.id}>
{newDay && <DayDivider label={dayLabel(m.time, ctx.now, ctx.locale, ctx.t)} />}
{prev && prev.id === lastRead && (
<div role="separator" aria-label={ctx.t.newSince} className="my-4 flex items-center gap-3 text-[12px] font-semibold text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]">
<span className="h-px flex-1 bg-[color-mix(in_oklab,var(--primary)_45%,transparent)]" />
{ctx.t.newSince}
</div>
)}
{style === 'row' ? <Row {...props} inThread={inThread} /> : <Bubble {...props} last={last} all={messages} compact={compact} showName={showNames} />}
</div>
);
})}
{typing && <TypingRow p={ctx.person(typing)} t={ctx.t} compact={compact} />}
<div className="h-2" />
</div>
);
}
/* Composer ------------------------------------------------------------- */
function Composer({ ctx, placeholder, onSend, reply, onCancelReply, compact, extra, autoFocusKey }: { ctx: Ctx; placeholder: string; onSend: (d: { text: string; attachments: MessengerAttachment[]; replyTo?: string }) => void; reply?: MessengerMessage | null; onCancelReply?: () => void; compact?: boolean; extra?: React.ReactNode; autoFocusKey?: unknown }) {
const { t } = ctx;
const id = useId();
const [text, setText] = useState('');
const [files, setFiles] = useState<MessengerAttachment[]>([]);
const [rec, setRec] = useState<number | null>(null);
const area = useRef<HTMLTextAreaElement>(null);
const picker = useRef<HTMLInputElement>(null);
const seed = useRef(1);
useEffect(() => {
if (rec === null) return;
const timer = window.setInterval(() => setRec((r) => (r === null ? r : r + 1)), 1000);
return () => window.clearInterval(timer);
}, [rec === null]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (autoFocusKey !== undefined && reply) area.current?.focus();
}, [autoFocusKey, reply]);
// Grow with the text up to six lines
useLayoutEffect(() => {
const el = area.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = `${Math.min(el.scrollHeight, 152)}px`;
}, [text]);
const can = !!text.trim() || files.length > 0;
const send = () => {
if (!can) return;
onSend({ text: text.trim(), attachments: files, replyTo: reply?.id });
setText('');
setFiles([]);
onCancelReply?.();
};
const stop = (keep: boolean) => {
if (keep && rec !== null) setFiles((f) => [...f, { kind: 'voice', duration: Math.max(2, rec), wave: wave(seed.current++ * 3.1) }]);
setRec(null);
area.current?.focus();
};
const chip = (a: MessengerAttachment) => (a.kind === 'voice' ? fill(t.voiceN, { d: duration(a.duration) }) : a.name);
return (
<div className="rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] transition-[border-color,box-shadow] duration-300 focus-within:border-[color-mix(in_oklab,var(--primary)_45%,var(--border-strong))] focus-within:shadow-[var(--highlight),0_0_0_3px_color-mix(in_oklab,var(--primary)_14%,transparent)]">
{reply && (
<div className="b5m-in flex items-center gap-2 border-b border-border px-3 py-2">
<Icon name="reply" size={15} className="shrink-0 text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]" />
<span className="min-w-0 flex-1 text-[12.5px]">
<span className="font-semibold text-foreground">{fill(t.replyingTo, { name: reply.from === ctx.me ? t.you : ctx.person(reply.from).name })}</span>
<span className="block truncate text-muted-foreground">{preview({ ...reply, from: '' }, ctx)}</span>
</span>
<IconButton icon="x" size="xs" label={t.cancelReply} onClick={() => (onCancelReply?.(), area.current?.focus())} />
</div>
)}
{files.length > 0 && (
<ul className="flex flex-wrap gap-1.5 px-2.5 pt-2.5" aria-label={t.attach}>
{files.map((a, i) => (
<li key={i} className="b5m-pop inline-flex h-8 max-w-full items-center gap-1.5 rounded-lg border border-border bg-surface ps-2 pe-0.5 text-[12.5px] text-foreground">
<Icon name={a.kind === 'image' ? 'image' : a.kind === 'voice' ? 'mic' : 'file'} size={14} className="shrink-0 text-muted-foreground" />
<span className="max-w-[11rem] truncate">{chip(a)}</span>
{a.kind === 'file' && <span className="shrink-0 tabular-nums text-subtle-foreground">{bytes(a.size, ctx.locale)}</span>}
<IconButton icon="x" size="xs" label={fill(t.remove, { name: chip(a) })} onClick={() => setFiles((f) => f.filter((_, k) => k !== i))} className="size-7" />
</li>
))}
</ul>
)}
{rec !== null ? (
<div className="flex items-center gap-2 px-2 py-2" role="group" aria-label={t.recording}>
<span className="b5m-rec ms-2 size-2.5 shrink-0 rounded-full bg-destructive" aria-hidden />
<span className="text-[13.5px] font-medium text-foreground">{t.recording}</span>
<span className="text-[13.5px] tabular-nums text-muted-foreground" aria-live="off">
{duration(rec)}
</span>
<span aria-hidden className="mx-2 flex h-6 min-w-0 flex-1 items-center gap-[2px] overflow-hidden">
{wave(rec + 2, 40).map((v, i) => (
<span key={i} className="w-[3px] shrink-0 rounded-full bg-[color-mix(in_oklab,var(--destructive)_55%,transparent)] transition-[height] duration-300" style={{ height: `${round(20 + v * 70)}%` }} />
))}
</span>
<IconButton icon="trash" size="sm" label={t.cancelRecord} onClick={() => stop(false)} />
<IconButton icon="check" size="sm" variant="primary" round label={t.stopRecord} onClick={() => stop(true)} />
</div>
) : (
<div className="flex items-end gap-1 p-1.5">
<input
ref={picker}
type="file"
multiple
tabIndex={-1}
aria-hidden
className="sr-only"
onChange={(e) => {
const list = [...(e.target.files ?? [])].map<MessengerAttachment>((f) => (f.type.startsWith('image/') ? { kind: 'image', name: f.name, scene: 'still-life', seed: seed.current++ } : { kind: 'file', name: f.name, size: f.size }));
setFiles((x) => [...x, ...list]);
e.target.value = '';
area.current?.focus();
}}
/>
<IconButton icon="paperclip" size="sm" label={t.attach} onClick={() => picker.current?.click()} />
<label htmlFor={id} className="sr-only">
{placeholder}
</label>
<textarea
ref={area}
id={id}
rows={1}
value={text}
placeholder={placeholder}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
send();
}
if (e.key === 'Escape' && reply) onCancelReply?.();
}}
className="block max-h-38 min-h-11 w-0 min-w-0 flex-1 resize-none bg-transparent px-1.5 py-2.5 text-base sm:min-h-9 sm:py-2 leading-[1.35] text-foreground outline-none placeholder:text-subtle-foreground sm:text-[14px]"
/>
{!compact && <IconButton icon="mic" size="sm" label={t.record} onClick={() => setRec(0)} className={cn(can && 'max-sm:hidden')} />}
<IconButton icon="send" size="sm" variant={can ? 'primary' : 'ghost'} label={t.send} disabled={!can} onClick={send} className={cn('transition-[background-color,color,transform]', can && 'scale-100')} />
</div>
)}
{(extra || !compact) && (
<div className="flex items-center justify-between gap-3 px-3 pb-2 text-[11.5px] text-subtle-foreground">
{extra ?? <span />}
{!compact && <span className="max-md:hidden">{t.hint}</span>}
</div>
)}
</div>
);
}
/* Headers -------------------------------------------------------------- */
function ConvHead({ c, ctx, onBack, actions, typing, children }: { c: MessengerConversation; ctx: Ctx; onBack?: () => void; actions?: React.ReactNode; typing?: string; children?: React.ReactNode }) {
const other = c.kind === 'direct' || !c.kind ? ctx.person(c.members.find((m) => m !== ctx.me) ?? ctx.me) : null;
const status = typing ? fill(ctx.t.typing, { name: ctx.person(typing).name.split(' ')[0] }) : other ? (other.status === 'online' ? ctx.t.online : other.status === 'away' ? ctx.t.away : other.role ?? ctx.t.offline) : c.topic ?? fill(ctx.t.members, { n: c.members.length });
return (
<div className="flex h-16 shrink-0 items-center gap-2 border-b border-border px-3 sm:gap-3 sm:px-5">
{onBack && (
<button type="button" onClick={onBack} className={cn('-ms-1.5 flex h-11 shrink-0 items-center gap-0.5 rounded-lg pe-2 ps-1 text-[14px] font-medium text-foreground hover:bg-accent md:hidden', ring)}>
<Icon name="chevron-left" size={20} />
{ctx.t.back}
</button>
)}
<GroupMark c={c} ctx={ctx} />
<div className="min-w-0 flex-1">
<h2 className="truncate text-[15px] font-semibold tracking-[-0.015em] text-foreground">{c.kind === 'channel' ? c.name : convName(c, ctx)}</h2>
<p className={cn('truncate text-[12.5px]', typing ? 'text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]' : 'text-muted-foreground')}>{status}</p>
</div>
{children}
{actions && <div className="flex shrink-0 items-center gap-0.5">{actions}</div>}
</div>
);
}
function SearchBox({ label, value, onChange, className, big, id }: { label: string; value: string; onChange: (v: string) => void; className?: string; big?: boolean; id?: string }) {
return (
<label className={cn('relative block', className)}>
<span className="sr-only">{label}</span>
<Icon name="search" size={big ? 18 : 15} className={cn('pointer-events-none absolute top-1/2 -translate-y-1/2 text-subtle-foreground', big ? 'start-3.5' : 'start-3')} />
<input
id={id}
type="search"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={label}
className={cn(
'w-full min-w-0 rounded-lg border border-input bg-background text-base text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] duration-300 placeholder:text-subtle-foreground focus:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)]',
big ? 'h-12 ps-11 pe-3 sm:text-[15px]' : 'h-11 ps-9 pe-3 sm:h-9 sm:text-[13px]',
)}
/>
</label>
);
}
function Segmented<T extends string>({ options, value, onChange, label, className }: { options: { value: T; label: React.ReactNode }[]; value: T; onChange: (v: T) => void; label: string; className?: string }) {
const ref = useRef<HTMLDivElement>(null);
return (
<div ref={ref} role="radiogroup" aria-label={label} className={cn('inline-flex rounded-lg bg-muted p-1', className)}>
{options.map((o, i) => (
<button
key={o.value}
type="button"
role="radio"
aria-checked={value === o.value}
tabIndex={value === o.value ? 0 : -1}
onClick={() => onChange(o.value)}
onKeyDown={(e) => {
const d = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
if (!d) return;
e.preventDefault();
const n = (i + d + options.length) % options.length;
onChange(options[n].value);
ref.current?.querySelectorAll<HTMLButtonElement>('[role=radio]')[n]?.focus();
}}
className={cn('inline-flex h-11 flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 text-[13px] font-medium transition-[background-color,color,box-shadow] duration-200 sm:h-8', value === o.value ? 'bg-background text-foreground shadow-[var(--shadow-sm)] ring-1 ring-border' : 'text-muted-foreground hover:text-foreground', ringIn)}
>
{o.label}
</button>
))}
</div>
);
}
/** Move focus between the rows of a list with the arrow keys */
function arrowNav(e: React.KeyboardEvent<HTMLElement>, selector = '[data-row]') {
const rows = [...e.currentTarget.querySelectorAll<HTMLElement>(selector)];
const i = rows.indexOf(document.activeElement as HTMLElement);
const k = e.key;
const n = k === 'ArrowDown' ? i + 1 : k === 'ArrowUp' ? i - 1 : k === 'Home' ? 0 : k === 'End' ? rows.length - 1 : null;
if (n === null || i < 0) return;
e.preventDefault();
rows[Math.max(0, Math.min(rows.length - 1, n))]?.focus();
}
/* ------------------------------------------------------------------ */
/* two-pane */
/* ------------------------------------------------------------------ */
type V = { ctx: Ctx; props: MessengerProps; framed: boolean };
const appHeight = (framed: boolean) => (framed ? 'h-[calc(100dvh-3.5rem)] md:h-dvh' : 'h-dvh');
function ConvRow({ c, ctx, active, onOpen, last, typing, unread, i }: { c: MessengerConversation; ctx: Ctx; active: boolean; onOpen: () => void; last?: MessengerMessage; typing?: string; unread: number; i: number }) {
return (
<li className="b5m-rise" style={{ '--b5m-d': `${i * 40}ms` } as React.CSSProperties}>
<button
type="button"
data-row
aria-current={active ? 'true' : undefined}
onClick={onOpen}
className={cn('group/row relative flex w-full items-center gap-3 rounded-xl px-2.5 py-2.5 text-left transition-colors duration-200', active ? 'bg-[color-mix(in_oklab,var(--primary)_10%,var(--surface))] ring-1 ring-inset ring-border' : 'hover:bg-surface', ringIn)}
>
<GroupMark c={c} ctx={ctx} />
<span className="min-w-0 flex-1">
<span className="flex items-baseline gap-2">
<span className={cn('min-w-0 flex-1 truncate text-[14px] text-foreground', unread ? 'font-semibold' : 'font-medium')}>{convName(c, ctx)}</span>
{c.muted && <Icon name="volume-x" size={13} className="shrink-0 text-subtle-foreground" label={ctx.locale === 'de' ? 'stumm' : 'muted'} />}
{last && <span className={cn('shrink-0 text-[12px] tabular-nums', unread ? 'font-medium text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]' : 'text-subtle-foreground')}>{stamp(last.time, ctx.now, ctx.locale, ctx.t)}</span>}
</span>
<span className="mt-0.5 flex items-center gap-2">
<span className={cn('min-w-0 flex-1 truncate text-[13px]', unread ? 'text-foreground' : 'text-muted-foreground')}>
{typing ? <span className="text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]">{fill(ctx.t.typing, { name: ctx.person(typing).name.split(' ')[0] })}</span> : preview(last, ctx)}
</span>
{unread > 0 && <span className="b5m-pop grid h-5 min-w-5 shrink-0 place-items-center rounded-full bg-primary px-1.5 text-[11px] font-semibold tabular-nums text-primary-foreground">{unread}</span>}
{last?.from === ctx.me && !unread && <StatusMark s={last.status} t={ctx.t} />}
</span>
</span>
</button>
</li>
);
}
function TwoPane({ ctx, props, framed }: V) {
const { t } = ctx;
const { conversations, replies, title } = props;
const chats = useChats(conversations, ctx, replies);
const [current, setCurrent] = useState(props.selected ?? conversations[0]?.id);
const [view, setView] = useState<'list' | 'detail'>(props.mobileView ?? 'list');
const [q, setQ] = useState('');
const [filter, setFilter] = useState<'all' | 'unread'>('all');
const [reads, setReads] = useState<Record<string, boolean>>({ [props.selected ?? conversations[0]?.id ?? '']: true });
const [reply, setReply] = useState<MessengerMessage | null>(null);
const [flash, setFlash] = useState<string>();
const log = useRef<HTMLDivElement>(null);
const c = conversations.find((x) => x.id === current) ?? conversations[0];
const unreadOf = (x: MessengerConversation) => (reads[x.id] ? 0 : x.unread ?? 0);
const shown = conversations
.filter((x) => (filter === 'all' || unreadOf(x) > 0) && (!q || convName(x, ctx).toLowerCase().includes(q.toLowerCase()) || (chats.msgs[x.id] ?? []).some((m) => m.text?.toLowerCase().includes(q.toLowerCase()))))
.sort((a, b) => toMs((chats.msgs[b.id] ?? []).at(-1)?.time ?? '2000-01-01') - toMs((chats.msgs[a.id] ?? []).at(-1)?.time ?? '2000-01-01'));
const groups = [
{ label: t.pinned, items: shown.filter((x) => x.pinned) },
{ label: t.chats, items: shown.filter((x) => !x.pinned) },
].filter((g) => g.items.length);
const h: MsgHandlers = {
onReact: (id, e) => c && chats.react(c.id, id, e),
onReply: setReply,
onJump: (id) => {
log.current?.querySelector<HTMLElement>(`[data-id="${id}"]`)?.scrollIntoView({ block: 'center', behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
setFlash(undefined);
requestAnimationFrame(() => setFlash(id));
},
onDownload: (name) => (chats.setAnnounce(fill(t.downloading, { name })), void ctx.onAction?.('download', { name })),
onCopy: useCopy(t, chats.setAnnounce),
};
const open = (id: string) => {
setCurrent(id);
setView('detail');
setReply(null);
setReads((r) => ({ ...r, [id]: true }));
};
return (
<div className={cn('flex min-h-0', appHeight(framed))}>
<section aria-label={t.conversations} className={cn('flex min-w-0 flex-1 flex-col border-border bg-[color-mix(in_oklab,var(--surface)_55%,var(--background))] md:w-[19rem] md:flex-none md:border-e lg:w-[21rem]', view === 'detail' && 'max-md:hidden')}>
<div className="b5m-rise space-y-3 px-3 pb-3 pt-4">
<div className="flex items-center justify-between gap-3 ps-1">
<h1 className="text-[19px] font-semibold tracking-[-0.025em] text-foreground">{title ?? t.messages}</h1>
<IconButton icon="edit" size="sm" variant="outline" label={t.newMessage} onClick={() => void ctx.onAction?.('new', {})} />
</div>
<SearchBox label={t.search} value={q} onChange={setQ} />
<Segmented
label={t.conversations}
value={filter}
onChange={setFilter}
className="flex w-full"
options={[
{ value: 'all', label: t.all },
{ value: 'unread', label: <>{t.unread}<span className="text-[12px] tabular-nums text-subtle-foreground">{conversations.filter((x) => unreadOf(x) > 0).length}</span></> },
]}
/>
</div>
<nav aria-label={t.conversations} className="kit-scrollbar relative min-h-0 flex-1 overflow-y-auto px-2 pb-4" onKeyDown={(e) => arrowNav(e)}>
{groups.map((g) => (
<div key={g.label} className="mt-2">
<h2 className="px-2.5 pb-1 text-[11.5px] font-medium uppercase tracking-[0.06em] text-subtle-foreground">{g.label}</h2>
<ul className="space-y-0.5">
{g.items.map((x, i) => (
<ConvRow key={x.id} c={x} ctx={ctx} i={i} active={x.id === c?.id} onOpen={() => open(x.id)} last={(chats.msgs[x.id] ?? []).at(-1)} typing={chats.typing[x.id]} unread={unreadOf(x)} />
))}
</ul>
</div>
))}
{!groups.length && <p className="mx-2 mt-4 rounded-xl border border-dashed border-border px-4 py-8 text-center text-[13px] text-muted-foreground">{t.noChats}</p>}
</nav>
</section>
{c && (
<section aria-label={convName(c, ctx)} className={cn('flex min-w-0 flex-1 flex-col', view === 'list' && 'max-md:hidden')}>
<ConvHead
c={c}
ctx={ctx}
typing={chats.typing[c.id]}
onBack={() => setView('list')}
actions={
<>
<IconButton icon="phone" size="sm" label={t.call} className="max-sm:hidden" onClick={() => void ctx.onAction?.('call', { conversation: c.id })} />
<IconButton icon="video" size="sm" label={t.video} onClick={() => void ctx.onAction?.('video', { conversation: c.id })} />
</>
}
/>
<Log key={c.id} scrollRef={log} messages={chats.msgs[c.id] ?? []} ctx={ctx} h={h} typing={chats.typing[c.id]} label={convName(c, ctx)} showNames={c.kind === 'group'} flashId={flash} className="px-3 pt-2 sm:px-5 lg:px-8" />
<div className="shrink-0 px-3 pb-3 pt-2 sm:px-5 lg:px-8 lg:pb-5">
<Composer key={c.id} ctx={ctx} placeholder={fill(t.write, { name: convName(c, ctx).split(' ')[0] })} reply={reply} onCancelReply={() => setReply(null)} autoFocusKey={reply?.id} onSend={(d) => void chats.send(c.id, d)} />
</div>
</section>
)}
<Live text={chats.announce} />
</div>
);
}
/* ------------------------------------------------------------------ */
/* thread */
/* ------------------------------------------------------------------ */
function SharedList({ msgs, ctx, onDownload }: { msgs: MessengerMessage[]; ctx: Ctx; onDownload: (n: string) => void }) {
const all = msgs.flatMap((m) => (m.attachments ?? []).map((a) => ({ a, m })));
const images = all.filter((x) => x.a.kind === 'image');
const other = all.filter((x) => x.a.kind !== 'image');
if (!all.length) return <p className="px-5 py-8 text-center text-[13px] text-muted-foreground">{ctx.t.noShared}</p>;
return (
<div className="space-y-5 p-4 sm:p-5">
{images.length > 0 && (
<ul className="grid grid-cols-3 gap-1.5">
{images.map(({ a }, i) =>
a.kind === 'image' ? (
<li key={i} className="b5m-pop overflow-hidden rounded-lg border border-border" style={{ animationDelay: `${i * 40}ms` }}>
<Scene kind={a.scene ?? 'architecture'} seed={a.seed ?? 3} className="block aspect-square w-full" />
<span className="sr-only">{a.name}</span>
</li>
) : null,
)}
</ul>
)}
<ul className="space-y-2">
{other.map(({ a, m }, i) => (
<li key={i} className="b5m-rise" style={{ '--b5m-d': `${i * 50}ms` } as React.CSSProperties}>
{a.kind === 'file' ? <FileTile a={a} ctx={ctx} onDownload={() => onDownload(a.name)} /> : a.kind === 'voice' ? <VoicePlayer a={a} t={ctx.t} id={`s-${m.id}-${i}`} /> : null}
<p className="mt-1 ps-1 text-[11.5px] text-subtle-foreground">
{m.from === ctx.me ? ctx.t.you : ctx.person(m.from).name} · {stamp(m.time, ctx.now, ctx.locale, ctx.t)}
</p>
</li>
))}
</ul>
</div>
);
}
function Thread({ ctx, props, framed }: V) {
const { t } = ctx;
const c = props.conversations.find((x) => x.id === props.selected) ?? props.conversations[0];
const chats = useChats(props.conversations, ctx, props.replies);
const [reply, setReply] = useState<MessengerMessage | null>(null);
const [atBottom, setAtBottom] = useState(true);
const [seenCount, setSeenCount] = useState(0);
const [info, setInfo] = useState(false);
const [flash, setFlash] = useState<string>();
const log = useRef<HTMLDivElement>(null);
const msgs = c ? chats.msgs[c.id] ?? [] : [];
useEffect(() => {
if (atBottom) setSeenCount(msgs.length);
}, [atBottom, msgs.length]);
if (!c) return null;
const pinned = msgs.find((m) => m.pinned);
const jump = (id: string) => {
log.current?.querySelector<HTMLElement>(`[data-id="${id}"]`)?.scrollIntoView({ block: 'center', behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
setFlash(undefined);
requestAnimationFrame(() => setFlash(id));
};
const h: MsgHandlers = {
onReact: (id, e) => chats.react(c.id, id, e),
onReply: setReply,
onJump: jump,
onDownload: (name) => (chats.setAnnounce(fill(t.downloading, { name })), void ctx.onAction?.('download', { name })),
onCopy: useCopy(t, chats.setAnnounce),
};
const fresh = Math.max(0, msgs.length - seenCount);
const panel = (
<aside aria-label={t.shared} className={cn('flex min-h-0 flex-col border-border bg-surface', 'max-xl:absolute max-xl:inset-y-0 max-xl:end-0 max-xl:z-20 max-xl:w-[min(22rem,100%)] max-xl:border-s max-xl:shadow-[var(--shadow-lg)]', 'xl:w-[20rem] xl:border-s', info ? 'b5m-in' : 'hidden')}>
<div className="flex h-16 shrink-0 items-center justify-between gap-3 border-b border-border px-5">
<h2 className="text-[14px] font-semibold text-foreground">{t.shared}</h2>
<IconButton icon="x" size="sm" label={t.hideInfo} onClick={() => setInfo(false)} />
</div>
<div className="kit-scrollbar min-h-0 flex-1 overflow-y-auto">
<SharedList msgs={msgs} ctx={ctx} onDownload={h.onDownload} />
</div>
</aside>
);
return (
<div className={cn('relative flex min-h-0', appHeight(framed))}>
<section aria-label={convName(c, ctx)} className="flex min-w-0 flex-1 flex-col">
<ConvHead
c={c}
ctx={ctx}
typing={chats.typing[c.id]}
actions={
<>
<IconButton icon="phone" size="sm" label={t.call} className="max-sm:hidden" onClick={() => void ctx.onAction?.('call', { conversation: c.id })} />
<IconButton icon="files" size="sm" label={info ? t.hideInfo : t.info} aria-pressed={info} onClick={() => setInfo((v) => !v)} />
</>
}
/>
{pinned && (
<button type="button" onClick={() => jump(pinned.id)} className={cn('b5m-rise flex shrink-0 items-center gap-3 border-b border-border bg-[color-mix(in_oklab,var(--primary)_5%,var(--background))] px-4 py-2 text-left transition-colors hover:bg-[color-mix(in_oklab,var(--primary)_9%,var(--background))] sm:px-5', ringIn)}>
<Icon name="pin" size={15} className="shrink-0 text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]" />
<span className="min-w-0 flex-1">
<span className="block text-[12px] font-semibold text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]">{t.pinned}</span>
<span className="block truncate text-[13px] text-foreground">{preview({ ...pinned, from: '' }, ctx)}</span>
</span>
<Icon name="arrow-up" size={15} className="shrink-0 text-subtle-foreground" />
</button>
)}
<div className="relative flex min-h-0 flex-1 flex-col">
<Log scrollRef={log} messages={msgs} ctx={ctx} h={h} typing={chats.typing[c.id]} label={convName(c, ctx)} showNames={c.kind === 'group'} lastRead={c.lastRead} onScrollState={setAtBottom} flashId={flash} className="mx-auto w-full max-w-[52rem] px-3 pt-2 sm:px-6" />
<button
type="button"
aria-label={t.jumpLatest}
onClick={() => {
const el = log.current;
el?.scrollTo({ top: el.scrollHeight, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
}}
className={cn(
'absolute bottom-3 end-4 inline-flex h-11 items-center gap-1.5 rounded-full border border-border bg-popover px-3.5 text-[13px] font-medium text-foreground shadow-[var(--highlight),var(--shadow-md)] transition-[opacity,visibility,translate] duration-300 hover:-translate-y-0.5 sm:h-9 sm:end-6',
atBottom ? 'invisible translate-y-2 opacity-0' : 'visible opacity-100',
ring,
)}
>
<Icon name="arrow-down" size={15} />
{fresh > 0 ? fill(t.newBelow, { n: fresh }) : t.jumpLatest}
</button>
</div>
<div className="mx-auto w-full max-w-[52rem] shrink-0 px-3 pb-3 pt-2 sm:px-6 lg:pb-5">
<Composer ctx={ctx} placeholder={fill(t.write, { name: convName(c, ctx).split(' ')[0] })} reply={reply} onCancelReply={() => setReply(null)} autoFocusKey={reply?.id} onSend={(d) => void chats.send(c.id, d)} />
</div>
</section>
{panel}
<Live text={chats.announce} />
</div>
);
}
/* ------------------------------------------------------------------ */
/* channel */
/* ------------------------------------------------------------------ */
function Members({ c, ctx }: { c: MessengerConversation; ctx: Ctx }) {
const people = c.members.map(ctx.person);
const on = people.filter((p) => p.status && p.status !== 'offline');
const off = people.filter((p) => !p.status || p.status === 'offline');
return (
<div className="space-y-5 p-4 sm:p-5">
{[
{ label: fill(ctx.t.onlineN, { n: on.length }), list: on },
{ label: fill(ctx.t.offlineN, { n: off.length }), list: off },
]
.filter((g) => g.list.length)
.map((g) => (
<div key={g.label}>
<h3 className="text-[11.5px] font-medium uppercase tracking-[0.06em] text-subtle-foreground">{g.label}</h3>
<ul className="mt-2.5 space-y-1">
{g.list.map((p, i) => (
<li key={p.id} className={cn('b5m-rise flex items-center gap-2.5 rounded-lg px-1.5 py-1.5', p.status === 'offline' || !p.status ? 'opacity-70' : '')} style={{ '--b5m-d': `${i * 35}ms` } as React.CSSProperties}>
<Person p={p} size="sm" />
<span className="min-w-0">
<span className="block truncate text-[13.5px] font-medium text-foreground">
{p.name}
{p.id === ctx.me && <span className="font-normal text-subtle-foreground"> ({ctx.t.you})</span>}
</span>
{p.role && <span className="block truncate text-[12px] text-muted-foreground">{p.role}</span>}
</span>
</li>
))}
</ul>
</div>
))}
</div>
);
}
function Channel({ ctx, props, framed }: V) {
const { t } = ctx;
const c = props.conversations.find((x) => x.id === props.selected) ?? props.conversations[0];
const chats = useChats(props.conversations, ctx, props.replies);
const [side, setSide] = useState<'members' | 'thread' | null>('members');
// Members start open beside the channel on desktops only, smaller screens open them on request
const [auto, setAuto] = useState(true);
const [narrow, setNarrow] = useState(false);
useEffect(() => {
const mq = window.matchMedia('(max-width: 1023.98px)');
const on = () => setNarrow(mq.matches);
on();
mq.addEventListener('change', on);
return () => mq.removeEventListener('change', on);
}, []);
const membersShown = side === 'members' && !(auto && narrow);
const [threadId, setThreadId] = useState<string>();
const [alsoChannel, setAlsoChannel] = useState(false);
const closeRef = useRef<HTMLButtonElement>(null);
const opener = useRef<HTMLElement | null>(null);
useEffect(() => {
if (side === 'thread') closeRef.current?.focus({ preventScroll: true });
}, [side, threadId]);
if (!c) return null;
const msgs = chats.msgs[c.id] ?? [];
const parent = msgs.find((m) => m.id === threadId);
const h: MsgHandlers = {
onReact: (id, e) => chats.react(c.id, id, e),
onThread: (m) => {
opener.current = document.activeElement as HTMLElement;
setThreadId(m.id);
setSide('thread');
},
onDownload: (name) => (chats.setAnnounce(fill(t.downloading, { name })), void ctx.onAction?.('download', { name })),
onCopy: useCopy(t, chats.setAnnounce),
};
const th: MsgHandlers = { ...h, onReact: (id, e) => parent && chats.react(c.id, id, e, parent.id), onThread: undefined };
const closeThread = () => {
setSide(window.innerWidth >= 1024 ? 'members' : null);
opener.current?.focus();
};
const memberPeople = c.members.map(ctx.person);
return (
<div className={cn('relative flex min-h-0', appHeight(framed))}>
<section aria-label={`#${c.name}`} className="flex min-w-0 flex-1 flex-col">
<ConvHead
c={c}
ctx={ctx}
actions={
<button
type="button"
aria-pressed={membersShown}
aria-label={membersShown ? t.hideMembers : t.showMembers}
onClick={() => {
setAuto(false);
setSide(membersShown ? null : 'members');
}}
className={cn('group/mem flex h-11 items-center gap-2 rounded-lg border border-border px-2 transition-colors hover:bg-accent aria-pressed:bg-accent sm:h-9', ring)}
>
<span className="flex -space-x-2">
{memberPeople.slice(0, 3).map((p) => (
<Avatar key={p.id} name={p.name} size="xs" alt="" ring className="transition-transform duration-300 group-hover/mem:-translate-y-px" />
))}
</span>
<span className="text-[13px] font-medium tabular-nums text-foreground">{c.members.length}</span>
</button>
}
/>
<Log messages={msgs} ctx={ctx} h={h} style="row" label={`#${c.name}`} className="px-1 pt-1 sm:px-3" />
<div className="shrink-0 px-3 pb-3 pt-2 sm:px-5 lg:pb-5">
<Composer ctx={ctx} placeholder={fill(t.write, { name: `#${c.name}` })} onSend={(d) => void chats.send(c.id, d)} />
</div>
</section>
{side && (
<aside
aria-label={side === 'thread' ? t.thread : t.membersTitle}
onKeyDown={(e) => e.key === 'Escape' && side === 'thread' && closeThread()}
className={cn('b5m-in flex min-h-0 flex-col border-border bg-surface', 'max-lg:absolute max-lg:inset-0 max-lg:z-20 max-lg:sm:start-auto max-lg:sm:w-[24rem] max-lg:sm:border-s max-lg:sm:shadow-[var(--shadow-lg)]', side === 'members' && auto && 'max-lg:hidden', side === 'thread' ? 'lg:w-[24rem] lg:border-s' : 'lg:w-[16.5rem] lg:border-s')}
>
<div className="flex h-16 shrink-0 items-center justify-between gap-3 border-b border-border ps-5 pe-3">
<div className="min-w-0">
<h2 className="text-[14px] font-semibold text-foreground">{side === 'thread' ? t.thread : t.membersTitle}</h2>
<p className="truncate text-[12px] text-muted-foreground">{side === 'thread' ? `#${c.name}` : fill(t.members, { n: c.members.length })}</p>
</div>
<IconButton ref={closeRef} icon="x" size="sm" label={side === 'thread' ? t.closeThread : t.hideMembers} onClick={() => (side === 'thread' ? closeThread() : setSide(null))} />
</div>
{side === 'thread' && parent ? (
<>
<div className="kit-scrollbar relative min-h-0 flex-1 overflow-y-auto">
<div className="border-b border-border px-1 pb-3 sm:px-2">
<Row m={parent} ctx={ctx} h={th} first index={-1} active={false} inThread />
</div>
<p className="flex items-center gap-3 px-5 pt-3 text-[12px] font-medium text-subtle-foreground">
{(parent.replies?.length ?? 0) === 1 ? t.oneReply : fill(t.replies, { n: parent.replies?.length ?? 0 })}
<span className="h-px flex-1 bg-border" />
</p>
<Log key={parent.id} messages={parent.replies ?? []} ctx={ctx} h={th} style="row" label={t.thread} inThread className="overflow-visible px-1 sm:px-2" />
</div>
<div className="shrink-0 p-3">
<Composer
ctx={ctx}
compact
placeholder={t.replyThread}
onSend={(d) => {
void chats.send(c.id, d, parent.id);
if (alsoChannel) void chats.send(c.id, { ...d, replyTo: parent.id });
}}
extra={
<label className="-my-2 inline-flex min-h-11 cursor-pointer items-center gap-2 text-[12px] text-muted-foreground sm:min-h-8">
<input type="checkbox" checked={alsoChannel} onChange={(e) => setAlsoChannel(e.target.checked)} className="size-4 accent-[var(--primary)]" />
{fill(t.alsoChannel, { name: c.name ?? '' })}
</label>
}
/>
</div>
</>
) : (
<div className="kit-scrollbar min-h-0 flex-1 overflow-y-auto">
<Members c={c} ctx={ctx} />
</div>
)}
</aside>
)}
<Live text={chats.announce} />
</div>
);
}
/* ------------------------------------------------------------------ */
/* widget */
/* ------------------------------------------------------------------ */
function Widget({ ctx, props }: V) {
const { t } = ctx;
const { conversations, position = 'contained', backdrop, className } = props;
const chats = useChats(conversations, ctx, props.replies);
const [open, setOpen] = useState(props.defaultOpen ?? true);
const [current, setCurrent] = useState<string | null>(props.selected ?? null);
const [reads, setReads] = useState<Record<string, boolean>>({});
const id = useId();
const launcher = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const meP = ctx.person(ctx.me);
const c = conversations.find((x) => x.id === current);
const unreadOf = (x: MessengerConversation) => (reads[x.id] ? 0 : x.unread ?? 0);
const total = conversations.reduce((a, x) => a + unreadOf(x), 0);
const close = useCallback(() => {
setOpen(false);
requestAnimationFrame(() => launcher.current?.focus());
}, []);
const h: MsgHandlers = {
onReact: (mid, e) => c && chats.react(c.id, mid, e),
onDownload: (name) => (chats.setAnnounce(fill(t.downloading, { name })), void ctx.onAction?.('download', { name })),
onCopy: useCopy(t, chats.setAnnounce),
};
const fixed = position === 'fixed';
const panel = open && (
<div
ref={panelRef}
id={id}
role="dialog"
aria-label={t.messages}
onKeyDown={(e) => e.key === 'Escape' && (e.stopPropagation(), close())}
className={cn(
'b5m-panel flex flex-col overflow-hidden border border-border bg-background shadow-[var(--highlight),var(--shadow-lg)]',
fixed ? 'fixed inset-x-0 bottom-0 z-50 h-[min(40rem,100dvh)] rounded-t-2xl sm:inset-x-auto sm:bottom-24 sm:end-6 sm:h-[38rem] sm:w-[24rem] sm:rounded-2xl' : 'absolute inset-x-2 bottom-2 top-2 z-10 rounded-2xl sm:inset-x-auto sm:bottom-[5.5rem] sm:end-5 sm:top-auto sm:h-[38rem] sm:max-h-[calc(100%-6.5rem)] sm:w-[24rem]',
)}
>
{c ? (
<>
<div className="flex h-16 shrink-0 items-center gap-2 border-b border-border px-2">
<IconButton icon="chevron-left" size="sm" label={t.back} onClick={() => setCurrent(null)} />
<GroupMark c={c} ctx={ctx} size="sm" />
<div className="min-w-0 flex-1">
<h2 className="truncate text-[14px] font-semibold text-foreground">{convName(c, ctx)}</h2>
<p className={cn('truncate text-[12px]', chats.typing[c.id] ? 'text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]' : 'text-muted-foreground')}>
{chats.typing[c.id] ? fill(t.typing, { name: ctx.person(chats.typing[c.id]!).name.split(' ')[0] }) : c.kind === 'group' ? fill(t.members, { n: c.members.length }) : ctx.person(c.members.find((m) => m !== ctx.me) ?? ctx.me).status === 'online' ? t.online : t.offline}
</p>
</div>
<IconButton icon="minus" size="sm" label={t.minimize} onClick={close} />
</div>
<Log key={c.id} messages={chats.msgs[c.id] ?? []} ctx={ctx} h={h} typing={chats.typing[c.id]} label={convName(c, ctx)} compact showNames={c.kind === 'group'} className="px-2.5 pt-1" />
<div className="shrink-0 p-2.5">
<Composer key={c.id} ctx={ctx} compact placeholder={fill(t.write, { name: convName(c, ctx).split(' ')[0] })} onSend={(d) => void chats.send(c.id, d)} />
</div>
</>
) : (
<>
<div className="relative shrink-0 overflow-hidden border-b border-border bg-[color-mix(in_oklab,var(--primary)_7%,var(--background))] px-5 pb-5 pt-4">
<div className="flex items-center justify-between">
<h2 className="text-[17px] font-semibold tracking-[-0.02em] text-foreground">{t.messages}</h2>
<IconButton icon="minus" size="sm" label={t.minimize} onClick={close} />
</div>
<p className="mt-1 text-pretty text-[13px] text-muted-foreground">{fill(t.widgetHello, { name: meP.name.split(' ')[0] })}</p>
<div className="mt-3 flex -space-x-2">
{[...new Set(conversations.flatMap((x) => x.members))]
.filter((m) => m !== ctx.me)
.slice(0, 5)
.map((m, i) => (
<span key={m} className="b5m-pop" style={{ animationDelay: `${120 + i * 50}ms` }}>
<Avatar name={ctx.person(m).name} size="sm" alt="" ring status={ctx.person(m).status === 'online' ? 'online' : undefined} />
</span>
))}
</div>
</div>
<nav aria-label={t.conversations} className="kit-scrollbar relative min-h-0 flex-1 overflow-y-auto p-2" onKeyDown={(e) => arrowNav(e)}>
<ul className="space-y-0.5">
{conversations.map((x, i) => (
<ConvRow key={x.id} c={x} ctx={ctx} i={i} active={false} onOpen={() => (setCurrent(x.id), setReads((r) => ({ ...r, [x.id]: true })))} last={(chats.msgs[x.id] ?? []).at(-1)} typing={chats.typing[x.id]} unread={unreadOf(x)} />
))}
</ul>
</nav>
<div className="shrink-0 border-t border-border p-3">
<Button variant="outline" iconLeft="edit" className="w-full" onClick={() => void ctx.onAction?.('new', {})}>
{t.newMessage}
</Button>
</div>
</>
)}
</div>
);
const button = (
<button
ref={launcher}
type="button"
aria-expanded={open}
aria-controls={id}
aria-label={`${open ? t.closeChat : t.openChat}${total ? ` (${total})` : ''}`}
onClick={() => (open ? close() : setOpen(true))}
className={cn(
fixed ? 'fixed bottom-4 end-4 z-50 sm:bottom-6 sm:end-6' : 'absolute bottom-4 end-4 z-10 sm:bottom-5 sm:end-5',
'grid size-14 place-items-center rounded-full bg-primary text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.2),var(--shadow-lg)] transition-transform duration-300 ease-[var(--ease-kit)] hover:scale-105 active:scale-95 motion-reduce:hover:scale-100',
open && 'max-sm:invisible',
ring,
)}
>
<Icon key={open ? 'x' : 'c'} name={open ? 'x' : 'chats'} size={24} className="b5m-pop" />
{total > 0 && !open && <span className="b5m-pop absolute -end-0.5 -top-0.5 grid h-5 min-w-5 place-items-center rounded-full bg-destructive px-1 text-[11px] font-semibold tabular-nums text-white ring-2 ring-background">{total}</span>}
</button>
);
if (fixed)
return (
<>
{panel}
{button}
<Live text={chats.announce} />
</>
);
return (
<div className={cn('relative min-h-[46rem] overflow-hidden', className)}>
{backdrop && <div aria-hidden className="pointer-events-none absolute inset-0 select-none">{backdrop}</div>}
{panel}
{button}
<Live text={chats.announce} />
</div>
);
}
/* ------------------------------------------------------------------ */
/* search */
/* ------------------------------------------------------------------ */
const norm = (s: string) => s.toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
/** The words of the query marked inside a text, trimmed around the first hit */
function Highlight({ text, words, trim = 90 }: { text: string; words: string[]; trim?: number }) {
if (!words.length) return <>{text}</>;
const n = norm(text);
let start = 0;
const firstHit = Math.min(...words.map((w) => n.indexOf(w)).filter((i) => i >= 0));
if (Number.isFinite(firstHit) && firstHit > trim / 2 && text.length > trim) start = firstHit - Math.round(trim / 3);
const slice = (start ? '…' : '') + text.slice(start);
const ns = norm(slice);
const parts: React.ReactNode[] = [];
let i = 0;
while (i < slice.length) {
let hit = -1;
let len = 0;
for (const w of words) {
const k = ns.indexOf(w, i);
if (k >= 0 && (hit < 0 || k < hit)) (hit = k), (len = w.length);
}
if (hit < 0) {
parts.push(slice.slice(i));
break;
}
if (hit > i) parts.push(slice.slice(i, hit));
parts.push(
<mark key={hit} className="rounded-[3px] bg-[color-mix(in_oklab,var(--primary)_22%,transparent)] px-0.5 text-foreground">
{slice.slice(hit, hit + len)}
</mark>,
);
i = hit + len;
}
return <>{parts}</>;
}
function Pick({ label, value, onChange, children }: { label: string; value: string; onChange: (v: string) => void; children: React.ReactNode }) {
return (
<label className="relative block min-w-0">
<span className="sr-only">{label}</span>
<select value={value} onChange={(e) => onChange(e.target.value)} className={cn('h-11 w-full min-w-0 cursor-pointer appearance-none truncate rounded-lg border border-input bg-background ps-3 pe-8 text-base text-foreground shadow-[var(--shadow-sm)] transition-[border-color] hover:border-border-strong sm:h-9 sm:text-[13px]', ring)}>
{children}
</select>
<Icon name="chevrons-up-down" size={14} className="pointer-events-none absolute end-2.5 top-1/2 -translate-y-1/2 text-subtle-foreground" />
</label>
);
}
function Search({ ctx, props, framed }: V) {
const { t } = ctx;
const { conversations } = props;
const [q, setQ] = useState(props.query ?? '');
const [from, setFrom] = useState('all');
const [where, setWhere] = useState('all');
const [has, setHas] = useState<Record<'file' | 'image' | 'voice', boolean>>({ file: false, image: false, voice: false });
const [pick, setPick] = useState(0);
const [view, setView] = useState<'list' | 'detail'>(props.mobileView ?? 'list');
const listId = useId();
const words = norm(q).split(/\s+/).filter(Boolean);
const people = [...new Set(conversations.flatMap((c) => c.members))].map(ctx.person);
const results = useMemo(() => {
const out: { c: MessengerConversation; m: MessengerMessage; i: number }[] = [];
for (const c of conversations) {
if (where !== 'all' && c.id !== where) continue;
c.messages.forEach((m, i) => {
if (from !== 'all' && m.from !== from) return;
const kinds = new Set(m.attachments?.map((a) => a.kind));
if ((Object.keys(has) as (keyof typeof has)[]).some((k) => has[k] && !kinds.has(k))) return;
const hay = norm([m.text ?? '', ...(m.attachments ?? []).map((a) => ('name' in a ? a.name : '')), ctx.person(m.from).name].join(' '));
if (!words.length && !Object.values(has).some(Boolean)) return;
if (words.every((w) => hay.includes(w))) out.push({ c, m, i });
});
}
return out.sort((a, b) => toMs(b.m.time) - toMs(a.m.time));
}, [conversations, where, from, has, words.join(' '), ctx]); // eslint-disable-line react-hooks/exhaustive-deps
const sel = results[Math.min(pick, results.length - 1)];
useEffect(() => setPick(0), [q, from, where, has]);
const announce = !words.length && !Object.values(has).some(Boolean) ? '' : results.length === 1 ? fill(t.oneResult, { q }) : fill(t.results, { n: results.length, q });
const contextMsgs = sel ? sel.c.messages.slice(Math.max(0, sel.i - 3), sel.i + 4) : [];
const noop: MsgHandlers = { onReact: () => undefined, onDownload: () => undefined, onCopy: () => undefined };
const filters = (
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap sm:items-center">
<Pick label={t.from} value={from} onChange={setFrom}>
<option value="all">{t.anyone}</option>
{people.map((p) => (
<option key={p.id} value={p.id}>
{p.id === ctx.me ? `${p.name} (${t.you})` : p.name}
</option>
))}
</Pick>
<Pick label={t.in} value={where} onChange={setWhere}>
<option value="all">{t.anywhere}</option>
{conversations.map((c) => (
<option key={c.id} value={c.id}>
{c.kind === 'channel' ? `#${c.name}` : convName(c, ctx)}
</option>
))}
</Pick>
<div role="group" aria-label={t.has} className="col-span-2 flex flex-wrap items-center gap-1.5">
<span className="me-1 text-[12.5px] text-muted-foreground max-sm:sr-only">{t.has}</span>
{(
[
['file', t.hasFile, 'file'],
['image', t.hasImage, 'image'],
['voice', t.hasVoice, 'mic'],
] as const
).map(([k, label, icon]) => (
<button key={k} type="button" aria-pressed={has[k]} onClick={() => setHas((x) => ({ ...x, [k]: !x[k] }))} className={cn('inline-flex h-11 items-center gap-1.5 rounded-lg border px-3 text-[13px] font-medium transition-colors sm:h-9', has[k] ? 'border-transparent bg-foreground text-background' : 'border-border text-muted-foreground hover:border-border-strong hover:text-foreground', ring)}>
<Icon name={icon as IconName} size={14} />
{label}
</button>
))}
</div>
</div>
);
return (
<div className={cn('flex min-h-0 flex-col', appHeight(framed))}>
<div className="b5m-rise shrink-0 border-b border-border px-4 pb-4 pt-5 sm:px-6 lg:px-8">
<h1 className="text-[clamp(1.375rem,1.2rem+0.8vw,1.75rem)] font-semibold tracking-[-0.03em] text-foreground">{props.title ?? t.searchMessages}</h1>
{props.subtitle && <p className="mt-0.5 text-[13px] text-muted-foreground">{props.subtitle}</p>}
<div className="mt-4 grid gap-3 xl:grid-cols-[minmax(0,28rem)_minmax(0,1fr)] xl:items-center">
<SearchBox big label={t.searchHint} value={q} onChange={setQ} />
{filters}
</div>
</div>
<div className="flex min-h-0 flex-1">
<section aria-label={t.resultsLabel} className={cn('flex min-w-0 flex-1 flex-col md:w-[24rem] md:flex-none md:border-e md:border-border lg:w-[28rem]', view === 'detail' && 'max-md:hidden')}>
<p className="shrink-0 px-4 pb-1 pt-3 text-[12.5px] font-medium text-muted-foreground sm:px-6 md:px-4" aria-hidden>
{announce}
</p>
{results.length ? (
<ul
id={listId}
role="listbox"
aria-label={t.resultsLabel}
tabIndex={0}
aria-activedescendant={sel ? `${listId}-${sel.m.id}` : undefined}
onKeyDown={(e) => {
const n = e.key === 'ArrowDown' ? pick + 1 : e.key === 'ArrowUp' ? pick - 1 : e.key === 'Home' ? 0 : e.key === 'End' ? results.length - 1 : null;
if (e.key === 'Enter') return setView('detail');
if (n === null) return;
e.preventDefault();
const k = Math.max(0, Math.min(results.length - 1, n));
setPick(k);
document.getElementById(`${listId}-${results[k].m.id}`)?.scrollIntoView({ block: 'nearest' });
}}
className="kit-scrollbar relative min-h-0 flex-1 space-y-1 overflow-y-auto px-2 pb-4 pt-1 outline-none focus-visible:[&>[aria-selected=true]]:outline-2 focus-visible:[&>[aria-selected=true]]:outline-solid focus-visible:[&>[aria-selected=true]]:-outline-offset-2 focus-visible:[&>[aria-selected=true]]:outline-[color-mix(in_oklab,var(--primary)_70%,transparent)] sm:px-4 md:px-2"
>
{results.map(({ c, m }, i) => {
const p = ctx.person(m.from);
const on = sel?.m.id === m.id;
const fileName = m.attachments?.find((a) => a.kind !== 'voice' && 'name' in a) as { name: string } | undefined;
return (
<li
key={m.id}
id={`${listId}-${m.id}`}
role="option"
aria-selected={on}
onClick={() => (setPick(i), setView('detail'))}
className={cn('b5m-rise flex cursor-pointer gap-3 rounded-xl p-3 transition-colors', on ? 'bg-[color-mix(in_oklab,var(--primary)_9%,var(--surface))] ring-1 ring-inset ring-border' : 'hover:bg-surface')}
style={{ '--b5m-d': `${Math.min(i, 8) * 35}ms` } as React.CSSProperties}
>
<Person p={p} size="sm" status={false} />
<span className="min-w-0 flex-1">
<span className="flex items-baseline gap-2">
<span className="min-w-0 flex-1 truncate text-[13.5px] font-semibold text-foreground">{m.from === ctx.me ? t.you : p.name}</span>
<span className="shrink-0 text-[12px] tabular-nums text-subtle-foreground">{stamp(m.time, ctx.now, ctx.locale, t)}</span>
</span>
{(c.kind === 'channel' || c.kind === 'group') && <span className="block truncate text-[12px] text-muted-foreground">{c.kind === 'channel' ? `#${c.name}` : convName(c, ctx)}</span>}
{m.text && (
<span className="mt-1 line-clamp-2 block text-[13.5px] leading-snug text-foreground/90">
<Highlight text={m.text} words={words} />
</span>
)}
{fileName && (
<span className="mt-1.5 inline-flex max-w-full items-center gap-1.5 rounded-md border border-border bg-card px-2 py-1 text-[12px] text-foreground">
<Icon name={m.attachments?.[0]?.kind === 'image' ? 'image' : 'file'} size={13} className="shrink-0 text-muted-foreground" />
<span className="truncate">
<Highlight text={fileName.name} words={words} />
</span>
</span>
)}
</span>
</li>
);
})}
</ul>
) : (
<p className="mx-4 mt-3 rounded-xl border border-dashed border-border px-4 py-10 text-center text-[13.5px] text-muted-foreground sm:mx-6 md:mx-4">{words.length || Object.values(has).some(Boolean) ? t.noResults : t.typeToSearch}</p>
)}
</section>
<section aria-label={t.context} className={cn('flex min-w-0 flex-1 flex-col bg-[color-mix(in_oklab,var(--surface)_50%,var(--background))]', view === 'list' && 'max-md:hidden')}>
{sel ? (
<>
<div className="flex h-16 shrink-0 items-center gap-3 border-b border-border px-3 sm:px-5">
<button type="button" onClick={() => setView('list')} className={cn('-ms-1.5 flex h-11 shrink-0 items-center gap-0.5 rounded-lg pe-2 ps-1 text-[14px] font-medium text-foreground hover:bg-accent md:hidden', ring)}>
<Icon name="chevron-left" size={20} />
{t.back}
</button>
<GroupMark c={sel.c} ctx={ctx} size="sm" />
<div className="min-w-0 flex-1">
<h2 className="truncate text-[14.5px] font-semibold text-foreground">{sel.c.kind === 'channel' ? `#${sel.c.name}` : convName(sel.c, ctx)}</h2>
<p className="truncate text-[12px] leading-snug text-muted-foreground">
{t.context} · {dayLabel(sel.m.time, ctx.now, ctx.locale, t)}
</p>
</div>
<Button size="sm" variant="outline" iconLeft="arrow-up-right" aria-label={t.openInChat} className="max-sm:hidden" onClick={() => void ctx.onAction?.('open', { conversation: sel.c.id, message: sel.m.id })}>
<span className="max-xl:sr-only">{t.openInChat}</span>
</Button>
</div>
<div key={sel.m.id} className="kit-scrollbar relative min-h-0 flex-1 overflow-y-auto px-3 py-3 sm:px-6">
{contextMsgs.map((m) => {
const hit = m.id === sel.m.id;
const p = ctx.person(m.from);
return (
<div key={m.id} className={cn('b5m-in relative flex gap-3 rounded-xl px-3 py-2.5', hit ? 'b5m-flash bg-[color-mix(in_oklab,var(--primary)_7%,var(--card))] ring-1 ring-[color-mix(in_oklab,var(--primary)_30%,var(--border))]' : 'opacity-80')}>
<Person p={p} size="sm" status={false} />
<div className="min-w-0 flex-1">
<p className="flex items-baseline gap-2">
<span className="text-[13.5px] font-semibold text-foreground">{m.from === ctx.me ? t.you : p.name}</span>
<span className="text-[12px] tabular-nums text-subtle-foreground">{clock(m.time, ctx.locale)}</span>
</p>
{m.text && (
<p className="whitespace-pre-line text-pretty text-[14px] leading-relaxed text-foreground [overflow-wrap:anywhere]">
<Highlight text={m.text} words={hit ? words : []} trim={9999} />
</p>
)}
{m.attachments?.length ? (
<div className="mt-1.5">
<Attachments list={m.attachments} ctx={ctx} id={`c-${m.id}`} onDownload={noop.onDownload} />
</div>
) : null}
</div>
</div>
);
})}
</div>
<div className="shrink-0 border-t border-border p-3 sm:hidden">
<Button className="w-full" variant="outline" onClick={() => void ctx.onAction?.('open', { conversation: sel.c.id, message: sel.m.id })}>
{t.openInChat}
</Button>
</div>
</>
) : (
<div className="grid flex-1 place-items-center p-8 text-center">
<div>
<span className="mx-auto grid size-12 place-items-center rounded-xl bg-surface-2 text-muted-foreground">
<Icon name="search" size={20} />
</span>
<p className="mt-3 max-w-[18rem] text-pretty text-[13.5px] text-muted-foreground">{t.typeToSearch}</p>
</div>
</div>
)}
</section>
</div>
<Live text={announce} />
</div>
);
}
/* ------------------------------------------------------------------ */
/* Messenger */
/* ------------------------------------------------------------------ */
/**
* Messaging screens for apps: a list with the open chat, a single thread with
* pinned message and shared files, a channel with members and threads, a small
* floating messenger and a search across every conversation. Replies, reactions,
* photos, files and voice messages, typing and read status. Enter sends.
* <Messenger variant="two-pane" me="u1" people={[…]} conversations={[…]} replies={[…]} />
*/
export function Messenger({ variant = 'two-pane', sidebar, labels, className, now = '2026-10-23T14:30', ...rest }: MessengerProps) {
const t = useTexts(TEXTS, labels);
const locale = useLocale();
const people = rest.people;
const person = useCallback((id: string): MessengerPerson => people.find((p) => p.id === id) ?? { id, name: id }, [people]);
const ctx: Ctx = { t, locale, me: rest.me, now, person, onAction: rest.onAction };
const props = { ...rest, now, className } as MessengerProps;
const framed = !!sidebar;
const v: V = { ctx, props, framed };
const body = (() => {
switch (variant) {
case 'thread':
return <Thread {...v} />;
case 'channel':
return <Channel {...v} />;
case 'widget':
return <Widget {...v} />;
case 'search':
return <Search {...v} />;
default:
return <TwoPane {...v} />;
}
})();
if (variant === 'widget' && !sidebar)
return (
<>
<Style />
{body}
</>
);
if (!sidebar)
return (
<div className={cn('bg-background', className)}>
<Style />
{body}
</div>
);
return (
<Sidebar mobile="drawer" {...sidebar} className={className}>
<Style />
{body}
</Sidebar>
);
}Frequent questions
How do I install Messenger?
With aiw add @ai-website/messenger. The command puts components/ui/messenger.tsx into your project, the code is yours from then on. It brings along avatar, button, icon, icon-button, photos and sidebar. It needs no further npm packages.
Which variants does Messenger have?
5 variants, List and chat, Conversation, Channel with threads, Floating messenger and Message search. You pick one with the variant prop, for example variant="thread".
Can I translate the texts of Messenger?
Yes. Messenger carries 92 texts of its own in English and German, such as “Messages”, “Conversations” and “Search conversations”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Messenger?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Messenger with aiw_components and install it with aiw_add @ai-website/messenger. Name the variant in your prompt, for example variant="two-pane", and the assistant writes your content straight into the props.