@adatechnology/conversations-ui 0.10.0 → 0.12.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/README.md +1 -0
- package/dist/participant/index.d.ts +6 -0
- package/dist/participant/index.js +21 -3
- package/dist/styles.css +39 -0
- package/package.json +2 -2
- package/src/participant/ConversationThread.tsx +3 -0
- package/src/participant/ParticipantBubbleBody.tsx +15 -1
- package/src/participant/ParticipantConversations.tsx +2 -0
- package/src/participant/ParticipantMessageBubble.test.tsx +35 -0
- package/src/participant/ParticipantMessageBubble.tsx +2 -0
- package/src/participant/ParticipantThread.test.tsx +46 -0
- package/src/participant/ParticipantThread.tsx +10 -0
- package/src/participant/ParticipantThreadScreen.tsx +1 -0
- package/src/participant/participantLabels.ts +4 -0
- package/src/styles.css +7 -1
package/README.md
CHANGED
|
@@ -334,6 +334,7 @@ import { ConversationThread } from '@adatechnology/conversations-ui/participant'
|
|
|
334
334
|
| `status?` | `'open' \| 'closed'` | `closed` tira o compositor e mostra `labels.closedNotice` |
|
|
335
335
|
| `counterpartLabel?` | `string` | linha curta sob o título, montada pelo host |
|
|
336
336
|
| `headerActions?` | `ReactNode` | slot à direita do cabeçalho; ausente não desenha nada. Espera botões curtos ou só ícone, cada um com `aria-label`: com ações, a barra quebra de linha e o título mantém no mínimo `6rem` |
|
|
337
|
+
| `renderComposerLock?` | `(conversation) => ReactNode \| undefined` | devolver um nó troca o compositor por ele (aviso + ações do host), dentro de `cv-p-thread__lock`; `undefined` mantém o compositor. `closed` continua tendo prioridade. Avaliado a cada render; o rascunho fica no controlador e volta ao destravar. Também existe em `ParticipantConversations` |
|
|
337
338
|
| `onBack?` | `() => void` | ausente = sem seta (o diálogo do host já tem o próprio fechar) |
|
|
338
339
|
| `quickReplies?` | `QuickReply[]` | chips acima do campo; tocar **preenche**, nunca envia |
|
|
339
340
|
| `renderSubjectIcon?`, `subjectGroups?` | | ícone do assunto no cabeçalho, como na lista |
|
|
@@ -190,6 +190,8 @@ type ParticipantConversationsLabels = {
|
|
|
190
190
|
readonly edit: string;
|
|
191
191
|
/** Name of the menu button on a failed message. Optional; translated hosts should fill it, otherwise the English default shows. */
|
|
192
192
|
readonly messageOptions?: string;
|
|
193
|
+
/** Toggle that opens the message as received (e.g. the whole email). Optional; the English default shows otherwise. */
|
|
194
|
+
readonly showOriginalText?: string;
|
|
193
195
|
readonly notFound: string;
|
|
194
196
|
readonly loading: string;
|
|
195
197
|
readonly loadError: string;
|
|
@@ -244,6 +246,8 @@ type ParticipantConversationsProps = {
|
|
|
244
246
|
tail?: boolean;
|
|
245
247
|
/** Absent draws no microphone. Present lets the user record and send an audio as a message of its own. */
|
|
246
248
|
audioRecording?: ParticipantAudioRecordingOptions;
|
|
249
|
+
/** Return a node to replace the composer for this conversation (notice + host actions); undefined keeps the composer. `closed` still wins. Evaluated every render. */
|
|
250
|
+
renderComposerLock?: (conversation: ParticipantConversationSummary) => ReactNode | undefined;
|
|
247
251
|
};
|
|
248
252
|
declare function ParticipantConversations(props: ParticipantConversationsProps): react.JSX.Element;
|
|
249
253
|
|
|
@@ -287,6 +291,8 @@ type ConversationThreadProps = {
|
|
|
287
291
|
readonly renderAuthorAvatar?: ParticipantAuthorAvatarRenderer;
|
|
288
292
|
/** Absent draws no microphone. Present lets the user record and send an audio as a message of its own. */
|
|
289
293
|
readonly audioRecording?: ParticipantAudioRecordingOptions;
|
|
294
|
+
/** Return a node to replace the composer (notice + host actions); undefined keeps the composer. `closed` still wins. Evaluated every render. */
|
|
295
|
+
readonly renderComposerLock?: (conversation: ParticipantConversationSummary) => ReactNode | undefined;
|
|
290
296
|
};
|
|
291
297
|
/** One conversation about one subject, drawn like the participant app and seen from either side. */
|
|
292
298
|
declare function ConversationThread(props: ConversationThreadProps): react.JSX.Element;
|
|
@@ -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,6 +2579,14 @@ 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
|
}
|
|
2582
|
+
function OriginalText({ content, labels }) {
|
|
2583
|
+
const original = content.originalText?.trim();
|
|
2584
|
+
if (!original || original === content.text?.trim()) return null;
|
|
2585
|
+
return /* @__PURE__ */ jsxs21("details", { className: "cv-p-bubble__original", children: [
|
|
2586
|
+
/* @__PURE__ */ jsx27("summary", { className: "cv-p-bubble__original-toggle", children: labels.showOriginalText ?? DEFAULT_SHOW_ORIGINAL_TEXT_LABEL }),
|
|
2587
|
+
/* @__PURE__ */ jsx27("div", { className: "cv-p-bubble__original-text", children: original })
|
|
2588
|
+
] });
|
|
2589
|
+
}
|
|
2580
2590
|
function BubbleBody({ content, labels, ownStatus, textId, resolveAttachmentUrl, onRetry, onDiscard, onEdit }) {
|
|
2581
2591
|
return /* @__PURE__ */ jsxs21(Fragment6, { children: [
|
|
2582
2592
|
/* @__PURE__ */ jsx27(BubbleHeading, { content, labels }),
|
|
@@ -2595,7 +2605,8 @@ function BubbleBody({ content, labels, ownStatus, textId, resolveAttachmentUrl,
|
|
|
2595
2605
|
/* @__PURE__ */ jsx27("time", { className: "cv-p-bubble__time", dateTime: content.createdAt, children: formatTimestamp(content.createdAt) }),
|
|
2596
2606
|
ownStatus ? /* @__PURE__ */ jsx27(OwnStatus, { status: ownStatus, labels, onRetry }) : null,
|
|
2597
2607
|
ownStatus === "failed" ? /* @__PURE__ */ jsx27(ParticipantFailedMenu, { labels, onEdit, onDiscard }) : null
|
|
2598
|
-
] })
|
|
2608
|
+
] }),
|
|
2609
|
+
/* @__PURE__ */ jsx27(OriginalText, { content, labels })
|
|
2599
2610
|
] });
|
|
2600
2611
|
}
|
|
2601
2612
|
|
|
@@ -2669,6 +2680,7 @@ function describeItem(item, perspective) {
|
|
|
2669
2680
|
isMine: isOwnMessage(message, perspective),
|
|
2670
2681
|
authorName: message.authorName ?? void 0,
|
|
2671
2682
|
text: message.text ?? void 0,
|
|
2683
|
+
originalText: message.originalText ?? void 0,
|
|
2672
2684
|
createdAt: message.createdAt,
|
|
2673
2685
|
attachments: message.attachments,
|
|
2674
2686
|
pendingFilenames: [],
|
|
@@ -2908,6 +2920,10 @@ function Footer({ props }) {
|
|
|
2908
2920
|
if (conversation.status === "closed") {
|
|
2909
2921
|
return /* @__PURE__ */ jsx33("p", { className: "cv-p-thread__closed", children: labels.closedNotice });
|
|
2910
2922
|
}
|
|
2923
|
+
const lock = props.renderComposerLock?.(conversation);
|
|
2924
|
+
if (lock !== void 0 && lock !== null) {
|
|
2925
|
+
return /* @__PURE__ */ jsx33("div", { className: "cv-p-thread__lock", role: "region", "aria-live": "polite", children: lock });
|
|
2926
|
+
}
|
|
2911
2927
|
return /* @__PURE__ */ jsx33(
|
|
2912
2928
|
ParticipantComposer,
|
|
2913
2929
|
{
|
|
@@ -3675,7 +3691,8 @@ function LoadedThread(props) {
|
|
|
3675
3691
|
avatars: props.avatars,
|
|
3676
3692
|
renderAuthorAvatar: props.renderAuthorAvatar,
|
|
3677
3693
|
tail: props.tail,
|
|
3678
|
-
audioRecording: props.audioRecording
|
|
3694
|
+
audioRecording: props.audioRecording,
|
|
3695
|
+
renderComposerLock: props.renderComposerLock
|
|
3679
3696
|
}
|
|
3680
3697
|
);
|
|
3681
3698
|
}
|
|
@@ -4043,7 +4060,8 @@ function ConversationThreadBody(props) {
|
|
|
4043
4060
|
tail: props.tail,
|
|
4044
4061
|
counterpartLabel: props.counterpartLabel,
|
|
4045
4062
|
headerActions: props.headerActions,
|
|
4046
|
-
audioRecording: props.audioRecording
|
|
4063
|
+
audioRecording: props.audioRecording,
|
|
4064
|
+
renderComposerLock: props.renderComposerLock
|
|
4047
4065
|
}
|
|
4048
4066
|
) })
|
|
4049
4067
|
}
|
package/dist/styles.css
CHANGED
|
@@ -1812,6 +1812,11 @@
|
|
|
1812
1812
|
white-space: nowrap;
|
|
1813
1813
|
border: 0;
|
|
1814
1814
|
}
|
|
1815
|
+
.cv-p-thread__lock {
|
|
1816
|
+
padding: 1rem;
|
|
1817
|
+
color: var(--cv-p-i-text-muted);
|
|
1818
|
+
border-top: 1px solid var(--cv-p-i-border);
|
|
1819
|
+
}
|
|
1815
1820
|
.cv-p-thread__closed {
|
|
1816
1821
|
margin: 0;
|
|
1817
1822
|
padding: 1rem;
|
|
@@ -1973,7 +1978,41 @@
|
|
|
1973
1978
|
.cv-p-bubble > .cv-p-attachment {
|
|
1974
1979
|
flex: 1 0 100%;
|
|
1975
1980
|
}
|
|
1981
|
+
.cv-p-bubble__original {
|
|
1982
|
+
flex: 1 0 100%;
|
|
1983
|
+
margin-top: 0.2rem;
|
|
1984
|
+
}
|
|
1985
|
+
.cv-p-bubble__original-toggle {
|
|
1986
|
+
display: inline-block;
|
|
1987
|
+
font-size: 0.7rem;
|
|
1988
|
+
color: var(--cv-p-i-text-muted);
|
|
1989
|
+
cursor: pointer;
|
|
1990
|
+
list-style: none;
|
|
1991
|
+
text-decoration: underline;
|
|
1992
|
+
text-decoration-style: dotted;
|
|
1993
|
+
text-underline-offset: 2px;
|
|
1994
|
+
}
|
|
1995
|
+
.cv-p-bubble__original-toggle::-webkit-details-marker {
|
|
1996
|
+
display: none;
|
|
1997
|
+
}
|
|
1998
|
+
.cv-p-bubble__original-toggle:focus-visible {
|
|
1999
|
+
outline: 2px solid var(--cv-p-i-accent);
|
|
2000
|
+
outline-offset: 2px;
|
|
2001
|
+
border-radius: 2px;
|
|
2002
|
+
}
|
|
2003
|
+
.cv-p-bubble__original-text {
|
|
2004
|
+
margin-top: 0.3rem;
|
|
2005
|
+
padding-inline-start: 0.5rem;
|
|
2006
|
+
border-inline-start: 2px solid var(--cv-p-i-border);
|
|
2007
|
+
max-height: 16rem;
|
|
2008
|
+
overflow: auto;
|
|
2009
|
+
white-space: pre-wrap;
|
|
2010
|
+
overflow-wrap: anywhere;
|
|
2011
|
+
font-size: 0.8rem;
|
|
2012
|
+
color: var(--cv-p-i-text-muted);
|
|
2013
|
+
}
|
|
1976
2014
|
.cv-p-bubble__meta {
|
|
2015
|
+
order: 1;
|
|
1977
2016
|
display: flex;
|
|
1978
2017
|
flex: 0 0 auto;
|
|
1979
2018
|
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.12.0",
|
|
4
4
|
"description": "WhatsApp conversation UI components — parametrizável por endpoint, tema e feature flags",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -35,7 +35,7 @@
|
|
|
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.
|
|
38
|
+
"@adatechnology/conversation-contracts": "0.7.0",
|
|
39
39
|
"@adatechnology/meta-whatsapp-contracts": "0.6.0"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
@@ -53,6 +53,8 @@ export type ConversationThreadProps = {
|
|
|
53
53
|
readonly renderAuthorAvatar?: ParticipantAuthorAvatarRenderer
|
|
54
54
|
/** Absent draws no microphone. Present lets the user record and send an audio as a message of its own. */
|
|
55
55
|
readonly audioRecording?: ParticipantAudioRecordingOptions
|
|
56
|
+
/** Return a node to replace the composer (notice + host actions); undefined keeps the composer. `closed` still wins. Evaluated every render. */
|
|
57
|
+
readonly renderComposerLock?: (conversation: ParticipantConversationSummary) => ReactNode | undefined
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
function useConversationThreadCore(props: ConversationThreadProps): ParticipantThreadCoreProps {
|
|
@@ -109,6 +111,7 @@ function ConversationThreadBody(props: ConversationThreadProps) {
|
|
|
109
111
|
counterpartLabel={props.counterpartLabel}
|
|
110
112
|
headerActions={props.headerActions}
|
|
111
113
|
audioRecording={props.audioRecording}
|
|
114
|
+
renderComposerLock={props.renderComposerLock}
|
|
112
115
|
/>
|
|
113
116
|
</div>
|
|
114
117
|
</div>
|
|
@@ -4,7 +4,7 @@ import { OwnStatus } from './ParticipantBubbleStatus'
|
|
|
4
4
|
import { ParticipantFailedMenu } from './ParticipantFailedMenu'
|
|
5
5
|
import type { BubbleContent } from './ParticipantMessageBubble'
|
|
6
6
|
import { ParticipantMessageText } from './ParticipantMessageText'
|
|
7
|
-
import type
|
|
7
|
+
import { DEFAULT_SHOW_ORIGINAL_TEXT_LABEL, type ParticipantConversationsLabels } from './participantLabels'
|
|
8
8
|
import type { ParticipantOwnMessageStatus } from './participantMessages'
|
|
9
9
|
|
|
10
10
|
type BubbleBodyProps = {
|
|
@@ -24,6 +24,18 @@ function BubbleHeading({ content, labels }: Pick<BubbleBodyProps, 'content' | 'l
|
|
|
24
24
|
return <span className="cv-p-bubble__author">{content.authorName}</span>
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/** Collapsed by default: the trimmed text is what the reader needs, the original is there on demand. */
|
|
28
|
+
function OriginalText({ content, labels }: Pick<BubbleBodyProps, 'content' | 'labels'>) {
|
|
29
|
+
const original = content.originalText?.trim()
|
|
30
|
+
if (!original || original === content.text?.trim()) return null
|
|
31
|
+
return (
|
|
32
|
+
<details className="cv-p-bubble__original">
|
|
33
|
+
<summary className="cv-p-bubble__original-toggle">{labels.showOriginalText ?? DEFAULT_SHOW_ORIGINAL_TEXT_LABEL}</summary>
|
|
34
|
+
<div className="cv-p-bubble__original-text">{original}</div>
|
|
35
|
+
</details>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
27
39
|
export function BubbleBody({ content, labels, ownStatus, textId, resolveAttachmentUrl, onRetry, onDiscard, onEdit }: BubbleBodyProps) {
|
|
28
40
|
return (
|
|
29
41
|
<>
|
|
@@ -49,6 +61,8 @@ export function BubbleBody({ content, labels, ownStatus, textId, resolveAttachme
|
|
|
49
61
|
{ownStatus ? <OwnStatus status={ownStatus} labels={labels} onRetry={onRetry} /> : null}
|
|
50
62
|
{ownStatus === 'failed' ? <ParticipantFailedMenu labels={labels} onEdit={onEdit} onDiscard={onDiscard} /> : null}
|
|
51
63
|
</span>
|
|
64
|
+
{/* Last child so the ids React derives for the siblings above stay as they were; CSS keeps the meta after it. */}
|
|
65
|
+
<OriginalText content={content} labels={labels} />
|
|
52
66
|
</>
|
|
53
67
|
)
|
|
54
68
|
}
|
|
@@ -44,6 +44,8 @@ export type ParticipantConversationsProps = {
|
|
|
44
44
|
tail?: boolean
|
|
45
45
|
/** Absent draws no microphone. Present lets the user record and send an audio as a message of its own. */
|
|
46
46
|
audioRecording?: ParticipantAudioRecordingOptions
|
|
47
|
+
/** Return a node to replace the composer for this conversation (notice + host actions); undefined keeps the composer. `closed` still wins. Evaluated every render. */
|
|
48
|
+
renderComposerLock?: (conversation: ParticipantConversationSummary) => ReactNode | undefined
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
export function ParticipantConversations(props: ParticipantConversationsProps) {
|
|
@@ -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
|
})
|
|
@@ -37,6 +37,7 @@ export type BubbleContent = {
|
|
|
37
37
|
readonly isMine: boolean
|
|
38
38
|
readonly authorName?: string
|
|
39
39
|
readonly text?: string
|
|
40
|
+
readonly originalText?: string
|
|
40
41
|
readonly createdAt: string
|
|
41
42
|
readonly attachments: readonly ParticipantAttachment[]
|
|
42
43
|
readonly pendingFilenames: readonly string[]
|
|
@@ -51,6 +52,7 @@ function describeItem(item: ParticipantTimelineItem, perspective?: ParticipantPe
|
|
|
51
52
|
isMine: isOwnMessage(message, perspective),
|
|
52
53
|
authorName: message.authorName ?? undefined,
|
|
53
54
|
text: message.text ?? undefined,
|
|
55
|
+
originalText: message.originalText ?? undefined,
|
|
54
56
|
createdAt: message.createdAt,
|
|
55
57
|
attachments: message.attachments,
|
|
56
58
|
pendingFilenames: [],
|
|
@@ -128,6 +128,52 @@ describe('ParticipantThread', () => {
|
|
|
128
128
|
expect(markup).not.toContain('<textarea')
|
|
129
129
|
})
|
|
130
130
|
|
|
131
|
+
it('replaces the composer with the lock node inside the lock region', () => {
|
|
132
|
+
const markup = render({ renderComposerLock: () => <p>Assumed by Ana</p> })
|
|
133
|
+
|
|
134
|
+
expect(markup).toContain(
|
|
135
|
+
'<div class="cv-p-thread__lock" role="region" aria-live="polite"><p>Assumed by Ana</p></div>',
|
|
136
|
+
)
|
|
137
|
+
expect(markup).not.toContain('<form')
|
|
138
|
+
expect(markup).not.toContain('<textarea')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('keeps the composer when renderComposerLock returns undefined or null', () => {
|
|
142
|
+
expect(render({ renderComposerLock: () => undefined })).toContain('<form')
|
|
143
|
+
expect(render({ renderComposerLock: () => null })).toContain('<form')
|
|
144
|
+
expect(render({ renderComposerLock: () => undefined })).not.toContain('cv-p-thread__lock')
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('lets the closed notice win over the lock', () => {
|
|
148
|
+
const markup = render({
|
|
149
|
+
conversation: buildConversation({ subjectId: '1', status: 'closed' }),
|
|
150
|
+
renderComposerLock: () => <p>Assumed by Ana</p>,
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
expect(markup).toContain('This conversation is closed')
|
|
154
|
+
expect(markup).not.toContain('Assumed by Ana')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('passes the conversation to renderComposerLock and brings the draft back when it unlocks', () => {
|
|
158
|
+
const draft = { value: 'half typed', onChange: () => undefined, files: [], onFilesChange: () => undefined }
|
|
159
|
+
const conversation = buildConversation({ subjectId: '7' })
|
|
160
|
+
const seen: string[] = []
|
|
161
|
+
|
|
162
|
+
const locked = render({
|
|
163
|
+
draft,
|
|
164
|
+
conversation,
|
|
165
|
+
renderComposerLock: (received) => {
|
|
166
|
+
seen.push(received.subjectId)
|
|
167
|
+
return <p>Locked</p>
|
|
168
|
+
},
|
|
169
|
+
})
|
|
170
|
+
const unlocked = render({ draft, conversation, renderComposerLock: () => undefined })
|
|
171
|
+
|
|
172
|
+
expect(seen).toEqual(['7'])
|
|
173
|
+
expect(locked).not.toContain('half typed')
|
|
174
|
+
expect(unlocked).toContain('half typed')
|
|
175
|
+
})
|
|
176
|
+
|
|
131
177
|
it('shows the composer when the conversation is open', () => {
|
|
132
178
|
const markup = render()
|
|
133
179
|
|
|
@@ -76,6 +76,8 @@ export type ParticipantThreadProps = {
|
|
|
76
76
|
readonly audioRecording?: ParticipantAudioRecordingOptions
|
|
77
77
|
/** Receives the recorded audio; the controller sends it as a message of its own. */
|
|
78
78
|
readonly onSendAudio?: (file: File) => void
|
|
79
|
+
/** Return a node to replace the composer for this conversation (notice + host actions); undefined keeps the composer. Evaluated every render. */
|
|
80
|
+
readonly renderComposerLock?: (conversation: ParticipantConversationSummary) => ReactNode | undefined
|
|
79
81
|
}
|
|
80
82
|
|
|
81
83
|
function Footer({ props }: { readonly props: ParticipantThreadProps }) {
|
|
@@ -83,6 +85,14 @@ function Footer({ props }: { readonly props: ParticipantThreadProps }) {
|
|
|
83
85
|
if (conversation.status === 'closed') {
|
|
84
86
|
return <p className="cv-p-thread__closed">{labels.closedNotice}</p>
|
|
85
87
|
}
|
|
88
|
+
const lock = props.renderComposerLock?.(conversation)
|
|
89
|
+
if (lock !== undefined && lock !== null) {
|
|
90
|
+
return (
|
|
91
|
+
<div className="cv-p-thread__lock" role="region" aria-live="polite">
|
|
92
|
+
{lock}
|
|
93
|
+
</div>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
86
96
|
return (
|
|
87
97
|
<ParticipantComposer
|
|
88
98
|
value={draft.value}
|
|
@@ -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
|
@@ -1275,6 +1275,7 @@
|
|
|
1275
1275
|
.cv-p-thread__messages { display: flex; flex-direction: column; gap: 0.5rem; }
|
|
1276
1276
|
.cv-p-thread__live { padding: 0 1rem; font-size: 0.75rem; color: var(--cv-p-i-accent); background: var(--cv-p-i-surface-raised); }
|
|
1277
1277
|
.cv-p-thread__live:empty { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
|
1278
|
+
.cv-p-thread__lock { padding: 1rem; color: var(--cv-p-i-text-muted); border-top: 1px solid var(--cv-p-i-border); }
|
|
1278
1279
|
.cv-p-thread__closed { margin: 0; padding: 1rem; text-align: center; color: var(--cv-p-i-text-muted); border-top: 1px solid var(--cv-p-i-border); }
|
|
1279
1280
|
.cv-p-day { align-self: center; margin: 0.5rem 0; padding: 0.25rem 0.75rem; background: var(--cv-p-i-surface-raised); border: 1px solid var(--cv-p-i-border); border-radius: var(--cv-p-i-radius); font: 0.65rem 'SFMono-Regular', Consolas, monospace; letter-spacing: 0.06em; text-transform: uppercase; color: var(--cv-p-i-text-muted); }
|
|
1280
1281
|
|
|
@@ -1301,7 +1302,12 @@
|
|
|
1301
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); }
|
|
1302
1303
|
.cv-p-bubble > .cv-p-text { flex: 0 1 auto; min-width: 0; margin-inline-end: auto; }
|
|
1303
1304
|
.cv-p-bubble > .cv-p-attachment { flex: 1 0 100%; }
|
|
1304
|
-
.cv-p-
|
|
1305
|
+
.cv-p-bubble__original { flex: 1 0 100%; margin-top: 0.2rem; }
|
|
1306
|
+
.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
|
+
.cv-p-bubble__original-toggle::-webkit-details-marker { display: none; }
|
|
1308
|
+
.cv-p-bubble__original-toggle:focus-visible { outline: 2px solid var(--cv-p-i-accent); outline-offset: 2px; border-radius: 2px; }
|
|
1309
|
+
.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); }
|
|
1310
|
+
.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); }
|
|
1305
1311
|
.cv-p-bubble__status { display: inline-flex; align-items: center; gap: 0.25rem; }
|
|
1306
1312
|
.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); }
|
|
1307
1313
|
.cv-p-bubble .cv-status-ticks--read { color: var(--cv-p-i-tick-read); }
|