Skip to content
kerfjsGitHub

Thread

A chat: messages, day dividers, typing, reactions and a composer.

SourcePackage @kerf-js/veneer

Preview

tsx
import { Thread } from '@kerf-js/veneer'import type { ChatMessage } from '@kerf-js/veneer'import type { Peer } from '@kerf-js/veneer'export function ReadOnly() {  return (  <Thread    label="Deploy d-4819 discussion"    height="240px"    messages={EARLY_CHAT}  />  )}const PEOPLE = [  {    id: 'mara',    name: 'Mara Lindqvist',    email: 'mara@kerf.dev',    role: 'Owner',    team: 'Platform',    initials: 'ML',  },  {    id: 'jun',    name: 'Jun Tanaka',    email: 'jun@kerf.dev',    role: 'Admin',    team: 'Platform',    initials: 'JT',  },  {    id: 'priya',    name: 'Priya Raman',    email: 'priya@kerf.dev',    role: 'Member',    team: 'Web',    initials: 'PR',  },  {    id: 'tomas',    name: 'Tomás Oliveira',    email: 'tomas@kerf.dev',    role: 'Member',    team: 'Web',    initials: 'TO',  },  {    id: 'lena',    name: 'Lena Fischer',    email: 'lena@kerf.dev',    role: 'Member',    team: 'Data',    initials: 'LF',  },  {    id: 'noor',    name: 'Noor Haddad',    email: 'noor@kerf.dev',    role: 'Viewer',    team: 'Data',    initials: 'NH',  },  {    id: 'sam',    name: 'Sam Okafor',    email: 'sam@kerf.dev',    role: 'Member',    team: 'Mobile',    initials: 'SO',  },  {    id: 'ines',    name: 'Inés Vidal',    email: 'ines@kerf.dev',    role: 'Viewer',    team: 'Mobile',    initials: 'IV',  },]const PEERS: Peer[] = PEOPLE.map((p, i) => ({  id: p.id,  name: p.name,  color: i + 1,}))const [  MARA,  JUN,  PRIYA,  TOMAS,  LENA,  NOOR,  SAM,  INES,] = PEERS as [  Peer,  Peer,  Peer,  Peer,  Peer,  Peer,  Peer,  Peer,]const AT = 1_793_460_000_000const min = 60_000const CHAT: ChatMessage[] = [  {    id: 'm1',    author: SAM,    time: '15:27',    at: AT,    day: 'Yesterday',    text:      'mobile-bff d-4819 failed its ' +      'health check on ap-south.',  },  {    id: 'm2',    author: SAM,    time: '15:28',    at: AT + min,    day: 'Yesterday',    text:      'Looks like the retry change. ' +      'Upstream calls pile up after ' +      'a timeout.',    attachment: {      kind: 'LOG',      name: 'd-4819.log',      meta: '48 KB · 1,204 lines',    },  },  {    id: 'm3',    author: JUN,    time: '15:31',    at: AT + 4 * min,    day: 'Yesterday',    text:      'Edge cache is holding. Roll ' +      'back and we look at it ' +      'tomorrow?',    reactions: [      {        emoji: '👍',        label: 'Thumbs up',        count: 2,        names: ['Sam', 'Mara'],      },    ],  },  {    id: 'm4',    author: MARA,    mine: true,    time: '15:33',    at: AT + 6 * min,    day: 'Yesterday',    text: 'Yes, roll it back. I will tell support.',    state: 'read',    readAt: '15:34',  },  {    id: 'm5',    author: SAM,    time: '09:12',    at: AT + 1060 * min,    day: 'Today',    text:      'Rolled back last night. 5xx ' +      'are at zero. @you the fix is ' +      'on a branch.',    mentions: ['you'],    replies: {      count: 2,      last: '09:40',      people: [JUN, LENA],    },  },  {    id: 'm6',    author: LENA,    time: '09:18',    at: AT + 1066 * min,    day: 'Today',    quote: {      ref: 'm2',      text: 'Upstream calls pile up after a timeout.',    },    text:      'search-api had the same thing ' +      'in March. A jittered backoff ' +      'fixed it.',  },  {    id: 'm7',    author: MARA,    mine: true,    time: '09:21',    at: AT + 1069 * min,    day: 'Today',    text:      'Good catch. Sam, can you add ' +      'that before we retry?',    state: 'read',    readAt: '09:22',  },  {    id: 'm8',    author: SAM,    time: '09:24',    at: AT + 1072 * min,    day: 'Today',    text: 'On it. New deploy after lunch.',    reactions: [      {        emoji: '🎉',        label: 'Party',        count: 1,        names: ['Mara'],      },    ],  },]const EARLY_CHAT = CHAT.slice(0, 3)

Installation

npm install @kerf-js/veneer

Usage

tsx
import { Thread } from '@kerf-js/veneer'<Thread  label="Deploy d-4819 discussion"  height="240px"  messages={EARLY_CHAT}/>

An incident channel

Send a message: it goes out, then Sam types and answers. All of it is a stub.

tsx
import { state } from '@kerf-js/core'import {  PresenceStack,  Spacer,  Text,  Thread} from '@kerf-js/veneer'import type {  ChatMessage,  ComposerSend,  MessageReaction} from '@kerf-js/veneer'import type { Peer } from '@kerf-js/veneer'export function Incident() {  const messages = state(CHAT)  const typing = state<string[]>([])  const add = (m: ChatMessage) =>    (messages.value = [...messages.value, m])  const send = async ({ text, quote }: ComposerSend) => {    const id = `m${messages.value.length + 1}`    add({      id,      author: MARA,      mine: true,      text,      quote,      time: '09:30',      day: 'Today',      state: 'sending',    })    await wait(500)    messages.value = messages.value.map((m) =>      m.id === id ? { ...m, state: 'sent' } : m,    )    typing.value = ['Sam Okafor']    await wait(1400)    typing.value = []    add({      id: `${id}-r`,      author: SAM,      text: 'Got it, thanks.',      time: '09:31',      day: 'Today',    })  }  const react = (    m: ChatMessage,    r: MessageReaction | null,  ) => {    const reactions = r      ? [{ ...r, count: r.count + 1, mine: true }]      : []    messages.value = messages.value.map((x) =>      x.id === m.id ? { ...x, reactions } : x,    )  }  return (    <Thread      label="incidents channel"      height="480px"      messages={messages}      unreadFrom="m5"      typing={typing}      header={        <>          <Text role="label"># incidents</Text>          <Spacer />          <PresenceStack            peers={IN_CHANNEL}            label="In this channel"          />        </>      }      onSend={(m) => void send(m)}      onReact={react}      composer={{        label: 'Message #incidents',        placeholder: 'Message #incidents',        mentions: PEERS,      }}    />  )}const PEOPLE = [  {    id: 'mara',    name: 'Mara Lindqvist',    email: 'mara@kerf.dev',    role: 'Owner',    team: 'Platform',    initials: 'ML',  },  {    id: 'jun',    name: 'Jun Tanaka',    email: 'jun@kerf.dev',    role: 'Admin',    team: 'Platform',    initials: 'JT',  },  {    id: 'priya',    name: 'Priya Raman',    email: 'priya@kerf.dev',    role: 'Member',    team: 'Web',    initials: 'PR',  },  {    id: 'tomas',    name: 'Tomás Oliveira',    email: 'tomas@kerf.dev',    role: 'Member',    team: 'Web',    initials: 'TO',  },  {    id: 'lena',    name: 'Lena Fischer',    email: 'lena@kerf.dev',    role: 'Member',    team: 'Data',    initials: 'LF',  },  {    id: 'noor',    name: 'Noor Haddad',    email: 'noor@kerf.dev',    role: 'Viewer',    team: 'Data',    initials: 'NH',  },  {    id: 'sam',    name: 'Sam Okafor',    email: 'sam@kerf.dev',    role: 'Member',    team: 'Mobile',    initials: 'SO',  },  {    id: 'ines',    name: 'Inés Vidal',    email: 'ines@kerf.dev',    role: 'Viewer',    team: 'Mobile',    initials: 'IV',  },]const PEERS: Peer[] = PEOPLE.map((p, i) => ({  id: p.id,  name: p.name,  color: i + 1,}))const [  MARA,  JUN,  PRIYA,  TOMAS,  LENA,  NOOR,  SAM,  INES,] = PEERS as [  Peer,  Peer,  Peer,  Peer,  Peer,  Peer,  Peer,  Peer,]const AT = 1_793_460_000_000const min = 60_000const CHAT: ChatMessage[] = [  {    id: 'm1',    author: SAM,    time: '15:27',    at: AT,    day: 'Yesterday',    text:      'mobile-bff d-4819 failed its ' +      'health check on ap-south.',  },  {    id: 'm2',    author: SAM,    time: '15:28',    at: AT + min,    day: 'Yesterday',    text:      'Looks like the retry change. ' +      'Upstream calls pile up after ' +      'a timeout.',    attachment: {      kind: 'LOG',      name: 'd-4819.log',      meta: '48 KB · 1,204 lines',    },  },  {    id: 'm3',    author: JUN,    time: '15:31',    at: AT + 4 * min,    day: 'Yesterday',    text:      'Edge cache is holding. Roll ' +      'back and we look at it ' +      'tomorrow?',    reactions: [      {        emoji: '👍',        label: 'Thumbs up',        count: 2,        names: ['Sam', 'Mara'],      },    ],  },  {    id: 'm4',    author: MARA,    mine: true,    time: '15:33',    at: AT + 6 * min,    day: 'Yesterday',    text: 'Yes, roll it back. I will tell support.',    state: 'read',    readAt: '15:34',  },  {    id: 'm5',    author: SAM,    time: '09:12',    at: AT + 1060 * min,    day: 'Today',    text:      'Rolled back last night. 5xx ' +      'are at zero. @you the fix is ' +      'on a branch.',    mentions: ['you'],    replies: {      count: 2,      last: '09:40',      people: [JUN, LENA],    },  },  {    id: 'm6',    author: LENA,    time: '09:18',    at: AT + 1066 * min,    day: 'Today',    quote: {      ref: 'm2',      text: 'Upstream calls pile up after a timeout.',    },    text:      'search-api had the same thing ' +      'in March. A jittered backoff ' +      'fixed it.',  },  {    id: 'm7',    author: MARA,    mine: true,    time: '09:21',    at: AT + 1069 * min,    day: 'Today',    text:      'Good catch. Sam, can you add ' +      'that before we retry?',    state: 'read',    readAt: '09:22',  },  {    id: 'm8',    author: SAM,    time: '09:24',    at: AT + 1072 * min,    day: 'Today',    text: 'On it. New deploy after lunch.',    reactions: [      {        emoji: '🎉',        label: 'Party',        count: 1,        names: ['Mara'],      },    ],  },]const wait = (ms: number) =>  new Promise((done) => {    setTimeout(done, ms)  })const IN_CHANNEL = [SAM, JUN, LENA]

When to use

Use it for

  • A channel or a conversation
  • Long histories: only the rows on screen are drawn

Reach for something else

Accessibility

Enter
Sends
ShiftEnter
Adds a line
@
Suggests people to mention

API Reference

PropTypeDefaultDescription
labelstring—Names the region: "calendar-engine channel".
messagesLive<ChatMessage[]>—The messages, oldest first. A state or a list.
unreadFromstring | null—The first unread message: opening lands on the divider before it.
typingLive<string[]>—Names typing now.
headerunknown—The header's content (data-part="app-bar"): channel, members, stack, call, search.
heightstring—A log height, if the parent doesn't size the thread.
control((c: VirtualControl) => void)—Gives you a handle to scroll the list.
composerOmit<ComposerProps, "quote" | "onSend" | "input">—The composer's props.
EventTypeDefaultDescription
onSend((m: ComposerSend) => void)—No onSend, no composer: a read-only log.
onRetry((m: ChatMessage) => void)—Sends a failed message again.
onReact((m: ChatMessage, r: MessageReaction | null) => void)—Adds or removes a reaction.
onReplies((m: ChatMessage) => void)—Opens a message's replies.
onOlder(() => void)—Near the top: load older messages, and prepend them to messages.
onDrift((index: number, predicted: number, actual: number) => void)—Reports when a row's height was guessed wrong.

RelatedComposerMessageConversation list