Skip to content
UI Kit
Accent color

Chat messenger UI

Messaging for apps. Chats with replies, reactions, photos, files and voice, typing and read status, channels with threads, a floating messenger and search.

5 variantsOne of 4 components in Team and collaboration

Conversations that stay in your product

A messenger keeps conversations inside your product instead of somewhere else. These screens carry replies, reactions, photos, files, voice messages, typing and read status, the details people expect from any chat they use every day.

List and chat puts conversations with search, an unread filter and typing next to the open chat, phones switch between both with a back button. Conversation shows one chat with day dividers, a pinned message, a mark for new messages, a jump to the latest and the shared files beside it. Channel with threads lays a team channel out as rows with names and roles, answers in a thread beside it and members by presence. Floating messenger starts from a launcher with the unread count, opens a small panel with the chats and closes with Escape, phones get the full screen. Message search looks through every conversation by words, person, place and attachments and shows each hit in its context.

Choose the shape from the relationship, two pane for support and sales, channels for teams, the floating messenger for a quick question on any page. Show read status quietly, a grey tick is enough. Keep the composer fixed at the bottom so the keyboard never covers it on phones.

Variants

List and chatNew in 1.2

Conversations with search, unread filter and typing next to the open chat. Phones switch between both with a back button.

ConversationNew in 1.2

One chat with day dividers, a pinned message, a mark for new messages, a jump to the latest and the shared files beside it.

Channel with threadsNew in 1.2

A team channel as rows with names and roles, answers in a thread beside it and the members by presence.

Floating messengerNew in 1.2

A launcher with the unread count, a small panel with the chats and a compact conversation. Escape closes it, phones get the full screen.

Details

What Messenger brings along, read from the registry and the source.

Area
App
Variants
5
Version
1.0.0 · since 1.2.0
File
components/ui/messenger.tsx
npm packages
None
Languages
English and German, 92 texts of its own
Studio
Adjust in code
In collections
SaaS app screens

Props

The MessengerProps interface from components/ui/messenger.tsx, with the comments from the code.

  • variantoptional
    MessengerVariant

    two-panethreadchannelwidgetsearch

  • sidebaroptional
    Omit<SidebarProps, 'children'> | null

    The app frame. Leave it out (or null) for a standalone screen.

  • titleoptional
    string
  • subtitleoptional
    string
  • merequired
    string

    Your person id

  • peoplerequired
    MessengerPerson[]
  • conversationsrequired
    MessengerConversation[]
  • selectedoptional
    string

    Id of the open conversation

  • nowoptional
    string

    ISO date and time that counts as now

  • repliesoptional
    string[]

    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

  • backdropoptional
    React.ReactNode

    widget: the page behind it in contained mode

  • defaultOpenoptional
    boolean

    widget: open on load

  • queryoptional
    string

    search: the starting query

  • onActionoptional
    (action: string, data: Record<string, unknown>) => void | Promise<void>

    Called on send, react, download and open. Without it the block simulates the other side.

Built-in texts

Messenger writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 92 texts
messagesEnglish MessagesGerman Nachrichten
conversationsEnglish ConversationsGerman Unterhaltungen
searchEnglish Search conversationsGerman Unterhaltungen durchsuchen
newMessageEnglish New messageGerman Neue Nachricht
allEnglish AllGerman Alle
unreadEnglish UnreadGerman Ungelesen
pinnedEnglish PinnedGerman Angeheftet
chatsEnglish ChatsGerman Chats
noChatsEnglish Every conversation is read.German Alle Unterhaltungen sind gelesen.
backEnglish BackGerman Zurück
youEnglish YouGerman Du
onlineEnglish OnlineGerman Online
awayEnglish AwayGerman Abwesend
offlineEnglish OfflineGerman Offline
membersEnglish {n} membersGerman {n} Mitglieder
membersTitleEnglish MembersGerman Mitglieder
onlineNEnglish Online, {n}German Online, {n}
offlineNEnglish Offline, {n}German Offline, {n}
typingEnglish {name} is typingGerman {name} schreibt
writeEnglish Message {name}German Nachricht an {name}
sendEnglish SendGerman Senden
attachEnglish Attach filesGerman Dateien anhängen
recordEnglish Record a voice messageGerman Sprachnachricht aufnehmen
stopRecordEnglish Stop and add the recordingGerman Aufnahme beenden und anhängen
cancelRecordEnglish Discard the recordingGerman Aufnahme verwerfen
recordingEnglish RecordingGerman Aufnahme läuft
removeEnglish Remove {name}German {name} entfernen
replyingToEnglish Replying to {name}German Antwort an {name}
cancelReplyEnglish Cancel the replyGerman Antwort abbrechen
reactEnglish Add a reactionGerman Reaktion hinzufügen
replyEnglish ReplyGerman Antworten
copyEnglish Copy textGerman Text kopieren
copiedEnglish Text copied.German Text kopiert.
reactedYouEnglish you reactedGerman du hast reagiert
reactionEnglish {emoji} {n}German {emoji} {n}
toggleReactionEnglish React with {emoji}German Mit {emoji} reagieren
sentEnglish SentGerman Gesendet
deliveredEnglish DeliveredGerman Zugestellt
readEnglish ReadGerman Gelesen
todayEnglish TodayGerman Heute
yesterdayEnglish YesterdayGerman Gestern
newSinceEnglish New messagesGerman Neue Nachrichten
jumpLatestEnglish Jump to the latest messageGerman Zur neuesten Nachricht
newBelowEnglish {n} newGerman {n} neu
photoEnglish PhotoGerman Foto
fileEnglish FileGerman Datei
voiceEnglish Voice messageGerman Sprachnachricht
voiceNEnglish Voice message, {d}German Sprachnachricht, {d}
playEnglish Play the voice messageGerman Sprachnachricht abspielen
pauseEnglish Pause the voice messageGerman Sprachnachricht anhalten
seekEnglish Position in the voice messageGerman Stelle in der Sprachnachricht
downloadEnglish Download {name}German {name} herunterladen
downloadingEnglish Download of {name} started.German Download von {name} gestartet.
hintEnglish Enter sends, Shift and Enter add a lineGerman Enter sendet, Umschalt und Enter für eine neue Zeile
callEnglish Start a callGerman Anruf starten
videoEnglish Start a video callGerman Videoanruf starten
infoEnglish Shared filesGerman Geteilte Dateien
hideInfoEnglish Hide shared filesGerman Geteilte Dateien ausblenden
sharedEnglish Shared in this chatGerman In diesem Chat geteilt
noSharedEnglish Photos, files and voice messages land here.German Fotos, Dateien und Sprachnachrichten landen hier.
repliesEnglish {n} repliesGerman {n} Antworten
oneReplyEnglish 1 replyGerman 1 Antwort
lastReplyEnglish last {when}German zuletzt {when}
threadEnglish ThreadGerman Thread
closeThreadEnglish Close the threadGerman Thread schließen
replyThreadEnglish Reply in the threadGerman Im Thread antworten
alsoChannelEnglish Also send to #{name}German Auch an #{name} senden
showMembersEnglish Show membersGerman Mitglieder zeigen
hideMembersEnglish Hide membersGerman Mitglieder ausblenden
openChatEnglish Open messagesGerman Nachrichten öffnen
closeChatEnglish Close messagesGerman Nachrichten schließen
minimizeEnglish MinimizeGerman Minimieren
widgetHelloEnglish Hi {name}, pick up where you left off.German Hallo {name}, mach da weiter, wo du warst.
searchMessagesEnglish Search messagesGerman Nachrichten durchsuchen
searchHintEnglish Words, names or file namesGerman Wörter, Namen oder Dateinamen
fromEnglish FromGerman Von
inEnglish InGerman In
anyoneEnglish AnyoneGerman Alle Personen
anywhereEnglish All conversationsGerman Alle Unterhaltungen
hasEnglish ContainsGerman Enthält
hasFileEnglish FilesGerman Dateien
hasImageEnglish PhotosGerman Fotos
hasVoiceEnglish VoiceGerman Sprache
resultsEnglish {n} results for “{q}”German {n} Treffer für „{q}“
oneResultEnglish 1 result for “{q}”German 1 Treffer für „{q}“
noResultsEnglish Nothing matches yet. Try fewer words or another person.German Noch kein Treffer. Versuche weniger Wörter oder eine andere Person.
typeToSearchEnglish Type a word to search every conversation.German Gib ein Wort ein, um alle Unterhaltungen zu durchsuchen.
contextEnglish In contextGerman Im Zusammenhang
openInChatEnglish Open in the conversationGerman In der Unterhaltung öffnen
resultsLabelEnglish ResultsGerman Treffer
sentNoteEnglish Message sent to {name}.German Nachricht an {name} gesendet.
from_English {name}: {text}German {name}: {text}

Accessibility

Read from the source of the component.

  • Messenger handles the keys Escape, Enter, Arrow up, Arrow down, Arrow left, Arrow right, Home, End and Space itself.
  • Sets aria-activedescendant, aria-checked, aria-controls, aria-current, aria-expanded, aria-label, aria-live, aria-pressed, aria-relevant, aria-selected, aria-valuemax, aria-valuemin, aria-valuenow and aria-valuetext, so screen readers announce purpose and state.
  • Uses the roles dialog, group, listbox, log, option, radio, radiogroup, separator, slider and status.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 2,074 lines
components/ui/messenger.tsx
'use client';

import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { cn, type Locale, prefersReducedMotion, round, useLocale, useTexts } from '@/lib/kit';
import { Avatar } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { IconButton } from '@/components/ui/icon-button';
import { Scene, type SceneKind } from '@/components/ui/photos';
import { Sidebar, type SidebarProps } from '@/components/ui/sidebar';

export type MessengerVariant = 'two-pane' | 'thread' | 'channel' | 'widget' | 'search';

export interface MessengerPerson {
  id: string;
  name: string;
  /** Job or a short note, shown in headers and the member list */
  role?: string;
  status?: 'online' | 'away' | 'offline';
}

export type MessengerAttachment =
  | { kind: 'image'; name: string; scene?: SceneKind; seed?: number }
  | { kind: 'file'; name: string; /** Bytes */ size: number }
  | { kind: 'voice'; /** Seconds */ duration: number; /** Bar heights from 0 to 1 */ wave?: number[] };

export interface MessengerReaction {
  emoji: string;
  /** Person ids */
  by: string[];
}

export interface MessengerMessage {
  id: string;
  /** Person id */
  from: string;
  /** ISO date and time */
  time: string;
  text?: string;
  attachments?: MessengerAttachment[];
  reactions?: MessengerReaction[];
  /** Id of the message this one answers */
  replyTo?: string;
  /** Own messages: how far they got */
  status?: 'sent' | 'delivered' | 'read';
  /** channel: answers in the thread */
  replies?: MessengerMessage[];
  pinned?: boolean;
}

export interface MessengerConversation {
  id: string;
  /** Group and channel name. Direct chats take the other person's name. */
  name?: string;
  kind?: 'direct' | 'group' | 'channel';
  /** Person ids, including you */
  members: string[];
  messages: MessengerMessage[];
  unread?: number;
  muted?: boolean;
  pinned?: boolean;
  topic?: string;
  /** Person id of someone typing right now */
  typing?: string;
  /** thread: the last message you read, a divider marks what came after */
  lastRead?: string;
}

export interface MessengerProps {
  variant?: MessengerVariant;
  /** The app frame. Leave it out (or null) for a standalone screen. */
  sidebar?: Omit<SidebarProps, 'children'> | null;
  title?: string;
  subtitle?: string;
  /** Your person id */
  me: string;
  people: MessengerPerson[];
  conversations: MessengerConversation[];
  /** Id of the open conversation */
  selected?: string;
  /** ISO date and time that counts as now */
  now?: string;
  /** Answers the other side sends after yours, in turn. Without them nobody answers. */
  replies?: string[];
  /** two-pane and search: which pane a phone shows first */
  mobileView?: 'list' | 'detail';
  /** widget: contained keeps it inside its parent (previews), fixed pins it to the window */
  position?: 'contained' | 'fixed';
  /** widget: the page behind it in contained mode */
  backdrop?: React.ReactNode;
  /** widget: open on load */
  defaultOpen?: boolean;
  /** search: the starting query */
  query?: string;
  /** Called on send, react, download and open. Without it the block simulates the other side. */
  onAction?: (action: string, data: Record<string, unknown>) => void | Promise<void>;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const TEXTS = {
  en: {
    messages: 'Messages',
    conversations: 'Conversations',
    search: 'Search conversations',
    newMessage: 'New message',
    all: 'All',
    unread: 'Unread',
    pinned: 'Pinned',
    chats: 'Chats',
    noChats: 'Every conversation is read.',
    back: 'Back',
    you: 'You',
    online: 'Online',
    away: 'Away',
    offline: 'Offline',
    members: '{n} members',
    membersTitle: 'Members',
    onlineN: 'Online, {n}',
    offlineN: 'Offline, {n}',
    typing: '{name} is typing',
    write: 'Message {name}',
    send: 'Send',
    attach: 'Attach files',
    record: 'Record a voice message',
    stopRecord: 'Stop and add the recording',
    cancelRecord: 'Discard the recording',
    recording: 'Recording',
    remove: 'Remove {name}',
    replyingTo: 'Replying to {name}',
    cancelReply: 'Cancel the reply',
    react: 'Add a reaction',
    reply: 'Reply',
    copy: 'Copy text',
    copied: 'Text copied.',
    reactedYou: 'you reacted',
    reaction: '{emoji} {n}',
    toggleReaction: 'React with {emoji}',
    sent: 'Sent',
    delivered: 'Delivered',
    read: 'Read',
    today: 'Today',
    yesterday: 'Yesterday',
    newSince: 'New messages',
    jumpLatest: 'Jump to the latest message',
    newBelow: '{n} new',
    photo: 'Photo',
    file: 'File',
    voice: 'Voice message',
    voiceN: 'Voice message, {d}',
    play: 'Play the voice message',
    pause: 'Pause the voice message',
    seek: 'Position in the voice message',
    download: 'Download {name}',
    downloading: 'Download of {name} started.',
    hint: 'Enter sends, Shift and Enter add a line',
    call: 'Start a call',
    video: 'Start a video call',
    info: 'Shared files',
    hideInfo: 'Hide shared files',
    shared: 'Shared in this chat',
    noShared: 'Photos, files and voice messages land here.',
    replies: '{n} replies',
    oneReply: '1 reply',
    lastReply: 'last {when}',
    thread: 'Thread',
    closeThread: 'Close the thread',
    replyThread: 'Reply in the thread',
    alsoChannel: 'Also send to #{name}',
    showMembers: 'Show members',
    hideMembers: 'Hide members',
    openChat: 'Open messages',
    closeChat: 'Close messages',
    minimize: 'Minimize',
    widgetHello: 'Hi {name}, pick up where you left off.',
    searchMessages: 'Search messages',
    searchHint: 'Words, names or file names',
    from: 'From',
    in: 'In',
    anyone: 'Anyone',
    anywhere: 'All conversations',
    has: 'Contains',
    hasFile: 'Files',
    hasImage: 'Photos',
    hasVoice: 'Voice',
    results: '{n} results for “{q}”',
    oneResult: '1 result for “{q}”',
    noResults: 'Nothing matches yet. Try fewer words or another person.',
    typeToSearch: 'Type a word to search every conversation.',
    context: 'In context',
    openInChat: 'Open in the conversation',
    resultsLabel: 'Results',
    sentNote: 'Message sent to {name}.',
    from_: '{name}: {text}',
  },
  de: {
    messages: 'Nachrichten',
    conversations: 'Unterhaltungen',
    search: 'Unterhaltungen durchsuchen',
    newMessage: 'Neue Nachricht',
    all: 'Alle',
    unread: 'Ungelesen',
    pinned: 'Angeheftet',
    chats: 'Chats',
    noChats: 'Alle Unterhaltungen sind gelesen.',
    back: 'Zurück',
    you: 'Du',
    online: 'Online',
    away: 'Abwesend',
    offline: 'Offline',
    members: '{n} Mitglieder',
    membersTitle: 'Mitglieder',
    onlineN: 'Online, {n}',
    offlineN: 'Offline, {n}',
    typing: '{name} schreibt',
    write: 'Nachricht an {name}',
    send: 'Senden',
    attach: 'Dateien anhängen',
    record: 'Sprachnachricht aufnehmen',
    stopRecord: 'Aufnahme beenden und anhängen',
    cancelRecord: 'Aufnahme verwerfen',
    recording: 'Aufnahme läuft',
    remove: '{name} entfernen',
    replyingTo: 'Antwort an {name}',
    cancelReply: 'Antwort abbrechen',
    react: 'Reaktion hinzufügen',
    reply: 'Antworten',
    copy: 'Text kopieren',
    copied: 'Text kopiert.',
    reactedYou: 'du hast reagiert',
    reaction: '{emoji} {n}',
    toggleReaction: 'Mit {emoji} reagieren',
    sent: 'Gesendet',
    delivered: 'Zugestellt',
    read: 'Gelesen',
    today: 'Heute',
    yesterday: 'Gestern',
    newSince: 'Neue Nachrichten',
    jumpLatest: 'Zur neuesten Nachricht',
    newBelow: '{n} neu',
    photo: 'Foto',
    file: 'Datei',
    voice: 'Sprachnachricht',
    voiceN: 'Sprachnachricht, {d}',
    play: 'Sprachnachricht abspielen',
    pause: 'Sprachnachricht anhalten',
    seek: 'Stelle in der Sprachnachricht',
    download: '{name} herunterladen',
    downloading: 'Download von {name} gestartet.',
    hint: 'Enter sendet, Umschalt und Enter für eine neue Zeile',
    call: 'Anruf starten',
    video: 'Videoanruf starten',
    info: 'Geteilte Dateien',
    hideInfo: 'Geteilte Dateien ausblenden',
    shared: 'In diesem Chat geteilt',
    noShared: 'Fotos, Dateien und Sprachnachrichten landen hier.',
    replies: '{n} Antworten',
    oneReply: '1 Antwort',
    lastReply: 'zuletzt {when}',
    thread: 'Thread',
    closeThread: 'Thread schließen',
    replyThread: 'Im Thread antworten',
    alsoChannel: 'Auch an #{name} senden',
    showMembers: 'Mitglieder zeigen',
    hideMembers: 'Mitglieder ausblenden',
    openChat: 'Nachrichten öffnen',
    closeChat: 'Nachrichten schließen',
    minimize: 'Minimieren',
    widgetHello: 'Hallo {name}, mach da weiter, wo du warst.',
    searchMessages: 'Nachrichten durchsuchen',
    searchHint: 'Wörter, Namen oder Dateinamen',
    from: 'Von',
    in: 'In',
    anyone: 'Alle Personen',
    anywhere: 'Alle Unterhaltungen',
    has: 'Enthält',
    hasFile: 'Dateien',
    hasImage: 'Fotos',
    hasVoice: 'Sprache',
    results: '{n} Treffer für „{q}“',
    oneResult: '1 Treffer für „{q}“',
    noResults: 'Noch kein Treffer. Versuche weniger Wörter oder eine andere Person.',
    typeToSearch: 'Gib ein Wort ein, um alle Unterhaltungen zu durchsuchen.',
    context: 'Im Zusammenhang',
    openInChat: 'In der Unterhaltung öffnen',
    resultsLabel: 'Treffer',
    sentNote: 'Nachricht an {name} gesendet.',
    from_: '{name}: {text}',
  },
};

type Texts = (typeof TEXTS)['en'];

const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));

/* ------------------------------------------------------------------ */
/* Motion                                                              */
/* ------------------------------------------------------------------ */
/*
 * b5m-in      a new message rises in
 * b5m-pop     a reaction chip pops
 * b5m-flash   the message a reply or a search hit points at lights up
 * b5m-dot     typing dots
 * b5m-rise    entrance of the panes on load
 * Only transform, opacity and background change. Reduced motion keeps all still.
 */
const CSS =
  '@keyframes b5m-in{from{opacity:0;transform:translateY(10px) scale(.98)}}@keyframes b5m-pop{from{opacity:0;transform:scale(.6)}}' +
  '@keyframes b5m-flash{from{background-color:color-mix(in oklab,var(--primary) 18%,transparent)}}@keyframes b5m-dot{0%,60%,100%{opacity:.35;transform:none}30%{opacity:1;transform:translateY(-3px)}}' +
  '@keyframes b5m-rise{from{opacity:0;transform:translateY(12px)}}@keyframes b5m-rec{50%{opacity:.25}}@keyframes b5m-panel{from{opacity:0;transform:translateY(16px) scale(.97)}}' +
  '@media (prefers-reduced-motion:no-preference){.b5m-in{animation:b5m-in .45s var(--ease-kit) both}.b5m-pop{animation:b5m-pop .35s cubic-bezier(.34,1.56,.64,1) both}' +
  '.b5m-flash{animation:b5m-flash 1.8s ease-out both}.b5m-dot{animation:b5m-dot 1.2s ease-in-out infinite}.b5m-rise{animation:b5m-rise .6s var(--ease-kit) var(--b5m-d,0ms) both}' +
  '.b5m-rec{animation:b5m-rec 1.1s steps(2) infinite}.b5m-panel{animation:b5m-panel .4s var(--ease-kit) both;transform-origin:bottom right}}';

function Style() {
  return (
    <style href="kitb5-messenger" precedence="default">
      {CSS}
    </style>
  );
}

/* ------------------------------------------------------------------ */
/* Time and numbers                                                    */
/* ------------------------------------------------------------------ */

const MIN = 60000;
const toMs = (iso: string) => Date.parse(iso.length <= 10 ? iso + 'T00:00:00Z' : iso + (iso.endsWith('Z') ? '' : 'Z'));
const intl = (l: Locale) => (l === 'de' ? 'de-DE' : 'en-US');
const dayKey = (iso: string) => iso.slice(0, 10);

function clock(iso: string, l: Locale) {
  return new Intl.DateTimeFormat(intl(l), { hour: l === 'de' ? '2-digit' : 'numeric', minute: '2-digit', timeZone: 'UTC' }).format(new Date(toMs(iso)));
}

function dayLabel(iso: string, now: string, l: Locale, t: Texts) {
  const d = Math.round((toMs(dayKey(now)) - toMs(dayKey(iso))) / (1440 * MIN));
  if (d === 0) return t.today;
  if (d === 1) return t.yesterday;
  return new Intl.DateTimeFormat(intl(l), d < 7 ? { weekday: 'long', day: 'numeric', month: 'long', timeZone: 'UTC' } : { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' }).format(new Date(toMs(iso)));
}

/** Short stamp for lists: the time today, then yesterday, a weekday, a date */
function stamp(iso: string, now: string, l: Locale, t: Texts) {
  const d = Math.round((toMs(dayKey(now)) - toMs(dayKey(iso))) / (1440 * MIN));
  if (d === 0) return clock(iso, l);
  if (d === 1) return t.yesterday;
  if (d < 7) return new Intl.DateTimeFormat(intl(l), { weekday: 'short', timeZone: 'UTC' }).format(new Date(toMs(iso)));
  return new Intl.DateTimeFormat(intl(l), { day: 'numeric', month: 'short', timeZone: 'UTC' }).format(new Date(toMs(iso)));
}

function ago(iso: string, now: string, l: Locale) {
  const rtf = new Intl.RelativeTimeFormat(intl(l), { numeric: 'auto' });
  const m = Math.round((toMs(iso) - toMs(now)) / MIN);
  const a = Math.abs(m);
  if (a < 60) return rtf.format(m, 'minute');
  if (a < 1440) return rtf.format(Math.round(m / 60), 'hour');
  return rtf.format(Math.round(m / 1440), 'day');
}

const duration = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;

function bytes(n: number, l: Locale) {
  const kb = n / 1024;
  const [v, unit] = kb >= 1024 ? [kb / 1024, 'megabyte'] : [kb, 'kilobyte'];
  return new Intl.NumberFormat(intl(l), { style: 'unit', unit, unitDisplay: 'short', maximumFractionDigits: v < 10 ? 1 : 0 }).format(v);
}

const ext = (name: string) => (name.includes('.') ? name.split('.').pop()!.slice(0, 4).toUpperCase() : 'FILE');

/** A steady waveform from a seed, so server and browser draw the same bars */
function wave(seed: number, n = 36) {
  return Array.from({ length: n }, (_, i) => round(0.25 + 0.75 * Math.abs(Math.sin(seed * 1.7 + i * 0.9) * Math.cos(i * 0.37 + seed)), 2));
}

/* ------------------------------------------------------------------ */
/* Conversation state with the simulated other side                    */
/* ------------------------------------------------------------------ */

type Ctx = {
  t: Texts;
  locale: Locale;
  me: string;
  now: string;
  person: (id: string) => MessengerPerson;
  onAction?: MessengerProps['onAction'];
};

const wait = (ms: number) => new Promise((r) => setTimeout(r, prefersReducedMotion() ? Math.min(ms, 300) : ms));

function convName(c: MessengerConversation, ctx: Ctx) {
  if (c.name) return c.name;
  const other = c.members.find((m) => m !== ctx.me);
  return other ? ctx.person(other).name : ctx.t.you;
}

function preview(m: MessengerMessage | undefined, ctx: Ctx) {
  if (!m) return '';
  const a = m.attachments?.[0];
  const body = m.text || (a ? (a.kind === 'image' ? ctx.t.photo : a.kind === 'voice' ? fill(ctx.t.voiceN, { d: duration(a.duration) }) : a.name) : '');
  return m.from === ctx.me ? `${ctx.t.you}: ${body}` : body;
}

function useChats(conversations: MessengerConversation[], ctx: Ctx, replies: string[] = []) {
  const [msgs, setMsgs] = useState<Record<string, MessengerMessage[]>>(() => Object.fromEntries(conversations.map((c) => [c.id, c.messages])));
  const [typing, setTyping] = useState<Record<string, string | undefined>>(() => Object.fromEntries(conversations.map((c) => [c.id, c.typing])));
  const [announce, setAnnounce] = useState('');
  const turn = useRef(0);
  const seq = useRef(0);
  const alive = useRef(true);
  useEffect(() => {
    alive.current = true;
    return () => {
      alive.current = false;
    };
  }, []);
  // The Studio edits the props: take new conversations over
  const sig = JSON.stringify(conversations.map((c) => [c.id, c.messages.length, c.typing]));
  const first = useRef(true);
  useEffect(() => {
    if (first.current) return void (first.current = false);
    setMsgs(Object.fromEntries(conversations.map((c) => [c.id, c.messages])));
    setTyping(Object.fromEntries(conversations.map((c) => [c.id, c.typing])));
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [sig]);

  const patch = (cid: string, id: string, fn: (m: MessengerMessage) => MessengerMessage) =>
    setMsgs((all) => ({ ...all, [cid]: (all[cid] ?? []).map((m) => (m.id === id ? fn(m) : m)) }));

  const send = async (cid: string, draft: { text: string; attachments: MessengerAttachment[]; replyTo?: string }, parent?: string) => {
    const conv = conversations.find((c) => c.id === cid);
    const id = `n${Date.now()}-${seq.current++}`;
    const msg: MessengerMessage = { id, from: ctx.me, time: ctx.now, text: draft.text || undefined, attachments: draft.attachments.length ? draft.attachments : undefined, replyTo: draft.replyTo, status: 'sent' };
    if (parent) {
      patch(cid, parent, (m) => ({ ...m, replies: [...(m.replies ?? []), msg] }));
    } else setMsgs((all) => ({ ...all, [cid]: [...(all[cid] ?? []), msg] }));
    setAnnounce(fill(ctx.t.sentNote, { name: conv ? convName(conv, ctx) : '' }));
    if (ctx.onAction) return void (await ctx.onAction('send', { conversation: cid, parent, message: msg }));
    const other = conv?.members.find((m) => m !== ctx.me && ctx.person(m).status !== 'offline') ?? conv?.members.find((m) => m !== ctx.me);
    const status = (s: MessengerMessage['status']) => (parent ? patch(cid, parent, (m) => ({ ...m, replies: m.replies?.map((r) => (r.id === id ? { ...r, status: s } : r)) })) : patch(cid, id, (m) => ({ ...m, status: s })));
    await wait(500);
    if (!alive.current) return;
    status('delivered');
    if (!other || !replies.length) return;
    await wait(800);
    if (!alive.current) return;
    status('read');
    if (!parent) setTyping((x) => ({ ...x, [cid]: other }));
    await wait(1700);
    if (!alive.current) return;
    const text = replies[turn.current++ % replies.length];
    const answer: MessengerMessage = { id: `r${Date.now()}-${seq.current++}`, from: other, time: ctx.now, text };
    if (parent) patch(cid, parent, (m) => ({ ...m, replies: [...(m.replies ?? []), answer] }));
    else {
      setTyping((x) => ({ ...x, [cid]: undefined }));
      setMsgs((all) => ({ ...all, [cid]: [...(all[cid] ?? []), answer] }));
    }
    setAnnounce(fill(ctx.t.from_, { name: ctx.person(other).name, text }));
  };

  const react = (cid: string, mid: string, emoji: string, parent?: string) => {
    const toggle = (m: MessengerMessage): MessengerMessage => {
      const list = m.reactions ?? [];
      const have = list.find((r) => r.emoji === emoji);
      const mine = have?.by.includes(ctx.me);
      const next = have ? list.map((r) => (r.emoji === emoji ? { ...r, by: mine ? r.by.filter((b) => b !== ctx.me) : [...r.by, ctx.me] } : r)).filter((r) => r.by.length) : [...list, { emoji, by: [ctx.me] }];
      return { ...m, reactions: next };
    };
    if (parent) patch(cid, parent, (m) => ({ ...m, replies: m.replies?.map((r) => (r.id === mid ? toggle(r) : r)) }));
    else patch(cid, mid, toggle);
    void ctx.onAction?.('react', { conversation: cid, message: mid, emoji });
  };

  return { msgs, typing, send, react, announce, setAnnounce };
}

/* ------------------------------------------------------------------ */
/* Parts                                                               */
/* ------------------------------------------------------------------ */

function Live({ text }: { text: string }) {
  return (
    <p role="status" aria-live="polite" className="sr-only">
      {text}
    </p>
  );
}

const ring = 'outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-[color-mix(in_oklab,var(--primary)_70%,transparent)]';
const ringIn = 'outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-[color-mix(in_oklab,var(--primary)_70%,transparent)]';

function Person({ p, size = 'sm', status = true, className }: { p: MessengerPerson; size?: 'xs' | 'sm' | 'md'; status?: boolean; className?: string }) {
  return <Avatar name={p.name} size={size} alt="" status={status && p.status && p.status !== 'offline' ? p.status : undefined} className={className} />;
}

/** Two stacked avatars for groups */
function GroupMark({ c, ctx, size = 'md' }: { c: MessengerConversation; ctx: Ctx; size?: 'sm' | 'md' }) {
  if (c.kind === 'channel')
    return (
      <span className={cn('grid shrink-0 place-items-center rounded-lg bg-[color-mix(in_oklab,var(--primary)_12%,var(--surface-2))] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]', size === 'md' ? 'size-10' : 'size-8')}>
        <Icon name="hash" size={size === 'md' ? 18 : 15} />
      </span>
    );
  const others = c.members.filter((m) => m !== ctx.me);
  if (c.kind !== 'group' || others.length < 2) {
    const p = ctx.person(others[0] ?? ctx.me);
    return <Person p={p} size={size} />;
  }
  return (
    <span className={cn('relative shrink-0', size === 'md' ? 'size-10' : 'size-8')} aria-hidden>
      <Avatar name={ctx.person(others[0]).name} size="xs" alt="" className={cn('absolute left-0 top-0', size === 'md' && 'size-[1.625rem] text-[9.5px]')} />
      <Avatar name={ctx.person(others[1]).name} size="xs" alt="" ring className={cn('absolute bottom-0 right-0', size === 'md' && 'size-[1.625rem] text-[9.5px]')} />
    </span>
  );
}

function TypingRow({ p, t, compact }: { p: MessengerPerson; t: Texts; compact?: boolean }) {
  return (
    <div className="b5m-in mt-3 flex items-end gap-2">
      {!compact && <Person p={p} size="sm" status={false} />}
      <span aria-hidden className="flex h-9 items-center gap-1 rounded-2xl rounded-bl-md bg-surface-2 px-3.5">
        {[0, 1, 2].map((i) => (
          <span key={i} className="b5m-dot size-1.5 rounded-full bg-muted-foreground" style={{ animationDelay: `${i * 160}ms` }} />
        ))}
      </span>
      <span className="pb-2 text-[12px] text-subtle-foreground">{fill(t.typing, { name: p.name.split(' ')[0] })}</span>
    </div>
  );
}

function DayDivider({ label }: { label: string }) {
  return (
    <div role="separator" aria-label={label} className="my-5 flex items-center gap-3 text-[12px] font-medium text-subtle-foreground first:mt-1">
      <span className="h-px flex-1 bg-border" />
      <span className="rounded-full border border-border bg-background px-2.5 py-0.5">{label}</span>
      <span className="h-px flex-1 bg-border" />
    </div>
  );
}

/* Attachments --------------------------------------------------------- */

function VoicePlayer({ a, t, mine, id }: { a: Extract<MessengerAttachment, { kind: 'voice' }>; t: Texts; mine?: boolean; id: string }) {
  const bars = a.wave?.length ? a.wave : wave(a.duration + id.length);
  const [pos, setPos] = useState(0);
  const [playing, setPlaying] = useState(false);
  const raf = useRef(0);
  useEffect(() => {
    if (!playing) return;
    let last = performance.now();
    const tick = (now: number) => {
      const dt = (now - last) / 1000;
      last = now;
      setPos((p) => {
        const n = p + dt;
        if (n >= a.duration) {
          setPlaying(false);
          return 0;
        }
        return n;
      });
      raf.current = requestAnimationFrame(tick);
    };
    raf.current = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf.current);
  }, [playing, a.duration]);
  const share = pos / a.duration;
  const seek = (v: number) => setPos(Math.max(0, Math.min(a.duration - 0.01, v)));
  return (
    <div className={cn('flex w-64 max-w-full items-center gap-2.5 rounded-2xl px-2 py-1.5', mine ? 'bg-[color-mix(in_oklab,var(--primary-foreground)_14%,transparent)]' : 'bg-surface-2')}>
      <button
        type="button"
        aria-label={playing ? t.pause : t.play}
        onClick={() => setPlaying((p) => !p)}
        className={cn('relative grid size-9 shrink-0 place-items-center rounded-full transition-transform duration-200 after:absolute after:-inset-1 hover:scale-105 active:scale-95', mine ? 'bg-primary-foreground text-primary' : 'bg-primary text-primary-foreground', ring)}
      >
        <Icon name={playing ? 'pause' : 'play'} size={15} className={cn(!playing && 'translate-x-px')} />
      </button>
      <div
        role="slider"
        tabIndex={0}
        aria-label={t.seek}
        aria-valuemin={0}
        aria-valuemax={Math.round(a.duration)}
        aria-valuenow={Math.round(pos)}
        aria-valuetext={`${duration(pos)} / ${duration(a.duration)}`}
        onKeyDown={(e) => {
          if (e.key === 'ArrowRight' || e.key === 'ArrowUp') (e.preventDefault(), seek(pos + 1));
          if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') (e.preventDefault(), seek(pos - 1));
          if (e.key === 'Home') (e.preventDefault(), seek(0));
          if (e.key === ' ' || e.key === 'Enter') (e.preventDefault(), setPlaying((p) => !p));
        }}
        onPointerDown={(e) => {
          const r = e.currentTarget.getBoundingClientRect();
          seek(((e.clientX - r.left) / r.width) * a.duration);
        }}
        className={cn('flex h-11 min-w-0 flex-1 cursor-pointer items-center gap-[2px] rounded-md sm:h-9', ring)}
      >
        {bars.map((h, i) => (
          <span
            key={i}
            aria-hidden
            className={cn('w-[3px] shrink-0 rounded-full transition-colors duration-150', i / bars.length < share ? (mine ? 'bg-primary-foreground' : 'bg-primary') : mine ? 'bg-[color-mix(in_oklab,var(--primary-foreground)_40%,transparent)]' : 'bg-[color-mix(in_oklab,var(--foreground)_22%,transparent)]')}
            style={{ height: `${round(18 + h * 62)}%` }}
          />
        ))}
      </div>
      <span className={cn('w-9 shrink-0 text-right text-[12px] tabular-nums', mine ? 'text-primary-foreground/85' : 'text-muted-foreground')}>{duration(playing || pos ? pos : a.duration)}</span>
    </div>
  );
}

function FileTile({ a, ctx, onDownload, mine }: { a: Extract<MessengerAttachment, { kind: 'file' }>; ctx: Ctx; onDownload?: () => void; mine?: boolean }) {
  return (
    <div className={cn('flex w-64 max-w-full items-center gap-3 rounded-xl border p-2 pe-1.5', mine ? 'border-[color-mix(in_oklab,var(--primary-foreground)_22%,transparent)] bg-[color-mix(in_oklab,var(--primary-foreground)_10%,transparent)]' : 'border-border bg-card')}>
      <span className={cn('grid size-10 shrink-0 place-items-center rounded-lg text-[10px] font-bold tracking-[0.04em]', mine ? 'bg-primary-foreground text-primary' : 'bg-[color-mix(in_oklab,var(--primary)_12%,var(--surface-2))] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]')}>{ext(a.name)}</span>
      <span className="min-w-0 flex-1">
        <span className={cn('block truncate text-[13.5px] font-medium', mine ? 'text-primary-foreground' : 'text-foreground')}>{a.name}</span>
        <span className={cn('block text-[12px] tabular-nums', mine ? 'text-primary-foreground/80' : 'text-muted-foreground')}>{bytes(a.size, ctx.locale)}</span>
      </span>
      <IconButton icon="download" size="sm" label={fill(ctx.t.download, { name: a.name })} onClick={onDownload} className={mine ? 'text-primary-foreground hover:bg-[color-mix(in_oklab,var(--primary-foreground)_16%,transparent)] hover:text-primary-foreground' : ''} />
    </div>
  );
}

function ImageTile({ a, wide }: { a: Extract<MessengerAttachment, { kind: 'image' }>; wide?: boolean }) {
  return (
    <figure className={cn('group/img relative overflow-hidden rounded-2xl border border-border bg-muted', wide ? 'w-72' : 'w-60', 'max-w-full')}>
      <Scene kind={a.scene ?? 'architecture'} seed={a.seed ?? 3} className="block aspect-[4/3] w-full transition-transform duration-700 ease-[var(--ease-kit)] group-hover/img:scale-[1.03] motion-reduce:transition-none" />
      <figcaption className="absolute inset-x-0 bottom-0 truncate bg-[linear-gradient(to_top,rgb(0_0_0/0.55),transparent)] px-3 pb-2 pt-6 text-[12px] font-medium text-white">{a.name}</figcaption>
    </figure>
  );
}

function Attachments({ list, ctx, mine, id, onDownload }: { list: MessengerAttachment[]; ctx: Ctx; mine?: boolean; id: string; onDownload: (name: string) => void }) {
  return (
    <div className={cn('flex flex-col gap-1.5', mine ? 'items-end' : 'items-start')}>
      {list.map((a, i) =>
        a.kind === 'image' ? <ImageTile key={i} a={a} /> : a.kind === 'voice' ? <VoicePlayer key={i} a={a} t={ctx.t} id={`${id}-${i}`} /> : <FileTile key={i} a={a} ctx={ctx} onDownload={() => onDownload(a.name)} />,
      )}
    </div>
  );
}

/* One message ---------------------------------------------------------- */

const EMOJI = ['👍', '❤️', '🎉', '😄', '👀', '✅'];

type MsgHandlers = {
  onReact: (id: string, emoji: string) => void;
  onReply?: (m: MessengerMessage) => void;
  onJump?: (id: string) => void;
  onThread?: (m: MessengerMessage) => void;
  onDownload: (name: string) => void;
  onCopy: (text: string) => void;
};

function Reactions({ m, ctx, onReact, mine }: { m: MessengerMessage; ctx: Ctx; onReact: (emoji: string) => void; mine?: boolean }) {
  if (!m.reactions?.length) return null;
  return (
    <div className={cn('mt-1 flex flex-wrap gap-1', mine && 'justify-end')}>
      {m.reactions.map((r) => {
        const own = r.by.includes(ctx.me);
        const names = r.by.map((b) => (b === ctx.me ? ctx.t.you : ctx.person(b).name.split(' ')[0])).join(', ');
        return (
          <button
            key={r.emoji}
            type="button"
            aria-pressed={own}
            aria-label={`${fill(ctx.t.reaction, { emoji: r.emoji, n: r.by.length })}${own ? `, ${ctx.t.reactedYou}` : ''}`}
            title={names}
            onClick={() => onReact(r.emoji)}
            className={cn(
              'b5m-pop relative inline-flex h-7 items-center gap-1 rounded-full border px-2 text-[12.5px] tabular-nums transition-[background-color,border-color,transform] duration-200 after:absolute after:-inset-y-2 after:inset-x-0 hover:-translate-y-px',
              own ? 'border-[color-mix(in_oklab,var(--primary)_45%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_12%,var(--background))] text-foreground' : 'border-border bg-background text-muted-foreground hover:border-border-strong',
              ring,
            )}
          >
            <span aria-hidden className="text-[13px] leading-none">
              {r.emoji}
            </span>
            <span aria-hidden className="font-medium">
              {r.by.length}
            </span>
          </button>
        );
      })}
    </div>
  );
}

/** Hover and focus toolbar of a message: react, reply, copy */
function MsgTools({ m, ctx, h, side, open, setOpen }: { m: MessengerMessage; ctx: Ctx; h: MsgHandlers; side: 'start' | 'end'; open: boolean; setOpen: (v: boolean) => void }) {
  const [picker, setPicker] = useState(false);
  useEffect(() => {
    if (!open) setPicker(false);
  }, [open]);
  const btn = cn('relative grid size-8 place-items-center rounded-md text-muted-foreground transition-colors after:absolute after:-inset-1.5 hover:bg-accent hover:text-foreground', ring);
  return (
    <div
      className={cn(
        'absolute -top-4 z-10 flex items-center gap-0.5 rounded-lg border border-border bg-popover p-0.5 shadow-[var(--highlight),var(--shadow-md)] transition-[opacity,visibility,translate] duration-200',
        side === 'end' ? 'end-2' : 'start-2',
        open ? 'visible translate-y-0 opacity-100' : 'invisible translate-y-1 opacity-0 group-hover/msg:visible group-hover/msg:translate-y-0 group-hover/msg:opacity-100 group-focus-within/msg:visible group-focus-within/msg:translate-y-0 group-focus-within/msg:opacity-100',
      )}
      onKeyDown={(e) => {
        if (e.key === 'Escape') {
          e.stopPropagation();
          setPicker(false);
          setOpen(false);
          (e.currentTarget.closest('[data-msg]') as HTMLElement | null)?.focus();
        }
      }}
    >
      {picker ? (
        <div role="group" aria-label={ctx.t.react} className="flex items-center">
          {EMOJI.map((e, i) => (
            <button
              key={e}
              type="button"
              autoFocus={i === 0}
              aria-label={fill(ctx.t.toggleReaction, { emoji: e })}
              onClick={() => {
                h.onReact(m.id, e);
                setPicker(false);
                setOpen(false);
              }}
              className={cn('b5m-pop relative grid size-8 place-items-center rounded-md text-[16px] transition-transform after:absolute after:-inset-1.5 hover:scale-125 hover:bg-accent', ring)}
              style={{ animationDelay: `${i * 25}ms` }}
            >
              <span aria-hidden>{e}</span>
            </button>
          ))}
        </div>
      ) : (
        <>
          <button type="button" aria-label={ctx.t.react} title={ctx.t.react} onClick={() => setPicker(true)} className={btn}>
            <Icon name="smile-plus" size={16} />
          </button>
          {h.onReply && (
            <button type="button" aria-label={ctx.t.reply} title={ctx.t.reply} onClick={() => (h.onReply?.(m), setOpen(false))} className={btn}>
              <Icon name="reply" size={16} />
            </button>
          )}
          {m.text && (
            <button type="button" aria-label={ctx.t.copy} title={ctx.t.copy} onClick={() => (h.onCopy(m.text ?? ''), setOpen(false))} className={btn}>
              <Icon name="copy" size={15} />
            </button>
          )}
        </>
      )}
    </div>
  );
}

function StatusMark({ s, t }: { s?: MessengerMessage['status']; t: Texts }) {
  if (!s) return null;
  return (
    <span className={cn('inline-flex items-center', s === 'read' ? 'text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]' : 'text-subtle-foreground')} title={t[s]}>
      <Icon key={s} name={s === 'sent' ? 'check' : 'check-check'} size={14} strokeWidth={2} className="b5m-pop" />
      <span className="sr-only">{t[s]}</span>
    </span>
  );
}

function Quote({ m, ctx, mine, onJump }: { m?: MessengerMessage; ctx: Ctx; mine?: boolean; onJump?: (id: string) => void }) {
  if (!m) return null;
  const p = ctx.person(m.from);
  return (
    <button
      type="button"
      onClick={() => onJump?.(m.id)}
      className={cn(
        'mb-1 block w-full max-w-[18rem] rounded-xl border-s-[3px] px-3 py-1.5 text-left transition-colors',
        mine ? 'border-primary-foreground/70 bg-[color-mix(in_oklab,var(--primary-foreground)_14%,transparent)] hover:bg-[color-mix(in_oklab,var(--primary-foreground)_20%,transparent)]' : 'border-primary bg-[color-mix(in_oklab,var(--foreground)_5%,transparent)] hover:bg-[color-mix(in_oklab,var(--foreground)_8%,transparent)]',
        ring,
      )}
    >
      <span className={cn('block text-[12px] font-semibold', mine ? 'text-primary-foreground' : 'text-foreground')}>{m.from === ctx.me ? ctx.t.you : p.name}</span>
      <span className={cn('block truncate text-[12.5px]', mine ? 'text-primary-foreground/85' : 'text-muted-foreground')}>{preview({ ...m, from: '' }, ctx)}</span>
    </button>
  );
}

/** A message as a bubble: own ones on the right in the accent */
function Bubble({ m, ctx, h, first, last, all, fresh, flash, index, active, compact, showName }: { m: MessengerMessage; ctx: Ctx; h: MsgHandlers; first: boolean; last: boolean; all: MessengerMessage[]; fresh?: boolean; flash?: boolean; index: number; active: boolean; compact?: boolean; showName?: boolean }) {
  const mine = m.from === ctx.me;
  const p = ctx.person(m.from);
  const [open, setOpen] = useState(false);
  const quoted = m.replyTo ? all.find((x) => x.id === m.replyTo) : undefined;
  return (
    <div
      data-msg={index}
      data-id={m.id}
      tabIndex={active ? 0 : -1}
      onClick={(e) => {
        if ((e.target as HTMLElement).closest('button,[role=slider]')) return;
        setOpen((o) => !o);
      }}
      onBlur={(e) => {
        if (!e.currentTarget.contains(e.relatedTarget as Node)) setOpen(false);
      }}
      className={cn('group/msg relative flex gap-2 rounded-xl px-1 py-0.5', mine ? 'justify-end' : 'justify-start', first ? 'mt-3' : 'mt-0.5', fresh && 'b5m-in', flash && 'b5m-flash', ringIn)}
    >
      {!mine && !compact && <span className="w-8 shrink-0 self-end">{last && <Person p={p} size="sm" status={false} />}</span>}
      <div className={cn('flex min-w-0 max-w-[min(32rem,80%)] flex-col', mine ? 'items-end' : 'items-start')}>
        {first && !mine && showName && <span className="mb-1 ms-1 text-[12px] font-medium text-muted-foreground">{p.name}</span>}
        {(m.text || quoted) && (
          <div
            className={cn(
              'w-fit max-w-full rounded-[1.25rem] px-3.5 py-2 text-[14px] leading-relaxed',
              mine ? 'bg-primary text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.14)]' : 'bg-surface-2 text-foreground',
              mine ? (last ? 'rounded-br-md' : 'rounded-r-md') : last ? 'rounded-bl-md' : 'rounded-l-md',
              quoted && 'pt-1.5 ps-1.5',
            )}
          >
            <Quote m={quoted} ctx={ctx} mine={mine} onJump={h.onJump} />
            {m.text && <p className={cn('whitespace-pre-line text-pretty [overflow-wrap:anywhere]', quoted && 'ps-2')}>{m.text}</p>}
          </div>
        )}
        {m.attachments?.length ? (
          <div className={cn(m.text && 'mt-1')}>
            <Attachments list={m.attachments} ctx={ctx} mine={mine} id={m.id} onDownload={h.onDownload} />
          </div>
        ) : null}
        <Reactions m={m} ctx={ctx} mine={mine} onReact={(e) => h.onReact(m.id, e)} />
        {last && (
          <span className={cn('mt-1 flex items-center gap-1 text-[11.5px] tabular-nums text-subtle-foreground', mine ? 'me-1' : 'ms-1')}>
            {clock(m.time, ctx.locale)}
            {mine && <StatusMark s={m.status} t={ctx.t} />}
          </span>
        )}
      </div>
      <MsgTools m={m} ctx={ctx} h={h} side={mine ? 'end' : 'start'} open={open} setOpen={setOpen} />
    </div>
  );
}

/** A message as a row with avatar and name, for channels */
function Row({ m, ctx, h, first, fresh, index, active, flash, inThread }: { m: MessengerMessage; ctx: Ctx; h: MsgHandlers; first: boolean; fresh?: boolean; index: number; active: boolean; flash?: boolean; inThread?: boolean }) {
  const p = ctx.person(m.from);
  const [open, setOpen] = useState(false);
  const n = m.replies?.length ?? 0;
  const lastReply = m.replies?.[n - 1];
  const people = [...new Set(m.replies?.map((r) => r.from) ?? [])].slice(0, 3);
  return (
    <div
      data-msg={index}
      data-id={m.id}
      tabIndex={active ? 0 : -1}
      onClick={(e) => {
        if ((e.target as HTMLElement).closest('button,[role=slider]')) return;
        setOpen((o) => !o);
      }}
      onBlur={(e) => {
        if (!e.currentTarget.contains(e.relatedTarget as Node)) setOpen(false);
      }}
      className={cn('group/msg relative flex gap-3 rounded-lg px-2 transition-colors hover:bg-[color-mix(in_oklab,var(--foreground)_3%,transparent)] sm:px-3', first ? 'mt-3 pt-1.5 pb-1' : 'py-1', fresh && 'b5m-in', flash && 'b5m-flash', ringIn)}
    >
      <span className="w-9 shrink-0">{first ? <Person p={p} size="sm" status={false} className="size-9" /> : <span className="hidden pt-1 text-right text-[11px] tabular-nums text-subtle-foreground group-hover/msg:block">{clock(m.time, ctx.locale)}</span>}</span>
      <div className="min-w-0 flex-1">
        {first && (
          <p className="flex flex-wrap items-baseline gap-x-2">
            <span className="text-[14px] font-semibold text-foreground">{m.from === ctx.me ? `${p.name}` : p.name}</span>
            {p.role && <span className="text-[12px] text-subtle-foreground max-sm:hidden">{p.role}</span>}
            <span className="text-[12px] tabular-nums text-subtle-foreground">{clock(m.time, ctx.locale)}</span>
          </p>
        )}
        {m.text && <p className="whitespace-pre-line text-pretty text-[14px] leading-relaxed text-foreground [overflow-wrap:anywhere]">{m.text}</p>}
        {m.attachments?.length ? (
          <div className="mt-1.5">
            <Attachments list={m.attachments} ctx={ctx} id={m.id} onDownload={h.onDownload} />
          </div>
        ) : null}
        <Reactions m={m} ctx={ctx} onReact={(e) => h.onReact(m.id, e)} />
        {!inThread && n > 0 && h.onThread && (
          <button type="button" onClick={() => h.onThread?.(m)} className={cn('group/th mt-1.5 -ms-1.5 inline-flex min-h-11 items-center gap-2 rounded-lg px-1.5 text-[13px] transition-colors hover:bg-card hover:shadow-[var(--shadow-sm)] sm:min-h-9', ring)}>
            <span className="flex -space-x-1.5">
              {people.map((id) => (
                <Avatar key={id} name={ctx.person(id).name} size="xs" alt="" ring className="size-5 text-[8px]" />
              ))}
            </span>
            <span className="font-semibold text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]">{n === 1 ? ctx.t.oneReply : fill(ctx.t.replies, { n })}</span>
            {lastReply && <span className="text-subtle-foreground">{fill(ctx.t.lastReply, { when: ago(lastReply.time, ctx.now, ctx.locale) })}</span>}
            <Icon name="chevron-right" size={14} className="text-subtle-foreground transition-transform group-hover/th:translate-x-0.5" />
          </button>
        )}
      </div>
      <MsgTools m={m} ctx={ctx} h={{ ...h, onReply: inThread ? undefined : h.onThread }} side="end" open={open} setOpen={setOpen} />
    </div>
  );
}

/* The message log ------------------------------------------------------ */

function useCopy(t: Texts, setAnnounce: (s: string) => void) {
  return (text: string) => {
    void navigator.clipboard?.writeText(text).catch(() => undefined);
    setAnnounce(t.copied);
  };
}

/**
 * The scrolling log. Arrow keys move between messages (one tab stop),
 * a focused message shows its toolbar, Tab reaches it.
 */
function Log({ messages, ctx, h, typing, style = 'bubble', label, compact, showNames, lastRead, onScrollState, scrollRef, className, flashId, inThread }: { messages: MessengerMessage[]; ctx: Ctx; h: MsgHandlers; typing?: string; style?: 'bubble' | 'row'; label: string; compact?: boolean; showNames?: boolean; lastRead?: string; onScrollState?: (atBottom: boolean) => void; scrollRef?: React.RefObject<HTMLDivElement | null>; className?: string; flashId?: string; inThread?: boolean }) {
  const own = useRef<HTMLDivElement>(null);
  const ref = scrollRef ?? own;
  const [active, setActive] = useState(messages.length - 1);
  const initial = useRef(new Set(messages.map((m) => m.id)));
  const count = messages.length;
  const lastMine = messages[count - 1]?.from === ctx.me;
  // Stick to the newest message on load, on own messages and while at the bottom
  const atBottom = useRef(true);
  useLayoutEffect(() => {
    const el = ref.current;
    if (!el) return;
    if (atBottom.current || lastMine) el.scrollTop = el.scrollHeight;
    setActive((a) => (a >= count - 2 ? count - 1 : a));
  }, [count, typing, lastMine, ref]);
  const move = (e: React.KeyboardEvent<HTMLDivElement>) => {
    if ((e.target as HTMLElement).closest('[data-msg] button,[data-msg] [role=slider]') && e.key !== 'Escape') return;
    const k = e.key;
    let next = active;
    if (k === 'ArrowDown') next = Math.min(count - 1, active + 1);
    else if (k === 'ArrowUp') next = Math.max(0, active - 1);
    else if (k === 'Home') next = 0;
    else if (k === 'End') next = count - 1;
    else return;
    e.preventDefault();
    setActive(next);
    ref.current?.querySelector<HTMLElement>(`[data-msg="${next}"]`)?.focus();
  };
  return (
    <div
      ref={ref}
      role="log"
      aria-live="polite"
      aria-relevant="additions"
      aria-label={label}
      onKeyDown={move}
      onFocus={(e) => {
        const i = (e.target as HTMLElement).closest('[data-msg]')?.getAttribute('data-msg');
        if (i !== null && i !== undefined) setActive(Number(i));
      }}
      onScroll={(e) => {
        const el = e.currentTarget;
        const b = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
        if (b !== atBottom.current) onScrollState?.(b);
        atBottom.current = b;
      }}
      className={cn('kit-scrollbar relative min-h-0 flex-1 overflow-y-auto overscroll-contain', className)}
    >
      {messages.map((m, i) => {
        const prev = messages[i - 1];
        const next = messages[i + 1];
        const newDay = !prev || dayKey(prev.time) !== dayKey(m.time);
        const gap = (a?: MessengerMessage, b?: MessengerMessage) => !a || !b || a.from !== b.from || dayKey(a.time) !== dayKey(b.time) || toMs(b.time) - toMs(a.time) > 10 * MIN;
        const first = gap(prev, m) || prev?.id === lastRead;
        const last = gap(m, next) || m.id === lastRead;
        const fresh = !initial.current.has(m.id);
        const props = { m, ctx, h, first, index: i, active: i === active, fresh, flash: flashId === m.id };
        return (
          <div key={m.id}>
            {newDay && <DayDivider label={dayLabel(m.time, ctx.now, ctx.locale, ctx.t)} />}
            {prev && prev.id === lastRead && (
              <div role="separator" aria-label={ctx.t.newSince} className="my-4 flex items-center gap-3 text-[12px] font-semibold text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]">
                <span className="h-px flex-1 bg-[color-mix(in_oklab,var(--primary)_45%,transparent)]" />
                {ctx.t.newSince}
              </div>
            )}
            {style === 'row' ? <Row {...props} inThread={inThread} /> : <Bubble {...props} last={last} all={messages} compact={compact} showName={showNames} />}
          </div>
        );
      })}
      {typing && <TypingRow p={ctx.person(typing)} t={ctx.t} compact={compact} />}
      <div className="h-2" />
    </div>
  );
}

/* Composer ------------------------------------------------------------- */

function Composer({ ctx, placeholder, onSend, reply, onCancelReply, compact, extra, autoFocusKey }: { ctx: Ctx; placeholder: string; onSend: (d: { text: string; attachments: MessengerAttachment[]; replyTo?: string }) => void; reply?: MessengerMessage | null; onCancelReply?: () => void; compact?: boolean; extra?: React.ReactNode; autoFocusKey?: unknown }) {
  const { t } = ctx;
  const id = useId();
  const [text, setText] = useState('');
  const [files, setFiles] = useState<MessengerAttachment[]>([]);
  const [rec, setRec] = useState<number | null>(null);
  const area = useRef<HTMLTextAreaElement>(null);
  const picker = useRef<HTMLInputElement>(null);
  const seed = useRef(1);
  useEffect(() => {
    if (rec === null) return;
    const timer = window.setInterval(() => setRec((r) => (r === null ? r : r + 1)), 1000);
    return () => window.clearInterval(timer);
  }, [rec === null]); // eslint-disable-line react-hooks/exhaustive-deps
  useEffect(() => {
    if (autoFocusKey !== undefined && reply) area.current?.focus();
  }, [autoFocusKey, reply]);
  // Grow with the text up to six lines
  useLayoutEffect(() => {
    const el = area.current;
    if (!el) return;
    el.style.height = 'auto';
    el.style.height = `${Math.min(el.scrollHeight, 152)}px`;
  }, [text]);
  const can = !!text.trim() || files.length > 0;
  const send = () => {
    if (!can) return;
    onSend({ text: text.trim(), attachments: files, replyTo: reply?.id });
    setText('');
    setFiles([]);
    onCancelReply?.();
  };
  const stop = (keep: boolean) => {
    if (keep && rec !== null) setFiles((f) => [...f, { kind: 'voice', duration: Math.max(2, rec), wave: wave(seed.current++ * 3.1) }]);
    setRec(null);
    area.current?.focus();
  };
  const chip = (a: MessengerAttachment) => (a.kind === 'voice' ? fill(t.voiceN, { d: duration(a.duration) }) : a.name);
  return (
    <div className="rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] transition-[border-color,box-shadow] duration-300 focus-within:border-[color-mix(in_oklab,var(--primary)_45%,var(--border-strong))] focus-within:shadow-[var(--highlight),0_0_0_3px_color-mix(in_oklab,var(--primary)_14%,transparent)]">
      {reply && (
        <div className="b5m-in flex items-center gap-2 border-b border-border px-3 py-2">
          <Icon name="reply" size={15} className="shrink-0 text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]" />
          <span className="min-w-0 flex-1 text-[12.5px]">
            <span className="font-semibold text-foreground">{fill(t.replyingTo, { name: reply.from === ctx.me ? t.you : ctx.person(reply.from).name })}</span>
            <span className="block truncate text-muted-foreground">{preview({ ...reply, from: '' }, ctx)}</span>
          </span>
          <IconButton icon="x" size="xs" label={t.cancelReply} onClick={() => (onCancelReply?.(), area.current?.focus())} />
        </div>
      )}
      {files.length > 0 && (
        <ul className="flex flex-wrap gap-1.5 px-2.5 pt-2.5" aria-label={t.attach}>
          {files.map((a, i) => (
            <li key={i} className="b5m-pop inline-flex h-8 max-w-full items-center gap-1.5 rounded-lg border border-border bg-surface ps-2 pe-0.5 text-[12.5px] text-foreground">
              <Icon name={a.kind === 'image' ? 'image' : a.kind === 'voice' ? 'mic' : 'file'} size={14} className="shrink-0 text-muted-foreground" />
              <span className="max-w-[11rem] truncate">{chip(a)}</span>
              {a.kind === 'file' && <span className="shrink-0 tabular-nums text-subtle-foreground">{bytes(a.size, ctx.locale)}</span>}
              <IconButton icon="x" size="xs" label={fill(t.remove, { name: chip(a) })} onClick={() => setFiles((f) => f.filter((_, k) => k !== i))} className="size-7" />
            </li>
          ))}
        </ul>
      )}
      {rec !== null ? (
        <div className="flex items-center gap-2 px-2 py-2" role="group" aria-label={t.recording}>
          <span className="b5m-rec ms-2 size-2.5 shrink-0 rounded-full bg-destructive" aria-hidden />
          <span className="text-[13.5px] font-medium text-foreground">{t.recording}</span>
          <span className="text-[13.5px] tabular-nums text-muted-foreground" aria-live="off">
            {duration(rec)}
          </span>
          <span aria-hidden className="mx-2 flex h-6 min-w-0 flex-1 items-center gap-[2px] overflow-hidden">
            {wave(rec + 2, 40).map((v, i) => (
              <span key={i} className="w-[3px] shrink-0 rounded-full bg-[color-mix(in_oklab,var(--destructive)_55%,transparent)] transition-[height] duration-300" style={{ height: `${round(20 + v * 70)}%` }} />
            ))}
          </span>
          <IconButton icon="trash" size="sm" label={t.cancelRecord} onClick={() => stop(false)} />
          <IconButton icon="check" size="sm" variant="primary" round label={t.stopRecord} onClick={() => stop(true)} />
        </div>
      ) : (
        <div className="flex items-end gap-1 p-1.5">
          <input
            ref={picker}
            type="file"
            multiple
            tabIndex={-1}
            aria-hidden
            className="sr-only"
            onChange={(e) => {
              const list = [...(e.target.files ?? [])].map<MessengerAttachment>((f) => (f.type.startsWith('image/') ? { kind: 'image', name: f.name, scene: 'still-life', seed: seed.current++ } : { kind: 'file', name: f.name, size: f.size }));
              setFiles((x) => [...x, ...list]);
              e.target.value = '';
              area.current?.focus();
            }}
          />
          <IconButton icon="paperclip" size="sm" label={t.attach} onClick={() => picker.current?.click()} />
          <label htmlFor={id} className="sr-only">
            {placeholder}
          </label>
          <textarea
            ref={area}
            id={id}
            rows={1}
            value={text}
            placeholder={placeholder}
            onChange={(e) => setText(e.target.value)}
            onKeyDown={(e) => {
              if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
                e.preventDefault();
                send();
              }
              if (e.key === 'Escape' && reply) onCancelReply?.();
            }}
            className="block max-h-38 min-h-11 w-0 min-w-0 flex-1 resize-none bg-transparent px-1.5 py-2.5 text-base sm:min-h-9 sm:py-2 leading-[1.35] text-foreground outline-none placeholder:text-subtle-foreground sm:text-[14px]"
          />
          {!compact && <IconButton icon="mic" size="sm" label={t.record} onClick={() => setRec(0)} className={cn(can && 'max-sm:hidden')} />}
          <IconButton icon="send" size="sm" variant={can ? 'primary' : 'ghost'} label={t.send} disabled={!can} onClick={send} className={cn('transition-[background-color,color,transform]', can && 'scale-100')} />
        </div>
      )}
      {(extra || !compact) && (
        <div className="flex items-center justify-between gap-3 px-3 pb-2 text-[11.5px] text-subtle-foreground">
          {extra ?? <span />}
          {!compact && <span className="max-md:hidden">{t.hint}</span>}
        </div>
      )}
    </div>
  );
}

/* Headers -------------------------------------------------------------- */

function ConvHead({ c, ctx, onBack, actions, typing, children }: { c: MessengerConversation; ctx: Ctx; onBack?: () => void; actions?: React.ReactNode; typing?: string; children?: React.ReactNode }) {
  const other = c.kind === 'direct' || !c.kind ? ctx.person(c.members.find((m) => m !== ctx.me) ?? ctx.me) : null;
  const status = typing ? fill(ctx.t.typing, { name: ctx.person(typing).name.split(' ')[0] }) : other ? (other.status === 'online' ? ctx.t.online : other.status === 'away' ? ctx.t.away : other.role ?? ctx.t.offline) : c.topic ?? fill(ctx.t.members, { n: c.members.length });
  return (
    <div className="flex h-16 shrink-0 items-center gap-2 border-b border-border px-3 sm:gap-3 sm:px-5">
      {onBack && (
        <button type="button" onClick={onBack} className={cn('-ms-1.5 flex h-11 shrink-0 items-center gap-0.5 rounded-lg pe-2 ps-1 text-[14px] font-medium text-foreground hover:bg-accent md:hidden', ring)}>
          <Icon name="chevron-left" size={20} />
          {ctx.t.back}
        </button>
      )}
      <GroupMark c={c} ctx={ctx} />
      <div className="min-w-0 flex-1">
        <h2 className="truncate text-[15px] font-semibold tracking-[-0.015em] text-foreground">{c.kind === 'channel' ? c.name : convName(c, ctx)}</h2>
        <p className={cn('truncate text-[12.5px]', typing ? 'text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]' : 'text-muted-foreground')}>{status}</p>
      </div>
      {children}
      {actions && <div className="flex shrink-0 items-center gap-0.5">{actions}</div>}
    </div>
  );
}

function SearchBox({ label, value, onChange, className, big, id }: { label: string; value: string; onChange: (v: string) => void; className?: string; big?: boolean; id?: string }) {
  return (
    <label className={cn('relative block', className)}>
      <span className="sr-only">{label}</span>
      <Icon name="search" size={big ? 18 : 15} className={cn('pointer-events-none absolute top-1/2 -translate-y-1/2 text-subtle-foreground', big ? 'start-3.5' : 'start-3')} />
      <input
        id={id}
        type="search"
        value={value}
        onChange={(e) => onChange(e.target.value)}
        placeholder={label}
        className={cn(
          'w-full min-w-0 rounded-lg border border-input bg-background text-base text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] duration-300 placeholder:text-subtle-foreground focus:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)]',
          big ? 'h-12 ps-11 pe-3 sm:text-[15px]' : 'h-11 ps-9 pe-3 sm:h-9 sm:text-[13px]',
        )}
      />
    </label>
  );
}

function Segmented<T extends string>({ options, value, onChange, label, className }: { options: { value: T; label: React.ReactNode }[]; value: T; onChange: (v: T) => void; label: string; className?: string }) {
  const ref = useRef<HTMLDivElement>(null);
  return (
    <div ref={ref} role="radiogroup" aria-label={label} className={cn('inline-flex rounded-lg bg-muted p-1', className)}>
      {options.map((o, i) => (
        <button
          key={o.value}
          type="button"
          role="radio"
          aria-checked={value === o.value}
          tabIndex={value === o.value ? 0 : -1}
          onClick={() => onChange(o.value)}
          onKeyDown={(e) => {
            const d = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
            if (!d) return;
            e.preventDefault();
            const n = (i + d + options.length) % options.length;
            onChange(options[n].value);
            ref.current?.querySelectorAll<HTMLButtonElement>('[role=radio]')[n]?.focus();
          }}
          className={cn('inline-flex h-11 flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 text-[13px] font-medium transition-[background-color,color,box-shadow] duration-200 sm:h-8', value === o.value ? 'bg-background text-foreground shadow-[var(--shadow-sm)] ring-1 ring-border' : 'text-muted-foreground hover:text-foreground', ringIn)}
        >
          {o.label}
        </button>
      ))}
    </div>
  );
}

/** Move focus between the rows of a list with the arrow keys */
function arrowNav(e: React.KeyboardEvent<HTMLElement>, selector = '[data-row]') {
  const rows = [...e.currentTarget.querySelectorAll<HTMLElement>(selector)];
  const i = rows.indexOf(document.activeElement as HTMLElement);
  const k = e.key;
  const n = k === 'ArrowDown' ? i + 1 : k === 'ArrowUp' ? i - 1 : k === 'Home' ? 0 : k === 'End' ? rows.length - 1 : null;
  if (n === null || i < 0) return;
  e.preventDefault();
  rows[Math.max(0, Math.min(rows.length - 1, n))]?.focus();
}

/* ------------------------------------------------------------------ */
/* two-pane                                                            */
/* ------------------------------------------------------------------ */

type V = { ctx: Ctx; props: MessengerProps; framed: boolean };

const appHeight = (framed: boolean) => (framed ? 'h-[calc(100dvh-3.5rem)] md:h-dvh' : 'h-dvh');

function ConvRow({ c, ctx, active, onOpen, last, typing, unread, i }: { c: MessengerConversation; ctx: Ctx; active: boolean; onOpen: () => void; last?: MessengerMessage; typing?: string; unread: number; i: number }) {
  return (
    <li className="b5m-rise" style={{ '--b5m-d': `${i * 40}ms` } as React.CSSProperties}>
      <button
        type="button"
        data-row
        aria-current={active ? 'true' : undefined}
        onClick={onOpen}
        className={cn('group/row relative flex w-full items-center gap-3 rounded-xl px-2.5 py-2.5 text-left transition-colors duration-200', active ? 'bg-[color-mix(in_oklab,var(--primary)_10%,var(--surface))] ring-1 ring-inset ring-border' : 'hover:bg-surface', ringIn)}
      >
        <GroupMark c={c} ctx={ctx} />
        <span className="min-w-0 flex-1">
          <span className="flex items-baseline gap-2">
            <span className={cn('min-w-0 flex-1 truncate text-[14px] text-foreground', unread ? 'font-semibold' : 'font-medium')}>{convName(c, ctx)}</span>
            {c.muted && <Icon name="volume-x" size={13} className="shrink-0 text-subtle-foreground" label={ctx.locale === 'de' ? 'stumm' : 'muted'} />}
            {last && <span className={cn('shrink-0 text-[12px] tabular-nums', unread ? 'font-medium text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]' : 'text-subtle-foreground')}>{stamp(last.time, ctx.now, ctx.locale, ctx.t)}</span>}
          </span>
          <span className="mt-0.5 flex items-center gap-2">
            <span className={cn('min-w-0 flex-1 truncate text-[13px]', unread ? 'text-foreground' : 'text-muted-foreground')}>
              {typing ? <span className="text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]">{fill(ctx.t.typing, { name: ctx.person(typing).name.split(' ')[0] })}</span> : preview(last, ctx)}
            </span>
            {unread > 0 && <span className="b5m-pop grid h-5 min-w-5 shrink-0 place-items-center rounded-full bg-primary px-1.5 text-[11px] font-semibold tabular-nums text-primary-foreground">{unread}</span>}
            {last?.from === ctx.me && !unread && <StatusMark s={last.status} t={ctx.t} />}
          </span>
        </span>
      </button>
    </li>
  );
}

function TwoPane({ ctx, props, framed }: V) {
  const { t } = ctx;
  const { conversations, replies, title } = props;
  const chats = useChats(conversations, ctx, replies);
  const [current, setCurrent] = useState(props.selected ?? conversations[0]?.id);
  const [view, setView] = useState<'list' | 'detail'>(props.mobileView ?? 'list');
  const [q, setQ] = useState('');
  const [filter, setFilter] = useState<'all' | 'unread'>('all');
  const [reads, setReads] = useState<Record<string, boolean>>({ [props.selected ?? conversations[0]?.id ?? '']: true });
  const [reply, setReply] = useState<MessengerMessage | null>(null);
  const [flash, setFlash] = useState<string>();
  const log = useRef<HTMLDivElement>(null);
  const c = conversations.find((x) => x.id === current) ?? conversations[0];
  const unreadOf = (x: MessengerConversation) => (reads[x.id] ? 0 : x.unread ?? 0);
  const shown = conversations
    .filter((x) => (filter === 'all' || unreadOf(x) > 0) && (!q || convName(x, ctx).toLowerCase().includes(q.toLowerCase()) || (chats.msgs[x.id] ?? []).some((m) => m.text?.toLowerCase().includes(q.toLowerCase()))))
    .sort((a, b) => toMs((chats.msgs[b.id] ?? []).at(-1)?.time ?? '2000-01-01') - toMs((chats.msgs[a.id] ?? []).at(-1)?.time ?? '2000-01-01'));
  const groups = [
    { label: t.pinned, items: shown.filter((x) => x.pinned) },
    { label: t.chats, items: shown.filter((x) => !x.pinned) },
  ].filter((g) => g.items.length);
  const h: MsgHandlers = {
    onReact: (id, e) => c && chats.react(c.id, id, e),
    onReply: setReply,
    onJump: (id) => {
      log.current?.querySelector<HTMLElement>(`[data-id="${id}"]`)?.scrollIntoView({ block: 'center', behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
      setFlash(undefined);
      requestAnimationFrame(() => setFlash(id));
    },
    onDownload: (name) => (chats.setAnnounce(fill(t.downloading, { name })), void ctx.onAction?.('download', { name })),
    onCopy: useCopy(t, chats.setAnnounce),
  };
  const open = (id: string) => {
    setCurrent(id);
    setView('detail');
    setReply(null);
    setReads((r) => ({ ...r, [id]: true }));
  };
  return (
    <div className={cn('flex min-h-0', appHeight(framed))}>
      <section aria-label={t.conversations} className={cn('flex min-w-0 flex-1 flex-col border-border bg-[color-mix(in_oklab,var(--surface)_55%,var(--background))] md:w-[19rem] md:flex-none md:border-e lg:w-[21rem]', view === 'detail' && 'max-md:hidden')}>
        <div className="b5m-rise space-y-3 px-3 pb-3 pt-4">
          <div className="flex items-center justify-between gap-3 ps-1">
            <h1 className="text-[19px] font-semibold tracking-[-0.025em] text-foreground">{title ?? t.messages}</h1>
            <IconButton icon="edit" size="sm" variant="outline" label={t.newMessage} onClick={() => void ctx.onAction?.('new', {})} />
          </div>
          <SearchBox label={t.search} value={q} onChange={setQ} />
          <Segmented
            label={t.conversations}
            value={filter}
            onChange={setFilter}
            className="flex w-full"
            options={[
              { value: 'all', label: t.all },
              { value: 'unread', label: <>{t.unread}<span className="text-[12px] tabular-nums text-subtle-foreground">{conversations.filter((x) => unreadOf(x) > 0).length}</span></> },
            ]}
          />
        </div>
        <nav aria-label={t.conversations} className="kit-scrollbar relative min-h-0 flex-1 overflow-y-auto px-2 pb-4" onKeyDown={(e) => arrowNav(e)}>
          {groups.map((g) => (
            <div key={g.label} className="mt-2">
              <h2 className="px-2.5 pb-1 text-[11.5px] font-medium uppercase tracking-[0.06em] text-subtle-foreground">{g.label}</h2>
              <ul className="space-y-0.5">
                {g.items.map((x, i) => (
                  <ConvRow key={x.id} c={x} ctx={ctx} i={i} active={x.id === c?.id} onOpen={() => open(x.id)} last={(chats.msgs[x.id] ?? []).at(-1)} typing={chats.typing[x.id]} unread={unreadOf(x)} />
                ))}
              </ul>
            </div>
          ))}
          {!groups.length && <p className="mx-2 mt-4 rounded-xl border border-dashed border-border px-4 py-8 text-center text-[13px] text-muted-foreground">{t.noChats}</p>}
        </nav>
      </section>
      {c && (
        <section aria-label={convName(c, ctx)} className={cn('flex min-w-0 flex-1 flex-col', view === 'list' && 'max-md:hidden')}>
          <ConvHead
            c={c}
            ctx={ctx}
            typing={chats.typing[c.id]}
            onBack={() => setView('list')}
            actions={
              <>
                <IconButton icon="phone" size="sm" label={t.call} className="max-sm:hidden" onClick={() => void ctx.onAction?.('call', { conversation: c.id })} />
                <IconButton icon="video" size="sm" label={t.video} onClick={() => void ctx.onAction?.('video', { conversation: c.id })} />
              </>
            }
          />
          <Log key={c.id} scrollRef={log} messages={chats.msgs[c.id] ?? []} ctx={ctx} h={h} typing={chats.typing[c.id]} label={convName(c, ctx)} showNames={c.kind === 'group'} flashId={flash} className="px-3 pt-2 sm:px-5 lg:px-8" />
          <div className="shrink-0 px-3 pb-3 pt-2 sm:px-5 lg:px-8 lg:pb-5">
            <Composer key={c.id} ctx={ctx} placeholder={fill(t.write, { name: convName(c, ctx).split(' ')[0] })} reply={reply} onCancelReply={() => setReply(null)} autoFocusKey={reply?.id} onSend={(d) => void chats.send(c.id, d)} />
          </div>
        </section>
      )}
      <Live text={chats.announce} />
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* thread                                                              */
/* ------------------------------------------------------------------ */

function SharedList({ msgs, ctx, onDownload }: { msgs: MessengerMessage[]; ctx: Ctx; onDownload: (n: string) => void }) {
  const all = msgs.flatMap((m) => (m.attachments ?? []).map((a) => ({ a, m })));
  const images = all.filter((x) => x.a.kind === 'image');
  const other = all.filter((x) => x.a.kind !== 'image');
  if (!all.length) return <p className="px-5 py-8 text-center text-[13px] text-muted-foreground">{ctx.t.noShared}</p>;
  return (
    <div className="space-y-5 p-4 sm:p-5">
      {images.length > 0 && (
        <ul className="grid grid-cols-3 gap-1.5">
          {images.map(({ a }, i) =>
            a.kind === 'image' ? (
              <li key={i} className="b5m-pop overflow-hidden rounded-lg border border-border" style={{ animationDelay: `${i * 40}ms` }}>
                <Scene kind={a.scene ?? 'architecture'} seed={a.seed ?? 3} className="block aspect-square w-full" />
                <span className="sr-only">{a.name}</span>
              </li>
            ) : null,
          )}
        </ul>
      )}
      <ul className="space-y-2">
        {other.map(({ a, m }, i) => (
          <li key={i} className="b5m-rise" style={{ '--b5m-d': `${i * 50}ms` } as React.CSSProperties}>
            {a.kind === 'file' ? <FileTile a={a} ctx={ctx} onDownload={() => onDownload(a.name)} /> : a.kind === 'voice' ? <VoicePlayer a={a} t={ctx.t} id={`s-${m.id}-${i}`} /> : null}
            <p className="mt-1 ps-1 text-[11.5px] text-subtle-foreground">
              {m.from === ctx.me ? ctx.t.you : ctx.person(m.from).name} · {stamp(m.time, ctx.now, ctx.locale, ctx.t)}
            </p>
          </li>
        ))}
      </ul>
    </div>
  );
}

function Thread({ ctx, props, framed }: V) {
  const { t } = ctx;
  const c = props.conversations.find((x) => x.id === props.selected) ?? props.conversations[0];
  const chats = useChats(props.conversations, ctx, props.replies);
  const [reply, setReply] = useState<MessengerMessage | null>(null);
  const [atBottom, setAtBottom] = useState(true);
  const [seenCount, setSeenCount] = useState(0);
  const [info, setInfo] = useState(false);
  const [flash, setFlash] = useState<string>();
  const log = useRef<HTMLDivElement>(null);
  const msgs = c ? chats.msgs[c.id] ?? [] : [];
  useEffect(() => {
    if (atBottom) setSeenCount(msgs.length);
  }, [atBottom, msgs.length]);
  if (!c) return null;
  const pinned = msgs.find((m) => m.pinned);
  const jump = (id: string) => {
    log.current?.querySelector<HTMLElement>(`[data-id="${id}"]`)?.scrollIntoView({ block: 'center', behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
    setFlash(undefined);
    requestAnimationFrame(() => setFlash(id));
  };
  const h: MsgHandlers = {
    onReact: (id, e) => chats.react(c.id, id, e),
    onReply: setReply,
    onJump: jump,
    onDownload: (name) => (chats.setAnnounce(fill(t.downloading, { name })), void ctx.onAction?.('download', { name })),
    onCopy: useCopy(t, chats.setAnnounce),
  };
  const fresh = Math.max(0, msgs.length - seenCount);
  const panel = (
    <aside aria-label={t.shared} className={cn('flex min-h-0 flex-col border-border bg-surface', 'max-xl:absolute max-xl:inset-y-0 max-xl:end-0 max-xl:z-20 max-xl:w-[min(22rem,100%)] max-xl:border-s max-xl:shadow-[var(--shadow-lg)]', 'xl:w-[20rem] xl:border-s', info ? 'b5m-in' : 'hidden')}>
      <div className="flex h-16 shrink-0 items-center justify-between gap-3 border-b border-border px-5">
        <h2 className="text-[14px] font-semibold text-foreground">{t.shared}</h2>
        <IconButton icon="x" size="sm" label={t.hideInfo} onClick={() => setInfo(false)} />
      </div>
      <div className="kit-scrollbar min-h-0 flex-1 overflow-y-auto">
        <SharedList msgs={msgs} ctx={ctx} onDownload={h.onDownload} />
      </div>
    </aside>
  );
  return (
    <div className={cn('relative flex min-h-0', appHeight(framed))}>
      <section aria-label={convName(c, ctx)} className="flex min-w-0 flex-1 flex-col">
        <ConvHead
          c={c}
          ctx={ctx}
          typing={chats.typing[c.id]}
          actions={
            <>
              <IconButton icon="phone" size="sm" label={t.call} className="max-sm:hidden" onClick={() => void ctx.onAction?.('call', { conversation: c.id })} />
              <IconButton icon="files" size="sm" label={info ? t.hideInfo : t.info} aria-pressed={info} onClick={() => setInfo((v) => !v)} />
            </>
          }
        />
        {pinned && (
          <button type="button" onClick={() => jump(pinned.id)} className={cn('b5m-rise flex shrink-0 items-center gap-3 border-b border-border bg-[color-mix(in_oklab,var(--primary)_5%,var(--background))] px-4 py-2 text-left transition-colors hover:bg-[color-mix(in_oklab,var(--primary)_9%,var(--background))] sm:px-5', ringIn)}>
            <Icon name="pin" size={15} className="shrink-0 text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]" />
            <span className="min-w-0 flex-1">
              <span className="block text-[12px] font-semibold text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]">{t.pinned}</span>
              <span className="block truncate text-[13px] text-foreground">{preview({ ...pinned, from: '' }, ctx)}</span>
            </span>
            <Icon name="arrow-up" size={15} className="shrink-0 text-subtle-foreground" />
          </button>
        )}
        <div className="relative flex min-h-0 flex-1 flex-col">
          <Log scrollRef={log} messages={msgs} ctx={ctx} h={h} typing={chats.typing[c.id]} label={convName(c, ctx)} showNames={c.kind === 'group'} lastRead={c.lastRead} onScrollState={setAtBottom} flashId={flash} className="mx-auto w-full max-w-[52rem] px-3 pt-2 sm:px-6" />
          <button
            type="button"
            aria-label={t.jumpLatest}
            onClick={() => {
              const el = log.current;
              el?.scrollTo({ top: el.scrollHeight, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
            }}
            className={cn(
              'absolute bottom-3 end-4 inline-flex h-11 items-center gap-1.5 rounded-full border border-border bg-popover px-3.5 text-[13px] font-medium text-foreground shadow-[var(--highlight),var(--shadow-md)] transition-[opacity,visibility,translate] duration-300 hover:-translate-y-0.5 sm:h-9 sm:end-6',
              atBottom ? 'invisible translate-y-2 opacity-0' : 'visible opacity-100',
              ring,
            )}
          >
            <Icon name="arrow-down" size={15} />
            {fresh > 0 ? fill(t.newBelow, { n: fresh }) : t.jumpLatest}
          </button>
        </div>
        <div className="mx-auto w-full max-w-[52rem] shrink-0 px-3 pb-3 pt-2 sm:px-6 lg:pb-5">
          <Composer ctx={ctx} placeholder={fill(t.write, { name: convName(c, ctx).split(' ')[0] })} reply={reply} onCancelReply={() => setReply(null)} autoFocusKey={reply?.id} onSend={(d) => void chats.send(c.id, d)} />
        </div>
      </section>
      {panel}
      <Live text={chats.announce} />
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* channel                                                             */
/* ------------------------------------------------------------------ */

function Members({ c, ctx }: { c: MessengerConversation; ctx: Ctx }) {
  const people = c.members.map(ctx.person);
  const on = people.filter((p) => p.status && p.status !== 'offline');
  const off = people.filter((p) => !p.status || p.status === 'offline');
  return (
    <div className="space-y-5 p-4 sm:p-5">
      {[
        { label: fill(ctx.t.onlineN, { n: on.length }), list: on },
        { label: fill(ctx.t.offlineN, { n: off.length }), list: off },
      ]
        .filter((g) => g.list.length)
        .map((g) => (
          <div key={g.label}>
            <h3 className="text-[11.5px] font-medium uppercase tracking-[0.06em] text-subtle-foreground">{g.label}</h3>
            <ul className="mt-2.5 space-y-1">
              {g.list.map((p, i) => (
                <li key={p.id} className={cn('b5m-rise flex items-center gap-2.5 rounded-lg px-1.5 py-1.5', p.status === 'offline' || !p.status ? 'opacity-70' : '')} style={{ '--b5m-d': `${i * 35}ms` } as React.CSSProperties}>
                  <Person p={p} size="sm" />
                  <span className="min-w-0">
                    <span className="block truncate text-[13.5px] font-medium text-foreground">
                      {p.name}
                      {p.id === ctx.me && <span className="font-normal text-subtle-foreground"> ({ctx.t.you})</span>}
                    </span>
                    {p.role && <span className="block truncate text-[12px] text-muted-foreground">{p.role}</span>}
                  </span>
                </li>
              ))}
            </ul>
          </div>
        ))}
    </div>
  );
}

function Channel({ ctx, props, framed }: V) {
  const { t } = ctx;
  const c = props.conversations.find((x) => x.id === props.selected) ?? props.conversations[0];
  const chats = useChats(props.conversations, ctx, props.replies);
  const [side, setSide] = useState<'members' | 'thread' | null>('members');
  // Members start open beside the channel on desktops only, smaller screens open them on request
  const [auto, setAuto] = useState(true);
  const [narrow, setNarrow] = useState(false);
  useEffect(() => {
    const mq = window.matchMedia('(max-width: 1023.98px)');
    const on = () => setNarrow(mq.matches);
    on();
    mq.addEventListener('change', on);
    return () => mq.removeEventListener('change', on);
  }, []);
  const membersShown = side === 'members' && !(auto && narrow);
  const [threadId, setThreadId] = useState<string>();
  const [alsoChannel, setAlsoChannel] = useState(false);
  const closeRef = useRef<HTMLButtonElement>(null);
  const opener = useRef<HTMLElement | null>(null);
  useEffect(() => {
    if (side === 'thread') closeRef.current?.focus({ preventScroll: true });
  }, [side, threadId]);
  if (!c) return null;
  const msgs = chats.msgs[c.id] ?? [];
  const parent = msgs.find((m) => m.id === threadId);
  const h: MsgHandlers = {
    onReact: (id, e) => chats.react(c.id, id, e),
    onThread: (m) => {
      opener.current = document.activeElement as HTMLElement;
      setThreadId(m.id);
      setSide('thread');
    },
    onDownload: (name) => (chats.setAnnounce(fill(t.downloading, { name })), void ctx.onAction?.('download', { name })),
    onCopy: useCopy(t, chats.setAnnounce),
  };
  const th: MsgHandlers = { ...h, onReact: (id, e) => parent && chats.react(c.id, id, e, parent.id), onThread: undefined };
  const closeThread = () => {
    setSide(window.innerWidth >= 1024 ? 'members' : null);
    opener.current?.focus();
  };
  const memberPeople = c.members.map(ctx.person);
  return (
    <div className={cn('relative flex min-h-0', appHeight(framed))}>
      <section aria-label={`#${c.name}`} className="flex min-w-0 flex-1 flex-col">
        <ConvHead
          c={c}
          ctx={ctx}
          actions={
            <button
              type="button"
              aria-pressed={membersShown}
              aria-label={membersShown ? t.hideMembers : t.showMembers}
              onClick={() => {
                setAuto(false);
                setSide(membersShown ? null : 'members');
              }}
              className={cn('group/mem flex h-11 items-center gap-2 rounded-lg border border-border px-2 transition-colors hover:bg-accent aria-pressed:bg-accent sm:h-9', ring)}
            >
              <span className="flex -space-x-2">
                {memberPeople.slice(0, 3).map((p) => (
                  <Avatar key={p.id} name={p.name} size="xs" alt="" ring className="transition-transform duration-300 group-hover/mem:-translate-y-px" />
                ))}
              </span>
              <span className="text-[13px] font-medium tabular-nums text-foreground">{c.members.length}</span>
            </button>
          }
        />
        <Log messages={msgs} ctx={ctx} h={h} style="row" label={`#${c.name}`} className="px-1 pt-1 sm:px-3" />
        <div className="shrink-0 px-3 pb-3 pt-2 sm:px-5 lg:pb-5">
          <Composer ctx={ctx} placeholder={fill(t.write, { name: `#${c.name}` })} onSend={(d) => void chats.send(c.id, d)} />
        </div>
      </section>
      {side && (
        <aside
          aria-label={side === 'thread' ? t.thread : t.membersTitle}
          onKeyDown={(e) => e.key === 'Escape' && side === 'thread' && closeThread()}
          className={cn('b5m-in flex min-h-0 flex-col border-border bg-surface', 'max-lg:absolute max-lg:inset-0 max-lg:z-20 max-lg:sm:start-auto max-lg:sm:w-[24rem] max-lg:sm:border-s max-lg:sm:shadow-[var(--shadow-lg)]', side === 'members' && auto && 'max-lg:hidden', side === 'thread' ? 'lg:w-[24rem] lg:border-s' : 'lg:w-[16.5rem] lg:border-s')}
        >
          <div className="flex h-16 shrink-0 items-center justify-between gap-3 border-b border-border ps-5 pe-3">
            <div className="min-w-0">
              <h2 className="text-[14px] font-semibold text-foreground">{side === 'thread' ? t.thread : t.membersTitle}</h2>
              <p className="truncate text-[12px] text-muted-foreground">{side === 'thread' ? `#${c.name}` : fill(t.members, { n: c.members.length })}</p>
            </div>
            <IconButton ref={closeRef} icon="x" size="sm" label={side === 'thread' ? t.closeThread : t.hideMembers} onClick={() => (side === 'thread' ? closeThread() : setSide(null))} />
          </div>
          {side === 'thread' && parent ? (
            <>
              <div className="kit-scrollbar relative min-h-0 flex-1 overflow-y-auto">
                <div className="border-b border-border px-1 pb-3 sm:px-2">
                  <Row m={parent} ctx={ctx} h={th} first index={-1} active={false} inThread />
                </div>
                <p className="flex items-center gap-3 px-5 pt-3 text-[12px] font-medium text-subtle-foreground">
                  {(parent.replies?.length ?? 0) === 1 ? t.oneReply : fill(t.replies, { n: parent.replies?.length ?? 0 })}
                  <span className="h-px flex-1 bg-border" />
                </p>
                <Log key={parent.id} messages={parent.replies ?? []} ctx={ctx} h={th} style="row" label={t.thread} inThread className="overflow-visible px-1 sm:px-2" />
              </div>
              <div className="shrink-0 p-3">
                <Composer
                  ctx={ctx}
                  compact
                  placeholder={t.replyThread}
                  onSend={(d) => {
                    void chats.send(c.id, d, parent.id);
                    if (alsoChannel) void chats.send(c.id, { ...d, replyTo: parent.id });
                  }}
                  extra={
                    <label className="-my-2 inline-flex min-h-11 cursor-pointer items-center gap-2 text-[12px] text-muted-foreground sm:min-h-8">
                      <input type="checkbox" checked={alsoChannel} onChange={(e) => setAlsoChannel(e.target.checked)} className="size-4 accent-[var(--primary)]" />
                      {fill(t.alsoChannel, { name: c.name ?? '' })}
                    </label>
                  }
                />
              </div>
            </>
          ) : (
            <div className="kit-scrollbar min-h-0 flex-1 overflow-y-auto">
              <Members c={c} ctx={ctx} />
            </div>
          )}
        </aside>
      )}
      <Live text={chats.announce} />
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* widget                                                              */
/* ------------------------------------------------------------------ */

function Widget({ ctx, props }: V) {
  const { t } = ctx;
  const { conversations, position = 'contained', backdrop, className } = props;
  const chats = useChats(conversations, ctx, props.replies);
  const [open, setOpen] = useState(props.defaultOpen ?? true);
  const [current, setCurrent] = useState<string | null>(props.selected ?? null);
  const [reads, setReads] = useState<Record<string, boolean>>({});
  const id = useId();
  const launcher = useRef<HTMLButtonElement>(null);
  const panelRef = useRef<HTMLDivElement>(null);
  const meP = ctx.person(ctx.me);
  const c = conversations.find((x) => x.id === current);
  const unreadOf = (x: MessengerConversation) => (reads[x.id] ? 0 : x.unread ?? 0);
  const total = conversations.reduce((a, x) => a + unreadOf(x), 0);
  const close = useCallback(() => {
    setOpen(false);
    requestAnimationFrame(() => launcher.current?.focus());
  }, []);
  const h: MsgHandlers = {
    onReact: (mid, e) => c && chats.react(c.id, mid, e),
    onDownload: (name) => (chats.setAnnounce(fill(t.downloading, { name })), void ctx.onAction?.('download', { name })),
    onCopy: useCopy(t, chats.setAnnounce),
  };
  const fixed = position === 'fixed';
  const panel = open && (
    <div
      ref={panelRef}
      id={id}
      role="dialog"
      aria-label={t.messages}
      onKeyDown={(e) => e.key === 'Escape' && (e.stopPropagation(), close())}
      className={cn(
        'b5m-panel flex flex-col overflow-hidden border border-border bg-background shadow-[var(--highlight),var(--shadow-lg)]',
        fixed ? 'fixed inset-x-0 bottom-0 z-50 h-[min(40rem,100dvh)] rounded-t-2xl sm:inset-x-auto sm:bottom-24 sm:end-6 sm:h-[38rem] sm:w-[24rem] sm:rounded-2xl' : 'absolute inset-x-2 bottom-2 top-2 z-10 rounded-2xl sm:inset-x-auto sm:bottom-[5.5rem] sm:end-5 sm:top-auto sm:h-[38rem] sm:max-h-[calc(100%-6.5rem)] sm:w-[24rem]',
      )}
    >
      {c ? (
        <>
          <div className="flex h-16 shrink-0 items-center gap-2 border-b border-border px-2">
            <IconButton icon="chevron-left" size="sm" label={t.back} onClick={() => setCurrent(null)} />
            <GroupMark c={c} ctx={ctx} size="sm" />
            <div className="min-w-0 flex-1">
              <h2 className="truncate text-[14px] font-semibold text-foreground">{convName(c, ctx)}</h2>
              <p className={cn('truncate text-[12px]', chats.typing[c.id] ? 'text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]' : 'text-muted-foreground')}>
                {chats.typing[c.id] ? fill(t.typing, { name: ctx.person(chats.typing[c.id]!).name.split(' ')[0] }) : c.kind === 'group' ? fill(t.members, { n: c.members.length }) : ctx.person(c.members.find((m) => m !== ctx.me) ?? ctx.me).status === 'online' ? t.online : t.offline}
              </p>
            </div>
            <IconButton icon="minus" size="sm" label={t.minimize} onClick={close} />
          </div>
          <Log key={c.id} messages={chats.msgs[c.id] ?? []} ctx={ctx} h={h} typing={chats.typing[c.id]} label={convName(c, ctx)} compact showNames={c.kind === 'group'} className="px-2.5 pt-1" />
          <div className="shrink-0 p-2.5">
            <Composer key={c.id} ctx={ctx} compact placeholder={fill(t.write, { name: convName(c, ctx).split(' ')[0] })} onSend={(d) => void chats.send(c.id, d)} />
          </div>
        </>
      ) : (
        <>
          <div className="relative shrink-0 overflow-hidden border-b border-border bg-[color-mix(in_oklab,var(--primary)_7%,var(--background))] px-5 pb-5 pt-4">
            <div className="flex items-center justify-between">
              <h2 className="text-[17px] font-semibold tracking-[-0.02em] text-foreground">{t.messages}</h2>
              <IconButton icon="minus" size="sm" label={t.minimize} onClick={close} />
            </div>
            <p className="mt-1 text-pretty text-[13px] text-muted-foreground">{fill(t.widgetHello, { name: meP.name.split(' ')[0] })}</p>
            <div className="mt-3 flex -space-x-2">
              {[...new Set(conversations.flatMap((x) => x.members))]
                .filter((m) => m !== ctx.me)
                .slice(0, 5)
                .map((m, i) => (
                  <span key={m} className="b5m-pop" style={{ animationDelay: `${120 + i * 50}ms` }}>
                    <Avatar name={ctx.person(m).name} size="sm" alt="" ring status={ctx.person(m).status === 'online' ? 'online' : undefined} />
                  </span>
                ))}
            </div>
          </div>
          <nav aria-label={t.conversations} className="kit-scrollbar relative min-h-0 flex-1 overflow-y-auto p-2" onKeyDown={(e) => arrowNav(e)}>
            <ul className="space-y-0.5">
              {conversations.map((x, i) => (
                <ConvRow key={x.id} c={x} ctx={ctx} i={i} active={false} onOpen={() => (setCurrent(x.id), setReads((r) => ({ ...r, [x.id]: true })))} last={(chats.msgs[x.id] ?? []).at(-1)} typing={chats.typing[x.id]} unread={unreadOf(x)} />
              ))}
            </ul>
          </nav>
          <div className="shrink-0 border-t border-border p-3">
            <Button variant="outline" iconLeft="edit" className="w-full" onClick={() => void ctx.onAction?.('new', {})}>
              {t.newMessage}
            </Button>
          </div>
        </>
      )}
    </div>
  );
  const button = (
    <button
      ref={launcher}
      type="button"
      aria-expanded={open}
      aria-controls={id}
      aria-label={`${open ? t.closeChat : t.openChat}${total ? ` (${total})` : ''}`}
      onClick={() => (open ? close() : setOpen(true))}
      className={cn(
        fixed ? 'fixed bottom-4 end-4 z-50 sm:bottom-6 sm:end-6' : 'absolute bottom-4 end-4 z-10 sm:bottom-5 sm:end-5',
        'grid size-14 place-items-center rounded-full bg-primary text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.2),var(--shadow-lg)] transition-transform duration-300 ease-[var(--ease-kit)] hover:scale-105 active:scale-95 motion-reduce:hover:scale-100',
        open && 'max-sm:invisible',
        ring,
      )}
    >
      <Icon key={open ? 'x' : 'c'} name={open ? 'x' : 'chats'} size={24} className="b5m-pop" />
      {total > 0 && !open && <span className="b5m-pop absolute -end-0.5 -top-0.5 grid h-5 min-w-5 place-items-center rounded-full bg-destructive px-1 text-[11px] font-semibold tabular-nums text-white ring-2 ring-background">{total}</span>}
    </button>
  );
  if (fixed)
    return (
      <>
        {panel}
        {button}
        <Live text={chats.announce} />
      </>
    );
  return (
    <div className={cn('relative min-h-[46rem] overflow-hidden', className)}>
      {backdrop && <div aria-hidden className="pointer-events-none absolute inset-0 select-none">{backdrop}</div>}
      {panel}
      {button}
      <Live text={chats.announce} />
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* search                                                              */
/* ------------------------------------------------------------------ */

const norm = (s: string) => s.toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');

/** The words of the query marked inside a text, trimmed around the first hit */
function Highlight({ text, words, trim = 90 }: { text: string; words: string[]; trim?: number }) {
  if (!words.length) return <>{text}</>;
  const n = norm(text);
  let start = 0;
  const firstHit = Math.min(...words.map((w) => n.indexOf(w)).filter((i) => i >= 0));
  if (Number.isFinite(firstHit) && firstHit > trim / 2 && text.length > trim) start = firstHit - Math.round(trim / 3);
  const slice = (start ? '…' : '') + text.slice(start);
  const ns = norm(slice);
  const parts: React.ReactNode[] = [];
  let i = 0;
  while (i < slice.length) {
    let hit = -1;
    let len = 0;
    for (const w of words) {
      const k = ns.indexOf(w, i);
      if (k >= 0 && (hit < 0 || k < hit)) (hit = k), (len = w.length);
    }
    if (hit < 0) {
      parts.push(slice.slice(i));
      break;
    }
    if (hit > i) parts.push(slice.slice(i, hit));
    parts.push(
      <mark key={hit} className="rounded-[3px] bg-[color-mix(in_oklab,var(--primary)_22%,transparent)] px-0.5 text-foreground">
        {slice.slice(hit, hit + len)}
      </mark>,
    );
    i = hit + len;
  }
  return <>{parts}</>;
}

function Pick({ label, value, onChange, children }: { label: string; value: string; onChange: (v: string) => void; children: React.ReactNode }) {
  return (
    <label className="relative block min-w-0">
      <span className="sr-only">{label}</span>
      <select value={value} onChange={(e) => onChange(e.target.value)} className={cn('h-11 w-full min-w-0 cursor-pointer appearance-none truncate rounded-lg border border-input bg-background ps-3 pe-8 text-base text-foreground shadow-[var(--shadow-sm)] transition-[border-color] hover:border-border-strong sm:h-9 sm:text-[13px]', ring)}>
        {children}
      </select>
      <Icon name="chevrons-up-down" size={14} className="pointer-events-none absolute end-2.5 top-1/2 -translate-y-1/2 text-subtle-foreground" />
    </label>
  );
}

function Search({ ctx, props, framed }: V) {
  const { t } = ctx;
  const { conversations } = props;
  const [q, setQ] = useState(props.query ?? '');
  const [from, setFrom] = useState('all');
  const [where, setWhere] = useState('all');
  const [has, setHas] = useState<Record<'file' | 'image' | 'voice', boolean>>({ file: false, image: false, voice: false });
  const [pick, setPick] = useState(0);
  const [view, setView] = useState<'list' | 'detail'>(props.mobileView ?? 'list');
  const listId = useId();
  const words = norm(q).split(/\s+/).filter(Boolean);
  const people = [...new Set(conversations.flatMap((c) => c.members))].map(ctx.person);
  const results = useMemo(() => {
    const out: { c: MessengerConversation; m: MessengerMessage; i: number }[] = [];
    for (const c of conversations) {
      if (where !== 'all' && c.id !== where) continue;
      c.messages.forEach((m, i) => {
        if (from !== 'all' && m.from !== from) return;
        const kinds = new Set(m.attachments?.map((a) => a.kind));
        if ((Object.keys(has) as (keyof typeof has)[]).some((k) => has[k] && !kinds.has(k))) return;
        const hay = norm([m.text ?? '', ...(m.attachments ?? []).map((a) => ('name' in a ? a.name : '')), ctx.person(m.from).name].join(' '));
        if (!words.length && !Object.values(has).some(Boolean)) return;
        if (words.every((w) => hay.includes(w))) out.push({ c, m, i });
      });
    }
    return out.sort((a, b) => toMs(b.m.time) - toMs(a.m.time));
  }, [conversations, where, from, has, words.join(' '), ctx]); // eslint-disable-line react-hooks/exhaustive-deps
  const sel = results[Math.min(pick, results.length - 1)];
  useEffect(() => setPick(0), [q, from, where, has]);
  const announce = !words.length && !Object.values(has).some(Boolean) ? '' : results.length === 1 ? fill(t.oneResult, { q }) : fill(t.results, { n: results.length, q });
  const contextMsgs = sel ? sel.c.messages.slice(Math.max(0, sel.i - 3), sel.i + 4) : [];
  const noop: MsgHandlers = { onReact: () => undefined, onDownload: () => undefined, onCopy: () => undefined };
  const filters = (
    <div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap sm:items-center">
      <Pick label={t.from} value={from} onChange={setFrom}>
        <option value="all">{t.anyone}</option>
        {people.map((p) => (
          <option key={p.id} value={p.id}>
            {p.id === ctx.me ? `${p.name} (${t.you})` : p.name}
          </option>
        ))}
      </Pick>
      <Pick label={t.in} value={where} onChange={setWhere}>
        <option value="all">{t.anywhere}</option>
        {conversations.map((c) => (
          <option key={c.id} value={c.id}>
            {c.kind === 'channel' ? `#${c.name}` : convName(c, ctx)}
          </option>
        ))}
      </Pick>
      <div role="group" aria-label={t.has} className="col-span-2 flex flex-wrap items-center gap-1.5">
        <span className="me-1 text-[12.5px] text-muted-foreground max-sm:sr-only">{t.has}</span>
        {(
          [
            ['file', t.hasFile, 'file'],
            ['image', t.hasImage, 'image'],
            ['voice', t.hasVoice, 'mic'],
          ] as const
        ).map(([k, label, icon]) => (
          <button key={k} type="button" aria-pressed={has[k]} onClick={() => setHas((x) => ({ ...x, [k]: !x[k] }))} className={cn('inline-flex h-11 items-center gap-1.5 rounded-lg border px-3 text-[13px] font-medium transition-colors sm:h-9', has[k] ? 'border-transparent bg-foreground text-background' : 'border-border text-muted-foreground hover:border-border-strong hover:text-foreground', ring)}>
            <Icon name={icon as IconName} size={14} />
            {label}
          </button>
        ))}
      </div>
    </div>
  );
  return (
    <div className={cn('flex min-h-0 flex-col', appHeight(framed))}>
      <div className="b5m-rise shrink-0 border-b border-border px-4 pb-4 pt-5 sm:px-6 lg:px-8">
        <h1 className="text-[clamp(1.375rem,1.2rem+0.8vw,1.75rem)] font-semibold tracking-[-0.03em] text-foreground">{props.title ?? t.searchMessages}</h1>
        {props.subtitle && <p className="mt-0.5 text-[13px] text-muted-foreground">{props.subtitle}</p>}
        <div className="mt-4 grid gap-3 xl:grid-cols-[minmax(0,28rem)_minmax(0,1fr)] xl:items-center">
          <SearchBox big label={t.searchHint} value={q} onChange={setQ} />
          {filters}
        </div>
      </div>
      <div className="flex min-h-0 flex-1">
        <section aria-label={t.resultsLabel} className={cn('flex min-w-0 flex-1 flex-col md:w-[24rem] md:flex-none md:border-e md:border-border lg:w-[28rem]', view === 'detail' && 'max-md:hidden')}>
          <p className="shrink-0 px-4 pb-1 pt-3 text-[12.5px] font-medium text-muted-foreground sm:px-6 md:px-4" aria-hidden>
            {announce}
          </p>
          {results.length ? (
            <ul
              id={listId}
              role="listbox"
              aria-label={t.resultsLabel}
              tabIndex={0}
              aria-activedescendant={sel ? `${listId}-${sel.m.id}` : undefined}
              onKeyDown={(e) => {
                const n = e.key === 'ArrowDown' ? pick + 1 : e.key === 'ArrowUp' ? pick - 1 : e.key === 'Home' ? 0 : e.key === 'End' ? results.length - 1 : null;
                if (e.key === 'Enter') return setView('detail');
                if (n === null) return;
                e.preventDefault();
                const k = Math.max(0, Math.min(results.length - 1, n));
                setPick(k);
                document.getElementById(`${listId}-${results[k].m.id}`)?.scrollIntoView({ block: 'nearest' });
              }}
              className="kit-scrollbar relative min-h-0 flex-1 space-y-1 overflow-y-auto px-2 pb-4 pt-1 outline-none focus-visible:[&>[aria-selected=true]]:outline-2 focus-visible:[&>[aria-selected=true]]:outline-solid focus-visible:[&>[aria-selected=true]]:-outline-offset-2 focus-visible:[&>[aria-selected=true]]:outline-[color-mix(in_oklab,var(--primary)_70%,transparent)] sm:px-4 md:px-2"
            >
              {results.map(({ c, m }, i) => {
                const p = ctx.person(m.from);
                const on = sel?.m.id === m.id;
                const fileName = m.attachments?.find((a) => a.kind !== 'voice' && 'name' in a) as { name: string } | undefined;
                return (
                  <li
                    key={m.id}
                    id={`${listId}-${m.id}`}
                    role="option"
                    aria-selected={on}
                    onClick={() => (setPick(i), setView('detail'))}
                    className={cn('b5m-rise flex cursor-pointer gap-3 rounded-xl p-3 transition-colors', on ? 'bg-[color-mix(in_oklab,var(--primary)_9%,var(--surface))] ring-1 ring-inset ring-border' : 'hover:bg-surface')}
                    style={{ '--b5m-d': `${Math.min(i, 8) * 35}ms` } as React.CSSProperties}
                  >
                    <Person p={p} size="sm" status={false} />
                    <span className="min-w-0 flex-1">
                      <span className="flex items-baseline gap-2">
                        <span className="min-w-0 flex-1 truncate text-[13.5px] font-semibold text-foreground">{m.from === ctx.me ? t.you : p.name}</span>
                        <span className="shrink-0 text-[12px] tabular-nums text-subtle-foreground">{stamp(m.time, ctx.now, ctx.locale, t)}</span>
                      </span>
                      {(c.kind === 'channel' || c.kind === 'group') && <span className="block truncate text-[12px] text-muted-foreground">{c.kind === 'channel' ? `#${c.name}` : convName(c, ctx)}</span>}
                      {m.text && (
                        <span className="mt-1 line-clamp-2 block text-[13.5px] leading-snug text-foreground/90">
                          <Highlight text={m.text} words={words} />
                        </span>
                      )}
                      {fileName && (
                        <span className="mt-1.5 inline-flex max-w-full items-center gap-1.5 rounded-md border border-border bg-card px-2 py-1 text-[12px] text-foreground">
                          <Icon name={m.attachments?.[0]?.kind === 'image' ? 'image' : 'file'} size={13} className="shrink-0 text-muted-foreground" />
                          <span className="truncate">
                            <Highlight text={fileName.name} words={words} />
                          </span>
                        </span>
                      )}
                    </span>
                  </li>
                );
              })}
            </ul>
          ) : (
            <p className="mx-4 mt-3 rounded-xl border border-dashed border-border px-4 py-10 text-center text-[13.5px] text-muted-foreground sm:mx-6 md:mx-4">{words.length || Object.values(has).some(Boolean) ? t.noResults : t.typeToSearch}</p>
          )}
        </section>
        <section aria-label={t.context} className={cn('flex min-w-0 flex-1 flex-col bg-[color-mix(in_oklab,var(--surface)_50%,var(--background))]', view === 'list' && 'max-md:hidden')}>
          {sel ? (
            <>
              <div className="flex h-16 shrink-0 items-center gap-3 border-b border-border px-3 sm:px-5">
                <button type="button" onClick={() => setView('list')} className={cn('-ms-1.5 flex h-11 shrink-0 items-center gap-0.5 rounded-lg pe-2 ps-1 text-[14px] font-medium text-foreground hover:bg-accent md:hidden', ring)}>
                  <Icon name="chevron-left" size={20} />
                  {t.back}
                </button>
                <GroupMark c={sel.c} ctx={ctx} size="sm" />
                <div className="min-w-0 flex-1">
                  <h2 className="truncate text-[14.5px] font-semibold text-foreground">{sel.c.kind === 'channel' ? `#${sel.c.name}` : convName(sel.c, ctx)}</h2>
                  <p className="truncate text-[12px] leading-snug text-muted-foreground">
                    {t.context} · {dayLabel(sel.m.time, ctx.now, ctx.locale, t)}
                  </p>
                </div>
                <Button size="sm" variant="outline" iconLeft="arrow-up-right" aria-label={t.openInChat} className="max-sm:hidden" onClick={() => void ctx.onAction?.('open', { conversation: sel.c.id, message: sel.m.id })}>
                  <span className="max-xl:sr-only">{t.openInChat}</span>
                </Button>
              </div>
              <div key={sel.m.id} className="kit-scrollbar relative min-h-0 flex-1 overflow-y-auto px-3 py-3 sm:px-6">
                {contextMsgs.map((m) => {
                  const hit = m.id === sel.m.id;
                  const p = ctx.person(m.from);
                  return (
                    <div key={m.id} className={cn('b5m-in relative flex gap-3 rounded-xl px-3 py-2.5', hit ? 'b5m-flash bg-[color-mix(in_oklab,var(--primary)_7%,var(--card))] ring-1 ring-[color-mix(in_oklab,var(--primary)_30%,var(--border))]' : 'opacity-80')}>
                      <Person p={p} size="sm" status={false} />
                      <div className="min-w-0 flex-1">
                        <p className="flex items-baseline gap-2">
                          <span className="text-[13.5px] font-semibold text-foreground">{m.from === ctx.me ? t.you : p.name}</span>
                          <span className="text-[12px] tabular-nums text-subtle-foreground">{clock(m.time, ctx.locale)}</span>
                        </p>
                        {m.text && (
                          <p className="whitespace-pre-line text-pretty text-[14px] leading-relaxed text-foreground [overflow-wrap:anywhere]">
                            <Highlight text={m.text} words={hit ? words : []} trim={9999} />
                          </p>
                        )}
                        {m.attachments?.length ? (
                          <div className="mt-1.5">
                            <Attachments list={m.attachments} ctx={ctx} id={`c-${m.id}`} onDownload={noop.onDownload} />
                          </div>
                        ) : null}
                      </div>
                    </div>
                  );
                })}
              </div>
              <div className="shrink-0 border-t border-border p-3 sm:hidden">
                <Button className="w-full" variant="outline" onClick={() => void ctx.onAction?.('open', { conversation: sel.c.id, message: sel.m.id })}>
                  {t.openInChat}
                </Button>
              </div>
            </>
          ) : (
            <div className="grid flex-1 place-items-center p-8 text-center">
              <div>
                <span className="mx-auto grid size-12 place-items-center rounded-xl bg-surface-2 text-muted-foreground">
                  <Icon name="search" size={20} />
                </span>
                <p className="mt-3 max-w-[18rem] text-pretty text-[13.5px] text-muted-foreground">{t.typeToSearch}</p>
              </div>
            </div>
          )}
        </section>
      </div>
      <Live text={announce} />
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Messenger                                                           */
/* ------------------------------------------------------------------ */

/**
 * Messaging screens for apps: a list with the open chat, a single thread with
 * pinned message and shared files, a channel with members and threads, a small
 * floating messenger and a search across every conversation. Replies, reactions,
 * photos, files and voice messages, typing and read status. Enter sends.
 *   <Messenger variant="two-pane" me="u1" people={[…]} conversations={[…]} replies={[…]} />
 */
export function Messenger({ variant = 'two-pane', sidebar, labels, className, now = '2026-10-23T14:30', ...rest }: MessengerProps) {
  const t = useTexts(TEXTS, labels);
  const locale = useLocale();
  const people = rest.people;
  const person = useCallback((id: string): MessengerPerson => people.find((p) => p.id === id) ?? { id, name: id }, [people]);
  const ctx: Ctx = { t, locale, me: rest.me, now, person, onAction: rest.onAction };
  const props = { ...rest, now, className } as MessengerProps;
  const framed = !!sidebar;
  const v: V = { ctx, props, framed };
  const body = (() => {
    switch (variant) {
      case 'thread':
        return <Thread {...v} />;
      case 'channel':
        return <Channel {...v} />;
      case 'widget':
        return <Widget {...v} />;
      case 'search':
        return <Search {...v} />;
      default:
        return <TwoPane {...v} />;
    }
  })();
  if (variant === 'widget' && !sidebar)
    return (
      <>
        <Style />
        {body}
      </>
    );
  if (!sidebar)
    return (
      <div className={cn('bg-background', className)}>
        <Style />
        {body}
      </div>
    );
  return (
    <Sidebar mobile="drawer" {...sidebar} className={className}>
      <Style />
      {body}
    </Sidebar>
  );
}

Frequent questions

How do I install Messenger?

With aiw add @ai-website/messenger. The command puts components/ui/messenger.tsx into your project, the code is yours from then on. It brings along avatar, button, icon, icon-button, photos and sidebar. It needs no further npm packages.

Which variants does Messenger have?

5 variants, List and chat, Conversation, Channel with threads, Floating messenger and Message search. You pick one with the variant prop, for example variant="thread".

Can I translate the texts of Messenger?

Yes. Messenger carries 92 texts of its own in English and German, such as “Messages”, “Conversations” and “Search conversations”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Messenger?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Messenger with aiw_components and install it with aiw_add @ai-website/messenger. Name the variant in your prompt, for example variant="two-pane", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard