@adatechnology/conversations-ui 0.4.2 → 0.6.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 (110) hide show
  1. package/README.md +231 -0
  2. package/dist/{ConversationSimulatorPanel-DnXElfY8.d.ts → ConversationSimulatorPanel-CrfFjiWh.d.ts} +2 -268
  3. package/dist/{chunk-PJNR6UMN.js → chunk-CBYEHPX6.js} +1 -63
  4. package/dist/{chunk-4P5KMWOI.js → chunk-FAQLC3TX.js} +327 -522
  5. package/dist/{chunk-HMCOTZAX.js → chunk-HOKNKXIT.js} +1 -1
  6. package/dist/chunk-MRVFFJEH.js +64 -0
  7. package/dist/chunk-O6457XHK.js +255 -0
  8. package/dist/chunk-OVFCLRQQ.js +84 -0
  9. package/dist/flows/index.js +5 -3
  10. package/dist/index.d.ts +21 -5
  11. package/dist/index.js +783 -807
  12. package/dist/participant/index.d.ts +186 -0
  13. package/dist/participant/index.js +1708 -0
  14. package/dist/preview/index.d.ts +4 -2
  15. package/dist/preview/index.js +4 -2
  16. package/dist/quickReply.types-B4gmZsSr.d.ts +274 -0
  17. package/dist/styles.css +689 -0
  18. package/package.json +8 -4
  19. package/src/ChannelIcon.tsx +2 -0
  20. package/src/MessageText.test.tsx +76 -0
  21. package/src/MessageText.tsx +28 -12
  22. package/src/MessageTextCopiedBadge.tsx +15 -0
  23. package/src/StatusTicks.test.tsx +51 -0
  24. package/src/StatusTicks.tsx +36 -6
  25. package/src/buildOutput.test.ts +41 -0
  26. package/src/channelCapability.test.ts +17 -0
  27. package/src/conversationChannel.test.ts +30 -1
  28. package/src/conversationChannel.ts +19 -1
  29. package/src/legacyChannels.golden.test.ts +89 -0
  30. package/src/legacyDefaults.golden.test.tsx +127 -0
  31. package/src/legacyStyles.fixture.css +1121 -0
  32. package/src/legacyStyles.golden.test.ts +121 -0
  33. package/src/messageTextCopyInteraction.test.ts +75 -0
  34. package/src/messageTextCopyInteraction.ts +37 -0
  35. package/src/participant/ParticipantAttachmentItem.tsx +77 -0
  36. package/src/participant/ParticipantChannelBadges.test.tsx +59 -0
  37. package/src/participant/ParticipantChannelBadges.tsx +75 -0
  38. package/src/participant/ParticipantComposer.test.tsx +97 -0
  39. package/src/participant/ParticipantComposer.tsx +167 -0
  40. package/src/participant/ParticipantConversations.test.tsx +90 -0
  41. package/src/participant/ParticipantConversations.tsx +49 -0
  42. package/src/participant/ParticipantConversationsScreen.tsx +78 -0
  43. package/src/participant/ParticipantInbox.test.tsx +244 -0
  44. package/src/participant/ParticipantInbox.tsx +149 -0
  45. package/src/participant/ParticipantInboxRow.tsx +75 -0
  46. package/src/participant/ParticipantInboxSearchField.tsx +34 -0
  47. package/src/participant/ParticipantLoadState.tsx +27 -0
  48. package/src/participant/ParticipantMessageBubble.test.tsx +167 -0
  49. package/src/participant/ParticipantMessageBubble.tsx +185 -0
  50. package/src/participant/ParticipantProtocolBadge.tsx +74 -0
  51. package/src/participant/ParticipantProtocolView.test.tsx +208 -0
  52. package/src/participant/ParticipantThread.test.tsx +149 -0
  53. package/src/participant/ParticipantThread.tsx +184 -0
  54. package/src/participant/ParticipantThreadScreen.tsx +135 -0
  55. package/src/participant/bindAttachmentUrlResolver.test.ts +25 -0
  56. package/src/participant/bindAttachmentUrlResolver.ts +7 -0
  57. package/src/participant/index.ts +26 -0
  58. package/src/participant/participant.types.ts +52 -0
  59. package/src/participant/participantApi.types.ts +59 -0
  60. package/src/participant/participantAttachmentUrl.test.ts +32 -0
  61. package/src/participant/participantAttachmentUrl.ts +10 -0
  62. package/src/participant/participantBubbleActions.test.ts +44 -0
  63. package/src/participant/participantBubbleActions.ts +40 -0
  64. package/src/participant/participantChannels.test.ts +42 -0
  65. package/src/participant/participantChannels.ts +33 -0
  66. package/src/participant/participantDrafts.test.ts +65 -0
  67. package/src/participant/participantDrafts.ts +50 -0
  68. package/src/participant/participantFileValidation.test.ts +86 -0
  69. package/src/participant/participantFileValidation.ts +72 -0
  70. package/src/participant/participantFixtures.test-helper.ts +41 -0
  71. package/src/participant/participantGrouping.test.ts +236 -0
  72. package/src/participant/participantGrouping.ts +90 -0
  73. package/src/participant/participantInboxState.test.ts +69 -0
  74. package/src/participant/participantInboxState.ts +74 -0
  75. package/src/participant/participantLabels.ts +115 -0
  76. package/src/participant/participantLoadView.test.ts +23 -0
  77. package/src/participant/participantLoadView.ts +21 -0
  78. package/src/participant/participantMessages.test.ts +128 -0
  79. package/src/participant/participantMessages.ts +73 -0
  80. package/src/participant/participantNewMessages.test.ts +54 -0
  81. package/src/participant/participantNewMessages.ts +29 -0
  82. package/src/participant/participantProtocol.test.ts +104 -0
  83. package/src/participant/participantProtocol.ts +52 -0
  84. package/src/participant/participantProtocolCopy.test.ts +45 -0
  85. package/src/participant/participantProtocolCopy.ts +32 -0
  86. package/src/participant/participantQuickReply.test.ts +23 -0
  87. package/src/participant/participantQuickReply.ts +6 -0
  88. package/src/participant/participantScroll.test.ts +69 -0
  89. package/src/participant/participantScroll.ts +43 -0
  90. package/src/participant/participantSendController.test.ts +201 -0
  91. package/src/participant/participantSendController.ts +134 -0
  92. package/src/participant/participantSendStates.ts +31 -0
  93. package/src/participant/participantTheme.ts +20 -0
  94. package/src/participant/participantThreadState.test.ts +75 -0
  95. package/src/participant/participantThreadState.ts +59 -0
  96. package/src/participant/shouldMarkRead.test.ts +24 -0
  97. package/src/participant/shouldMarkRead.ts +16 -0
  98. package/src/participant/useDocumentVisibility.ts +18 -0
  99. package/src/participant/useParticipantConversationView.ts +60 -0
  100. package/src/participant/useParticipantDraft.ts +45 -0
  101. package/src/participant/useParticipantInbox.ts +57 -0
  102. package/src/participant/useParticipantMarkRead.ts +26 -0
  103. package/src/participant/useParticipantRevalidation.ts +36 -0
  104. package/src/participant/useParticipantSend.ts +84 -0
  105. package/src/participant/useParticipantThread.ts +116 -0
  106. package/src/participant/useParticipantThreadScroll.ts +26 -0
  107. package/src/participant/useStickToBottom.ts +77 -0
  108. package/src/preview/ConversationSimulatorPanel.tsx +8 -0
  109. package/src/styles.css +178 -0
  110. package/src/types.ts +3 -1
@@ -0,0 +1,149 @@
1
+ import type { ParticipantConversationSummary, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
2
+
3
+ import type {
4
+ ParticipantInboxFilter,
5
+ ParticipantInboxSection,
6
+ ParticipantInboxView,
7
+ ParticipantSubjectGroup,
8
+ ParticipantSubjectIconRenderer,
9
+ } from './participant.types'
10
+ import { type ParticipantConversationsLabels } from './participantLabels'
11
+ import { resolveLoadView, type ParticipantLoadStatus } from './participantLoadView'
12
+ import { ParticipantInboxRow } from './ParticipantInboxRow'
13
+ import { ParticipantInboxSearchField, type ParticipantInboxSearch } from './ParticipantInboxSearchField'
14
+ import { ParticipantLoadError, ParticipantLoading } from './ParticipantLoadState'
15
+
16
+ export type ParticipantInboxProps = {
17
+ readonly view: ParticipantInboxView
18
+ readonly subjectGroups: readonly ParticipantSubjectGroup[]
19
+ /** subjectType of the active filter, or 'all'. */
20
+ readonly filter: string
21
+ readonly onFilterChange: (filter: string) => void
22
+ readonly onSelect: (subject: ParticipantSubjectRef) => void
23
+ readonly labels: ParticipantConversationsLabels
24
+ readonly status: ParticipantLoadStatus
25
+ readonly refresh: () => void
26
+ readonly hasMore: boolean
27
+ readonly loadMore: () => void
28
+ readonly locale?: string
29
+ /** Absent = no search field. The host narrows the view with the query; the field only edits it. */
30
+ readonly search?: ParticipantInboxSearch
31
+ /** Absent, or returning null/undefined, = the subject group icon. */
32
+ readonly renderSubjectIcon?: ParticipantSubjectIconRenderer
33
+ }
34
+
35
+ const ALL_FILTER = 'all'
36
+
37
+ function resolveNoResultsLabel({ view, hasMore, labels }: ParticipantInboxProps): string {
38
+ if (view.hasMatchesOutsideFilter) return labels.noResultsInFilter
39
+ return hasMore ? labels.noResultsLoadedOnly : labels.noResults
40
+ }
41
+
42
+ function sectionTitle(section: ParticipantInboxSection, props: ParticipantInboxProps): string {
43
+ if (section.key === 'awaiting') return props.labels.sectionAwaiting
44
+ if (section.key === 'closed') return props.labels.sectionClosed
45
+ if (section.key === 'other') return props.labels.sectionOther
46
+ return props.subjectGroups.find((group) => group.subjectType === section.key)?.label ?? section.key
47
+ }
48
+
49
+ type FiltersProps = {
50
+ readonly filters: readonly ParticipantInboxFilter[]
51
+ readonly active: string
52
+ readonly labels: ParticipantConversationsLabels
53
+ readonly onChange: (filter: string) => void
54
+ }
55
+
56
+ function Filters({ filters, active, labels, onChange }: FiltersProps) {
57
+ return (
58
+ <div className="cv-p-filters" role="group" aria-label={labels.filtersGroup}>
59
+ {filters.map((filter) => (
60
+ <button
61
+ key={filter.subjectType}
62
+ type="button"
63
+ className="cv-p-chip"
64
+ aria-pressed={filter.subjectType === active}
65
+ onClick={() => onChange(filter.subjectType)}
66
+ >
67
+ {filter.subjectType === ALL_FILTER ? labels.filterAll : filter.label}
68
+ {filter.unreadCount > 0 ? <b className="cv-p-chip__count">{filter.unreadCount}</b> : null}
69
+ </button>
70
+ ))}
71
+ </div>
72
+ )
73
+ }
74
+
75
+ function SectionHeading({ title, count }: { readonly title: string; readonly count: number }) {
76
+ return (
77
+ <>
78
+ <span className="cv-p-section__title">{title}</span>
79
+ <span className="cv-p-section__count">{count}</span>
80
+ </>
81
+ )
82
+ }
83
+
84
+ function Section({
85
+ section,
86
+ props,
87
+ }: {
88
+ readonly section: ParticipantInboxSection
89
+ readonly props: ParticipantInboxProps
90
+ }) {
91
+ const rows = section.conversations.map((conversation) => (
92
+ <ParticipantInboxRow
93
+ key={`${conversation.subjectType}:${conversation.subjectId}`}
94
+ conversation={conversation}
95
+ group={props.subjectGroups.find((group) => group.subjectType === conversation.subjectType)}
96
+ labels={props.labels}
97
+ locale={props.locale}
98
+ onSelect={props.onSelect}
99
+ renderSubjectIcon={props.renderSubjectIcon}
100
+ />
101
+ ))
102
+ const heading = <SectionHeading title={sectionTitle(section, props)} count={section.conversations.length} />
103
+
104
+ if (section.key === 'closed') {
105
+ return (
106
+ <details className="cv-p-section cv-p-section--closed">
107
+ <summary className="cv-p-section__heading">{heading}</summary>
108
+ {rows}
109
+ </details>
110
+ )
111
+ }
112
+ const modifier = section.key === 'awaiting' ? ' cv-p-section--awaiting' : ''
113
+ return (
114
+ <section className={`cv-p-section${modifier}`}>
115
+ <h3 className="cv-p-section__heading">{heading}</h3>
116
+ {rows}
117
+ </section>
118
+ )
119
+ }
120
+
121
+ export function ParticipantInbox(props: ParticipantInboxProps) {
122
+ const { view, labels, filter, onFilterChange } = props
123
+ const isSearching = (props.search?.value.trim() ?? '') !== ''
124
+ const loadView = resolveLoadView({ status: props.status, hasItems: view.sections.length > 0 || isSearching })
125
+ const hasNoResults = isSearching && view.sections.length === 0 && props.status === 'ready'
126
+ const noResultsLabel = resolveNoResultsLabel(props)
127
+
128
+ return (
129
+ <div className="cv-p cv-p-inbox" aria-busy={loadView.isLoading}>
130
+ <h2 className="cv-p-inbox__title">{labels.inboxTitle}</h2>
131
+ {view.showFilters ? (
132
+ <Filters filters={view.filters} active={view.activeFilter} labels={labels} onChange={onFilterChange} />
133
+ ) : null}
134
+ {props.search?.isVisible ? <ParticipantInboxSearchField search={props.search} labels={labels} /> : null}
135
+ {loadView.isLoading ? <ParticipantLoading labels={labels} /> : null}
136
+ {loadView.hasError ? <ParticipantLoadError labels={labels} onRetry={props.refresh} /> : null}
137
+ {loadView.isEmpty ? <p className="cv-p-empty">{labels.emptyInbox}</p> : null}
138
+ {hasNoResults ? <p className="cv-p-empty">{noResultsLabel}</p> : null}
139
+ {view.sections.map((section) => (
140
+ <Section key={section.key} section={section} props={props} />
141
+ ))}
142
+ {props.hasMore ? (
143
+ <button type="button" className="cv-p-button cv-p-inbox__more" onClick={props.loadMore}>
144
+ {labels.loadMore}
145
+ </button>
146
+ ) : null}
147
+ </div>
148
+ )
149
+ }
@@ -0,0 +1,75 @@
1
+ import type { ParticipantConversationSummary, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
2
+
3
+ import { formatTimestamp, isSameDay } from '../lib/format'
4
+ import type { ParticipantSubjectGroup, ParticipantSubjectIconRenderer } from './participant.types'
5
+ import { formatParticipantLabel, type ParticipantConversationsLabels } from './participantLabels'
6
+ import { ParticipantChannelBadges } from './ParticipantChannelBadges'
7
+
8
+ export type ParticipantInboxRowProps = {
9
+ readonly conversation: ParticipantConversationSummary
10
+ readonly group: ParticipantSubjectGroup | undefined
11
+ readonly labels: ParticipantConversationsLabels
12
+ readonly locale?: string
13
+ readonly onSelect: (subject: ParticipantSubjectRef) => void
14
+ readonly renderSubjectIcon?: ParticipantSubjectIconRenderer
15
+ }
16
+
17
+ function formatRowTime(iso: string, locale: string | undefined): string {
18
+ const date = new Date(iso)
19
+ if (isSameDay(date, new Date())) return formatTimestamp(iso)
20
+ return date.toLocaleDateString(locale, { day: '2-digit', month: '2-digit' })
21
+ }
22
+
23
+ function RowIcon({ props, kind }: { readonly props: ParticipantInboxRowProps; readonly kind: string }) {
24
+ const custom = props.renderSubjectIcon?.(props.conversation)
25
+ if (custom !== null && custom !== undefined && custom !== false && custom !== '') {
26
+ return (
27
+ <span className="cv-p-row__icon cv-p-inbox__icon" aria-hidden="true">
28
+ {custom}
29
+ </span>
30
+ )
31
+ }
32
+ return (
33
+ <span className="cv-p-row__icon" aria-hidden="true">
34
+ {props.group?.icon ?? kind.slice(0, 2)}
35
+ </span>
36
+ )
37
+ }
38
+
39
+ export function ParticipantInboxRow(props: ParticipantInboxRowProps) {
40
+ const { conversation, group, labels, locale, onSelect } = props
41
+ const kind = group?.label ?? conversation.subjectType
42
+ const className = conversation.awaitingParticipant ? 'cv-p-row cv-p-row--awaiting' : 'cv-p-row'
43
+ const { subjectType, subjectId } = conversation
44
+
45
+ return (
46
+ <button type="button" className={className} onClick={() => onSelect({ subjectType, subjectId })}>
47
+ <RowIcon props={props} kind={kind} />
48
+ <span className="cv-p-row__body">
49
+ <span className="cv-p-row__kind">{kind}</span>
50
+ <span className="cv-p-row__title">{conversation.subjectLabel}</span>
51
+ <span className="cv-p-row__tags">
52
+ {conversation.protocol ? (
53
+ <span className="cv-p-protocol">
54
+ <span className="cv-p-sr-only">{labels.protocolPrefix} </span>
55
+ {conversation.protocol}
56
+ </span>
57
+ ) : null}
58
+ <ParticipantChannelBadges channels={conversation.channels} labels={labels} variant="inline" />
59
+ </span>
60
+ {conversation.lastMessagePreview ? (
61
+ <span className="cv-p-row__preview">{conversation.lastMessagePreview}</span>
62
+ ) : null}
63
+ </span>
64
+ <span className="cv-p-row__meta">
65
+ {conversation.lastMessageAt ? <span>{formatRowTime(conversation.lastMessageAt, locale)}</span> : null}
66
+ {conversation.unreadCount > 0 ? (
67
+ <span className="cv-p-row__unread">
68
+ <span aria-hidden="true">{conversation.unreadCount}</span>
69
+ <span className="cv-p-sr-only">{formatParticipantLabel(labels.unreadCount, conversation.unreadCount)}</span>
70
+ </span>
71
+ ) : null}
72
+ </span>
73
+ </button>
74
+ )
75
+ }
@@ -0,0 +1,34 @@
1
+ import { useId } from 'react'
2
+
3
+ import type { ParticipantConversationsLabels } from './participantLabels'
4
+
5
+ export type ParticipantInboxSearch = {
6
+ readonly value: string
7
+ readonly onChange: (value: string) => void
8
+ readonly isVisible: boolean
9
+ }
10
+
11
+ export type ParticipantInboxSearchFieldProps = {
12
+ readonly search: ParticipantInboxSearch
13
+ readonly labels: ParticipantConversationsLabels
14
+ }
15
+
16
+ export function ParticipantInboxSearchField({ search, labels }: ParticipantInboxSearchFieldProps) {
17
+ const inputId = useId()
18
+ return (
19
+ <div className="cv-p-search">
20
+ <label className="cv-p-sr-only" htmlFor={inputId}>
21
+ {labels.searchLabel}
22
+ </label>
23
+ <input
24
+ id={inputId}
25
+ type="search"
26
+ className="cv-p-search__input"
27
+ value={search.value}
28
+ placeholder={labels.searchPlaceholder}
29
+ autoComplete="off"
30
+ onChange={(event) => search.onChange(event.target.value)}
31
+ />
32
+ </div>
33
+ )
34
+ }
@@ -0,0 +1,27 @@
1
+ import type { ParticipantConversationsLabels } from './participantLabels'
2
+
3
+ export type ParticipantLoadErrorProps = {
4
+ readonly labels: ParticipantConversationsLabels
5
+ readonly onRetry: () => void
6
+ }
7
+
8
+ export function ParticipantLoadError({ labels, onRetry }: ParticipantLoadErrorProps) {
9
+ return (
10
+ <div className="cv-p-error" role="alert">
11
+ <p className="cv-p-error__message">{labels.loadError}</p>
12
+ <button type="button" className="cv-p-button" onClick={onRetry}>
13
+ {labels.retry}
14
+ </button>
15
+ </div>
16
+ )
17
+ }
18
+
19
+ export function ParticipantLoading({ labels }: { readonly labels: ParticipantConversationsLabels }) {
20
+ return (
21
+ <div className="cv-p-loading">
22
+ <span className="cv-p-skeleton" aria-hidden="true" />
23
+ <span className="cv-p-skeleton" aria-hidden="true" />
24
+ <span className="cv-p-sr-only">{labels.loading}</span>
25
+ </div>
26
+ )
27
+ }
@@ -0,0 +1,167 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
5
+ import { findForeignClassTokens, findUtilityClassTokens, buildMessage } from './participantFixtures.test-helper'
6
+ import type { ParticipantTimelineItem } from './participantMessages'
7
+ import { ParticipantMessageBubble } from './ParticipantMessageBubble'
8
+
9
+ const LABELS = DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS
10
+ const resolveAttachmentUrl = async (): Promise<string> => 'https://files.example/a.png'
11
+
12
+ function renderServer(message = buildMessage({ id: 'm1' }), extra: { onRetry?: () => void; confirmsRead?: boolean } = {}): string {
13
+ const item: ParticipantTimelineItem = { kind: 'server', message }
14
+ return renderToStaticMarkup(
15
+ <ParticipantMessageBubble
16
+ item={item}
17
+ labels={LABELS}
18
+ confirmsRead={extra.confirmsRead ?? true}
19
+ resolveAttachmentUrl={resolveAttachmentUrl}
20
+ onRetry={extra.onRetry}
21
+ />,
22
+ )
23
+ }
24
+
25
+ function renderPending(
26
+ displayState: 'sending' | 'queued' | 'failed',
27
+ onRetry?: () => void,
28
+ extra: { onDiscard?: () => void; onEdit?: () => void } = {},
29
+ ): string {
30
+ const item: ParticipantTimelineItem = {
31
+ kind: 'pending',
32
+ origin: 'host',
33
+ displayState,
34
+ pending: {
35
+ clientMessageId: 'c1',
36
+ subject: { subjectType: 'invoice', subjectId: '1' },
37
+ text: 'Draft text',
38
+ createdAt: '2026-10-01T10:00:00.000Z',
39
+ state: displayState === 'sending' ? 'failed' : displayState,
40
+ },
41
+ }
42
+ return renderToStaticMarkup(
43
+ <ParticipantMessageBubble
44
+ item={item}
45
+ labels={LABELS}
46
+ confirmsRead
47
+ resolveAttachmentUrl={resolveAttachmentUrl}
48
+ onRetry={onRetry}
49
+ onDiscard={extra.onDiscard}
50
+ onEdit={extra.onEdit}
51
+ />,
52
+ )
53
+ }
54
+
55
+ describe('ParticipantMessageBubble', () => {
56
+ it('puts an inbound message on the own side and an outbound one on the other', () => {
57
+ expect(renderServer(buildMessage({ id: 'a', direction: 'inbound' }))).toContain('cv-p-bubble--mine')
58
+ expect(renderServer(buildMessage({ id: 'b', direction: 'outbound' }))).not.toContain('cv-p-bubble--mine')
59
+ })
60
+
61
+ it('shows the author name only when the message is not mine', () => {
62
+ const theirs = renderServer(buildMessage({ id: 'a', direction: 'outbound', authorName: 'Marina' }))
63
+ const mine = renderServer(buildMessage({ id: 'b', direction: 'inbound', authorName: 'Marina' }))
64
+
65
+ expect(theirs).toContain('Marina')
66
+ expect(mine).not.toContain('Marina')
67
+ })
68
+
69
+ it('renders a time element with the ISO dateTime', () => {
70
+ expect(renderServer()).toMatch(/<time[^>]*dateTime="2026-10-01T10:00:00.000Z"/)
71
+ })
72
+
73
+ it.each([
74
+ ['sent', 'cv-status-ticks--sent', 'Sent'],
75
+ ['delivered', 'cv-status-ticks--delivered', 'Delivered'],
76
+ ['read', 'cv-status-ticks--read', 'Read'],
77
+ ] as const)('shows the %s status on my message with accessible text', (status, ticksClass, text) => {
78
+ const markup = renderServer(buildMessage({ id: 'a', direction: 'inbound', status }))
79
+
80
+ expect(markup).toContain(ticksClass)
81
+ expect(markup).toContain(text)
82
+ })
83
+
84
+ it('downgrades read to delivered when the channel does not confirm reads', () => {
85
+ const markup = renderServer(buildMessage({ id: 'a', direction: 'inbound', status: 'read' }), { confirmsRead: false })
86
+
87
+ expect(markup).toContain('cv-status-ticks--delivered')
88
+ expect(markup).not.toContain('cv-status-ticks--read')
89
+ })
90
+
91
+ it('shows no status on the other side', () => {
92
+ expect(renderServer(buildMessage({ id: 'a', direction: 'outbound', status: 'read' }))).not.toContain('cv-status-ticks')
93
+ })
94
+
95
+ it('renders pending sending and queued with their texts', () => {
96
+ expect(renderPending('sending')).toContain('Sending')
97
+ const queued = renderPending('queued')
98
+ expect(queued).toContain('Queued — sends when back online')
99
+ expect(queued).toContain('cv-status-ticks--queued')
100
+ })
101
+
102
+ it('renders the retry button only for failed with onRetry', () => {
103
+ const withRetry = renderPending('failed', () => undefined)
104
+ const withoutRetry = renderPending('failed')
105
+
106
+ expect(withRetry).toContain('<button')
107
+ expect(withRetry).toContain('Failed — tap to retry')
108
+ expect(withRetry).toContain('cv-status-ticks--failed')
109
+ expect(findUtilityClassTokens(withRetry)).toEqual([])
110
+ expect(withoutRetry).not.toContain('<button')
111
+ expect(withoutRetry).toContain('cv-status-ticks--failed')
112
+ })
113
+
114
+ it('shows discard and edit on a failed pending only when the handlers exist', () => {
115
+ const none = renderPending('failed')
116
+ const both = renderPending('failed', () => undefined, { onDiscard: () => undefined, onEdit: () => undefined })
117
+ const notFailed = renderPending('queued', undefined, { onDiscard: () => undefined, onEdit: () => undefined })
118
+
119
+ expect(none).not.toContain('cv-p-bubble__action')
120
+ expect(both).toContain('>Discard<')
121
+ expect(both).toContain('>Edit<')
122
+ expect(notFailed).not.toContain('cv-p-bubble__action')
123
+ })
124
+
125
+ it('a failed server message shows only "Failed", with no button', () => {
126
+ const markup = renderServer(buildMessage({ id: 'a', direction: 'inbound', status: 'failed' }))
127
+
128
+ expect(markup).toContain('Failed')
129
+ expect(markup).not.toContain('<button')
130
+ })
131
+
132
+ it('renders attachments by kind: audio controls, image alt, document link', () => {
133
+ const message = buildMessage({
134
+ id: 'a',
135
+ text: null,
136
+ attachments: [
137
+ { id: '1', kind: 'audio', filename: 'voice.ogg', mimeType: 'audio/ogg', sizeBytes: 10 },
138
+ { id: '2', kind: 'image', filename: 'photo.png', mimeType: 'image/png', sizeBytes: 10 },
139
+ { id: '3', kind: 'document', filename: 'note.pdf', mimeType: 'application/pdf', sizeBytes: 2048 },
140
+ ],
141
+ })
142
+ const markup = renderServer(message)
143
+
144
+ expect(markup).toContain('photo.png')
145
+ expect(markup).toContain('note.pdf')
146
+ expect(markup).toContain('voice.ogg')
147
+ expect(markup).toContain('cv-p-attachment--image')
148
+ expect(markup).toContain('cv-p-attachment--audio')
149
+ expect(markup).toContain('cv-p-attachment--document')
150
+ })
151
+
152
+ it('o texto do participante não é botão nem copia ao toque', () => {
153
+ const markup = renderServer(buildMessage({ id: 'a', direction: 'inbound' }))
154
+
155
+ expect(markup).toContain('class="cv-message-text"')
156
+ expect(markup).not.toContain('role="button"')
157
+ expect(markup).not.toContain('cv-message-text--copyable')
158
+ })
159
+
160
+ it('uses only cv-p-* classes and no Tailwind utilities', () => {
161
+ const markup = renderServer(buildMessage({ id: 'a', direction: 'inbound', status: 'read' }))
162
+
163
+ expect(findUtilityClassTokens(markup)).toEqual([])
164
+ expect(findForeignClassTokens(markup)).toEqual([])
165
+ expect(markup).toContain('cv-p-')
166
+ })
167
+ })
@@ -0,0 +1,185 @@
1
+ import type { MessageDeliveryStatus, ParticipantAttachment } from '@adatechnology/conversation-contracts'
2
+
3
+ import { MessageText } from '../MessageText'
4
+ import { StatusTicks } from '../StatusTicks'
5
+ import { formatTimestamp } from '../lib/format'
6
+ import { ParticipantAttachmentItem, type ResolveParticipantAttachmentUrl } from './ParticipantAttachmentItem'
7
+ import type { ParticipantConversationsLabels } from './participantLabels'
8
+ import {
9
+ isOwnMessage,
10
+ resolveOwnMessageStatus,
11
+ type ParticipantOwnMessageStatus,
12
+ type ParticipantPendingDisplayState,
13
+ type ParticipantTimelineItem,
14
+ } from './participantMessages'
15
+
16
+ export type ParticipantMessageBubbleProps = {
17
+ readonly item: ParticipantTimelineItem
18
+ readonly labels: ParticipantConversationsLabels
19
+ readonly confirmsRead: boolean
20
+ readonly resolveAttachmentUrl: ResolveParticipantAttachmentUrl
21
+ /** Absent means no retry button, even when the message failed. */
22
+ readonly onRetry?: () => void
23
+ /** Absent means no discard button. */
24
+ readonly onDiscard?: () => void
25
+ /** Absent means no edit button. */
26
+ readonly onEdit?: () => void
27
+ }
28
+
29
+ type BubbleContent = {
30
+ readonly isMine: boolean
31
+ readonly authorName?: string
32
+ readonly text?: string
33
+ readonly createdAt: string
34
+ readonly attachments: readonly ParticipantAttachment[]
35
+ readonly pendingFilenames: readonly string[]
36
+ readonly displayState?: ParticipantPendingDisplayState
37
+ readonly serverStatus?: MessageDeliveryStatus
38
+ }
39
+
40
+ function describeItem(item: ParticipantTimelineItem): BubbleContent {
41
+ if (item.kind === 'server') {
42
+ const { message } = item
43
+ return {
44
+ isMine: isOwnMessage(message),
45
+ authorName: message.authorName ?? undefined,
46
+ text: message.text ?? undefined,
47
+ createdAt: message.createdAt,
48
+ attachments: message.attachments,
49
+ pendingFilenames: [],
50
+ serverStatus: message.status,
51
+ }
52
+ }
53
+ return {
54
+ isMine: true,
55
+ text: item.pending.text,
56
+ createdAt: item.pending.createdAt,
57
+ attachments: [],
58
+ pendingFilenames: (item.pending.attachments ?? []).map((attachment) => attachment.filename),
59
+ displayState: item.displayState,
60
+ }
61
+ }
62
+
63
+ function statusText(status: ParticipantOwnMessageStatus, labels: ParticipantConversationsLabels): string {
64
+ const byStatus: Record<ParticipantOwnMessageStatus, string> = {
65
+ sending: labels.statusSending,
66
+ queued: labels.statusQueued,
67
+ sent: labels.statusSent,
68
+ delivered: labels.statusDelivered,
69
+ read: labels.statusRead,
70
+ failed: labels.statusFailed,
71
+ }
72
+ return byStatus[status]
73
+ }
74
+
75
+ type OwnStatusProps = {
76
+ readonly status: ParticipantOwnMessageStatus
77
+ readonly labels: ParticipantConversationsLabels
78
+ readonly onRetry?: () => void
79
+ }
80
+
81
+ function OwnStatus({ status, labels, onRetry }: OwnStatusProps) {
82
+ const ticksStatus = status === 'sending' ? 'queued' : status
83
+ const isVisibleText = status === 'sending' || status === 'queued' || status === 'failed'
84
+
85
+ if (status === 'failed' && onRetry) {
86
+ return (
87
+ <button type="button" className="cv-p-bubble__retry" onClick={onRetry}>
88
+ <StatusTicks status="failed" appearance="stylesheet" />
89
+ <span>{labels.statusFailed}</span>
90
+ </button>
91
+ )
92
+ }
93
+
94
+ const text = status === 'failed' ? labels.statusFailedShort : statusText(status, labels)
95
+ return (
96
+ <span className="cv-p-bubble__status">
97
+ <StatusTicks status={ticksStatus} appearance="stylesheet" />
98
+ <span className={isVisibleText ? 'cv-p-bubble__status-text' : 'cv-p-sr-only'}>{text}</span>
99
+ </span>
100
+ )
101
+ }
102
+
103
+ type FailedActionsProps = {
104
+ readonly labels: ParticipantConversationsLabels
105
+ readonly onDiscard?: () => void
106
+ readonly onEdit?: () => void
107
+ }
108
+
109
+ function FailedActions({ labels, onDiscard, onEdit }: FailedActionsProps) {
110
+ if (!onDiscard && !onEdit) return null
111
+ return (
112
+ <span className="cv-p-bubble__actions">
113
+ {onEdit ? (
114
+ <button type="button" className="cv-p-bubble__action" onClick={onEdit}>
115
+ {labels.edit}
116
+ </button>
117
+ ) : null}
118
+ {onDiscard ? (
119
+ <button type="button" className="cv-p-bubble__action" onClick={onDiscard}>
120
+ {labels.discard}
121
+ </button>
122
+ ) : null}
123
+ </span>
124
+ )
125
+ }
126
+
127
+ function toTextPayload(text: string, createdAt: string, isMine: boolean) {
128
+ return {
129
+ id: createdAt,
130
+ type: 'text' as const,
131
+ content: text,
132
+ direction: isMine ? ('inbound' as const) : ('outbound' as const),
133
+ sender: isMine ? ('customer' as const) : ('agent' as const),
134
+ timestamp: createdAt,
135
+ }
136
+ }
137
+
138
+ export function ParticipantMessageBubble({
139
+ item,
140
+ labels,
141
+ confirmsRead,
142
+ resolveAttachmentUrl,
143
+ onRetry,
144
+ onDiscard,
145
+ onEdit,
146
+ }: ParticipantMessageBubbleProps) {
147
+ const content = describeItem(item)
148
+ const { isMine } = content
149
+ const ownStatus = isMine
150
+ ? resolveOwnMessageStatus({ displayState: content.displayState, serverStatus: content.serverStatus, confirmsRead })
151
+ : undefined
152
+
153
+ return (
154
+ <div className={isMine ? 'cv-p-bubble cv-p-bubble--mine' : 'cv-p-bubble'}>
155
+ {isMine ? (
156
+ <span className="cv-p-sr-only">{labels.me}</span>
157
+ ) : content.authorName ? (
158
+ <span className="cv-p-bubble__author">{content.authorName}</span>
159
+ ) : null}
160
+ {content.text ? (
161
+ <MessageText message={toTextPayload(content.text, content.createdAt, isMine)} copyOnClick={false} appearance="stylesheet" />
162
+ ) : null}
163
+ {content.attachments.map((attachment) => (
164
+ <ParticipantAttachmentItem
165
+ key={attachment.id}
166
+ attachment={attachment}
167
+ resolveAttachmentUrl={resolveAttachmentUrl}
168
+ labels={labels}
169
+ />
170
+ ))}
171
+ {content.pendingFilenames.map((filename, index) => (
172
+ <span key={`${filename}-${index}`} className="cv-p-attachment cv-p-attachment--pending">
173
+ {filename}
174
+ </span>
175
+ ))}
176
+ <span className="cv-p-bubble__meta">
177
+ <time className="cv-p-bubble__time" dateTime={content.createdAt}>
178
+ {formatTimestamp(content.createdAt)}
179
+ </time>
180
+ {ownStatus ? <OwnStatus status={ownStatus} labels={labels} onRetry={onRetry} /> : null}
181
+ </span>
182
+ {ownStatus === 'failed' ? <FailedActions labels={labels} onDiscard={onDiscard} onEdit={onEdit} /> : null}
183
+ </div>
184
+ )
185
+ }