@adatechnology/conversations-ui 0.7.0 → 0.8.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 +96 -1
- package/dist/{ConversationSimulatorPanel-CrfFjiWh.d.ts → ConversationSimulatorPanel-q-wVgBCN.d.ts} +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/participant/index.d.ts +45 -3
- package/dist/participant/index.js +287 -112
- package/dist/preview/index.d.ts +3 -3
- package/dist/{quickReply.types-B4gmZsSr.d.ts → quickReply.types-DzWH3NYV.d.ts} +1 -1
- package/dist/styles.css +74 -29
- package/package.json +1 -1
- package/src/participant/ConversationThread.test.tsx +165 -0
- package/src/participant/ConversationThread.tsx +117 -0
- package/src/participant/ParticipantMessageBubble.tsx +7 -3
- package/src/participant/ParticipantThread.tsx +9 -0
- package/src/participant/ParticipantThreadHeader.tsx +10 -0
- package/src/participant/ParticipantThreadScreen.tsx +6 -46
- package/src/participant/ParticipantTimeline.tsx +6 -3
- package/src/participant/conversationThreadApi.test.ts +27 -0
- package/src/participant/conversationThreadApi.ts +24 -0
- package/src/participant/conversationThreadCss.test.ts +116 -0
- package/src/participant/conversationThreadSummary.ts +34 -0
- package/src/participant/index.ts +3 -0
- package/src/participant/participantContrast.test-helper.ts +116 -0
- package/src/participant/participantGolden.test.tsx +46 -0
- package/src/participant/participantGoldenScenarios.test-helper.tsx +57 -0
- package/src/participant/participantMessages.ts +4 -3
- package/src/participant/participantNewMessages.test.ts +21 -0
- package/src/participant/participantNewMessages.ts +8 -3
- package/src/participant/participantPerspective.test.ts +67 -0
- package/src/participant/participantPerspective.ts +50 -0
- package/src/participant/participantPerspectiveRender.test.tsx +94 -0
- package/src/participant/participantThemeContrast.test.ts +98 -0
- package/src/participant/test-fixtures/conversations-list.golden.html +1 -0
- package/src/participant/test-fixtures/conversations-selected.golden.html +1 -0
- package/src/participant/test-fixtures/thread-avatars-initials.golden.html +1 -0
- package/src/participant/test-fixtures/thread-closed.golden.html +1 -0
- package/src/participant/test-fixtures/thread-default.golden.html +1 -0
- package/src/participant/test-fixtures/thread-error.golden.html +1 -0
- package/src/participant/test-fixtures/thread-loading.golden.html +1 -0
- package/src/participant/test-fixtures/thread-plain.golden.html +1 -0
- package/src/participant/test-fixtures/thread-tail-off.golden.html +1 -0
- package/src/participant/useParticipantConversationView.ts +3 -0
- package/src/participant/useParticipantThread.ts +13 -2
- package/src/participant/useParticipantThreadController.ts +112 -0
- package/src/participant/useParticipantThreadScroll.ts +3 -1
- package/src/participant/useParticipantUnread.test.tsx +40 -0
- package/src/participant/useParticipantUnread.ts +38 -0
- package/src/styles.css +24 -12
package/dist/preview/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { M as MessagePayload } from '../quickReply.types-
|
|
2
|
-
import { C as ConversationSummary, a as ConversationEventSource, b as ConversationsApi, L as ListConversationsParams, c as ConversationPage, S as SSEProvider, d as ConversationDocument, e as SendPreviewMediaParams, P as PreviewUploadedMedia, f as PreviewWebhookClient, R as ResolveMediaUrl } from '../ConversationSimulatorPanel-
|
|
3
|
-
export { A as AudioRecorderButton, g as AudioRecorderButtonLabels, h as AudioRecorderButtonProps, i as ConversationPreview, j as ConversationPreviewProps, k as ConversationSimulatorClient, l as ConversationSimulatorPanel, m as ConversationSimulatorPanelLabels, n as ConversationSimulatorPanelProps, o as CreatePreviewMediaUploaderParams, p as CreatePreviewWebhookClientParams, D as DEFAULT_AUDIO_RECORDER_BUTTON_LABELS, q as DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS, r as DEFAULT_MEDIA_UPLOAD_PATH, s as PreviewInProductionError, t as PreviewMediaUploadRejectedError, u as PreviewMediaUploadRequest, v as PreviewUploadedMedia, w as PreviewWebhookRejectedError, x as SIMULATOR_FILE_MEDIA_KINDS, y as SendSimulatorMediaParams, z as SimulatorMediaKind, T as ToSimulatorClientParams, B as acceptsMediaKind, E as assertPreviewEnvironment, F as createPreviewMediaPoster, G as createPreviewMediaUploader, H as createPreviewWebhookClient, I as isConversationSimulatorClient, J as mediaKindOf, K as mediaTypeOf, M as signPreviewPayload, N as simulatorPanelLabelsOf, O as toConversationSimulatorClient } from '../ConversationSimulatorPanel-
|
|
1
|
+
import { M as MessagePayload } from '../quickReply.types-DzWH3NYV.js';
|
|
2
|
+
import { C as ConversationSummary, a as ConversationEventSource, b as ConversationsApi, L as ListConversationsParams, c as ConversationPage, S as SSEProvider, d as ConversationDocument, e as SendPreviewMediaParams, P as PreviewUploadedMedia, f as PreviewWebhookClient, R as ResolveMediaUrl } from '../ConversationSimulatorPanel-q-wVgBCN.js';
|
|
3
|
+
export { A as AudioRecorderButton, g as AudioRecorderButtonLabels, h as AudioRecorderButtonProps, i as ConversationPreview, j as ConversationPreviewProps, k as ConversationSimulatorClient, l as ConversationSimulatorPanel, m as ConversationSimulatorPanelLabels, n as ConversationSimulatorPanelProps, o as CreatePreviewMediaUploaderParams, p as CreatePreviewWebhookClientParams, D as DEFAULT_AUDIO_RECORDER_BUTTON_LABELS, q as DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS, r as DEFAULT_MEDIA_UPLOAD_PATH, s as PreviewInProductionError, t as PreviewMediaUploadRejectedError, u as PreviewMediaUploadRequest, v as PreviewUploadedMedia, w as PreviewWebhookRejectedError, x as SIMULATOR_FILE_MEDIA_KINDS, y as SendSimulatorMediaParams, z as SimulatorMediaKind, T as ToSimulatorClientParams, B as acceptsMediaKind, E as assertPreviewEnvironment, F as createPreviewMediaPoster, G as createPreviewMediaUploader, H as createPreviewWebhookClient, I as isConversationSimulatorClient, J as mediaKindOf, K as mediaTypeOf, M as signPreviewPayload, N as simulatorPanelLabelsOf, O as toConversationSimulatorClient } from '../ConversationSimulatorPanel-q-wVgBCN.js';
|
|
4
4
|
import { InteractiveReplyOption } from '@adatechnology/meta-whatsapp-contracts/testing';
|
|
5
5
|
import * as react from 'react';
|
|
6
6
|
export { PREVIEW_MEDIA_ID_PREFIX } from '@adatechnology/meta-whatsapp-contracts';
|
|
@@ -271,4 +271,4 @@ type ConversationVariable = {
|
|
|
271
271
|
readonly value: string;
|
|
272
272
|
};
|
|
273
273
|
|
|
274
|
-
export { type ConversationChannel as C, DEFAULT_CONVERSATION_CHANNEL as D, type FormatContactHandleParams as F, HANDLE_KIND as H, type InteractiveSelection as I, type MessagePayload as M, type QuickReply as Q, REOPEN_MECHANISM as R, type StoredAttachmentSendResult as S, type TranscriptionMode as T, type
|
|
274
|
+
export { type ConversationChannel as C, DEFAULT_CONVERSATION_CHANNEL as D, type FormatContactHandleParams as F, HANDLE_KIND as H, type InteractiveSelection as I, type MessagePayload as M, type QuickReply as Q, REOPEN_MECHANISM as R, type StoredAttachmentSendResult as S, type TranscriptionMode as T, type MessageTranscription as a, type QuickReplyInput as b, type QuickReplyAttachment as c, type ConversationsTheme as d, type InteractivePayload as e, type ConversationsFeatures as f, type QueuedAttachment as g, type ConversationVariable as h, type ChannelFilter as i, type ChannelFilterOption as j, CHANNEL_CAPABILITIES as k, CHANNEL_FILTER_ALL as l, CONVERSATION_CHANNEL as m, type ChannelCapabilities as n, type ConversationsUIConfig as o, type HandleKind as p, type InteractiveOption as q, type InteractiveSection as r, QUICK_REPLY_ATTACHMENT_LIMIT as s, type ReopenMechanism as t, type TranscriptionStatus as u, capabilitiesOf as v, channelFiltersFor as w, contactFlag as x, formatContactHandle as y };
|
package/dist/styles.css
CHANGED
|
@@ -1314,12 +1314,14 @@
|
|
|
1314
1314
|
--cv-p-i-border: var(--cv-p-border, #d5d9df);
|
|
1315
1315
|
--cv-p-i-accent: var(--cv-p-accent, #a85a1c);
|
|
1316
1316
|
--cv-p-i-accent-contrast: var(--cv-p-accent-contrast, #ffffff);
|
|
1317
|
-
--cv-p-i-highlight: var(--cv-p-highlight, #
|
|
1317
|
+
--cv-p-i-highlight: var(--cv-p-highlight, #fcf3ea);
|
|
1318
1318
|
--cv-p-i-danger: var(--cv-p-danger, #c62828);
|
|
1319
1319
|
--cv-p-i-radius: var(--cv-p-radius, 0);
|
|
1320
1320
|
--cv-p-i-touch: 2.75rem;
|
|
1321
1321
|
--cv-p-i-scrollbar-thumb: var(--cv-p-scrollbar-thumb, var(--cv-p-i-border));
|
|
1322
1322
|
--cv-p-i-scrollbar-thumb-hover: var(--cv-p-scrollbar-thumb-hover, var(--cv-p-i-accent));
|
|
1323
|
+
--cv-p-i-tick: var(--cv-p-tick, var(--cv-p-i-text-muted));
|
|
1324
|
+
--cv-p-i-tick-read: var(--cv-p-tick-read, #0369a1);
|
|
1323
1325
|
--cv-p-i-wallpaper-ink: var(--cv-p-i-border);
|
|
1324
1326
|
--cv-p-i-wallpaper:
|
|
1325
1327
|
radial-gradient(
|
|
@@ -1354,6 +1356,7 @@
|
|
|
1354
1356
|
--cv-p-i-accent-contrast: var(--cv-p-accent-contrast, #10222c);
|
|
1355
1357
|
--cv-p-i-highlight: var(--cv-p-highlight, rgb(213 138 71 / 10%));
|
|
1356
1358
|
--cv-p-i-danger: var(--cv-p-danger, #ff6b63);
|
|
1359
|
+
--cv-p-i-tick-read: var(--cv-p-tick-read, #5cc8ff);
|
|
1357
1360
|
}
|
|
1358
1361
|
.cv-p *,
|
|
1359
1362
|
.cv-p *::before,
|
|
@@ -1463,6 +1466,9 @@
|
|
|
1463
1466
|
.cv-p-error__message {
|
|
1464
1467
|
margin: 0;
|
|
1465
1468
|
}
|
|
1469
|
+
.cv-p-thread__scroll > .cv-p-error {
|
|
1470
|
+
background: var(--cv-p-i-surface);
|
|
1471
|
+
}
|
|
1466
1472
|
.cv-p-loading {
|
|
1467
1473
|
display: flex;
|
|
1468
1474
|
flex-direction: column;
|
|
@@ -1706,6 +1712,29 @@
|
|
|
1706
1712
|
text-overflow: ellipsis;
|
|
1707
1713
|
min-width: 0;
|
|
1708
1714
|
}
|
|
1715
|
+
.cv-p-thread__counterpart {
|
|
1716
|
+
margin: 0;
|
|
1717
|
+
overflow: hidden;
|
|
1718
|
+
text-overflow: ellipsis;
|
|
1719
|
+
white-space: nowrap;
|
|
1720
|
+
font-size: 0.78rem;
|
|
1721
|
+
line-height: 1.3;
|
|
1722
|
+
color: var(--cv-p-i-text-muted);
|
|
1723
|
+
}
|
|
1724
|
+
.cv-p-thread__actions {
|
|
1725
|
+
flex: 0 0 auto;
|
|
1726
|
+
display: flex;
|
|
1727
|
+
align-items: center;
|
|
1728
|
+
gap: 0.5rem;
|
|
1729
|
+
margin-inline-start: auto;
|
|
1730
|
+
max-width: 100%;
|
|
1731
|
+
}
|
|
1732
|
+
.cv-p-thread__header:has(.cv-p-thread__actions) {
|
|
1733
|
+
flex-wrap: wrap;
|
|
1734
|
+
}
|
|
1735
|
+
.cv-p-thread__header:has(.cv-p-thread__actions) .cv-p-thread__heading {
|
|
1736
|
+
min-width: 6rem;
|
|
1737
|
+
}
|
|
1709
1738
|
.cv-p-thread__meta {
|
|
1710
1739
|
display: flex;
|
|
1711
1740
|
flex-wrap: wrap;
|
|
@@ -1770,6 +1799,7 @@
|
|
|
1770
1799
|
padding: 0 1rem;
|
|
1771
1800
|
font-size: 0.75rem;
|
|
1772
1801
|
color: var(--cv-p-i-accent);
|
|
1802
|
+
background: var(--cv-p-i-surface-raised);
|
|
1773
1803
|
}
|
|
1774
1804
|
.cv-p-thread__live:empty {
|
|
1775
1805
|
position: absolute;
|
|
@@ -1911,33 +1941,6 @@
|
|
|
1911
1941
|
.cv-p-bubble--no-tail::after {
|
|
1912
1942
|
content: none;
|
|
1913
1943
|
}
|
|
1914
|
-
@media (forced-colors: active) {
|
|
1915
|
-
.cv-p-bubble::before,
|
|
1916
|
-
.cv-p-bubble::after {
|
|
1917
|
-
content: none;
|
|
1918
|
-
}
|
|
1919
|
-
.cv-p-bubble,
|
|
1920
|
-
.cv-p-bubble--mine {
|
|
1921
|
-
border-radius: var(--cv-p-i-radius);
|
|
1922
|
-
}
|
|
1923
|
-
.cv-p-thread__scroll {
|
|
1924
|
-
background-image: none;
|
|
1925
|
-
}
|
|
1926
|
-
.cv-p-thread__scroll,
|
|
1927
|
-
.cv-p-inbox,
|
|
1928
|
-
.cv-p-composer__input {
|
|
1929
|
-
scrollbar-width: auto;
|
|
1930
|
-
scrollbar-color: auto;
|
|
1931
|
-
}
|
|
1932
|
-
.cv-p-filters,
|
|
1933
|
-
.cv-p-quick {
|
|
1934
|
-
mask-image: none;
|
|
1935
|
-
-webkit-mask-image: none;
|
|
1936
|
-
}
|
|
1937
|
-
.cv-p-thread__latest-badge {
|
|
1938
|
-
border: 1px solid ButtonText;
|
|
1939
|
-
}
|
|
1940
|
-
}
|
|
1941
1944
|
@media print {
|
|
1942
1945
|
.cv-p-bubble::before,
|
|
1943
1946
|
.cv-p-bubble::after {
|
|
@@ -1990,6 +1993,17 @@
|
|
|
1990
1993
|
align-items: center;
|
|
1991
1994
|
gap: 0.25rem;
|
|
1992
1995
|
}
|
|
1996
|
+
.cv-p-bubble .cv-status-ticks--queued,
|
|
1997
|
+
.cv-p-bubble .cv-status-ticks--sent,
|
|
1998
|
+
.cv-p-bubble .cv-status-ticks--delivered {
|
|
1999
|
+
color: var(--cv-p-i-tick);
|
|
2000
|
+
}
|
|
2001
|
+
.cv-p-bubble .cv-status-ticks--read {
|
|
2002
|
+
color: var(--cv-p-i-tick-read);
|
|
2003
|
+
}
|
|
2004
|
+
.cv-p-bubble .cv-status-ticks--failed {
|
|
2005
|
+
color: var(--cv-p-i-danger);
|
|
2006
|
+
}
|
|
1993
2007
|
.cv-p-bubble__retry {
|
|
1994
2008
|
min-height: var(--cv-p-i-touch);
|
|
1995
2009
|
display: inline-flex;
|
|
@@ -2483,7 +2497,38 @@
|
|
|
2483
2497
|
.cv-p-copyable__done {
|
|
2484
2498
|
margin-inline-start: 0.35rem;
|
|
2485
2499
|
font-size: 0.85em;
|
|
2486
|
-
color: var(--cv-p-i-
|
|
2500
|
+
color: var(--cv-p-i-text);
|
|
2501
|
+
}
|
|
2502
|
+
@media (forced-colors: active) {
|
|
2503
|
+
.cv-p-bubble::before,
|
|
2504
|
+
.cv-p-bubble::after {
|
|
2505
|
+
content: none;
|
|
2506
|
+
}
|
|
2507
|
+
.cv-p-bubble,
|
|
2508
|
+
.cv-p-bubble--mine {
|
|
2509
|
+
border-radius: var(--cv-p-i-radius);
|
|
2510
|
+
}
|
|
2511
|
+
.cv-p-thread__scroll {
|
|
2512
|
+
background-image: none;
|
|
2513
|
+
}
|
|
2514
|
+
.cv-p-thread__scroll,
|
|
2515
|
+
.cv-p-inbox,
|
|
2516
|
+
.cv-p-composer__input {
|
|
2517
|
+
scrollbar-width: auto;
|
|
2518
|
+
scrollbar-color: auto;
|
|
2519
|
+
}
|
|
2520
|
+
.cv-p-filters,
|
|
2521
|
+
.cv-p-quick {
|
|
2522
|
+
mask-image: none;
|
|
2523
|
+
-webkit-mask-image: none;
|
|
2524
|
+
}
|
|
2525
|
+
.cv-p-thread__latest-badge {
|
|
2526
|
+
border: 1px solid ButtonText;
|
|
2527
|
+
}
|
|
2528
|
+
.cv-p-bubble .cv-status-ticks,
|
|
2529
|
+
.cv-p-copyable__done {
|
|
2530
|
+
color: CanvasText;
|
|
2531
|
+
}
|
|
2487
2532
|
}
|
|
2488
2533
|
@media (forced-colors: active) {
|
|
2489
2534
|
.cv-p-text__code,
|
package/package.json
CHANGED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it, mock } from 'bun:test'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
import type { ParticipantMessage } from '@adatechnology/conversation-contracts'
|
|
5
|
+
|
|
6
|
+
import type { ConversationThreadApi } from './conversationThreadApi'
|
|
7
|
+
import type { ConversationThreadProps } from './ConversationThread'
|
|
8
|
+
import { buildMessage, findForeignClassTokens, findUtilityClassTokens } from './participantFixtures.test-helper'
|
|
9
|
+
import * as realThreadModule from './useParticipantThread'
|
|
10
|
+
|
|
11
|
+
const COUNTERPART = buildMessage({
|
|
12
|
+
id: 'a',
|
|
13
|
+
direction: 'inbound',
|
|
14
|
+
authorName: 'Ana Souza',
|
|
15
|
+
createdAt: '2026-10-01T10:00:00.000Z',
|
|
16
|
+
})
|
|
17
|
+
const OWN_READ = buildMessage({ id: 'b', direction: 'outbound', status: 'read', createdAt: '2026-10-01T10:01:00.000Z' })
|
|
18
|
+
const OWN_DELIVERED = buildMessage({
|
|
19
|
+
id: 'c',
|
|
20
|
+
direction: 'outbound',
|
|
21
|
+
status: 'delivered',
|
|
22
|
+
createdAt: '2026-10-01T10:02:00.000Z',
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const realUseParticipantThread = realThreadModule.useParticipantThread
|
|
26
|
+
let injected: readonly ParticipantMessage[] | undefined
|
|
27
|
+
|
|
28
|
+
const API: ConversationThreadApi = {
|
|
29
|
+
fetchMessages: async () => [],
|
|
30
|
+
sendMessage: async () => ({ outcome: 'queued' }),
|
|
31
|
+
resolveAttachmentUrl: async () => 'https://files.example/a',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let ConversationThread: typeof import('./ConversationThread').ConversationThread
|
|
35
|
+
|
|
36
|
+
beforeAll(async () => {
|
|
37
|
+
// Server rendering runs no effects, so the messages are injected over the real hook.
|
|
38
|
+
mock.module('./useParticipantThread', () => ({
|
|
39
|
+
...realThreadModule,
|
|
40
|
+
useParticipantThread: (...args: Parameters<typeof realUseParticipantThread>) => {
|
|
41
|
+
const result = realUseParticipantThread(...args)
|
|
42
|
+
return injected ? { ...result, messages: injected, status: 'ready' as const } : result
|
|
43
|
+
},
|
|
44
|
+
}))
|
|
45
|
+
ConversationThread = (await import('./ConversationThread')).ConversationThread
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
afterAll(() => {
|
|
49
|
+
injected = undefined
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
function render(messages: readonly ParticipantMessage[], overrides: Partial<ConversationThreadProps> = {}): string {
|
|
53
|
+
injected = messages
|
|
54
|
+
return renderToStaticMarkup(
|
|
55
|
+
<ConversationThread api={API} subject={{ subjectType: 'case', subjectId: '7' }} title="Case 7" {...overrides} />,
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function bubblesOf(markup: string): string[] {
|
|
60
|
+
return markup.split('<div class="cv-p-bubble').slice(1)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('ConversationThread, operator perspective', () => {
|
|
64
|
+
const MESSAGES = [COUNTERPART, OWN_READ, OWN_DELIVERED]
|
|
65
|
+
|
|
66
|
+
it('owns the outbound messages and not the inbound ones', () => {
|
|
67
|
+
const bubbles = bubblesOf(render(MESSAGES, { perspective: 'operator' }))
|
|
68
|
+
expect(bubbles.map((bubble) => bubble.startsWith(' cv-p-bubble--mine'))).toEqual([false, true, true])
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('draws the ticks on the operator messages only', () => {
|
|
72
|
+
const [received, readOwn, deliveredOwn] = bubblesOf(render(MESSAGES, { perspective: 'operator' }))
|
|
73
|
+
expect(received).not.toContain('cv-status-ticks')
|
|
74
|
+
expect(readOwn).toContain('cv-status-ticks--read')
|
|
75
|
+
expect(deliveredOwn).toContain('cv-status-ticks--delivered')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('draws the initials on the counterpart and never on the operator messages', () => {
|
|
79
|
+
const markup = render(MESSAGES, { perspective: 'operator', avatars: 'initials' })
|
|
80
|
+
expect(markup.match(/class="cv-p-avatar" aria-hidden="true">AS</g)?.length).toBe(1)
|
|
81
|
+
expect(markup.match(/class="cv-p-bubble-row"/g)?.length).toBe(1)
|
|
82
|
+
expect(render([OWN_READ, OWN_DELIVERED], { perspective: 'operator', avatars: 'initials' })).not.toContain(
|
|
83
|
+
'cv-p-avatar',
|
|
84
|
+
)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('labels the received bubble with the author name', () => {
|
|
88
|
+
expect(render(MESSAGES, { perspective: 'operator' })).toContain(
|
|
89
|
+
'<span class="cv-p-bubble__author">Ana Souza</span>',
|
|
90
|
+
)
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('ConversationThread, default perspective', () => {
|
|
95
|
+
const MESSAGES = [COUNTERPART, OWN_READ]
|
|
96
|
+
|
|
97
|
+
it('is the participant: inbound is mine, and naming it explicitly changes nothing', () => {
|
|
98
|
+
const implicit = render(MESSAGES)
|
|
99
|
+
expect(bubblesOf(implicit).map((bubble) => bubble.startsWith(' cv-p-bubble--mine'))).toEqual([true, false])
|
|
100
|
+
expect(render(MESSAGES, { perspective: 'participant' })).toBe(implicit)
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
describe('ConversationThread, header and footer', () => {
|
|
105
|
+
it('shows the host actions and the counterpart line in the header, and nothing without them', () => {
|
|
106
|
+
const plain = render([COUNTERPART])
|
|
107
|
+
expect(plain).not.toContain('cv-p-thread__actions')
|
|
108
|
+
expect(plain).not.toContain('cv-p-thread__counterpart')
|
|
109
|
+
const markup = render([COUNTERPART], {
|
|
110
|
+
headerActions: <button type="button">Close</button>,
|
|
111
|
+
counterpartLabel: 'Participant: Ana',
|
|
112
|
+
})
|
|
113
|
+
expect(markup).toContain('<div class="cv-p-thread__actions"><button type="button">Close</button></div>')
|
|
114
|
+
expect(markup).toContain('<p class="cv-p-thread__counterpart">Participant: Ana</p>')
|
|
115
|
+
expect(markup.indexOf('cv-p-thread__actions')).toBeLessThan(markup.indexOf('</header>'))
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('removes the composer and shows the notice when closed, and keeps it when open', () => {
|
|
119
|
+
const closed = render([COUNTERPART], { status: 'closed', perspective: 'operator' })
|
|
120
|
+
expect(closed).toContain('This conversation is closed')
|
|
121
|
+
expect(closed).not.toContain('<form')
|
|
122
|
+
expect(render([COUNTERPART], { status: 'open' })).toContain('<form')
|
|
123
|
+
expect(render([COUNTERPART])).toContain('<form')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('draws the title, protocol and channels only from what the host gave', () => {
|
|
127
|
+
expect(render([COUNTERPART])).not.toContain('cv-p-protocol')
|
|
128
|
+
expect(render([COUNTERPART])).not.toContain('cv-p-channels')
|
|
129
|
+
const markup = render([COUNTERPART], { protocol: '261009-K7M2', channels: ['app', 'whatsapp'] })
|
|
130
|
+
expect(markup).toContain('261009-K7M2')
|
|
131
|
+
expect(markup).toContain('cv-p-channel--whatsapp')
|
|
132
|
+
expect(markup).toContain('Case 7')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('draws no back arrow without onBack', () => {
|
|
136
|
+
expect(render([COUNTERPART])).not.toContain('cv-p-thread__back')
|
|
137
|
+
expect(render([COUNTERPART], { onBack: () => undefined })).toContain('cv-p-thread__back')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('offers quick replies as plain buttons that only fill the field', () => {
|
|
141
|
+
const markup = render([COUNTERPART], {
|
|
142
|
+
quickReplies: [{ id: '1', title: 'Thanks', shortcut: 'ty', body: 'Thank you' }],
|
|
143
|
+
})
|
|
144
|
+
const chips = [...markup.matchAll(/<button[^>]*class="cv-p-chip"[^>]*>/g)].map((match) => match[0])
|
|
145
|
+
expect(chips).toHaveLength(1)
|
|
146
|
+
expect(chips[0]).toContain('type="button"')
|
|
147
|
+
expect(render([COUNTERPART])).not.toContain('cv-p-quick')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('applies the theme and the class on a wrapper and uses only the package classes', () => {
|
|
151
|
+
const markup = render([COUNTERPART, OWN_READ], {
|
|
152
|
+
theme: { primaryColor: '#123456' },
|
|
153
|
+
className: 'host-panel',
|
|
154
|
+
onBack: () => undefined,
|
|
155
|
+
})
|
|
156
|
+
expect(markup).toContain('--cv-p-accent:#123456')
|
|
157
|
+
expect(markup).toContain('cv-p-root host-panel')
|
|
158
|
+
expect(findUtilityClassTokens(markup.replace('host-panel', ''))).toEqual([])
|
|
159
|
+
expect(findForeignClassTokens(markup.replace('host-panel', ''))).toEqual([])
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('draws the conversation without any provider or list around it', () => {
|
|
163
|
+
expect(render([])).toContain('cv-p-thread__title')
|
|
164
|
+
})
|
|
165
|
+
})
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { useCallback, useMemo, useReducer, useRef, type ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { ParticipantConversationSummary, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
|
|
4
|
+
|
|
5
|
+
import type { ConversationChannel } from '../conversationChannel'
|
|
6
|
+
import type { QuickReply } from '../quickReplies/quickReply.types'
|
|
7
|
+
import type { ConversationsTheme } from '../types'
|
|
8
|
+
import type { ParticipantAuthorAvatarRenderer } from './ParticipantAuthorAvatar'
|
|
9
|
+
import { ParticipantThread } from './ParticipantThread'
|
|
10
|
+
import { toParticipantApi, type ConversationThreadApi } from './conversationThreadApi'
|
|
11
|
+
import { buildThreadSummary } from './conversationThreadSummary'
|
|
12
|
+
import type { ParticipantSubjectGroup, ParticipantSubjectIconRenderer } from './participant.types'
|
|
13
|
+
import type { ParticipantPendingMessage } from './participantApi.types'
|
|
14
|
+
import { draftKey, type ParticipantDrafts } from './participantDrafts'
|
|
15
|
+
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS, type ParticipantConversationsLabels } from './participantLabels'
|
|
16
|
+
import type { ParticipantPerspective } from './participantPerspective'
|
|
17
|
+
import { participantSendStatesReducer } from './participantSendStates'
|
|
18
|
+
import { buildParticipantThemeStyle } from './participantTheme'
|
|
19
|
+
import { useParticipantThreadController, type ParticipantThreadCoreProps } from './useParticipantThreadController'
|
|
20
|
+
|
|
21
|
+
export type ConversationThreadProps = {
|
|
22
|
+
readonly api: ConversationThreadApi
|
|
23
|
+
readonly subject: ParticipantSubjectRef
|
|
24
|
+
/** `participant` (default) is the outside party; `operator` is the company, which owns the outbound messages. */
|
|
25
|
+
readonly perspective?: ParticipantPerspective
|
|
26
|
+
readonly title: string
|
|
27
|
+
readonly protocol?: string
|
|
28
|
+
readonly channels?: ParticipantConversationSummary['channels']
|
|
29
|
+
/** `closed` removes the composer and shows `labels.closedNotice`. */
|
|
30
|
+
readonly status?: 'open' | 'closed'
|
|
31
|
+
/** Short line under the title, written by the host (for example "Participant: Ana"). */
|
|
32
|
+
readonly counterpartLabel?: string
|
|
33
|
+
/** Slot at the end of the header bar for the host's own actions (close, reopen...). The package knows none of them. */
|
|
34
|
+
readonly headerActions?: ReactNode
|
|
35
|
+
/** Absent draws no back arrow. */
|
|
36
|
+
readonly onBack?: () => void
|
|
37
|
+
readonly renderSubjectIcon?: ParticipantSubjectIconRenderer
|
|
38
|
+
readonly subjectGroups?: readonly ParticipantSubjectGroup[]
|
|
39
|
+
/** Chips above the field: tapping one fills the text, it never sends. */
|
|
40
|
+
readonly quickReplies?: readonly QuickReply[]
|
|
41
|
+
readonly labels?: Partial<ParticipantConversationsLabels>
|
|
42
|
+
readonly theme?: ConversationsTheme
|
|
43
|
+
readonly className?: string
|
|
44
|
+
readonly locale?: string
|
|
45
|
+
readonly channel?: ConversationChannel
|
|
46
|
+
readonly pendingMessages?: readonly ParticipantPendingMessage[]
|
|
47
|
+
readonly onRetryPending?: (clientMessageId: string) => void
|
|
48
|
+
/** Tells the host the other side's messages were marked read, to refresh its own badges. */
|
|
49
|
+
readonly onMarkedRead?: (subject: ParticipantSubjectRef) => void
|
|
50
|
+
readonly tail?: boolean
|
|
51
|
+
readonly avatars?: 'initials'
|
|
52
|
+
readonly renderAuthorAvatar?: ParticipantAuthorAvatarRenderer
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function useConversationThreadCore(props: ConversationThreadProps): ParticipantThreadCoreProps {
|
|
56
|
+
const { perspective = 'participant' } = props
|
|
57
|
+
const { subjectType, subjectId } = props.subject
|
|
58
|
+
const subject = useMemo<ParticipantSubjectRef>(() => ({ subjectType, subjectId }), [subjectType, subjectId])
|
|
59
|
+
const onMarkedReadRef = useRef(props.onMarkedRead)
|
|
60
|
+
onMarkedReadRef.current = props.onMarkedRead
|
|
61
|
+
const handleMarkedRead = useCallback((marked: ParticipantSubjectRef) => onMarkedReadRef.current?.(marked), [])
|
|
62
|
+
const api = useMemo(() => toParticipantApi(props.api), [props.api])
|
|
63
|
+
const draftsRef = useRef<ParticipantDrafts>(new Map())
|
|
64
|
+
const [sendStates, dispatchSendStates] = useReducer(participantSendStatesReducer, new Map())
|
|
65
|
+
const labels = useMemo(() => ({ ...DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS, ...props.labels }), [props.labels])
|
|
66
|
+
const { title, protocol, channels, status } = props
|
|
67
|
+
const conversation = useMemo(
|
|
68
|
+
() => buildThreadSummary({ subject, title, protocol, channels, status }),
|
|
69
|
+
[subject, title, protocol, channels, status],
|
|
70
|
+
)
|
|
71
|
+
return useParticipantThreadController({
|
|
72
|
+
api,
|
|
73
|
+
subject,
|
|
74
|
+
conversation,
|
|
75
|
+
labels,
|
|
76
|
+
draftsRef,
|
|
77
|
+
sendStates,
|
|
78
|
+
dispatchSendStates,
|
|
79
|
+
onMarkedRead: handleMarkedRead,
|
|
80
|
+
channel: props.channel,
|
|
81
|
+
locale: props.locale,
|
|
82
|
+
pendingMessages: props.pendingMessages,
|
|
83
|
+
onRetryPending: props.onRetryPending,
|
|
84
|
+
perspective,
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function ConversationThreadBody(props: ConversationThreadProps) {
|
|
89
|
+
const core = useConversationThreadCore(props)
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<div
|
|
93
|
+
className={['cv-p-root', props.className].filter(Boolean).join(' ')}
|
|
94
|
+
style={buildParticipantThemeStyle(props.theme)}
|
|
95
|
+
>
|
|
96
|
+
<div className="cv-p-root__screen">
|
|
97
|
+
<ParticipantThread
|
|
98
|
+
{...core}
|
|
99
|
+
onBack={props.onBack}
|
|
100
|
+
quickReplies={props.quickReplies}
|
|
101
|
+
subjectGroups={props.subjectGroups}
|
|
102
|
+
renderSubjectIcon={props.renderSubjectIcon}
|
|
103
|
+
avatars={props.avatars}
|
|
104
|
+
renderAuthorAvatar={props.renderAuthorAvatar}
|
|
105
|
+
tail={props.tail}
|
|
106
|
+
counterpartLabel={props.counterpartLabel}
|
|
107
|
+
headerActions={props.headerActions}
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** One conversation about one subject, drawn like the participant app and seen from either side. */
|
|
115
|
+
export function ConversationThread(props: ConversationThreadProps) {
|
|
116
|
+
return <ConversationThreadBody key={draftKey(props.subject)} {...props} />
|
|
117
|
+
}
|
|
@@ -7,6 +7,7 @@ import { FailedActions, OwnStatus } from './ParticipantBubbleStatus'
|
|
|
7
7
|
import { ParticipantMessageText } from './ParticipantMessageText'
|
|
8
8
|
import { ParticipantAttachmentItem, type ResolveParticipantAttachmentUrl } from './ParticipantAttachmentItem'
|
|
9
9
|
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
10
|
+
import type { ParticipantPerspective } from './participantPerspective'
|
|
10
11
|
import {
|
|
11
12
|
isOwnMessage,
|
|
12
13
|
resolveOwnMessageStatus,
|
|
@@ -29,6 +30,8 @@ export type ParticipantMessageBubbleProps = {
|
|
|
29
30
|
readonly avatar?: ReactNode
|
|
30
31
|
/** Speech-bubble tail on the bottom corner; false removes it. Default true. */
|
|
31
32
|
readonly tail?: boolean
|
|
33
|
+
/** Who is looking; absent is the participant. The operator owns the outbound messages. */
|
|
34
|
+
readonly perspective?: ParticipantPerspective
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
type BubbleContent = {
|
|
@@ -42,11 +45,11 @@ type BubbleContent = {
|
|
|
42
45
|
readonly serverStatus?: MessageDeliveryStatus
|
|
43
46
|
}
|
|
44
47
|
|
|
45
|
-
function describeItem(item: ParticipantTimelineItem): BubbleContent {
|
|
48
|
+
function describeItem(item: ParticipantTimelineItem, perspective?: ParticipantPerspective): BubbleContent {
|
|
46
49
|
if (item.kind === 'server') {
|
|
47
50
|
const { message } = item
|
|
48
51
|
return {
|
|
49
|
-
isMine: isOwnMessage(message),
|
|
52
|
+
isMine: isOwnMessage(message, perspective),
|
|
50
53
|
authorName: message.authorName ?? undefined,
|
|
51
54
|
text: message.text ?? undefined,
|
|
52
55
|
createdAt: message.createdAt,
|
|
@@ -82,8 +85,9 @@ export function ParticipantMessageBubble({
|
|
|
82
85
|
onEdit,
|
|
83
86
|
avatar,
|
|
84
87
|
tail = true,
|
|
88
|
+
perspective,
|
|
85
89
|
}: ParticipantMessageBubbleProps) {
|
|
86
|
-
const content = describeItem(item)
|
|
90
|
+
const content = describeItem(item, perspective)
|
|
87
91
|
const { isMine } = content
|
|
88
92
|
const ownStatus = isMine
|
|
89
93
|
? resolveOwnMessageStatus({ displayState: content.displayState, serverStatus: content.serverStatus, confirmsRead })
|
|
@@ -12,6 +12,7 @@ import type { ParticipantPendingActions } from './participantBubbleActions'
|
|
|
12
12
|
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
13
13
|
import { resolveLoadView, type ParticipantLoadStatus } from './participantLoadView'
|
|
14
14
|
import { ParticipantLoadError, ParticipantLoading } from './ParticipantLoadState'
|
|
15
|
+
import type { ParticipantPerspective } from './participantPerspective'
|
|
15
16
|
import type { ParticipantThreadScroll } from './useStickToBottom'
|
|
16
17
|
import type { ParticipantAuthorAvatarRenderer } from './ParticipantAuthorAvatar'
|
|
17
18
|
import { ParticipantScrollToLatest } from './ParticipantScrollToLatest'
|
|
@@ -62,6 +63,12 @@ export type ParticipantThreadProps = {
|
|
|
62
63
|
readonly renderSubjectIcon?: ParticipantSubjectIconRenderer
|
|
63
64
|
/** Speech-bubble tail on the bottom corner; false removes it. Default true. */
|
|
64
65
|
readonly tail?: boolean
|
|
66
|
+
/** Who is looking; absent is the participant, so nothing changes for the existing screen. */
|
|
67
|
+
readonly perspective?: ParticipantPerspective
|
|
68
|
+
/** Short line under the title, written by the host. */
|
|
69
|
+
readonly counterpartLabel?: string
|
|
70
|
+
/** Host slot at the end of the header bar (close, reopen...). */
|
|
71
|
+
readonly headerActions?: ReactNode
|
|
65
72
|
}
|
|
66
73
|
|
|
67
74
|
function Footer({ props }: { readonly props: ParticipantThreadProps }) {
|
|
@@ -100,6 +107,8 @@ function ThreadBody(props: ParticipantThreadProps) {
|
|
|
100
107
|
renderSubjectIcon={props.renderSubjectIcon}
|
|
101
108
|
onBack={props.onBack}
|
|
102
109
|
onOpenSubject={props.onOpenSubject}
|
|
110
|
+
counterpartLabel={props.counterpartLabel}
|
|
111
|
+
actions={props.headerActions}
|
|
103
112
|
/>
|
|
104
113
|
{subjectCard ? <div className="cv-p-thread__subject-card">{subjectCard}</div> : null}
|
|
105
114
|
<div className="cv-p-thread__scroll" ref={props.scroll.ref} onScroll={props.scroll.onScroll} tabIndex={-1}>
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
1
3
|
import type { ParticipantConversationSummary, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
|
|
2
4
|
|
|
3
5
|
import { ParticipantBackButton } from './ParticipantBackButton'
|
|
@@ -16,6 +18,10 @@ export type ParticipantThreadHeaderProps = {
|
|
|
16
18
|
readonly renderSubjectIcon?: ParticipantSubjectIconRenderer
|
|
17
19
|
readonly onBack?: () => void
|
|
18
20
|
readonly onOpenSubject?: (subject: ParticipantSubjectRef) => void
|
|
21
|
+
/** Short line under the title, written by the host (for example who is on the other side). */
|
|
22
|
+
readonly counterpartLabel?: string
|
|
23
|
+
/** Host slot at the end of the bar; the package knows none of these actions. */
|
|
24
|
+
readonly actions?: ReactNode
|
|
19
25
|
}
|
|
20
26
|
|
|
21
27
|
type TitleProps = Pick<ParticipantThreadHeaderProps, 'conversation' | 'labels' | 'onOpenSubject'>
|
|
@@ -47,6 +53,8 @@ export function ParticipantThreadHeader({
|
|
|
47
53
|
renderSubjectIcon,
|
|
48
54
|
onBack,
|
|
49
55
|
onOpenSubject,
|
|
56
|
+
counterpartLabel,
|
|
57
|
+
actions,
|
|
50
58
|
}: ParticipantThreadHeaderProps) {
|
|
51
59
|
const group = subjectGroups?.find((candidate) => candidate.subjectType === conversation.subjectType)
|
|
52
60
|
const icon = resolveConversationIcon({ conversation, group, renderSubjectIcon })
|
|
@@ -59,6 +67,7 @@ export function ParticipantThreadHeader({
|
|
|
59
67
|
<div className="cv-p-thread__heading">
|
|
60
68
|
{icon === undefined && group ? <p className="cv-p-thread__eyebrow">{group.label}</p> : null}
|
|
61
69
|
<Title conversation={conversation} labels={labels} onOpenSubject={onOpenSubject} />
|
|
70
|
+
{counterpartLabel ? <p className="cv-p-thread__counterpart">{counterpartLabel}</p> : null}
|
|
62
71
|
{conversation.protocol || hasChannels ? (
|
|
63
72
|
<div className="cv-p-thread__meta">
|
|
64
73
|
{conversation.protocol ? (
|
|
@@ -68,6 +77,7 @@ export function ParticipantThreadHeader({
|
|
|
68
77
|
</div>
|
|
69
78
|
) : null}
|
|
70
79
|
</div>
|
|
80
|
+
{actions ? <div className="cv-p-thread__actions">{actions}</div> : null}
|
|
71
81
|
</header>
|
|
72
82
|
)
|
|
73
83
|
}
|
|
@@ -1,19 +1,15 @@
|
|
|
1
|
-
import { useEffect,
|
|
1
|
+
import { useEffect, useState, type Dispatch, type MutableRefObject } from 'react'
|
|
2
2
|
|
|
3
3
|
import type { ParticipantConversationSummary, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
|
|
4
4
|
|
|
5
5
|
import { ParticipantBackButton } from './ParticipantBackButton'
|
|
6
6
|
import { ParticipantThread } from './ParticipantThread'
|
|
7
7
|
import type { ParticipantConversationsScreenProps } from './ParticipantConversationsScreen'
|
|
8
|
-
import { bindAttachmentUrlResolver } from './bindAttachmentUrlResolver'
|
|
9
8
|
import type { ParticipantDrafts } from './participantDrafts'
|
|
10
9
|
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
11
10
|
import type { ParticipantConversationsApi } from './participantApi.types'
|
|
12
|
-
import { findFailedEntry, hasSendingEntry } from './participantSendController'
|
|
13
11
|
import type { ParticipantSendStates, ParticipantSendStatesAction } from './participantSendStates'
|
|
14
|
-
import {
|
|
15
|
-
import { useParticipantConversationView } from './useParticipantConversationView'
|
|
16
|
-
import { useParticipantThreadScroll } from './useParticipantThreadScroll'
|
|
12
|
+
import { useParticipantThreadController } from './useParticipantThreadController'
|
|
17
13
|
|
|
18
14
|
export type ParticipantThreadScreenProps = Omit<ParticipantConversationsScreenProps, 'selected' | 'labels'> & {
|
|
19
15
|
readonly selected: ParticipantSubjectRef
|
|
@@ -90,53 +86,17 @@ export function ParticipantThreadScreen(props: ParticipantThreadScreenProps) {
|
|
|
90
86
|
type LoadedThreadProps = ParticipantThreadScreenProps & { readonly conversation: ParticipantConversationSummary }
|
|
91
87
|
|
|
92
88
|
function LoadedThread(props: LoadedThreadProps) {
|
|
93
|
-
const
|
|
94
|
-
const { draft, setText, setFiles, takeForSend, restoreFromEntry } = useParticipantDraft(draftsRef, selected)
|
|
95
|
-
const { thread, items, sendEntries, dispatchSend } = useParticipantConversationView({
|
|
89
|
+
const core = useParticipantThreadController({
|
|
96
90
|
...props,
|
|
97
|
-
subject: selected,
|
|
98
|
-
onMarkedRead: inbox.markSubjectRead,
|
|
91
|
+
subject: props.selected,
|
|
92
|
+
onMarkedRead: props.inbox.markSubjectRead,
|
|
99
93
|
})
|
|
100
|
-
const resolveAttachmentUrl = useMemo(() => bindAttachmentUrlResolver(api), [api])
|
|
101
|
-
const { scroll, newMessagesCount } = useParticipantThreadScroll(items)
|
|
102
|
-
|
|
103
|
-
function handleSend(): void {
|
|
104
|
-
const content = takeForSend()
|
|
105
|
-
if (content) void thread.send(content)
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function handleEdit(clientMessageId: string): void {
|
|
109
|
-
const entry = findFailedEntry(sendEntries, clientMessageId)
|
|
110
|
-
if (!entry) return
|
|
111
|
-
restoreFromEntry(entry)
|
|
112
|
-
dispatchSend({ type: 'discarded', clientMessageId })
|
|
113
|
-
}
|
|
114
94
|
|
|
115
95
|
return (
|
|
116
96
|
<ParticipantThread
|
|
117
|
-
|
|
118
|
-
items={items}
|
|
119
|
-
hasMore={thread.hasMore}
|
|
120
|
-
labels={props.labels}
|
|
121
|
-
resolveAttachmentUrl={resolveAttachmentUrl}
|
|
122
|
-
draft={{ value: draft.text, onChange: setText, files: draft.files, onFilesChange: setFiles }}
|
|
123
|
-
onSend={handleSend}
|
|
124
|
-
status={thread.status}
|
|
125
|
-
refresh={() => void thread.refresh()}
|
|
126
|
-
scroll={scroll}
|
|
127
|
-
newMessagesCount={newMessagesCount}
|
|
128
|
-
isSending={hasSendingEntry(sendEntries)}
|
|
129
|
-
channel={props.channel}
|
|
130
|
-
locale={props.locale}
|
|
97
|
+
{...core}
|
|
131
98
|
onBack={props.onBack}
|
|
132
99
|
onOpenSubject={props.onOpenSubject}
|
|
133
|
-
onLoadOlder={() => void thread.loadOlder()}
|
|
134
|
-
pendingActions={{
|
|
135
|
-
retryLocal: (clientMessageId) => void thread.retry(clientMessageId),
|
|
136
|
-
discardLocal: (clientMessageId) => dispatchSend({ type: 'discarded', clientMessageId }),
|
|
137
|
-
editLocal: handleEdit,
|
|
138
|
-
...(props.onRetryPending ? { retryHost: props.onRetryPending } : {}),
|
|
139
|
-
}}
|
|
140
100
|
quickReplies={props.quickReplies}
|
|
141
101
|
renderSubjectCard={props.renderSubjectCard}
|
|
142
102
|
subjectGroups={props.subjectGroups}
|