@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
package/dist/styles.css
CHANGED
|
@@ -2145,6 +2145,216 @@
|
|
|
2145
2145
|
.cv-p-failed-menu__item:focus-visible {
|
|
2146
2146
|
background: var(--cv-p-i-highlight);
|
|
2147
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
|
+
}
|
|
2148
2358
|
.cv-p-attachment {
|
|
2149
2359
|
display: flex;
|
|
2150
2360
|
flex-direction: column;
|
|
@@ -2529,6 +2739,9 @@
|
|
|
2529
2739
|
.cv-p-composer__attach {
|
|
2530
2740
|
transition: background-color 0.12s, border-color 0.12s;
|
|
2531
2741
|
}
|
|
2742
|
+
.cv-p-message-menu__trigger {
|
|
2743
|
+
transition: opacity 0.12s;
|
|
2744
|
+
}
|
|
2532
2745
|
}
|
|
2533
2746
|
.cv-p-thread__scroll,
|
|
2534
2747
|
.cv-p-inbox,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adatechnology/conversations-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "WhatsApp conversation UI components — parametrizável por endpoint, tema e feature flags",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
"clsx": "^2.1.1",
|
|
36
36
|
"lucide-react": "^1.21.0",
|
|
37
37
|
"tailwind-merge": "^3.6.0",
|
|
38
|
-
"@adatechnology/
|
|
39
|
-
"@adatechnology/
|
|
38
|
+
"@adatechnology/conversation-contracts": "0.7.0",
|
|
39
|
+
"@adatechnology/meta-whatsapp-contracts": "0.6.0"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
42
|
"@xyflow/react": "^12",
|
|
@@ -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
|
}
|
|
@@ -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
|
|
@@ -53,6 +54,8 @@ export type ConversationThreadProps = {
|
|
|
53
54
|
readonly renderAuthorAvatar?: ParticipantAuthorAvatarRenderer
|
|
54
55
|
/** Host slot at the end of each message's meta line (copy, forward...). The package knows no action. */
|
|
55
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
|
|
56
59
|
/** Absent draws no microphone. Present lets the user record and send an audio as a message of its own. */
|
|
57
60
|
readonly audioRecording?: ParticipantAudioRecordingOptions
|
|
58
61
|
/** Return a node to replace the composer (notice + host actions); undefined keeps the composer. `closed` still wins. Evaluated every render. */
|
|
@@ -110,6 +113,7 @@ function ConversationThreadBody(props: ConversationThreadProps) {
|
|
|
110
113
|
avatars={props.avatars}
|
|
111
114
|
renderAuthorAvatar={props.renderAuthorAvatar}
|
|
112
115
|
renderMessageActions={props.renderMessageActions}
|
|
116
|
+
messageMenu={props.messageMenu}
|
|
113
117
|
tail={props.tail}
|
|
114
118
|
counterpartLabel={props.counterpartLabel}
|
|
115
119
|
headerActions={props.headerActions}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { User } from 'lucide-react'
|
|
2
|
-
import type { ParticipantAttachment } from '@adatechnology/conversation-contracts'
|
|
2
|
+
import type { MessageDeliveryStatus, ParticipantAttachment } from '@adatechnology/conversation-contracts'
|
|
3
3
|
import type { ReactNode } from 'react'
|
|
4
4
|
|
|
5
5
|
import { authorInitials } from './participantAvatar'
|
|
@@ -13,6 +13,12 @@ export type ParticipantMessageActionsTarget = {
|
|
|
13
13
|
readonly authorName: string | null
|
|
14
14
|
readonly isMine: boolean
|
|
15
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
|
|
16
22
|
}
|
|
17
23
|
|
|
18
24
|
export type ParticipantMessageActionsRenderer = (message: ParticipantMessageActionsTarget) => ReactNode
|
|
@@ -34,6 +34,7 @@ function hostActions(content: BubbleContent, render?: ParticipantMessageActionsR
|
|
|
34
34
|
authorName: content.authorName ?? null,
|
|
35
35
|
isMine: content.isMine,
|
|
36
36
|
attachments: content.attachments,
|
|
37
|
+
createdAt: content.createdAt,
|
|
37
38
|
})
|
|
38
39
|
}
|
|
39
40
|
|
|
@@ -43,13 +44,25 @@ function OriginalText({ content, labels }: Pick<BubbleBodyProps, 'content' | 'la
|
|
|
43
44
|
if (!original || original === content.text?.trim()) return null
|
|
44
45
|
return (
|
|
45
46
|
<details className="cv-p-bubble__original">
|
|
46
|
-
<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>
|
|
47
50
|
<div className="cv-p-bubble__original-text">{original}</div>
|
|
48
51
|
</details>
|
|
49
52
|
)
|
|
50
53
|
}
|
|
51
54
|
|
|
52
|
-
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) {
|
|
53
66
|
const actions = hostActions(content, renderMessageActions)
|
|
54
67
|
return (
|
|
55
68
|
<>
|
|
@@ -73,7 +86,9 @@ export function BubbleBody({ content, labels, ownStatus, textId, resolveAttachme
|
|
|
73
86
|
{formatTimestamp(content.createdAt)}
|
|
74
87
|
</time>
|
|
75
88
|
{ownStatus ? <OwnStatus status={ownStatus} labels={labels} onRetry={onRetry} /> : null}
|
|
76
|
-
{ownStatus === 'failed' ?
|
|
89
|
+
{ownStatus === 'failed' ? (
|
|
90
|
+
<ParticipantFailedMenu labels={labels} onEdit={onEdit} onDiscard={onDiscard} />
|
|
91
|
+
) : null}
|
|
77
92
|
{actions ? <span className="cv-p-bubble__host-actions">{actions}</span> : null}
|
|
78
93
|
</span>
|
|
79
94
|
{/* Last child so the ids React derives for the siblings above stay as they were; CSS keeps the meta after it. */}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
|
@@ -42,6 +43,8 @@ export type ParticipantConversationsProps = {
|
|
|
42
43
|
renderAuthorAvatar?: ParticipantAuthorAvatarRenderer
|
|
43
44
|
/** Host slot at the end of each message's meta line (copy, forward...). The package knows no action. */
|
|
44
45
|
renderMessageActions?: ParticipantMessageActionsRenderer
|
|
46
|
+
/** Opt-in message menu as in common chat apps: copy, select messages, plus the host's entries. */
|
|
47
|
+
messageMenu?: ParticipantMessageMenuOptions
|
|
45
48
|
/** Speech-bubble tail on the bottom corner; false removes it. Default true. */
|
|
46
49
|
tail?: boolean
|
|
47
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
|
+
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { useId, type ReactNode } from 'react'
|
|
1
|
+
import { useId, type HTMLAttributes, type ReactNode, type Ref } from 'react'
|
|
2
2
|
|
|
3
3
|
import type { MessageDeliveryStatus, ParticipantAttachment } from '@adatechnology/conversation-contracts'
|
|
4
4
|
|
|
5
5
|
import { BubbleBody } from './ParticipantBubbleBody'
|
|
6
|
+
import { ParticipantMenuBubble } from './ParticipantMenuBubble'
|
|
7
|
+
import { useParticipantMessageMenu } from './ParticipantMessageMenuContext'
|
|
6
8
|
import type { ParticipantMessageActionsRenderer } from './ParticipantAuthorAvatar'
|
|
7
9
|
import { RetryButton } from './ParticipantBubbleStatus'
|
|
8
10
|
import type { ResolveParticipantAttachmentUrl } from './ParticipantAttachmentItem'
|
|
@@ -82,20 +84,35 @@ function bubbleClassName(isMine: boolean, hasTail: boolean, hasFailed: boolean):
|
|
|
82
84
|
return classes.join(' ')
|
|
83
85
|
}
|
|
84
86
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
87
|
+
/** What the message menu adds to a bubble: its ref and handlers, a corner slot and a state class. */
|
|
88
|
+
export type ParticipantBubbleFrame = {
|
|
89
|
+
readonly ref?: Ref<HTMLDivElement>
|
|
90
|
+
readonly handlers?: HTMLAttributes<HTMLDivElement>
|
|
91
|
+
readonly corner?: ReactNode
|
|
92
|
+
readonly className?: string
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
type RenderBubbleParams = {
|
|
96
|
+
readonly props: ParticipantMessageBubbleProps
|
|
97
|
+
readonly content: BubbleContent
|
|
98
|
+
readonly textId: string
|
|
99
|
+
readonly frame?: ParticipantBubbleFrame
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function renderParticipantBubble({ props, content, textId, frame }: RenderBubbleParams): ReactNode {
|
|
103
|
+
const { confirmsRead, onRetry, avatar, tail = true } = props
|
|
89
104
|
const ownStatus = content.isMine
|
|
90
105
|
? resolveOwnMessageStatus({ displayState: content.displayState, serverStatus: content.serverStatus, confirmsRead })
|
|
91
106
|
: undefined
|
|
92
107
|
const isFailed = ownStatus === 'failed'
|
|
93
108
|
const canRetry = isFailed && onRetry !== undefined
|
|
109
|
+
const className = [bubbleClassName(content.isMine, tail, isFailed), frame?.className].filter(Boolean).join(' ')
|
|
94
110
|
|
|
95
111
|
const bubble = (
|
|
96
|
-
<div className={
|
|
97
|
-
{canRetry ? <RetryButton labels={labels} onRetry={onRetry} describedBy={textId} /> : null}
|
|
112
|
+
<div className={className} ref={frame?.ref} {...frame?.handlers}>
|
|
113
|
+
{canRetry ? <RetryButton labels={props.labels} onRetry={onRetry} describedBy={textId} /> : null}
|
|
98
114
|
<BubbleBody {...props} content={content} ownStatus={ownStatus} textId={canRetry ? textId : undefined} />
|
|
115
|
+
{frame?.corner}
|
|
99
116
|
</div>
|
|
100
117
|
)
|
|
101
118
|
if (avatar === undefined || content.isMine) return bubble
|
|
@@ -106,3 +123,13 @@ export function ParticipantMessageBubble(props: ParticipantMessageBubbleProps) {
|
|
|
106
123
|
</div>
|
|
107
124
|
)
|
|
108
125
|
}
|
|
126
|
+
|
|
127
|
+
export function ParticipantMessageBubble(props: ParticipantMessageBubbleProps) {
|
|
128
|
+
const textId = useId()
|
|
129
|
+
const menu = useParticipantMessageMenu()
|
|
130
|
+
const content = describeItem(props.item, props.perspective)
|
|
131
|
+
if (menu && content.messageId !== undefined) {
|
|
132
|
+
return <ParticipantMenuBubble props={props} content={content} textId={textId} state={menu} />
|
|
133
|
+
}
|
|
134
|
+
return renderParticipantBubble({ props, content, textId })
|
|
135
|
+
}
|