@adatechnology/conversations-ui 0.4.1 → 0.5.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 (93) hide show
  1. package/README.md +169 -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-HMCOTZAX.js → chunk-HOKNKXIT.js} +1 -1
  5. package/dist/{chunk-4P5KMWOI.js → chunk-JXJCPN34.js} +327 -522
  6. package/dist/chunk-MRVFFJEH.js +64 -0
  7. package/dist/chunk-RJMSM3YA.js +249 -0
  8. package/dist/chunk-WSFGNR5R.js +55 -0
  9. package/dist/flows/index.js +5 -3
  10. package/dist/index.d.ts +9 -3
  11. package/dist/index.js +783 -807
  12. package/dist/participant/index.d.ts +162 -0
  13. package/dist/participant/index.js +1448 -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 +591 -0
  18. package/package.json +9 -5
  19. package/src/ChannelIcon.tsx +2 -0
  20. package/src/MessageBubble.test.tsx +2 -2
  21. package/src/MessageText.test.tsx +59 -0
  22. package/src/MessageText.tsx +20 -12
  23. package/src/MessageTextCopiedBadge.tsx +7 -0
  24. package/src/StatusTicks.test.tsx +30 -0
  25. package/src/StatusTicks.tsx +24 -9
  26. package/src/buildOutput.test.ts +41 -0
  27. package/src/channelCapability.test.ts +17 -0
  28. package/src/conversationChannel.test.ts +30 -1
  29. package/src/conversationChannel.ts +19 -1
  30. package/src/participant/ParticipantAttachmentItem.tsx +77 -0
  31. package/src/participant/ParticipantComposer.test.tsx +97 -0
  32. package/src/participant/ParticipantComposer.tsx +167 -0
  33. package/src/participant/ParticipantConversations.test.tsx +90 -0
  34. package/src/participant/ParticipantConversations.tsx +42 -0
  35. package/src/participant/ParticipantConversationsScreen.tsx +67 -0
  36. package/src/participant/ParticipantInbox.test.tsx +147 -0
  37. package/src/participant/ParticipantInbox.tsx +168 -0
  38. package/src/participant/ParticipantLoadState.tsx +27 -0
  39. package/src/participant/ParticipantMessageBubble.test.tsx +165 -0
  40. package/src/participant/ParticipantMessageBubble.tsx +185 -0
  41. package/src/participant/ParticipantThread.test.tsx +132 -0
  42. package/src/participant/ParticipantThread.tsx +174 -0
  43. package/src/participant/ParticipantThreadScreen.tsx +135 -0
  44. package/src/participant/bindAttachmentUrlResolver.test.ts +25 -0
  45. package/src/participant/bindAttachmentUrlResolver.ts +7 -0
  46. package/src/participant/index.ts +25 -0
  47. package/src/participant/participant.types.ts +43 -0
  48. package/src/participant/participantApi.types.ts +59 -0
  49. package/src/participant/participantAttachmentUrl.test.ts +32 -0
  50. package/src/participant/participantAttachmentUrl.ts +10 -0
  51. package/src/participant/participantBubbleActions.test.ts +44 -0
  52. package/src/participant/participantBubbleActions.ts +40 -0
  53. package/src/participant/participantDrafts.test.ts +65 -0
  54. package/src/participant/participantDrafts.ts +50 -0
  55. package/src/participant/participantFileValidation.test.ts +86 -0
  56. package/src/participant/participantFileValidation.ts +72 -0
  57. package/src/participant/participantFixtures.test-helper.ts +41 -0
  58. package/src/participant/participantGrouping.test.ts +138 -0
  59. package/src/participant/participantGrouping.ts +77 -0
  60. package/src/participant/participantInboxState.test.ts +69 -0
  61. package/src/participant/participantInboxState.ts +74 -0
  62. package/src/participant/participantLabels.ts +86 -0
  63. package/src/participant/participantLoadView.test.ts +23 -0
  64. package/src/participant/participantLoadView.ts +21 -0
  65. package/src/participant/participantMessages.test.ts +128 -0
  66. package/src/participant/participantMessages.ts +73 -0
  67. package/src/participant/participantNewMessages.test.ts +54 -0
  68. package/src/participant/participantNewMessages.ts +29 -0
  69. package/src/participant/participantQuickReply.test.ts +23 -0
  70. package/src/participant/participantQuickReply.ts +6 -0
  71. package/src/participant/participantScroll.test.ts +69 -0
  72. package/src/participant/participantScroll.ts +43 -0
  73. package/src/participant/participantSendController.test.ts +201 -0
  74. package/src/participant/participantSendController.ts +134 -0
  75. package/src/participant/participantSendStates.ts +31 -0
  76. package/src/participant/participantTheme.ts +20 -0
  77. package/src/participant/participantThreadState.test.ts +75 -0
  78. package/src/participant/participantThreadState.ts +59 -0
  79. package/src/participant/shouldMarkRead.test.ts +24 -0
  80. package/src/participant/shouldMarkRead.ts +16 -0
  81. package/src/participant/useDocumentVisibility.ts +18 -0
  82. package/src/participant/useParticipantConversationView.ts +60 -0
  83. package/src/participant/useParticipantDraft.ts +45 -0
  84. package/src/participant/useParticipantInbox.ts +57 -0
  85. package/src/participant/useParticipantMarkRead.ts +26 -0
  86. package/src/participant/useParticipantRevalidation.ts +36 -0
  87. package/src/participant/useParticipantSend.ts +84 -0
  88. package/src/participant/useParticipantThread.ts +116 -0
  89. package/src/participant/useParticipantThreadScroll.ts +26 -0
  90. package/src/participant/useStickToBottom.ts +77 -0
  91. package/src/preview/ConversationSimulatorPanel.tsx +8 -0
  92. package/src/styles.css +160 -0
  93. package/src/types.ts +3 -1
@@ -0,0 +1,72 @@
1
+ import { channelCapabilityFor } from '../channelCapability'
2
+ import type { ConversationChannel } from '../conversationChannel'
3
+
4
+ export type ParticipantFileRejection = 'tooLarge' | 'typeNotAccepted'
5
+
6
+ export type ValidateParticipantFilesParams = {
7
+ readonly files: readonly File[]
8
+ readonly maxBytes: number
9
+ /** MIME types or wildcards such as `image/*`. Absent means any type. */
10
+ readonly acceptedTypes?: readonly string[]
11
+ }
12
+
13
+ export type ValidateParticipantFilesResult = {
14
+ readonly accepted: readonly File[]
15
+ readonly rejection?: ParticipantFileRejection
16
+ }
17
+
18
+ function matchesAcceptedType(mimeType: string, acceptedTypes: readonly string[]): boolean {
19
+ return acceptedTypes.some((accepted) =>
20
+ accepted.endsWith('/*') ? mimeType.startsWith(accepted.slice(0, -1)) : mimeType === accepted,
21
+ )
22
+ }
23
+
24
+ function rejectionOf(file: File, params: Omit<ValidateParticipantFilesParams, 'files'>): ParticipantFileRejection | undefined {
25
+ if (params.acceptedTypes && !matchesAcceptedType(file.type, params.acceptedTypes)) return 'typeNotAccepted'
26
+ if (file.size > params.maxBytes) return 'tooLarge'
27
+ return undefined
28
+ }
29
+
30
+ export function validateParticipantFiles(params: ValidateParticipantFilesParams): ValidateParticipantFilesResult {
31
+ const accepted: File[] = []
32
+ let rejection: ParticipantFileRejection | undefined
33
+ for (const file of params.files) {
34
+ const reason = rejectionOf(file, params)
35
+ if (reason === undefined) accepted.push(file)
36
+ else rejection = rejection ?? reason
37
+ }
38
+ return rejection === undefined ? { accepted } : { accepted, rejection }
39
+ }
40
+
41
+ export type RejectedParticipantFile = {
42
+ readonly file: File
43
+ readonly reason: ParticipantFileRejection
44
+ }
45
+
46
+ export type ReduceFilesSelectionParams = {
47
+ readonly current: readonly File[]
48
+ readonly incoming: readonly File[]
49
+ readonly maxBytes: number
50
+ readonly acceptedTypes?: readonly string[]
51
+ }
52
+
53
+ export type ReduceFilesSelectionResult = {
54
+ readonly files: readonly File[]
55
+ readonly rejected: readonly RejectedParticipantFile[]
56
+ }
57
+
58
+ /** A refusal never drops what was already chosen: valid files are kept, refused ones are reported. */
59
+ export function reduceFilesSelection(params: ReduceFilesSelectionParams): ReduceFilesSelectionResult {
60
+ const files = [...params.current]
61
+ const rejected: RejectedParticipantFile[] = []
62
+ for (const file of params.incoming) {
63
+ const reason = rejectionOf(file, params)
64
+ if (reason === undefined) files.push(file)
65
+ else rejected.push({ file, reason })
66
+ }
67
+ return { files, rejected }
68
+ }
69
+
70
+ export function maxBytesForChannel(channel: ConversationChannel = 'app'): number {
71
+ return channelCapabilityFor(channel).attachments.maxBytes
72
+ }
@@ -0,0 +1,41 @@
1
+ import type { ParticipantConversationSummary, ParticipantMessage } from '@adatechnology/conversation-contracts'
2
+
3
+ export function buildConversation(
4
+ overrides: Partial<ParticipantConversationSummary> & { subjectId: string },
5
+ ): ParticipantConversationSummary {
6
+ return {
7
+ subjectType: 'invoice',
8
+ subjectLabel: 'Invoice 4521',
9
+ lastMessageAt: '2026-10-01T10:00:00.000Z',
10
+ unreadCount: 0,
11
+ awaitingParticipant: false,
12
+ status: 'open',
13
+ ...overrides,
14
+ }
15
+ }
16
+
17
+ export function buildMessage(overrides: Partial<ParticipantMessage> & { id: string }): ParticipantMessage {
18
+ return {
19
+ direction: 'outbound',
20
+ text: 'Hello',
21
+ attachments: [],
22
+ createdAt: '2026-10-01T10:00:00.000Z',
23
+ ...overrides,
24
+ }
25
+ }
26
+
27
+ const UTILITY_PREFIXES = /^(flex|grid|block|inline|hidden|p[xytblr]?-|m[xytblr]?-|text-|bg-|border|rounded|w-|h-|min-|max-|items-|justify-|gap-|space-|font-|shadow|dark:|hover:|sm:|md:)/
28
+
29
+ /** Returns the class tokens in the markup that look like Tailwind utilities. */
30
+ export function findUtilityClassTokens(markup: string): string[] {
31
+ const classAttributes = [...markup.matchAll(/class="([^"]*)"/g)].map((match) => match[1] ?? '')
32
+ const tokens = classAttributes.flatMap((value) => value.split(/\s+/).filter(Boolean))
33
+ return tokens.filter((token) => UTILITY_PREFIXES.test(token))
34
+ }
35
+
36
+ /** Returns class tokens that do not belong to the participant namespace or the reused leaf components. */
37
+ export function findForeignClassTokens(markup: string): string[] {
38
+ const classAttributes = [...markup.matchAll(/class="([^"]*)"/g)].map((match) => match[1] ?? '')
39
+ const tokens = classAttributes.flatMap((value) => value.split(/\s+/).filter(Boolean))
40
+ return tokens.filter((token) => !/^(cv-p$|cv-p-|cv-status-ticks|cv-message-text|lucide)/.test(token))
41
+ }
@@ -0,0 +1,138 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import type { ParticipantConversationSummary } from '@adatechnology/conversation-contracts'
4
+
5
+ import { groupParticipantConversations } from './participantGrouping'
6
+
7
+ function build(overrides: Partial<ParticipantConversationSummary> & { subjectId: string }): ParticipantConversationSummary {
8
+ return {
9
+ subjectType: 'invoice',
10
+ subjectLabel: 'Label',
11
+ lastMessageAt: '2026-10-01T10:00:00.000Z',
12
+ unreadCount: 0,
13
+ awaitingParticipant: false,
14
+ status: 'open',
15
+ ...overrides,
16
+ }
17
+ }
18
+
19
+ const GROUPS = [
20
+ { subjectType: 'zeta', label: 'Zeta' },
21
+ { subjectType: 'alpha', label: 'Alpha' },
22
+ ] as const
23
+
24
+ function keysOf(view: ReturnType<typeof groupParticipantConversations>): string[] {
25
+ return view.sections.map((section) => section.key)
26
+ }
27
+
28
+ describe('groupParticipantConversations', () => {
29
+ it('applies precedence closed > awaiting > group > other', () => {
30
+ const view = groupParticipantConversations({
31
+ subjectGroups: GROUPS,
32
+ conversations: [
33
+ build({ subjectId: '1', subjectType: 'zeta', status: 'closed', awaitingParticipant: true }),
34
+ build({ subjectId: '2', subjectType: 'zeta', awaitingParticipant: true }),
35
+ build({ subjectId: '3', subjectType: 'zeta' }),
36
+ build({ subjectId: '4', subjectType: 'unknown' }),
37
+ ],
38
+ })
39
+ expect(keysOf(view)).toEqual(['awaiting', 'zeta', 'other', 'closed'])
40
+ expect(view.sections.map((section) => section.conversations.map((c) => c.subjectId))).toEqual([
41
+ ['2'],
42
+ ['3'],
43
+ ['4'],
44
+ ['1'],
45
+ ])
46
+ })
47
+
48
+ it('orders group sections by the subjectGroups array, not alphabetically, skipping empty groups', () => {
49
+ const view = groupParticipantConversations({
50
+ subjectGroups: [...GROUPS, { subjectType: 'empty', label: 'Empty' }],
51
+ conversations: [build({ subjectId: '1', subjectType: 'alpha' }), build({ subjectId: '2', subjectType: 'zeta' })],
52
+ })
53
+ expect(keysOf(view)).toEqual(['zeta', 'alpha'])
54
+ })
55
+
56
+ it('sorts by lastMessageAt descending, null last, ties by subjectLabel', () => {
57
+ const view = groupParticipantConversations({
58
+ subjectGroups: GROUPS,
59
+ conversations: [
60
+ build({ subjectId: 'a', subjectType: 'zeta', lastMessageAt: null, subjectLabel: 'A' }),
61
+ build({ subjectId: 'b', subjectType: 'zeta', lastMessageAt: '2026-10-01T10:00:00.000Z', subjectLabel: 'Beta' }),
62
+ build({ subjectId: 'c', subjectType: 'zeta', lastMessageAt: '2026-10-02T10:00:00.000Z', subjectLabel: 'Z' }),
63
+ build({ subjectId: 'd', subjectType: 'zeta', lastMessageAt: '2026-10-01T10:00:00.000Z', subjectLabel: 'Alpha' }),
64
+ ],
65
+ })
66
+ expect(view.sections[0]?.conversations.map((c) => c.subjectId)).toEqual(['c', 'd', 'b', 'a'])
67
+ })
68
+
69
+ it('sums unread per section and per filter', () => {
70
+ const view = groupParticipantConversations({
71
+ subjectGroups: GROUPS,
72
+ conversations: [
73
+ build({ subjectId: '1', subjectType: 'zeta', unreadCount: 2 }),
74
+ build({ subjectId: '2', subjectType: 'zeta', unreadCount: 3, awaitingParticipant: true }),
75
+ build({ subjectId: '3', subjectType: 'alpha', unreadCount: 4 }),
76
+ ],
77
+ })
78
+ expect(view.sections.map((section) => [section.key, section.unreadCount])).toEqual([
79
+ ['awaiting', 3],
80
+ ['zeta', 2],
81
+ ['alpha', 4],
82
+ ])
83
+ expect(view.filters.map((filter) => [filter.subjectType, filter.unreadCount])).toEqual([
84
+ ['all', 9],
85
+ ['zeta', 5],
86
+ ['alpha', 4],
87
+ ])
88
+ })
89
+
90
+ it('places every conversation in exactly one section', () => {
91
+ const conversations = [
92
+ build({ subjectId: '1', subjectType: 'zeta', status: 'closed' }),
93
+ build({ subjectId: '2', subjectType: 'alpha', awaitingParticipant: true }),
94
+ build({ subjectId: '3', subjectType: 'nope' }),
95
+ build({ subjectId: '4', subjectType: 'alpha' }),
96
+ build({ subjectId: '5', subjectType: 'zeta', lastMessageAt: null }),
97
+ ]
98
+ const view = groupParticipantConversations({ subjectGroups: GROUPS, conversations })
99
+ const total = view.sections.reduce((sum, section) => sum + section.conversations.length, 0)
100
+ expect(total).toBe(conversations.length)
101
+ })
102
+
103
+ it('sends unknown subjectType to other and never drops it', () => {
104
+ const view = groupParticipantConversations({
105
+ subjectGroups: GROUPS,
106
+ conversations: [build({ subjectId: '1', subjectType: 'mystery' })],
107
+ })
108
+ expect(keysOf(view)).toEqual(['other'])
109
+ })
110
+
111
+ it('shows filters only with two or more groups that have conversations', () => {
112
+ const one = groupParticipantConversations({
113
+ subjectGroups: GROUPS,
114
+ conversations: [build({ subjectId: '1', subjectType: 'zeta' })],
115
+ })
116
+ expect(one.showFilters).toBe(false)
117
+ const two = groupParticipantConversations({
118
+ subjectGroups: GROUPS,
119
+ conversations: [build({ subjectId: '1', subjectType: 'zeta' }), build({ subjectId: '2', subjectType: 'alpha' })],
120
+ })
121
+ expect(two.showFilters).toBe(true)
122
+ })
123
+
124
+ it('restricts every section to the filtered subjectType, including awaiting', () => {
125
+ const view = groupParticipantConversations({
126
+ subjectGroups: GROUPS,
127
+ filter: 'alpha',
128
+ conversations: [
129
+ build({ subjectId: '1', subjectType: 'zeta', awaitingParticipant: true }),
130
+ build({ subjectId: '2', subjectType: 'alpha', awaitingParticipant: true }),
131
+ build({ subjectId: '3', subjectType: 'alpha' }),
132
+ build({ subjectId: '4', subjectType: 'zeta' }),
133
+ ],
134
+ })
135
+ expect(view.sections.map((section) => section.conversations.map((c) => c.subjectId))).toEqual([['2'], ['3']])
136
+ expect(groupParticipantConversations({ subjectGroups: GROUPS, filter: 'all', conversations: [build({ subjectId: '1' })] }).sections).toHaveLength(1)
137
+ })
138
+ })
@@ -0,0 +1,77 @@
1
+ import type { ParticipantConversationSummary } from '@adatechnology/conversation-contracts'
2
+
3
+ import type {
4
+ GroupParticipantConversationsParams,
5
+ ParticipantInboxFilter,
6
+ ParticipantInboxSection,
7
+ ParticipantInboxView,
8
+ } from './participant.types'
9
+
10
+ const ALL_FILTER = 'all'
11
+ const SECTION_AWAITING = 'awaiting'
12
+ const SECTION_OTHER = 'other'
13
+ const SECTION_CLOSED = 'closed'
14
+
15
+ function compareConversations(left: ParticipantConversationSummary, right: ParticipantConversationSummary): number {
16
+ if (left.lastMessageAt !== right.lastMessageAt) {
17
+ if (left.lastMessageAt === null) return 1
18
+ if (right.lastMessageAt === null) return -1
19
+ return left.lastMessageAt < right.lastMessageAt ? 1 : -1
20
+ }
21
+ return left.subjectLabel.localeCompare(right.subjectLabel)
22
+ }
23
+
24
+ function sumUnread(conversations: readonly ParticipantConversationSummary[]): number {
25
+ return conversations.reduce((sum, conversation) => sum + conversation.unreadCount, 0)
26
+ }
27
+
28
+ function resolveSectionKey(conversation: ParticipantConversationSummary, knownTypes: ReadonlySet<string>): string {
29
+ if (conversation.status === 'closed') return SECTION_CLOSED
30
+ if (conversation.awaitingParticipant) return SECTION_AWAITING
31
+ return knownTypes.has(conversation.subjectType) ? conversation.subjectType : SECTION_OTHER
32
+ }
33
+
34
+ function buildSection(key: string, conversations: readonly ParticipantConversationSummary[]): ParticipantInboxSection {
35
+ const sorted = [...conversations].sort(compareConversations)
36
+ return { key, conversations: sorted, unreadCount: sumUnread(sorted) }
37
+ }
38
+
39
+ function buildFilters(
40
+ params: GroupParticipantConversationsParams,
41
+ ): Pick<ParticipantInboxView, 'filters' | 'showFilters'> {
42
+ const groupFilters: ParticipantInboxFilter[] = []
43
+ for (const group of params.subjectGroups) {
44
+ const own = params.conversations.filter((conversation) => conversation.subjectType === group.subjectType)
45
+ if (own.length === 0) continue
46
+ groupFilters.push({ subjectType: group.subjectType, label: group.label, unreadCount: sumUnread(own) })
47
+ }
48
+ const all: ParticipantInboxFilter = {
49
+ subjectType: ALL_FILTER,
50
+ label: 'All',
51
+ unreadCount: sumUnread(params.conversations),
52
+ }
53
+ return { filters: [all, ...groupFilters], showFilters: groupFilters.length >= 2 }
54
+ }
55
+
56
+ export function groupParticipantConversations(params: GroupParticipantConversationsParams): ParticipantInboxView {
57
+ const { filter, subjectGroups } = params
58
+ const isFiltered = filter !== undefined && filter !== ALL_FILTER
59
+ const visible = isFiltered
60
+ ? params.conversations.filter((conversation) => conversation.subjectType === filter)
61
+ : params.conversations
62
+ const knownTypes = new Set(subjectGroups.map((group) => group.subjectType))
63
+
64
+ const buckets = new Map<string, ParticipantConversationSummary[]>()
65
+ for (const conversation of visible) {
66
+ const key = resolveSectionKey(conversation, knownTypes)
67
+ buckets.set(key, [...(buckets.get(key) ?? []), conversation])
68
+ }
69
+
70
+ const orderedKeys = [SECTION_AWAITING, ...subjectGroups.map((group) => group.subjectType), SECTION_OTHER, SECTION_CLOSED]
71
+ const sections = orderedKeys.flatMap((key) => {
72
+ const bucket = buckets.get(key)
73
+ return bucket === undefined ? [] : [buildSection(key, bucket)]
74
+ })
75
+
76
+ return { sections, ...buildFilters(params) }
77
+ }
@@ -0,0 +1,69 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import type { ParticipantConversationSummary } from '@adatechnology/conversation-contracts'
4
+
5
+ import { INITIAL_PARTICIPANT_INBOX_STATE, participantInboxReducer } from './participantInboxState'
6
+
7
+ function conversation(subjectId: string, unreadCount = 0, subjectType = 'trip'): ParticipantConversationSummary {
8
+ return { subjectType, subjectId, subjectLabel: subjectId, lastMessageAt: null, unreadCount, awaitingParticipant: false, status: 'open' }
9
+ }
10
+
11
+ describe('participantInboxReducer', () => {
12
+ it('starts idle and goes to loading on started', () => {
13
+ expect(INITIAL_PARTICIPANT_INBOX_STATE.status).toBe('idle')
14
+ expect(participantInboxReducer(INITIAL_PARTICIPANT_INBOX_STATE, { type: 'started' }).status).toBe('loading')
15
+ })
16
+
17
+ it('keeps ready status while revalidating', () => {
18
+ const ready = participantInboxReducer(INITIAL_PARTICIPANT_INBOX_STATE, { type: 'loaded', conversations: [conversation('a')] })
19
+ expect(participantInboxReducer(ready, { type: 'started' }).status).toBe('ready')
20
+ })
21
+
22
+ it('loaded stores the list and the cursor', () => {
23
+ const state = participantInboxReducer(INITIAL_PARTICIPANT_INBOX_STATE, { type: 'loaded', conversations: [conversation('a')], nextCursor: 'c1' })
24
+ expect(state).toMatchObject({ status: 'ready', nextCursor: 'c1' })
25
+ const replaced = participantInboxReducer(state, { type: 'loaded', conversations: [conversation('a')] })
26
+ expect(replaced.conversations.map((item) => item.subjectId)).toEqual(['a'])
27
+ expect(replaced.nextCursor).toBeUndefined()
28
+ })
29
+
30
+ it('loaded (revalidation) keeps the appended pages and lets the newest version of a subject win', () => {
31
+ const first = participantInboxReducer(INITIAL_PARTICIPANT_INBOX_STATE, { type: 'loaded', conversations: [conversation('a'), conversation('b')], nextCursor: 'c1' })
32
+ const paged = participantInboxReducer(first, { type: 'appended', conversations: [conversation('c'), conversation('d')], nextCursor: 'c2' })
33
+ const revalidated = participantInboxReducer(paged, { type: 'loaded', conversations: [conversation('b', 7), conversation('a')], nextCursor: 'c1' })
34
+
35
+ expect(revalidated.conversations.map((item) => item.subjectId)).toEqual(['b', 'a', 'c', 'd'])
36
+ expect(revalidated.conversations[0]?.unreadCount).toBe(7)
37
+ expect(revalidated.nextCursor).toBe('c2')
38
+ })
39
+
40
+ it('loaded takes the new cursor when no extra pages were appended', () => {
41
+ const first = participantInboxReducer(INITIAL_PARTICIPANT_INBOX_STATE, { type: 'loaded', conversations: [conversation('a')], nextCursor: 'c1' })
42
+ const revalidated = participantInboxReducer(first, { type: 'loaded', conversations: [conversation('a')], nextCursor: 'c9' })
43
+ expect(revalidated.nextCursor).toBe('c9')
44
+ })
45
+
46
+ it('appended adds the next page without duplicating a subject', () => {
47
+ const first = participantInboxReducer(INITIAL_PARTICIPANT_INBOX_STATE, { type: 'loaded', conversations: [conversation('a'), conversation('b')], nextCursor: 'c1' })
48
+ const next = participantInboxReducer(first, { type: 'appended', conversations: [conversation('b', 3), conversation('c')] })
49
+ expect(next.conversations.map((item) => item.subjectId)).toEqual(['a', 'b', 'c'])
50
+ expect(next.conversations[1]?.unreadCount).toBe(3)
51
+ expect(next.nextCursor).toBeUndefined()
52
+ })
53
+
54
+ it('markedRead zeroes only that subject', () => {
55
+ const loaded = participantInboxReducer(INITIAL_PARTICIPANT_INBOX_STATE, {
56
+ type: 'loaded',
57
+ conversations: [conversation('a', 2), conversation('b', 4), conversation('a', 5, 'invoice')],
58
+ })
59
+ const next = participantInboxReducer(loaded, { type: 'markedRead', subject: { subjectType: 'trip', subjectId: 'a' } })
60
+ expect(next.conversations.map((item) => item.unreadCount)).toEqual([0, 4, 5])
61
+ })
62
+
63
+ it('failed records the error and keeps the conversations', () => {
64
+ const loaded = participantInboxReducer(INITIAL_PARTICIPANT_INBOX_STATE, { type: 'loaded', conversations: [conversation('a')] })
65
+ const failed = participantInboxReducer(loaded, { type: 'failed', error: 'boom' })
66
+ expect(failed).toMatchObject({ status: 'error', error: 'boom' })
67
+ expect(failed.conversations).toHaveLength(1)
68
+ })
69
+ })
@@ -0,0 +1,74 @@
1
+ import type { ParticipantConversationSummary, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
2
+
3
+ export type ParticipantInboxState = {
4
+ readonly status: 'idle' | 'loading' | 'ready' | 'error'
5
+ readonly conversations: readonly ParticipantConversationSummary[]
6
+ readonly nextCursor?: string
7
+ readonly error?: string
8
+ }
9
+
10
+ export type ParticipantInboxAction =
11
+ | { readonly type: 'started' }
12
+ | { readonly type: 'loaded'; readonly conversations: readonly ParticipantConversationSummary[]; readonly nextCursor?: string }
13
+ | { readonly type: 'appended'; readonly conversations: readonly ParticipantConversationSummary[]; readonly nextCursor?: string }
14
+ | { readonly type: 'markedRead'; readonly subject: ParticipantSubjectRef }
15
+ | { readonly type: 'failed'; readonly error: string }
16
+
17
+ export const INITIAL_PARTICIPANT_INBOX_STATE: ParticipantInboxState = { status: 'idle', conversations: [] }
18
+
19
+ function subjectKey(subject: ParticipantSubjectRef): string {
20
+ return `${subject.subjectType}:${subject.subjectId}`
21
+ }
22
+
23
+ function withCursor(
24
+ state: ParticipantInboxState,
25
+ conversations: readonly ParticipantConversationSummary[],
26
+ nextCursor: string | undefined,
27
+ ): ParticipantInboxState {
28
+ const { error: _error, nextCursor: _nextCursor, ...rest } = state
29
+ return { ...rest, status: 'ready', conversations, ...(nextCursor === undefined ? {} : { nextCursor }) }
30
+ }
31
+
32
+ /** Revalidation brings the first page again: it must not drop the pages appended after it. */
33
+ function mergeFirstPage(
34
+ state: ParticipantInboxState,
35
+ incoming: readonly ParticipantConversationSummary[],
36
+ nextCursor: string | undefined,
37
+ ): ParticipantInboxState {
38
+ const incomingKeys = new Set(incoming.map(subjectKey))
39
+ const retained = state.conversations.filter((conversation) => !incomingKeys.has(subjectKey(conversation)))
40
+ const cursor = retained.length > 0 ? state.nextCursor : nextCursor
41
+ return withCursor(state, [...incoming, ...retained], cursor)
42
+ }
43
+
44
+ function appendUnique(
45
+ current: readonly ParticipantConversationSummary[],
46
+ incoming: readonly ParticipantConversationSummary[],
47
+ ): readonly ParticipantConversationSummary[] {
48
+ const incomingByKey = new Map(incoming.map((conversation) => [subjectKey(conversation), conversation]))
49
+ const updated = current.map((conversation) => incomingByKey.get(subjectKey(conversation)) ?? conversation)
50
+ const knownKeys = new Set(current.map(subjectKey))
51
+ return [...updated, ...incoming.filter((conversation) => !knownKeys.has(subjectKey(conversation)))]
52
+ }
53
+
54
+ export function participantInboxReducer(state: ParticipantInboxState, action: ParticipantInboxAction): ParticipantInboxState {
55
+ switch (action.type) {
56
+ case 'started':
57
+ return state.status === 'ready' ? state : { ...state, status: 'loading' }
58
+ case 'loaded':
59
+ return mergeFirstPage(state, action.conversations, action.nextCursor)
60
+ case 'appended':
61
+ return withCursor(state, appendUnique(state.conversations, action.conversations), action.nextCursor)
62
+ case 'markedRead': {
63
+ const target = subjectKey(action.subject)
64
+ return {
65
+ ...state,
66
+ conversations: state.conversations.map((conversation) =>
67
+ subjectKey(conversation) === target ? { ...conversation, unreadCount: 0 } : conversation,
68
+ ),
69
+ }
70
+ }
71
+ case 'failed':
72
+ return { ...state, status: 'error', error: action.error }
73
+ }
74
+ }
@@ -0,0 +1,86 @@
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
+ }
42
+
43
+ export const DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS: ParticipantConversationsLabels = {
44
+ inboxTitle: 'Conversations',
45
+ sectionAwaiting: 'Waiting for your reply',
46
+ sectionClosed: 'Closed',
47
+ sectionOther: 'Other',
48
+ filterAll: 'All',
49
+ filtersGroup: 'Filter by subject',
50
+ emptyInbox: 'No conversations yet',
51
+ unreadCount: '{count} unread',
52
+ back: 'Back',
53
+ openSubject: 'Open subject',
54
+ me: 'Me',
55
+ newMessages: 'New messages',
56
+ loadOlder: 'Load older messages',
57
+ closedNotice: 'This conversation is closed',
58
+ messageInputLabel: 'Message',
59
+ messageInputPlaceholder: 'Write a message…',
60
+ send: 'Send',
61
+ attach: 'Attach',
62
+ attachmentsList: 'Selected attachments',
63
+ removeAttachment: 'Remove attachment',
64
+ attachmentTooLarge: 'Attachment too large',
65
+ attachmentTypeNotAccepted: 'File type not accepted',
66
+ quickRepliesGroup: 'Quick replies',
67
+ downloadAttachment: 'Download',
68
+ statusSending: 'Sending',
69
+ statusQueued: 'Queued — sends when back online',
70
+ statusSent: 'Sent',
71
+ statusDelivered: 'Delivered',
72
+ statusRead: 'Read',
73
+ statusFailed: 'Failed — tap to retry',
74
+ statusFailedShort: 'Failed',
75
+ retry: 'Retry',
76
+ discard: 'Discard',
77
+ edit: 'Edit',
78
+ notFound: 'Conversation not found',
79
+ loading: 'Loading…',
80
+ loadError: 'Could not load. Check your connection and try again.',
81
+ loadMore: 'Load more',
82
+ }
83
+
84
+ export function formatParticipantLabel(template: string, count: number): string {
85
+ return template.replace('{count}', String(count))
86
+ }
@@ -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
+ }