@adatechnology/conversations-ui 0.4.2 → 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.
- package/README.md +169 -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-HMCOTZAX.js → chunk-HOKNKXIT.js} +1 -1
- package/dist/{chunk-4P5KMWOI.js → chunk-JXJCPN34.js} +327 -522
- package/dist/chunk-MRVFFJEH.js +64 -0
- package/dist/chunk-RJMSM3YA.js +249 -0
- package/dist/chunk-WSFGNR5R.js +55 -0
- package/dist/flows/index.js +5 -3
- package/dist/index.d.ts +9 -3
- package/dist/index.js +783 -807
- package/dist/participant/index.d.ts +162 -0
- package/dist/participant/index.js +1448 -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 +591 -0
- package/package.json +8 -4
- package/src/ChannelIcon.tsx +2 -0
- package/src/MessageBubble.test.tsx +2 -2
- package/src/MessageText.test.tsx +59 -0
- package/src/MessageText.tsx +20 -12
- package/src/MessageTextCopiedBadge.tsx +7 -0
- package/src/StatusTicks.test.tsx +30 -0
- package/src/StatusTicks.tsx +24 -9
- 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/participant/ParticipantAttachmentItem.tsx +77 -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 +42 -0
- package/src/participant/ParticipantConversationsScreen.tsx +67 -0
- package/src/participant/ParticipantInbox.test.tsx +147 -0
- package/src/participant/ParticipantInbox.tsx +168 -0
- package/src/participant/ParticipantLoadState.tsx +27 -0
- package/src/participant/ParticipantMessageBubble.test.tsx +165 -0
- package/src/participant/ParticipantMessageBubble.tsx +185 -0
- package/src/participant/ParticipantThread.test.tsx +132 -0
- package/src/participant/ParticipantThread.tsx +174 -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 +25 -0
- package/src/participant/participant.types.ts +43 -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/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 +138 -0
- package/src/participant/participantGrouping.ts +77 -0
- package/src/participant/participantInboxState.test.ts +69 -0
- package/src/participant/participantInboxState.ts +74 -0
- package/src/participant/participantLabels.ts +86 -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/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 +160 -0
- 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
|
+
}
|