@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.
Files changed (35) hide show
  1. package/dist/chunk-RLUNDWGA.js +51 -0
  2. package/dist/index.d.ts +2 -1
  3. package/dist/index.js +5 -43
  4. package/dist/participant/index.d.ts +74 -2
  5. package/dist/participant/index.js +1058 -399
  6. package/dist/styles.css +213 -0
  7. package/package.json +3 -3
  8. package/src/conversationTranscript.ts +6 -2
  9. package/src/participant/ConversationThread.tsx +4 -0
  10. package/src/participant/ParticipantAuthorAvatar.tsx +7 -1
  11. package/src/participant/ParticipantBubbleBody.tsx +18 -3
  12. package/src/participant/ParticipantConversationMenu.tsx +89 -0
  13. package/src/participant/ParticipantConversations.tsx +3 -0
  14. package/src/participant/ParticipantMenuBubble.tsx +106 -0
  15. package/src/participant/ParticipantMessageBubble.tsx +34 -7
  16. package/src/participant/ParticipantMessageInfo.tsx +70 -0
  17. package/src/participant/ParticipantMessageMenu.tsx +168 -0
  18. package/src/participant/ParticipantMessageMenuContext.tsx +109 -0
  19. package/src/participant/ParticipantSelectionBar.tsx +64 -0
  20. package/src/participant/ParticipantThread.tsx +27 -2
  21. package/src/participant/ParticipantThreadScreen.tsx +1 -0
  22. package/src/participant/index.ts +6 -0
  23. package/src/participant/participantConversationMenuDom.test.tsx +166 -0
  24. package/src/participant/participantLabels.ts +40 -0
  25. package/src/participant/participantMessageInfoModel.test.ts +68 -0
  26. package/src/participant/participantMessageInfoModel.ts +80 -0
  27. package/src/participant/participantMessageMenuDom.test.tsx +111 -0
  28. package/src/participant/participantMessageMenuModel.test.ts +43 -0
  29. package/src/participant/participantMessageMenuModel.ts +91 -0
  30. package/src/participant/participantMessageMoment.ts +7 -0
  31. package/src/participant/participantTranscriptionText.test.ts +48 -0
  32. package/src/participant/participantTranscriptionText.ts +28 -0
  33. package/src/participant/useOpenUpwards.ts +22 -0
  34. package/src/participant/useParticipantFailedMenu.ts +3 -0
  35. 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.13.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/meta-whatsapp-contracts": "0.6.0",
39
- "@adatechnology/conversation-contracts": "0.7.0"
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
- URL.revokeObjectURL(url)
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">{labels.showOriginalText ?? DEFAULT_SHOW_ORIGINAL_TEXT_LABEL}</summary>
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({ content, labels, ownStatus, textId, resolveAttachmentUrl, onRetry, onDiscard, onEdit, renderMessageActions }: BubbleBodyProps) {
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' ? <ParticipantFailedMenu labels={labels} onEdit={onEdit} onDiscard={onDiscard} /> : null}
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
- export function ParticipantMessageBubble(props: ParticipantMessageBubbleProps) {
86
- const { item, labels, confirmsRead, onRetry, avatar, tail = true, perspective } = props
87
- const textId = useId()
88
- const content = describeItem(item, perspective)
87
+ /** What the message menu adds to a bubble: its ref and handlers, a corner slot and a state class. */
88
+ export type ParticipantBubbleFrame = {
89
+ readonly ref?: Ref<HTMLDivElement>
90
+ readonly handlers?: HTMLAttributes<HTMLDivElement>
91
+ readonly corner?: ReactNode
92
+ readonly className?: string
93
+ }
94
+
95
+ type RenderBubbleParams = {
96
+ readonly props: ParticipantMessageBubbleProps
97
+ readonly content: BubbleContent
98
+ readonly textId: string
99
+ readonly frame?: ParticipantBubbleFrame
100
+ }
101
+
102
+ export function renderParticipantBubble({ props, content, textId, frame }: RenderBubbleParams): ReactNode {
103
+ const { confirmsRead, onRetry, avatar, tail = true } = props
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={bubbleClassName(content.isMine, tail, isFailed)}>
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
+ }