@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.
- package/dist/participant/index.d.ts +15 -2
- package/dist/participant/index.js +19 -3
- package/dist/styles.css +5 -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 +16 -1
- package/src/participant/ParticipantConversations.tsx +3 -1
- package/src/participant/ParticipantMessageBubble.test.tsx +51 -0
- package/src/participant/ParticipantMessageBubble.tsx +5 -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/styles.css +1 -0
|
@@ -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;
|
|
@@ -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.
|
|
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,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 {
|
|
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'
|
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; }
|