@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.
- 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 +87 -2
- package/dist/participant/index.js +1073 -398
- package/dist/styles.css +218 -0
- package/package.json +1 -1
- package/src/conversationTranscript.ts +6 -2
- package/src/participant/ConversationThread.tsx +8 -1
- package/src/participant/ParticipantAuthorAvatar.tsx +18 -0
- package/src/participant/ParticipantBubbleBody.tsx +33 -3
- package/src/participant/ParticipantConversationMenu.tsx +89 -0
- package/src/participant/ParticipantConversations.tsx +6 -1
- package/src/participant/ParticipantMenuBubble.tsx +106 -0
- package/src/participant/ParticipantMessageBubble.test.tsx +51 -0
- package/src/participant/ParticipantMessageBubble.tsx +39 -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 +30 -3
- package/src/participant/ParticipantThreadScreen.tsx +2 -0
- package/src/participant/ParticipantTimeline.tsx +7 -1
- package/src/participant/index.ts +11 -1
- 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 +41 -0
package/dist/styles.css
CHANGED
|
@@ -1978,6 +1978,11 @@
|
|
|
1978
1978
|
.cv-p-bubble > .cv-p-attachment {
|
|
1979
1979
|
flex: 1 0 100%;
|
|
1980
1980
|
}
|
|
1981
|
+
.cv-p-bubble__host-actions {
|
|
1982
|
+
display: inline-flex;
|
|
1983
|
+
align-items: center;
|
|
1984
|
+
gap: 0.25rem;
|
|
1985
|
+
}
|
|
1981
1986
|
.cv-p-bubble__original {
|
|
1982
1987
|
flex: 1 0 100%;
|
|
1983
1988
|
margin-top: 0.2rem;
|
|
@@ -2140,6 +2145,216 @@
|
|
|
2140
2145
|
.cv-p-failed-menu__item:focus-visible {
|
|
2141
2146
|
background: var(--cv-p-i-highlight);
|
|
2142
2147
|
}
|
|
2148
|
+
.cv-p-message-menu {
|
|
2149
|
+
position: absolute;
|
|
2150
|
+
top: 0.2rem;
|
|
2151
|
+
right: 0.2rem;
|
|
2152
|
+
display: inline-flex;
|
|
2153
|
+
}
|
|
2154
|
+
.cv-p-message-menu__trigger {
|
|
2155
|
+
display: inline-grid;
|
|
2156
|
+
place-items: center;
|
|
2157
|
+
width: 1.5rem;
|
|
2158
|
+
height: 1.25rem;
|
|
2159
|
+
padding: 0;
|
|
2160
|
+
border: 0;
|
|
2161
|
+
border-radius: var(--cv-p-i-radius);
|
|
2162
|
+
color: var(--cv-p-i-text-muted);
|
|
2163
|
+
background: var(--cv-p-tail-fill, var(--cv-p-i-surface-raised));
|
|
2164
|
+
box-shadow: -0.5rem 0 0.5rem var(--cv-p-tail-fill, var(--cv-p-i-surface-raised));
|
|
2165
|
+
cursor: pointer;
|
|
2166
|
+
opacity: 0;
|
|
2167
|
+
}
|
|
2168
|
+
.cv-p-bubble:hover .cv-p-message-menu__trigger,
|
|
2169
|
+
.cv-p-message-menu__trigger:focus-visible,
|
|
2170
|
+
.cv-p-message-menu[data-open] .cv-p-message-menu__trigger {
|
|
2171
|
+
opacity: 1;
|
|
2172
|
+
}
|
|
2173
|
+
.cv-p-message-menu__trigger:hover {
|
|
2174
|
+
color: var(--cv-p-i-text);
|
|
2175
|
+
}
|
|
2176
|
+
@media (hover: none) {
|
|
2177
|
+
.cv-p-message-menu__trigger:not(:focus-visible) {
|
|
2178
|
+
pointer-events: none;
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
.cv-p-message-menu__list {
|
|
2182
|
+
min-width: 11rem;
|
|
2183
|
+
}
|
|
2184
|
+
.cv-p-message-menu__list {
|
|
2185
|
+
bottom: auto;
|
|
2186
|
+
top: calc(100% + 0.25rem);
|
|
2187
|
+
}
|
|
2188
|
+
.cv-p-message-menu__list.cv-p-message-menu__list--up {
|
|
2189
|
+
top: auto;
|
|
2190
|
+
bottom: calc(100% + 0.25rem);
|
|
2191
|
+
}
|
|
2192
|
+
.cv-p-message-menu__item {
|
|
2193
|
+
display: flex;
|
|
2194
|
+
align-items: center;
|
|
2195
|
+
font: inherit;
|
|
2196
|
+
font-size: 0.85rem;
|
|
2197
|
+
text-transform: none;
|
|
2198
|
+
letter-spacing: normal;
|
|
2199
|
+
text-decoration: none;
|
|
2200
|
+
white-space: nowrap;
|
|
2201
|
+
}
|
|
2202
|
+
.cv-p-message-menu__item:disabled,
|
|
2203
|
+
.cv-p-message-menu__item[aria-disabled=true] {
|
|
2204
|
+
color: var(--cv-p-i-text-muted);
|
|
2205
|
+
cursor: default;
|
|
2206
|
+
}
|
|
2207
|
+
.cv-p-bubble--menu-open {
|
|
2208
|
+
z-index: 3;
|
|
2209
|
+
}
|
|
2210
|
+
.cv-p-bubble:has(.cv-p-message-menu[data-open]) {
|
|
2211
|
+
z-index: 3;
|
|
2212
|
+
}
|
|
2213
|
+
.cv-p-conversation-menu {
|
|
2214
|
+
position: relative;
|
|
2215
|
+
display: inline-flex;
|
|
2216
|
+
}
|
|
2217
|
+
.cv-p-conversation-menu__trigger {
|
|
2218
|
+
display: inline-flex;
|
|
2219
|
+
align-items: center;
|
|
2220
|
+
justify-content: center;
|
|
2221
|
+
min-width: var(--cv-p-i-touch);
|
|
2222
|
+
min-height: var(--cv-p-i-touch);
|
|
2223
|
+
padding: 0;
|
|
2224
|
+
border: 0;
|
|
2225
|
+
border-radius: var(--cv-p-i-radius);
|
|
2226
|
+
color: var(--cv-p-i-text-muted);
|
|
2227
|
+
background: transparent;
|
|
2228
|
+
cursor: pointer;
|
|
2229
|
+
}
|
|
2230
|
+
.cv-p-conversation-menu__trigger:hover,
|
|
2231
|
+
.cv-p-conversation-menu__trigger:focus-visible,
|
|
2232
|
+
.cv-p-conversation-menu__trigger[aria-expanded=true] {
|
|
2233
|
+
color: var(--cv-p-i-text);
|
|
2234
|
+
background: var(--cv-p-i-highlight);
|
|
2235
|
+
}
|
|
2236
|
+
.cv-p-conversation-menu__list {
|
|
2237
|
+
z-index: 4;
|
|
2238
|
+
}
|
|
2239
|
+
.cv-p-message-info {
|
|
2240
|
+
min-width: 14rem;
|
|
2241
|
+
padding: 0.5rem 0.75rem 0.65rem;
|
|
2242
|
+
color: var(--cv-p-i-text);
|
|
2243
|
+
font-size: 0.85rem;
|
|
2244
|
+
cursor: default;
|
|
2245
|
+
}
|
|
2246
|
+
.cv-p-message-info__head {
|
|
2247
|
+
display: flex;
|
|
2248
|
+
align-items: center;
|
|
2249
|
+
justify-content: space-between;
|
|
2250
|
+
gap: 0.5rem;
|
|
2251
|
+
margin-bottom: 0.35rem;
|
|
2252
|
+
}
|
|
2253
|
+
.cv-p-message-info__title {
|
|
2254
|
+
font-weight: 600;
|
|
2255
|
+
}
|
|
2256
|
+
.cv-p-message-info__close {
|
|
2257
|
+
display: inline-flex;
|
|
2258
|
+
align-items: center;
|
|
2259
|
+
justify-content: center;
|
|
2260
|
+
padding: 0.2rem;
|
|
2261
|
+
border: 0;
|
|
2262
|
+
border-radius: var(--cv-p-i-radius);
|
|
2263
|
+
color: var(--cv-p-i-text-muted);
|
|
2264
|
+
background: transparent;
|
|
2265
|
+
cursor: pointer;
|
|
2266
|
+
}
|
|
2267
|
+
.cv-p-message-info__close:hover,
|
|
2268
|
+
.cv-p-message-info__close:focus-visible {
|
|
2269
|
+
color: var(--cv-p-i-text);
|
|
2270
|
+
background: var(--cv-p-i-highlight);
|
|
2271
|
+
}
|
|
2272
|
+
.cv-p-message-info__rows {
|
|
2273
|
+
display: grid;
|
|
2274
|
+
gap: 0.25rem;
|
|
2275
|
+
margin: 0;
|
|
2276
|
+
}
|
|
2277
|
+
.cv-p-message-info__row {
|
|
2278
|
+
display: flex;
|
|
2279
|
+
justify-content: space-between;
|
|
2280
|
+
gap: 1rem;
|
|
2281
|
+
}
|
|
2282
|
+
.cv-p-message-info__row dt {
|
|
2283
|
+
color: var(--cv-p-i-text-muted);
|
|
2284
|
+
}
|
|
2285
|
+
.cv-p-message-info__row dd {
|
|
2286
|
+
margin: 0;
|
|
2287
|
+
text-align: end;
|
|
2288
|
+
font-variant-numeric: tabular-nums;
|
|
2289
|
+
}
|
|
2290
|
+
.cv-p-select-row {
|
|
2291
|
+
display: flex;
|
|
2292
|
+
align-items: center;
|
|
2293
|
+
gap: 0.6rem;
|
|
2294
|
+
width: 100%;
|
|
2295
|
+
cursor: pointer;
|
|
2296
|
+
border-radius: var(--cv-p-i-radius);
|
|
2297
|
+
}
|
|
2298
|
+
.cv-p-select-row[data-selected] {
|
|
2299
|
+
background: var(--cv-p-i-highlight);
|
|
2300
|
+
}
|
|
2301
|
+
.cv-p-select-row > .cv-p-bubble--mine {
|
|
2302
|
+
margin-inline-start: auto;
|
|
2303
|
+
}
|
|
2304
|
+
.cv-p-select-row > .cv-p-bubble-row {
|
|
2305
|
+
max-width: 92%;
|
|
2306
|
+
}
|
|
2307
|
+
.cv-p-select-row__check {
|
|
2308
|
+
flex: 0 0 auto;
|
|
2309
|
+
width: 1.1rem;
|
|
2310
|
+
height: 1.1rem;
|
|
2311
|
+
margin: 0 0 0 0.25rem;
|
|
2312
|
+
accent-color: var(--cv-p-i-accent);
|
|
2313
|
+
cursor: pointer;
|
|
2314
|
+
}
|
|
2315
|
+
.cv-p-selection-bar {
|
|
2316
|
+
display: flex;
|
|
2317
|
+
flex-wrap: wrap;
|
|
2318
|
+
align-items: center;
|
|
2319
|
+
gap: 0.5rem;
|
|
2320
|
+
padding: 0.6rem 0.75rem;
|
|
2321
|
+
border-top: 1px solid var(--cv-p-i-border);
|
|
2322
|
+
background: var(--cv-p-i-surface-raised);
|
|
2323
|
+
}
|
|
2324
|
+
.cv-p-selection-bar__count {
|
|
2325
|
+
margin-inline-end: auto;
|
|
2326
|
+
font-weight: 600;
|
|
2327
|
+
color: var(--cv-p-i-text);
|
|
2328
|
+
}
|
|
2329
|
+
.cv-p-selection-bar__close,
|
|
2330
|
+
.cv-p-selection-bar__action {
|
|
2331
|
+
min-height: var(--cv-p-i-touch);
|
|
2332
|
+
padding: 0 0.75rem;
|
|
2333
|
+
border: 1px solid var(--cv-p-i-border);
|
|
2334
|
+
border-radius: var(--cv-p-i-radius);
|
|
2335
|
+
color: var(--cv-p-i-text);
|
|
2336
|
+
background: transparent;
|
|
2337
|
+
font: inherit;
|
|
2338
|
+
font-size: 0.85rem;
|
|
2339
|
+
cursor: pointer;
|
|
2340
|
+
}
|
|
2341
|
+
.cv-p-selection-bar__close {
|
|
2342
|
+
display: inline-grid;
|
|
2343
|
+
place-items: center;
|
|
2344
|
+
min-width: var(--cv-p-i-touch);
|
|
2345
|
+
padding: 0;
|
|
2346
|
+
border-color: transparent;
|
|
2347
|
+
}
|
|
2348
|
+
.cv-p-selection-bar__action:disabled {
|
|
2349
|
+
color: var(--cv-p-i-text-muted);
|
|
2350
|
+
cursor: default;
|
|
2351
|
+
}
|
|
2352
|
+
.cv-p-selection-bar__close:hover,
|
|
2353
|
+
.cv-p-selection-bar__action:hover:not(:disabled),
|
|
2354
|
+
.cv-p-selection-bar__close:focus-visible,
|
|
2355
|
+
.cv-p-selection-bar__action:focus-visible {
|
|
2356
|
+
background: var(--cv-p-i-highlight);
|
|
2357
|
+
}
|
|
2143
2358
|
.cv-p-attachment {
|
|
2144
2359
|
display: flex;
|
|
2145
2360
|
flex-direction: column;
|
|
@@ -2524,6 +2739,9 @@
|
|
|
2524
2739
|
.cv-p-composer__attach {
|
|
2525
2740
|
transition: background-color 0.12s, border-color 0.12s;
|
|
2526
2741
|
}
|
|
2742
|
+
.cv-p-message-menu__trigger {
|
|
2743
|
+
transition: opacity 0.12s;
|
|
2744
|
+
}
|
|
2527
2745
|
}
|
|
2528
2746
|
.cv-p-thread__scroll,
|
|
2529
2747
|
.cv-p-inbox,
|
package/package.json
CHANGED
|
@@ -75,7 +75,8 @@ export function buildTranscriptFilename(whatsappNumber: string, generatedAt: Dat
|
|
|
75
75
|
|
|
76
76
|
/**
|
|
77
77
|
* Dispara o download no navegador. `revokeObjectURL` no fim não é higiene opcional: sem ele cada
|
|
78
|
-
* export retém o blob inteiro em memória até a aba fechar.
|
|
78
|
+
* export retém o blob inteiro em memória até a aba fechar. O link entra no documento enquanto é clicado,
|
|
79
|
+
* como o Firefox exige, e a revogação vai para o tique seguinte, depois de o download começar.
|
|
79
80
|
*/
|
|
80
81
|
export function downloadTextFile(filename: string, content: string): void {
|
|
81
82
|
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
|
|
@@ -84,6 +85,9 @@ export function downloadTextFile(filename: string, content: string): void {
|
|
|
84
85
|
|
|
85
86
|
anchor.href = url
|
|
86
87
|
anchor.download = filename
|
|
88
|
+
anchor.rel = 'noopener'
|
|
89
|
+
document.body.appendChild(anchor)
|
|
87
90
|
anchor.click()
|
|
88
|
-
|
|
91
|
+
anchor.remove()
|
|
92
|
+
setTimeout(() => URL.revokeObjectURL(url), 0)
|
|
89
93
|
}
|
|
@@ -5,7 +5,7 @@ import type { ParticipantConversationSummary, ParticipantSubjectRef } from '@ada
|
|
|
5
5
|
import type { ConversationChannel } from '../conversationChannel'
|
|
6
6
|
import type { QuickReply } from '../quickReplies/quickReply.types'
|
|
7
7
|
import type { ConversationsTheme } from '../types'
|
|
8
|
-
import type { ParticipantAuthorAvatarRenderer } from './ParticipantAuthorAvatar'
|
|
8
|
+
import type { ParticipantAuthorAvatarRenderer, ParticipantMessageActionsRenderer } from './ParticipantAuthorAvatar'
|
|
9
9
|
import { ParticipantThread } from './ParticipantThread'
|
|
10
10
|
import { toParticipantApi, type ConversationThreadApi } from './conversationThreadApi'
|
|
11
11
|
import { buildThreadSummary } from './conversationThreadSummary'
|
|
@@ -18,6 +18,7 @@ import type { ParticipantPerspective } from './participantPerspective'
|
|
|
18
18
|
import { participantSendStatesReducer } from './participantSendStates'
|
|
19
19
|
import { buildParticipantThemeStyle } from './participantTheme'
|
|
20
20
|
import { useParticipantThreadController, type ParticipantThreadCoreProps } from './useParticipantThreadController'
|
|
21
|
+
import type { ParticipantMessageMenuOptions } from './participantMessageMenuModel'
|
|
21
22
|
|
|
22
23
|
export type ConversationThreadProps = {
|
|
23
24
|
readonly api: ConversationThreadApi
|
|
@@ -51,6 +52,10 @@ export type ConversationThreadProps = {
|
|
|
51
52
|
readonly tail?: boolean
|
|
52
53
|
readonly avatars?: 'initials'
|
|
53
54
|
readonly renderAuthorAvatar?: ParticipantAuthorAvatarRenderer
|
|
55
|
+
/** Host slot at the end of each message's meta line (copy, forward...). The package knows no action. */
|
|
56
|
+
readonly renderMessageActions?: ParticipantMessageActionsRenderer
|
|
57
|
+
/** Opt-in message menu as in common chat apps: copy, select messages, plus the host's entries. */
|
|
58
|
+
readonly messageMenu?: ParticipantMessageMenuOptions
|
|
54
59
|
/** Absent draws no microphone. Present lets the user record and send an audio as a message of its own. */
|
|
55
60
|
readonly audioRecording?: ParticipantAudioRecordingOptions
|
|
56
61
|
/** Return a node to replace the composer (notice + host actions); undefined keeps the composer. `closed` still wins. Evaluated every render. */
|
|
@@ -107,6 +112,8 @@ function ConversationThreadBody(props: ConversationThreadProps) {
|
|
|
107
112
|
renderSubjectIcon={props.renderSubjectIcon}
|
|
108
113
|
avatars={props.avatars}
|
|
109
114
|
renderAuthorAvatar={props.renderAuthorAvatar}
|
|
115
|
+
renderMessageActions={props.renderMessageActions}
|
|
116
|
+
messageMenu={props.messageMenu}
|
|
110
117
|
tail={props.tail}
|
|
111
118
|
counterpartLabel={props.counterpartLabel}
|
|
112
119
|
headerActions={props.headerActions}
|
|
@@ -1,10 +1,28 @@
|
|
|
1
1
|
import { User } from 'lucide-react'
|
|
2
|
+
import type { MessageDeliveryStatus, ParticipantAttachment } from '@adatechnology/conversation-contracts'
|
|
2
3
|
import type { ReactNode } from 'react'
|
|
3
4
|
|
|
4
5
|
import { authorInitials } from './participantAvatar'
|
|
5
6
|
|
|
6
7
|
export type ParticipantAuthorAvatarRenderer = (author: { name: string | null }) => ReactNode
|
|
7
8
|
|
|
9
|
+
/** What the host receives to draw per-message actions (copy, forward...); only messages the server already holds. */
|
|
10
|
+
export type ParticipantMessageActionsTarget = {
|
|
11
|
+
readonly id: string
|
|
12
|
+
readonly text: string | null
|
|
13
|
+
readonly authorName: string | null
|
|
14
|
+
readonly isMine: boolean
|
|
15
|
+
readonly attachments: readonly ParticipantAttachment[]
|
|
16
|
+
/** When the message was written (ISO 8601). */
|
|
17
|
+
readonly createdAt: string
|
|
18
|
+
/** Delivery state of a message this side sent, when the channel reports one. */
|
|
19
|
+
readonly status?: MessageDeliveryStatus | undefined
|
|
20
|
+
/** When the other side read it (ISO 8601), when known. */
|
|
21
|
+
readonly readAt?: string | null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type ParticipantMessageActionsRenderer = (message: ParticipantMessageActionsTarget) => ReactNode
|
|
25
|
+
|
|
8
26
|
export type ParticipantAuthorAvatarProps = {
|
|
9
27
|
readonly name: string | null
|
|
10
28
|
/** Host slot (a photo, for instance); its result wins over the initials. */
|
|
@@ -2,6 +2,7 @@ import { formatTimestamp } from '../lib/format'
|
|
|
2
2
|
import { ParticipantAttachmentItem, type ResolveParticipantAttachmentUrl } from './ParticipantAttachmentItem'
|
|
3
3
|
import { OwnStatus } from './ParticipantBubbleStatus'
|
|
4
4
|
import { ParticipantFailedMenu } from './ParticipantFailedMenu'
|
|
5
|
+
import type { ParticipantMessageActionsRenderer } from './ParticipantAuthorAvatar'
|
|
5
6
|
import type { BubbleContent } from './ParticipantMessageBubble'
|
|
6
7
|
import { ParticipantMessageText } from './ParticipantMessageText'
|
|
7
8
|
import { DEFAULT_SHOW_ORIGINAL_TEXT_LABEL, type ParticipantConversationsLabels } from './participantLabels'
|
|
@@ -16,6 +17,7 @@ type BubbleBodyProps = {
|
|
|
16
17
|
readonly onRetry?: () => void
|
|
17
18
|
readonly onDiscard?: () => void
|
|
18
19
|
readonly onEdit?: () => void
|
|
20
|
+
readonly renderMessageActions?: ParticipantMessageActionsRenderer
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
function BubbleHeading({ content, labels }: Pick<BubbleBodyProps, 'content' | 'labels'>) {
|
|
@@ -24,19 +26,44 @@ function BubbleHeading({ content, labels }: Pick<BubbleBodyProps, 'content' | 'l
|
|
|
24
26
|
return <span className="cv-p-bubble__author">{content.authorName}</span>
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
function hostActions(content: BubbleContent, render?: ParticipantMessageActionsRenderer) {
|
|
30
|
+
if (!render || content.messageId === undefined) return null
|
|
31
|
+
return render({
|
|
32
|
+
id: content.messageId,
|
|
33
|
+
text: content.text ?? null,
|
|
34
|
+
authorName: content.authorName ?? null,
|
|
35
|
+
isMine: content.isMine,
|
|
36
|
+
attachments: content.attachments,
|
|
37
|
+
createdAt: content.createdAt,
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
27
41
|
/** Collapsed by default: the trimmed text is what the reader needs, the original is there on demand. */
|
|
28
42
|
function OriginalText({ content, labels }: Pick<BubbleBodyProps, 'content' | 'labels'>) {
|
|
29
43
|
const original = content.originalText?.trim()
|
|
30
44
|
if (!original || original === content.text?.trim()) return null
|
|
31
45
|
return (
|
|
32
46
|
<details className="cv-p-bubble__original">
|
|
33
|
-
<summary className="cv-p-bubble__original-toggle">
|
|
47
|
+
<summary className="cv-p-bubble__original-toggle">
|
|
48
|
+
{labels.showOriginalText ?? DEFAULT_SHOW_ORIGINAL_TEXT_LABEL}
|
|
49
|
+
</summary>
|
|
34
50
|
<div className="cv-p-bubble__original-text">{original}</div>
|
|
35
51
|
</details>
|
|
36
52
|
)
|
|
37
53
|
}
|
|
38
54
|
|
|
39
|
-
export function BubbleBody({
|
|
55
|
+
export function BubbleBody({
|
|
56
|
+
content,
|
|
57
|
+
labels,
|
|
58
|
+
ownStatus,
|
|
59
|
+
textId,
|
|
60
|
+
resolveAttachmentUrl,
|
|
61
|
+
onRetry,
|
|
62
|
+
onDiscard,
|
|
63
|
+
onEdit,
|
|
64
|
+
renderMessageActions,
|
|
65
|
+
}: BubbleBodyProps) {
|
|
66
|
+
const actions = hostActions(content, renderMessageActions)
|
|
40
67
|
return (
|
|
41
68
|
<>
|
|
42
69
|
<BubbleHeading content={content} labels={labels} />
|
|
@@ -59,7 +86,10 @@ export function BubbleBody({ content, labels, ownStatus, textId, resolveAttachme
|
|
|
59
86
|
{formatTimestamp(content.createdAt)}
|
|
60
87
|
</time>
|
|
61
88
|
{ownStatus ? <OwnStatus status={ownStatus} labels={labels} onRetry={onRetry} /> : null}
|
|
62
|
-
{ownStatus === 'failed' ?
|
|
89
|
+
{ownStatus === 'failed' ? (
|
|
90
|
+
<ParticipantFailedMenu labels={labels} onEdit={onEdit} onDiscard={onDiscard} />
|
|
91
|
+
) : null}
|
|
92
|
+
{actions ? <span className="cv-p-bubble__host-actions">{actions}</span> : null}
|
|
63
93
|
</span>
|
|
64
94
|
{/* Last child so the ids React derives for the siblings above stay as they were; CSS keeps the meta after it. */}
|
|
65
95
|
<OriginalText content={content} labels={labels} />
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { EllipsisVertical } from 'lucide-react'
|
|
2
|
+
import { useId } from 'react'
|
|
3
|
+
|
|
4
|
+
import type { ParticipantMessageMenuState } from './ParticipantMessageMenuContext'
|
|
5
|
+
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS as DEFAULTS } from './participantLabels'
|
|
6
|
+
import { downloadTextFile } from '../conversationTranscript'
|
|
7
|
+
import { conversationExportFileName, conversationExportText } from './participantMessageInfoModel'
|
|
8
|
+
import { useParticipantFailedMenu } from './useParticipantFailedMenu'
|
|
9
|
+
|
|
10
|
+
type ParticipantConversationMenuProps = {
|
|
11
|
+
readonly state: ParticipantMessageMenuState
|
|
12
|
+
/** Title of the conversation: first line of the file and its name. */
|
|
13
|
+
readonly title: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The header's menu of a thread with `messageMenu`, as in common chat apps: enter selection mode with nothing
|
|
18
|
+
* checked yet, or download the loaded messages as text (`[date time] author: text`, one per line), through the
|
|
19
|
+
* same download the WhatsApp workspace uses.
|
|
20
|
+
*/
|
|
21
|
+
export function ParticipantConversationMenu({ state, title }: ParticipantConversationMenuProps) {
|
|
22
|
+
const menu = useParticipantFailedMenu()
|
|
23
|
+
const triggerId = useId()
|
|
24
|
+
const { labels } = state
|
|
25
|
+
const listClass = `cv-p-failed-menu__list cv-p-message-menu__list cv-p-conversation-menu__list${menu.placement === 'below' ? ' cv-p-failed-menu__list--below' : ''}`
|
|
26
|
+
const entries = [
|
|
27
|
+
{ id: 'select', label: labels.selectMessages ?? DEFAULTS.selectMessages, run: () => state.start() },
|
|
28
|
+
{
|
|
29
|
+
id: 'download',
|
|
30
|
+
label: labels.downloadConversation ?? DEFAULTS.downloadConversation,
|
|
31
|
+
disabled: state.messages.length === 0,
|
|
32
|
+
run: () =>
|
|
33
|
+
downloadTextFile(
|
|
34
|
+
conversationExportFileName(title),
|
|
35
|
+
conversationExportText({
|
|
36
|
+
title,
|
|
37
|
+
messages: state.messages,
|
|
38
|
+
locale: state.locale,
|
|
39
|
+
meLabel: labels.me,
|
|
40
|
+
transcriptionLabel: labels.transcriptionPrefix ?? DEFAULTS.transcriptionPrefix,
|
|
41
|
+
}),
|
|
42
|
+
),
|
|
43
|
+
},
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<span className="cv-p-conversation-menu" ref={menu.containerRef} onBlur={menu.handleBlur}>
|
|
48
|
+
<button
|
|
49
|
+
type="button"
|
|
50
|
+
id={triggerId}
|
|
51
|
+
ref={menu.triggerRef}
|
|
52
|
+
className="cv-p-conversation-menu__trigger"
|
|
53
|
+
aria-haspopup="menu"
|
|
54
|
+
aria-expanded={menu.state.isOpen}
|
|
55
|
+
aria-label={labels.conversationOptions ?? DEFAULTS.conversationOptions}
|
|
56
|
+
onClick={menu.handleTriggerClick}
|
|
57
|
+
onKeyDown={menu.handleTriggerKeyDown}
|
|
58
|
+
>
|
|
59
|
+
<EllipsisVertical size={18} aria-hidden={true} />
|
|
60
|
+
</button>
|
|
61
|
+
{menu.state.isOpen ? (
|
|
62
|
+
<div
|
|
63
|
+
role="menu"
|
|
64
|
+
aria-labelledby={triggerId}
|
|
65
|
+
ref={menu.menuRef}
|
|
66
|
+
className={listClass}
|
|
67
|
+
onKeyDown={menu.handleMenuKeyDown}
|
|
68
|
+
>
|
|
69
|
+
{entries.map((entry) => (
|
|
70
|
+
<button
|
|
71
|
+
key={entry.id}
|
|
72
|
+
type="button"
|
|
73
|
+
role="menuitem"
|
|
74
|
+
tabIndex={-1}
|
|
75
|
+
className="cv-p-failed-menu__item cv-p-message-menu__item"
|
|
76
|
+
disabled={entry.disabled}
|
|
77
|
+
onClick={() => {
|
|
78
|
+
menu.close()
|
|
79
|
+
entry.run()
|
|
80
|
+
}}
|
|
81
|
+
>
|
|
82
|
+
{entry.label}
|
|
83
|
+
</button>
|
|
84
|
+
))}
|
|
85
|
+
</div>
|
|
86
|
+
) : null}
|
|
87
|
+
</span>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
@@ -5,7 +5,7 @@ import type { ParticipantConversationSummary, ParticipantSubjectRef } from '@ada
|
|
|
5
5
|
import type { ConversationChannel } from '../conversationChannel'
|
|
6
6
|
import type { QuickReply } from '../quickReplies/quickReply.types'
|
|
7
7
|
import type { ConversationsTheme } from '../types'
|
|
8
|
-
import type { ParticipantAuthorAvatarRenderer } from './ParticipantAuthorAvatar'
|
|
8
|
+
import type { ParticipantAuthorAvatarRenderer, ParticipantMessageActionsRenderer } from './ParticipantAuthorAvatar'
|
|
9
9
|
import { ParticipantConversationsScreen } from './ParticipantConversationsScreen'
|
|
10
10
|
import type {
|
|
11
11
|
ParticipantConversationsClassNames,
|
|
@@ -16,6 +16,7 @@ import type { ParticipantConversationsApi, ParticipantPendingMessage } from './p
|
|
|
16
16
|
import type { ParticipantAudioRecordingOptions } from './participantAudio.types'
|
|
17
17
|
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
18
18
|
import { useParticipantInbox } from './useParticipantInbox'
|
|
19
|
+
import type { ParticipantMessageMenuOptions } from './participantMessageMenuModel'
|
|
19
20
|
|
|
20
21
|
export type ParticipantConversationsProps = {
|
|
21
22
|
api: ParticipantConversationsApi
|
|
@@ -40,6 +41,10 @@ export type ParticipantConversationsProps = {
|
|
|
40
41
|
avatars?: 'initials'
|
|
41
42
|
/** Host slot for the author's photo; wins over 'initials' and enables avatars on its own. */
|
|
42
43
|
renderAuthorAvatar?: ParticipantAuthorAvatarRenderer
|
|
44
|
+
/** Host slot at the end of each message's meta line (copy, forward...). The package knows no action. */
|
|
45
|
+
renderMessageActions?: ParticipantMessageActionsRenderer
|
|
46
|
+
/** Opt-in message menu as in common chat apps: copy, select messages, plus the host's entries. */
|
|
47
|
+
messageMenu?: ParticipantMessageMenuOptions
|
|
43
48
|
/** Speech-bubble tail on the bottom corner; false removes it. Default true. */
|
|
44
49
|
tail?: boolean
|
|
45
50
|
/** Absent draws no microphone. Present lets the user record and send an audio as a message of its own. */
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { useRef, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
import { ParticipantMessageMenu } from './ParticipantMessageMenu'
|
|
4
|
+
import type { ParticipantMessageMenuState } from './ParticipantMessageMenuContext'
|
|
5
|
+
import {
|
|
6
|
+
renderParticipantBubble,
|
|
7
|
+
type BubbleContent,
|
|
8
|
+
type ParticipantMessageBubbleProps,
|
|
9
|
+
} from './ParticipantMessageBubble'
|
|
10
|
+
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS as DEFAULTS } from './participantLabels'
|
|
11
|
+
import { selectedTextWithin } from './participantMessageMenuModel'
|
|
12
|
+
import { useParticipantFailedMenu } from './useParticipantFailedMenu'
|
|
13
|
+
|
|
14
|
+
const LONG_PRESS_MS = 500
|
|
15
|
+
|
|
16
|
+
type ParticipantMenuBubbleProps = {
|
|
17
|
+
readonly props: ParticipantMessageBubbleProps
|
|
18
|
+
readonly content: BubbleContent
|
|
19
|
+
readonly textId: string
|
|
20
|
+
readonly state: ParticipantMessageMenuState
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A server bubble with the message menu: chevron on hover, right-click or long press, and selection mode. */
|
|
24
|
+
export function ParticipantMenuBubble({ props, content, textId, state }: ParticipantMenuBubbleProps) {
|
|
25
|
+
const menu = useParticipantFailedMenu()
|
|
26
|
+
const bubbleRef = useRef<HTMLDivElement | null>(null)
|
|
27
|
+
const pressTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
28
|
+
const [selectedText, setSelectedText] = useState('')
|
|
29
|
+
const id = content.messageId ?? ''
|
|
30
|
+
const message = state.messages.find((candidate) => candidate.id === id)
|
|
31
|
+
|
|
32
|
+
function captureSelection(): void {
|
|
33
|
+
if (!menu.state.isOpen) setSelectedText(selectedTextWithin(bubbleRef.current))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function openFromGesture(): void {
|
|
37
|
+
captureSelection()
|
|
38
|
+
if (!menu.state.isOpen) menu.open()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function cancelPress(): void {
|
|
42
|
+
clearTimeout(pressTimer.current)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (state.isSelecting)
|
|
46
|
+
return (
|
|
47
|
+
<SelectRow state={state} id={id}>
|
|
48
|
+
{renderParticipantBubble({ props, content, textId })}
|
|
49
|
+
</SelectRow>
|
|
50
|
+
)
|
|
51
|
+
if (!message) return renderParticipantBubble({ props, content, textId })
|
|
52
|
+
|
|
53
|
+
return renderParticipantBubble({
|
|
54
|
+
props,
|
|
55
|
+
content,
|
|
56
|
+
textId,
|
|
57
|
+
frame: {
|
|
58
|
+
ref: bubbleRef,
|
|
59
|
+
className: menu.state.isOpen ? 'cv-p-bubble--menu-open' : undefined,
|
|
60
|
+
handlers: {
|
|
61
|
+
onContextMenu: (event) => {
|
|
62
|
+
event.preventDefault()
|
|
63
|
+
openFromGesture()
|
|
64
|
+
},
|
|
65
|
+
onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => {
|
|
66
|
+
if (event.pointerType !== 'touch') return
|
|
67
|
+
cancelPress()
|
|
68
|
+
pressTimer.current = setTimeout(openFromGesture, LONG_PRESS_MS)
|
|
69
|
+
},
|
|
70
|
+
onPointerUp: cancelPress,
|
|
71
|
+
onPointerCancel: cancelPress,
|
|
72
|
+
onPointerMove: cancelPress,
|
|
73
|
+
},
|
|
74
|
+
corner: (
|
|
75
|
+
<ParticipantMessageMenu
|
|
76
|
+
menu={menu}
|
|
77
|
+
state={state}
|
|
78
|
+
message={message}
|
|
79
|
+
selectedText={selectedText}
|
|
80
|
+
onTriggerPointerDown={captureSelection}
|
|
81
|
+
/>
|
|
82
|
+
),
|
|
83
|
+
},
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
type SelectRowProps = { readonly state: ParticipantMessageMenuState; readonly id: string; readonly children: ReactNode }
|
|
88
|
+
|
|
89
|
+
/** Selection mode: a check box beside the bubble; a click anywhere on the row checks or unchecks it. */
|
|
90
|
+
function SelectRow({ state, id, children }: SelectRowProps) {
|
|
91
|
+
const isSelected = state.selectedIds.includes(id)
|
|
92
|
+
const label = state.labels.selectMessage ?? DEFAULTS.selectMessage ?? ''
|
|
93
|
+
return (
|
|
94
|
+
<div className="cv-p-select-row" data-selected={isSelected ? 'true' : undefined} onClick={() => state.toggle(id)}>
|
|
95
|
+
<input
|
|
96
|
+
type="checkbox"
|
|
97
|
+
className="cv-p-select-row__check"
|
|
98
|
+
checked={isSelected}
|
|
99
|
+
aria-label={label}
|
|
100
|
+
onChange={() => state.toggle(id)}
|
|
101
|
+
onClick={(event) => event.stopPropagation()}
|
|
102
|
+
/>
|
|
103
|
+
{children}
|
|
104
|
+
</div>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
@@ -247,3 +247,54 @@ describe('ParticipantMessageBubble avatar slot', () => {
|
|
|
247
247
|
expect(own).not.toContain('cv-p-bubble-row')
|
|
248
248
|
})
|
|
249
249
|
})
|
|
250
|
+
|
|
251
|
+
describe('renderMessageActions', () => {
|
|
252
|
+
const message = buildMessage({ id: 'm-actions' })
|
|
253
|
+
|
|
254
|
+
it('draws the host actions at the end of a server message and hands over the message', () => {
|
|
255
|
+
const received: string[] = []
|
|
256
|
+
const markup = renderToStaticMarkup(
|
|
257
|
+
<ParticipantMessageBubble
|
|
258
|
+
item={{ kind: 'server', message }}
|
|
259
|
+
labels={LABELS}
|
|
260
|
+
confirmsRead
|
|
261
|
+
resolveAttachmentUrl={resolveAttachmentUrl}
|
|
262
|
+
renderMessageActions={(target) => {
|
|
263
|
+
received.push(target.id)
|
|
264
|
+
return <button type="button">copy</button>
|
|
265
|
+
}}
|
|
266
|
+
/>,
|
|
267
|
+
)
|
|
268
|
+
expect(received).toEqual(['m-actions'])
|
|
269
|
+
expect(markup).toContain('cv-p-bubble__host-actions')
|
|
270
|
+
expect(markup).toContain('>copy<')
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('draws nothing for a message the server does not hold yet', () => {
|
|
274
|
+
const markup = renderToStaticMarkup(
|
|
275
|
+
<ParticipantMessageBubble
|
|
276
|
+
item={{
|
|
277
|
+
kind: 'pending',
|
|
278
|
+
origin: 'host',
|
|
279
|
+
displayState: 'sending',
|
|
280
|
+
pending: {
|
|
281
|
+
clientMessageId: 'c1',
|
|
282
|
+
subject: { subjectType: 'invoice', subjectId: '1' },
|
|
283
|
+
text: 'oi',
|
|
284
|
+
createdAt: '2026-01-01T10:00:00.000Z',
|
|
285
|
+
state: 'queued',
|
|
286
|
+
},
|
|
287
|
+
}}
|
|
288
|
+
labels={LABELS}
|
|
289
|
+
confirmsRead
|
|
290
|
+
resolveAttachmentUrl={resolveAttachmentUrl}
|
|
291
|
+
renderMessageActions={() => <button type="button">copy</button>}
|
|
292
|
+
/>,
|
|
293
|
+
)
|
|
294
|
+
expect(markup).not.toContain('cv-p-bubble__host-actions')
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('stays as before when the host gives no slot', () => {
|
|
298
|
+
expect(renderServer(message)).not.toContain('cv-p-bubble__host-actions')
|
|
299
|
+
})
|
|
300
|
+
})
|