@adatechnology/conversations-ui 0.12.0 → 0.14.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/dist/chunk-RLUNDWGA.js +51 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +5 -43
- package/dist/participant/index.d.ts +87 -2
- package/dist/participant/index.js +1073 -398
- package/dist/styles.css +218 -0
- package/package.json +1 -1
- package/src/conversationTranscript.ts +6 -2
- package/src/participant/ConversationThread.tsx +8 -1
- package/src/participant/ParticipantAuthorAvatar.tsx +18 -0
- package/src/participant/ParticipantBubbleBody.tsx +33 -3
- package/src/participant/ParticipantConversationMenu.tsx +89 -0
- package/src/participant/ParticipantConversations.tsx +6 -1
- package/src/participant/ParticipantMenuBubble.tsx +106 -0
- package/src/participant/ParticipantMessageBubble.test.tsx +51 -0
- package/src/participant/ParticipantMessageBubble.tsx +39 -7
- package/src/participant/ParticipantMessageInfo.tsx +70 -0
- package/src/participant/ParticipantMessageMenu.tsx +168 -0
- package/src/participant/ParticipantMessageMenuContext.tsx +109 -0
- package/src/participant/ParticipantSelectionBar.tsx +64 -0
- package/src/participant/ParticipantThread.tsx +30 -3
- package/src/participant/ParticipantThreadScreen.tsx +2 -0
- package/src/participant/ParticipantTimeline.tsx +7 -1
- package/src/participant/index.ts +11 -1
- package/src/participant/participantConversationMenuDom.test.tsx +166 -0
- package/src/participant/participantLabels.ts +40 -0
- package/src/participant/participantMessageInfoModel.test.ts +68 -0
- package/src/participant/participantMessageInfoModel.ts +80 -0
- package/src/participant/participantMessageMenuDom.test.tsx +111 -0
- package/src/participant/participantMessageMenuModel.test.ts +43 -0
- package/src/participant/participantMessageMenuModel.ts +91 -0
- package/src/participant/participantMessageMoment.ts +7 -0
- package/src/participant/participantTranscriptionText.test.ts +48 -0
- package/src/participant/participantTranscriptionText.ts +28 -0
- package/src/participant/useOpenUpwards.ts +22 -0
- package/src/participant/useParticipantFailedMenu.ts +3 -0
- package/src/styles.css +41 -0
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import { useId, type ReactNode } from 'react'
|
|
1
|
+
import { useId, type HTMLAttributes, type ReactNode, type Ref } from 'react'
|
|
2
2
|
|
|
3
3
|
import type { MessageDeliveryStatus, ParticipantAttachment } from '@adatechnology/conversation-contracts'
|
|
4
4
|
|
|
5
5
|
import { BubbleBody } from './ParticipantBubbleBody'
|
|
6
|
+
import { ParticipantMenuBubble } from './ParticipantMenuBubble'
|
|
7
|
+
import { useParticipantMessageMenu } from './ParticipantMessageMenuContext'
|
|
8
|
+
import type { ParticipantMessageActionsRenderer } from './ParticipantAuthorAvatar'
|
|
6
9
|
import { RetryButton } from './ParticipantBubbleStatus'
|
|
7
10
|
import type { ResolveParticipantAttachmentUrl } from './ParticipantAttachmentItem'
|
|
8
11
|
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
@@ -31,6 +34,8 @@ export type ParticipantMessageBubbleProps = {
|
|
|
31
34
|
readonly tail?: boolean
|
|
32
35
|
/** Who is looking; absent is the participant. The operator owns the outbound messages. */
|
|
33
36
|
readonly perspective?: ParticipantPerspective
|
|
37
|
+
/** Host actions at the end of the meta line of a server message; pending ones have nothing to act on yet. */
|
|
38
|
+
readonly renderMessageActions?: ParticipantMessageActionsRenderer
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
export type BubbleContent = {
|
|
@@ -43,6 +48,7 @@ export type BubbleContent = {
|
|
|
43
48
|
readonly pendingFilenames: readonly string[]
|
|
44
49
|
readonly displayState?: ParticipantPendingDisplayState
|
|
45
50
|
readonly serverStatus?: MessageDeliveryStatus
|
|
51
|
+
readonly messageId?: string
|
|
46
52
|
}
|
|
47
53
|
|
|
48
54
|
function describeItem(item: ParticipantTimelineItem, perspective?: ParticipantPerspective): BubbleContent {
|
|
@@ -57,6 +63,7 @@ function describeItem(item: ParticipantTimelineItem, perspective?: ParticipantPe
|
|
|
57
63
|
attachments: message.attachments,
|
|
58
64
|
pendingFilenames: [],
|
|
59
65
|
serverStatus: message.status,
|
|
66
|
+
messageId: message.id,
|
|
60
67
|
}
|
|
61
68
|
}
|
|
62
69
|
return {
|
|
@@ -77,20 +84,35 @@ function bubbleClassName(isMine: boolean, hasTail: boolean, hasFailed: boolean):
|
|
|
77
84
|
return classes.join(' ')
|
|
78
85
|
}
|
|
79
86
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
87
|
+
/** What the message menu adds to a bubble: its ref and handlers, a corner slot and a state class. */
|
|
88
|
+
export type ParticipantBubbleFrame = {
|
|
89
|
+
readonly ref?: Ref<HTMLDivElement>
|
|
90
|
+
readonly handlers?: HTMLAttributes<HTMLDivElement>
|
|
91
|
+
readonly corner?: ReactNode
|
|
92
|
+
readonly className?: string
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
type RenderBubbleParams = {
|
|
96
|
+
readonly props: ParticipantMessageBubbleProps
|
|
97
|
+
readonly content: BubbleContent
|
|
98
|
+
readonly textId: string
|
|
99
|
+
readonly frame?: ParticipantBubbleFrame
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function renderParticipantBubble({ props, content, textId, frame }: RenderBubbleParams): ReactNode {
|
|
103
|
+
const { confirmsRead, onRetry, avatar, tail = true } = props
|
|
84
104
|
const ownStatus = content.isMine
|
|
85
105
|
? resolveOwnMessageStatus({ displayState: content.displayState, serverStatus: content.serverStatus, confirmsRead })
|
|
86
106
|
: undefined
|
|
87
107
|
const isFailed = ownStatus === 'failed'
|
|
88
108
|
const canRetry = isFailed && onRetry !== undefined
|
|
109
|
+
const className = [bubbleClassName(content.isMine, tail, isFailed), frame?.className].filter(Boolean).join(' ')
|
|
89
110
|
|
|
90
111
|
const bubble = (
|
|
91
|
-
<div className={
|
|
92
|
-
{canRetry ? <RetryButton labels={labels} onRetry={onRetry} describedBy={textId} /> : null}
|
|
112
|
+
<div className={className} ref={frame?.ref} {...frame?.handlers}>
|
|
113
|
+
{canRetry ? <RetryButton labels={props.labels} onRetry={onRetry} describedBy={textId} /> : null}
|
|
93
114
|
<BubbleBody {...props} content={content} ownStatus={ownStatus} textId={canRetry ? textId : undefined} />
|
|
115
|
+
{frame?.corner}
|
|
94
116
|
</div>
|
|
95
117
|
)
|
|
96
118
|
if (avatar === undefined || content.isMine) return bubble
|
|
@@ -101,3 +123,13 @@ export function ParticipantMessageBubble(props: ParticipantMessageBubbleProps) {
|
|
|
101
123
|
</div>
|
|
102
124
|
)
|
|
103
125
|
}
|
|
126
|
+
|
|
127
|
+
export function ParticipantMessageBubble(props: ParticipantMessageBubbleProps) {
|
|
128
|
+
const textId = useId()
|
|
129
|
+
const menu = useParticipantMessageMenu()
|
|
130
|
+
const content = describeItem(props.item, props.perspective)
|
|
131
|
+
if (menu && content.messageId !== undefined) {
|
|
132
|
+
return <ParticipantMenuBubble props={props} content={content} textId={textId} state={menu} />
|
|
133
|
+
}
|
|
134
|
+
return renderParticipantBubble({ props, content, textId })
|
|
135
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { X } from 'lucide-react'
|
|
2
|
+
import { useEffect, useRef } from 'react'
|
|
3
|
+
|
|
4
|
+
import type { ParticipantMessageActionsTarget } from './ParticipantAuthorAvatar'
|
|
5
|
+
import type { ParticipantMessageMenuState } from './ParticipantMessageMenuContext'
|
|
6
|
+
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS as DEFAULTS } from './participantLabels'
|
|
7
|
+
import { messageInfoRows } from './participantMessageInfoModel'
|
|
8
|
+
import { useOpenUpwards } from './useOpenUpwards'
|
|
9
|
+
|
|
10
|
+
type ParticipantMessageInfoProps = {
|
|
11
|
+
readonly state: ParticipantMessageMenuState
|
|
12
|
+
readonly message: ParticipantMessageActionsTarget
|
|
13
|
+
readonly onClose: () => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Small panel beside the bubble: author, when it was sent and, for this side's messages, delivery and reading. */
|
|
17
|
+
export function ParticipantMessageInfo({ state, message, onClose }: ParticipantMessageInfoProps) {
|
|
18
|
+
const panelRef = useRef<HTMLDivElement | null>(null)
|
|
19
|
+
const closeRef = useRef<HTMLButtonElement | null>(null)
|
|
20
|
+
const { labels } = state
|
|
21
|
+
const title = labels.messageInfo ?? DEFAULTS.messageInfo ?? ''
|
|
22
|
+
const rows = messageInfoRows(message, labels, state.locale)
|
|
23
|
+
const isUpwards = useOpenUpwards(true, panelRef)
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
closeRef.current?.focus()
|
|
27
|
+
function handleKeyDown(event: KeyboardEvent): void {
|
|
28
|
+
if (event.key === 'Escape') onClose()
|
|
29
|
+
}
|
|
30
|
+
function handlePointerDown(event: PointerEvent): void {
|
|
31
|
+
if (event.target instanceof Node && !panelRef.current?.contains(event.target)) onClose()
|
|
32
|
+
}
|
|
33
|
+
document.addEventListener('keydown', handleKeyDown)
|
|
34
|
+
document.addEventListener('pointerdown', handlePointerDown)
|
|
35
|
+
return () => {
|
|
36
|
+
document.removeEventListener('keydown', handleKeyDown)
|
|
37
|
+
document.removeEventListener('pointerdown', handlePointerDown)
|
|
38
|
+
}
|
|
39
|
+
}, [onClose])
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div
|
|
43
|
+
ref={panelRef}
|
|
44
|
+
role="dialog"
|
|
45
|
+
aria-label={title}
|
|
46
|
+
className={`cv-p-failed-menu__list cv-p-message-menu__list cv-p-message-info${isUpwards ? ' cv-p-message-menu__list--up' : ''}`}
|
|
47
|
+
>
|
|
48
|
+
<div className="cv-p-message-info__head">
|
|
49
|
+
<span className="cv-p-message-info__title">{title}</span>
|
|
50
|
+
<button
|
|
51
|
+
ref={closeRef}
|
|
52
|
+
type="button"
|
|
53
|
+
className="cv-p-message-info__close"
|
|
54
|
+
aria-label={labels.closeMessageInfo ?? DEFAULTS.closeMessageInfo}
|
|
55
|
+
onClick={onClose}
|
|
56
|
+
>
|
|
57
|
+
<X size={14} aria-hidden={true} />
|
|
58
|
+
</button>
|
|
59
|
+
</div>
|
|
60
|
+
<dl className="cv-p-message-info__rows">
|
|
61
|
+
{rows.map((row) => (
|
|
62
|
+
<div key={row.id} className="cv-p-message-info__row">
|
|
63
|
+
<dt>{row.label}</dt>
|
|
64
|
+
<dd>{row.value}</dd>
|
|
65
|
+
</div>
|
|
66
|
+
))}
|
|
67
|
+
</dl>
|
|
68
|
+
</div>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { ChevronDown } from 'lucide-react'
|
|
2
|
+
import { useCallback, useId, useState } from 'react'
|
|
3
|
+
|
|
4
|
+
import type { ParticipantMessageActionsTarget } from './ParticipantAuthorAvatar'
|
|
5
|
+
import { downloadTextFile } from '../conversationTranscript'
|
|
6
|
+
import { ParticipantMessageInfo } from './ParticipantMessageInfo'
|
|
7
|
+
import { formatMessageMoment } from './participantMessageMoment'
|
|
8
|
+
import { readyTranscriptions, transcriptionFileName } from './participantTranscriptionText'
|
|
9
|
+
import type { ParticipantMessageMenuState } from './ParticipantMessageMenuContext'
|
|
10
|
+
import {
|
|
11
|
+
DEFAULT_MESSAGE_OPTIONS_LABEL,
|
|
12
|
+
DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS as DEFAULTS,
|
|
13
|
+
} from './participantLabels'
|
|
14
|
+
import { copyableText, type ParticipantMessageMenuItem } from './participantMessageMenuModel'
|
|
15
|
+
import type { ParticipantFailedMenuController } from './useParticipantFailedMenu'
|
|
16
|
+
import { useOpenUpwards } from './useOpenUpwards'
|
|
17
|
+
import { useParticipantCopyFeedback } from './useParticipantCopyFeedback'
|
|
18
|
+
|
|
19
|
+
type ParticipantMessageMenuProps = {
|
|
20
|
+
readonly menu: ParticipantFailedMenuController
|
|
21
|
+
readonly state: ParticipantMessageMenuState
|
|
22
|
+
readonly message: ParticipantMessageActionsTarget
|
|
23
|
+
/** Text the user had selected inside the bubble when the menu opened; empty when none. */
|
|
24
|
+
readonly selectedText: string
|
|
25
|
+
readonly onTriggerPointerDown: () => void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type Entry = ParticipantMessageMenuItem & { readonly run?: () => void }
|
|
29
|
+
|
|
30
|
+
/** A transcribed audio: copy what was said, or save it as a text file named after the audio. */
|
|
31
|
+
function transcriptionEntries(props: ParticipantMessageMenuProps, copy: (value: string) => void): readonly Entry[] {
|
|
32
|
+
const { state, message } = props
|
|
33
|
+
const { labels } = state
|
|
34
|
+
const ready = readyTranscriptions(message.attachments)
|
|
35
|
+
const [first] = ready
|
|
36
|
+
if (!first) return []
|
|
37
|
+
const prefix = labels.transcriptionPrefix ?? DEFAULTS.transcriptionPrefix ?? ''
|
|
38
|
+
const text = ready.map((entry) => entry.text).join('\n\n')
|
|
39
|
+
const author = message.isMine ? labels.me : (message.authorName ?? '')
|
|
40
|
+
const head = `[${formatMessageMoment(message.createdAt, state.locale)}]${author === '' ? '' : ` ${author}`}`
|
|
41
|
+
return [
|
|
42
|
+
{
|
|
43
|
+
id: 'copy-transcription',
|
|
44
|
+
label: labels.copyTranscription ?? DEFAULTS.copyTranscription ?? '',
|
|
45
|
+
run: () => copy(text),
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: 'download-transcription',
|
|
49
|
+
label: labels.downloadTranscription ?? DEFAULTS.downloadTranscription ?? '',
|
|
50
|
+
run: () => downloadTextFile(transcriptionFileName(first.filename, prefix), `${head}\n\n${text}\n`),
|
|
51
|
+
},
|
|
52
|
+
]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function entriesFor(
|
|
56
|
+
props: ParticipantMessageMenuProps,
|
|
57
|
+
copy: (value: string) => void,
|
|
58
|
+
showInfo: () => void,
|
|
59
|
+
): readonly Entry[] {
|
|
60
|
+
const { state, message, selectedText } = props
|
|
61
|
+
const { labels } = state
|
|
62
|
+
const text = copyableText(message)
|
|
63
|
+
const own: Entry[] = []
|
|
64
|
+
if (selectedText !== '' && selectedText !== text) {
|
|
65
|
+
own.push({
|
|
66
|
+
id: 'copy-selection',
|
|
67
|
+
label: labels.copySelection ?? DEFAULTS.copySelection ?? '',
|
|
68
|
+
run: () => copy(selectedText),
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
if (text !== null)
|
|
72
|
+
own.push({ id: 'copy', label: labels.copyMessage ?? DEFAULTS.copyMessage ?? '', run: () => copy(text) })
|
|
73
|
+
own.push({
|
|
74
|
+
id: 'select',
|
|
75
|
+
label: labels.selectMessages ?? DEFAULTS.selectMessages ?? '',
|
|
76
|
+
run: () => state.start(message.id),
|
|
77
|
+
})
|
|
78
|
+
own.push(...transcriptionEntries(props, copy))
|
|
79
|
+
own.push({ id: 'info', label: labels.messageInfo ?? DEFAULTS.messageInfo ?? '', run: showInfo })
|
|
80
|
+
return [...own, ...(state.options.items?.(message) ?? []).map((item) => ({ ...item, run: item.onSelect }))]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The chevron that shows on hover or focus, and the menu it opens: the package's entries (copy, select, info), then the host's. */
|
|
84
|
+
export function ParticipantMessageMenu(props: ParticipantMessageMenuProps) {
|
|
85
|
+
const { menu, state } = props
|
|
86
|
+
const triggerId = useId()
|
|
87
|
+
const { copy } = useParticipantCopyFeedback()
|
|
88
|
+
const label = state.labels.messageOptions ?? DEFAULT_MESSAGE_OPTIONS_LABEL
|
|
89
|
+
const [isInfoOpen, setInfoOpen] = useState(false)
|
|
90
|
+
const closeInfo = useCallback(() => setInfoOpen(false), [])
|
|
91
|
+
const entries = entriesFor(
|
|
92
|
+
props,
|
|
93
|
+
(value) => copy(value, state.labels.valueCopied),
|
|
94
|
+
() => setInfoOpen(true),
|
|
95
|
+
)
|
|
96
|
+
const isUpwards = useOpenUpwards(menu.state.isOpen, menu.menuRef)
|
|
97
|
+
const listClass = `cv-p-failed-menu__list cv-p-message-menu__list${isUpwards ? ' cv-p-message-menu__list--up' : ''}`
|
|
98
|
+
|
|
99
|
+
function choose(entry: Entry): void {
|
|
100
|
+
menu.close()
|
|
101
|
+
entry.run?.()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<span
|
|
106
|
+
className="cv-p-message-menu"
|
|
107
|
+
data-open={menu.state.isOpen || isInfoOpen ? 'true' : undefined}
|
|
108
|
+
ref={menu.containerRef}
|
|
109
|
+
onBlur={menu.handleBlur}
|
|
110
|
+
>
|
|
111
|
+
<button
|
|
112
|
+
type="button"
|
|
113
|
+
id={triggerId}
|
|
114
|
+
ref={menu.triggerRef}
|
|
115
|
+
className="cv-p-message-menu__trigger"
|
|
116
|
+
aria-haspopup="menu"
|
|
117
|
+
aria-expanded={menu.state.isOpen}
|
|
118
|
+
aria-label={label}
|
|
119
|
+
onPointerDown={props.onTriggerPointerDown}
|
|
120
|
+
onClick={menu.handleTriggerClick}
|
|
121
|
+
onKeyDown={menu.handleTriggerKeyDown}
|
|
122
|
+
>
|
|
123
|
+
<ChevronDown size={16} aria-hidden={true} />
|
|
124
|
+
</button>
|
|
125
|
+
{menu.state.isOpen ? (
|
|
126
|
+
<div
|
|
127
|
+
role="menu"
|
|
128
|
+
aria-labelledby={triggerId}
|
|
129
|
+
ref={menu.menuRef}
|
|
130
|
+
className={listClass}
|
|
131
|
+
onKeyDown={menu.handleMenuKeyDown}
|
|
132
|
+
>
|
|
133
|
+
{entries.map((entry) =>
|
|
134
|
+
entry.href ? (
|
|
135
|
+
<a
|
|
136
|
+
key={entry.id}
|
|
137
|
+
role="menuitem"
|
|
138
|
+
tabIndex={-1}
|
|
139
|
+
className="cv-p-failed-menu__item cv-p-message-menu__item"
|
|
140
|
+
href={entry.href}
|
|
141
|
+
download={entry.download}
|
|
142
|
+
target="_blank"
|
|
143
|
+
rel="noopener noreferrer"
|
|
144
|
+
aria-disabled={entry.disabled ? 'true' : undefined}
|
|
145
|
+
onClick={() => choose(entry)}
|
|
146
|
+
>
|
|
147
|
+
{entry.label}
|
|
148
|
+
</a>
|
|
149
|
+
) : (
|
|
150
|
+
<button
|
|
151
|
+
key={entry.id}
|
|
152
|
+
type="button"
|
|
153
|
+
role="menuitem"
|
|
154
|
+
tabIndex={-1}
|
|
155
|
+
className="cv-p-failed-menu__item cv-p-message-menu__item"
|
|
156
|
+
disabled={entry.disabled}
|
|
157
|
+
onClick={() => choose(entry)}
|
|
158
|
+
>
|
|
159
|
+
{entry.label}
|
|
160
|
+
</button>
|
|
161
|
+
),
|
|
162
|
+
)}
|
|
163
|
+
</div>
|
|
164
|
+
) : null}
|
|
165
|
+
{isInfoOpen ? <ParticipantMessageInfo state={state} message={props.message} onClose={closeInfo} /> : null}
|
|
166
|
+
</span>
|
|
167
|
+
)
|
|
168
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { ParticipantMessageActionsTarget } from './ParticipantAuthorAvatar'
|
|
4
|
+
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
5
|
+
import { isOwnMessage, type ParticipantTimelineItem } from './participantMessages'
|
|
6
|
+
import { toggleSelectedId, type ParticipantMessageMenuOptions } from './participantMessageMenuModel'
|
|
7
|
+
import type { ParticipantPerspective } from './participantPerspective'
|
|
8
|
+
|
|
9
|
+
export type ParticipantMessageMenuState = {
|
|
10
|
+
readonly options: ParticipantMessageMenuOptions
|
|
11
|
+
readonly labels: ParticipantConversationsLabels
|
|
12
|
+
readonly locale?: string
|
|
13
|
+
/** Server messages of the thread, in conversation order: what the selection bar acts on. */
|
|
14
|
+
readonly messages: readonly ParticipantMessageActionsTarget[]
|
|
15
|
+
readonly selectedIds: readonly string[]
|
|
16
|
+
readonly isSelecting: boolean
|
|
17
|
+
/** Enters selection mode, with this message already checked when one is given. */
|
|
18
|
+
readonly start: (id?: string) => void
|
|
19
|
+
readonly toggle: (id: string) => void
|
|
20
|
+
readonly clear: () => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const MessageMenuContext = createContext<ParticipantMessageMenuState | null>(null)
|
|
24
|
+
|
|
25
|
+
/** Null when the host did not opt in: the bubble stays as it was. */
|
|
26
|
+
export function useParticipantMessageMenu(): ParticipantMessageMenuState | null {
|
|
27
|
+
return useContext(MessageMenuContext)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function toMessageTargets(
|
|
31
|
+
items: readonly ParticipantTimelineItem[],
|
|
32
|
+
perspective?: ParticipantPerspective,
|
|
33
|
+
): readonly ParticipantMessageActionsTarget[] {
|
|
34
|
+
return items.flatMap((item) =>
|
|
35
|
+
item.kind === 'server'
|
|
36
|
+
? [
|
|
37
|
+
{
|
|
38
|
+
id: item.message.id,
|
|
39
|
+
text: item.message.text ?? null,
|
|
40
|
+
authorName: item.message.authorName ?? null,
|
|
41
|
+
isMine: isOwnMessage(item.message, perspective),
|
|
42
|
+
attachments: item.message.attachments,
|
|
43
|
+
createdAt: item.message.createdAt,
|
|
44
|
+
status: item.message.status,
|
|
45
|
+
readAt: item.message.readAt ?? null,
|
|
46
|
+
},
|
|
47
|
+
]
|
|
48
|
+
: [],
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type ProviderProps = {
|
|
53
|
+
readonly options?: ParticipantMessageMenuOptions
|
|
54
|
+
readonly labels: ParticipantConversationsLabels
|
|
55
|
+
readonly locale?: string
|
|
56
|
+
readonly items: readonly ParticipantTimelineItem[]
|
|
57
|
+
readonly perspective?: ParticipantPerspective
|
|
58
|
+
readonly children: ReactNode
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Holds the selection of one thread; Escape leaves selection mode, as in any system selection. */
|
|
62
|
+
export function ParticipantMessageMenuProvider({
|
|
63
|
+
options,
|
|
64
|
+
labels,
|
|
65
|
+
locale,
|
|
66
|
+
items,
|
|
67
|
+
perspective,
|
|
68
|
+
children,
|
|
69
|
+
}: ProviderProps) {
|
|
70
|
+
const [selection, setSelection] = useState<{ readonly isSelecting: boolean; readonly ids: readonly string[] }>({
|
|
71
|
+
isSelecting: false,
|
|
72
|
+
ids: [],
|
|
73
|
+
})
|
|
74
|
+
const clear = useCallback(() => setSelection({ isSelecting: false, ids: [] }), [])
|
|
75
|
+
const start = useCallback((id?: string) => setSelection({ isSelecting: true, ids: id ? [id] : [] }), [])
|
|
76
|
+
const toggle = useCallback(
|
|
77
|
+
(id: string) => setSelection((current) => ({ ...current, ids: toggleSelectedId(current.ids, id) })),
|
|
78
|
+
[],
|
|
79
|
+
)
|
|
80
|
+
const messages = useMemo(() => toMessageTargets(items, perspective), [items, perspective])
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (!selection.isSelecting) return undefined
|
|
84
|
+
function handleKeyDown(event: KeyboardEvent): void {
|
|
85
|
+
if (event.key === 'Escape') clear()
|
|
86
|
+
}
|
|
87
|
+
document.addEventListener('keydown', handleKeyDown)
|
|
88
|
+
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
89
|
+
}, [selection.isSelecting, clear])
|
|
90
|
+
|
|
91
|
+
const value = useMemo<ParticipantMessageMenuState | null>(
|
|
92
|
+
() =>
|
|
93
|
+
options
|
|
94
|
+
? {
|
|
95
|
+
options,
|
|
96
|
+
labels,
|
|
97
|
+
locale,
|
|
98
|
+
messages,
|
|
99
|
+
selectedIds: selection.ids,
|
|
100
|
+
isSelecting: selection.isSelecting,
|
|
101
|
+
start,
|
|
102
|
+
toggle,
|
|
103
|
+
clear,
|
|
104
|
+
}
|
|
105
|
+
: null,
|
|
106
|
+
[options, labels, locale, messages, selection, start, toggle, clear],
|
|
107
|
+
)
|
|
108
|
+
return <MessageMenuContext.Provider value={value}>{children}</MessageMenuContext.Provider>
|
|
109
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { X } from 'lucide-react'
|
|
2
|
+
|
|
3
|
+
import type { ParticipantMessageMenuState } from './ParticipantMessageMenuContext'
|
|
4
|
+
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS as DEFAULTS, formatParticipantLabel } from './participantLabels'
|
|
5
|
+
import { formatMessagesForCopy, selectedInOrder } from './participantMessageMenuModel'
|
|
6
|
+
import { useParticipantCopyFeedback } from './useParticipantCopyFeedback'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Takes the composer's place while messages are selected: how many, Copy (with date, time and author, one
|
|
10
|
+
* line each) and the host's actions, all in conversation order. The close button and Escape leave the mode.
|
|
11
|
+
*/
|
|
12
|
+
export function ParticipantSelectionBar({ state }: { readonly state: ParticipantMessageMenuState }) {
|
|
13
|
+
const { labels } = state
|
|
14
|
+
const { copy } = useParticipantCopyFeedback()
|
|
15
|
+
const selected = selectedInOrder(state.messages, state.selectedIds)
|
|
16
|
+
const count = formatParticipantLabel(labels.selectedCount ?? DEFAULTS.selectedCount ?? '{count}', selected.length)
|
|
17
|
+
const actions = state.options.selectionActions?.(selected) ?? []
|
|
18
|
+
const isEmpty = selected.length === 0
|
|
19
|
+
|
|
20
|
+
function copySelected(): void {
|
|
21
|
+
copy(
|
|
22
|
+
formatMessagesForCopy(selected, {
|
|
23
|
+
locale: state.locale,
|
|
24
|
+
meLabel: labels.me,
|
|
25
|
+
transcriptionLabel: labels.transcriptionPrefix ?? DEFAULTS.transcriptionPrefix,
|
|
26
|
+
}),
|
|
27
|
+
labels.valueCopied,
|
|
28
|
+
)
|
|
29
|
+
state.clear()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className="cv-p-selection-bar" role="toolbar" aria-label={count}>
|
|
34
|
+
<button
|
|
35
|
+
type="button"
|
|
36
|
+
className="cv-p-selection-bar__close"
|
|
37
|
+
aria-label={labels.cancelSelection ?? DEFAULTS.cancelSelection}
|
|
38
|
+
onClick={state.clear}
|
|
39
|
+
>
|
|
40
|
+
<X size={18} aria-hidden={true} />
|
|
41
|
+
</button>
|
|
42
|
+
<span className="cv-p-selection-bar__count" aria-live="polite">
|
|
43
|
+
{count}
|
|
44
|
+
</span>
|
|
45
|
+
<button type="button" className="cv-p-selection-bar__action" disabled={isEmpty} onClick={copySelected}>
|
|
46
|
+
{labels.copyMessage ?? DEFAULTS.copyMessage}
|
|
47
|
+
</button>
|
|
48
|
+
{actions.map((action) => (
|
|
49
|
+
<button
|
|
50
|
+
key={action.id}
|
|
51
|
+
type="button"
|
|
52
|
+
className="cv-p-selection-bar__action"
|
|
53
|
+
disabled={isEmpty || action.disabled}
|
|
54
|
+
onClick={() => {
|
|
55
|
+
action.onSelect(selected)
|
|
56
|
+
state.clear()
|
|
57
|
+
}}
|
|
58
|
+
>
|
|
59
|
+
{action.label}
|
|
60
|
+
</button>
|
|
61
|
+
))}
|
|
62
|
+
</div>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
@@ -7,6 +7,7 @@ import type { QuickReply } from '../quickReplies/quickReply.types'
|
|
|
7
7
|
import type { ParticipantTimelineItem } from './participantMessages'
|
|
8
8
|
import { TooltipLayer } from '../Tooltip'
|
|
9
9
|
import { ParticipantComposer } from './ParticipantComposer'
|
|
10
|
+
import { ParticipantConversationMenu } from './ParticipantConversationMenu'
|
|
10
11
|
import { useSuppressTouchTooltips } from './useSuppressTouchTooltips'
|
|
11
12
|
import { ParticipantCopyAnnouncer, ParticipantThreadLive } from './ParticipantCopyAnnouncer'
|
|
12
13
|
import type { ResolveParticipantAttachmentUrl } from './ParticipantAttachmentItem'
|
|
@@ -16,9 +17,12 @@ import { resolveLoadView, type ParticipantLoadStatus } from './participantLoadVi
|
|
|
16
17
|
import { ParticipantLoadError, ParticipantLoading } from './ParticipantLoadState'
|
|
17
18
|
import type { ParticipantPerspective } from './participantPerspective'
|
|
18
19
|
import type { ParticipantThreadScroll } from './useStickToBottom'
|
|
19
|
-
import type { ParticipantAuthorAvatarRenderer } from './ParticipantAuthorAvatar'
|
|
20
|
+
import type { ParticipantAuthorAvatarRenderer, ParticipantMessageActionsRenderer } from './ParticipantAuthorAvatar'
|
|
20
21
|
import { ParticipantScrollToLatest } from './ParticipantScrollToLatest'
|
|
21
22
|
import { ParticipantTimeline } from './ParticipantTimeline'
|
|
23
|
+
import { ParticipantMessageMenuProvider, useParticipantMessageMenu } from './ParticipantMessageMenuContext'
|
|
24
|
+
import type { ParticipantMessageMenuOptions } from './participantMessageMenuModel'
|
|
25
|
+
import { ParticipantSelectionBar } from './ParticipantSelectionBar'
|
|
22
26
|
import { ParticipantThreadHeader } from './ParticipantThreadHeader'
|
|
23
27
|
import type { ParticipantSubjectGroup, ParticipantSubjectIconRenderer } from './participant.types'
|
|
24
28
|
import type { ParticipantAudioRecordingOptions } from './participantAudio.types'
|
|
@@ -62,6 +66,10 @@ export type ParticipantThreadProps = {
|
|
|
62
66
|
readonly avatars?: 'initials'
|
|
63
67
|
/** Host slot for the author's photo; wins over 'initials' and enables avatars on its own. */
|
|
64
68
|
readonly renderAuthorAvatar?: ParticipantAuthorAvatarRenderer
|
|
69
|
+
/** Host slot at the end of each message's meta line (copy, forward...). The package knows no action. */
|
|
70
|
+
readonly renderMessageActions?: ParticipantMessageActionsRenderer
|
|
71
|
+
/** Opt-in message menu (copy, select messages, host entries); absent leaves the bubbles as they were. */
|
|
72
|
+
readonly messageMenu?: ParticipantMessageMenuOptions
|
|
65
73
|
/** Host icon of the conversation avatar in the header; absent or empty falls back to the subject group icon. */
|
|
66
74
|
readonly renderSubjectIcon?: ParticipantSubjectIconRenderer
|
|
67
75
|
/** Speech-bubble tail on the bottom corner; false removes it. Default true. */
|
|
@@ -82,6 +90,8 @@ export type ParticipantThreadProps = {
|
|
|
82
90
|
|
|
83
91
|
function Footer({ props }: { readonly props: ParticipantThreadProps }) {
|
|
84
92
|
const { conversation, labels, draft } = props
|
|
93
|
+
const menu = useParticipantMessageMenu()
|
|
94
|
+
if (menu?.isSelecting) return <ParticipantSelectionBar state={menu} />
|
|
85
95
|
if (conversation.status === 'closed') {
|
|
86
96
|
return <p className="cv-p-thread__closed">{labels.closedNotice}</p>
|
|
87
97
|
}
|
|
@@ -113,6 +123,15 @@ function Footer({ props }: { readonly props: ParticipantThreadProps }) {
|
|
|
113
123
|
}
|
|
114
124
|
|
|
115
125
|
function Header({ props }: { readonly props: ParticipantThreadProps }) {
|
|
126
|
+
const menu = useParticipantMessageMenu()
|
|
127
|
+
const actions = menu ? (
|
|
128
|
+
<>
|
|
129
|
+
{props.headerActions}
|
|
130
|
+
<ParticipantConversationMenu state={menu} title={props.conversation.subjectLabel} />
|
|
131
|
+
</>
|
|
132
|
+
) : (
|
|
133
|
+
props.headerActions
|
|
134
|
+
)
|
|
116
135
|
return (
|
|
117
136
|
<ParticipantThreadHeader
|
|
118
137
|
conversation={props.conversation}
|
|
@@ -122,7 +141,7 @@ function Header({ props }: { readonly props: ParticipantThreadProps }) {
|
|
|
122
141
|
onBack={props.onBack}
|
|
123
142
|
onOpenSubject={props.onOpenSubject}
|
|
124
143
|
counterpartLabel={props.counterpartLabel}
|
|
125
|
-
actions={
|
|
144
|
+
actions={actions}
|
|
126
145
|
/>
|
|
127
146
|
)
|
|
128
147
|
}
|
|
@@ -164,7 +183,15 @@ function ThreadBody(props: ParticipantThreadProps) {
|
|
|
164
183
|
export function ParticipantThread(props: ParticipantThreadProps) {
|
|
165
184
|
return (
|
|
166
185
|
<ParticipantCopyAnnouncer>
|
|
167
|
-
<
|
|
186
|
+
<ParticipantMessageMenuProvider
|
|
187
|
+
options={props.messageMenu}
|
|
188
|
+
labels={props.labels}
|
|
189
|
+
locale={props.locale}
|
|
190
|
+
items={props.items}
|
|
191
|
+
perspective={props.perspective}
|
|
192
|
+
>
|
|
193
|
+
<ThreadBody {...props} />
|
|
194
|
+
</ParticipantMessageMenuProvider>
|
|
168
195
|
<TooltipLayer />
|
|
169
196
|
</ParticipantCopyAnnouncer>
|
|
170
197
|
)
|
|
@@ -103,6 +103,8 @@ function LoadedThread(props: LoadedThreadProps) {
|
|
|
103
103
|
renderSubjectIcon={props.renderSubjectIcon}
|
|
104
104
|
avatars={props.avatars}
|
|
105
105
|
renderAuthorAvatar={props.renderAuthorAvatar}
|
|
106
|
+
renderMessageActions={props.renderMessageActions}
|
|
107
|
+
messageMenu={props.messageMenu}
|
|
106
108
|
tail={props.tail}
|
|
107
109
|
audioRecording={props.audioRecording}
|
|
108
110
|
renderComposerLock={props.renderComposerLock}
|
|
@@ -4,7 +4,11 @@ import { channelCapabilityFor } from '../channelCapability'
|
|
|
4
4
|
import type { ConversationChannel } from '../conversationChannel'
|
|
5
5
|
import { isSameDay } from '../lib/format'
|
|
6
6
|
import type { ResolveParticipantAttachmentUrl } from './ParticipantAttachmentItem'
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
ParticipantAuthorAvatar,
|
|
9
|
+
type ParticipantAuthorAvatarRenderer,
|
|
10
|
+
type ParticipantMessageActionsRenderer,
|
|
11
|
+
} from './ParticipantAuthorAvatar'
|
|
8
12
|
import { ParticipantDayDivider } from './ParticipantDayDivider'
|
|
9
13
|
import { ParticipantMessageBubble } from './ParticipantMessageBubble'
|
|
10
14
|
import { shouldShowAvatar, type ParticipantAvatarAuthor } from './participantAvatar'
|
|
@@ -26,6 +30,7 @@ export type ParticipantTimelineProps = {
|
|
|
26
30
|
readonly pendingActions?: ParticipantPendingActions
|
|
27
31
|
readonly avatars?: 'initials'
|
|
28
32
|
readonly renderAuthorAvatar?: ParticipantAuthorAvatarRenderer
|
|
33
|
+
readonly renderMessageActions?: ParticipantMessageActionsRenderer
|
|
29
34
|
readonly tail?: boolean
|
|
30
35
|
readonly perspective?: ParticipantPerspective
|
|
31
36
|
}
|
|
@@ -98,6 +103,7 @@ export function ParticipantTimeline(props: ParticipantTimelineProps) {
|
|
|
98
103
|
tail={props.tail}
|
|
99
104
|
perspective={props.perspective}
|
|
100
105
|
avatar={avatar}
|
|
106
|
+
renderMessageActions={props.renderMessageActions}
|
|
101
107
|
{...resolveActions(item, pendingActions)}
|
|
102
108
|
/>,
|
|
103
109
|
)
|
package/src/participant/index.ts
CHANGED
|
@@ -2,7 +2,11 @@ export { ParticipantConversations, type ParticipantConversationsProps } from './
|
|
|
2
2
|
export { ConversationThread, type ConversationThreadProps } from './ConversationThread'
|
|
3
3
|
export type { ConversationThreadApi } from './conversationThreadApi'
|
|
4
4
|
export type { ParticipantPerspective } from './participantPerspective'
|
|
5
|
-
export type {
|
|
5
|
+
export type {
|
|
6
|
+
ParticipantAuthorAvatarRenderer,
|
|
7
|
+
ParticipantMessageActionsRenderer,
|
|
8
|
+
ParticipantMessageActionsTarget,
|
|
9
|
+
} from './ParticipantAuthorAvatar'
|
|
6
10
|
export { groupParticipantConversations } from './participantGrouping'
|
|
7
11
|
export { DEFAULT_MAX_RECORDING_BYTES, type ParticipantAudioRecordingOptions } from './participantAudio.types'
|
|
8
12
|
export { DEFAULT_PARTICIPANT_AUDIO_LABELS, type ParticipantAudioLabels } from './participantAudioLabels'
|
|
@@ -31,3 +35,9 @@ export type {
|
|
|
31
35
|
ParticipantSubjectRef,
|
|
32
36
|
ParticipantTranscription,
|
|
33
37
|
} from '@adatechnology/conversation-contracts'
|
|
38
|
+
export type {
|
|
39
|
+
ParticipantMessageMenuItem,
|
|
40
|
+
ParticipantMessageMenuOptions,
|
|
41
|
+
ParticipantSelectionAction,
|
|
42
|
+
} from './participantMessageMenuModel'
|
|
43
|
+
export { formatMessagesForCopy } from './participantMessageMenuModel'
|