Messenger UI für Apps
Nachrichten für Apps. Chats mit Antworten, Reaktionen, Fotos, Dateien und Sprache, Tippanzeige und Lesestatus, Kanäle mit Threads, ein schwebender Messenger und Suche.
Gespräche, die in deinem Produkt bleiben
Ein Messenger hält Gespräche in deinem Produkt statt irgendwo anders. Diese Screens tragen Antworten, Reaktionen, Fotos, Dateien, Sprachnachrichten, Tippanzeige und Lesestatus, all die Details, die Menschen von jedem Chat kennen, den sie täglich nutzen.
„Liste und Chat“ stellt Unterhaltungen mit Suche, Ungelesen-Filter und Tippanzeige neben den offenen Chat, Telefone wechseln mit Zurück zwischen beiden. Die „Unterhaltung“ zeigt einen Chat mit Tagestrennern, angehefteter Nachricht, Marke für Neues, Sprung zur neuesten Nachricht und den geteilten Dateien daneben. „Kanal mit Threads“ zeigt einen Teamkanal als Zeilen mit Namen und Rollen, Antworten im Thread daneben und die Mitglieder nach Anwesenheit. Der „Schwebende Messenger“ startet aus einem Knopf mit der Zahl ungelesener Nachrichten, öffnet ein kleines Feld mit den Chats und schließt mit Escape, Telefone bekommen den ganzen Schirm. Die „Nachrichtensuche“ durchsucht alle Unterhaltungen nach Wörtern, Person, Ort und Anhängen und zeigt jeden Treffer im Zusammenhang.
Wähle die Form nach der Beziehung, zwei Spalten für Support und Vertrieb, Kanäle für Teams, den schwebenden Messenger für eine schnelle Frage auf jeder Seite. Zeig den Lesestatus leise, ein grauer Haken reicht. Halte das Eingabefeld unten fest, dann verdeckt die Tastatur es am Telefon nie.
Variants
Details
Was Messenger mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Team und Zusammenarbeit
- Bereich
- App
- Variants
- 5
- Version
- 1.0.0 · seit 1.2.0
- Datei
- components/ui/messenger.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 92 eigene Texte
- Studio
- Im Code anpassen
- In Sammlungen
- SaaS-App-Screens
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle MessengerProps aus components/ui/messenger.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalMessengerVarianttwo-panethreadchannelwidgetsearch
sidebaroptionalOmit<SidebarProps, 'children'> | nullThe app frame. Leave it out (or null) for a standalone screen.
titleoptionalstringsubtitleoptionalstringmePflichtstringYour person id
peoplePflichtMessengerPerson[]conversationsPflichtMessengerConversation[]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.
Eingebaute Texte
Diese Texte schreibt Messenger selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 92 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| messages | Englisch Messages | Deutsch Nachrichten |
| conversations | Englisch Conversations | Deutsch Unterhaltungen |
| search | Englisch Search conversations | Deutsch Unterhaltungen durchsuchen |
| newMessage | Englisch New message | Deutsch Neue Nachricht |
| all | Englisch All | Deutsch Alle |
| unread | Englisch Unread | Deutsch Ungelesen |
| pinned | Englisch Pinned | Deutsch Angeheftet |
| chats | Englisch Chats | Deutsch Chats |
| noChats | Englisch Every conversation is read. | Deutsch Alle Unterhaltungen sind gelesen. |
| back | Englisch Back | Deutsch Zurück |
| you | Englisch You | Deutsch Du |
| online | Englisch Online | Deutsch Online |
| away | Englisch Away | Deutsch Abwesend |
| offline | Englisch Offline | Deutsch Offline |
| members | Englisch {n} members | Deutsch {n} Mitglieder |
| membersTitle | Englisch Members | Deutsch Mitglieder |
| onlineN | Englisch Online, {n} | Deutsch Online, {n} |
| offlineN | Englisch Offline, {n} | Deutsch Offline, {n} |
| typing | Englisch {name} is typing | Deutsch {name} schreibt |
| write | Englisch Message {name} | Deutsch Nachricht an {name} |
| send | Englisch Send | Deutsch Senden |
| attach | Englisch Attach files | Deutsch Dateien anhängen |
| record | Englisch Record a voice message | Deutsch Sprachnachricht aufnehmen |
| stopRecord | Englisch Stop and add the recording | Deutsch Aufnahme beenden und anhängen |
| cancelRecord | Englisch Discard the recording | Deutsch Aufnahme verwerfen |
| recording | Englisch Recording | Deutsch Aufnahme läuft |
| remove | Englisch Remove {name} | Deutsch {name} entfernen |
| replyingTo | Englisch Replying to {name} | Deutsch Antwort an {name} |
| cancelReply | Englisch Cancel the reply | Deutsch Antwort abbrechen |
| react | Englisch Add a reaction | Deutsch Reaktion hinzufügen |
| reply | Englisch Reply | Deutsch Antworten |
| copy | Englisch Copy text | Deutsch Text kopieren |
| copied | Englisch Text copied. | Deutsch Text kopiert. |
| reactedYou | Englisch you reacted | Deutsch du hast reagiert |
| reaction | Englisch {emoji} {n} | Deutsch {emoji} {n} |
| toggleReaction | Englisch React with {emoji} | Deutsch Mit {emoji} reagieren |
| sent | Englisch Sent | Deutsch Gesendet |
| delivered | Englisch Delivered | Deutsch Zugestellt |
| read | Englisch Read | Deutsch Gelesen |
| today | Englisch Today | Deutsch Heute |
| yesterday | Englisch Yesterday | Deutsch Gestern |
| newSince | Englisch New messages | Deutsch Neue Nachrichten |
| jumpLatest | Englisch Jump to the latest message | Deutsch Zur neuesten Nachricht |
| newBelow | Englisch {n} new | Deutsch {n} neu |
| photo | Englisch Photo | Deutsch Foto |
| file | Englisch File | Deutsch Datei |
| voice | Englisch Voice message | Deutsch Sprachnachricht |
| voiceN | Englisch Voice message, {d} | Deutsch Sprachnachricht, {d} |
| play | Englisch Play the voice message | Deutsch Sprachnachricht abspielen |
| pause | Englisch Pause the voice message | Deutsch Sprachnachricht anhalten |
| seek | Englisch Position in the voice message | Deutsch Stelle in der Sprachnachricht |
| download | Englisch Download {name} | Deutsch {name} herunterladen |
| downloading | Englisch Download of {name} started. | Deutsch Download von {name} gestartet. |
| hint | Englisch Enter sends, Shift and Enter add a line | Deutsch Enter sendet, Umschalt und Enter für eine neue Zeile |
| call | Englisch Start a call | Deutsch Anruf starten |
| video | Englisch Start a video call | Deutsch Videoanruf starten |
| info | Englisch Shared files | Deutsch Geteilte Dateien |
| hideInfo | Englisch Hide shared files | Deutsch Geteilte Dateien ausblenden |
| shared | Englisch Shared in this chat | Deutsch In diesem Chat geteilt |
| noShared | Englisch Photos, files and voice messages land here. | Deutsch Fotos, Dateien und Sprachnachrichten landen hier. |
| replies | Englisch {n} replies | Deutsch {n} Antworten |
| oneReply | Englisch 1 reply | Deutsch 1 Antwort |
| lastReply | Englisch last {when} | Deutsch zuletzt {when} |
| thread | Englisch Thread | Deutsch Thread |
| closeThread | Englisch Close the thread | Deutsch Thread schließen |
| replyThread | Englisch Reply in the thread | Deutsch Im Thread antworten |
| alsoChannel | Englisch Also send to #{name} | Deutsch Auch an #{name} senden |
| showMembers | Englisch Show members | Deutsch Mitglieder zeigen |
| hideMembers | Englisch Hide members | Deutsch Mitglieder ausblenden |
| openChat | Englisch Open messages | Deutsch Nachrichten öffnen |
| closeChat | Englisch Close messages | Deutsch Nachrichten schließen |
| minimize | Englisch Minimize | Deutsch Minimieren |
| widgetHello | Englisch Hi {name}, pick up where you left off. | Deutsch Hallo {name}, mach da weiter, wo du warst. |
| searchMessages | Englisch Search messages | Deutsch Nachrichten durchsuchen |
| searchHint | Englisch Words, names or file names | Deutsch Wörter, Namen oder Dateinamen |
| from | Englisch From | Deutsch Von |
| in | Englisch In | Deutsch In |
| anyone | Englisch Anyone | Deutsch Alle Personen |
| anywhere | Englisch All conversations | Deutsch Alle Unterhaltungen |
| has | Englisch Contains | Deutsch Enthält |
| hasFile | Englisch Files | Deutsch Dateien |
| hasImage | Englisch Photos | Deutsch Fotos |
| hasVoice | Englisch Voice | Deutsch Sprache |
| results | Englisch {n} results for “{q}” | Deutsch {n} Treffer für „{q}“ |
| oneResult | Englisch 1 result for “{q}” | Deutsch 1 Treffer für „{q}“ |
| noResults | Englisch Nothing matches yet. Try fewer words or another person. | Deutsch Noch kein Treffer. Versuche weniger Wörter oder eine andere Person. |
| typeToSearch | Englisch Type a word to search every conversation. | Deutsch Gib ein Wort ein, um alle Unterhaltungen zu durchsuchen. |
| context | Englisch In context | Deutsch Im Zusammenhang |
| openInChat | Englisch Open in the conversation | Deutsch In der Unterhaltung öffnen |
| resultsLabel | Englisch Results | Deutsch Treffer |
| sentNote | Englisch Message sent to {name}. | Deutsch Nachricht an {name} gesendet. |
| from_ | Englisch {name}: {text} | Deutsch {name}: {text} |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Messenger reagiert selbst auf die Tasten Escape, Enter, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Pos1, Ende und Leertaste.
- Setzt 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 und aria-valuetext, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen dialog, group, listbox, log, option, radio, radiogroup, separator, slider und status.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Jedes Feld hat ein Label, sichtbar oder für Screenreader.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 2 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 2.074 Zeilen
'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>
);
}Häufige Fragen
Wie installiere ich Messenger?
Mit aiw add @ai-website/messenger. Der Befehl legt die Datei components/ui/messenger.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind avatar, button, icon, icon-button, photos und sidebar. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Messenger?
5 Varianten, Liste und Chat, Unterhaltung, Kanal mit Threads, Schwebender Messenger und Nachrichtensuche. Du wählst sie mit der Prop variant, zum Beispiel variant="thread".
Kann ich die Texte von Messenger übersetzen?
Ja. Messenger bringt 92 eigene Texte auf Deutsch und Englisch mit, etwa „Nachrichten“, „Unterhaltungen“ und „Unterhaltungen durchsuchen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Messenger einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Messenger mit aiw_components und installieren sie mit aiw_add @ai-website/messenger. Nenn im Prompt die Variante, etwa variant="two-pane", dann schreibt der Assistent deine Inhalte direkt in die Props.