@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.
Files changed (37) 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 +87 -2
  5. package/dist/participant/index.js +1073 -398
  6. package/dist/styles.css +218 -0
  7. package/package.json +1 -1
  8. package/src/conversationTranscript.ts +6 -2
  9. package/src/participant/ConversationThread.tsx +8 -1
  10. package/src/participant/ParticipantAuthorAvatar.tsx +18 -0
  11. package/src/participant/ParticipantBubbleBody.tsx +33 -3
  12. package/src/participant/ParticipantConversationMenu.tsx +89 -0
  13. package/src/participant/ParticipantConversations.tsx +6 -1
  14. package/src/participant/ParticipantMenuBubble.tsx +106 -0
  15. package/src/participant/ParticipantMessageBubble.test.tsx +51 -0
  16. package/src/participant/ParticipantMessageBubble.tsx +39 -7
  17. package/src/participant/ParticipantMessageInfo.tsx +70 -0
  18. package/src/participant/ParticipantMessageMenu.tsx +168 -0
  19. package/src/participant/ParticipantMessageMenuContext.tsx +109 -0
  20. package/src/participant/ParticipantSelectionBar.tsx +64 -0
  21. package/src/participant/ParticipantThread.tsx +30 -3
  22. package/src/participant/ParticipantThreadScreen.tsx +2 -0
  23. package/src/participant/ParticipantTimeline.tsx +7 -1
  24. package/src/participant/index.ts +11 -1
  25. package/src/participant/participantConversationMenuDom.test.tsx +166 -0
  26. package/src/participant/participantLabels.ts +40 -0
  27. package/src/participant/participantMessageInfoModel.test.ts +68 -0
  28. package/src/participant/participantMessageInfoModel.ts +80 -0
  29. package/src/participant/participantMessageMenuDom.test.tsx +111 -0
  30. package/src/participant/participantMessageMenuModel.test.ts +43 -0
  31. package/src/participant/participantMessageMenuModel.ts +91 -0
  32. package/src/participant/participantMessageMoment.ts +7 -0
  33. package/src/participant/participantTranscriptionText.test.ts +48 -0
  34. package/src/participant/participantTranscriptionText.ts +28 -0
  35. package/src/participant/useOpenUpwards.ts +22 -0
  36. package/src/participant/useParticipantFailedMenu.ts +3 -0
  37. 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
- export function ParticipantMessageBubble(props: ParticipantMessageBubbleProps) {
81
- const { item, labels, confirmsRead, onRetry, avatar, tail = true, perspective } = props
82
- const textId = useId()
83
- const content = describeItem(item, perspective)
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={bubbleClassName(content.isMine, tail, isFailed)}>
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={props.headerActions}
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
- <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>
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 { ParticipantAuthorAvatar, type ParticipantAuthorAvatarRenderer } from './ParticipantAuthorAvatar'
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
  )
@@ -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 { ParticipantAuthorAvatarRenderer } from './ParticipantAuthorAvatar'
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'