The Greek copy of the Conics arrived from Constantinople this morning.
Display
Chat
A conversation between people. It follows new messages while you're at the bottom and leaves you where you are while you read back, with a button for what arrived meanwhile. Your messages show at once and say when they're delivered, read, or need a retry. This is the conversation itself; for a whole app with a conversation list, search, threads and details, use the Messaging block.
Installation
npx sinaui add chatCopy src/lib/ui/Chat.svelte and chat.ts, with Avatar, Button, Icon, Image, MentionInput, Popover, Spinner, VoiceNote, the dropdown folder, announce.ts, motion.ts, scroll-edges.ts and tokens.css. Icons come from @hugeicons/core-free-icons.
Examples
A conversation
Type and press Enter; Shift+Enter starts a new line. Point at a message, or tab to it, for its actions.
<script lang="ts">
import Chat from '#lib/ui/Chat.svelte';
import { conversation, me, users, wait } from './data';
let messages = $state(
conversation([
['thabit', 'The Greek copy of the Conics arrived from Constantinople this morning.'],
['hunayn', 'Finally! Is it the complete one?'],
['thabit', 'Books one to seven. The eighth is still missing.'],
['thabit', 'I’ll start on book five tonight.'],
['hunayn', 'Keep the Greek terms in the margin, we’ll settle the Arabic ones together.'],
['thabit', 'Agreed. Can you check my reading of the second proposition tomorrow?']
])
);
</script>
<div class="frame">
<Chat bind:messages {users} {me} label="Chat with Thabit ibn Qurra" send={() => wait(600)} />
</div>
<style>
.frame {
inline-size: 100%;
block-size: 28rem;
border-radius: var(--ui-radius);
background: var(--ui-surface);
box-shadow: var(--ui-shadow-card);
overflow: hidden;
}
</style>Delivered, read, and retry
Send something: it goes from Sending to Sent, Delivered and Read. Turn on “Fail the next send” to see Retry.
<script lang="ts">
import { onMount } from 'svelte';
import Chat from '#lib/ui/Chat.svelte';
import Switch from '#lib/ui/Switch.svelte';
import type { ChatMessage } from '#lib/ui/chat.js';
import { conversation, me, users, wait } from './data';
let failNext = $state(false);
let messages = $state(
conversation([
['maryam', 'The new astrolabe plate is ready for Baghdad’s latitude.'],
['hunayn', 'Wonderful. Send the measurements when you can.', { status: 'read' }],
['maryam', 'Tonight, after I check them against the star table.'],
['hunayn', 'Also, could you engrave the Qibla line on the back?', { status: 'delivered' }]
])
);
// Pending receipts, cancelled if the example goes away.
const timers: ReturnType<typeof setTimeout>[] = [];
onMount(() => () => timers.forEach(clearTimeout));
const mark = (id: string, status: ChatMessage['status'], ms: number) =>
timers.push(
setTimeout(() => (messages = messages.map((x) => (x.id === id ? { ...x, status } : x))), ms)
);
// Sent, then delivered, then read, like a real service reporting back.
async function send(m: ChatMessage) {
await wait(700);
if (failNext) {
failNext = false;
throw new Error('offline');
}
mark(m.id, 'delivered', 1200);
mark(m.id, 'read', 3000);
}
</script>
<div class="demo">
<Switch bind:checked={failNext}>Fail the next send</Switch>
<div class="frame">
<Chat bind:messages {users} {me} label="Chat with Maryam al-Asturlabi" {send} />
</div>
</div>
<style>
.demo {
display: grid;
gap: 1rem;
inline-size: 100%;
}
.frame {
block-size: 26rem;
border-radius: var(--ui-radius);
background: var(--ui-surface);
box-shadow: var(--ui-shadow-card);
overflow: hidden;
}
</style>Replies and reactions
Press a quoted message to jump to the original. React from a message's actions; press a reaction to add or take back yours.
<script lang="ts">
import Chat from '#lib/ui/Chat.svelte';
import { conversation, me, users, wait } from './data';
let messages = $state(
conversation([
['kindi', 'Draft of the letter on the five essences is on the copying desk.'],
[
'thabit',
'Read it last night. The part on time is the strongest.',
{ reactions: { '👍': ['kindi'] } }
],
['kindi', 'Anyone have the Persian glossary? I need it for the cosmology terms.'],
[
'hunayn',
'@Al-Kindi it’s in the west reading room, second shelf.',
{ replyTo: 'm2', reactions: { '🙏': ['kindi'], '❤️': ['thabit', 'maryam'] } }
],
['maryam', 'Can we meet after the noon prayer to go through the diagrams?'],
['thabit', 'Works for me.', { replyTo: 'm4', reactions: { '👍': ['maryam', 'hunayn'] } }]
])
);
</script>
<div class="frame">
<Chat
bind:messages
{users}
{me}
label="Translators’ group"
send={() => wait(500)}
save={() => wait(300)}
/>
</div>
<style>
.frame {
inline-size: 100%;
block-size: 30rem;
border-radius: var(--ui-radius);
background: var(--ui-surface);
box-shadow: var(--ui-shadow-card);
overflow: hidden;
}
</style>Pictures and files
Pictures open larger when pressed; files download. Attach your own with the paper clip.
<script lang="ts">
import starTile from './star-tile.svg';
import Chat from '#lib/ui/Chat.svelte';
import { conversation, me, users, wait } from './data';
let messages = $state(
conversation([
[
'maryam',
'Here’s the tile pattern for the observatory floor.',
{
attachments: [
{
name: 'star-tile.svg',
size: 4_812,
type: 'image/svg+xml',
url: starTile,
width: 1,
height: 1
}
]
}
],
['hunayn', 'Beautiful. And the reading room?'],
[
'maryam',
'',
{
attachments: [
{
name: 'reading-room.jpg',
size: 218_430,
type: 'image/jpeg',
url: 'https://upload.wikimedia.org/wikipedia/commons/thumb/a/a9/A_Library_in_Golden_Islamic_Age.jpg/960px-A_Library_in_Golden_Islamic_Age.jpg',
width: 960,
height: 1280
}
]
}
],
[
'maryam',
'And the measurements, as promised.',
{
attachments: [
{
name: 'baghdad-plate-measurements.txt',
size: 96,
type: 'text/plain',
url: `data:text/plain,${encodeURIComponent('Baghdad plate\nLatitude 33° 20′\nAltitude circles every 2°\nQibla bearing 196°\n')}`
}
]
}
]
])
);
</script>
<div class="frame">
<Chat bind:messages {users} {me} label="Plans from Maryam al-Asturlabi" send={() => wait(900)} />
</div>
<style>
.frame {
inline-size: 100%;
block-size: 32rem;
border-radius: var(--ui-radius);
background: var(--ui-surface);
box-shadow: var(--ui-shadow-card);
overflow: hidden;
}
</style>Older messages
500 messages, loaded 40 at a time as you scroll up. What you're reading stays put while each page arrives above it.
<script lang="ts">
import Chat from '#lib/ui/Chat.svelte';
import type { ChatMessage } from '#lib/ui/chat.js';
import { me, users, wait } from './data';
// 500 messages, one an hour going back; 40 load at a time as you scroll up.
const authors = ['thabit', 'hunayn', 'kindi', 'maryam'];
const topics = [
'the Conics',
'the star table',
'the glossary',
'Galen’s anatomy',
'the new paper',
'the observatory'
];
const all: ChatMessage[] = Array.from({ length: 500 }, (_, i) => ({
id: `h${i}`,
author: authors[(i * 7) % 4],
text: `Note ${i + 1} on ${topics[i % topics.length]}.`,
at: Date.now() - (500 - i) * 3_600_000,
status: authors[(i * 7) % 4] === me ? 'read' : undefined
}));
let messages = $state(all.slice(-40));
async function older() {
await wait(700);
const first = all.findIndex((m) => m.id === messages[0]?.id);
return all.slice(Math.max(0, first - 40), first);
}
</script>
<div class="frame">
<Chat bind:messages {users} {me} label="Library log" {older} send={() => wait(500)} />
</div>
<style>
.frame {
inline-size: 100%;
block-size: 30rem;
border-radius: var(--ui-radius);
background: var(--ui-surface);
box-shadow: var(--ui-shadow-card);
overflow: hidden;
}
</style>Typing and new messages
Send something and Al-Kindi types back. Scroll up first, then press “Thabit writes in”: you stay where you are and a button counts what's new.
<script lang="ts">
import { onMount } from 'svelte';
import Button from '#lib/ui/Button.svelte';
import Chat from '#lib/ui/Chat.svelte';
import { conversation, me, users, wait } from './data';
let messages = $state(
conversation([
['kindi', 'I’m in the reading room until sunset if anyone needs the glossary.'],
['hunayn', 'Thanks! Ask me anything about the Galen translation.']
])
);
let typing = $state<string[]>([]);
let unread = $state<string>();
// Pending demo replies, cancelled if the example goes away.
const timers: ReturnType<typeof setTimeout>[] = [];
const later = (fn: () => void, ms: number) => timers.push(setTimeout(fn, ms));
onMount(() => () => timers.forEach(clearTimeout));
const replies = [
'Good question. Let me check the Syriac copy first.',
'In the margin of folio 12 there’s a note about exactly that.',
'I agree. We should ask Thabit, he read it twice.',
'Send me the passage and I’ll compare it tonight.'
];
let turn = 0;
// Someone else writes: typing first, then the message.
function incoming(author: string, text: string) {
typing = [...typing, author];
later(() => {
typing = typing.filter((t) => t !== author);
messages = [...messages, { id: crypto.randomUUID(), author, text, at: Date.now() }];
}, 1600);
}
async function send() {
await wait(500);
later(() => incoming('kindi', replies[turn++ % replies.length]), 600);
}
</script>
<div class="demo">
<div class="frame">
<Chat bind:messages {users} {me} {typing} {unread} label="Chat with Al-Kindi" {send} />
</div>
<Button
variant="secondary"
onclick={() =>
incoming('thabit', 'Did anyone see the new paper from Samarkand? It’s much smoother.')}
>
Thabit writes in
</Button>
</div>
<style>
.demo {
display: grid;
justify-items: start;
gap: 1rem;
inline-size: 100%;
}
.frame {
inline-size: 100%;
block-size: 26rem;
border-radius: var(--ui-radius);
background: var(--ui-surface);
box-shadow: var(--ui-shadow-card);
overflow: hidden;
}
</style>Props
| Prop | Type | Default |
|---|---|---|
messages | ChatMessage[], oldest first (bindable) | [] |
users | ChatUser[], you included (required) | |
me | string: your user id (required) | |
variant | 'bubbles' (yours on the far side) or 'flat' (everyone on one side) | 'bubbles' |
send | (message, files) => Promise: resolve to mark it sent, throw to offer Retry | |
save | (message) => Promise: saves an edit, delete or reaction; throw to undo it | |
older | () => Promise<ChatMessage[]>: the page before; empty at the start | |
unread | string: the first unread message's id, for the New divider | |
typing | string[]: who's typing | [] |
thread | (message) => void: adds Reply in thread and a replies link | |
menu | { label, icon, run, show? }[]: more items for each message's menu | [] |
hide | (message) => Promise: adds Delete for me | |
onread | (id) => void: the newest message you've seen | |
draft | string: keeps unsent text per conversation | |
label, placeholder | string | |
attachments | boolean | true |
header | Snippet: above the messages |
Keyboard
| Key | Does |
|---|---|
| Enter | Sends; while @ suggestions are open, picks one |
| Shift Enter | A new line |
| ↑ in an empty box | Edits your last message |
| ↑ ↓ on a message | Moves to the message before or after |
| Home End on a message | The first or last loaded message |
| Tab on a message | Into its actions: react, reply, more |
| Escape | Cancels an edit or a reply; on a message, back to the box |
Accessibility
- Messages from others are announced as they arrive; yours and older pages aren't.
- Each message is read as who sent it and what it says, with the full time as its description.
- Only one message is in the tab order; the arrows move between them.
- Delivery, read and failed states are written out, not only shown as ticks.
- Links open in a new tab and message text is never treated as HTML.
- With reduced motion, messages appear without sliding and scrolling jumps instead of gliding.