@adatechnology/conversations-ui 0.11.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;
@@ -190,6 +199,8 @@ type ParticipantConversationsLabels = {
190
199
  readonly edit: string;
191
200
  /** Name of the menu button on a failed message. Optional; translated hosts should fill it, otherwise the English default shows. */
192
201
  readonly messageOptions?: string;
202
+ /** Toggle that opens the message as received (e.g. the whole email). Optional; the English default shows otherwise. */
203
+ readonly showOriginalText?: string;
193
204
  readonly notFound: string;
194
205
  readonly loading: string;
195
206
  readonly loadError: string;
@@ -240,6 +251,8 @@ type ParticipantConversationsProps = {
240
251
  avatars?: 'initials';
241
252
  /** Host slot for the author's photo; wins over 'initials' and enables avatars on its own. */
242
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;
243
256
  /** Speech-bubble tail on the bottom corner; false removes it. Default true. */
244
257
  tail?: boolean;
245
258
  /** Absent draws no microphone. Present lets the user record and send an audio as a message of its own. */
@@ -287,6 +300,8 @@ type ConversationThreadProps = {
287
300
  readonly tail?: boolean;
288
301
  readonly avatars?: 'initials';
289
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;
290
305
  /** Absent draws no microphone. Present lets the user record and send an audio as a message of its own. */
291
306
  readonly audioRecording?: ParticipantAudioRecordingOptions;
292
307
  /** Return a node to replace the composer (notice + host actions); undefined keeps the composer. `closed` still wins. Evaluated every render. */
@@ -297,4 +312,4 @@ declare function ConversationThread(props: ConversationThreadProps): react.JSX.E
297
312
 
298
313
  declare function groupParticipantConversations(params: GroupParticipantConversationsParams): ParticipantInboxView;
299
314
 
300
- 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 };
@@ -64,6 +64,7 @@ function formatRecordingElapsedLabel(template, elapsed, limit) {
64
64
 
65
65
  // src/participant/participantLabels.ts
66
66
  var DEFAULT_MESSAGE_OPTIONS_LABEL = "Message options";
67
+ var DEFAULT_SHOW_ORIGINAL_TEXT_LABEL = "Show original";
67
68
  var DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS = {
68
69
  inboxTitle: "Conversations",
69
70
  sectionAwaiting: "Waiting for your reply",
@@ -101,6 +102,7 @@ var DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS = {
101
102
  discard: "Discard",
102
103
  edit: "Edit",
103
104
  messageOptions: DEFAULT_MESSAGE_OPTIONS_LABEL,
105
+ showOriginalText: DEFAULT_SHOW_ORIGINAL_TEXT_LABEL,
104
106
  notFound: "Conversation not found",
105
107
  loading: "Loading\u2026",
106
108
  loadError: "Could not load. Check your connection and try again.",
@@ -2577,7 +2579,26 @@ function BubbleHeading({ content, labels }) {
2577
2579
  if (!content.authorName) return null;
2578
2580
  return /* @__PURE__ */ jsx27("span", { className: "cv-p-bubble__author", children: content.authorName });
2579
2581
  }
2580
- function BubbleBody({ content, labels, ownStatus, textId, resolveAttachmentUrl, onRetry, onDiscard, onEdit }) {
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
+ }
2592
+ function OriginalText({ content, labels }) {
2593
+ const original = content.originalText?.trim();
2594
+ if (!original || original === content.text?.trim()) return null;
2595
+ return /* @__PURE__ */ jsxs21("details", { className: "cv-p-bubble__original", children: [
2596
+ /* @__PURE__ */ jsx27("summary", { className: "cv-p-bubble__original-toggle", children: labels.showOriginalText ?? DEFAULT_SHOW_ORIGINAL_TEXT_LABEL }),
2597
+ /* @__PURE__ */ jsx27("div", { className: "cv-p-bubble__original-text", children: original })
2598
+ ] });
2599
+ }
2600
+ function BubbleBody({ content, labels, ownStatus, textId, resolveAttachmentUrl, onRetry, onDiscard, onEdit, renderMessageActions }) {
2601
+ const actions = hostActions(content, renderMessageActions);
2581
2602
  return /* @__PURE__ */ jsxs21(Fragment6, { children: [
2582
2603
  /* @__PURE__ */ jsx27(BubbleHeading, { content, labels }),
2583
2604
  content.text ? /* @__PURE__ */ jsx27(ParticipantMessageText, { text: content.text, labels, id: textId }) : null,
@@ -2594,8 +2615,10 @@ function BubbleBody({ content, labels, ownStatus, textId, resolveAttachmentUrl,
2594
2615
  /* @__PURE__ */ jsxs21("span", { className: "cv-p-bubble__meta", children: [
2595
2616
  /* @__PURE__ */ jsx27("time", { className: "cv-p-bubble__time", dateTime: content.createdAt, children: formatTimestamp(content.createdAt) }),
2596
2617
  ownStatus ? /* @__PURE__ */ jsx27(OwnStatus, { status: ownStatus, labels, onRetry }) : null,
2597
- ownStatus === "failed" ? /* @__PURE__ */ jsx27(ParticipantFailedMenu, { labels, onEdit, onDiscard }) : null
2598
- ] })
2618
+ ownStatus === "failed" ? /* @__PURE__ */ jsx27(ParticipantFailedMenu, { labels, onEdit, onDiscard }) : null,
2619
+ actions ? /* @__PURE__ */ jsx27("span", { className: "cv-p-bubble__host-actions", children: actions }) : null
2620
+ ] }),
2621
+ /* @__PURE__ */ jsx27(OriginalText, { content, labels })
2599
2622
  ] });
2600
2623
  }
2601
2624
 
@@ -2669,10 +2692,12 @@ function describeItem(item, perspective) {
2669
2692
  isMine: isOwnMessage(message, perspective),
2670
2693
  authorName: message.authorName ?? void 0,
2671
2694
  text: message.text ?? void 0,
2695
+ originalText: message.originalText ?? void 0,
2672
2696
  createdAt: message.createdAt,
2673
2697
  attachments: message.attachments,
2674
2698
  pendingFilenames: [],
2675
- serverStatus: message.status
2699
+ serverStatus: message.status,
2700
+ messageId: message.id
2676
2701
  };
2677
2702
  }
2678
2703
  return {
@@ -2780,6 +2805,7 @@ function ParticipantTimeline(props) {
2780
2805
  tail: props.tail,
2781
2806
  perspective: props.perspective,
2782
2807
  avatar,
2808
+ renderMessageActions: props.renderMessageActions,
2783
2809
  ...resolveActions(item, pendingActions)
2784
2810
  },
2785
2811
  keyOf(item)
@@ -3678,6 +3704,7 @@ function LoadedThread(props) {
3678
3704
  renderSubjectIcon: props.renderSubjectIcon,
3679
3705
  avatars: props.avatars,
3680
3706
  renderAuthorAvatar: props.renderAuthorAvatar,
3707
+ renderMessageActions: props.renderMessageActions,
3681
3708
  tail: props.tail,
3682
3709
  audioRecording: props.audioRecording,
3683
3710
  renderComposerLock: props.renderComposerLock
@@ -4045,6 +4072,7 @@ function ConversationThreadBody(props) {
4045
4072
  renderSubjectIcon: props.renderSubjectIcon,
4046
4073
  avatars: props.avatars,
4047
4074
  renderAuthorAvatar: props.renderAuthorAvatar,
4075
+ renderMessageActions: props.renderMessageActions,
4048
4076
  tail: props.tail,
4049
4077
  counterpartLabel: props.counterpartLabel,
4050
4078
  headerActions: props.headerActions,
package/dist/styles.css CHANGED
@@ -1978,7 +1978,46 @@
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
+ }
1986
+ .cv-p-bubble__original {
1987
+ flex: 1 0 100%;
1988
+ margin-top: 0.2rem;
1989
+ }
1990
+ .cv-p-bubble__original-toggle {
1991
+ display: inline-block;
1992
+ font-size: 0.7rem;
1993
+ color: var(--cv-p-i-text-muted);
1994
+ cursor: pointer;
1995
+ list-style: none;
1996
+ text-decoration: underline;
1997
+ text-decoration-style: dotted;
1998
+ text-underline-offset: 2px;
1999
+ }
2000
+ .cv-p-bubble__original-toggle::-webkit-details-marker {
2001
+ display: none;
2002
+ }
2003
+ .cv-p-bubble__original-toggle:focus-visible {
2004
+ outline: 2px solid var(--cv-p-i-accent);
2005
+ outline-offset: 2px;
2006
+ border-radius: 2px;
2007
+ }
2008
+ .cv-p-bubble__original-text {
2009
+ margin-top: 0.3rem;
2010
+ padding-inline-start: 0.5rem;
2011
+ border-inline-start: 2px solid var(--cv-p-i-border);
2012
+ max-height: 16rem;
2013
+ overflow: auto;
2014
+ white-space: pre-wrap;
2015
+ overflow-wrap: anywhere;
2016
+ font-size: 0.8rem;
2017
+ color: var(--cv-p-i-text-muted);
2018
+ }
1981
2019
  .cv-p-bubble__meta {
2020
+ order: 1;
1982
2021
  display: flex;
1983
2022
  flex: 0 0 auto;
1984
2023
  justify-content: flex-end;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adatechnology/conversations-ui",
3
- "version": "0.11.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.6.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,9 +2,10 @@ 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
- import type { ParticipantConversationsLabels } from './participantLabels'
8
+ import { DEFAULT_SHOW_ORIGINAL_TEXT_LABEL, type ParticipantConversationsLabels } from './participantLabels'
8
9
  import type { ParticipantOwnMessageStatus } from './participantMessages'
9
10
 
10
11
  type BubbleBodyProps = {
@@ -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,7 +26,31 @@ function BubbleHeading({ content, labels }: Pick<BubbleBodyProps, 'content' | 'l
24
26
  return <span className="cv-p-bubble__author">{content.authorName}</span>
25
27
  }
26
28
 
27
- export function BubbleBody({ content, labels, ownStatus, textId, resolveAttachmentUrl, onRetry, onDiscard, onEdit }: BubbleBodyProps) {
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
+
40
+ /** Collapsed by default: the trimmed text is what the reader needs, the original is there on demand. */
41
+ function OriginalText({ content, labels }: Pick<BubbleBodyProps, 'content' | 'labels'>) {
42
+ const original = content.originalText?.trim()
43
+ if (!original || original === content.text?.trim()) return null
44
+ return (
45
+ <details className="cv-p-bubble__original">
46
+ <summary className="cv-p-bubble__original-toggle">{labels.showOriginalText ?? DEFAULT_SHOW_ORIGINAL_TEXT_LABEL}</summary>
47
+ <div className="cv-p-bubble__original-text">{original}</div>
48
+ </details>
49
+ )
50
+ }
51
+
52
+ export function BubbleBody({ content, labels, ownStatus, textId, resolveAttachmentUrl, onRetry, onDiscard, onEdit, renderMessageActions }: BubbleBodyProps) {
53
+ const actions = hostActions(content, renderMessageActions)
28
54
  return (
29
55
  <>
30
56
  <BubbleHeading content={content} labels={labels} />
@@ -48,7 +74,10 @@ export function BubbleBody({ content, labels, ownStatus, textId, resolveAttachme
48
74
  </time>
49
75
  {ownStatus ? <OwnStatus status={ownStatus} labels={labels} onRetry={onRetry} /> : null}
50
76
  {ownStatus === 'failed' ? <ParticipantFailedMenu labels={labels} onEdit={onEdit} onDiscard={onDiscard} /> : null}
77
+ {actions ? <span className="cv-p-bubble__host-actions">{actions}</span> : null}
51
78
  </span>
79
+ {/* Last child so the ids React derives for the siblings above stay as they were; CSS keeps the meta after it. */}
80
+ <OriginalText content={content} labels={labels} />
52
81
  </>
53
82
  )
54
83
  }
@@ -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. */
@@ -70,6 +70,41 @@ describe('ParticipantMessageBubble', () => {
70
70
  expect(mine).not.toContain('Marina')
71
71
  })
72
72
 
73
+ it('keeps the original text collapsed behind a discreet toggle inside the bubble', () => {
74
+ const original = 'Pode descarregar.\n\nEm ter., 20 de out. de 2026, Equipe escreveu:\n> Autorizam?'
75
+ const markup = renderServer(buildMessage({ id: 'a', text: 'Pode descarregar.', originalText: original }))
76
+
77
+ expect(markup).toContain('<details class="cv-p-bubble__original">')
78
+ expect(markup).not.toContain('<details class="cv-p-bubble__original" open')
79
+ expect(markup).toContain('<summary class="cv-p-bubble__original-toggle">Show original</summary>')
80
+ expect(markup).toContain('Em ter., 20 de out. de 2026, Equipe escreveu:')
81
+ expect(findUtilityClassTokens(markup)).toEqual([])
82
+ })
83
+
84
+ it('uses the host label for the original-text toggle', () => {
85
+ const item: ParticipantTimelineItem = {
86
+ kind: 'server',
87
+ message: buildMessage({ id: 'a', text: 'Ok', originalText: 'Ok\n> antes' }),
88
+ }
89
+ const markup = renderToStaticMarkup(
90
+ <ParticipantMessageBubble
91
+ item={item}
92
+ labels={{ ...LABELS, showOriginalText: 'ver histórico' }}
93
+ confirmsRead
94
+ resolveAttachmentUrl={resolveAttachmentUrl}
95
+ />,
96
+ )
97
+
98
+ expect(markup).toContain('>ver histórico</summary>')
99
+ })
100
+
101
+ it('shows no toggle when the original is absent, blank or the same as the text', () => {
102
+ expect(renderServer(buildMessage({ id: 'a', text: 'Ok' }))).not.toContain('<details')
103
+ expect(renderServer(buildMessage({ id: 'b', text: 'Ok', originalText: null }))).not.toContain('<details')
104
+ expect(renderServer(buildMessage({ id: 'c', text: 'Ok', originalText: ' ' }))).not.toContain('<details')
105
+ expect(renderServer(buildMessage({ id: 'd', text: 'Ok', originalText: ' Ok\n' }))).not.toContain('<details')
106
+ })
107
+
73
108
  it('renders a time element with the ISO dateTime', () => {
74
109
  expect(renderServer()).toMatch(/<time[^>]*dateTime="2026-10-01T10:00:00.000Z"/)
75
110
  })
@@ -212,3 +247,54 @@ describe('ParticipantMessageBubble avatar slot', () => {
212
247
  expect(own).not.toContain('cv-p-bubble-row')
213
248
  })
214
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,17 +32,21 @@ 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 = {
37
40
  readonly isMine: boolean
38
41
  readonly authorName?: string
39
42
  readonly text?: string
43
+ readonly originalText?: string
40
44
  readonly createdAt: string
41
45
  readonly attachments: readonly ParticipantAttachment[]
42
46
  readonly pendingFilenames: readonly string[]
43
47
  readonly displayState?: ParticipantPendingDisplayState
44
48
  readonly serverStatus?: MessageDeliveryStatus
49
+ readonly messageId?: string
45
50
  }
46
51
 
47
52
  function describeItem(item: ParticipantTimelineItem, perspective?: ParticipantPerspective): BubbleContent {
@@ -51,10 +56,12 @@ function describeItem(item: ParticipantTimelineItem, perspective?: ParticipantPe
51
56
  isMine: isOwnMessage(message, perspective),
52
57
  authorName: message.authorName ?? undefined,
53
58
  text: message.text ?? undefined,
59
+ originalText: message.originalText ?? undefined,
54
60
  createdAt: message.createdAt,
55
61
  attachments: message.attachments,
56
62
  pendingFilenames: [],
57
63
  serverStatus: message.status,
64
+ messageId: message.id,
58
65
  }
59
66
  }
60
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'
@@ -1,6 +1,7 @@
1
1
  import { DEFAULT_PARTICIPANT_AUDIO_LABELS, type ParticipantAudioLabels } from './participantAudioLabels'
2
2
 
3
3
  export const DEFAULT_MESSAGE_OPTIONS_LABEL = 'Message options'
4
+ export const DEFAULT_SHOW_ORIGINAL_TEXT_LABEL = 'Show original'
4
5
 
5
6
  export type ParticipantConversationsLabels = {
6
7
  readonly inboxTitle: string
@@ -43,6 +44,8 @@ export type ParticipantConversationsLabels = {
43
44
  readonly edit: string
44
45
  /** Name of the menu button on a failed message. Optional; translated hosts should fill it, otherwise the English default shows. */
45
46
  readonly messageOptions?: string
47
+ /** Toggle that opens the message as received (e.g. the whole email). Optional; the English default shows otherwise. */
48
+ readonly showOriginalText?: string
46
49
  readonly notFound: string
47
50
  readonly loading: string
48
51
  readonly loadError: string
@@ -106,6 +109,7 @@ export const DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS: ParticipantConversationsL
106
109
  discard: 'Discard',
107
110
  edit: 'Edit',
108
111
  messageOptions: DEFAULT_MESSAGE_OPTIONS_LABEL,
112
+ showOriginalText: DEFAULT_SHOW_ORIGINAL_TEXT_LABEL,
109
113
  notFound: 'Conversation not found',
110
114
  loading: 'Loading…',
111
115
  loadError: 'Could not load. Check your connection and try again.',
package/src/styles.css CHANGED
@@ -1302,7 +1302,13 @@
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__meta { display: flex; flex: 0 0 auto; justify-content: flex-end; align-items: center; gap: 0.35rem; margin-inline-start: auto; margin-top: 0.25rem; font: 0.65rem 'SFMono-Regular', Consolas, monospace; color: var(--cv-p-i-text-muted); }
1305
+ .cv-p-bubble__host-actions { display: inline-flex; align-items: center; gap: 0.25rem; }
1306
+ .cv-p-bubble__original { flex: 1 0 100%; margin-top: 0.2rem; }
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; }
1308
+ .cv-p-bubble__original-toggle::-webkit-details-marker { display: none; }
1309
+ .cv-p-bubble__original-toggle:focus-visible { outline: 2px solid var(--cv-p-i-accent); outline-offset: 2px; border-radius: 2px; }
1310
+ .cv-p-bubble__original-text { margin-top: 0.3rem; padding-inline-start: 0.5rem; border-inline-start: 2px solid var(--cv-p-i-border); max-height: 16rem; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; font-size: 0.8rem; color: var(--cv-p-i-text-muted); }
1311
+ .cv-p-bubble__meta { order: 1; display: flex; flex: 0 0 auto; justify-content: flex-end; align-items: center; gap: 0.35rem; margin-inline-start: auto; margin-top: 0.25rem; font: 0.65rem 'SFMono-Regular', Consolas, monospace; color: var(--cv-p-i-text-muted); }
1306
1312
  .cv-p-bubble__status { display: inline-flex; align-items: center; gap: 0.25rem; }
1307
1313
  .cv-p-bubble .cv-status-ticks--queued, .cv-p-bubble .cv-status-ticks--sent, .cv-p-bubble .cv-status-ticks--delivered { color: var(--cv-p-i-tick); }
1308
1314
  .cv-p-bubble .cv-status-ticks--read { color: var(--cv-p-i-tick-read); }