@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.
Files changed (35) hide show
  1. package/dist/chunk-RLUNDWGA.js +51 -0
  2. package/dist/index.d.ts +2 -1
  3. package/dist/index.js +5 -43
  4. package/dist/participant/index.d.ts +74 -2
  5. package/dist/participant/index.js +1058 -399
  6. package/dist/styles.css +213 -0
  7. package/package.json +3 -3
  8. package/src/conversationTranscript.ts +6 -2
  9. package/src/participant/ConversationThread.tsx +4 -0
  10. package/src/participant/ParticipantAuthorAvatar.tsx +7 -1
  11. package/src/participant/ParticipantBubbleBody.tsx +18 -3
  12. package/src/participant/ParticipantConversationMenu.tsx +89 -0
  13. package/src/participant/ParticipantConversations.tsx +3 -0
  14. package/src/participant/ParticipantMenuBubble.tsx +106 -0
  15. package/src/participant/ParticipantMessageBubble.tsx +34 -7
  16. package/src/participant/ParticipantMessageInfo.tsx +70 -0
  17. package/src/participant/ParticipantMessageMenu.tsx +168 -0
  18. package/src/participant/ParticipantMessageMenuContext.tsx +109 -0
  19. package/src/participant/ParticipantSelectionBar.tsx +64 -0
  20. package/src/participant/ParticipantThread.tsx +27 -2
  21. package/src/participant/ParticipantThreadScreen.tsx +1 -0
  22. package/src/participant/index.ts +6 -0
  23. package/src/participant/participantConversationMenuDom.test.tsx +166 -0
  24. package/src/participant/participantLabels.ts +40 -0
  25. package/src/participant/participantMessageInfoModel.test.ts +68 -0
  26. package/src/participant/participantMessageInfoModel.ts +80 -0
  27. package/src/participant/participantMessageMenuDom.test.tsx +111 -0
  28. package/src/participant/participantMessageMenuModel.test.ts +43 -0
  29. package/src/participant/participantMessageMenuModel.ts +91 -0
  30. package/src/participant/participantMessageMoment.ts +7 -0
  31. package/src/participant/participantTranscriptionText.test.ts +48 -0
  32. package/src/participant/participantTranscriptionText.ts +28 -0
  33. package/src/participant/useOpenUpwards.ts +22 -0
  34. package/src/participant/useParticipantFailedMenu.ts +3 -0
  35. 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={props.headerActions}
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
- <ThreadBody {...props} />
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}
@@ -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
+ })