@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.
Files changed (110) hide show
  1. package/README.md +231 -0
  2. package/dist/{ConversationSimulatorPanel-DnXElfY8.d.ts → ConversationSimulatorPanel-CrfFjiWh.d.ts} +2 -268
  3. package/dist/{chunk-PJNR6UMN.js → chunk-CBYEHPX6.js} +1 -63
  4. package/dist/{chunk-4P5KMWOI.js → chunk-FAQLC3TX.js} +327 -522
  5. package/dist/{chunk-HMCOTZAX.js → chunk-HOKNKXIT.js} +1 -1
  6. package/dist/chunk-MRVFFJEH.js +64 -0
  7. package/dist/chunk-O6457XHK.js +255 -0
  8. package/dist/chunk-OVFCLRQQ.js +84 -0
  9. package/dist/flows/index.js +5 -3
  10. package/dist/index.d.ts +21 -5
  11. package/dist/index.js +783 -807
  12. package/dist/participant/index.d.ts +186 -0
  13. package/dist/participant/index.js +1708 -0
  14. package/dist/preview/index.d.ts +4 -2
  15. package/dist/preview/index.js +4 -2
  16. package/dist/quickReply.types-B4gmZsSr.d.ts +274 -0
  17. package/dist/styles.css +689 -0
  18. package/package.json +8 -4
  19. package/src/ChannelIcon.tsx +2 -0
  20. package/src/MessageText.test.tsx +76 -0
  21. package/src/MessageText.tsx +28 -12
  22. package/src/MessageTextCopiedBadge.tsx +15 -0
  23. package/src/StatusTicks.test.tsx +51 -0
  24. package/src/StatusTicks.tsx +36 -6
  25. package/src/buildOutput.test.ts +41 -0
  26. package/src/channelCapability.test.ts +17 -0
  27. package/src/conversationChannel.test.ts +30 -1
  28. package/src/conversationChannel.ts +19 -1
  29. package/src/legacyChannels.golden.test.ts +89 -0
  30. package/src/legacyDefaults.golden.test.tsx +127 -0
  31. package/src/legacyStyles.fixture.css +1121 -0
  32. package/src/legacyStyles.golden.test.ts +121 -0
  33. package/src/messageTextCopyInteraction.test.ts +75 -0
  34. package/src/messageTextCopyInteraction.ts +37 -0
  35. package/src/participant/ParticipantAttachmentItem.tsx +77 -0
  36. package/src/participant/ParticipantChannelBadges.test.tsx +59 -0
  37. package/src/participant/ParticipantChannelBadges.tsx +75 -0
  38. package/src/participant/ParticipantComposer.test.tsx +97 -0
  39. package/src/participant/ParticipantComposer.tsx +167 -0
  40. package/src/participant/ParticipantConversations.test.tsx +90 -0
  41. package/src/participant/ParticipantConversations.tsx +49 -0
  42. package/src/participant/ParticipantConversationsScreen.tsx +78 -0
  43. package/src/participant/ParticipantInbox.test.tsx +244 -0
  44. package/src/participant/ParticipantInbox.tsx +149 -0
  45. package/src/participant/ParticipantInboxRow.tsx +75 -0
  46. package/src/participant/ParticipantInboxSearchField.tsx +34 -0
  47. package/src/participant/ParticipantLoadState.tsx +27 -0
  48. package/src/participant/ParticipantMessageBubble.test.tsx +167 -0
  49. package/src/participant/ParticipantMessageBubble.tsx +185 -0
  50. package/src/participant/ParticipantProtocolBadge.tsx +74 -0
  51. package/src/participant/ParticipantProtocolView.test.tsx +208 -0
  52. package/src/participant/ParticipantThread.test.tsx +149 -0
  53. package/src/participant/ParticipantThread.tsx +184 -0
  54. package/src/participant/ParticipantThreadScreen.tsx +135 -0
  55. package/src/participant/bindAttachmentUrlResolver.test.ts +25 -0
  56. package/src/participant/bindAttachmentUrlResolver.ts +7 -0
  57. package/src/participant/index.ts +26 -0
  58. package/src/participant/participant.types.ts +52 -0
  59. package/src/participant/participantApi.types.ts +59 -0
  60. package/src/participant/participantAttachmentUrl.test.ts +32 -0
  61. package/src/participant/participantAttachmentUrl.ts +10 -0
  62. package/src/participant/participantBubbleActions.test.ts +44 -0
  63. package/src/participant/participantBubbleActions.ts +40 -0
  64. package/src/participant/participantChannels.test.ts +42 -0
  65. package/src/participant/participantChannels.ts +33 -0
  66. package/src/participant/participantDrafts.test.ts +65 -0
  67. package/src/participant/participantDrafts.ts +50 -0
  68. package/src/participant/participantFileValidation.test.ts +86 -0
  69. package/src/participant/participantFileValidation.ts +72 -0
  70. package/src/participant/participantFixtures.test-helper.ts +41 -0
  71. package/src/participant/participantGrouping.test.ts +236 -0
  72. package/src/participant/participantGrouping.ts +90 -0
  73. package/src/participant/participantInboxState.test.ts +69 -0
  74. package/src/participant/participantInboxState.ts +74 -0
  75. package/src/participant/participantLabels.ts +115 -0
  76. package/src/participant/participantLoadView.test.ts +23 -0
  77. package/src/participant/participantLoadView.ts +21 -0
  78. package/src/participant/participantMessages.test.ts +128 -0
  79. package/src/participant/participantMessages.ts +73 -0
  80. package/src/participant/participantNewMessages.test.ts +54 -0
  81. package/src/participant/participantNewMessages.ts +29 -0
  82. package/src/participant/participantProtocol.test.ts +104 -0
  83. package/src/participant/participantProtocol.ts +52 -0
  84. package/src/participant/participantProtocolCopy.test.ts +45 -0
  85. package/src/participant/participantProtocolCopy.ts +32 -0
  86. package/src/participant/participantQuickReply.test.ts +23 -0
  87. package/src/participant/participantQuickReply.ts +6 -0
  88. package/src/participant/participantScroll.test.ts +69 -0
  89. package/src/participant/participantScroll.ts +43 -0
  90. package/src/participant/participantSendController.test.ts +201 -0
  91. package/src/participant/participantSendController.ts +134 -0
  92. package/src/participant/participantSendStates.ts +31 -0
  93. package/src/participant/participantTheme.ts +20 -0
  94. package/src/participant/participantThreadState.test.ts +75 -0
  95. package/src/participant/participantThreadState.ts +59 -0
  96. package/src/participant/shouldMarkRead.test.ts +24 -0
  97. package/src/participant/shouldMarkRead.ts +16 -0
  98. package/src/participant/useDocumentVisibility.ts +18 -0
  99. package/src/participant/useParticipantConversationView.ts +60 -0
  100. package/src/participant/useParticipantDraft.ts +45 -0
  101. package/src/participant/useParticipantInbox.ts +57 -0
  102. package/src/participant/useParticipantMarkRead.ts +26 -0
  103. package/src/participant/useParticipantRevalidation.ts +36 -0
  104. package/src/participant/useParticipantSend.ts +84 -0
  105. package/src/participant/useParticipantThread.ts +116 -0
  106. package/src/participant/useParticipantThreadScroll.ts +26 -0
  107. package/src/participant/useStickToBottom.ts +77 -0
  108. package/src/preview/ConversationSimulatorPanel.tsx +8 -0
  109. package/src/styles.css +178 -0
  110. 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
+ })