@adatechnology/conversations-ui 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-RLUNDWGA.js +51 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +5 -43
- package/dist/participant/index.d.ts +74 -2
- package/dist/participant/index.js +1058 -399
- package/dist/styles.css +213 -0
- package/package.json +3 -3
- package/src/conversationTranscript.ts +6 -2
- package/src/participant/ConversationThread.tsx +4 -0
- package/src/participant/ParticipantAuthorAvatar.tsx +7 -1
- package/src/participant/ParticipantBubbleBody.tsx +18 -3
- package/src/participant/ParticipantConversationMenu.tsx +89 -0
- package/src/participant/ParticipantConversations.tsx +3 -0
- package/src/participant/ParticipantMenuBubble.tsx +106 -0
- package/src/participant/ParticipantMessageBubble.tsx +34 -7
- package/src/participant/ParticipantMessageInfo.tsx +70 -0
- package/src/participant/ParticipantMessageMenu.tsx +168 -0
- package/src/participant/ParticipantMessageMenuContext.tsx +109 -0
- package/src/participant/ParticipantSelectionBar.tsx +64 -0
- package/src/participant/ParticipantThread.tsx +27 -2
- package/src/participant/ParticipantThreadScreen.tsx +1 -0
- package/src/participant/index.ts +6 -0
- package/src/participant/participantConversationMenuDom.test.tsx +166 -0
- package/src/participant/participantLabels.ts +40 -0
- package/src/participant/participantMessageInfoModel.test.ts +68 -0
- package/src/participant/participantMessageInfoModel.ts +80 -0
- package/src/participant/participantMessageMenuDom.test.tsx +111 -0
- package/src/participant/participantMessageMenuModel.test.ts +43 -0
- package/src/participant/participantMessageMenuModel.ts +91 -0
- package/src/participant/participantMessageMoment.ts +7 -0
- package/src/participant/participantTranscriptionText.test.ts +48 -0
- package/src/participant/participantTranscriptionText.ts +28 -0
- package/src/participant/useOpenUpwards.ts +22 -0
- package/src/participant/useParticipantFailedMenu.ts +3 -0
- package/src/styles.css +40 -0
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ParticipantAttachment } from '@adatechnology/conversation-contracts'
|
|
2
|
+
|
|
3
|
+
export type ReadyTranscription = { readonly filename: string; readonly text: string }
|
|
4
|
+
|
|
5
|
+
/** The audios of a message whose transcription is ready and says something, in attachment order. */
|
|
6
|
+
export function readyTranscriptions(attachments: readonly ParticipantAttachment[]): readonly ReadyTranscription[] {
|
|
7
|
+
return attachments.flatMap((attachment) => {
|
|
8
|
+
const transcription = attachment.kind === 'audio' ? attachment.transcription : undefined
|
|
9
|
+
const text = transcription?.status === 'ready' ? transcription.text.trim() : ''
|
|
10
|
+
return text === '' ? [] : [{ filename: attachment.filename, text }]
|
|
11
|
+
})
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** One attachment in a copy or an export: `[file]`, followed by what was said when the audio was transcribed. */
|
|
15
|
+
export function attachmentForCopy(attachment: ParticipantAttachment, transcriptionLabel: string): string {
|
|
16
|
+
const [ready] = readyTranscriptions([attachment])
|
|
17
|
+
return ready ? `[${attachment.filename}] ${transcriptionLabel}: ${ready.text}` : `[${attachment.filename}]`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The file name of one audio's transcription: the audio's name without its extension, then the label. */
|
|
21
|
+
export function transcriptionFileName(audioFilename: string, transcriptionLabel: string): string {
|
|
22
|
+
const base = audioFilename.replace(/\.[^.]+$/, '')
|
|
23
|
+
const safe = `${base} ${transcriptionLabel}`
|
|
24
|
+
.replace(/[\\/:*?"<>|]+/g, ' ')
|
|
25
|
+
.replace(/\s+/g, ' ')
|
|
26
|
+
.trim()
|
|
27
|
+
return `${safe}.txt`
|
|
28
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { useLayoutEffect, useState, type RefObject } from 'react'
|
|
2
|
+
|
|
3
|
+
const SCROLLER_SELECTOR = '.cv-p-thread__scroll'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A panel under a bubble opens downwards; when that would cross the bottom of the thread's scrolling area (the
|
|
7
|
+
* last messages), it opens upwards instead, so it is never cut off.
|
|
8
|
+
*/
|
|
9
|
+
export function useOpenUpwards(isOpen: boolean, panelRef: RefObject<HTMLElement | null>): boolean {
|
|
10
|
+
const [isUpwards, setUpwards] = useState(false)
|
|
11
|
+
useLayoutEffect(() => {
|
|
12
|
+
const panel = panelRef.current
|
|
13
|
+
if (!isOpen || !panel) {
|
|
14
|
+
setUpwards(false)
|
|
15
|
+
return
|
|
16
|
+
}
|
|
17
|
+
const scroller = panel.closest(SCROLLER_SELECTOR)
|
|
18
|
+
if (!scroller) return
|
|
19
|
+
setUpwards(panel.getBoundingClientRect().bottom > scroller.getBoundingClientRect().bottom)
|
|
20
|
+
}, [isOpen, panelRef])
|
|
21
|
+
return isUpwards
|
|
22
|
+
}
|
|
@@ -21,6 +21,8 @@ export type ParticipantFailedMenuController = {
|
|
|
21
21
|
readonly handleMenuKeyDown: (event: KeyboardEvent<HTMLDivElement>) => void
|
|
22
22
|
readonly handleBlur: (event: FocusEvent<HTMLSpanElement>) => void
|
|
23
23
|
readonly close: () => void
|
|
24
|
+
/** Opens with the first item focused, as a right-click or a long press does. */
|
|
25
|
+
readonly open: () => void
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
export function useParticipantFailedMenu(): ParticipantFailedMenuController {
|
|
@@ -56,5 +58,6 @@ export function useParticipantFailedMenu(): ParticipantFailedMenuController {
|
|
|
56
58
|
handleMenuKeyDown: (event) => handleFailedMenuKeyDown({ event, menuRef, triggerRef, dispatch }),
|
|
57
59
|
handleBlur,
|
|
58
60
|
close: () => dispatch({ type: 'close' }),
|
|
61
|
+
open: () => dispatch({ type: 'open', initialFocus: 'first' }),
|
|
59
62
|
}
|
|
60
63
|
}
|
package/src/styles.css
CHANGED
|
@@ -1328,6 +1328,45 @@
|
|
|
1328
1328
|
.cv-p-failed-menu__list--below { bottom: auto; top: calc(100% + 0.25rem); }
|
|
1329
1329
|
.cv-p-failed-menu__item--danger { color: var(--cv-p-i-danger); }
|
|
1330
1330
|
.cv-p-failed-menu__item:hover, .cv-p-failed-menu__item:focus-visible { background: var(--cv-p-i-highlight); }
|
|
1331
|
+
/* Menu da mensagem (opt-in `messageMenu`): seta discreta no canto do balão, à vista só com o ponteiro em cima, com
|
|
1332
|
+
foco ou com o menu aberto; no toque abre por toque longo e a seta não ocupa o balão. */
|
|
1333
|
+
.cv-p-message-menu { position: absolute; top: 0.2rem; right: 0.2rem; display: inline-flex; }
|
|
1334
|
+
.cv-p-message-menu__trigger { display: inline-grid; place-items: center; width: 1.5rem; height: 1.25rem; padding: 0; border: 0; border-radius: var(--cv-p-i-radius); color: var(--cv-p-i-text-muted); background: var(--cv-p-tail-fill, var(--cv-p-i-surface-raised)); box-shadow: -0.5rem 0 0.5rem var(--cv-p-tail-fill, var(--cv-p-i-surface-raised)); cursor: pointer; opacity: 0; }
|
|
1335
|
+
.cv-p-bubble:hover .cv-p-message-menu__trigger, .cv-p-message-menu__trigger:focus-visible, .cv-p-message-menu[data-open] .cv-p-message-menu__trigger { opacity: 1; }
|
|
1336
|
+
.cv-p-message-menu__trigger:hover { color: var(--cv-p-i-text); }
|
|
1337
|
+
@media (hover: none) { .cv-p-message-menu__trigger:not(:focus-visible) { pointer-events: none; } }
|
|
1338
|
+
.cv-p-message-menu__list { min-width: 11rem; }
|
|
1339
|
+
.cv-p-message-menu__list { bottom: auto; top: calc(100% + 0.25rem); }
|
|
1340
|
+
.cv-p-message-menu__list.cv-p-message-menu__list--up { top: auto; bottom: calc(100% + 0.25rem); }
|
|
1341
|
+
.cv-p-message-menu__item { display: flex; align-items: center; font: inherit; font-size: 0.85rem; text-transform: none; letter-spacing: normal; text-decoration: none; white-space: nowrap; }
|
|
1342
|
+
.cv-p-message-menu__item:disabled, .cv-p-message-menu__item[aria-disabled='true'] { color: var(--cv-p-i-text-muted); cursor: default; }
|
|
1343
|
+
.cv-p-bubble--menu-open { z-index: 3; }
|
|
1344
|
+
.cv-p-bubble:has(.cv-p-message-menu[data-open]) { z-index: 3; }
|
|
1345
|
+
.cv-p-conversation-menu { position: relative; display: inline-flex; }
|
|
1346
|
+
.cv-p-conversation-menu__trigger { display: inline-flex; align-items: center; justify-content: center; min-width: var(--cv-p-i-touch); min-height: var(--cv-p-i-touch); padding: 0; border: 0; border-radius: var(--cv-p-i-radius); color: var(--cv-p-i-text-muted); background: transparent; cursor: pointer; }
|
|
1347
|
+
.cv-p-conversation-menu__trigger:hover, .cv-p-conversation-menu__trigger:focus-visible, .cv-p-conversation-menu__trigger[aria-expanded='true'] { color: var(--cv-p-i-text); background: var(--cv-p-i-highlight); }
|
|
1348
|
+
.cv-p-conversation-menu__list { z-index: 4; }
|
|
1349
|
+
.cv-p-message-info { min-width: 14rem; padding: 0.5rem 0.75rem 0.65rem; color: var(--cv-p-i-text); font-size: 0.85rem; cursor: default; }
|
|
1350
|
+
.cv-p-message-info__head { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; margin-bottom: 0.35rem; }
|
|
1351
|
+
.cv-p-message-info__title { font-weight: 600; }
|
|
1352
|
+
.cv-p-message-info__close { display: inline-flex; align-items: center; justify-content: center; padding: 0.2rem; border: 0; border-radius: var(--cv-p-i-radius); color: var(--cv-p-i-text-muted); background: transparent; cursor: pointer; }
|
|
1353
|
+
.cv-p-message-info__close:hover, .cv-p-message-info__close:focus-visible { color: var(--cv-p-i-text); background: var(--cv-p-i-highlight); }
|
|
1354
|
+
.cv-p-message-info__rows { display: grid; gap: 0.25rem; margin: 0; }
|
|
1355
|
+
.cv-p-message-info__row { display: flex; justify-content: space-between; gap: 1rem; }
|
|
1356
|
+
.cv-p-message-info__row dt { color: var(--cv-p-i-text-muted); }
|
|
1357
|
+
.cv-p-message-info__row dd { margin: 0; text-align: end; font-variant-numeric: tabular-nums; }
|
|
1358
|
+
/* Modo de seleção: caixa à esquerda de cada mensagem; a linha inteira marca e desmarca. */
|
|
1359
|
+
.cv-p-select-row { display: flex; align-items: center; gap: 0.6rem; width: 100%; cursor: pointer; border-radius: var(--cv-p-i-radius); }
|
|
1360
|
+
.cv-p-select-row[data-selected] { background: var(--cv-p-i-highlight); }
|
|
1361
|
+
.cv-p-select-row > .cv-p-bubble--mine { margin-inline-start: auto; }
|
|
1362
|
+
.cv-p-select-row > .cv-p-bubble-row { max-width: 92%; }
|
|
1363
|
+
.cv-p-select-row__check { flex: 0 0 auto; width: 1.1rem; height: 1.1rem; margin: 0 0 0 0.25rem; accent-color: var(--cv-p-i-accent); cursor: pointer; }
|
|
1364
|
+
.cv-p-selection-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; padding: 0.6rem 0.75rem; border-top: 1px solid var(--cv-p-i-border); background: var(--cv-p-i-surface-raised); }
|
|
1365
|
+
.cv-p-selection-bar__count { margin-inline-end: auto; font-weight: 600; color: var(--cv-p-i-text); }
|
|
1366
|
+
.cv-p-selection-bar__close, .cv-p-selection-bar__action { min-height: var(--cv-p-i-touch); padding: 0 0.75rem; border: 1px solid var(--cv-p-i-border); border-radius: var(--cv-p-i-radius); color: var(--cv-p-i-text); background: transparent; font: inherit; font-size: 0.85rem; cursor: pointer; }
|
|
1367
|
+
.cv-p-selection-bar__close { display: inline-grid; place-items: center; min-width: var(--cv-p-i-touch); padding: 0; border-color: transparent; }
|
|
1368
|
+
.cv-p-selection-bar__action:disabled { color: var(--cv-p-i-text-muted); cursor: default; }
|
|
1369
|
+
.cv-p-selection-bar__close:hover, .cv-p-selection-bar__action:hover:not(:disabled), .cv-p-selection-bar__close:focus-visible, .cv-p-selection-bar__action:focus-visible { background: var(--cv-p-i-highlight); }
|
|
1331
1370
|
.cv-p-attachment { display: flex; flex-direction: column; gap: 0.25rem; margin-top: 0.35rem; color: inherit; }
|
|
1332
1371
|
.cv-p-attachment--document { flex-direction: row; justify-content: space-between; min-height: var(--cv-p-i-touch); align-items: center; text-decoration: underline; }
|
|
1333
1372
|
.cv-p-attachment__image { display: block; max-width: 100%; max-height: 16rem; object-fit: contain; }
|
|
@@ -1390,6 +1429,7 @@
|
|
|
1390
1429
|
.cv-p-thread__latest-button { animation: cv-p-latest-in 0.16s ease-out; }
|
|
1391
1430
|
.cv-p-protocol__copy, .cv-p-search__input { transition: border-color 0.12s, background-color 0.12s; }
|
|
1392
1431
|
.cv-p-row, .cv-p-button, .cv-p-chip, .cv-p-thread__back, .cv-p-composer__attach { transition: background-color 0.12s, border-color 0.12s; }
|
|
1432
|
+
.cv-p-message-menu__trigger { transition: opacity 0.12s; }
|
|
1393
1433
|
}
|
|
1394
1434
|
|
|
1395
1435
|
/* Scrolls. Verticais: barra fina no tema. Faixas horizontais: sem barra, com esmaecimento nas bordas. O
|