diff --git a/src/LiveExample.test.tsx b/src/LiveExample.test.tsx
index 166e0a1..3f87fc4 100644
--- a/src/LiveExample.test.tsx
+++ b/src/LiveExample.test.tsx
@@ -127,7 +127,7 @@ describe('React live demo', () => {
expect(fake.sdk.markConversationRead).toHaveBeenCalledWith('room-1', { throughMessageId: 'message-1', privateStateVersion: 2 }),
)
expect(screen.queryByRole('img', { name: 'Unread' })).toBeNull()
- expect(screen.getByText('UI SDK 0.9.0')).toBeInTheDocument()
+ expect(screen.getByText('UI SDK 0.10.0')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Mark unread' }))
// The action goes through the SDK's /unread route; the package patches the row and renders the
diff --git a/src/LiveExample.tsx b/src/LiveExample.tsx
index d537558..d738a38 100644
--- a/src/LiveExample.tsx
+++ b/src/LiveExample.tsx
@@ -32,6 +32,7 @@ import {
personas,
uploadAttachment,
} from './demo'
+import { ReadReceiptAvatars } from './ReadReceiptAvatars'
import './live.css'
export function LiveExample() {
@@ -259,7 +260,7 @@ export function LiveExample() {
{state.status}
- UI SDK 0.9.0
+ UI SDK 0.10.0
@@ -494,6 +495,7 @@ function LiveConversation({
})
}}
composerPlaceholder="Message your team…"
+ renderReadReceipt={({ readerIds }) => }
formatTime={(date) => date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
/>
diff --git a/src/ReadReceiptAvatars.test.tsx b/src/ReadReceiptAvatars.test.tsx
new file mode 100644
index 0000000..22b1351
--- /dev/null
+++ b/src/ReadReceiptAvatars.test.tsx
@@ -0,0 +1,13 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import { ReadReceiptAvatars } from './ReadReceiptAvatars'
+
+describe('live read receipts', () => {
+ it('shows reader identities as compact avatars', () => {
+ render()
+ const receipt = screen.getByRole('group', { name: 'Read by Alex, Sam' })
+ expect(receipt.querySelectorAll('.demo-read-receipt__avatar')).toHaveLength(2)
+ expect(receipt).toHaveTextContent('AR')
+ expect(receipt).toHaveTextContent('SP')
+ })
+})
diff --git a/src/ReadReceiptAvatars.tsx b/src/ReadReceiptAvatars.tsx
new file mode 100644
index 0000000..c05a90b
--- /dev/null
+++ b/src/ReadReceiptAvatars.tsx
@@ -0,0 +1,30 @@
+import { displayName, personas } from './demo'
+
+/** Compact reader identities beside an outgoing message, like the Flutter demo. */
+export function ReadReceiptAvatars({ readerIds }: { readerIds: ReadonlySet }) {
+ const readers = [...readerIds].sort()
+ if (readers.length === 0) return
+
+ const names = readers.map(displayName)
+ return (
+
+ {readers.slice(0, 3).map((id) => {
+ const persona = personas.find((entry) => entry.id === id)
+ const name = displayName(id)
+ const initials = persona?.initials ?? name.slice(0, 2).toUpperCase()
+ return (
+
+ {initials}
+
+ )
+ })}
+ {readers.length > 3 && +{readers.length - 3}}
+
+ )
+}
diff --git a/src/live.css b/src/live.css
index 30cfb44..7a9886a 100644
--- a/src/live.css
+++ b/src/live.css
@@ -452,6 +452,35 @@
.demo-conversation .ckui-messages {
background: #fcfdfb;
}
+.demo-conversation .ckui-message-bubble { order: 0; }
+.demo-conversation .demo-read-receipt { order: 1; }
+.demo-conversation .ckui-reactions { order: 2; }
+.demo-conversation .ckui-message-actions,
+.demo-conversation .ckui-message-confirm { order: 3; }
+.demo-read-receipt {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ min-height: 18px;
+ padding-top: 2px;
+ padding-right: 2px;
+}
+.demo-read-receipt--empty { display: none; }
+.demo-read-receipt__avatar {
+ display: grid;
+ width: 18px;
+ height: 18px;
+ margin-left: -5px;
+ place-items: center;
+ border: 2px solid #fff;
+ border-radius: 50%;
+ color: #fff;
+ font-size: 7px;
+ font-weight: 700;
+ line-height: 1;
+}
+.demo-read-receipt__avatar:first-child { margin-left: 0; }
+.demo-read-receipt__more { padding-left: 4px; color: var(--demo-muted); font-size: 10px; }
.demo-conversation .ckui-media-card--image img {
max-height: 230px;
object-fit: contain;