@adatechnology/conversations-ui 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +96 -1
  2. package/dist/{ConversationSimulatorPanel-CrfFjiWh.d.ts → ConversationSimulatorPanel-q-wVgBCN.d.ts} +1 -1
  3. package/dist/index.d.ts +4 -4
  4. package/dist/participant/index.d.ts +45 -3
  5. package/dist/participant/index.js +287 -112
  6. package/dist/preview/index.d.ts +3 -3
  7. package/dist/{quickReply.types-B4gmZsSr.d.ts → quickReply.types-DzWH3NYV.d.ts} +1 -1
  8. package/dist/styles.css +74 -29
  9. package/package.json +1 -1
  10. package/src/participant/ConversationThread.test.tsx +165 -0
  11. package/src/participant/ConversationThread.tsx +117 -0
  12. package/src/participant/ParticipantMessageBubble.tsx +7 -3
  13. package/src/participant/ParticipantThread.tsx +9 -0
  14. package/src/participant/ParticipantThreadHeader.tsx +10 -0
  15. package/src/participant/ParticipantThreadScreen.tsx +6 -46
  16. package/src/participant/ParticipantTimeline.tsx +6 -3
  17. package/src/participant/conversationThreadApi.test.ts +27 -0
  18. package/src/participant/conversationThreadApi.ts +24 -0
  19. package/src/participant/conversationThreadCss.test.ts +116 -0
  20. package/src/participant/conversationThreadSummary.ts +34 -0
  21. package/src/participant/index.ts +3 -0
  22. package/src/participant/participantContrast.test-helper.ts +116 -0
  23. package/src/participant/participantGolden.test.tsx +46 -0
  24. package/src/participant/participantGoldenScenarios.test-helper.tsx +57 -0
  25. package/src/participant/participantMessages.ts +4 -3
  26. package/src/participant/participantNewMessages.test.ts +21 -0
  27. package/src/participant/participantNewMessages.ts +8 -3
  28. package/src/participant/participantPerspective.test.ts +67 -0
  29. package/src/participant/participantPerspective.ts +50 -0
  30. package/src/participant/participantPerspectiveRender.test.tsx +94 -0
  31. package/src/participant/participantThemeContrast.test.ts +98 -0
  32. package/src/participant/test-fixtures/conversations-list.golden.html +1 -0
  33. package/src/participant/test-fixtures/conversations-selected.golden.html +1 -0
  34. package/src/participant/test-fixtures/thread-avatars-initials.golden.html +1 -0
  35. package/src/participant/test-fixtures/thread-closed.golden.html +1 -0
  36. package/src/participant/test-fixtures/thread-default.golden.html +1 -0
  37. package/src/participant/test-fixtures/thread-error.golden.html +1 -0
  38. package/src/participant/test-fixtures/thread-loading.golden.html +1 -0
  39. package/src/participant/test-fixtures/thread-plain.golden.html +1 -0
  40. package/src/participant/test-fixtures/thread-tail-off.golden.html +1 -0
  41. package/src/participant/useParticipantConversationView.ts +3 -0
  42. package/src/participant/useParticipantThread.ts +13 -2
  43. package/src/participant/useParticipantThreadController.ts +112 -0
  44. package/src/participant/useParticipantThreadScroll.ts +3 -1
  45. package/src/participant/useParticipantUnread.test.tsx +40 -0
  46. package/src/participant/useParticipantUnread.ts +38 -0
  47. package/src/styles.css +24 -12
@@ -0,0 +1,50 @@
1
+ import type { MessageDirection, ParticipantMessage } from '@adatechnology/conversation-contracts'
2
+
3
+ /** Who is looking at the conversation. `participant` is the outside party; `operator` is the company. */
4
+ export type ParticipantPerspective = 'participant' | 'operator'
5
+
6
+ export const DEFAULT_PARTICIPANT_PERSPECTIVE: ParticipantPerspective = 'participant'
7
+
8
+ /** `direction` is from the company side: the participant writes `inbound`, the operator writes `outbound`. */
9
+ const OWN_DIRECTION: Record<ParticipantPerspective, MessageDirection> = {
10
+ participant: 'inbound',
11
+ operator: 'outbound',
12
+ }
13
+
14
+ export function isMessageMine(
15
+ message: Pick<ParticipantMessage, 'direction'>,
16
+ perspective: ParticipantPerspective = DEFAULT_PARTICIPANT_PERSPECTIVE,
17
+ ): boolean {
18
+ return message.direction === OWN_DIRECTION[perspective]
19
+ }
20
+
21
+ type ReadState = Pick<ParticipantMessage, 'direction' | 'status' | 'readAt'>
22
+
23
+ function isUnreadByViewer(message: ReadState, perspective: ParticipantPerspective): boolean {
24
+ if (isMessageMine(message, perspective)) return false
25
+ return !message.readAt && message.status !== 'read'
26
+ }
27
+
28
+ export function countOtherSideMessages(
29
+ messages: readonly Pick<ParticipantMessage, 'direction'>[],
30
+ perspective: ParticipantPerspective,
31
+ ): number {
32
+ return messages.filter((message) => !isMessageMine(message, perspective)).length
33
+ }
34
+
35
+ export function countUnreadByViewer(messages: readonly ReadState[], perspective: ParticipantPerspective): number {
36
+ return messages.filter((message) => isUnreadByViewer(message, perspective)).length
37
+ }
38
+
39
+ export type ResolveViewerUnreadParams = {
40
+ readonly messages: readonly ReadState[]
41
+ readonly perspective: ParticipantPerspective
42
+ /** How many other-side messages the viewer already acknowledged through markRead. */
43
+ readonly acknowledgedCount: number
44
+ }
45
+
46
+ /** Unread other-side messages, or zero while nothing arrived since the last acknowledgement. */
47
+ export function resolveViewerUnread({ messages, perspective, acknowledgedCount }: ResolveViewerUnreadParams): number {
48
+ if (countOtherSideMessages(messages, perspective) <= acknowledgedCount) return 0
49
+ return countUnreadByViewer(messages, perspective)
50
+ }
@@ -0,0 +1,94 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { buildConversation, buildMessage } from './participantFixtures.test-helper'
5
+ import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
6
+ import type { ParticipantTimelineItem } from './participantMessages'
7
+ import { ParticipantThread, type ParticipantThreadProps } from './ParticipantThread'
8
+
9
+ const ITEMS: readonly ParticipantTimelineItem[] = [
10
+ {
11
+ kind: 'server',
12
+ message: buildMessage({
13
+ id: 'a',
14
+ direction: 'inbound',
15
+ authorName: 'Ana Souza',
16
+ createdAt: '2026-10-01T10:00:00.000Z',
17
+ }),
18
+ },
19
+ {
20
+ kind: 'server',
21
+ message: buildMessage({ id: 'b', direction: 'outbound', status: 'read', createdAt: '2026-10-01T10:05:00.000Z' }),
22
+ },
23
+ {
24
+ kind: 'pending',
25
+ origin: 'local',
26
+ displayState: 'queued',
27
+ pending: {
28
+ clientMessageId: 'c',
29
+ subject: { subjectType: 'invoice', subjectId: '1' },
30
+ text: 'Waiting',
31
+ createdAt: '2026-10-01T10:06:00.000Z',
32
+ state: 'queued',
33
+ },
34
+ },
35
+ ]
36
+
37
+ function render(overrides: Partial<ParticipantThreadProps> = {}): string {
38
+ return renderToStaticMarkup(
39
+ <ParticipantThread
40
+ conversation={buildConversation({ subjectId: '1', subjectLabel: 'Invoice 4521' })}
41
+ items={ITEMS}
42
+ hasMore={false}
43
+ labels={DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS}
44
+ resolveAttachmentUrl={async () => 'https://files.example/a'}
45
+ draft={{ value: '', onChange: () => undefined, files: [], onFilesChange: () => undefined }}
46
+ onSend={() => undefined}
47
+ status="ready"
48
+ refresh={() => undefined}
49
+ scroll={{ ref: { current: null }, onScroll: () => undefined }}
50
+ newMessagesCount={0}
51
+ isSending={false}
52
+ {...overrides}
53
+ />,
54
+ )
55
+ }
56
+
57
+ function mineFlags(markup: string): boolean[] {
58
+ return markup
59
+ .split('<div class="cv-p-bubble')
60
+ .slice(1)
61
+ .map((bubble) => bubble.startsWith(' cv-p-bubble--mine'))
62
+ }
63
+
64
+ describe('ParticipantThread perspective', () => {
65
+ it('is untouched without the new props: the participant owns the inbound messages', () => {
66
+ expect(mineFlags(render())).toEqual([true, false, true])
67
+ expect(render({ perspective: 'participant' })).toBe(render())
68
+ })
69
+
70
+ it('draws none of the new header parts without the new props', () => {
71
+ const markup = render()
72
+ expect(markup).not.toContain('cv-p-thread__actions')
73
+ expect(markup).not.toContain('cv-p-thread__counterpart')
74
+ })
75
+
76
+ it('inverts which bubble is mine for the operator, keeping the own local pending message', () => {
77
+ expect(mineFlags(render({ perspective: 'operator' }))).toEqual([false, true, true])
78
+ })
79
+
80
+ it('moves the ticks to the operator side', () => {
81
+ const participant = render()
82
+ const operator = render({ perspective: 'operator' })
83
+ expect(participant).not.toContain('cv-status-ticks--read')
84
+ expect(operator).toContain('cv-status-ticks--read')
85
+ expect(operator).toContain('cv-status-ticks--queued')
86
+ })
87
+
88
+ it('puts the avatar on the counterpart, not on the operator', () => {
89
+ const participant = render({ avatars: 'initials' })
90
+ const operator = render({ perspective: 'operator', avatars: 'initials' })
91
+ expect(participant).not.toContain('>AS<')
92
+ expect(operator).toContain('class="cv-p-avatar" aria-hidden="true">AS<')
93
+ })
94
+ })
@@ -0,0 +1,98 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import {
4
+ contrastRatio,
5
+ declaredToken,
6
+ defaultPalette,
7
+ fallbackOf,
8
+ flatten,
9
+ mappedPalette,
10
+ over,
11
+ parseColor,
12
+ tint,
13
+ type Palette,
14
+ type Rgba,
15
+ } from './participantContrast.test-helper'
16
+
17
+ const TEXT_MINIMUM = 4.5
18
+ const NON_TEXT_MINIMUM = 3
19
+ const WALLPAPER_SUBTLETY = 2
20
+
21
+ /** The light mapping a host panel can set on `.cv-p`; every other token keeps the package default. */
22
+ const PANEL_LIGHT_MAPPING = {
23
+ surface: '#f2efe9',
24
+ raised: '#ffffff',
25
+ text: '#1f2a30',
26
+ muted: '#59636a',
27
+ border: '#cfc8bb',
28
+ accent: '#a85a1c',
29
+ highlight: '#fbf3ea',
30
+ } as const
31
+
32
+ type Scenario = { readonly name: string; readonly palette: Palette; readonly tick: Rgba; readonly tickRead: Rgba }
33
+
34
+ function scenario(name: string, palette: Palette, theme: 'light' | 'dark'): Scenario {
35
+ const flat = flatten(palette)
36
+ const tick = flat.muted
37
+ const tickRead = parseColor(fallbackOf(declaredToken('--cv-p-i-tick-read', theme)))
38
+ return { name, palette: flat, tick, tickRead }
39
+ }
40
+
41
+ const SCENARIOS: readonly Scenario[] = [
42
+ scenario('light defaults', defaultPalette('light'), 'light'),
43
+ scenario('dark defaults', defaultPalette('dark'), 'dark'),
44
+ scenario('panel light mapping', mappedPalette(PANEL_LIGHT_MAPPING), 'light'),
45
+ ]
46
+
47
+ function fillOf(palette: Palette, surface: 'received' | 'mine'): Rgba {
48
+ return surface === 'received' ? palette.raised : over(palette.highlight, palette.surface)
49
+ }
50
+
51
+ describe.each(SCENARIOS.map((entry) => [entry.name, entry] as const))(
52
+ 'contrast: %s',
53
+ (_name, { palette, tick, tickRead }) => {
54
+ it('keeps the text of both bubbles at 4.5:1 or more', () => {
55
+ for (const side of ['received', 'mine'] as const) {
56
+ const fill = fillOf(palette, side)
57
+ expect(contrastRatio(palette.text, fill)).toBeGreaterThanOrEqual(TEXT_MINIMUM)
58
+ expect(contrastRatio(palette.muted, fill)).toBeGreaterThanOrEqual(TEXT_MINIMUM)
59
+ expect(contrastRatio(palette.accent, fill)).toBeGreaterThanOrEqual(TEXT_MINIMUM)
60
+ expect(contrastRatio(palette.danger, fill)).toBeGreaterThanOrEqual(TEXT_MINIMUM)
61
+ }
62
+ })
63
+
64
+ it('keeps every tick at 3:1 or more on both bubbles', () => {
65
+ for (const side of ['received', 'mine'] as const) {
66
+ const fill = fillOf(palette, side)
67
+ expect(contrastRatio(tick, fill)).toBeGreaterThanOrEqual(NON_TEXT_MINIMUM)
68
+ expect(contrastRatio(tickRead, fill)).toBeGreaterThanOrEqual(NON_TEXT_MINIMUM)
69
+ expect(contrastRatio(palette.danger, fill)).toBeGreaterThanOrEqual(NON_TEXT_MINIMUM)
70
+ }
71
+ })
72
+
73
+ it('tells the own bubble from the page by an accent edge of 3:1 or more', () => {
74
+ expect(contrastRatio(palette.accent, palette.surface)).toBeGreaterThanOrEqual(NON_TEXT_MINIMUM)
75
+ expect(contrastRatio(palette.accent, fillOf(palette, 'mine'))).toBeGreaterThanOrEqual(NON_TEXT_MINIMUM)
76
+ })
77
+
78
+ it('reads copyable tokens: text at 4.5:1 on the tint, the dotted underline at 3:1', () => {
79
+ for (const side of ['received', 'mine'] as const) {
80
+ const tinted = tint(palette.accent, 14, fillOf(palette, side))
81
+ expect(contrastRatio(palette.text, tinted)).toBeGreaterThanOrEqual(TEXT_MINIMUM)
82
+ expect(contrastRatio(palette.accent, fillOf(palette, side))).toBeGreaterThanOrEqual(NON_TEXT_MINIMUM)
83
+ }
84
+ })
85
+
86
+ it('lets text sit on the wallpaper dots and keeps the wallpaper itself subtle', () => {
87
+ const ink = palette.border
88
+ expect(contrastRatio(palette.text, ink)).toBeGreaterThanOrEqual(TEXT_MINIMUM)
89
+ expect(contrastRatio(palette.danger, palette.surface)).toBeGreaterThanOrEqual(TEXT_MINIMUM)
90
+ expect(contrastRatio(ink, palette.surface)).toBeLessThanOrEqual(WALLPAPER_SUBTLETY)
91
+ })
92
+
93
+ it('keeps the strip above the composer and the unread badge readable', () => {
94
+ expect(contrastRatio(palette.accent, palette.raised)).toBeGreaterThanOrEqual(TEXT_MINIMUM)
95
+ expect(contrastRatio(palette.accentContrast, palette.accent)).toBeGreaterThanOrEqual(TEXT_MINIMUM)
96
+ })
97
+ },
98
+ )
@@ -0,0 +1 @@
1
+ <div class="cv-p-root"><div class="cv-p-root__screen"><div class="cv-p cv-p-inbox" aria-busy="true"><h2 class="cv-p-inbox__title">Conversations</h2><div class="cv-p-loading"><span class="cv-p-skeleton" aria-hidden="true"></span><span class="cv-p-skeleton" aria-hidden="true"></span><span class="cv-p-sr-only">Loading…</span></div></div></div></div>
@@ -0,0 +1 @@
1
+ <div class="cv-p-root"><div class="cv-p-root__screen"><div class="cv-p cv-p-thread" aria-busy="true"></div></div></div>
@@ -0,0 +1 @@
1
+ <div class="cv-p cv-p-thread" aria-busy="false"><header class="cv-p-thread__header"><button type="button" class="cv-p-thread__back" aria-label="Back"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-left" aria-hidden="true" focusable="false"><path d="m12 19-7-7 7-7"></path><path d="M19 12H5"></path></svg></button><div class="cv-p-thread__heading"><h2 class="cv-p-thread__title"><span class="cv-p-thread__title-text">Invoice 4521</span></h2><div class="cv-p-thread__meta"><span class="cv-p-protocol cv-p-protocol--header"><span class="cv-p-protocol__code"><span class="cv-p-sr-only">Protocol </span>P-1</span><button type="button" class="cv-p-protocol__copy" aria-label="Copy protocol P-1"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg></button></span><div class="cv-p-sr-only" role="status" aria-live="polite"></div><span class="cv-p-channels"><span class="cv-p-sr-only">Channels in this conversation: </span><span class="cv-p-channel cv-p-channel--app"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-smartphone" aria-hidden="true"><rect width="14" height="20" x="5" y="2" rx="2" ry="2"></rect><path d="M12 18h.01"></path></svg><span class="cv-p-sr-only">App, </span></span><span class="cv-p-channel cv-p-channel--whatsapp"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-message-circle" aria-hidden="true"><path d="M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719"></path></svg><span class="cv-p-sr-only">WhatsApp</span></span></span></div></div></header><div class="cv-p-thread__scroll" tabindex="-1"><button type="button" class="cv-p-thread__older">Load older messages</button><div class="cv-p-thread__messages"><div class="cv-p-day" role="separator" aria-label="Thu, 10/01"><time class="cv-p-day__label" dateTime="2026-10-01">Thu, 10/01</time></div><div class="cv-p-bubble cv-p-bubble--mine"><span class="cv-p-sr-only">Me</span><div class="cv-p-text">*<strong>oi</strong>* <button type="button" class="cv-p-copyable cv-p-copyable--phone" aria-label="Copy 11 98888-7777">11 98888-7777</button> <button type="button" class="cv-p-copyable cv-p-copyable--email" aria-label="Copy a@b.com">a@b.com</button> <span class="cv-p-text__link-group"><a class="cv-p-text__link" href="https://x.com" rel="noopener noreferrer nofollow ugc" target="_blank">https://x.com<span class="cv-p-sr-only"> (opens in a new tab)</span></a><button type="button" class="cv-p-text__link-copy" aria-label="Copy https://x.com"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg></button></span></div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-01T12:00:00.000Z">12:00</time><span class="cv-p-bubble__status"><span class="cv-status-ticks cv-status-ticks--sent"><svg viewBox="0 0 20 12" width="15" height="9" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 6.5L5 10.5L14.5 1" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path></svg></span><span class="cv-p-sr-only">Sent</span></span></span></div><div class="cv-p-bubble-row"><span class="cv-p-bubble-row__avatar"><span class="cv-p-avatar" aria-hidden="true">O</span></span><div class="cv-p-bubble"><span class="cv-p-bubble__author">Op</span><div class="cv-p-text">Hello</div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-01T12:05:00.000Z">12:05</time></span></div></div><div class="cv-p-day" role="separator" aria-label="Fri, 10/02"><time class="cv-p-day__label" dateTime="2026-10-02">Fri, 10/02</time></div><div class="cv-p-bubble cv-p-bubble--mine"><span class="cv-p-sr-only">Me</span><div class="cv-p-text">Hello</div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-02T12:05:00.000Z">12:05</time><span class="cv-p-bubble__status"><span class="cv-status-ticks cv-status-ticks--delivered"><svg viewBox="0 0 20 12" width="15" height="9" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 6.5L4.5 10L11 2" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path><path d="M6 6.5L9.5 10L19 1" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path></svg></span><span class="cv-p-sr-only">Delivered</span></span></span></div><div class="cv-p-bubble cv-p-bubble--mine"><span class="cv-p-sr-only">Me</span><div class="cv-p-text">W</div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-02T12:06:00.000Z">12:06</time><button type="button" class="cv-p-bubble__retry"><span class="cv-status-ticks cv-status-ticks--failed"><svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-triangle-alert lucide-alert-triangle" aria-hidden="true"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"></path><path d="M12 9v4"></path><path d="M12 17h.01"></path></svg></span><span>Failed — tap to retry</span></button></span><span class="cv-p-bubble__actions"><button type="button" class="cv-p-bubble__action">Edit</button><button type="button" class="cv-p-bubble__action">Discard</button></span></div></div><div class="cv-p-thread__latest"><button type="button" class="cv-p-thread__latest-button" aria-label="Scroll to latest message"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"></path></svg><span class="cv-p-thread__latest-badge" aria-hidden="true">2</span></button></div></div><div class="cv-p-thread__live" role="status" aria-live="polite">New messages</div><form class="cv-p-composer"><div class="cv-p-quick" role="group" aria-label="Quick replies"><button type="button" class="cv-p-chip">T</button></div><div class="cv-p-composer__row"><input class="cv-p-composer__file-input" type="file" multiple="" hidden=""/><button type="button" class="cv-p-composer__attach" aria-label="Attach"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-paperclip" aria-hidden="true" focusable="false"><path d="m16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551"></path></svg></button><textarea class="cv-p-composer__input" aria-label="Message" placeholder="Write a message…" rows="1">abc</textarea><button type="submit" class="cv-p-button cv-p-button--primary cv-p-composer__send" aria-label="Send"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-send" aria-hidden="true" focusable="false"><path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"></path><path d="m21.854 2.147-10.94 10.939"></path></svg></button></div></form></div>
@@ -0,0 +1 @@
1
+ <div class="cv-p cv-p-thread" aria-busy="false"><header class="cv-p-thread__header"><button type="button" class="cv-p-thread__back" aria-label="Back"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-left" aria-hidden="true" focusable="false"><path d="m12 19-7-7 7-7"></path><path d="M19 12H5"></path></svg></button><div class="cv-p-thread__heading"><h2 class="cv-p-thread__title"><span class="cv-p-thread__title-text">Invoice 4521</span></h2></div></header><div class="cv-p-thread__scroll" tabindex="-1"><button type="button" class="cv-p-thread__older">Load older messages</button><div class="cv-p-thread__messages"><div class="cv-p-day" role="separator" aria-label="Thu, 10/01"><time class="cv-p-day__label" dateTime="2026-10-01">Thu, 10/01</time></div><div class="cv-p-bubble cv-p-bubble--mine"><span class="cv-p-sr-only">Me</span><div class="cv-p-text">*<strong>oi</strong>* <button type="button" class="cv-p-copyable cv-p-copyable--phone" aria-label="Copy 11 98888-7777">11 98888-7777</button> <button type="button" class="cv-p-copyable cv-p-copyable--email" aria-label="Copy a@b.com">a@b.com</button> <span class="cv-p-text__link-group"><a class="cv-p-text__link" href="https://x.com" rel="noopener noreferrer nofollow ugc" target="_blank">https://x.com<span class="cv-p-sr-only"> (opens in a new tab)</span></a><button type="button" class="cv-p-text__link-copy" aria-label="Copy https://x.com"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg></button></span></div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-01T12:00:00.000Z">12:00</time><span class="cv-p-bubble__status"><span class="cv-status-ticks cv-status-ticks--sent"><svg viewBox="0 0 20 12" width="15" height="9" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 6.5L5 10.5L14.5 1" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path></svg></span><span class="cv-p-sr-only">Sent</span></span></span></div><div class="cv-p-bubble"><span class="cv-p-bubble__author">Op</span><div class="cv-p-text">Hello</div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-01T12:05:00.000Z">12:05</time></span></div><div class="cv-p-day" role="separator" aria-label="Fri, 10/02"><time class="cv-p-day__label" dateTime="2026-10-02">Fri, 10/02</time></div><div class="cv-p-bubble cv-p-bubble--mine"><span class="cv-p-sr-only">Me</span><div class="cv-p-text">Hello</div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-02T12:05:00.000Z">12:05</time><span class="cv-p-bubble__status"><span class="cv-status-ticks cv-status-ticks--delivered"><svg viewBox="0 0 20 12" width="15" height="9" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 6.5L4.5 10L11 2" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path><path d="M6 6.5L9.5 10L19 1" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path></svg></span><span class="cv-p-sr-only">Delivered</span></span></span></div><div class="cv-p-bubble cv-p-bubble--mine"><span class="cv-p-sr-only">Me</span><div class="cv-p-text">W</div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-02T12:06:00.000Z">12:06</time><button type="button" class="cv-p-bubble__retry"><span class="cv-status-ticks cv-status-ticks--failed"><svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-triangle-alert lucide-alert-triangle" aria-hidden="true"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"></path><path d="M12 9v4"></path><path d="M12 17h.01"></path></svg></span><span>Failed — tap to retry</span></button></span><span class="cv-p-bubble__actions"><button type="button" class="cv-p-bubble__action">Edit</button><button type="button" class="cv-p-bubble__action">Discard</button></span></div></div><div class="cv-p-thread__latest"><button type="button" class="cv-p-thread__latest-button" aria-label="Scroll to latest message"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"></path></svg><span class="cv-p-thread__latest-badge" aria-hidden="true">2</span></button></div></div><div class="cv-p-thread__live" role="status" aria-live="polite">New messages</div><p class="cv-p-thread__closed">This conversation is closed</p></div>
@@ -0,0 +1 @@
1
+ <div class="cv-p cv-p-thread" aria-busy="false"><header class="cv-p-thread__header"><button type="button" class="cv-p-thread__back" aria-label="Back"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-left" aria-hidden="true" focusable="false"><path d="m12 19-7-7 7-7"></path><path d="M19 12H5"></path></svg></button><div class="cv-p-thread__heading"><h2 class="cv-p-thread__title"><span class="cv-p-thread__title-text">Invoice 4521</span></h2><div class="cv-p-thread__meta"><span class="cv-p-protocol cv-p-protocol--header"><span class="cv-p-protocol__code"><span class="cv-p-sr-only">Protocol </span>P-1</span><button type="button" class="cv-p-protocol__copy" aria-label="Copy protocol P-1"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg></button></span><div class="cv-p-sr-only" role="status" aria-live="polite"></div><span class="cv-p-channels"><span class="cv-p-sr-only">Channels in this conversation: </span><span class="cv-p-channel cv-p-channel--app"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-smartphone" aria-hidden="true"><rect width="14" height="20" x="5" y="2" rx="2" ry="2"></rect><path d="M12 18h.01"></path></svg><span class="cv-p-sr-only">App, </span></span><span class="cv-p-channel cv-p-channel--whatsapp"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-message-circle" aria-hidden="true"><path d="M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719"></path></svg><span class="cv-p-sr-only">WhatsApp</span></span></span></div></div></header><div class="cv-p-thread__scroll" tabindex="-1"><button type="button" class="cv-p-thread__older">Load older messages</button><div class="cv-p-thread__messages"><div class="cv-p-day" role="separator" aria-label="Thu, 10/01"><time class="cv-p-day__label" dateTime="2026-10-01">Thu, 10/01</time></div><div class="cv-p-bubble cv-p-bubble--mine"><span class="cv-p-sr-only">Me</span><div class="cv-p-text">*<strong>oi</strong>* <button type="button" class="cv-p-copyable cv-p-copyable--phone" aria-label="Copy 11 98888-7777">11 98888-7777</button> <button type="button" class="cv-p-copyable cv-p-copyable--email" aria-label="Copy a@b.com">a@b.com</button> <span class="cv-p-text__link-group"><a class="cv-p-text__link" href="https://x.com" rel="noopener noreferrer nofollow ugc" target="_blank">https://x.com<span class="cv-p-sr-only"> (opens in a new tab)</span></a><button type="button" class="cv-p-text__link-copy" aria-label="Copy https://x.com"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg></button></span></div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-01T12:00:00.000Z">12:00</time><span class="cv-p-bubble__status"><span class="cv-status-ticks cv-status-ticks--sent"><svg viewBox="0 0 20 12" width="15" height="9" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 6.5L5 10.5L14.5 1" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path></svg></span><span class="cv-p-sr-only">Sent</span></span></span></div><div class="cv-p-bubble"><span class="cv-p-bubble__author">Op</span><div class="cv-p-text">Hello</div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-01T12:05:00.000Z">12:05</time></span></div><div class="cv-p-day" role="separator" aria-label="Fri, 10/02"><time class="cv-p-day__label" dateTime="2026-10-02">Fri, 10/02</time></div><div class="cv-p-bubble cv-p-bubble--mine"><span class="cv-p-sr-only">Me</span><div class="cv-p-text">Hello</div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-02T12:05:00.000Z">12:05</time><span class="cv-p-bubble__status"><span class="cv-status-ticks cv-status-ticks--delivered"><svg viewBox="0 0 20 12" width="15" height="9" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 6.5L4.5 10L11 2" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path><path d="M6 6.5L9.5 10L19 1" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path></svg></span><span class="cv-p-sr-only">Delivered</span></span></span></div><div class="cv-p-bubble cv-p-bubble--mine"><span class="cv-p-sr-only">Me</span><div class="cv-p-text">W</div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-02T12:06:00.000Z">12:06</time><button type="button" class="cv-p-bubble__retry"><span class="cv-status-ticks cv-status-ticks--failed"><svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-triangle-alert lucide-alert-triangle" aria-hidden="true"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"></path><path d="M12 9v4"></path><path d="M12 17h.01"></path></svg></span><span>Failed — tap to retry</span></button></span><span class="cv-p-bubble__actions"><button type="button" class="cv-p-bubble__action">Edit</button><button type="button" class="cv-p-bubble__action">Discard</button></span></div></div><div class="cv-p-thread__latest"><button type="button" class="cv-p-thread__latest-button" aria-label="Scroll to latest message"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"></path></svg><span class="cv-p-thread__latest-badge" aria-hidden="true">2</span></button></div></div><div class="cv-p-thread__live" role="status" aria-live="polite">New messages</div><form class="cv-p-composer"><div class="cv-p-quick" role="group" aria-label="Quick replies"><button type="button" class="cv-p-chip">T</button></div><div class="cv-p-composer__row"><input class="cv-p-composer__file-input" type="file" multiple="" hidden=""/><button type="button" class="cv-p-composer__attach" aria-label="Attach"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-paperclip" aria-hidden="true" focusable="false"><path d="m16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551"></path></svg></button><textarea class="cv-p-composer__input" aria-label="Message" placeholder="Write a message…" rows="1">abc</textarea><button type="submit" class="cv-p-button cv-p-button--primary cv-p-composer__send" aria-label="Send"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-send" aria-hidden="true" focusable="false"><path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"></path><path d="m21.854 2.147-10.94 10.939"></path></svg></button></div></form></div>
@@ -0,0 +1 @@
1
+ <div class="cv-p cv-p-thread" aria-busy="false"><header class="cv-p-thread__header"><button type="button" class="cv-p-thread__back" aria-label="Back"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-left" aria-hidden="true" focusable="false"><path d="m12 19-7-7 7-7"></path><path d="M19 12H5"></path></svg></button><div class="cv-p-thread__heading"><h2 class="cv-p-thread__title"><span class="cv-p-thread__title-text">Invoice 4521</span></h2><div class="cv-p-thread__meta"><span class="cv-p-protocol cv-p-protocol--header"><span class="cv-p-protocol__code"><span class="cv-p-sr-only">Protocol </span>P-1</span><button type="button" class="cv-p-protocol__copy" aria-label="Copy protocol P-1"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg></button></span><div class="cv-p-sr-only" role="status" aria-live="polite"></div><span class="cv-p-channels"><span class="cv-p-sr-only">Channels in this conversation: </span><span class="cv-p-channel cv-p-channel--app"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-smartphone" aria-hidden="true"><rect width="14" height="20" x="5" y="2" rx="2" ry="2"></rect><path d="M12 18h.01"></path></svg><span class="cv-p-sr-only">App, </span></span><span class="cv-p-channel cv-p-channel--whatsapp"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-message-circle" aria-hidden="true"><path d="M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719"></path></svg><span class="cv-p-sr-only">WhatsApp</span></span></span></div></div></header><div class="cv-p-thread__scroll" tabindex="-1"><div class="cv-p-error" role="alert"><p class="cv-p-error__message">Could not load. Check your connection and try again.</p><button type="button" class="cv-p-button">Retry</button></div><button type="button" class="cv-p-thread__older">Load older messages</button><div class="cv-p-thread__messages"></div></div><div class="cv-p-thread__live" role="status" aria-live="polite">New messages</div><form class="cv-p-composer"><div class="cv-p-quick" role="group" aria-label="Quick replies"><button type="button" class="cv-p-chip">T</button></div><div class="cv-p-composer__row"><input class="cv-p-composer__file-input" type="file" multiple="" hidden=""/><button type="button" class="cv-p-composer__attach" aria-label="Attach"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-paperclip" aria-hidden="true" focusable="false"><path d="m16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551"></path></svg></button><textarea class="cv-p-composer__input" aria-label="Message" placeholder="Write a message…" rows="1">abc</textarea><button type="submit" class="cv-p-button cv-p-button--primary cv-p-composer__send" aria-label="Send"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-send" aria-hidden="true" focusable="false"><path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"></path><path d="m21.854 2.147-10.94 10.939"></path></svg></button></div></form></div>
@@ -0,0 +1 @@
1
+ <div class="cv-p cv-p-thread" aria-busy="true"><header class="cv-p-thread__header"><button type="button" class="cv-p-thread__back" aria-label="Back"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-left" aria-hidden="true" focusable="false"><path d="m12 19-7-7 7-7"></path><path d="M19 12H5"></path></svg></button><div class="cv-p-thread__heading"><h2 class="cv-p-thread__title"><span class="cv-p-thread__title-text">Invoice 4521</span></h2><div class="cv-p-thread__meta"><span class="cv-p-protocol cv-p-protocol--header"><span class="cv-p-protocol__code"><span class="cv-p-sr-only">Protocol </span>P-1</span><button type="button" class="cv-p-protocol__copy" aria-label="Copy protocol P-1"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg></button></span><div class="cv-p-sr-only" role="status" aria-live="polite"></div><span class="cv-p-channels"><span class="cv-p-sr-only">Channels in this conversation: </span><span class="cv-p-channel cv-p-channel--app"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-smartphone" aria-hidden="true"><rect width="14" height="20" x="5" y="2" rx="2" ry="2"></rect><path d="M12 18h.01"></path></svg><span class="cv-p-sr-only">App, </span></span><span class="cv-p-channel cv-p-channel--whatsapp"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-message-circle" aria-hidden="true"><path d="M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719"></path></svg><span class="cv-p-sr-only">WhatsApp</span></span></span></div></div></header><div class="cv-p-thread__scroll" tabindex="-1"><div class="cv-p-loading"><span class="cv-p-skeleton" aria-hidden="true"></span><span class="cv-p-skeleton" aria-hidden="true"></span><span class="cv-p-sr-only">Loading…</span></div><button type="button" class="cv-p-thread__older">Load older messages</button><div class="cv-p-thread__messages"></div></div><div class="cv-p-thread__live" role="status" aria-live="polite">New messages</div><form class="cv-p-composer"><div class="cv-p-quick" role="group" aria-label="Quick replies"><button type="button" class="cv-p-chip">T</button></div><div class="cv-p-composer__row"><input class="cv-p-composer__file-input" type="file" multiple="" hidden=""/><button type="button" class="cv-p-composer__attach" aria-label="Attach"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-paperclip" aria-hidden="true" focusable="false"><path d="m16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551"></path></svg></button><textarea class="cv-p-composer__input" aria-label="Message" placeholder="Write a message…" rows="1">abc</textarea><button type="submit" class="cv-p-button cv-p-button--primary cv-p-composer__send" aria-label="Send"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-send" aria-hidden="true" focusable="false"><path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"></path><path d="m21.854 2.147-10.94 10.939"></path></svg></button></div></form></div>
@@ -0,0 +1 @@
1
+ <div class="cv-p cv-p-thread" aria-busy="false"><header class="cv-p-thread__header"><button type="button" class="cv-p-thread__back" aria-label="Back"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-left" aria-hidden="true" focusable="false"><path d="m12 19-7-7 7-7"></path><path d="M19 12H5"></path></svg></button><div class="cv-p-thread__heading"><h2 class="cv-p-thread__title"><span class="cv-p-thread__title-text">Invoice 4521</span></h2><div class="cv-p-thread__meta"><span class="cv-p-protocol cv-p-protocol--header"><span class="cv-p-protocol__code"><span class="cv-p-sr-only">Protocol </span>P-1</span><button type="button" class="cv-p-protocol__copy" aria-label="Copy protocol P-1"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg></button></span><div class="cv-p-sr-only" role="status" aria-live="polite"></div><span class="cv-p-channels"><span class="cv-p-sr-only">Channels in this conversation: </span><span class="cv-p-channel cv-p-channel--app"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-smartphone" aria-hidden="true"><rect width="14" height="20" x="5" y="2" rx="2" ry="2"></rect><path d="M12 18h.01"></path></svg><span class="cv-p-sr-only">App, </span></span><span class="cv-p-channel cv-p-channel--whatsapp"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-message-circle" aria-hidden="true"><path d="M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719"></path></svg><span class="cv-p-sr-only">WhatsApp</span></span></span></div></div></header><div class="cv-p-thread__scroll" tabindex="-1"><div class="cv-p-thread__messages"><div class="cv-p-day" role="separator" aria-label="Thu, 10/01"><time class="cv-p-day__label" dateTime="2026-10-01">Thu, 10/01</time></div><div class="cv-p-bubble cv-p-bubble--mine"><span class="cv-p-sr-only">Me</span><div class="cv-p-text">*<strong>oi</strong>* <button type="button" class="cv-p-copyable cv-p-copyable--phone" aria-label="Copy 11 98888-7777">11 98888-7777</button> <button type="button" class="cv-p-copyable cv-p-copyable--email" aria-label="Copy a@b.com">a@b.com</button> <span class="cv-p-text__link-group"><a class="cv-p-text__link" href="https://x.com" rel="noopener noreferrer nofollow ugc" target="_blank">https://x.com<span class="cv-p-sr-only"> (opens in a new tab)</span></a><button type="button" class="cv-p-text__link-copy" aria-label="Copy https://x.com"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg></button></span></div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-01T12:00:00.000Z">12:00</time><span class="cv-p-bubble__status"><span class="cv-status-ticks cv-status-ticks--sent"><svg viewBox="0 0 20 12" width="15" height="9" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 6.5L5 10.5L14.5 1" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path></svg></span><span class="cv-p-sr-only">Sent</span></span></span></div><div class="cv-p-bubble"><span class="cv-p-bubble__author">Op</span><div class="cv-p-text">Hello</div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-01T12:05:00.000Z">12:05</time></span></div></div></div><div class="cv-p-thread__live" role="status" aria-live="polite"></div><form class="cv-p-composer"><div class="cv-p-composer__row"><input class="cv-p-composer__file-input" type="file" multiple="" hidden=""/><button type="button" class="cv-p-composer__attach" aria-label="Attach"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-paperclip" aria-hidden="true" focusable="false"><path d="m16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551"></path></svg></button><textarea class="cv-p-composer__input" aria-label="Message" placeholder="Write a message…" rows="1">abc</textarea><button type="submit" class="cv-p-button cv-p-button--primary cv-p-composer__send" aria-label="Send"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-send" aria-hidden="true" focusable="false"><path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"></path><path d="m21.854 2.147-10.94 10.939"></path></svg></button></div></form></div>
@@ -0,0 +1 @@
1
+ <div class="cv-p cv-p-thread" aria-busy="false"><header class="cv-p-thread__header"><button type="button" class="cv-p-thread__back" aria-label="Back"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-left" aria-hidden="true" focusable="false"><path d="m12 19-7-7 7-7"></path><path d="M19 12H5"></path></svg></button><div class="cv-p-thread__heading"><h2 class="cv-p-thread__title"><span class="cv-p-thread__title-text">Invoice 4521</span></h2><div class="cv-p-thread__meta"><span class="cv-p-protocol cv-p-protocol--header"><span class="cv-p-protocol__code"><span class="cv-p-sr-only">Protocol </span>P-1</span><button type="button" class="cv-p-protocol__copy" aria-label="Copy protocol P-1"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg></button></span><div class="cv-p-sr-only" role="status" aria-live="polite"></div><span class="cv-p-channels"><span class="cv-p-sr-only">Channels in this conversation: </span><span class="cv-p-channel cv-p-channel--app"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-smartphone" aria-hidden="true"><rect width="14" height="20" x="5" y="2" rx="2" ry="2"></rect><path d="M12 18h.01"></path></svg><span class="cv-p-sr-only">App, </span></span><span class="cv-p-channel cv-p-channel--whatsapp"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-message-circle" aria-hidden="true"><path d="M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719"></path></svg><span class="cv-p-sr-only">WhatsApp</span></span></span></div></div></header><div class="cv-p-thread__scroll" tabindex="-1"><button type="button" class="cv-p-thread__older">Load older messages</button><div class="cv-p-thread__messages"><div class="cv-p-day" role="separator" aria-label="Thu, 10/01"><time class="cv-p-day__label" dateTime="2026-10-01">Thu, 10/01</time></div><div class="cv-p-bubble cv-p-bubble--mine cv-p-bubble--no-tail"><span class="cv-p-sr-only">Me</span><div class="cv-p-text">*<strong>oi</strong>* <button type="button" class="cv-p-copyable cv-p-copyable--phone" aria-label="Copy 11 98888-7777">11 98888-7777</button> <button type="button" class="cv-p-copyable cv-p-copyable--email" aria-label="Copy a@b.com">a@b.com</button> <span class="cv-p-text__link-group"><a class="cv-p-text__link" href="https://x.com" rel="noopener noreferrer nofollow ugc" target="_blank">https://x.com<span class="cv-p-sr-only"> (opens in a new tab)</span></a><button type="button" class="cv-p-text__link-copy" aria-label="Copy https://x.com"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg></button></span></div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-01T12:00:00.000Z">12:00</time><span class="cv-p-bubble__status"><span class="cv-status-ticks cv-status-ticks--sent"><svg viewBox="0 0 20 12" width="15" height="9" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 6.5L5 10.5L14.5 1" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path></svg></span><span class="cv-p-sr-only">Sent</span></span></span></div><div class="cv-p-bubble cv-p-bubble--no-tail"><span class="cv-p-bubble__author">Op</span><div class="cv-p-text">Hello</div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-01T12:05:00.000Z">12:05</time></span></div><div class="cv-p-day" role="separator" aria-label="Fri, 10/02"><time class="cv-p-day__label" dateTime="2026-10-02">Fri, 10/02</time></div><div class="cv-p-bubble cv-p-bubble--mine cv-p-bubble--no-tail"><span class="cv-p-sr-only">Me</span><div class="cv-p-text">Hello</div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-02T12:05:00.000Z">12:05</time><span class="cv-p-bubble__status"><span class="cv-status-ticks cv-status-ticks--delivered"><svg viewBox="0 0 20 12" width="15" height="9" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 6.5L4.5 10L11 2" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path><path d="M6 6.5L9.5 10L19 1" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path></svg></span><span class="cv-p-sr-only">Delivered</span></span></span></div><div class="cv-p-bubble cv-p-bubble--mine cv-p-bubble--no-tail"><span class="cv-p-sr-only">Me</span><div class="cv-p-text">W</div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-02T12:06:00.000Z">12:06</time><button type="button" class="cv-p-bubble__retry"><span class="cv-status-ticks cv-status-ticks--failed"><svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-triangle-alert lucide-alert-triangle" aria-hidden="true"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"></path><path d="M12 9v4"></path><path d="M12 17h.01"></path></svg></span><span>Failed — tap to retry</span></button></span><span class="cv-p-bubble__actions"><button type="button" class="cv-p-bubble__action">Edit</button><button type="button" class="cv-p-bubble__action">Discard</button></span></div></div><div class="cv-p-thread__latest"><button type="button" class="cv-p-thread__latest-button" aria-label="Scroll to latest message"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"></path></svg><span class="cv-p-thread__latest-badge" aria-hidden="true">2</span></button></div></div><div class="cv-p-thread__live" role="status" aria-live="polite">New messages</div><form class="cv-p-composer"><div class="cv-p-quick" role="group" aria-label="Quick replies"><button type="button" class="cv-p-chip">T</button></div><div class="cv-p-composer__row"><input class="cv-p-composer__file-input" type="file" multiple="" hidden=""/><button type="button" class="cv-p-composer__attach" aria-label="Attach"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-paperclip" aria-hidden="true" focusable="false"><path d="m16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551"></path></svg></button><textarea class="cv-p-composer__input" aria-label="Message" placeholder="Write a message…" rows="1">abc</textarea><button type="submit" class="cv-p-button cv-p-button--primary cv-p-composer__send" aria-label="Send"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-send" aria-hidden="true" focusable="false"><path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"></path><path d="m21.854 2.147-10.94 10.939"></path></svg></button></div></form></div>
@@ -4,6 +4,7 @@ import type { ParticipantConversationSummary, ParticipantSubjectRef } from '@ada
4
4
 
5
5
  import type { ConversationChannel } from '../conversationChannel'
6
6
  import type { ParticipantConversationsApi, ParticipantPendingMessage } from './participantApi.types'
7
+ import type { ParticipantPerspective } from './participantPerspective'
7
8
  import { mergeParticipantMessages, type ParticipantTimelineItem } from './participantMessages'
8
9
  import type { ParticipantSendAction, ParticipantSendEntry } from './participantSendController'
9
10
  import { selectSendEntries, type ParticipantSendStates, type ParticipantSendStatesAction } from './participantSendStates'
@@ -18,6 +19,7 @@ export type UseParticipantConversationViewParams = {
18
19
  readonly pendingMessages?: readonly ParticipantPendingMessage[]
19
20
  readonly sendStates: ParticipantSendStates
20
21
  readonly dispatchSendStates: Dispatch<ParticipantSendStatesAction>
22
+ readonly perspective?: ParticipantPerspective
21
23
  }
22
24
 
23
25
  export type UseParticipantConversationViewResult = {
@@ -44,6 +46,7 @@ export function useParticipantConversationView(params: UseParticipantConversatio
44
46
  sendEntries,
45
47
  dispatchSend,
46
48
  hostPending: pendingMessages,
49
+ perspective: params.perspective,
47
50
  })
48
51
  const items = useMemo(
49
52
  () =>
@@ -17,9 +17,11 @@ import {
17
17
  type ParticipantSendEntry,
18
18
  } from './participantSendController'
19
19
  import { INITIAL_PARTICIPANT_THREAD_STATE, participantThreadReducer, type ParticipantThreadState } from './participantThreadState'
20
+ import type { ParticipantPerspective } from './participantPerspective'
20
21
  import { useParticipantMarkRead } from './useParticipantMarkRead'
21
22
  import { useParticipantRevalidation } from './useParticipantRevalidation'
22
23
  import { useParticipantSend, type UseParticipantSendResult } from './useParticipantSend'
24
+ import { useParticipantUnread } from './useParticipantUnread'
23
25
 
24
26
  const THREAD_PAGE_SIZE = 30
25
27
 
@@ -35,6 +37,8 @@ export type UseParticipantThreadParams = {
35
37
  readonly sendEntries: readonly ParticipantSendEntry[]
36
38
  readonly dispatchSend: (action: ParticipantSendAction) => void
37
39
  readonly hostPending?: readonly ParticipantPendingMessage[]
40
+ /** Present on a single-conversation screen: unread comes from the messages, not from `unreadCount`. */
41
+ readonly perspective?: ParticipantPerspective
38
42
  }
39
43
 
40
44
  export type UseParticipantThreadResult = ParticipantThreadState &
@@ -55,7 +59,7 @@ function isForSubject(event: ParticipantConversationEvent | undefined, subject:
55
59
  }
56
60
 
57
61
  export function useParticipantThread(params: UseParticipantThreadParams): UseParticipantThreadResult {
58
- const { api, subject, channel, unreadCount = 0, onMarkedRead, sendEntries, dispatchSend, hostPending = [] } = params
62
+ const { api, subject, channel, unreadCount = 0, onMarkedRead, sendEntries, dispatchSend, hostPending = [], perspective } = params
59
63
  const { subjectType, subjectId } = subject
60
64
  const stableSubject = useMemo<ParticipantSubjectRef>(() => ({ subjectType, subjectId }), [subjectType, subjectId])
61
65
  const [state, dispatch] = useReducer(participantThreadReducer, INITIAL_PARTICIPANT_THREAD_STATE)
@@ -109,7 +113,14 @@ export function useParticipantThread(params: UseParticipantThreadParams): UsePar
109
113
  dispatchSend({ type: 'reflected', knownClientMessageIds: collectKnownClientMessageIds({ messages: state.messages, hostPending }) })
110
114
  }, [sendEntries, state.messages, hostPending, dispatchSend])
111
115
 
112
- useParticipantMarkRead({ apiRef, subject: stableSubject, unreadCount, messageCount: state.messages.length, onMarkedRead })
116
+ const unread = useParticipantUnread({ messages: state.messages, perspective, reportedUnreadCount: unreadCount, onMarkedRead })
117
+ useParticipantMarkRead({
118
+ apiRef,
119
+ subject: stableSubject,
120
+ unreadCount: unread.unreadCount,
121
+ messageCount: state.messages.length,
122
+ onMarkedRead: unread.handleMarkedRead,
123
+ })
113
124
 
114
125
  const localPending = useMemo(() => toLocalPending(sendEntries), [sendEntries])
115
126
  return { ...state, localPending, confirmsRead, refresh, loadOlder, ...sending }
@@ -0,0 +1,112 @@
1
+ import { useMemo, type Dispatch, type MutableRefObject } from 'react'
2
+
3
+ import type { ParticipantConversationSummary, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
4
+
5
+ import type { ConversationChannel } from '../conversationChannel'
6
+ import type { ParticipantThreadProps } from './ParticipantThread'
7
+ import { bindAttachmentUrlResolver } from './bindAttachmentUrlResolver'
8
+ import type { ParticipantConversationsApi, ParticipantPendingMessage } from './participantApi.types'
9
+ import type { ParticipantDrafts } from './participantDrafts'
10
+ import type { ParticipantConversationsLabels } from './participantLabels'
11
+ import type { ParticipantPerspective } from './participantPerspective'
12
+ import { findFailedEntry, hasSendingEntry, type ParticipantSendAction } from './participantSendController'
13
+ import type { ParticipantSendStates, ParticipantSendStatesAction } from './participantSendStates'
14
+ import { useParticipantConversationView } from './useParticipantConversationView'
15
+ import { useParticipantDraft } from './useParticipantDraft'
16
+ import { useParticipantThread, type UseParticipantThreadResult } from './useParticipantThread'
17
+ import { useParticipantThreadScroll } from './useParticipantThreadScroll'
18
+
19
+ export type ParticipantThreadControllerParams = {
20
+ readonly api: ParticipantConversationsApi
21
+ readonly subject: ParticipantSubjectRef
22
+ readonly conversation: ParticipantConversationSummary
23
+ readonly labels: ParticipantConversationsLabels
24
+ /** Holds the immutable drafts of every subject; replaced, never mutated. */
25
+ readonly draftsRef: MutableRefObject<ParticipantDrafts>
26
+ readonly sendStates: ParticipantSendStates
27
+ readonly dispatchSendStates: Dispatch<ParticipantSendStatesAction>
28
+ readonly onMarkedRead: (subject: ParticipantSubjectRef) => void
29
+ readonly channel?: ConversationChannel
30
+ readonly locale?: string
31
+ readonly pendingMessages?: readonly ParticipantPendingMessage[]
32
+ readonly onRetryPending?: (clientMessageId: string) => void
33
+ readonly perspective?: ParticipantPerspective
34
+ }
35
+
36
+ /** Everything a conversation screen needs from the hooks; the caller adds only its own presentation props. */
37
+ export type ParticipantThreadCoreProps = Pick<
38
+ ParticipantThreadProps,
39
+ | 'conversation'
40
+ | 'items'
41
+ | 'hasMore'
42
+ | 'labels'
43
+ | 'resolveAttachmentUrl'
44
+ | 'draft'
45
+ | 'onSend'
46
+ | 'status'
47
+ | 'refresh'
48
+ | 'scroll'
49
+ | 'newMessagesCount'
50
+ | 'isSending'
51
+ | 'channel'
52
+ | 'locale'
53
+ | 'onLoadOlder'
54
+ | 'pendingActions'
55
+ | 'perspective'
56
+ >
57
+
58
+ type BuildPendingActionsParams = {
59
+ readonly thread: UseParticipantThreadResult
60
+ readonly dispatchSend: (action: ParticipantSendAction) => void
61
+ readonly handleEdit: (clientMessageId: string) => void
62
+ readonly onRetryPending: ParticipantThreadControllerParams['onRetryPending']
63
+ }
64
+
65
+ function buildPendingActions({ thread, dispatchSend, handleEdit, onRetryPending }: BuildPendingActionsParams): ParticipantThreadCoreProps['pendingActions'] {
66
+ return {
67
+ retryLocal: (clientMessageId) => void thread.retry(clientMessageId),
68
+ discardLocal: (clientMessageId) => dispatchSend({ type: 'discarded', clientMessageId }),
69
+ editLocal: handleEdit,
70
+ ...(onRetryPending ? { retryHost: onRetryPending } : {}),
71
+ }
72
+ }
73
+
74
+ export function useParticipantThreadController(params: ParticipantThreadControllerParams): ParticipantThreadCoreProps {
75
+ const { api, subject, conversation, draftsRef } = params
76
+ const { draft, setText, setFiles, takeForSend, restoreFromEntry } = useParticipantDraft(draftsRef, subject)
77
+ const { thread, items, sendEntries, dispatchSend } = useParticipantConversationView(params)
78
+ const resolveAttachmentUrl = useMemo(() => bindAttachmentUrlResolver(api), [api])
79
+ const { scroll, newMessagesCount } = useParticipantThreadScroll(items, params.perspective)
80
+
81
+ function handleSend(): void {
82
+ const content = takeForSend()
83
+ if (content) void thread.send(content)
84
+ }
85
+
86
+ function handleEdit(clientMessageId: string): void {
87
+ const entry = findFailedEntry(sendEntries, clientMessageId)
88
+ if (!entry) return
89
+ restoreFromEntry(entry)
90
+ dispatchSend({ type: 'discarded', clientMessageId })
91
+ }
92
+
93
+ return {
94
+ conversation,
95
+ items,
96
+ hasMore: thread.hasMore,
97
+ labels: params.labels,
98
+ resolveAttachmentUrl,
99
+ draft: { value: draft.text, onChange: setText, files: draft.files, onFilesChange: setFiles },
100
+ onSend: handleSend,
101
+ status: thread.status,
102
+ refresh: () => void thread.refresh(),
103
+ scroll,
104
+ newMessagesCount,
105
+ isSending: hasSendingEntry(sendEntries),
106
+ channel: params.channel,
107
+ locale: params.locale,
108
+ onLoadOlder: () => void thread.loadOlder(),
109
+ pendingActions: buildPendingActions({ thread, dispatchSend, handleEdit, onRetryPending: params.onRetryPending }),
110
+ perspective: params.perspective,
111
+ }
112
+ }
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
2
2
 
3
3
  import { collectServerMessageIds, countNewIncomingMessages } from './participantNewMessages'
4
4
  import type { ParticipantTimelineItem } from './participantMessages'
5
+ import type { ParticipantPerspective } from './participantPerspective'
5
6
  import { useScrollAwayState } from './useScrollAwayState'
6
7
  import { useStickToBottom, type ParticipantThreadScroll } from './useStickToBottom'
7
8
 
@@ -17,6 +18,7 @@ function keyOf(item: ParticipantTimelineItem | undefined): string | undefined {
17
18
 
18
19
  export function useParticipantThreadScroll(
19
20
  items: readonly ParticipantTimelineItem[],
21
+ perspective?: ParticipantPerspective,
20
22
  ): UseParticipantThreadScrollResult {
21
23
  const { scroll: base, isNearBottom } = useStickToBottom({
22
24
  firstKey: keyOf(items[0]),
@@ -30,5 +32,5 @@ export function useParticipantThreadScroll(
30
32
  if (isNearBottom) seenIds.current = collectServerMessageIds(items)
31
33
  }, [isNearBottom, items])
32
34
 
33
- return { scroll, newMessagesCount: isNearBottom ? 0 : countNewIncomingMessages(seenIds.current, items) }
35
+ return { scroll, newMessagesCount: isNearBottom ? 0 : countNewIncomingMessages(seenIds.current, items, perspective) }
34
36
  }