@adatechnology/conversations-ui 0.4.2 → 0.6.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 +231 -0
- package/dist/{ConversationSimulatorPanel-DnXElfY8.d.ts → ConversationSimulatorPanel-CrfFjiWh.d.ts} +2 -268
- package/dist/{chunk-PJNR6UMN.js → chunk-CBYEHPX6.js} +1 -63
- package/dist/{chunk-4P5KMWOI.js → chunk-FAQLC3TX.js} +327 -522
- package/dist/{chunk-HMCOTZAX.js → chunk-HOKNKXIT.js} +1 -1
- package/dist/chunk-MRVFFJEH.js +64 -0
- package/dist/chunk-O6457XHK.js +255 -0
- package/dist/chunk-OVFCLRQQ.js +84 -0
- package/dist/flows/index.js +5 -3
- package/dist/index.d.ts +21 -5
- package/dist/index.js +783 -807
- package/dist/participant/index.d.ts +186 -0
- package/dist/participant/index.js +1708 -0
- package/dist/preview/index.d.ts +4 -2
- package/dist/preview/index.js +4 -2
- package/dist/quickReply.types-B4gmZsSr.d.ts +274 -0
- package/dist/styles.css +689 -0
- package/package.json +8 -4
- package/src/ChannelIcon.tsx +2 -0
- package/src/MessageText.test.tsx +76 -0
- package/src/MessageText.tsx +28 -12
- package/src/MessageTextCopiedBadge.tsx +15 -0
- package/src/StatusTicks.test.tsx +51 -0
- package/src/StatusTicks.tsx +36 -6
- package/src/buildOutput.test.ts +41 -0
- package/src/channelCapability.test.ts +17 -0
- package/src/conversationChannel.test.ts +30 -1
- package/src/conversationChannel.ts +19 -1
- package/src/legacyChannels.golden.test.ts +89 -0
- package/src/legacyDefaults.golden.test.tsx +127 -0
- package/src/legacyStyles.fixture.css +1121 -0
- package/src/legacyStyles.golden.test.ts +121 -0
- package/src/messageTextCopyInteraction.test.ts +75 -0
- package/src/messageTextCopyInteraction.ts +37 -0
- package/src/participant/ParticipantAttachmentItem.tsx +77 -0
- package/src/participant/ParticipantChannelBadges.test.tsx +59 -0
- package/src/participant/ParticipantChannelBadges.tsx +75 -0
- package/src/participant/ParticipantComposer.test.tsx +97 -0
- package/src/participant/ParticipantComposer.tsx +167 -0
- package/src/participant/ParticipantConversations.test.tsx +90 -0
- package/src/participant/ParticipantConversations.tsx +49 -0
- package/src/participant/ParticipantConversationsScreen.tsx +78 -0
- package/src/participant/ParticipantInbox.test.tsx +244 -0
- package/src/participant/ParticipantInbox.tsx +149 -0
- package/src/participant/ParticipantInboxRow.tsx +75 -0
- package/src/participant/ParticipantInboxSearchField.tsx +34 -0
- package/src/participant/ParticipantLoadState.tsx +27 -0
- package/src/participant/ParticipantMessageBubble.test.tsx +167 -0
- package/src/participant/ParticipantMessageBubble.tsx +185 -0
- package/src/participant/ParticipantProtocolBadge.tsx +74 -0
- package/src/participant/ParticipantProtocolView.test.tsx +208 -0
- package/src/participant/ParticipantThread.test.tsx +149 -0
- package/src/participant/ParticipantThread.tsx +184 -0
- package/src/participant/ParticipantThreadScreen.tsx +135 -0
- package/src/participant/bindAttachmentUrlResolver.test.ts +25 -0
- package/src/participant/bindAttachmentUrlResolver.ts +7 -0
- package/src/participant/index.ts +26 -0
- package/src/participant/participant.types.ts +52 -0
- package/src/participant/participantApi.types.ts +59 -0
- package/src/participant/participantAttachmentUrl.test.ts +32 -0
- package/src/participant/participantAttachmentUrl.ts +10 -0
- package/src/participant/participantBubbleActions.test.ts +44 -0
- package/src/participant/participantBubbleActions.ts +40 -0
- package/src/participant/participantChannels.test.ts +42 -0
- package/src/participant/participantChannels.ts +33 -0
- package/src/participant/participantDrafts.test.ts +65 -0
- package/src/participant/participantDrafts.ts +50 -0
- package/src/participant/participantFileValidation.test.ts +86 -0
- package/src/participant/participantFileValidation.ts +72 -0
- package/src/participant/participantFixtures.test-helper.ts +41 -0
- package/src/participant/participantGrouping.test.ts +236 -0
- package/src/participant/participantGrouping.ts +90 -0
- package/src/participant/participantInboxState.test.ts +69 -0
- package/src/participant/participantInboxState.ts +74 -0
- package/src/participant/participantLabels.ts +115 -0
- package/src/participant/participantLoadView.test.ts +23 -0
- package/src/participant/participantLoadView.ts +21 -0
- package/src/participant/participantMessages.test.ts +128 -0
- package/src/participant/participantMessages.ts +73 -0
- package/src/participant/participantNewMessages.test.ts +54 -0
- package/src/participant/participantNewMessages.ts +29 -0
- package/src/participant/participantProtocol.test.ts +104 -0
- package/src/participant/participantProtocol.ts +52 -0
- package/src/participant/participantProtocolCopy.test.ts +45 -0
- package/src/participant/participantProtocolCopy.ts +32 -0
- package/src/participant/participantQuickReply.test.ts +23 -0
- package/src/participant/participantQuickReply.ts +6 -0
- package/src/participant/participantScroll.test.ts +69 -0
- package/src/participant/participantScroll.ts +43 -0
- package/src/participant/participantSendController.test.ts +201 -0
- package/src/participant/participantSendController.ts +134 -0
- package/src/participant/participantSendStates.ts +31 -0
- package/src/participant/participantTheme.ts +20 -0
- package/src/participant/participantThreadState.test.ts +75 -0
- package/src/participant/participantThreadState.ts +59 -0
- package/src/participant/shouldMarkRead.test.ts +24 -0
- package/src/participant/shouldMarkRead.ts +16 -0
- package/src/participant/useDocumentVisibility.ts +18 -0
- package/src/participant/useParticipantConversationView.ts +60 -0
- package/src/participant/useParticipantDraft.ts +45 -0
- package/src/participant/useParticipantInbox.ts +57 -0
- package/src/participant/useParticipantMarkRead.ts +26 -0
- package/src/participant/useParticipantRevalidation.ts +36 -0
- package/src/participant/useParticipantSend.ts +84 -0
- package/src/participant/useParticipantThread.ts +116 -0
- package/src/participant/useParticipantThreadScroll.ts +26 -0
- package/src/participant/useStickToBottom.ts +77 -0
- package/src/preview/ConversationSimulatorPanel.tsx +8 -0
- package/src/styles.css +178 -0
- package/src/types.ts +3 -1
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
export type ParticipantConversationsLabels = {
|
|
2
|
+
readonly inboxTitle: string
|
|
3
|
+
readonly sectionAwaiting: string
|
|
4
|
+
readonly sectionClosed: string
|
|
5
|
+
readonly sectionOther: string
|
|
6
|
+
readonly filterAll: string
|
|
7
|
+
readonly filtersGroup: string
|
|
8
|
+
readonly emptyInbox: string
|
|
9
|
+
/** Uses the `{count}` placeholder. */
|
|
10
|
+
readonly unreadCount: string
|
|
11
|
+
readonly back: string
|
|
12
|
+
readonly openSubject: string
|
|
13
|
+
readonly me: string
|
|
14
|
+
readonly newMessages: string
|
|
15
|
+
readonly loadOlder: string
|
|
16
|
+
readonly closedNotice: string
|
|
17
|
+
readonly messageInputLabel: string
|
|
18
|
+
readonly messageInputPlaceholder: string
|
|
19
|
+
readonly send: string
|
|
20
|
+
readonly attach: string
|
|
21
|
+
readonly attachmentsList: string
|
|
22
|
+
readonly removeAttachment: string
|
|
23
|
+
readonly attachmentTooLarge: string
|
|
24
|
+
readonly attachmentTypeNotAccepted: string
|
|
25
|
+
readonly quickRepliesGroup: string
|
|
26
|
+
readonly downloadAttachment: string
|
|
27
|
+
readonly statusSending: string
|
|
28
|
+
readonly statusQueued: string
|
|
29
|
+
readonly statusSent: string
|
|
30
|
+
readonly statusDelivered: string
|
|
31
|
+
readonly statusRead: string
|
|
32
|
+
readonly statusFailed: string
|
|
33
|
+
readonly statusFailedShort: string
|
|
34
|
+
readonly retry: string
|
|
35
|
+
readonly discard: string
|
|
36
|
+
readonly edit: string
|
|
37
|
+
readonly notFound: string
|
|
38
|
+
readonly loading: string
|
|
39
|
+
readonly loadError: string
|
|
40
|
+
readonly loadMore: string
|
|
41
|
+
readonly searchLabel: string
|
|
42
|
+
readonly searchPlaceholder: string
|
|
43
|
+
readonly noResults: string
|
|
44
|
+
readonly noResultsInFilter: string
|
|
45
|
+
readonly noResultsLoadedOnly: string
|
|
46
|
+
/** Spoken before the protocol code, e.g. "Protocol 261009-K7M2". */
|
|
47
|
+
readonly protocolPrefix: string
|
|
48
|
+
readonly copyProtocol: string
|
|
49
|
+
readonly protocolCopied: string
|
|
50
|
+
readonly channelsGroup: string
|
|
51
|
+
readonly channelApp: string
|
|
52
|
+
readonly channelWhatsapp: string
|
|
53
|
+
readonly channelEmail: string
|
|
54
|
+
readonly channelPortal: string
|
|
55
|
+
readonly channelWebchat: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS: ParticipantConversationsLabels = {
|
|
59
|
+
inboxTitle: 'Conversations',
|
|
60
|
+
sectionAwaiting: 'Waiting for your reply',
|
|
61
|
+
sectionClosed: 'Closed',
|
|
62
|
+
sectionOther: 'Other',
|
|
63
|
+
filterAll: 'All',
|
|
64
|
+
filtersGroup: 'Filter by subject',
|
|
65
|
+
emptyInbox: 'No conversations yet',
|
|
66
|
+
unreadCount: '{count} unread',
|
|
67
|
+
back: 'Back',
|
|
68
|
+
openSubject: 'Open subject',
|
|
69
|
+
me: 'Me',
|
|
70
|
+
newMessages: 'New messages',
|
|
71
|
+
loadOlder: 'Load older messages',
|
|
72
|
+
closedNotice: 'This conversation is closed',
|
|
73
|
+
messageInputLabel: 'Message',
|
|
74
|
+
messageInputPlaceholder: 'Write a message…',
|
|
75
|
+
send: 'Send',
|
|
76
|
+
attach: 'Attach',
|
|
77
|
+
attachmentsList: 'Selected attachments',
|
|
78
|
+
removeAttachment: 'Remove attachment',
|
|
79
|
+
attachmentTooLarge: 'Attachment too large',
|
|
80
|
+
attachmentTypeNotAccepted: 'File type not accepted',
|
|
81
|
+
quickRepliesGroup: 'Quick replies',
|
|
82
|
+
downloadAttachment: 'Download',
|
|
83
|
+
statusSending: 'Sending',
|
|
84
|
+
statusQueued: 'Queued — sends when back online',
|
|
85
|
+
statusSent: 'Sent',
|
|
86
|
+
statusDelivered: 'Delivered',
|
|
87
|
+
statusRead: 'Read',
|
|
88
|
+
statusFailed: 'Failed — tap to retry',
|
|
89
|
+
statusFailedShort: 'Failed',
|
|
90
|
+
retry: 'Retry',
|
|
91
|
+
discard: 'Discard',
|
|
92
|
+
edit: 'Edit',
|
|
93
|
+
notFound: 'Conversation not found',
|
|
94
|
+
loading: 'Loading…',
|
|
95
|
+
loadError: 'Could not load. Check your connection and try again.',
|
|
96
|
+
loadMore: 'Load more',
|
|
97
|
+
searchLabel: 'Search conversations',
|
|
98
|
+
searchPlaceholder: 'Search by title or protocol',
|
|
99
|
+
noResults: 'No conversations found',
|
|
100
|
+
noResultsInFilter: 'No results in this filter — try All',
|
|
101
|
+
noResultsLoadedOnly: 'Nothing in the loaded conversations — load more',
|
|
102
|
+
protocolPrefix: 'Protocol',
|
|
103
|
+
copyProtocol: 'Copy protocol',
|
|
104
|
+
protocolCopied: 'Protocol copied',
|
|
105
|
+
channelsGroup: 'Channels in this conversation',
|
|
106
|
+
channelApp: 'App',
|
|
107
|
+
channelWhatsapp: 'WhatsApp',
|
|
108
|
+
channelEmail: 'Email',
|
|
109
|
+
channelPortal: 'Portal',
|
|
110
|
+
channelWebchat: 'Web chat',
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function formatParticipantLabel(template: string, count: number): string {
|
|
114
|
+
return template.replace('{count}', String(count))
|
|
115
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { resolveLoadView } from './participantLoadView'
|
|
4
|
+
|
|
5
|
+
describe('resolveLoadView', () => {
|
|
6
|
+
it('is loading while idle or loading with nothing to show', () => {
|
|
7
|
+
expect(resolveLoadView({ status: 'idle', hasItems: false })).toEqual({ isLoading: true, hasError: false, isEmpty: false })
|
|
8
|
+
expect(resolveLoadView({ status: 'loading', hasItems: false })).toEqual({ isLoading: true, hasError: false, isEmpty: false })
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('keeps showing items while revalidating', () => {
|
|
12
|
+
expect(resolveLoadView({ status: 'loading', hasItems: true })).toEqual({ isLoading: false, hasError: false, isEmpty: false })
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('is empty only after a successful load', () => {
|
|
16
|
+
expect(resolveLoadView({ status: 'ready', hasItems: false })).toEqual({ isLoading: false, hasError: false, isEmpty: true })
|
|
17
|
+
expect(resolveLoadView({ status: 'error', hasItems: false })).toEqual({ isLoading: false, hasError: true, isEmpty: false })
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('reports the error even when items remain on screen', () => {
|
|
21
|
+
expect(resolveLoadView({ status: 'error', hasItems: true }).hasError).toBe(true)
|
|
22
|
+
})
|
|
23
|
+
})
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type ParticipantLoadStatus = 'idle' | 'loading' | 'ready' | 'error'
|
|
2
|
+
|
|
3
|
+
export type ResolveLoadViewParams = {
|
|
4
|
+
readonly status: ParticipantLoadStatus
|
|
5
|
+
readonly hasItems: boolean
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type ParticipantLoadView = {
|
|
9
|
+
readonly isLoading: boolean
|
|
10
|
+
readonly hasError: boolean
|
|
11
|
+
/** Only after a successful load: an error or a pending load never draws the empty state. */
|
|
12
|
+
readonly isEmpty: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function resolveLoadView({ status, hasItems }: ResolveLoadViewParams): ParticipantLoadView {
|
|
16
|
+
return {
|
|
17
|
+
isLoading: (status === 'idle' || status === 'loading') && !hasItems,
|
|
18
|
+
hasError: status === 'error',
|
|
19
|
+
isEmpty: status === 'ready' && !hasItems,
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import type { ParticipantMessage } from '@adatechnology/conversation-contracts'
|
|
4
|
+
|
|
5
|
+
import type { ParticipantLocalPendingMessage, ParticipantPendingMessage } from './participantApi.types'
|
|
6
|
+
import { isOwnMessage, mergeParticipantMessages, resolveOwnMessageStatus } from './participantMessages'
|
|
7
|
+
|
|
8
|
+
const SUBJECT = { subjectType: 'trip', subjectId: 'a' }
|
|
9
|
+
const OTHER_SUBJECT = { subjectType: 'trip', subjectId: 'b' }
|
|
10
|
+
|
|
11
|
+
function server(id: string, createdAt: string, overrides: Partial<ParticipantMessage> = {}): ParticipantMessage {
|
|
12
|
+
return { id, direction: 'inbound', attachments: [], createdAt, ...overrides }
|
|
13
|
+
}
|
|
14
|
+
function host(clientMessageId: string, createdAt: string, overrides: Partial<ParticipantPendingMessage> = {}): ParticipantPendingMessage {
|
|
15
|
+
return { clientMessageId, subject: SUBJECT, text: 'x', createdAt, state: 'queued', ...overrides }
|
|
16
|
+
}
|
|
17
|
+
function local(clientMessageId: string, createdAt: string, overrides: Partial<ParticipantLocalPendingMessage> = {}): ParticipantLocalPendingMessage {
|
|
18
|
+
return { clientMessageId, subject: SUBJECT, text: 'x', createdAt, state: 'sending', ...overrides }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('mergeParticipantMessages', () => {
|
|
22
|
+
it('orders everything by createdAt ascending', () => {
|
|
23
|
+
const items = mergeParticipantMessages({
|
|
24
|
+
serverMessages: [server('s2', '2026-10-01T10:02:00.000Z'), server('s1', '2026-10-01T10:00:00.000Z')],
|
|
25
|
+
hostPending: [host('h1', '2026-10-01T10:03:00.000Z')],
|
|
26
|
+
localPending: [local('l1', '2026-10-01T10:01:00.000Z')],
|
|
27
|
+
subject: SUBJECT,
|
|
28
|
+
})
|
|
29
|
+
expect(items.map((item) => (item.kind === 'server' ? item.message.id : item.pending.clientMessageId))).toEqual(['s1', 'l1', 's2', 'h1'])
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('hides a host pending when a server message has the same clientMessageId', () => {
|
|
33
|
+
const items = mergeParticipantMessages({
|
|
34
|
+
serverMessages: [server('s1', '2026-10-01T10:00:00.000Z', { clientMessageId: 'c1' })],
|
|
35
|
+
hostPending: [host('c1', '2026-10-01T09:59:00.000Z')],
|
|
36
|
+
localPending: [],
|
|
37
|
+
subject: SUBJECT,
|
|
38
|
+
})
|
|
39
|
+
expect(items).toHaveLength(1)
|
|
40
|
+
expect(items[0]?.kind).toBe('server')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('ignores host pending of another subject', () => {
|
|
44
|
+
const items = mergeParticipantMessages({
|
|
45
|
+
serverMessages: [],
|
|
46
|
+
hostPending: [host('c1', '2026-10-01T09:59:00.000Z', { subject: OTHER_SUBJECT })],
|
|
47
|
+
localPending: [],
|
|
48
|
+
subject: SUBJECT,
|
|
49
|
+
})
|
|
50
|
+
expect(items).toHaveLength(0)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('maps host pending states and tags the origin', () => {
|
|
54
|
+
const items = mergeParticipantMessages({
|
|
55
|
+
serverMessages: [],
|
|
56
|
+
hostPending: [host('q', '2026-10-01T10:00:00.000Z'), host('f', '2026-10-01T10:01:00.000Z', { state: 'failed' })],
|
|
57
|
+
localPending: [],
|
|
58
|
+
subject: SUBJECT,
|
|
59
|
+
})
|
|
60
|
+
expect(items.map((item) => (item.kind === 'pending' ? [item.origin, item.displayState] : []))).toEqual([
|
|
61
|
+
['host', 'queued'],
|
|
62
|
+
['host', 'failed'],
|
|
63
|
+
])
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('shows local pending as sending or failed when no other source has it', () => {
|
|
67
|
+
const items = mergeParticipantMessages({
|
|
68
|
+
serverMessages: [],
|
|
69
|
+
hostPending: [],
|
|
70
|
+
localPending: [local('a', '2026-10-01T10:00:00.000Z'), local('b', '2026-10-01T10:01:00.000Z', { state: 'failed' })],
|
|
71
|
+
subject: SUBJECT,
|
|
72
|
+
})
|
|
73
|
+
expect(items.map((item) => (item.kind === 'pending' ? [item.origin, item.displayState] : []))).toEqual([
|
|
74
|
+
['local', 'sending'],
|
|
75
|
+
['local', 'failed'],
|
|
76
|
+
])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('hides a local pending already present on the server or at the host', () => {
|
|
80
|
+
const items = mergeParticipantMessages({
|
|
81
|
+
serverMessages: [server('s1', '2026-10-01T10:00:00.000Z', { clientMessageId: 'a' })],
|
|
82
|
+
hostPending: [host('b', '2026-10-01T10:01:00.000Z')],
|
|
83
|
+
localPending: [local('a', '2026-10-01T09:59:00.000Z'), local('b', '2026-10-01T09:59:30.000Z')],
|
|
84
|
+
subject: SUBJECT,
|
|
85
|
+
})
|
|
86
|
+
expect(items.map((item) => item.kind)).toEqual(['server', 'pending'])
|
|
87
|
+
expect(items[1]).toMatchObject({ kind: 'pending', origin: 'host' })
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('keeps a failed local pending with its original clientMessageId for resend', () => {
|
|
91
|
+
const items = mergeParticipantMessages({
|
|
92
|
+
serverMessages: [],
|
|
93
|
+
hostPending: [],
|
|
94
|
+
localPending: [local('keep-me', '2026-10-01T10:00:00.000Z', { state: 'failed' })],
|
|
95
|
+
subject: SUBJECT,
|
|
96
|
+
})
|
|
97
|
+
expect(items[0]).toMatchObject({ kind: 'pending', pending: { clientMessageId: 'keep-me' } })
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('isOwnMessage', () => {
|
|
102
|
+
it('treats inbound (company perspective) as the participant own message', () => {
|
|
103
|
+
expect(isOwnMessage(server('s', '2026-10-01T10:00:00.000Z', { direction: 'inbound' }))).toBe(true)
|
|
104
|
+
expect(isOwnMessage(server('s', '2026-10-01T10:00:00.000Z', { direction: 'outbound' }))).toBe(false)
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe('resolveOwnMessageStatus', () => {
|
|
109
|
+
it('uses the local display state for pending messages', () => {
|
|
110
|
+
expect(resolveOwnMessageStatus({ displayState: 'sending', confirmsRead: true })).toBe('sending')
|
|
111
|
+
expect(resolveOwnMessageStatus({ displayState: 'queued', confirmsRead: true })).toBe('queued')
|
|
112
|
+
expect(resolveOwnMessageStatus({ displayState: 'failed', confirmsRead: true })).toBe('failed')
|
|
113
|
+
})
|
|
114
|
+
it('defaults to sent without server status', () => {
|
|
115
|
+
expect(resolveOwnMessageStatus({ confirmsRead: true })).toBe('sent')
|
|
116
|
+
})
|
|
117
|
+
it('maps server statuses', () => {
|
|
118
|
+
expect(resolveOwnMessageStatus({ serverStatus: 'queued', confirmsRead: true })).toBe('queued')
|
|
119
|
+
expect(resolveOwnMessageStatus({ serverStatus: 'sent', confirmsRead: true })).toBe('sent')
|
|
120
|
+
expect(resolveOwnMessageStatus({ serverStatus: 'delivered', confirmsRead: true })).toBe('delivered')
|
|
121
|
+
expect(resolveOwnMessageStatus({ serverStatus: 'failed', confirmsRead: true })).toBe('failed')
|
|
122
|
+
expect(resolveOwnMessageStatus({ serverStatus: 'bounced', confirmsRead: true })).toBe('failed')
|
|
123
|
+
})
|
|
124
|
+
it('reports read only when the channel confirms it', () => {
|
|
125
|
+
expect(resolveOwnMessageStatus({ serverStatus: 'read', confirmsRead: true })).toBe('read')
|
|
126
|
+
expect(resolveOwnMessageStatus({ serverStatus: 'read', confirmsRead: false })).toBe('delivered')
|
|
127
|
+
})
|
|
128
|
+
})
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { MessageDeliveryStatus, ParticipantMessage, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
|
|
2
|
+
|
|
3
|
+
import type { ParticipantLocalPendingMessage, ParticipantPendingMessage } from './participantApi.types'
|
|
4
|
+
|
|
5
|
+
export type ParticipantPendingDisplayState = 'sending' | 'queued' | 'failed' | 'sent'
|
|
6
|
+
export type ParticipantOwnMessageStatus = 'sending' | 'queued' | 'sent' | 'delivered' | 'read' | 'failed'
|
|
7
|
+
|
|
8
|
+
export type ParticipantTimelineItem =
|
|
9
|
+
| { readonly kind: 'server'; readonly message: ParticipantMessage }
|
|
10
|
+
| {
|
|
11
|
+
readonly kind: 'pending'
|
|
12
|
+
readonly pending: ParticipantPendingMessage | ParticipantLocalPendingMessage
|
|
13
|
+
readonly origin: 'host' | 'local'
|
|
14
|
+
readonly displayState: ParticipantPendingDisplayState
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type MergeParticipantMessagesParams = {
|
|
18
|
+
readonly serverMessages: readonly ParticipantMessage[]
|
|
19
|
+
readonly hostPending: readonly ParticipantPendingMessage[]
|
|
20
|
+
readonly localPending: readonly ParticipantLocalPendingMessage[]
|
|
21
|
+
readonly subject: ParticipantSubjectRef
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type ResolveOwnMessageStatusParams = {
|
|
25
|
+
readonly displayState?: ParticipantPendingDisplayState
|
|
26
|
+
readonly serverStatus?: MessageDeliveryStatus
|
|
27
|
+
readonly confirmsRead: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isSameSubject(left: ParticipantSubjectRef, right: ParticipantSubjectRef): boolean {
|
|
31
|
+
return left.subjectType === right.subjectType && left.subjectId === right.subjectId
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function createdAtOf(item: ParticipantTimelineItem): number {
|
|
35
|
+
return Date.parse(item.kind === 'server' ? item.message.createdAt : item.pending.createdAt)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function mergeParticipantMessages(params: MergeParticipantMessagesParams): readonly ParticipantTimelineItem[] {
|
|
39
|
+
const { serverMessages, hostPending, localPending, subject } = params
|
|
40
|
+
const knownClientIds = new Set<string>()
|
|
41
|
+
for (const message of serverMessages) {
|
|
42
|
+
if (message.clientMessageId) knownClientIds.add(message.clientMessageId)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const items: ParticipantTimelineItem[] = serverMessages.map((message) => ({ kind: 'server', message }))
|
|
46
|
+
|
|
47
|
+
for (const pending of hostPending) {
|
|
48
|
+
if (!isSameSubject(pending.subject, subject) || knownClientIds.has(pending.clientMessageId)) continue
|
|
49
|
+
knownClientIds.add(pending.clientMessageId)
|
|
50
|
+
items.push({ kind: 'pending', pending, origin: 'host', displayState: pending.state })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
for (const pending of localPending) {
|
|
54
|
+
if (!isSameSubject(pending.subject, subject) || knownClientIds.has(pending.clientMessageId)) continue
|
|
55
|
+
items.push({ kind: 'pending', pending, origin: 'local', displayState: pending.state })
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return items.sort((left, right) => createdAtOf(left) - createdAtOf(right))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Direction is from the company perspective: the participant is the inbound side. */
|
|
62
|
+
export function isOwnMessage(message: ParticipantMessage): boolean {
|
|
63
|
+
return message.direction === 'inbound'
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function resolveOwnMessageStatus(params: ResolveOwnMessageStatusParams): ParticipantOwnMessageStatus {
|
|
67
|
+
const { displayState, serverStatus, confirmsRead } = params
|
|
68
|
+
if (displayState) return displayState
|
|
69
|
+
if (serverStatus === undefined) return 'sent'
|
|
70
|
+
if (serverStatus === 'failed' || serverStatus === 'bounced') return 'failed'
|
|
71
|
+
if (serverStatus === 'read') return confirmsRead ? 'read' : 'delivered'
|
|
72
|
+
return serverStatus
|
|
73
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { buildMessage } from './participantFixtures.test-helper'
|
|
4
|
+
import { collectServerMessageIds, countNewIncomingMessages } from './participantNewMessages'
|
|
5
|
+
import type { ParticipantTimelineItem } from './participantMessages'
|
|
6
|
+
|
|
7
|
+
function server(id: string, direction: 'inbound' | 'outbound', createdAt = '2026-10-01T10:00:00.000Z'): ParticipantTimelineItem {
|
|
8
|
+
return { kind: 'server', message: buildMessage({ id, direction, createdAt }) }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('countNewIncomingMessages', () => {
|
|
12
|
+
it('counts only unseen messages from the other side (outbound)', () => {
|
|
13
|
+
const items = [
|
|
14
|
+
server('a', 'outbound', '2026-10-01T10:00:00.000Z'),
|
|
15
|
+
server('b', 'outbound', '2026-10-01T10:01:00.000Z'),
|
|
16
|
+
server('c', 'inbound', '2026-10-01T10:02:00.000Z'),
|
|
17
|
+
server('d', 'outbound', '2026-10-01T10:03:00.000Z'),
|
|
18
|
+
]
|
|
19
|
+
expect(countNewIncomingMessages(new Set(['a']), items)).toBe(2)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('does not count outbound history loaded above the messages already seen', () => {
|
|
23
|
+
const seen = [server('m30', 'outbound', '2026-10-01T10:30:00.000Z'), server('m31', 'inbound', '2026-10-01T10:31:00.000Z')]
|
|
24
|
+
const older = [server('m1', 'outbound', '2026-10-01T10:01:00.000Z'), server('m2', 'outbound', '2026-10-01T10:02:00.000Z')]
|
|
25
|
+
expect(countNewIncomingMessages(collectServerMessageIds(seen), [...older, ...seen])).toBe(0)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('counts a reply that arrives after the messages already seen', () => {
|
|
29
|
+
const seen = [server('m30', 'outbound', '2026-10-01T10:30:00.000Z'), server('m31', 'inbound', '2026-10-01T10:31:00.000Z')]
|
|
30
|
+
const reply = server('m32', 'outbound', '2026-10-01T10:32:00.000Z')
|
|
31
|
+
expect(countNewIncomingMessages(collectServerMessageIds(seen), [...seen, reply])).toBe(1)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('is zero when everything was seen', () => {
|
|
35
|
+
const items = [server('a', 'outbound')]
|
|
36
|
+
expect(countNewIncomingMessages(collectServerMessageIds(items), items)).toBe(0)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('ignores the pending messages of the participant', () => {
|
|
40
|
+
const pending: ParticipantTimelineItem = {
|
|
41
|
+
kind: 'pending',
|
|
42
|
+
origin: 'local',
|
|
43
|
+
displayState: 'sending',
|
|
44
|
+
pending: { clientMessageId: 'x', subject: { subjectType: 'invoice', subjectId: '1' }, createdAt: '2026-10-01T10:00:00.000Z', state: 'sending', text: 'hi' },
|
|
45
|
+
}
|
|
46
|
+
expect(countNewIncomingMessages(new Set(), [pending])).toBe(0)
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('collectServerMessageIds', () => {
|
|
51
|
+
it('collects the ids of server items only', () => {
|
|
52
|
+
expect([...collectServerMessageIds([server('a', 'inbound'), server('b', 'outbound')])]).toEqual(['a', 'b'])
|
|
53
|
+
})
|
|
54
|
+
})
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ParticipantTimelineItem } from './participantMessages'
|
|
2
|
+
|
|
3
|
+
export function collectServerMessageIds(items: readonly ParticipantTimelineItem[]): ReadonlySet<string> {
|
|
4
|
+
const ids = new Set<string>()
|
|
5
|
+
for (const item of items) {
|
|
6
|
+
if (item.kind === 'server') ids.add(item.message.id)
|
|
7
|
+
}
|
|
8
|
+
return ids
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function latestSeenTime(previousIds: ReadonlySet<string>, items: readonly ParticipantTimelineItem[]): number {
|
|
12
|
+
let latest = Number.NEGATIVE_INFINITY
|
|
13
|
+
for (const item of items) {
|
|
14
|
+
if (item.kind === 'server' && previousIds.has(item.message.id)) latest = Math.max(latest, Date.parse(item.message.createdAt))
|
|
15
|
+
}
|
|
16
|
+
return latest
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Messages from the other side ('outbound') newer than what the participant has seen; older history loaded above is not news. */
|
|
20
|
+
export function countNewIncomingMessages(previousIds: ReadonlySet<string>, currentItems: readonly ParticipantTimelineItem[]): number {
|
|
21
|
+
const latestSeen = latestSeenTime(previousIds, currentItems)
|
|
22
|
+
return currentItems.filter(
|
|
23
|
+
(item) =>
|
|
24
|
+
item.kind === 'server' &&
|
|
25
|
+
item.message.direction === 'outbound' &&
|
|
26
|
+
!previousIds.has(item.message.id) &&
|
|
27
|
+
Date.parse(item.message.createdAt) > latestSeen,
|
|
28
|
+
).length
|
|
29
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { buildConversation } from './participantFixtures.test-helper'
|
|
4
|
+
import {
|
|
5
|
+
PROTOCOL_SEARCH_CONVERSATION_THRESHOLD,
|
|
6
|
+
filterConversationsBySearch,
|
|
7
|
+
isInboxSearchVisible,
|
|
8
|
+
matchesProtocolQuery,
|
|
9
|
+
normalizeProtocolQuery,
|
|
10
|
+
shouldShowInboxSearch,
|
|
11
|
+
} from './participantProtocol'
|
|
12
|
+
|
|
13
|
+
describe('normalizeProtocolQuery', () => {
|
|
14
|
+
it('uppercases and drops dashes and spaces', () => {
|
|
15
|
+
expect(normalizeProtocolQuery(' 261009-k7 m2 ')).toBe('261009K7M2')
|
|
16
|
+
})
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
describe('matchesProtocolQuery', () => {
|
|
20
|
+
it('matches partially, without dash and without case', () => {
|
|
21
|
+
expect(matchesProtocolQuery('261009-K7M2', '261009k7')).toBe(true)
|
|
22
|
+
expect(matchesProtocolQuery('261009-K7M2', 'k7m2')).toBe(true)
|
|
23
|
+
expect(matchesProtocolQuery('261009-K7M2', '9-k')).toBe(true)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('does not match a different code', () => {
|
|
27
|
+
expect(matchesProtocolQuery('261009-K7M2', 'ZZZ')).toBe(false)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('an empty query matches everything, even a missing protocol', () => {
|
|
31
|
+
expect(matchesProtocolQuery(undefined, '')).toBe(true)
|
|
32
|
+
expect(matchesProtocolQuery('261009-K7M2', ' ')).toBe(true)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('a missing protocol never matches a real query', () => {
|
|
36
|
+
expect(matchesProtocolQuery(undefined, 'K7')).toBe(false)
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('filterConversationsBySearch', () => {
|
|
41
|
+
const conversations = [
|
|
42
|
+
buildConversation({ subjectId: '1', subjectLabel: 'Damaged item', protocol: '261009-K7M2' }),
|
|
43
|
+
buildConversation({ subjectId: '2', subjectLabel: 'Late item' }),
|
|
44
|
+
buildConversation({ subjectId: '3', subjectLabel: 'Other topic', protocol: '261008-AB23' }),
|
|
45
|
+
]
|
|
46
|
+
const ids = (query: string) => filterConversationsBySearch(conversations, query).map((item) => item.subjectId)
|
|
47
|
+
|
|
48
|
+
it('returns everything for an empty query', () => {
|
|
49
|
+
expect(ids('')).toEqual(['1', '2', '3'])
|
|
50
|
+
expect(ids(' ')).toEqual(['1', '2', '3'])
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('matches the title without accent or case', () => {
|
|
54
|
+
expect(ids('DAMAGED')).toEqual(['1'])
|
|
55
|
+
expect(ids('látè')).toEqual(['2'])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('matches the protocol partially, without dash and case', () => {
|
|
59
|
+
expect(ids('k7m')).toEqual(['1'])
|
|
60
|
+
expect(ids('261008')).toEqual(['3'])
|
|
61
|
+
expect(ids('2610')).toEqual(['1', '3'])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('a query of only separators does not match every protocol', () => {
|
|
65
|
+
expect(ids('-')).toEqual([])
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('returns nothing when neither title nor protocol match', () => {
|
|
69
|
+
expect(ids('zzz')).toEqual([])
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('shouldShowInboxSearch', () => {
|
|
74
|
+
it('shows when some conversation has a protocol', () => {
|
|
75
|
+
expect(shouldShowInboxSearch([buildConversation({ subjectId: '1', protocol: '261009-K7M2' })])).toBe(true)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('shows beyond the threshold even without protocols', () => {
|
|
79
|
+
const many = Array.from({ length: PROTOCOL_SEARCH_CONVERSATION_THRESHOLD + 1 }, (_, index) =>
|
|
80
|
+
buildConversation({ subjectId: String(index) }),
|
|
81
|
+
)
|
|
82
|
+
expect(shouldShowInboxSearch(many)).toBe(true)
|
|
83
|
+
expect(shouldShowInboxSearch(many.slice(0, PROTOCOL_SEARCH_CONVERSATION_THRESHOLD))).toBe(false)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('hides for a short list without protocols', () => {
|
|
87
|
+
expect(shouldShowInboxSearch([buildConversation({ subjectId: '1' })])).toBe(false)
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('isInboxSearchVisible', () => {
|
|
92
|
+
const short = [buildConversation({ subjectId: '1' })]
|
|
93
|
+
const withProtocol = [buildConversation({ subjectId: '1', protocol: '261009-K7M2' })]
|
|
94
|
+
|
|
95
|
+
it('follows the threshold rule when there is no query', () => {
|
|
96
|
+
expect(isInboxSearchVisible({ conversations: short, searchQuery: '' })).toBe(false)
|
|
97
|
+
expect(isInboxSearchVisible({ conversations: withProtocol, searchQuery: '' })).toBe(true)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('stays visible while a query is applied, even if a refresh shrank the list', () => {
|
|
101
|
+
expect(isInboxSearchVisible({ conversations: short, searchQuery: 'k7' })).toBe(true)
|
|
102
|
+
expect(isInboxSearchVisible({ conversations: [], searchQuery: 'k7' })).toBe(true)
|
|
103
|
+
})
|
|
104
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ParticipantConversationSummary } from '@adatechnology/conversation-contracts'
|
|
2
|
+
|
|
3
|
+
/** Search appears when the list is long even if no conversation carries a protocol. */
|
|
4
|
+
export const PROTOCOL_SEARCH_CONVERSATION_THRESHOLD = 8
|
|
5
|
+
|
|
6
|
+
export function normalizeProtocolQuery(text: string): string {
|
|
7
|
+
return text.replace(/[-\s]/g, '').toUpperCase()
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function matchesProtocolQuery(protocol: string | undefined, query: string): boolean {
|
|
11
|
+
const normalizedQuery = normalizeProtocolQuery(query)
|
|
12
|
+
if (normalizedQuery === '') return true
|
|
13
|
+
if (protocol === undefined) return false
|
|
14
|
+
return normalizeProtocolQuery(protocol).includes(normalizedQuery)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function foldText(text: string): string {
|
|
18
|
+
return text.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function matchesTitle(subjectLabel: string, query: string): boolean {
|
|
22
|
+
return foldText(subjectLabel).includes(foldText(query))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function filterConversationsBySearch(
|
|
26
|
+
conversations: readonly ParticipantConversationSummary[],
|
|
27
|
+
query: string,
|
|
28
|
+
): readonly ParticipantConversationSummary[] {
|
|
29
|
+
const trimmedQuery = query.trim()
|
|
30
|
+
if (trimmedQuery === '') return conversations
|
|
31
|
+
const hasProtocolTerm = normalizeProtocolQuery(trimmedQuery) !== ''
|
|
32
|
+
return conversations.filter(
|
|
33
|
+
(conversation) =>
|
|
34
|
+
matchesTitle(conversation.subjectLabel, trimmedQuery) ||
|
|
35
|
+
(hasProtocolTerm && matchesProtocolQuery(conversation.protocol, trimmedQuery)),
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type InboxSearchVisibilityParams = {
|
|
40
|
+
readonly conversations: readonly ParticipantConversationSummary[]
|
|
41
|
+
readonly searchQuery: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** An applied query keeps the field, so a refresh that shrinks the list cannot strand it. */
|
|
45
|
+
export function isInboxSearchVisible({ conversations, searchQuery }: InboxSearchVisibilityParams): boolean {
|
|
46
|
+
return searchQuery !== '' || shouldShowInboxSearch(conversations)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function shouldShowInboxSearch(conversations: readonly ParticipantConversationSummary[]): boolean {
|
|
50
|
+
if (conversations.length > PROTOCOL_SEARCH_CONVERSATION_THRESHOLD) return true
|
|
51
|
+
return conversations.some((conversation) => conversation.protocol !== undefined)
|
|
52
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { INITIAL_PROTOCOL_COPY_STATE, copyProtocolToClipboard, protocolCopyReducer } from './participantProtocolCopy'
|
|
4
|
+
|
|
5
|
+
describe('copyProtocolToClipboard', () => {
|
|
6
|
+
it('writes the protocol and reports success', async () => {
|
|
7
|
+
const written: string[] = []
|
|
8
|
+
const clipboard = { writeText: async (text: string) => void written.push(text) }
|
|
9
|
+
expect(await copyProtocolToClipboard('261009-K7M2', clipboard)).toBe(true)
|
|
10
|
+
expect(written).toEqual(['261009-K7M2'])
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('reports failure silently when the clipboard rejects', async () => {
|
|
14
|
+
const clipboard = { writeText: async () => Promise.reject(new Error('denied')) }
|
|
15
|
+
expect(await copyProtocolToClipboard('261009-K7M2', clipboard)).toBe(false)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('reports failure when there is no clipboard', async () => {
|
|
19
|
+
expect(await copyProtocolToClipboard('261009-K7M2', undefined)).toBe(false)
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe('protocolCopyReducer', () => {
|
|
24
|
+
it('starts not copied', () => {
|
|
25
|
+
expect(INITIAL_PROTOCOL_COPY_STATE).toEqual({ copyCount: 0, isCopied: false })
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('a copy marks it copied and counts it', () => {
|
|
29
|
+
expect(protocolCopyReducer(INITIAL_PROTOCOL_COPY_STATE, { type: 'copied' })).toEqual({
|
|
30
|
+
copyCount: 1,
|
|
31
|
+
isCopied: true,
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('a second copy while still copied bumps the counter so the timer restarts and the notice is announced again', () => {
|
|
36
|
+
const first = protocolCopyReducer(INITIAL_PROTOCOL_COPY_STATE, { type: 'copied' })
|
|
37
|
+
const second = protocolCopyReducer(first, { type: 'copied' })
|
|
38
|
+
expect(second).toEqual({ copyCount: 2, isCopied: true })
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('expiry clears the notice and keeps the count', () => {
|
|
42
|
+
const copied = protocolCopyReducer(INITIAL_PROTOCOL_COPY_STATE, { type: 'copied' })
|
|
43
|
+
expect(protocolCopyReducer(copied, { type: 'expired' })).toEqual({ copyCount: 1, isCopied: false })
|
|
44
|
+
})
|
|
45
|
+
})
|