@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.
- package/README.md +231 -0
- package/dist/{ConversationSimulatorPanel-DnXElfY8.d.ts → ConversationSimulatorPanel-CrfFjiWh.d.ts} +2 -268
- package/dist/{chunk-PJNR6UMN.js → chunk-CBYEHPX6.js} +1 -63
- package/dist/{chunk-4P5KMWOI.js → chunk-FAQLC3TX.js} +327 -522
- package/dist/{chunk-HMCOTZAX.js → chunk-HOKNKXIT.js} +1 -1
- package/dist/chunk-MRVFFJEH.js +64 -0
- package/dist/chunk-O6457XHK.js +255 -0
- package/dist/chunk-OVFCLRQQ.js +84 -0
- package/dist/flows/index.js +5 -3
- package/dist/index.d.ts +21 -5
- package/dist/index.js +783 -807
- package/dist/participant/index.d.ts +186 -0
- package/dist/participant/index.js +1708 -0
- package/dist/preview/index.d.ts +4 -2
- package/dist/preview/index.js +4 -2
- package/dist/quickReply.types-B4gmZsSr.d.ts +274 -0
- package/dist/styles.css +689 -0
- package/package.json +8 -4
- package/src/ChannelIcon.tsx +2 -0
- package/src/MessageText.test.tsx +76 -0
- package/src/MessageText.tsx +28 -12
- package/src/MessageTextCopiedBadge.tsx +15 -0
- package/src/StatusTicks.test.tsx +51 -0
- package/src/StatusTicks.tsx +36 -6
- package/src/buildOutput.test.ts +41 -0
- package/src/channelCapability.test.ts +17 -0
- package/src/conversationChannel.test.ts +30 -1
- package/src/conversationChannel.ts +19 -1
- package/src/legacyChannels.golden.test.ts +89 -0
- package/src/legacyDefaults.golden.test.tsx +127 -0
- package/src/legacyStyles.fixture.css +1121 -0
- package/src/legacyStyles.golden.test.ts +121 -0
- package/src/messageTextCopyInteraction.test.ts +75 -0
- package/src/messageTextCopyInteraction.ts +37 -0
- package/src/participant/ParticipantAttachmentItem.tsx +77 -0
- package/src/participant/ParticipantChannelBadges.test.tsx +59 -0
- package/src/participant/ParticipantChannelBadges.tsx +75 -0
- package/src/participant/ParticipantComposer.test.tsx +97 -0
- package/src/participant/ParticipantComposer.tsx +167 -0
- package/src/participant/ParticipantConversations.test.tsx +90 -0
- package/src/participant/ParticipantConversations.tsx +49 -0
- package/src/participant/ParticipantConversationsScreen.tsx +78 -0
- package/src/participant/ParticipantInbox.test.tsx +244 -0
- package/src/participant/ParticipantInbox.tsx +149 -0
- package/src/participant/ParticipantInboxRow.tsx +75 -0
- package/src/participant/ParticipantInboxSearchField.tsx +34 -0
- package/src/participant/ParticipantLoadState.tsx +27 -0
- package/src/participant/ParticipantMessageBubble.test.tsx +167 -0
- package/src/participant/ParticipantMessageBubble.tsx +185 -0
- package/src/participant/ParticipantProtocolBadge.tsx +74 -0
- package/src/participant/ParticipantProtocolView.test.tsx +208 -0
- package/src/participant/ParticipantThread.test.tsx +149 -0
- package/src/participant/ParticipantThread.tsx +184 -0
- package/src/participant/ParticipantThreadScreen.tsx +135 -0
- package/src/participant/bindAttachmentUrlResolver.test.ts +25 -0
- package/src/participant/bindAttachmentUrlResolver.ts +7 -0
- package/src/participant/index.ts +26 -0
- package/src/participant/participant.types.ts +52 -0
- package/src/participant/participantApi.types.ts +59 -0
- package/src/participant/participantAttachmentUrl.test.ts +32 -0
- package/src/participant/participantAttachmentUrl.ts +10 -0
- package/src/participant/participantBubbleActions.test.ts +44 -0
- package/src/participant/participantBubbleActions.ts +40 -0
- package/src/participant/participantChannels.test.ts +42 -0
- package/src/participant/participantChannels.ts +33 -0
- package/src/participant/participantDrafts.test.ts +65 -0
- package/src/participant/participantDrafts.ts +50 -0
- package/src/participant/participantFileValidation.test.ts +86 -0
- package/src/participant/participantFileValidation.ts +72 -0
- package/src/participant/participantFixtures.test-helper.ts +41 -0
- package/src/participant/participantGrouping.test.ts +236 -0
- package/src/participant/participantGrouping.ts +90 -0
- package/src/participant/participantInboxState.test.ts +69 -0
- package/src/participant/participantInboxState.ts +74 -0
- package/src/participant/participantLabels.ts +115 -0
- package/src/participant/participantLoadView.test.ts +23 -0
- package/src/participant/participantLoadView.ts +21 -0
- package/src/participant/participantMessages.test.ts +128 -0
- package/src/participant/participantMessages.ts +73 -0
- package/src/participant/participantNewMessages.test.ts +54 -0
- package/src/participant/participantNewMessages.ts +29 -0
- package/src/participant/participantProtocol.test.ts +104 -0
- package/src/participant/participantProtocol.ts +52 -0
- package/src/participant/participantProtocolCopy.test.ts +45 -0
- package/src/participant/participantProtocolCopy.ts +32 -0
- package/src/participant/participantQuickReply.test.ts +23 -0
- package/src/participant/participantQuickReply.ts +6 -0
- package/src/participant/participantScroll.test.ts +69 -0
- package/src/participant/participantScroll.ts +43 -0
- package/src/participant/participantSendController.test.ts +201 -0
- package/src/participant/participantSendController.ts +134 -0
- package/src/participant/participantSendStates.ts +31 -0
- package/src/participant/participantTheme.ts +20 -0
- package/src/participant/participantThreadState.test.ts +75 -0
- package/src/participant/participantThreadState.ts +59 -0
- package/src/participant/shouldMarkRead.test.ts +24 -0
- package/src/participant/shouldMarkRead.ts +16 -0
- package/src/participant/useDocumentVisibility.ts +18 -0
- package/src/participant/useParticipantConversationView.ts +60 -0
- package/src/participant/useParticipantDraft.ts +45 -0
- package/src/participant/useParticipantInbox.ts +57 -0
- package/src/participant/useParticipantMarkRead.ts +26 -0
- package/src/participant/useParticipantRevalidation.ts +36 -0
- package/src/participant/useParticipantSend.ts +84 -0
- package/src/participant/useParticipantThread.ts +116 -0
- package/src/participant/useParticipantThreadScroll.ts +26 -0
- package/src/participant/useStickToBottom.ts +77 -0
- package/src/preview/ConversationSimulatorPanel.tsx +8 -0
- package/src/styles.css +178 -0
- 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
|
+
}
|