@adatechnology/conversations-ui 0.12.0 → 0.13.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.
@@ -1,12 +1,21 @@
1
1
  import * as react from 'react';
2
2
  import { ReactNode } from 'react';
3
- import { ParticipantConversationSummary, ParticipantConversationPage, ParticipantSubjectRef, ParticipantMessage, ParticipantAttachment } from '@adatechnology/conversation-contracts';
3
+ import { ParticipantAttachment, ParticipantConversationSummary, ParticipantConversationPage, ParticipantSubjectRef, ParticipantMessage } from '@adatechnology/conversation-contracts';
4
4
  export { ParticipantAttachment, ParticipantConversationPage, ParticipantConversationSummary, ParticipantMessage, ParticipantSubjectRef, ParticipantTranscription } from '@adatechnology/conversation-contracts';
5
5
  import { C as ConversationChannel, d as ConversationsTheme, Q as QuickReply } from '../quickReply.types-DzWH3NYV.js';
6
6
 
7
7
  type ParticipantAuthorAvatarRenderer = (author: {
8
8
  name: string | null;
9
9
  }) => ReactNode;
10
+ /** What the host receives to draw per-message actions (copy, forward...); only messages the server already holds. */
11
+ type ParticipantMessageActionsTarget = {
12
+ readonly id: string;
13
+ readonly text: string | null;
14
+ readonly authorName: string | null;
15
+ readonly isMine: boolean;
16
+ readonly attachments: readonly ParticipantAttachment[];
17
+ };
18
+ type ParticipantMessageActionsRenderer = (message: ParticipantMessageActionsTarget) => ReactNode;
10
19
 
11
20
  type ParticipantSubjectGroup = {
12
21
  readonly subjectType: string;
@@ -242,6 +251,8 @@ type ParticipantConversationsProps = {
242
251
  avatars?: 'initials';
243
252
  /** Host slot for the author's photo; wins over 'initials' and enables avatars on its own. */
244
253
  renderAuthorAvatar?: ParticipantAuthorAvatarRenderer;
254
+ /** Host slot at the end of each message's meta line (copy, forward...). The package knows no action. */
255
+ renderMessageActions?: ParticipantMessageActionsRenderer;
245
256
  /** Speech-bubble tail on the bottom corner; false removes it. Default true. */
246
257
  tail?: boolean;
247
258
  /** Absent draws no microphone. Present lets the user record and send an audio as a message of its own. */
@@ -289,6 +300,8 @@ type ConversationThreadProps = {
289
300
  readonly tail?: boolean;
290
301
  readonly avatars?: 'initials';
291
302
  readonly renderAuthorAvatar?: ParticipantAuthorAvatarRenderer;
303
+ /** Host slot at the end of each message's meta line (copy, forward...). The package knows no action. */
304
+ readonly renderMessageActions?: ParticipantMessageActionsRenderer;
292
305
  /** Absent draws no microphone. Present lets the user record and send an audio as a message of its own. */
293
306
  readonly audioRecording?: ParticipantAudioRecordingOptions;
294
307
  /** Return a node to replace the composer (notice + host actions); undefined keeps the composer. `closed` still wins. Evaluated every render. */
@@ -299,4 +312,4 @@ declare function ConversationThread(props: ConversationThreadProps): react.JSX.E
299
312
 
300
313
  declare function groupParticipantConversations(params: GroupParticipantConversationsParams): ParticipantInboxView;
301
314
 
302
- export { ConversationThread, type ConversationThreadApi, type ConversationThreadProps, DEFAULT_MAX_RECORDING_BYTES, DEFAULT_PARTICIPANT_AUDIO_LABELS, DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS, type GroupParticipantConversationsParams, type ParticipantAudioLabels, type ParticipantAudioRecordingOptions, type ParticipantAuthorAvatarRenderer, type ParticipantConversationEvent, ParticipantConversations, type ParticipantConversationsApi, type ParticipantConversationsClassNames, type ParticipantConversationsLabels, type ParticipantConversationsProps, type ParticipantInboxSection, type ParticipantInboxView, type ParticipantLocalPendingMessage, type ParticipantPendingMessage, type ParticipantPerspective, type ParticipantSendInput, type ParticipantSendResult, type ParticipantSubjectGroup, type ParticipantSubjectIconRenderer, groupParticipantConversations };
315
+ export { ConversationThread, type ConversationThreadApi, type ConversationThreadProps, DEFAULT_MAX_RECORDING_BYTES, DEFAULT_PARTICIPANT_AUDIO_LABELS, DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS, type GroupParticipantConversationsParams, type ParticipantAudioLabels, type ParticipantAudioRecordingOptions, type ParticipantAuthorAvatarRenderer, type ParticipantConversationEvent, ParticipantConversations, type ParticipantConversationsApi, type ParticipantConversationsClassNames, type ParticipantConversationsLabels, type ParticipantConversationsProps, type ParticipantInboxSection, type ParticipantInboxView, type ParticipantLocalPendingMessage, type ParticipantMessageActionsRenderer, type ParticipantMessageActionsTarget, type ParticipantPendingMessage, type ParticipantPerspective, type ParticipantSendInput, type ParticipantSendResult, type ParticipantSubjectGroup, type ParticipantSubjectIconRenderer, groupParticipantConversations };
@@ -2579,6 +2579,16 @@ function BubbleHeading({ content, labels }) {
2579
2579
  if (!content.authorName) return null;
2580
2580
  return /* @__PURE__ */ jsx27("span", { className: "cv-p-bubble__author", children: content.authorName });
2581
2581
  }
2582
+ function hostActions(content, render) {
2583
+ if (!render || content.messageId === void 0) return null;
2584
+ return render({
2585
+ id: content.messageId,
2586
+ text: content.text ?? null,
2587
+ authorName: content.authorName ?? null,
2588
+ isMine: content.isMine,
2589
+ attachments: content.attachments
2590
+ });
2591
+ }
2582
2592
  function OriginalText({ content, labels }) {
2583
2593
  const original = content.originalText?.trim();
2584
2594
  if (!original || original === content.text?.trim()) return null;
@@ -2587,7 +2597,8 @@ function OriginalText({ content, labels }) {
2587
2597
  /* @__PURE__ */ jsx27("div", { className: "cv-p-bubble__original-text", children: original })
2588
2598
  ] });
2589
2599
  }
2590
- function BubbleBody({ content, labels, ownStatus, textId, resolveAttachmentUrl, onRetry, onDiscard, onEdit }) {
2600
+ function BubbleBody({ content, labels, ownStatus, textId, resolveAttachmentUrl, onRetry, onDiscard, onEdit, renderMessageActions }) {
2601
+ const actions = hostActions(content, renderMessageActions);
2591
2602
  return /* @__PURE__ */ jsxs21(Fragment6, { children: [
2592
2603
  /* @__PURE__ */ jsx27(BubbleHeading, { content, labels }),
2593
2604
  content.text ? /* @__PURE__ */ jsx27(ParticipantMessageText, { text: content.text, labels, id: textId }) : null,
@@ -2604,7 +2615,8 @@ function BubbleBody({ content, labels, ownStatus, textId, resolveAttachmentUrl,
2604
2615
  /* @__PURE__ */ jsxs21("span", { className: "cv-p-bubble__meta", children: [
2605
2616
  /* @__PURE__ */ jsx27("time", { className: "cv-p-bubble__time", dateTime: content.createdAt, children: formatTimestamp(content.createdAt) }),
2606
2617
  ownStatus ? /* @__PURE__ */ jsx27(OwnStatus, { status: ownStatus, labels, onRetry }) : null,
2607
- ownStatus === "failed" ? /* @__PURE__ */ jsx27(ParticipantFailedMenu, { labels, onEdit, onDiscard }) : null
2618
+ ownStatus === "failed" ? /* @__PURE__ */ jsx27(ParticipantFailedMenu, { labels, onEdit, onDiscard }) : null,
2619
+ actions ? /* @__PURE__ */ jsx27("span", { className: "cv-p-bubble__host-actions", children: actions }) : null
2608
2620
  ] }),
2609
2621
  /* @__PURE__ */ jsx27(OriginalText, { content, labels })
2610
2622
  ] });
@@ -2684,7 +2696,8 @@ function describeItem(item, perspective) {
2684
2696
  createdAt: message.createdAt,
2685
2697
  attachments: message.attachments,
2686
2698
  pendingFilenames: [],
2687
- serverStatus: message.status
2699
+ serverStatus: message.status,
2700
+ messageId: message.id
2688
2701
  };
2689
2702
  }
2690
2703
  return {
@@ -2792,6 +2805,7 @@ function ParticipantTimeline(props) {
2792
2805
  tail: props.tail,
2793
2806
  perspective: props.perspective,
2794
2807
  avatar,
2808
+ renderMessageActions: props.renderMessageActions,
2795
2809
  ...resolveActions(item, pendingActions)
2796
2810
  },
2797
2811
  keyOf(item)
@@ -3690,6 +3704,7 @@ function LoadedThread(props) {
3690
3704
  renderSubjectIcon: props.renderSubjectIcon,
3691
3705
  avatars: props.avatars,
3692
3706
  renderAuthorAvatar: props.renderAuthorAvatar,
3707
+ renderMessageActions: props.renderMessageActions,
3693
3708
  tail: props.tail,
3694
3709
  audioRecording: props.audioRecording,
3695
3710
  renderComposerLock: props.renderComposerLock
@@ -4057,6 +4072,7 @@ function ConversationThreadBody(props) {
4057
4072
  renderSubjectIcon: props.renderSubjectIcon,
4058
4073
  avatars: props.avatars,
4059
4074
  renderAuthorAvatar: props.renderAuthorAvatar,
4075
+ renderMessageActions: props.renderMessageActions,
4060
4076
  tail: props.tail,
4061
4077
  counterpartLabel: props.counterpartLabel,
4062
4078
  headerActions: props.headerActions,
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adatechnology/conversations-ui",
3
- "version": "0.12.0",
3
+ "version": "0.13.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/conversation-contracts": "0.7.0",
39
- "@adatechnology/meta-whatsapp-contracts": "0.6.0"
38
+ "@adatechnology/meta-whatsapp-contracts": "0.6.0",
39
+ "@adatechnology/conversation-contracts": "0.7.0"
40
40
  },
41
41
  "peerDependencies": {
42
42
  "@xyflow/react": "^12",
@@ -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'
@@ -51,6 +51,8 @@ export type ConversationThreadProps = {
51
51
  readonly tail?: boolean
52
52
  readonly avatars?: 'initials'
53
53
  readonly renderAuthorAvatar?: ParticipantAuthorAvatarRenderer
54
+ /** Host slot at the end of each message's meta line (copy, forward...). The package knows no action. */
55
+ readonly renderMessageActions?: ParticipantMessageActionsRenderer
54
56
  /** Absent draws no microphone. Present lets the user record and send an audio as a message of its own. */
55
57
  readonly audioRecording?: ParticipantAudioRecordingOptions
56
58
  /** Return a node to replace the composer (notice + host actions); undefined keeps the composer. `closed` still wins. Evaluated every render. */
@@ -107,6 +109,7 @@ function ConversationThreadBody(props: ConversationThreadProps) {
107
109
  renderSubjectIcon={props.renderSubjectIcon}
108
110
  avatars={props.avatars}
109
111
  renderAuthorAvatar={props.renderAuthorAvatar}
112
+ renderMessageActions={props.renderMessageActions}
110
113
  tail={props.tail}
111
114
  counterpartLabel={props.counterpartLabel}
112
115
  headerActions={props.headerActions}
@@ -1,10 +1,22 @@
1
1
  import { User } from 'lucide-react'
2
+ import type { 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
+ }
17
+
18
+ export type ParticipantMessageActionsRenderer = (message: ParticipantMessageActionsTarget) => ReactNode
19
+
8
20
  export type ParticipantAuthorAvatarProps = {
9
21
  readonly name: string | null
10
22
  /** 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,6 +26,17 @@ 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
+ })
38
+ }
39
+
27
40
  /** Collapsed by default: the trimmed text is what the reader needs, the original is there on demand. */
28
41
  function OriginalText({ content, labels }: Pick<BubbleBodyProps, 'content' | 'labels'>) {
29
42
  const original = content.originalText?.trim()
@@ -36,7 +49,8 @@ function OriginalText({ content, labels }: Pick<BubbleBodyProps, 'content' | 'la
36
49
  )
37
50
  }
38
51
 
39
- export function BubbleBody({ content, labels, ownStatus, textId, resolveAttachmentUrl, onRetry, onDiscard, onEdit }: BubbleBodyProps) {
52
+ export function BubbleBody({ content, labels, ownStatus, textId, resolveAttachmentUrl, onRetry, onDiscard, onEdit, renderMessageActions }: BubbleBodyProps) {
53
+ const actions = hostActions(content, renderMessageActions)
40
54
  return (
41
55
  <>
42
56
  <BubbleHeading content={content} labels={labels} />
@@ -60,6 +74,7 @@ export function BubbleBody({ content, labels, ownStatus, textId, resolveAttachme
60
74
  </time>
61
75
  {ownStatus ? <OwnStatus status={ownStatus} labels={labels} onRetry={onRetry} /> : null}
62
76
  {ownStatus === 'failed' ? <ParticipantFailedMenu labels={labels} onEdit={onEdit} onDiscard={onDiscard} /> : null}
77
+ {actions ? <span className="cv-p-bubble__host-actions">{actions}</span> : null}
63
78
  </span>
64
79
  {/* Last child so the ids React derives for the siblings above stay as they were; CSS keeps the meta after it. */}
65
80
  <OriginalText content={content} labels={labels} />
@@ -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,
@@ -40,6 +40,8 @@ export type ParticipantConversationsProps = {
40
40
  avatars?: 'initials'
41
41
  /** Host slot for the author's photo; wins over 'initials' and enables avatars on its own. */
42
42
  renderAuthorAvatar?: ParticipantAuthorAvatarRenderer
43
+ /** Host slot at the end of each message's meta line (copy, forward...). The package knows no action. */
44
+ renderMessageActions?: ParticipantMessageActionsRenderer
43
45
  /** Speech-bubble tail on the bottom corner; false removes it. Default true. */
44
46
  tail?: boolean
45
47
  /** Absent draws no microphone. Present lets the user record and send an audio as a message of its own. */
@@ -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
+ })
@@ -3,6 +3,7 @@ import { useId, type ReactNode } from 'react'
3
3
  import type { MessageDeliveryStatus, ParticipantAttachment } from '@adatechnology/conversation-contracts'
4
4
 
5
5
  import { BubbleBody } from './ParticipantBubbleBody'
6
+ import type { ParticipantMessageActionsRenderer } from './ParticipantAuthorAvatar'
6
7
  import { RetryButton } from './ParticipantBubbleStatus'
7
8
  import type { ResolveParticipantAttachmentUrl } from './ParticipantAttachmentItem'
8
9
  import type { ParticipantConversationsLabels } from './participantLabels'
@@ -31,6 +32,8 @@ export type ParticipantMessageBubbleProps = {
31
32
  readonly tail?: boolean
32
33
  /** Who is looking; absent is the participant. The operator owns the outbound messages. */
33
34
  readonly perspective?: ParticipantPerspective
35
+ /** Host actions at the end of the meta line of a server message; pending ones have nothing to act on yet. */
36
+ readonly renderMessageActions?: ParticipantMessageActionsRenderer
34
37
  }
35
38
 
36
39
  export type BubbleContent = {
@@ -43,6 +46,7 @@ export type BubbleContent = {
43
46
  readonly pendingFilenames: readonly string[]
44
47
  readonly displayState?: ParticipantPendingDisplayState
45
48
  readonly serverStatus?: MessageDeliveryStatus
49
+ readonly messageId?: string
46
50
  }
47
51
 
48
52
  function describeItem(item: ParticipantTimelineItem, perspective?: ParticipantPerspective): BubbleContent {
@@ -57,6 +61,7 @@ function describeItem(item: ParticipantTimelineItem, perspective?: ParticipantPe
57
61
  attachments: message.attachments,
58
62
  pendingFilenames: [],
59
63
  serverStatus: message.status,
64
+ messageId: message.id,
60
65
  }
61
66
  }
62
67
  return {
@@ -16,7 +16,7 @@ import { resolveLoadView, type ParticipantLoadStatus } from './participantLoadVi
16
16
  import { ParticipantLoadError, ParticipantLoading } from './ParticipantLoadState'
17
17
  import type { ParticipantPerspective } from './participantPerspective'
18
18
  import type { ParticipantThreadScroll } from './useStickToBottom'
19
- import type { ParticipantAuthorAvatarRenderer } from './ParticipantAuthorAvatar'
19
+ import type { ParticipantAuthorAvatarRenderer, ParticipantMessageActionsRenderer } from './ParticipantAuthorAvatar'
20
20
  import { ParticipantScrollToLatest } from './ParticipantScrollToLatest'
21
21
  import { ParticipantTimeline } from './ParticipantTimeline'
22
22
  import { ParticipantThreadHeader } from './ParticipantThreadHeader'
@@ -62,6 +62,8 @@ export type ParticipantThreadProps = {
62
62
  readonly avatars?: 'initials'
63
63
  /** Host slot for the author's photo; wins over 'initials' and enables avatars on its own. */
64
64
  readonly renderAuthorAvatar?: ParticipantAuthorAvatarRenderer
65
+ /** Host slot at the end of each message's meta line (copy, forward...). The package knows no action. */
66
+ readonly renderMessageActions?: ParticipantMessageActionsRenderer
65
67
  /** Host icon of the conversation avatar in the header; absent or empty falls back to the subject group icon. */
66
68
  readonly renderSubjectIcon?: ParticipantSubjectIconRenderer
67
69
  /** Speech-bubble tail on the bottom corner; false removes it. Default true. */
@@ -103,6 +103,7 @@ function LoadedThread(props: LoadedThreadProps) {
103
103
  renderSubjectIcon={props.renderSubjectIcon}
104
104
  avatars={props.avatars}
105
105
  renderAuthorAvatar={props.renderAuthorAvatar}
106
+ renderMessageActions={props.renderMessageActions}
106
107
  tail={props.tail}
107
108
  audioRecording={props.audioRecording}
108
109
  renderComposerLock={props.renderComposerLock}
@@ -4,7 +4,11 @@ import { channelCapabilityFor } from '../channelCapability'
4
4
  import type { ConversationChannel } from '../conversationChannel'
5
5
  import { isSameDay } from '../lib/format'
6
6
  import type { ResolveParticipantAttachmentUrl } from './ParticipantAttachmentItem'
7
- import { ParticipantAuthorAvatar, type ParticipantAuthorAvatarRenderer } from './ParticipantAuthorAvatar'
7
+ import {
8
+ ParticipantAuthorAvatar,
9
+ type ParticipantAuthorAvatarRenderer,
10
+ type ParticipantMessageActionsRenderer,
11
+ } from './ParticipantAuthorAvatar'
8
12
  import { ParticipantDayDivider } from './ParticipantDayDivider'
9
13
  import { ParticipantMessageBubble } from './ParticipantMessageBubble'
10
14
  import { shouldShowAvatar, type ParticipantAvatarAuthor } from './participantAvatar'
@@ -26,6 +30,7 @@ export type ParticipantTimelineProps = {
26
30
  readonly pendingActions?: ParticipantPendingActions
27
31
  readonly avatars?: 'initials'
28
32
  readonly renderAuthorAvatar?: ParticipantAuthorAvatarRenderer
33
+ readonly renderMessageActions?: ParticipantMessageActionsRenderer
29
34
  readonly tail?: boolean
30
35
  readonly perspective?: ParticipantPerspective
31
36
  }
@@ -98,6 +103,7 @@ export function ParticipantTimeline(props: ParticipantTimelineProps) {
98
103
  tail={props.tail}
99
104
  perspective={props.perspective}
100
105
  avatar={avatar}
106
+ renderMessageActions={props.renderMessageActions}
101
107
  {...resolveActions(item, pendingActions)}
102
108
  />,
103
109
  )
@@ -2,7 +2,11 @@ export { ParticipantConversations, type ParticipantConversationsProps } from './
2
2
  export { ConversationThread, type ConversationThreadProps } from './ConversationThread'
3
3
  export type { ConversationThreadApi } from './conversationThreadApi'
4
4
  export type { ParticipantPerspective } from './participantPerspective'
5
- export type { ParticipantAuthorAvatarRenderer } from './ParticipantAuthorAvatar'
5
+ export type {
6
+ ParticipantAuthorAvatarRenderer,
7
+ ParticipantMessageActionsRenderer,
8
+ ParticipantMessageActionsTarget,
9
+ } from './ParticipantAuthorAvatar'
6
10
  export { groupParticipantConversations } from './participantGrouping'
7
11
  export { DEFAULT_MAX_RECORDING_BYTES, type ParticipantAudioRecordingOptions } from './participantAudio.types'
8
12
  export { DEFAULT_PARTICIPANT_AUDIO_LABELS, type ParticipantAudioLabels } from './participantAudioLabels'
package/src/styles.css CHANGED
@@ -1302,6 +1302,7 @@
1302
1302
  .cv-p-bubble__author { display: block; flex: 1 0 100%; margin-bottom: 0.15rem; font: 0.65rem 'SFMono-Regular', Consolas, monospace; text-transform: uppercase; color: var(--cv-p-i-accent); }
1303
1303
  .cv-p-bubble > .cv-p-text { flex: 0 1 auto; min-width: 0; margin-inline-end: auto; }
1304
1304
  .cv-p-bubble > .cv-p-attachment { flex: 1 0 100%; }
1305
+ .cv-p-bubble__host-actions { display: inline-flex; align-items: center; gap: 0.25rem; }
1305
1306
  .cv-p-bubble__original { flex: 1 0 100%; margin-top: 0.2rem; }
1306
1307
  .cv-p-bubble__original-toggle { display: inline-block; font-size: 0.7rem; color: var(--cv-p-i-text-muted); cursor: pointer; list-style: none; text-decoration: underline; text-decoration-style: dotted; text-underline-offset: 2px; }
1307
1308
  .cv-p-bubble__original-toggle::-webkit-details-marker { display: none; }