Zum Inhalt springen
UI Kit
Accent

Messenger UI für Apps

Nachrichten für Apps. Chats mit Antworten, Reaktionen, Fotos, Dateien und Sprache, Tippanzeige und Lesestatus, Kanäle mit Threads, ein schwebender Messenger und Suche.

5 VariantsEine von 4 Components in Team und Zusammenarbeit

Gespräche, die in deinem Produkt bleiben

Ein Messenger hält Gespräche in deinem Produkt statt irgendwo anders. Diese Screens tragen Antworten, Reaktionen, Fotos, Dateien, Sprachnachrichten, Tippanzeige und Lesestatus, all die Details, die Menschen von jedem Chat kennen, den sie täglich nutzen.

„Liste und Chat“ stellt Unterhaltungen mit Suche, Ungelesen-Filter und Tippanzeige neben den offenen Chat, Telefone wechseln mit Zurück zwischen beiden. Die „Unterhaltung“ zeigt einen Chat mit Tagestrennern, angehefteter Nachricht, Marke für Neues, Sprung zur neuesten Nachricht und den geteilten Dateien daneben. „Kanal mit Threads“ zeigt einen Teamkanal als Zeilen mit Namen und Rollen, Antworten im Thread daneben und die Mitglieder nach Anwesenheit. Der „Schwebende Messenger“ startet aus einem Knopf mit der Zahl ungelesener Nachrichten, öffnet ein kleines Feld mit den Chats und schließt mit Escape, Telefone bekommen den ganzen Schirm. Die „Nachrichtensuche“ durchsucht alle Unterhaltungen nach Wörtern, Person, Ort und Anhängen und zeigt jeden Treffer im Zusammenhang.

Wähle die Form nach der Beziehung, zwei Spalten für Support und Vertrieb, Kanäle für Teams, den schwebenden Messenger für eine schnelle Frage auf jeder Seite. Zeig den Lesestatus leise, ein grauer Haken reicht. Halte das Eingabefeld unten fest, dann verdeckt die Tastatur es am Telefon nie.

Variants

Liste und ChatNeu in 1.2

Unterhaltungen mit Suche, Ungelesen-Filter und Tippanzeige neben dem offenen Chat. Telefone wechseln mit Zurück zwischen beiden.

UnterhaltungNeu in 1.2

Ein Chat mit Tagestrennern, angehefteter Nachricht, Marke für Neues, Sprung zur neuesten Nachricht und den geteilten Dateien daneben.

Kanal mit ThreadsNeu in 1.2

Ein Teamkanal als Zeilen mit Namen und Rollen, Antworten in einem Thread daneben und die Mitglieder nach Anwesenheit.

Schwebender MessengerNeu in 1.2

Ein Startknopf mit der Zahl ungelesener Nachrichten, ein kleines Feld mit den Chats und eine kompakte Unterhaltung. Escape schließt, Telefone bekommen den ganzen Schirm.

Details

Was Messenger mitbringt, aus Registry und Quellcode gelesen.

Bereich
App
Variants
5
Version
1.0.0 · seit 1.2.0
Datei
components/ui/messenger.tsx
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 92 eigene Texte
Studio
Im Code anpassen
In Sammlungen
SaaS-App-Screens

Props

Die Schnittstelle MessengerProps aus components/ui/messenger.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • 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
  • mePflicht
    string

    Your person id

  • peoplePflicht
    MessengerPerson[]
  • conversationsPflicht
    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.

Eingebaute Texte

Diese Texte schreibt Messenger selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 92 Texte anzeigen
messagesEnglisch MessagesDeutsch Nachrichten
conversationsEnglisch ConversationsDeutsch Unterhaltungen
searchEnglisch Search conversationsDeutsch Unterhaltungen durchsuchen
newMessageEnglisch New messageDeutsch Neue Nachricht
allEnglisch AllDeutsch Alle
unreadEnglisch UnreadDeutsch Ungelesen
pinnedEnglisch PinnedDeutsch Angeheftet
chatsEnglisch ChatsDeutsch Chats
noChatsEnglisch Every conversation is read.Deutsch Alle Unterhaltungen sind gelesen.
backEnglisch BackDeutsch Zurück
youEnglisch YouDeutsch Du
onlineEnglisch OnlineDeutsch Online
awayEnglisch AwayDeutsch Abwesend
offlineEnglisch OfflineDeutsch Offline
membersEnglisch {n} membersDeutsch {n} Mitglieder
membersTitleEnglisch MembersDeutsch Mitglieder
onlineNEnglisch Online, {n}Deutsch Online, {n}
offlineNEnglisch Offline, {n}Deutsch Offline, {n}
typingEnglisch {name} is typingDeutsch {name} schreibt
writeEnglisch Message {name}Deutsch Nachricht an {name}
sendEnglisch SendDeutsch Senden
attachEnglisch Attach filesDeutsch Dateien anhängen
recordEnglisch Record a voice messageDeutsch Sprachnachricht aufnehmen
stopRecordEnglisch Stop and add the recordingDeutsch Aufnahme beenden und anhängen
cancelRecordEnglisch Discard the recordingDeutsch Aufnahme verwerfen
recordingEnglisch RecordingDeutsch Aufnahme läuft
removeEnglisch Remove {name}Deutsch {name} entfernen
replyingToEnglisch Replying to {name}Deutsch Antwort an {name}
cancelReplyEnglisch Cancel the replyDeutsch Antwort abbrechen
reactEnglisch Add a reactionDeutsch Reaktion hinzufügen
replyEnglisch ReplyDeutsch Antworten
copyEnglisch Copy textDeutsch Text kopieren
copiedEnglisch Text copied.Deutsch Text kopiert.
reactedYouEnglisch you reactedDeutsch du hast reagiert
reactionEnglisch {emoji} {n}Deutsch {emoji} {n}
toggleReactionEnglisch React with {emoji}Deutsch Mit {emoji} reagieren
sentEnglisch SentDeutsch Gesendet
deliveredEnglisch DeliveredDeutsch Zugestellt
readEnglisch ReadDeutsch Gelesen
todayEnglisch TodayDeutsch Heute
yesterdayEnglisch YesterdayDeutsch Gestern
newSinceEnglisch New messagesDeutsch Neue Nachrichten
jumpLatestEnglisch Jump to the latest messageDeutsch Zur neuesten Nachricht
newBelowEnglisch {n} newDeutsch {n} neu
photoEnglisch PhotoDeutsch Foto
fileEnglisch FileDeutsch Datei
voiceEnglisch Voice messageDeutsch Sprachnachricht
voiceNEnglisch Voice message, {d}Deutsch Sprachnachricht, {d}
playEnglisch Play the voice messageDeutsch Sprachnachricht abspielen
pauseEnglisch Pause the voice messageDeutsch Sprachnachricht anhalten
seekEnglisch Position in the voice messageDeutsch Stelle in der Sprachnachricht
downloadEnglisch Download {name}Deutsch {name} herunterladen
downloadingEnglisch Download of {name} started.Deutsch Download von {name} gestartet.
hintEnglisch Enter sends, Shift and Enter add a lineDeutsch Enter sendet, Umschalt und Enter für eine neue Zeile
callEnglisch Start a callDeutsch Anruf starten
videoEnglisch Start a video callDeutsch Videoanruf starten
infoEnglisch Shared filesDeutsch Geteilte Dateien
hideInfoEnglisch Hide shared filesDeutsch Geteilte Dateien ausblenden
sharedEnglisch Shared in this chatDeutsch In diesem Chat geteilt
noSharedEnglisch Photos, files and voice messages land here.Deutsch Fotos, Dateien und Sprachnachrichten landen hier.
repliesEnglisch {n} repliesDeutsch {n} Antworten
oneReplyEnglisch 1 replyDeutsch 1 Antwort
lastReplyEnglisch last {when}Deutsch zuletzt {when}
threadEnglisch ThreadDeutsch Thread
closeThreadEnglisch Close the threadDeutsch Thread schließen
replyThreadEnglisch Reply in the threadDeutsch Im Thread antworten
alsoChannelEnglisch Also send to #{name}Deutsch Auch an #{name} senden
showMembersEnglisch Show membersDeutsch Mitglieder zeigen
hideMembersEnglisch Hide membersDeutsch Mitglieder ausblenden
openChatEnglisch Open messagesDeutsch Nachrichten öffnen
closeChatEnglisch Close messagesDeutsch Nachrichten schließen
minimizeEnglisch MinimizeDeutsch Minimieren
widgetHelloEnglisch Hi {name}, pick up where you left off.Deutsch Hallo {name}, mach da weiter, wo du warst.
searchMessagesEnglisch Search messagesDeutsch Nachrichten durchsuchen
searchHintEnglisch Words, names or file namesDeutsch Wörter, Namen oder Dateinamen
fromEnglisch FromDeutsch Von
inEnglisch InDeutsch In
anyoneEnglisch AnyoneDeutsch Alle Personen
anywhereEnglisch All conversationsDeutsch Alle Unterhaltungen
hasEnglisch ContainsDeutsch Enthält
hasFileEnglisch FilesDeutsch Dateien
hasImageEnglisch PhotosDeutsch Fotos
hasVoiceEnglisch VoiceDeutsch Sprache
resultsEnglisch {n} results for “{q}”Deutsch {n} Treffer für „{q}“
oneResultEnglisch 1 result for “{q}”Deutsch 1 Treffer für „{q}“
noResultsEnglisch Nothing matches yet. Try fewer words or another person.Deutsch Noch kein Treffer. Versuche weniger Wörter oder eine andere Person.
typeToSearchEnglisch Type a word to search every conversation.Deutsch Gib ein Wort ein, um alle Unterhaltungen zu durchsuchen.
contextEnglisch In contextDeutsch Im Zusammenhang
openInChatEnglisch Open in the conversationDeutsch In der Unterhaltung öffnen
resultsLabelEnglisch ResultsDeutsch Treffer
sentNoteEnglisch Message sent to {name}.Deutsch Nachricht an {name} gesendet.
from_Englisch {name}: {text}Deutsch {name}: {text}

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Messenger reagiert selbst auf die Tasten Escape, Enter, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Pos1, Ende und Leertaste.
  • Setzt aria-activedescendant, aria-checked, aria-controls, aria-current, aria-expanded, aria-label, aria-live, aria-pressed, aria-relevant, aria-selected, aria-valuemax, aria-valuemin, aria-valuenow und aria-valuetext, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen dialog, group, listbox, log, option, radio, radiogroup, separator, slider und status.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 2 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 2.074 Zeilen
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>
  );
}

Häufige Fragen

Wie installiere ich Messenger?

Mit aiw add @ai-website/messenger. Der Befehl legt die Datei components/ui/messenger.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind avatar, button, icon, icon-button, photos und sidebar. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Messenger?

5 Varianten, Liste und Chat, Unterhaltung, Kanal mit Threads, Schwebender Messenger und Nachrichtensuche. Du wählst sie mit der Prop variant, zum Beispiel variant="thread".

Kann ich die Texte von Messenger übersetzen?

Ja. Messenger bringt 92 eigene Texte auf Deutsch und Englisch mit, etwa „Nachrichten“, „Unterhaltungen“ und „Unterhaltungen durchsuchen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Messenger einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Messenger mit aiw_components und installieren sie mit aiw_add @ai-website/messenger. Nenn im Prompt die Variante, etwa variant="two-pane", dann schreibt der Assistent deine Inhalte direkt in die Props.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert