@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.
- package/dist/participant/index.d.ts +17 -2
- package/dist/participant/index.js +32 -4
- package/dist/styles.css +39 -0
- package/package.json +3 -3
- package/src/participant/ConversationThread.tsx +4 -1
- package/src/participant/ParticipantAuthorAvatar.tsx +12 -0
- package/src/participant/ParticipantBubbleBody.tsx +31 -2
- package/src/participant/ParticipantConversations.tsx +3 -1
- package/src/participant/ParticipantMessageBubble.test.tsx +86 -0
- package/src/participant/ParticipantMessageBubble.tsx +7 -0
- package/src/participant/ParticipantThread.tsx +3 -1
- package/src/participant/ParticipantThreadScreen.tsx +1 -0
- package/src/participant/ParticipantTimeline.tsx +7 -1
- package/src/participant/index.ts +5 -1
- package/src/participant/participantLabels.ts +4 -0
- package/src/styles.css +7 -1
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
import * as react from 'react';
|
|
2
2
|
import { ReactNode } from 'react';
|
|
3
|
-
import { ParticipantConversationSummary, ParticipantConversationPage, ParticipantSubjectRef, ParticipantMessage
|
|
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
|
|
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.
|
|
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/
|
|
39
|
-
"@adatechnology/
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
)
|
package/src/participant/index.ts
CHANGED
|
@@ -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 {
|
|
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-
|
|
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); }
|