@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
@@ -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
+ })
@@ -44,6 +44,29 @@ export type ParticipantConversationsLabels = {
44
44
  readonly edit: string
45
45
  /** Name of the menu button on a failed message. Optional; translated hosts should fill it, otherwise the English default shows. */
46
46
  readonly messageOptions?: string
47
+ /** Message menu (opt-in `messageMenu`). Optional; translated hosts should fill them, otherwise English shows. */
48
+ readonly copyMessage?: string
49
+ readonly copySelection?: string
50
+ readonly selectMessages?: string
51
+ /** Name of the check box beside each message in selection mode. */
52
+ readonly selectMessage?: string
53
+ readonly cancelSelection?: string
54
+ /** Selection bar count; `{count}` is replaced. */
55
+ readonly selectedCount?: string
56
+ /** Message info panel: when it was sent, its delivery state and when it was read. */
57
+ readonly messageInfo?: string
58
+ readonly infoAuthor?: string
59
+ readonly infoSentAt?: string
60
+ readonly infoStatus?: string
61
+ readonly infoReadAt?: string
62
+ readonly closeMessageInfo?: string
63
+ /** Header menu of a thread with `messageMenu`: select messages and download the conversation as text. */
64
+ readonly conversationOptions?: string
65
+ readonly downloadConversation?: string
66
+ /** Transcribed audio: the word before what was said in copies and exports, and its two menu entries. */
67
+ readonly transcriptionPrefix?: string
68
+ readonly copyTranscription?: string
69
+ readonly downloadTranscription?: string
47
70
  /** Toggle that opens the message as received (e.g. the whole email). Optional; the English default shows otherwise. */
48
71
  readonly showOriginalText?: string
49
72
  readonly notFound: string
@@ -110,6 +133,23 @@ export const DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS: ParticipantConversationsL
110
133
  edit: 'Edit',
111
134
  messageOptions: DEFAULT_MESSAGE_OPTIONS_LABEL,
112
135
  showOriginalText: DEFAULT_SHOW_ORIGINAL_TEXT_LABEL,
136
+ copyMessage: 'Copy',
137
+ copySelection: 'Copy selection',
138
+ selectMessages: 'Select messages',
139
+ selectMessage: 'Select message',
140
+ cancelSelection: 'Cancel selection',
141
+ selectedCount: '{count} selected',
142
+ messageInfo: 'Message info',
143
+ infoAuthor: 'From',
144
+ infoSentAt: 'Sent',
145
+ infoStatus: 'Status',
146
+ infoReadAt: 'Read',
147
+ closeMessageInfo: 'Close',
148
+ conversationOptions: 'Conversation options',
149
+ downloadConversation: 'Download conversation',
150
+ transcriptionPrefix: 'Transcription',
151
+ copyTranscription: 'Copy transcription',
152
+ downloadTranscription: 'Download transcription',
113
153
  notFound: 'Conversation not found',
114
154
  loading: 'Loading…',
115
155
  loadError: 'Could not load. Check your connection and try again.',
@@ -0,0 +1,68 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import type { ParticipantMessageActionsTarget } from './ParticipantAuthorAvatar'
4
+ import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS as LABELS } from './participantLabels'
5
+ import { conversationExportFileName, conversationExportText, messageInfoRows } from './participantMessageInfoModel'
6
+
7
+ function target(overrides: Partial<ParticipantMessageActionsTarget>): ParticipantMessageActionsTarget {
8
+ return {
9
+ id: 'm1',
10
+ text: 'Hello',
11
+ authorName: 'Bruno',
12
+ isMine: false,
13
+ attachments: [],
14
+ createdAt: '2026-10-01T13:05:00.000Z',
15
+ ...overrides,
16
+ }
17
+ }
18
+
19
+ describe('message info', () => {
20
+ it('lists author and send time of an incoming message, and nothing about delivery', () => {
21
+ const rows = messageInfoRows(target({ status: 'read', readAt: '2026-10-01T14:00:00.000Z' }), LABELS, 'pt-BR')
22
+ expect(rows.map((row) => row.id)).toEqual(['author', 'sent'])
23
+ expect(rows[0]?.value).toBe('Bruno')
24
+ expect(rows[1]?.value).toMatch(/^01\/10\/2026 \d{2}:05$/)
25
+ })
26
+
27
+ it('adds delivery state and read time to a message this side sent', () => {
28
+ const rows = messageInfoRows(
29
+ target({ isMine: true, status: 'read', readAt: '2026-10-01T14:10:00.000Z' }),
30
+ LABELS,
31
+ 'pt-BR',
32
+ )
33
+ expect(rows.map((row) => [row.id, row.label])).toEqual([
34
+ ['author', 'From'],
35
+ ['sent', 'Sent'],
36
+ ['status', 'Status'],
37
+ ['read', 'Read'],
38
+ ])
39
+ expect(rows[0]?.value).toBe(LABELS.me)
40
+ expect(rows[2]?.value).toBe(LABELS.statusRead)
41
+ expect(rows[3]?.value).toMatch(/:10$/)
42
+ })
43
+
44
+ it('leaves out what the channel never reported', () => {
45
+ expect(messageInfoRows(target({ isMine: true }), LABELS).map((row) => row.id)).toEqual(['author', 'sent'])
46
+ })
47
+ })
48
+
49
+ describe('conversation export', () => {
50
+ it('writes the title, a blank line and one stamped line per message', () => {
51
+ const text = conversationExportText({
52
+ title: 'Order 12',
53
+ messages: [target({}), target({ id: 'm2', isMine: true, text: 'Ok' })],
54
+ locale: 'pt-BR',
55
+ meLabel: 'Me',
56
+ })
57
+ const lines = text.split('\n')
58
+ expect(lines[0]).toBe('Order 12')
59
+ expect(lines[1]).toBe('')
60
+ expect(lines[2]).toMatch(/^\[01\/10\/2026 \d{2}:05\] Bruno: Hello$/)
61
+ expect(lines[3]).toMatch(/\] Me: Ok$/)
62
+ })
63
+
64
+ it('makes a safe file name from the title', () => {
65
+ expect(conversationExportFileName('NF 123/1: atraso')).toBe('NF 123 1 atraso.txt')
66
+ expect(conversationExportFileName(' // ')).toBe('conversation.txt')
67
+ })
68
+ })
@@ -0,0 +1,80 @@
1
+ import type { MessageDeliveryStatus } from '@adatechnology/conversation-contracts'
2
+
3
+ import type { ParticipantMessageActionsTarget } from './ParticipantAuthorAvatar'
4
+ import {
5
+ DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS as DEFAULTS,
6
+ type ParticipantConversationsLabels,
7
+ } from './participantLabels'
8
+ import { formatMessagesForCopy } from './participantMessageMenuModel'
9
+ import { formatMessageMoment } from './participantMessageMoment'
10
+
11
+ export type ParticipantMessageInfoRow = { readonly id: string; readonly label: string; readonly value: string }
12
+
13
+ function statusLabel(status: MessageDeliveryStatus, labels: ParticipantConversationsLabels): string {
14
+ const byStatus: Record<MessageDeliveryStatus, string> = {
15
+ queued: labels.statusQueued,
16
+ sent: labels.statusSent,
17
+ delivered: labels.statusDelivered,
18
+ read: labels.statusRead,
19
+ failed: labels.statusFailedShort,
20
+ bounced: labels.statusFailedShort,
21
+ }
22
+ return byStatus[status]
23
+ }
24
+
25
+ /**
26
+ * What the info panel lists: who wrote it and when; for a message this side sent, also how far it got and
27
+ * when it was read, as far as the channel told. Nothing is invented: an unknown step is left out.
28
+ */
29
+ export function messageInfoRows(
30
+ message: ParticipantMessageActionsTarget,
31
+ labels: ParticipantConversationsLabels,
32
+ locale?: string,
33
+ ): readonly ParticipantMessageInfoRow[] {
34
+ const author = message.isMine ? labels.me : (message.authorName ?? '')
35
+ const rows: ParticipantMessageInfoRow[] = []
36
+ if (author !== '') rows.push({ id: 'author', label: labels.infoAuthor ?? DEFAULTS.infoAuthor ?? '', value: author })
37
+ rows.push({
38
+ id: 'sent',
39
+ label: labels.infoSentAt ?? DEFAULTS.infoSentAt ?? '',
40
+ value: formatMessageMoment(message.createdAt, locale),
41
+ })
42
+ if (!message.isMine) return rows
43
+ if (message.status) {
44
+ rows.push({
45
+ id: 'status',
46
+ label: labels.infoStatus ?? DEFAULTS.infoStatus ?? '',
47
+ value: statusLabel(message.status, labels),
48
+ })
49
+ }
50
+ if (message.readAt) {
51
+ rows.push({
52
+ id: 'read',
53
+ label: labels.infoReadAt ?? DEFAULTS.infoReadAt ?? '',
54
+ value: formatMessageMoment(message.readAt, locale),
55
+ })
56
+ }
57
+ return rows
58
+ }
59
+
60
+ type ConversationExport = {
61
+ readonly title: string
62
+ readonly messages: readonly ParticipantMessageActionsTarget[]
63
+ readonly locale?: string
64
+ readonly meLabel: string
65
+ readonly transcriptionLabel?: string
66
+ }
67
+
68
+ /** The conversation as a text file: the title, a blank line, then one `[date time] author: text` line each. */
69
+ export function conversationExportText({ title, messages, ...format }: ConversationExport): string {
70
+ return `${title}\n\n${formatMessagesForCopy(messages, format)}\n`
71
+ }
72
+
73
+ /** A file name any system accepts, from the conversation title. */
74
+ export function conversationExportFileName(title: string): string {
75
+ const safe = title
76
+ .replace(/[\\/:*?"<>|]+/g, ' ')
77
+ .replace(/\s+/g, ' ')
78
+ .trim()
79
+ return `${safe === '' ? 'conversation' : safe}.txt`
80
+ }
@@ -0,0 +1,111 @@
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 { ParticipantMessageMenuProvider } from './ParticipantMessageMenuContext'
6
+ import { ParticipantSelectionBar } from './ParticipantSelectionBar'
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 { ParticipantMessageMenuOptions } from './participantMessageMenuModel'
11
+ import type { ParticipantTimelineItem } from './participantMessages'
12
+ import { useParticipantMessageMenu } from './ParticipantMessageMenuContext'
13
+
14
+ let harness: DomHarness
15
+
16
+ beforeAll(async () => {
17
+ harness = await setupDom()
18
+ })
19
+ afterAll(() => harness.teardown())
20
+
21
+ const ITEMS: readonly ParticipantTimelineItem[] = [
22
+ { kind: 'server', message: buildMessage({ id: 'm1', text: 'First', direction: 'inbound', authorName: 'Bruno' }) },
23
+ { kind: 'server', message: buildMessage({ id: 'm2', text: 'Second', direction: 'inbound', authorName: 'Bruno' }) },
24
+ ]
25
+
26
+ function Bar() {
27
+ const menu = useParticipantMessageMenu()
28
+ return menu?.isSelecting ? <ParticipantSelectionBar state={menu} /> : null
29
+ }
30
+
31
+ function mount(options?: ParticipantMessageMenuOptions) {
32
+ return harness.mount(
33
+ <ParticipantMessageMenuProvider options={options} labels={LABELS} items={ITEMS}>
34
+ <div className="cv-p-thread__scroll">
35
+ <ParticipantTimeline items={ITEMS} labels={LABELS} resolveAttachmentUrl={() => Promise.resolve('')} />
36
+ </div>
37
+ <Bar />
38
+ </ParticipantMessageMenuProvider>,
39
+ )
40
+ }
41
+
42
+ function fire(element: Element, type: string): void {
43
+ act(() => element.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true })))
44
+ }
45
+
46
+ function menuLabels(container: HTMLElement): string[] {
47
+ return Array.from(container.querySelectorAll('[role="menuitem"]')).map((item) => item.textContent ?? '')
48
+ }
49
+
50
+ describe('message menu in a DOM', () => {
51
+ it('draws nothing new when the host did not opt in', () => {
52
+ const { container, unmount } = mount()
53
+ expect(container.querySelector('.cv-p-message-menu')).toBeNull()
54
+ unmount()
55
+ })
56
+
57
+ it('opens from the chevron with Copy, Select messages and the host entries', () => {
58
+ const forward = { id: 'forward', label: 'Forward', onSelect: () => undefined }
59
+ const { container, unmount } = mount({ items: () => [forward] })
60
+ const trigger = container.querySelector<HTMLButtonElement>('.cv-p-message-menu__trigger')!
61
+ expect(trigger.getAttribute('aria-haspopup')).toBe('menu')
62
+ fire(trigger, 'click')
63
+ expect(menuLabels(container)).toEqual(['Copy', 'Select messages', 'Message info', 'Forward'])
64
+ unmount()
65
+ })
66
+
67
+ it('opens from a right-click on the bubble', () => {
68
+ const { container, unmount } = mount({})
69
+ fire(container.querySelector('.cv-p-bubble')!, 'contextmenu')
70
+ expect(menuLabels(container)).toEqual(['Copy', 'Select messages', 'Message info'])
71
+ unmount()
72
+ })
73
+
74
+ it('selects messages with a check box per row, a bar with the count, and Escape leaves', () => {
75
+ let received: readonly string[] = []
76
+ const { container, unmount } = mount({
77
+ selectionActions: () => [
78
+ { id: 'fw', label: 'Forward all', onSelect: (messages) => (received = messages.map((m) => m.id)) },
79
+ ],
80
+ })
81
+ fire(container.querySelectorAll('.cv-p-message-menu__trigger')[1]!, 'click')
82
+ fire(
83
+ Array.from(container.querySelectorAll('[role="menuitem"]')).find(
84
+ (item) => item.textContent === 'Select messages',
85
+ )!,
86
+ 'click',
87
+ )
88
+ expect(container.querySelectorAll('.cv-p-select-row').length).toBe(2)
89
+ expect(container.querySelector('.cv-p-selection-bar__count')?.textContent).toBe('1 selected')
90
+ fire(container.querySelectorAll('.cv-p-select-row')[0]!, 'click')
91
+ expect(container.querySelector('.cv-p-selection-bar__count')?.textContent).toBe('2 selected')
92
+ fire(
93
+ Array.from(container.querySelectorAll('.cv-p-selection-bar__action')).find(
94
+ (b) => b.textContent === 'Forward all',
95
+ )!,
96
+ 'click',
97
+ )
98
+ expect(received).toEqual(['m1', 'm2'])
99
+ expect(container.querySelector('.cv-p-selection-bar')).toBeNull()
100
+ fire(container.querySelector('.cv-p-message-menu__trigger')!, 'click')
101
+ fire(
102
+ Array.from(container.querySelectorAll('[role="menuitem"]')).find(
103
+ (item) => item.textContent === 'Select messages',
104
+ )!,
105
+ 'click',
106
+ )
107
+ pressKey(document.body, 'Escape')
108
+ expect(container.querySelector('.cv-p-select-row')).toBeNull()
109
+ unmount()
110
+ })
111
+ })
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import type { ParticipantMessageActionsTarget } from './ParticipantAuthorAvatar'
4
+ import { copyableText, formatMessagesForCopy, selectedInOrder, toggleSelectedId } from './participantMessageMenuModel'
5
+
6
+ const FIRST: ParticipantMessageActionsTarget = {
7
+ id: 'm1',
8
+ text: 'Wait at the dock.',
9
+ authorName: 'Ana',
10
+ isMine: true,
11
+ attachments: [],
12
+ createdAt: '2026-10-20T14:20:00',
13
+ }
14
+ const SECOND: ParticipantMessageActionsTarget = {
15
+ id: 'm2',
16
+ text: ' Here is the bill. ',
17
+ authorName: 'Bruno',
18
+ isMine: false,
19
+ attachments: [{ id: 'a1', kind: 'document', filename: 'bill.pdf', mimeType: 'application/pdf', sizeBytes: 10 }],
20
+ createdAt: '2026-10-20T14:41:00',
21
+ }
22
+
23
+ describe('message menu model', () => {
24
+ it('toggles an id in and out, keeping the pick order', () => {
25
+ expect(toggleSelectedId(['m2'], 'm1')).toEqual(['m2', 'm1'])
26
+ expect(toggleSelectedId(['m2', 'm1'], 'm2')).toEqual(['m1'])
27
+ })
28
+
29
+ it('returns the selection in conversation order, not pick order', () => {
30
+ expect(selectedInOrder([FIRST, SECOND], ['m2', 'm1']).map((message) => message.id)).toEqual(['m1', 'm2'])
31
+ })
32
+
33
+ it('copies one message as its trimmed text, and nothing for a file alone', () => {
34
+ expect(copyableText(SECOND)).toBe('Here is the bill.')
35
+ expect(copyableText({ text: ' ' })).toBeNull()
36
+ })
37
+
38
+ it('copies several messages with date, time and author, one line each, files by name', () => {
39
+ expect(formatMessagesForCopy([FIRST, SECOND], { locale: 'pt-BR', meLabel: 'Eu' })).toBe(
40
+ '[20/10/2026 14:20] Eu: Wait at the dock.\n[20/10/2026 14:41] Bruno: Here is the bill. [bill.pdf]',
41
+ )
42
+ })
43
+ })
@@ -0,0 +1,91 @@
1
+ import type { ParticipantMessageActionsTarget } from './ParticipantAuthorAvatar'
2
+ import { formatMessageMoment } from './participantMessageMoment'
3
+ import { attachmentForCopy } from './participantTranscriptionText'
4
+
5
+ /** One entry the host adds to a message's menu, after the package's own (copy, select). */
6
+ export type ParticipantMessageMenuItem = {
7
+ readonly id: string
8
+ readonly label: string
9
+ /** Runs on choice; the menu closes first. */
10
+ readonly onSelect?: () => void
11
+ /** Draws the entry as a link (a file to open or save) instead of a button. */
12
+ readonly href?: string
13
+ /** With `href`: the file name the browser saves it under. */
14
+ readonly download?: string
15
+ readonly disabled?: boolean
16
+ }
17
+
18
+ /** One button the host adds to the selection bar; it receives the selected messages in conversation order. */
19
+ export type ParticipantSelectionAction = {
20
+ readonly id: string
21
+ readonly label: string
22
+ readonly onSelect: (messages: readonly ParticipantMessageActionsTarget[]) => void
23
+ readonly disabled?: boolean
24
+ }
25
+
26
+ /**
27
+ * Opt-in message menu, as in common chat apps: a discreet chevron on the bubble (or a right-click, or a long
28
+ * press) opens Copy and Select messages, plus the host's entries. Absent leaves the bubble as it was.
29
+ */
30
+ export type ParticipantMessageMenuOptions = {
31
+ readonly items?: (message: ParticipantMessageActionsTarget) => readonly ParticipantMessageMenuItem[]
32
+ readonly selectionActions?: (
33
+ messages: readonly ParticipantMessageActionsTarget[],
34
+ ) => readonly ParticipantSelectionAction[]
35
+ }
36
+
37
+ /** Adds the id, or removes it when it is already there; keeps the rest in the order they were picked. */
38
+ export function toggleSelectedId(ids: readonly string[], id: string): readonly string[] {
39
+ return ids.includes(id) ? ids.filter((candidate) => candidate !== id) : [...ids, id]
40
+ }
41
+
42
+ /** The selected messages in the order of the conversation, whatever order they were picked in. */
43
+ export function selectedInOrder(
44
+ messages: readonly ParticipantMessageActionsTarget[],
45
+ ids: readonly string[],
46
+ ): readonly ParticipantMessageActionsTarget[] {
47
+ const picked = new Set(ids)
48
+ return messages.filter((message) => picked.has(message.id))
49
+ }
50
+
51
+ /** The trimmed text of one message, or null when there is nothing to copy (a file alone). */
52
+ export function copyableText(message: Pick<ParticipantMessageActionsTarget, 'text'>): string | null {
53
+ const text = message.text?.trim() ?? ''
54
+ return text === '' ? null : text
55
+ }
56
+
57
+ type CopyFormat = {
58
+ readonly locale?: string
59
+ readonly meLabel: string
60
+ /** Precedes what was said in a transcribed audio. */
61
+ readonly transcriptionLabel?: string
62
+ }
63
+
64
+ /**
65
+ * Several messages as text: one line each, `[date time] author: text [file]`, like a chat export. A transcribed
66
+ * audio carries what was said, so the text stands on its own outside the thread.
67
+ */
68
+ export function formatMessagesForCopy(
69
+ messages: readonly ParticipantMessageActionsTarget[],
70
+ format: CopyFormat,
71
+ ): string {
72
+ return messages
73
+ .map((message) => {
74
+ const author = message.isMine ? format.meLabel : (message.authorName ?? '')
75
+ const head = `[${formatMessageMoment(message.createdAt, format.locale)}]${author === '' ? '' : ` ${author}:`}`
76
+ const files = message.attachments.map((attachment) =>
77
+ attachmentForCopy(attachment, format.transcriptionLabel ?? 'Transcription'),
78
+ )
79
+ return [head, copyableText(message) ?? '', ...files].filter((part) => part !== '').join(' ')
80
+ })
81
+ .join('\n')
82
+ }
83
+
84
+ /** Text the user selected, when it starts and ends inside the element; empty otherwise. */
85
+ export function selectedTextWithin(container: Element | null): string {
86
+ const selection = typeof window === 'undefined' ? null : window.getSelection()
87
+ if (!container || !selection || selection.isCollapsed) return ''
88
+ const { anchorNode, focusNode } = selection
89
+ if (!anchorNode || !focusNode || !container.contains(anchorNode) || !container.contains(focusNode)) return ''
90
+ return selection.toString().trim()
91
+ }
@@ -0,0 +1,7 @@
1
+ /** Date and time in the reader's locale, as the copy, the export and the info panel write them. */
2
+ export function formatMessageMoment(iso: string, locale?: string): string {
3
+ const date = new Date(iso)
4
+ const day = date.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric' })
5
+ const time = date.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' })
6
+ return `${day} ${time}`
7
+ }
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import type { ParticipantAttachment } from '@adatechnology/conversation-contracts'
4
+
5
+ import type { ParticipantMessageActionsTarget } from './ParticipantAuthorAvatar'
6
+ import { formatMessagesForCopy } from './participantMessageMenuModel'
7
+ import { readyTranscriptions, transcriptionFileName } from './participantTranscriptionText'
8
+
9
+ const READY = { status: 'ready', text: ' Cheguei na doca. ', generatedAt: '2026-10-01T13:06:00.000Z' } as const
10
+
11
+ function audio(overrides: Partial<ParticipantAttachment> = {}): ParticipantAttachment {
12
+ return {
13
+ id: 'a1',
14
+ kind: 'audio',
15
+ filename: 'voz.ogg',
16
+ mimeType: 'audio/ogg',
17
+ sizeBytes: 10,
18
+ transcription: READY,
19
+ ...overrides,
20
+ }
21
+ }
22
+
23
+ describe('transcription text', () => {
24
+ it('keeps only ready, non-empty transcriptions of audios', () => {
25
+ expect(readyTranscriptions([audio()])).toEqual([{ filename: 'voz.ogg', text: 'Cheguei na doca.' }])
26
+ expect(readyTranscriptions([audio({ transcription: { status: 'pending' } })])).toEqual([])
27
+ expect(readyTranscriptions([audio({ transcription: { ...READY, text: ' ' } })])).toEqual([])
28
+ expect(readyTranscriptions([audio({ kind: 'document', filename: 'a.pdf' })])).toEqual([])
29
+ })
30
+
31
+ it('puts what was said in copies and exports, after the file name', () => {
32
+ const message: ParticipantMessageActionsTarget = {
33
+ id: 'm1',
34
+ text: null,
35
+ authorName: 'Bruno',
36
+ isMine: false,
37
+ attachments: [audio()],
38
+ createdAt: '2026-10-01T13:05:00.000Z',
39
+ }
40
+ expect(
41
+ formatMessagesForCopy([message], { locale: 'pt-BR', meLabel: 'Eu', transcriptionLabel: 'Transcrição' }),
42
+ ).toMatch(/^\[01\/10\/2026 \d{2}:05\] Bruno: \[voz\.ogg\] Transcrição: Cheguei na doca\.$/)
43
+ })
44
+
45
+ it('names the file after the audio', () => {
46
+ expect(transcriptionFileName('audio-motorista.ogg', 'Transcrição')).toBe('audio-motorista Transcrição.txt')
47
+ })
48
+ })