@adatechnology/conversations-ui 0.13.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 +74 -2
- package/dist/participant/index.js +1058 -399
- package/dist/styles.css +213 -0
- package/package.json +3 -3
- package/src/conversationTranscript.ts +6 -2
- package/src/participant/ConversationThread.tsx +4 -0
- package/src/participant/ParticipantAuthorAvatar.tsx +7 -1
- package/src/participant/ParticipantBubbleBody.tsx +18 -3
- package/src/participant/ParticipantConversationMenu.tsx +89 -0
- package/src/participant/ParticipantConversations.tsx +3 -0
- package/src/participant/ParticipantMenuBubble.tsx +106 -0
- package/src/participant/ParticipantMessageBubble.tsx +34 -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 +27 -2
- package/src/participant/ParticipantThreadScreen.tsx +1 -0
- package/src/participant/index.ts +6 -0
- 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 +40 -0
|
@@ -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'
|
|
@@ -19,6 +20,9 @@ import type { ParticipantThreadScroll } from './useStickToBottom'
|
|
|
19
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'
|
|
@@ -64,6 +68,8 @@ export type ParticipantThreadProps = {
|
|
|
64
68
|
readonly renderAuthorAvatar?: ParticipantAuthorAvatarRenderer
|
|
65
69
|
/** Host slot at the end of each message's meta line (copy, forward...). The package knows no action. */
|
|
66
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
|
|
67
73
|
/** Host icon of the conversation avatar in the header; absent or empty falls back to the subject group icon. */
|
|
68
74
|
readonly renderSubjectIcon?: ParticipantSubjectIconRenderer
|
|
69
75
|
/** Speech-bubble tail on the bottom corner; false removes it. Default true. */
|
|
@@ -84,6 +90,8 @@ export type ParticipantThreadProps = {
|
|
|
84
90
|
|
|
85
91
|
function Footer({ props }: { readonly props: ParticipantThreadProps }) {
|
|
86
92
|
const { conversation, labels, draft } = props
|
|
93
|
+
const menu = useParticipantMessageMenu()
|
|
94
|
+
if (menu?.isSelecting) return <ParticipantSelectionBar state={menu} />
|
|
87
95
|
if (conversation.status === 'closed') {
|
|
88
96
|
return <p className="cv-p-thread__closed">{labels.closedNotice}</p>
|
|
89
97
|
}
|
|
@@ -115,6 +123,15 @@ function Footer({ props }: { readonly props: ParticipantThreadProps }) {
|
|
|
115
123
|
}
|
|
116
124
|
|
|
117
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
|
+
)
|
|
118
135
|
return (
|
|
119
136
|
<ParticipantThreadHeader
|
|
120
137
|
conversation={props.conversation}
|
|
@@ -124,7 +141,7 @@ function Header({ props }: { readonly props: ParticipantThreadProps }) {
|
|
|
124
141
|
onBack={props.onBack}
|
|
125
142
|
onOpenSubject={props.onOpenSubject}
|
|
126
143
|
counterpartLabel={props.counterpartLabel}
|
|
127
|
-
actions={
|
|
144
|
+
actions={actions}
|
|
128
145
|
/>
|
|
129
146
|
)
|
|
130
147
|
}
|
|
@@ -166,7 +183,15 @@ function ThreadBody(props: ParticipantThreadProps) {
|
|
|
166
183
|
export function ParticipantThread(props: ParticipantThreadProps) {
|
|
167
184
|
return (
|
|
168
185
|
<ParticipantCopyAnnouncer>
|
|
169
|
-
<
|
|
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>
|
|
170
195
|
<TooltipLayer />
|
|
171
196
|
</ParticipantCopyAnnouncer>
|
|
172
197
|
)
|
|
@@ -104,6 +104,7 @@ function LoadedThread(props: LoadedThreadProps) {
|
|
|
104
104
|
avatars={props.avatars}
|
|
105
105
|
renderAuthorAvatar={props.renderAuthorAvatar}
|
|
106
106
|
renderMessageActions={props.renderMessageActions}
|
|
107
|
+
messageMenu={props.messageMenu}
|
|
107
108
|
tail={props.tail}
|
|
108
109
|
audioRecording={props.audioRecording}
|
|
109
110
|
renderComposerLock={props.renderComposerLock}
|
package/src/participant/index.ts
CHANGED
|
@@ -35,3 +35,9 @@ export type {
|
|
|
35
35
|
ParticipantSubjectRef,
|
|
36
36
|
ParticipantTranscription,
|
|
37
37
|
} from '@adatechnology/conversation-contracts'
|
|
38
|
+
export type {
|
|
39
|
+
ParticipantMessageMenuItem,
|
|
40
|
+
ParticipantMessageMenuOptions,
|
|
41
|
+
ParticipantSelectionAction,
|
|
42
|
+
} from './participantMessageMenuModel'
|
|
43
|
+
export { formatMessagesForCopy } from './participantMessageMenuModel'
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it } from 'bun:test'
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
|
|
4
|
+
import { setupDom, pressKey, type DomHarness } from '../domHarness.test-helper'
|
|
5
|
+
import { ParticipantConversationMenu } from './ParticipantConversationMenu'
|
|
6
|
+
import { ParticipantMessageMenuProvider, useParticipantMessageMenu } from './ParticipantMessageMenuContext'
|
|
7
|
+
import { ParticipantTimeline } from './ParticipantTimeline'
|
|
8
|
+
import { buildMessage } from './participantFixtures.test-helper'
|
|
9
|
+
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS as LABELS } from './participantLabels'
|
|
10
|
+
import type { ParticipantTimelineItem } from './participantMessages'
|
|
11
|
+
|
|
12
|
+
let harness: DomHarness
|
|
13
|
+
|
|
14
|
+
beforeAll(async () => {
|
|
15
|
+
harness = await setupDom()
|
|
16
|
+
})
|
|
17
|
+
afterAll(() => harness.teardown())
|
|
18
|
+
|
|
19
|
+
const ITEMS: readonly ParticipantTimelineItem[] = [
|
|
20
|
+
{
|
|
21
|
+
kind: 'server',
|
|
22
|
+
message: buildMessage({
|
|
23
|
+
id: 'm1',
|
|
24
|
+
text: 'Mine',
|
|
25
|
+
direction: 'inbound',
|
|
26
|
+
status: 'read',
|
|
27
|
+
readAt: '2026-10-01T10:30:00.000Z',
|
|
28
|
+
}),
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
kind: 'server',
|
|
32
|
+
message: buildMessage({
|
|
33
|
+
id: 'm2',
|
|
34
|
+
text: null,
|
|
35
|
+
direction: 'outbound',
|
|
36
|
+
authorName: 'Bruno',
|
|
37
|
+
attachments: [
|
|
38
|
+
{
|
|
39
|
+
id: 'a1',
|
|
40
|
+
kind: 'audio',
|
|
41
|
+
filename: 'voz.ogg',
|
|
42
|
+
mimeType: 'audio/ogg',
|
|
43
|
+
sizeBytes: 10,
|
|
44
|
+
transcription: { status: 'ready', text: 'Cheguei na doca.', generatedAt: '2026-10-01T10:31:00.000Z' },
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
}),
|
|
48
|
+
},
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
function HeaderMenu() {
|
|
52
|
+
const menu = useParticipantMessageMenu()
|
|
53
|
+
return menu ? (
|
|
54
|
+
<>
|
|
55
|
+
<ParticipantConversationMenu state={menu} title="Order 12" />
|
|
56
|
+
<span data-testid="selecting">{menu.isSelecting ? `on:${menu.selectedIds.length}` : 'off'}</span>
|
|
57
|
+
</>
|
|
58
|
+
) : null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function mount() {
|
|
62
|
+
return harness.mount(
|
|
63
|
+
<ParticipantMessageMenuProvider options={{}} labels={LABELS} items={ITEMS}>
|
|
64
|
+
<HeaderMenu />
|
|
65
|
+
<div className="cv-p-thread__scroll">
|
|
66
|
+
<ParticipantTimeline items={ITEMS} labels={LABELS} resolveAttachmentUrl={() => Promise.resolve('')} />
|
|
67
|
+
</div>
|
|
68
|
+
</ParticipantMessageMenuProvider>,
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function fire(element: Element, type: string): void {
|
|
73
|
+
act(() => element.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true })))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function item(container: HTMLElement, label: string): Element {
|
|
77
|
+
return Array.from(container.querySelectorAll('[role="menuitem"]')).find((entry) => entry.textContent === label)!
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe('message info and conversation menu in a DOM', () => {
|
|
81
|
+
it('shows who sent it, when, the delivery state and the read time; Escape closes', () => {
|
|
82
|
+
const { container, unmount } = mount()
|
|
83
|
+
fire(container.querySelector('.cv-p-message-menu__trigger')!, 'click')
|
|
84
|
+
fire(item(container, 'Message info'), 'click')
|
|
85
|
+
const panel = container.querySelector('[role="dialog"]')!
|
|
86
|
+
expect(panel.getAttribute('aria-label')).toBe('Message info')
|
|
87
|
+
expect(Array.from(panel.querySelectorAll('dt')).map((dt) => dt.textContent)).toEqual([
|
|
88
|
+
'From',
|
|
89
|
+
'Sent',
|
|
90
|
+
'Status',
|
|
91
|
+
'Read',
|
|
92
|
+
])
|
|
93
|
+
expect(panel.querySelectorAll('dd')[2]?.textContent).toBe(LABELS.statusRead)
|
|
94
|
+
pressKey(document.body, 'Escape')
|
|
95
|
+
expect(container.querySelector('[role="dialog"]')).toBeNull()
|
|
96
|
+
unmount()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('enters selection mode with nothing checked from the header', () => {
|
|
100
|
+
const { container, unmount } = mount()
|
|
101
|
+
const trigger = container.querySelector('.cv-p-conversation-menu__trigger')!
|
|
102
|
+
expect(trigger.getAttribute('aria-label')).toBe('Conversation options')
|
|
103
|
+
fire(trigger, 'click')
|
|
104
|
+
expect(
|
|
105
|
+
Array.from(container.querySelectorAll('.cv-p-conversation-menu [role="menuitem"]')).map((e) => e.textContent),
|
|
106
|
+
).toEqual(['Select messages', 'Download conversation'])
|
|
107
|
+
fire(item(container, 'Select messages'), 'click')
|
|
108
|
+
expect(container.querySelector('[data-testid="selecting"]')?.textContent).toBe('on:0')
|
|
109
|
+
unmount()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('downloads the conversation as a text file named after the title', async () => {
|
|
113
|
+
const saved: { name: string; blob: Blob | null } = { name: '', blob: null }
|
|
114
|
+
const originalCreate = URL.createObjectURL
|
|
115
|
+
const originalRevoke = URL.revokeObjectURL
|
|
116
|
+
const originalClick = HTMLAnchorElement.prototype.click
|
|
117
|
+
URL.createObjectURL = (blob: Blob) => {
|
|
118
|
+
saved.blob = blob
|
|
119
|
+
return 'blob:test'
|
|
120
|
+
}
|
|
121
|
+
URL.revokeObjectURL = () => undefined
|
|
122
|
+
HTMLAnchorElement.prototype.click = function click(this: HTMLAnchorElement) {
|
|
123
|
+
saved.name = this.download
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
const { container, unmount } = mount()
|
|
127
|
+
fire(container.querySelector('.cv-p-conversation-menu__trigger')!, 'click')
|
|
128
|
+
fire(item(container, 'Download conversation'), 'click')
|
|
129
|
+
expect(saved.name).toBe('Order 12.txt')
|
|
130
|
+
const text = await saved.blob!.text()
|
|
131
|
+
expect(text.startsWith('Order 12\n\n[')).toBe(true)
|
|
132
|
+
expect(text).toContain(`${LABELS.me}: Mine`)
|
|
133
|
+
expect(text).toContain('Bruno: [voz.ogg] Transcription: Cheguei na doca.')
|
|
134
|
+
unmount()
|
|
135
|
+
} finally {
|
|
136
|
+
URL.createObjectURL = originalCreate
|
|
137
|
+
URL.revokeObjectURL = originalRevoke
|
|
138
|
+
HTMLAnchorElement.prototype.click = originalClick
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('offers to copy and to download what was said in a transcribed audio', () => {
|
|
143
|
+
let savedName = ''
|
|
144
|
+
const originalCreate = URL.createObjectURL
|
|
145
|
+
const originalRevoke = URL.revokeObjectURL
|
|
146
|
+
const originalClick = HTMLAnchorElement.prototype.click
|
|
147
|
+
URL.createObjectURL = () => 'blob:test'
|
|
148
|
+
URL.revokeObjectURL = () => undefined
|
|
149
|
+
HTMLAnchorElement.prototype.click = function click(this: HTMLAnchorElement) {
|
|
150
|
+
savedName = this.download
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
const { container, unmount } = mount()
|
|
154
|
+
fire(container.querySelectorAll('.cv-p-message-menu__trigger')[1]!, 'click')
|
|
155
|
+
const labels = Array.from(container.querySelectorAll('[role="menuitem"]')).map((entry) => entry.textContent)
|
|
156
|
+
expect(labels).toEqual(['Select messages', 'Copy transcription', 'Download transcription', 'Message info'])
|
|
157
|
+
fire(item(container, 'Download transcription'), 'click')
|
|
158
|
+
expect(savedName).toBe('voz Transcription.txt')
|
|
159
|
+
unmount()
|
|
160
|
+
} finally {
|
|
161
|
+
URL.createObjectURL = originalCreate
|
|
162
|
+
URL.revokeObjectURL = originalRevoke
|
|
163
|
+
HTMLAnchorElement.prototype.click = originalClick
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
})
|