@adatechnology/conversations-ui 0.7.0 → 0.9.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 +96 -1
- package/dist/{ConversationSimulatorPanel-CrfFjiWh.d.ts → ConversationSimulatorPanel-q-wVgBCN.d.ts} +1 -1
- package/dist/{chunk-HMCOTZAX.js → chunk-NSOAUYNE.js} +33 -28
- package/dist/chunk-ZSOD5AQT.js +19 -0
- package/dist/flows/index.js +4 -2
- package/dist/index.d.ts +4 -4
- package/dist/index.js +5 -3
- package/dist/participant/index.d.ts +48 -3
- package/dist/participant/index.js +782 -347
- package/dist/preview/index.d.ts +3 -3
- package/dist/{quickReply.types-B4gmZsSr.d.ts → quickReply.types-DzWH3NYV.d.ts} +1 -1
- package/dist/styles.css +173 -52
- package/package.json +2 -1
- package/src/Tooltip.tsx +5 -12
- package/src/TooltipDom.test.tsx +78 -0
- package/src/domHarness.test-helper.ts +53 -0
- package/src/participant/ConversationThread.test.tsx +165 -0
- package/src/participant/ConversationThread.tsx +117 -0
- package/src/participant/ParticipantBackButton.tsx +1 -1
- package/src/participant/ParticipantBubbleBody.tsx +54 -0
- package/src/participant/ParticipantBubbleStatus.tsx +22 -28
- package/src/participant/ParticipantChannelBadges.tsx +10 -2
- package/src/participant/ParticipantComposer.tsx +1 -1
- package/src/participant/ParticipantComposerControls.tsx +2 -0
- package/src/participant/ParticipantCopyableToken.tsx +1 -0
- package/src/participant/ParticipantFailedMenu.tsx +40 -0
- package/src/participant/ParticipantFailedMenuList.tsx +41 -0
- package/src/participant/ParticipantMessageBubble.test.tsx +6 -6
- package/src/participant/ParticipantMessageBubble.tsx +26 -54
- package/src/participant/ParticipantMessageText.test.tsx +1 -1
- package/src/participant/ParticipantMessageText.tsx +4 -2
- package/src/participant/ParticipantProtocolBadge.tsx +1 -0
- package/src/participant/ParticipantScrollToLatest.tsx +1 -1
- package/src/participant/ParticipantThread.tsx +30 -10
- package/src/participant/ParticipantThreadHeader.test.tsx +1 -1
- package/src/participant/ParticipantThreadHeader.tsx +10 -0
- package/src/participant/ParticipantThreadScreen.tsx +6 -46
- package/src/participant/ParticipantTimeline.tsx +6 -3
- package/src/participant/conversationThreadApi.test.ts +27 -0
- package/src/participant/conversationThreadApi.ts +24 -0
- package/src/participant/conversationThreadCss.test.ts +116 -0
- package/src/participant/conversationThreadSummary.ts +34 -0
- package/src/participant/failedMenuKeyboard.ts +29 -0
- package/src/participant/index.ts +3 -0
- package/src/participant/participantContrast.test-helper.ts +116 -0
- package/src/participant/participantFailedMenuDom.test.tsx +154 -0
- package/src/participant/participantFailedMenuState.test.ts +61 -0
- package/src/participant/participantFailedMenuState.ts +57 -0
- package/src/participant/participantFailedMessage.test.tsx +83 -0
- package/src/participant/participantGolden.test.tsx +46 -0
- package/src/participant/participantGoldenScenarios.test-helper.tsx +57 -0
- package/src/participant/participantLabels.ts +6 -0
- package/src/participant/participantMessages.ts +4 -3
- package/src/participant/participantNewMessages.test.ts +21 -0
- package/src/participant/participantNewMessages.ts +8 -3
- package/src/participant/participantPerspective.test.ts +67 -0
- package/src/participant/participantPerspective.ts +50 -0
- package/src/participant/participantPerspectiveRender.test.tsx +94 -0
- package/src/participant/participantThemeContrast.test.ts +106 -0
- package/src/participant/participantTooltips.test.tsx +74 -0
- package/src/participant/test-fixtures/conversations-list.golden.html +1 -0
- package/src/participant/test-fixtures/conversations-selected.golden.html +1 -0
- package/src/participant/test-fixtures/thread-avatars-initials.golden.html +1 -0
- package/src/participant/test-fixtures/thread-closed.golden.html +1 -0
- package/src/participant/test-fixtures/thread-default.golden.html +1 -0
- package/src/participant/test-fixtures/thread-error.golden.html +1 -0
- package/src/participant/test-fixtures/thread-loading.golden.html +1 -0
- package/src/participant/test-fixtures/thread-plain.golden.html +1 -0
- package/src/participant/test-fixtures/thread-tail-off.golden.html +1 -0
- package/src/participant/useFailedMenuEffects.ts +59 -0
- package/src/participant/useParticipantConversationView.ts +3 -0
- package/src/participant/useParticipantFailedMenu.ts +60 -0
- package/src/participant/useParticipantThread.ts +13 -2
- package/src/participant/useParticipantThreadController.ts +112 -0
- package/src/participant/useParticipantThreadScroll.ts +3 -1
- package/src/participant/useParticipantUnread.test.tsx +40 -0
- package/src/participant/useParticipantUnread.ts +38 -0
- package/src/participant/useSuppressTouchTooltips.ts +38 -0
- package/src/styles.css +43 -17
- package/src/tooltipLayerRegistry.test.ts +56 -0
- package/src/tooltipLayerRegistry.ts +34 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { useCallback, useMemo, useReducer, useRef, type ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { ParticipantConversationSummary, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
|
|
4
|
+
|
|
5
|
+
import type { ConversationChannel } from '../conversationChannel'
|
|
6
|
+
import type { QuickReply } from '../quickReplies/quickReply.types'
|
|
7
|
+
import type { ConversationsTheme } from '../types'
|
|
8
|
+
import type { ParticipantAuthorAvatarRenderer } from './ParticipantAuthorAvatar'
|
|
9
|
+
import { ParticipantThread } from './ParticipantThread'
|
|
10
|
+
import { toParticipantApi, type ConversationThreadApi } from './conversationThreadApi'
|
|
11
|
+
import { buildThreadSummary } from './conversationThreadSummary'
|
|
12
|
+
import type { ParticipantSubjectGroup, ParticipantSubjectIconRenderer } from './participant.types'
|
|
13
|
+
import type { ParticipantPendingMessage } from './participantApi.types'
|
|
14
|
+
import { draftKey, type ParticipantDrafts } from './participantDrafts'
|
|
15
|
+
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS, type ParticipantConversationsLabels } from './participantLabels'
|
|
16
|
+
import type { ParticipantPerspective } from './participantPerspective'
|
|
17
|
+
import { participantSendStatesReducer } from './participantSendStates'
|
|
18
|
+
import { buildParticipantThemeStyle } from './participantTheme'
|
|
19
|
+
import { useParticipantThreadController, type ParticipantThreadCoreProps } from './useParticipantThreadController'
|
|
20
|
+
|
|
21
|
+
export type ConversationThreadProps = {
|
|
22
|
+
readonly api: ConversationThreadApi
|
|
23
|
+
readonly subject: ParticipantSubjectRef
|
|
24
|
+
/** `participant` (default) is the outside party; `operator` is the company, which owns the outbound messages. */
|
|
25
|
+
readonly perspective?: ParticipantPerspective
|
|
26
|
+
readonly title: string
|
|
27
|
+
readonly protocol?: string
|
|
28
|
+
readonly channels?: ParticipantConversationSummary['channels']
|
|
29
|
+
/** `closed` removes the composer and shows `labels.closedNotice`. */
|
|
30
|
+
readonly status?: 'open' | 'closed'
|
|
31
|
+
/** Short line under the title, written by the host (for example "Participant: Ana"). */
|
|
32
|
+
readonly counterpartLabel?: string
|
|
33
|
+
/** Slot at the end of the header bar for the host's own actions (close, reopen...). The package knows none of them. */
|
|
34
|
+
readonly headerActions?: ReactNode
|
|
35
|
+
/** Absent draws no back arrow. */
|
|
36
|
+
readonly onBack?: () => void
|
|
37
|
+
readonly renderSubjectIcon?: ParticipantSubjectIconRenderer
|
|
38
|
+
readonly subjectGroups?: readonly ParticipantSubjectGroup[]
|
|
39
|
+
/** Chips above the field: tapping one fills the text, it never sends. */
|
|
40
|
+
readonly quickReplies?: readonly QuickReply[]
|
|
41
|
+
readonly labels?: Partial<ParticipantConversationsLabels>
|
|
42
|
+
readonly theme?: ConversationsTheme
|
|
43
|
+
readonly className?: string
|
|
44
|
+
readonly locale?: string
|
|
45
|
+
readonly channel?: ConversationChannel
|
|
46
|
+
readonly pendingMessages?: readonly ParticipantPendingMessage[]
|
|
47
|
+
readonly onRetryPending?: (clientMessageId: string) => void
|
|
48
|
+
/** Tells the host the other side's messages were marked read, to refresh its own badges. */
|
|
49
|
+
readonly onMarkedRead?: (subject: ParticipantSubjectRef) => void
|
|
50
|
+
readonly tail?: boolean
|
|
51
|
+
readonly avatars?: 'initials'
|
|
52
|
+
readonly renderAuthorAvatar?: ParticipantAuthorAvatarRenderer
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function useConversationThreadCore(props: ConversationThreadProps): ParticipantThreadCoreProps {
|
|
56
|
+
const { perspective = 'participant' } = props
|
|
57
|
+
const { subjectType, subjectId } = props.subject
|
|
58
|
+
const subject = useMemo<ParticipantSubjectRef>(() => ({ subjectType, subjectId }), [subjectType, subjectId])
|
|
59
|
+
const onMarkedReadRef = useRef(props.onMarkedRead)
|
|
60
|
+
onMarkedReadRef.current = props.onMarkedRead
|
|
61
|
+
const handleMarkedRead = useCallback((marked: ParticipantSubjectRef) => onMarkedReadRef.current?.(marked), [])
|
|
62
|
+
const api = useMemo(() => toParticipantApi(props.api), [props.api])
|
|
63
|
+
const draftsRef = useRef<ParticipantDrafts>(new Map())
|
|
64
|
+
const [sendStates, dispatchSendStates] = useReducer(participantSendStatesReducer, new Map())
|
|
65
|
+
const labels = useMemo(() => ({ ...DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS, ...props.labels }), [props.labels])
|
|
66
|
+
const { title, protocol, channels, status } = props
|
|
67
|
+
const conversation = useMemo(
|
|
68
|
+
() => buildThreadSummary({ subject, title, protocol, channels, status }),
|
|
69
|
+
[subject, title, protocol, channels, status],
|
|
70
|
+
)
|
|
71
|
+
return useParticipantThreadController({
|
|
72
|
+
api,
|
|
73
|
+
subject,
|
|
74
|
+
conversation,
|
|
75
|
+
labels,
|
|
76
|
+
draftsRef,
|
|
77
|
+
sendStates,
|
|
78
|
+
dispatchSendStates,
|
|
79
|
+
onMarkedRead: handleMarkedRead,
|
|
80
|
+
channel: props.channel,
|
|
81
|
+
locale: props.locale,
|
|
82
|
+
pendingMessages: props.pendingMessages,
|
|
83
|
+
onRetryPending: props.onRetryPending,
|
|
84
|
+
perspective,
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function ConversationThreadBody(props: ConversationThreadProps) {
|
|
89
|
+
const core = useConversationThreadCore(props)
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<div
|
|
93
|
+
className={['cv-p-root', props.className].filter(Boolean).join(' ')}
|
|
94
|
+
style={buildParticipantThemeStyle(props.theme)}
|
|
95
|
+
>
|
|
96
|
+
<div className="cv-p-root__screen">
|
|
97
|
+
<ParticipantThread
|
|
98
|
+
{...core}
|
|
99
|
+
onBack={props.onBack}
|
|
100
|
+
quickReplies={props.quickReplies}
|
|
101
|
+
subjectGroups={props.subjectGroups}
|
|
102
|
+
renderSubjectIcon={props.renderSubjectIcon}
|
|
103
|
+
avatars={props.avatars}
|
|
104
|
+
renderAuthorAvatar={props.renderAuthorAvatar}
|
|
105
|
+
tail={props.tail}
|
|
106
|
+
counterpartLabel={props.counterpartLabel}
|
|
107
|
+
headerActions={props.headerActions}
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** One conversation about one subject, drawn like the participant app and seen from either side. */
|
|
115
|
+
export function ConversationThread(props: ConversationThreadProps) {
|
|
116
|
+
return <ConversationThreadBody key={draftKey(props.subject)} {...props} />
|
|
117
|
+
}
|
|
@@ -7,7 +7,7 @@ export type ParticipantBackButtonProps = {
|
|
|
7
7
|
|
|
8
8
|
export function ParticipantBackButton({ label, onBack }: ParticipantBackButtonProps) {
|
|
9
9
|
return (
|
|
10
|
-
<button type="button" className="cv-p-thread__back" aria-label={label} onClick={onBack}>
|
|
10
|
+
<button type="button" className="cv-p-thread__back" aria-label={label} data-cv-tooltip={label} onClick={onBack}>
|
|
11
11
|
<ArrowLeft size={24} aria-hidden="true" focusable="false" />
|
|
12
12
|
</button>
|
|
13
13
|
)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { formatTimestamp } from '../lib/format'
|
|
2
|
+
import { ParticipantAttachmentItem, type ResolveParticipantAttachmentUrl } from './ParticipantAttachmentItem'
|
|
3
|
+
import { OwnStatus } from './ParticipantBubbleStatus'
|
|
4
|
+
import { ParticipantFailedMenu } from './ParticipantFailedMenu'
|
|
5
|
+
import type { BubbleContent } from './ParticipantMessageBubble'
|
|
6
|
+
import { ParticipantMessageText } from './ParticipantMessageText'
|
|
7
|
+
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
8
|
+
import type { ParticipantOwnMessageStatus } from './participantMessages'
|
|
9
|
+
|
|
10
|
+
type BubbleBodyProps = {
|
|
11
|
+
readonly content: BubbleContent
|
|
12
|
+
readonly labels: ParticipantConversationsLabels
|
|
13
|
+
readonly ownStatus?: ParticipantOwnMessageStatus
|
|
14
|
+
readonly textId?: string
|
|
15
|
+
readonly resolveAttachmentUrl: ResolveParticipantAttachmentUrl
|
|
16
|
+
readonly onRetry?: () => void
|
|
17
|
+
readonly onDiscard?: () => void
|
|
18
|
+
readonly onEdit?: () => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function BubbleHeading({ content, labels }: Pick<BubbleBodyProps, 'content' | 'labels'>) {
|
|
22
|
+
if (content.isMine) return <span className="cv-p-sr-only">{labels.me}</span>
|
|
23
|
+
if (!content.authorName) return null
|
|
24
|
+
return <span className="cv-p-bubble__author">{content.authorName}</span>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function BubbleBody({ content, labels, ownStatus, textId, resolveAttachmentUrl, onRetry, onDiscard, onEdit }: BubbleBodyProps) {
|
|
28
|
+
return (
|
|
29
|
+
<>
|
|
30
|
+
<BubbleHeading content={content} labels={labels} />
|
|
31
|
+
{content.text ? <ParticipantMessageText text={content.text} labels={labels} id={textId} /> : null}
|
|
32
|
+
{content.attachments.map((attachment) => (
|
|
33
|
+
<ParticipantAttachmentItem
|
|
34
|
+
key={attachment.id}
|
|
35
|
+
attachment={attachment}
|
|
36
|
+
resolveAttachmentUrl={resolveAttachmentUrl}
|
|
37
|
+
labels={labels}
|
|
38
|
+
/>
|
|
39
|
+
))}
|
|
40
|
+
{content.pendingFilenames.map((filename, index) => (
|
|
41
|
+
<span key={`${filename}-${index}`} className="cv-p-attachment cv-p-attachment--pending">
|
|
42
|
+
{filename}
|
|
43
|
+
</span>
|
|
44
|
+
))}
|
|
45
|
+
<span className="cv-p-bubble__meta">
|
|
46
|
+
<time className="cv-p-bubble__time" dateTime={content.createdAt}>
|
|
47
|
+
{formatTimestamp(content.createdAt)}
|
|
48
|
+
</time>
|
|
49
|
+
{ownStatus ? <OwnStatus status={ownStatus} labels={labels} onRetry={onRetry} /> : null}
|
|
50
|
+
{ownStatus === 'failed' ? <ParticipantFailedMenu labels={labels} onEdit={onEdit} onDiscard={onDiscard} /> : null}
|
|
51
|
+
</span>
|
|
52
|
+
</>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
@@ -24,44 +24,38 @@ export function OwnStatus({ status, labels, onRetry }: OwnStatusProps) {
|
|
|
24
24
|
const ticksStatus = status === 'sending' ? 'queued' : status
|
|
25
25
|
const isVisibleText = status === 'sending' || status === 'queued' || status === 'failed'
|
|
26
26
|
|
|
27
|
-
if (status === 'failed' && onRetry) {
|
|
28
|
-
return (
|
|
29
|
-
<button type="button" className="cv-p-bubble__retry" onClick={onRetry}>
|
|
30
|
-
<StatusTicks status="failed" appearance="stylesheet" />
|
|
31
|
-
<span>{labels.statusFailed}</span>
|
|
32
|
-
</button>
|
|
33
|
-
)
|
|
34
|
-
}
|
|
35
|
-
|
|
36
27
|
const text = status === 'failed' ? labels.statusFailedShort : statusText(status, labels)
|
|
28
|
+
const isTextVisible = isVisibleText && !(status === 'failed' && onRetry)
|
|
37
29
|
return (
|
|
38
30
|
<span className="cv-p-bubble__status">
|
|
39
|
-
<StatusTicks status={ticksStatus} appearance="stylesheet" />
|
|
40
|
-
<span className={
|
|
31
|
+
<StatusTicks status={ticksStatus} appearance="stylesheet" title={isTextVisible ? undefined : text} />
|
|
32
|
+
<span className={isTextVisible ? 'cv-p-bubble__status-text' : 'cv-p-sr-only'}>{text}</span>
|
|
41
33
|
</span>
|
|
42
34
|
)
|
|
43
35
|
}
|
|
44
36
|
|
|
45
|
-
type
|
|
37
|
+
type RetryButtonProps = {
|
|
46
38
|
readonly labels: ParticipantConversationsLabels
|
|
47
|
-
readonly
|
|
48
|
-
|
|
39
|
+
readonly onRetry: () => void
|
|
40
|
+
/** Id of the message text, read after the name so the button says which message it resends. */
|
|
41
|
+
readonly describedBy?: string
|
|
49
42
|
}
|
|
50
43
|
|
|
51
|
-
export function
|
|
52
|
-
if (!onDiscard && !onEdit) return null
|
|
44
|
+
export function RetryButton({ labels, onRetry, describedBy }: RetryButtonProps) {
|
|
53
45
|
return (
|
|
54
|
-
<
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
{
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
46
|
+
<button
|
|
47
|
+
type="button"
|
|
48
|
+
className="cv-p-failed-retry"
|
|
49
|
+
aria-label={labels.statusFailed}
|
|
50
|
+
aria-describedby={describedBy}
|
|
51
|
+
data-cv-tooltip={labels.statusFailed}
|
|
52
|
+
onClick={onRetry}
|
|
53
|
+
>
|
|
54
|
+
<span className="cv-p-failed-retry__icon">
|
|
55
|
+
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" focusable="false">
|
|
56
|
+
<path d="M13 8a5 5 0 1 1-1.6-3.7M13 2.5v3h-3" fill="none" stroke="currentColor" strokeWidth="1.5" />
|
|
57
|
+
</svg>
|
|
58
|
+
</span>
|
|
59
|
+
</button>
|
|
66
60
|
)
|
|
67
61
|
}
|
|
@@ -59,7 +59,11 @@ export function ParticipantChannelBadges({
|
|
|
59
59
|
<span className="cv-p-channels">
|
|
60
60
|
<span className="cv-p-sr-only">{`${labels.channelsGroup}: `}</span>
|
|
61
61
|
{descriptors.map((descriptor, index) => (
|
|
62
|
-
<span
|
|
62
|
+
<span
|
|
63
|
+
key={descriptor.channel}
|
|
64
|
+
className={`cv-p-channel cv-p-channel--${descriptor.channel}`}
|
|
65
|
+
data-cv-tooltip={resolveParticipantChannelLabel(descriptor, labels)}
|
|
66
|
+
>
|
|
63
67
|
<BadgeContent
|
|
64
68
|
descriptor={descriptor}
|
|
65
69
|
labels={labels}
|
|
@@ -75,7 +79,11 @@ export function ParticipantChannelBadges({
|
|
|
75
79
|
return (
|
|
76
80
|
<ul className="cv-p-channels" aria-label={labels.channelsGroup}>
|
|
77
81
|
{descriptors.map((descriptor) => (
|
|
78
|
-
<li
|
|
82
|
+
<li
|
|
83
|
+
key={descriptor.channel}
|
|
84
|
+
className={`cv-p-channel cv-p-channel--${descriptor.channel}`}
|
|
85
|
+
data-cv-tooltip={resolveParticipantChannelLabel(descriptor, labels)}
|
|
86
|
+
>
|
|
79
87
|
<BadgeContent descriptor={descriptor} labels={labels} separator="" iconSize={iconSize} />
|
|
80
88
|
</li>
|
|
81
89
|
))}
|
|
@@ -60,7 +60,7 @@ function FileList({ files, labels, onRemove }: FileListProps) {
|
|
|
60
60
|
<li key={`${file.name}-${file.size}-${index}`} className="cv-p-files__item">
|
|
61
61
|
<span className="cv-p-files__name">{file.name}</span>
|
|
62
62
|
<span className="cv-p-files__size">{formatFileSize(file.size)}</span>
|
|
63
|
-
<button type="button" className="cv-p-files__remove" aria-label={`${labels.removeAttachment}: ${file.name}`} onClick={() => onRemove(file)}>
|
|
63
|
+
<button type="button" className="cv-p-files__remove" aria-label={`${labels.removeAttachment}: ${file.name}`} data-cv-tooltip={labels.removeAttachment} onClick={() => onRemove(file)}>
|
|
64
64
|
<span aria-hidden="true">×</span>
|
|
65
65
|
<span className="cv-p-sr-only">{labels.removeAttachment}</span>
|
|
66
66
|
</button>
|
|
@@ -29,6 +29,7 @@ export function AttachControl({ labels, disabled, acceptedTypes, onChosen }: Att
|
|
|
29
29
|
type="button"
|
|
30
30
|
className="cv-p-composer__attach"
|
|
31
31
|
aria-label={labels.attach}
|
|
32
|
+
data-cv-tooltip={labels.attach}
|
|
32
33
|
disabled={disabled}
|
|
33
34
|
onClick={() => fileInputRef.current?.click()}
|
|
34
35
|
>
|
|
@@ -44,6 +45,7 @@ export function SendButton({ label, disabled }: { readonly label: string; readon
|
|
|
44
45
|
type="submit"
|
|
45
46
|
className="cv-p-button cv-p-button--primary cv-p-composer__send"
|
|
46
47
|
aria-label={label}
|
|
48
|
+
data-cv-tooltip={label}
|
|
47
49
|
disabled={disabled}
|
|
48
50
|
>
|
|
49
51
|
<Send size={20} aria-hidden="true" focusable="false" />
|
|
@@ -43,6 +43,7 @@ export function ParticipantLinkCopyButton({ value, labels }: ParticipantLinkCopy
|
|
|
43
43
|
type="button"
|
|
44
44
|
className="cv-p-text__link-copy"
|
|
45
45
|
aria-label={`${labels.copyValue} ${value}`}
|
|
46
|
+
data-cv-tooltip={labels.copyValue}
|
|
46
47
|
onClick={() => copy(value, labels.valueCopied)}
|
|
47
48
|
>
|
|
48
49
|
{isCopied ? <Check size={14} aria-hidden={true} /> : <Copy size={14} aria-hidden={true} />}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { useId } from 'react'
|
|
2
|
+
|
|
3
|
+
import { ParticipantFailedMenuList } from './ParticipantFailedMenuList'
|
|
4
|
+
import { DEFAULT_MESSAGE_OPTIONS_LABEL, type ParticipantConversationsLabels } from './participantLabels'
|
|
5
|
+
import { useParticipantFailedMenu } from './useParticipantFailedMenu'
|
|
6
|
+
|
|
7
|
+
type ParticipantFailedMenuProps = {
|
|
8
|
+
readonly labels: ParticipantConversationsLabels
|
|
9
|
+
readonly onEdit?: () => void
|
|
10
|
+
readonly onDiscard?: () => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function ParticipantFailedMenu({ labels, onEdit, onDiscard }: ParticipantFailedMenuProps) {
|
|
14
|
+
const menu = useParticipantFailedMenu()
|
|
15
|
+
const triggerId = useId()
|
|
16
|
+
if (!onEdit && !onDiscard) return null
|
|
17
|
+
const label = labels.messageOptions ?? DEFAULT_MESSAGE_OPTIONS_LABEL
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<span className="cv-p-failed-menu" ref={menu.containerRef} onBlur={menu.handleBlur}>
|
|
21
|
+
<button
|
|
22
|
+
type="button"
|
|
23
|
+
id={triggerId}
|
|
24
|
+
ref={menu.triggerRef}
|
|
25
|
+
className="cv-p-failed-menu__trigger"
|
|
26
|
+
aria-haspopup="menu"
|
|
27
|
+
aria-expanded={menu.state.isOpen}
|
|
28
|
+
aria-label={label}
|
|
29
|
+
data-cv-tooltip={menu.state.isOpen ? undefined : label}
|
|
30
|
+
onClick={menu.handleTriggerClick}
|
|
31
|
+
onKeyDown={menu.handleTriggerKeyDown}
|
|
32
|
+
>
|
|
33
|
+
<span aria-hidden="true">⋯</span>
|
|
34
|
+
</button>
|
|
35
|
+
{menu.state.isOpen ? (
|
|
36
|
+
<ParticipantFailedMenuList menu={menu} labelledBy={triggerId} labels={labels} onEdit={onEdit} onDiscard={onDiscard} />
|
|
37
|
+
) : null}
|
|
38
|
+
</span>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ParticipantFailedMenuController } from './useParticipantFailedMenu'
|
|
2
|
+
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
3
|
+
|
|
4
|
+
type ParticipantFailedMenuListProps = {
|
|
5
|
+
readonly menu: ParticipantFailedMenuController
|
|
6
|
+
readonly labelledBy: string
|
|
7
|
+
readonly labels: ParticipantConversationsLabels
|
|
8
|
+
readonly onEdit?: () => void
|
|
9
|
+
readonly onDiscard?: () => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function ParticipantFailedMenuList({ menu, labelledBy, labels, onEdit, onDiscard }: ParticipantFailedMenuListProps) {
|
|
13
|
+
function runAndClose(action: () => void): () => void {
|
|
14
|
+
return () => {
|
|
15
|
+
menu.close()
|
|
16
|
+
action()
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const className = `cv-p-failed-menu__list${menu.placement === 'below' ? ' cv-p-failed-menu__list--below' : ''}`
|
|
21
|
+
return (
|
|
22
|
+
<div role="menu" aria-labelledby={labelledBy} ref={menu.menuRef} className={className} onKeyDown={menu.handleMenuKeyDown}>
|
|
23
|
+
{onEdit ? (
|
|
24
|
+
<button type="button" role="menuitem" tabIndex={-1} className="cv-p-failed-menu__item" onClick={runAndClose(onEdit)}>
|
|
25
|
+
{labels.edit}
|
|
26
|
+
</button>
|
|
27
|
+
) : null}
|
|
28
|
+
{onDiscard ? (
|
|
29
|
+
<button
|
|
30
|
+
type="button"
|
|
31
|
+
role="menuitem"
|
|
32
|
+
tabIndex={-1}
|
|
33
|
+
className="cv-p-failed-menu__item cv-p-failed-menu__item--danger"
|
|
34
|
+
onClick={runAndClose(onDiscard)}
|
|
35
|
+
>
|
|
36
|
+
{labels.discard}
|
|
37
|
+
</button>
|
|
38
|
+
) : null}
|
|
39
|
+
</div>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
@@ -112,22 +112,22 @@ describe('ParticipantMessageBubble', () => {
|
|
|
112
112
|
const withoutRetry = renderPending('failed')
|
|
113
113
|
|
|
114
114
|
expect(withRetry).toContain('<button')
|
|
115
|
-
expect(withRetry).toContain('Failed — tap to retry')
|
|
115
|
+
expect(withRetry).toContain('aria-label="Failed — tap to retry"')
|
|
116
|
+
expect(withRetry).toContain('cv-p-failed-retry')
|
|
116
117
|
expect(withRetry).toContain('cv-status-ticks--failed')
|
|
117
118
|
expect(findUtilityClassTokens(withRetry)).toEqual([])
|
|
118
119
|
expect(withoutRetry).not.toContain('<button')
|
|
119
120
|
expect(withoutRetry).toContain('cv-status-ticks--failed')
|
|
120
121
|
})
|
|
121
122
|
|
|
122
|
-
it('
|
|
123
|
+
it('offers edit and discard through the menu trigger only when a handler exists', () => {
|
|
123
124
|
const none = renderPending('failed')
|
|
124
125
|
const both = renderPending('failed', () => undefined, { onDiscard: () => undefined, onEdit: () => undefined })
|
|
125
126
|
const notFailed = renderPending('queued', undefined, { onDiscard: () => undefined, onEdit: () => undefined })
|
|
126
127
|
|
|
127
|
-
expect(none).not.toContain('cv-p-
|
|
128
|
-
expect(both).toContain('
|
|
129
|
-
expect(
|
|
130
|
-
expect(notFailed).not.toContain('cv-p-bubble__action')
|
|
128
|
+
expect(none).not.toContain('cv-p-failed-menu')
|
|
129
|
+
expect(both).toContain('aria-haspopup="menu"')
|
|
130
|
+
expect(notFailed).not.toContain('cv-p-failed-menu')
|
|
131
131
|
})
|
|
132
132
|
|
|
133
133
|
it('a failed server message shows only "Failed", with no button', () => {
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { useId, type ReactNode } from 'react'
|
|
2
2
|
|
|
3
3
|
import type { MessageDeliveryStatus, ParticipantAttachment } from '@adatechnology/conversation-contracts'
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { ParticipantAttachmentItem, type ResolveParticipantAttachmentUrl } from './ParticipantAttachmentItem'
|
|
5
|
+
import { BubbleBody } from './ParticipantBubbleBody'
|
|
6
|
+
import { RetryButton } from './ParticipantBubbleStatus'
|
|
7
|
+
import type { ResolveParticipantAttachmentUrl } from './ParticipantAttachmentItem'
|
|
9
8
|
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
9
|
+
import type { ParticipantPerspective } from './participantPerspective'
|
|
10
10
|
import {
|
|
11
11
|
isOwnMessage,
|
|
12
12
|
resolveOwnMessageStatus,
|
|
@@ -19,19 +19,21 @@ export type ParticipantMessageBubbleProps = {
|
|
|
19
19
|
readonly labels: ParticipantConversationsLabels
|
|
20
20
|
readonly confirmsRead: boolean
|
|
21
21
|
readonly resolveAttachmentUrl: ResolveParticipantAttachmentUrl
|
|
22
|
-
/** Absent means no retry button, even when the message failed. */
|
|
22
|
+
/** Absent means no retry button beside the bubble, even when the message failed. */
|
|
23
23
|
readonly onRetry?: () => void
|
|
24
|
-
/** Absent means no discard
|
|
24
|
+
/** Absent means no discard item in the failed-message menu. */
|
|
25
25
|
readonly onDiscard?: () => void
|
|
26
|
-
/** Absent means no edit
|
|
26
|
+
/** Absent means no edit item in the failed-message menu. */
|
|
27
27
|
readonly onEdit?: () => void
|
|
28
28
|
/** Opt-in slot to the left of a received bubble; null reserves the space, absent changes nothing. */
|
|
29
29
|
readonly avatar?: ReactNode
|
|
30
30
|
/** Speech-bubble tail on the bottom corner; false removes it. Default true. */
|
|
31
31
|
readonly tail?: boolean
|
|
32
|
+
/** Who is looking; absent is the participant. The operator owns the outbound messages. */
|
|
33
|
+
readonly perspective?: ParticipantPerspective
|
|
32
34
|
}
|
|
33
35
|
|
|
34
|
-
type BubbleContent = {
|
|
36
|
+
export type BubbleContent = {
|
|
35
37
|
readonly isMine: boolean
|
|
36
38
|
readonly authorName?: string
|
|
37
39
|
readonly text?: string
|
|
@@ -42,11 +44,11 @@ type BubbleContent = {
|
|
|
42
44
|
readonly serverStatus?: MessageDeliveryStatus
|
|
43
45
|
}
|
|
44
46
|
|
|
45
|
-
function describeItem(item: ParticipantTimelineItem): BubbleContent {
|
|
47
|
+
function describeItem(item: ParticipantTimelineItem, perspective?: ParticipantPerspective): BubbleContent {
|
|
46
48
|
if (item.kind === 'server') {
|
|
47
49
|
const { message } = item
|
|
48
50
|
return {
|
|
49
|
-
isMine: isOwnMessage(message),
|
|
51
|
+
isMine: isOwnMessage(message, perspective),
|
|
50
52
|
authorName: message.authorName ?? undefined,
|
|
51
53
|
text: message.text ?? undefined,
|
|
52
54
|
createdAt: message.createdAt,
|
|
@@ -65,61 +67,31 @@ function describeItem(item: ParticipantTimelineItem): BubbleContent {
|
|
|
65
67
|
}
|
|
66
68
|
}
|
|
67
69
|
|
|
68
|
-
function bubbleClassName(isMine: boolean, hasTail: boolean): string {
|
|
70
|
+
function bubbleClassName(isMine: boolean, hasTail: boolean, hasFailed: boolean): string {
|
|
69
71
|
const classes = ['cv-p-bubble']
|
|
70
72
|
if (isMine) classes.push('cv-p-bubble--mine')
|
|
73
|
+
if (hasFailed) classes.push('cv-p-bubble--failed')
|
|
71
74
|
if (!hasTail) classes.push('cv-p-bubble--no-tail')
|
|
72
75
|
return classes.join(' ')
|
|
73
76
|
}
|
|
74
77
|
|
|
75
|
-
export function ParticipantMessageBubble({
|
|
76
|
-
item,
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
onRetry,
|
|
81
|
-
onDiscard,
|
|
82
|
-
onEdit,
|
|
83
|
-
avatar,
|
|
84
|
-
tail = true,
|
|
85
|
-
}: ParticipantMessageBubbleProps) {
|
|
86
|
-
const content = describeItem(item)
|
|
87
|
-
const { isMine } = content
|
|
88
|
-
const ownStatus = isMine
|
|
78
|
+
export function ParticipantMessageBubble(props: ParticipantMessageBubbleProps) {
|
|
79
|
+
const { item, labels, confirmsRead, onRetry, avatar, tail = true, perspective } = props
|
|
80
|
+
const textId = useId()
|
|
81
|
+
const content = describeItem(item, perspective)
|
|
82
|
+
const ownStatus = content.isMine
|
|
89
83
|
? resolveOwnMessageStatus({ displayState: content.displayState, serverStatus: content.serverStatus, confirmsRead })
|
|
90
84
|
: undefined
|
|
85
|
+
const isFailed = ownStatus === 'failed'
|
|
86
|
+
const canRetry = isFailed && onRetry !== undefined
|
|
91
87
|
|
|
92
88
|
const bubble = (
|
|
93
|
-
<div className={bubbleClassName(isMine, tail)}>
|
|
94
|
-
{
|
|
95
|
-
|
|
96
|
-
) : content.authorName ? (
|
|
97
|
-
<span className="cv-p-bubble__author">{content.authorName}</span>
|
|
98
|
-
) : null}
|
|
99
|
-
{content.text ? <ParticipantMessageText text={content.text} labels={labels} /> : null}
|
|
100
|
-
{content.attachments.map((attachment) => (
|
|
101
|
-
<ParticipantAttachmentItem
|
|
102
|
-
key={attachment.id}
|
|
103
|
-
attachment={attachment}
|
|
104
|
-
resolveAttachmentUrl={resolveAttachmentUrl}
|
|
105
|
-
labels={labels}
|
|
106
|
-
/>
|
|
107
|
-
))}
|
|
108
|
-
{content.pendingFilenames.map((filename, index) => (
|
|
109
|
-
<span key={`${filename}-${index}`} className="cv-p-attachment cv-p-attachment--pending">
|
|
110
|
-
{filename}
|
|
111
|
-
</span>
|
|
112
|
-
))}
|
|
113
|
-
<span className="cv-p-bubble__meta">
|
|
114
|
-
<time className="cv-p-bubble__time" dateTime={content.createdAt}>
|
|
115
|
-
{formatTimestamp(content.createdAt)}
|
|
116
|
-
</time>
|
|
117
|
-
{ownStatus ? <OwnStatus status={ownStatus} labels={labels} onRetry={onRetry} /> : null}
|
|
118
|
-
</span>
|
|
119
|
-
{ownStatus === 'failed' ? <FailedActions labels={labels} onDiscard={onDiscard} onEdit={onEdit} /> : null}
|
|
89
|
+
<div className={bubbleClassName(content.isMine, tail, isFailed)}>
|
|
90
|
+
{canRetry ? <RetryButton labels={labels} onRetry={onRetry} describedBy={textId} /> : null}
|
|
91
|
+
<BubbleBody {...props} content={content} ownStatus={ownStatus} textId={canRetry ? textId : undefined} />
|
|
120
92
|
</div>
|
|
121
93
|
)
|
|
122
|
-
if (avatar === undefined || isMine) return bubble
|
|
94
|
+
if (avatar === undefined || content.isMine) return bubble
|
|
123
95
|
return (
|
|
124
96
|
<div className="cv-p-bubble-row">
|
|
125
97
|
<span className="cv-p-bubble-row__avatar">{avatar}</span>
|
|
@@ -45,7 +45,7 @@ describe('ParticipantMessageText - links', () => {
|
|
|
45
45
|
expect(markup).toContain('target="_blank"')
|
|
46
46
|
expect(markup).toContain('<span class="cv-p-sr-only"> (opens in a new tab)</span>')
|
|
47
47
|
expect(markup).toContain(
|
|
48
|
-
'<button type="button" class="cv-p-text__link-copy" aria-label="Copy https://a.com/?a=1&b=2">',
|
|
48
|
+
'<button type="button" class="cv-p-text__link-copy" aria-label="Copy https://a.com/?a=1&b=2" data-cv-tooltip="Copy">',
|
|
49
49
|
)
|
|
50
50
|
})
|
|
51
51
|
|
|
@@ -7,6 +7,8 @@ import type { FormatBlock } from './participantMessageFormat.types'
|
|
|
7
7
|
export type ParticipantMessageTextProps = {
|
|
8
8
|
readonly text: string | null | undefined
|
|
9
9
|
readonly labels: ParticipantTextLabels
|
|
10
|
+
/** Lets a control point at the text with aria-describedby; absent leaves the markup as it was. */
|
|
11
|
+
readonly id?: string
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
function renderBlock(block: FormatBlock, labels: ParticipantTextLabels, key: number) {
|
|
@@ -40,7 +42,7 @@ function renderBlock(block: FormatBlock, labels: ParticipantTextLabels, key: num
|
|
|
40
42
|
)
|
|
41
43
|
}
|
|
42
44
|
|
|
43
|
-
export function ParticipantMessageText({ text, labels }: ParticipantMessageTextProps) {
|
|
45
|
+
export function ParticipantMessageText({ text, labels, id }: ParticipantMessageTextProps) {
|
|
44
46
|
const blocks = useMemo(() => parseParticipantMessage(text), [text])
|
|
45
47
|
if (blocks.length === 0) return null
|
|
46
48
|
const [only] = blocks
|
|
@@ -48,5 +50,5 @@ export function ParticipantMessageText({ text, labels }: ParticipantMessageTextP
|
|
|
48
50
|
blocks.length === 1 && only?.kind === 'paragraph'
|
|
49
51
|
? renderInlineNodes(only.children, labels)
|
|
50
52
|
: blocks.map((block, key) => renderBlock(block, labels, key))
|
|
51
|
-
return <div className="cv-p-text">{children}</div>
|
|
53
|
+
return <div className="cv-p-text" id={id}>{children}</div>
|
|
52
54
|
}
|
|
@@ -39,6 +39,7 @@ export function ParticipantProtocolBadgeView({
|
|
|
39
39
|
type="button"
|
|
40
40
|
className="cv-p-protocol__copy"
|
|
41
41
|
aria-label={`${labels.copyProtocol} ${protocol}`}
|
|
42
|
+
data-cv-tooltip={labels.copyProtocol}
|
|
42
43
|
onClick={onCopy}
|
|
43
44
|
>
|
|
44
45
|
{isCopied ? <Check size={18} aria-hidden={true} /> : <Copy size={18} aria-hidden={true} />}
|
|
@@ -23,7 +23,7 @@ export function ParticipantScrollToLatest({
|
|
|
23
23
|
if (!isVisible || onClick === undefined) return null
|
|
24
24
|
return (
|
|
25
25
|
<div className="cv-p-thread__latest">
|
|
26
|
-
<button type="button" className="cv-p-thread__latest-button" aria-label={label} onClick={(event) => {
|
|
26
|
+
<button type="button" className="cv-p-thread__latest-button" aria-label={label} data-cv-tooltip={label} onClick={(event) => {
|
|
27
27
|
focusScrollerOf(event.currentTarget)
|
|
28
28
|
onClick()
|
|
29
29
|
}}
|