Thread
A chat: messages, day dividers, typing, reactions and a composer.
SourcePackage @weft-js/warp
Preview
tsx
import { state } from '@weft-js/core'import { Thread } from '@weft-js/warp'import type { ChatMessage, Peer } from '@weft-js/warp'export function ReadOnly() { return ( <Thread label="Deploy d-4819 discussion" height="240px" messages={EARLY_CHAT} /> )}const PEOPLE = [ { id: 'mara', name: 'Mara Lindqvist', email: 'mara@weft.dev', role: 'Owner', team: 'Platform', initials: 'ML' }, { id: 'jun', name: 'Jun Tanaka', email: 'jun@weft.dev', role: 'Admin', team: 'Platform', initials: 'JT' }, { id: 'priya', name: 'Priya Raman', email: 'priya@weft.dev', role: 'Member', team: 'Web', initials: 'PR' }, { id: 'tomas', name: 'Tomás Oliveira', email: 'tomas@weft.dev', role: 'Member', team: 'Web', initials: 'TO' }, { id: 'lena', name: 'Lena Fischer', email: 'lena@weft.dev', role: 'Member', team: 'Data', initials: 'LF' }, { id: 'noor', name: 'Noor Haddad', email: 'noor@weft.dev', role: 'Viewer', team: 'Data', initials: 'NH' }, { id: 'sam', name: 'Sam Okafor', email: 'sam@weft.dev', role: 'Member', team: 'Mobile', initials: 'SO' }, { id: 'ines', name: 'Inés Vidal', email: 'ines@weft.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 @weft-js/warpUsage
tsx
import { Thread } from '@weft-js/warp'<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 '@weft-js/core'import { PresenceStack, Spacer, Text, Thread } from '@weft-js/warp'import type { ComposerSend, ChatMessage, MessageReaction, Peer } from '@weft-js/warp'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@weft.dev', role: 'Owner', team: 'Platform', initials: 'ML' }, { id: 'jun', name: 'Jun Tanaka', email: 'jun@weft.dev', role: 'Admin', team: 'Platform', initials: 'JT' }, { id: 'priya', name: 'Priya Raman', email: 'priya@weft.dev', role: 'Member', team: 'Web', initials: 'PR' }, { id: 'tomas', name: 'Tomás Oliveira', email: 'tomas@weft.dev', role: 'Member', team: 'Web', initials: 'TO' }, { id: 'lena', name: 'Lena Fischer', email: 'lena@weft.dev', role: 'Member', team: 'Data', initials: 'LF' }, { id: 'noor', name: 'Noor Haddad', email: 'noor@weft.dev', role: 'Viewer', team: 'Data', initials: 'NH' }, { id: 'sam', name: 'Sam Okafor', email: 'sam@weft.dev', role: 'Member', team: 'Mobile', initials: 'SO' }, { id: 'ines', name: 'Inés Vidal', email: 'ines@weft.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
- CommentThread, when the talk hangs off one spot in a document
Accessibility
- Enter
- Sends
- ShiftEnter
- Adds a line
- @
- Suggests people to mention
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | — | Names the region: "calendar-engine channel". |
messages | Live<ChatMessage[]> | — | The messages, oldest first. A state or a list. |
unreadFrom | string | null | — | The first unread message: opening lands on the divider before it. |
typing | Live<string[]> | — | Names typing now. |
header | unknown | — | The header's content (data-part="app-bar"): channel, members, stack, call, search. |
height | string | — | A log height, if the parent doesn't size the thread. |
control | ((c: VirtualControl) => void) | — | Gives you a handle to scroll the list. |
composer | Omit<ComposerProps, "quote" | "onSend" | "input"> | — | The composer's props. |
| Event | Type | Default | Description |
|---|---|---|---|
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. |
Parts
Every element carries a stable name, so you can style it from your own CSS: [data-scope="thread"] [data-part="log"]
logrootapp-barjump