@adatechnology/conversations-ui 0.4.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +231 -0
- package/dist/{ConversationSimulatorPanel-DnXElfY8.d.ts → ConversationSimulatorPanel-CrfFjiWh.d.ts} +2 -268
- package/dist/{chunk-PJNR6UMN.js → chunk-CBYEHPX6.js} +1 -63
- package/dist/{chunk-4P5KMWOI.js → chunk-FAQLC3TX.js} +327 -522
- package/dist/{chunk-HMCOTZAX.js → chunk-HOKNKXIT.js} +1 -1
- package/dist/chunk-MRVFFJEH.js +64 -0
- package/dist/chunk-O6457XHK.js +255 -0
- package/dist/chunk-OVFCLRQQ.js +84 -0
- package/dist/flows/index.js +5 -3
- package/dist/index.d.ts +21 -5
- package/dist/index.js +783 -807
- package/dist/participant/index.d.ts +186 -0
- package/dist/participant/index.js +1708 -0
- package/dist/preview/index.d.ts +4 -2
- package/dist/preview/index.js +4 -2
- package/dist/quickReply.types-B4gmZsSr.d.ts +274 -0
- package/dist/styles.css +689 -0
- package/package.json +8 -4
- package/src/ChannelIcon.tsx +2 -0
- package/src/MessageText.test.tsx +76 -0
- package/src/MessageText.tsx +28 -12
- package/src/MessageTextCopiedBadge.tsx +15 -0
- package/src/StatusTicks.test.tsx +51 -0
- package/src/StatusTicks.tsx +36 -6
- package/src/buildOutput.test.ts +41 -0
- package/src/channelCapability.test.ts +17 -0
- package/src/conversationChannel.test.ts +30 -1
- package/src/conversationChannel.ts +19 -1
- package/src/legacyChannels.golden.test.ts +89 -0
- package/src/legacyDefaults.golden.test.tsx +127 -0
- package/src/legacyStyles.fixture.css +1121 -0
- package/src/legacyStyles.golden.test.ts +121 -0
- package/src/messageTextCopyInteraction.test.ts +75 -0
- package/src/messageTextCopyInteraction.ts +37 -0
- package/src/participant/ParticipantAttachmentItem.tsx +77 -0
- package/src/participant/ParticipantChannelBadges.test.tsx +59 -0
- package/src/participant/ParticipantChannelBadges.tsx +75 -0
- package/src/participant/ParticipantComposer.test.tsx +97 -0
- package/src/participant/ParticipantComposer.tsx +167 -0
- package/src/participant/ParticipantConversations.test.tsx +90 -0
- package/src/participant/ParticipantConversations.tsx +49 -0
- package/src/participant/ParticipantConversationsScreen.tsx +78 -0
- package/src/participant/ParticipantInbox.test.tsx +244 -0
- package/src/participant/ParticipantInbox.tsx +149 -0
- package/src/participant/ParticipantInboxRow.tsx +75 -0
- package/src/participant/ParticipantInboxSearchField.tsx +34 -0
- package/src/participant/ParticipantLoadState.tsx +27 -0
- package/src/participant/ParticipantMessageBubble.test.tsx +167 -0
- package/src/participant/ParticipantMessageBubble.tsx +185 -0
- package/src/participant/ParticipantProtocolBadge.tsx +74 -0
- package/src/participant/ParticipantProtocolView.test.tsx +208 -0
- package/src/participant/ParticipantThread.test.tsx +149 -0
- package/src/participant/ParticipantThread.tsx +184 -0
- package/src/participant/ParticipantThreadScreen.tsx +135 -0
- package/src/participant/bindAttachmentUrlResolver.test.ts +25 -0
- package/src/participant/bindAttachmentUrlResolver.ts +7 -0
- package/src/participant/index.ts +26 -0
- package/src/participant/participant.types.ts +52 -0
- package/src/participant/participantApi.types.ts +59 -0
- package/src/participant/participantAttachmentUrl.test.ts +32 -0
- package/src/participant/participantAttachmentUrl.ts +10 -0
- package/src/participant/participantBubbleActions.test.ts +44 -0
- package/src/participant/participantBubbleActions.ts +40 -0
- package/src/participant/participantChannels.test.ts +42 -0
- package/src/participant/participantChannels.ts +33 -0
- package/src/participant/participantDrafts.test.ts +65 -0
- package/src/participant/participantDrafts.ts +50 -0
- package/src/participant/participantFileValidation.test.ts +86 -0
- package/src/participant/participantFileValidation.ts +72 -0
- package/src/participant/participantFixtures.test-helper.ts +41 -0
- package/src/participant/participantGrouping.test.ts +236 -0
- package/src/participant/participantGrouping.ts +90 -0
- package/src/participant/participantInboxState.test.ts +69 -0
- package/src/participant/participantInboxState.ts +74 -0
- package/src/participant/participantLabels.ts +115 -0
- package/src/participant/participantLoadView.test.ts +23 -0
- package/src/participant/participantLoadView.ts +21 -0
- package/src/participant/participantMessages.test.ts +128 -0
- package/src/participant/participantMessages.ts +73 -0
- package/src/participant/participantNewMessages.test.ts +54 -0
- package/src/participant/participantNewMessages.ts +29 -0
- package/src/participant/participantProtocol.test.ts +104 -0
- package/src/participant/participantProtocol.ts +52 -0
- package/src/participant/participantProtocolCopy.test.ts +45 -0
- package/src/participant/participantProtocolCopy.ts +32 -0
- package/src/participant/participantQuickReply.test.ts +23 -0
- package/src/participant/participantQuickReply.ts +6 -0
- package/src/participant/participantScroll.test.ts +69 -0
- package/src/participant/participantScroll.ts +43 -0
- package/src/participant/participantSendController.test.ts +201 -0
- package/src/participant/participantSendController.ts +134 -0
- package/src/participant/participantSendStates.ts +31 -0
- package/src/participant/participantTheme.ts +20 -0
- package/src/participant/participantThreadState.test.ts +75 -0
- package/src/participant/participantThreadState.ts +59 -0
- package/src/participant/shouldMarkRead.test.ts +24 -0
- package/src/participant/shouldMarkRead.ts +16 -0
- package/src/participant/useDocumentVisibility.ts +18 -0
- package/src/participant/useParticipantConversationView.ts +60 -0
- package/src/participant/useParticipantDraft.ts +45 -0
- package/src/participant/useParticipantInbox.ts +57 -0
- package/src/participant/useParticipantMarkRead.ts +26 -0
- package/src/participant/useParticipantRevalidation.ts +36 -0
- package/src/participant/useParticipantSend.ts +84 -0
- package/src/participant/useParticipantThread.ts +116 -0
- package/src/participant/useParticipantThreadScroll.ts +26 -0
- package/src/participant/useStickToBottom.ts +77 -0
- package/src/preview/ConversationSimulatorPanel.tsx +8 -0
- package/src/styles.css +178 -0
- package/src/types.ts +3 -1
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { channelCapabilityFor } from '../channelCapability'
|
|
4
|
+
import { maxBytesForChannel, reduceFilesSelection, validateParticipantFiles } from './participantFileValidation'
|
|
5
|
+
|
|
6
|
+
function buildFile(name: string, type: string, sizeBytes: number): File {
|
|
7
|
+
return new File([new Uint8Array(sizeBytes)], name, { type })
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('validateParticipantFiles', () => {
|
|
11
|
+
it('accepts files within the size limit and accepted types', () => {
|
|
12
|
+
const photo = buildFile('photo.jpg', 'image/jpeg', 10)
|
|
13
|
+
const result = validateParticipantFiles({ files: [photo], maxBytes: 100, acceptedTypes: ['image/*'] })
|
|
14
|
+
|
|
15
|
+
expect(result.accepted).toEqual([photo])
|
|
16
|
+
expect(result.rejection).toBeUndefined()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('rejects a file above maxBytes with tooLarge and keeps the valid ones', () => {
|
|
20
|
+
const small = buildFile('a.jpg', 'image/jpeg', 10)
|
|
21
|
+
const big = buildFile('b.jpg', 'image/jpeg', 101)
|
|
22
|
+
const result = validateParticipantFiles({ files: [small, big], maxBytes: 100 })
|
|
23
|
+
|
|
24
|
+
expect(result.accepted).toEqual([small])
|
|
25
|
+
expect(result.rejection).toBe('tooLarge')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('rejects a type outside acceptedTypes, by exact type or wildcard', () => {
|
|
29
|
+
const sheet = buildFile('a.xls', 'application/vnd.ms-excel', 1)
|
|
30
|
+
const pdf = buildFile('a.pdf', 'application/pdf', 1)
|
|
31
|
+
const result = validateParticipantFiles({ files: [sheet, pdf], maxBytes: 100, acceptedTypes: ['image/*', 'application/pdf'] })
|
|
32
|
+
|
|
33
|
+
expect(result.accepted).toEqual([pdf])
|
|
34
|
+
expect(result.rejection).toBe('typeNotAccepted')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('accepts any type when acceptedTypes is absent', () => {
|
|
38
|
+
const any = buildFile('a.bin', '', 1)
|
|
39
|
+
|
|
40
|
+
expect(validateParticipantFiles({ files: [any], maxBytes: 100 }).accepted).toEqual([any])
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('reduceFilesSelection', () => {
|
|
45
|
+
const ACCEPTED = ['image/*', 'application/pdf']
|
|
46
|
+
|
|
47
|
+
it('keeps already chosen files and appends the valid incoming ones', () => {
|
|
48
|
+
const current = [buildFile('a.jpg', 'image/jpeg', 10)]
|
|
49
|
+
const incoming = [buildFile('b.pdf', 'application/pdf', 10)]
|
|
50
|
+
const result = reduceFilesSelection({ current, incoming, maxBytes: 100, acceptedTypes: ACCEPTED })
|
|
51
|
+
|
|
52
|
+
expect(result.files).toEqual([...current, ...incoming])
|
|
53
|
+
expect(result.rejected).toEqual([])
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('a refusal never empties what was already chosen', () => {
|
|
57
|
+
const current = [buildFile('a.jpg', 'image/jpeg', 10)]
|
|
58
|
+
const big = buildFile('big.jpg', 'image/jpeg', 101)
|
|
59
|
+
const sheet = buildFile('s.xls', 'application/vnd.ms-excel', 1)
|
|
60
|
+
const result = reduceFilesSelection({ current, incoming: [big, sheet], maxBytes: 100, acceptedTypes: ACCEPTED })
|
|
61
|
+
|
|
62
|
+
expect(result.files).toEqual(current)
|
|
63
|
+
expect(result.rejected).toEqual([
|
|
64
|
+
{ file: big, reason: 'tooLarge' },
|
|
65
|
+
{ file: sheet, reason: 'typeNotAccepted' },
|
|
66
|
+
])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('keeps the valid files of a mixed selection', () => {
|
|
70
|
+
const good = buildFile('ok.jpg', 'image/jpeg', 10)
|
|
71
|
+
const big = buildFile('big.jpg', 'image/jpeg', 101)
|
|
72
|
+
const result = reduceFilesSelection({ current: [], incoming: [big, good], maxBytes: 100 })
|
|
73
|
+
|
|
74
|
+
expect(result.files).toEqual([good])
|
|
75
|
+
expect(result.rejected).toHaveLength(1)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('takes the ceiling from the channel capability', () => {
|
|
79
|
+
const twentyFiveMegabytes = 25 * 1024 * 1024
|
|
80
|
+
expect(channelCapabilityFor('app').attachments.maxBytes).toBe(twentyFiveMegabytes)
|
|
81
|
+
expect(channelCapabilityFor('portal').attachments.maxBytes).toBeGreaterThan(0)
|
|
82
|
+
expect(maxBytesForChannel('app')).toBe(twentyFiveMegabytes)
|
|
83
|
+
expect(maxBytesForChannel(undefined)).toBe(twentyFiveMegabytes)
|
|
84
|
+
expect(maxBytesForChannel('portal')).toBe(channelCapabilityFor('portal').attachments.maxBytes)
|
|
85
|
+
})
|
|
86
|
+
})
|
|
@@ -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,236 @@
|
|
|
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(
|
|
8
|
+
overrides: Partial<ParticipantConversationSummary> & { subjectId: string },
|
|
9
|
+
): ParticipantConversationSummary {
|
|
10
|
+
return {
|
|
11
|
+
subjectType: 'invoice',
|
|
12
|
+
subjectLabel: 'Label',
|
|
13
|
+
lastMessageAt: '2026-10-01T10:00:00.000Z',
|
|
14
|
+
unreadCount: 0,
|
|
15
|
+
awaitingParticipant: false,
|
|
16
|
+
status: 'open',
|
|
17
|
+
...overrides,
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const GROUPS = [
|
|
22
|
+
{ subjectType: 'zeta', label: 'Zeta' },
|
|
23
|
+
{ subjectType: 'alpha', label: 'Alpha' },
|
|
24
|
+
] as const
|
|
25
|
+
|
|
26
|
+
function keysOf(view: ReturnType<typeof groupParticipantConversations>): string[] {
|
|
27
|
+
return view.sections.map((section) => section.key)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('groupParticipantConversations', () => {
|
|
31
|
+
it('applies precedence closed > awaiting > group > other', () => {
|
|
32
|
+
const view = groupParticipantConversations({
|
|
33
|
+
subjectGroups: GROUPS,
|
|
34
|
+
conversations: [
|
|
35
|
+
build({ subjectId: '1', subjectType: 'zeta', status: 'closed', awaitingParticipant: true }),
|
|
36
|
+
build({ subjectId: '2', subjectType: 'zeta', awaitingParticipant: true }),
|
|
37
|
+
build({ subjectId: '3', subjectType: 'zeta' }),
|
|
38
|
+
build({ subjectId: '4', subjectType: 'unknown' }),
|
|
39
|
+
],
|
|
40
|
+
})
|
|
41
|
+
expect(keysOf(view)).toEqual(['awaiting', 'zeta', 'other', 'closed'])
|
|
42
|
+
expect(view.sections.map((section) => section.conversations.map((c) => c.subjectId))).toEqual([
|
|
43
|
+
['2'],
|
|
44
|
+
['3'],
|
|
45
|
+
['4'],
|
|
46
|
+
['1'],
|
|
47
|
+
])
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('orders group sections by the subjectGroups array, not alphabetically, skipping empty groups', () => {
|
|
51
|
+
const view = groupParticipantConversations({
|
|
52
|
+
subjectGroups: [...GROUPS, { subjectType: 'empty', label: 'Empty' }],
|
|
53
|
+
conversations: [build({ subjectId: '1', subjectType: 'alpha' }), build({ subjectId: '2', subjectType: 'zeta' })],
|
|
54
|
+
})
|
|
55
|
+
expect(keysOf(view)).toEqual(['zeta', 'alpha'])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('sorts by lastMessageAt descending, null last, ties by subjectLabel', () => {
|
|
59
|
+
const view = groupParticipantConversations({
|
|
60
|
+
subjectGroups: GROUPS,
|
|
61
|
+
conversations: [
|
|
62
|
+
build({ subjectId: 'a', subjectType: 'zeta', lastMessageAt: null, subjectLabel: 'A' }),
|
|
63
|
+
build({ subjectId: 'b', subjectType: 'zeta', lastMessageAt: '2026-10-01T10:00:00.000Z', subjectLabel: 'Beta' }),
|
|
64
|
+
build({ subjectId: 'c', subjectType: 'zeta', lastMessageAt: '2026-10-02T10:00:00.000Z', subjectLabel: 'Z' }),
|
|
65
|
+
build({
|
|
66
|
+
subjectId: 'd',
|
|
67
|
+
subjectType: 'zeta',
|
|
68
|
+
lastMessageAt: '2026-10-01T10:00:00.000Z',
|
|
69
|
+
subjectLabel: 'Alpha',
|
|
70
|
+
}),
|
|
71
|
+
],
|
|
72
|
+
})
|
|
73
|
+
expect(view.sections[0]?.conversations.map((c) => c.subjectId)).toEqual(['c', 'd', 'b', 'a'])
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('sums unread per section and per filter', () => {
|
|
77
|
+
const view = groupParticipantConversations({
|
|
78
|
+
subjectGroups: GROUPS,
|
|
79
|
+
conversations: [
|
|
80
|
+
build({ subjectId: '1', subjectType: 'zeta', unreadCount: 2 }),
|
|
81
|
+
build({ subjectId: '2', subjectType: 'zeta', unreadCount: 3, awaitingParticipant: true }),
|
|
82
|
+
build({ subjectId: '3', subjectType: 'alpha', unreadCount: 4 }),
|
|
83
|
+
],
|
|
84
|
+
})
|
|
85
|
+
expect(view.sections.map((section) => [section.key, section.unreadCount])).toEqual([
|
|
86
|
+
['awaiting', 3],
|
|
87
|
+
['zeta', 2],
|
|
88
|
+
['alpha', 4],
|
|
89
|
+
])
|
|
90
|
+
expect(view.filters.map((filter) => [filter.subjectType, filter.unreadCount])).toEqual([
|
|
91
|
+
['all', 9],
|
|
92
|
+
['zeta', 5],
|
|
93
|
+
['alpha', 4],
|
|
94
|
+
])
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('places every conversation in exactly one section', () => {
|
|
98
|
+
const conversations = [
|
|
99
|
+
build({ subjectId: '1', subjectType: 'zeta', status: 'closed' }),
|
|
100
|
+
build({ subjectId: '2', subjectType: 'alpha', awaitingParticipant: true }),
|
|
101
|
+
build({ subjectId: '3', subjectType: 'nope' }),
|
|
102
|
+
build({ subjectId: '4', subjectType: 'alpha' }),
|
|
103
|
+
build({ subjectId: '5', subjectType: 'zeta', lastMessageAt: null }),
|
|
104
|
+
]
|
|
105
|
+
const view = groupParticipantConversations({ subjectGroups: GROUPS, conversations })
|
|
106
|
+
const total = view.sections.reduce((sum, section) => sum + section.conversations.length, 0)
|
|
107
|
+
expect(total).toBe(conversations.length)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('sends unknown subjectType to other and never drops it', () => {
|
|
111
|
+
const view = groupParticipantConversations({
|
|
112
|
+
subjectGroups: GROUPS,
|
|
113
|
+
conversations: [build({ subjectId: '1', subjectType: 'mystery' })],
|
|
114
|
+
})
|
|
115
|
+
expect(keysOf(view)).toEqual(['other'])
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('shows filters only with two or more groups that have conversations', () => {
|
|
119
|
+
const one = groupParticipantConversations({
|
|
120
|
+
subjectGroups: GROUPS,
|
|
121
|
+
conversations: [build({ subjectId: '1', subjectType: 'zeta' })],
|
|
122
|
+
})
|
|
123
|
+
expect(one.showFilters).toBe(false)
|
|
124
|
+
const two = groupParticipantConversations({
|
|
125
|
+
subjectGroups: GROUPS,
|
|
126
|
+
conversations: [build({ subjectId: '1', subjectType: 'zeta' }), build({ subjectId: '2', subjectType: 'alpha' })],
|
|
127
|
+
})
|
|
128
|
+
expect(two.showFilters).toBe(true)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('restricts every section to the filtered subjectType, including awaiting', () => {
|
|
132
|
+
const view = groupParticipantConversations({
|
|
133
|
+
subjectGroups: GROUPS,
|
|
134
|
+
filter: 'alpha',
|
|
135
|
+
conversations: [
|
|
136
|
+
build({ subjectId: '1', subjectType: 'zeta', awaitingParticipant: true }),
|
|
137
|
+
build({ subjectId: '2', subjectType: 'alpha', awaitingParticipant: true }),
|
|
138
|
+
build({ subjectId: '3', subjectType: 'alpha' }),
|
|
139
|
+
build({ subjectId: '4', subjectType: 'zeta' }),
|
|
140
|
+
],
|
|
141
|
+
})
|
|
142
|
+
expect(view.sections.map((section) => section.conversations.map((c) => c.subjectId))).toEqual([['2'], ['3']])
|
|
143
|
+
expect(
|
|
144
|
+
groupParticipantConversations({
|
|
145
|
+
subjectGroups: GROUPS,
|
|
146
|
+
filter: 'all',
|
|
147
|
+
conversations: [build({ subjectId: '1' })],
|
|
148
|
+
}).sections,
|
|
149
|
+
).toHaveLength(1)
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
describe('groupParticipantConversations with a search query', () => {
|
|
154
|
+
const conversations = [
|
|
155
|
+
build({ subjectId: '1', subjectType: 'alpha', protocol: '261009-K7M2', unreadCount: 2 }),
|
|
156
|
+
build({ subjectId: '2', subjectType: 'zeta', protocol: '261008-AB23', unreadCount: 1 }),
|
|
157
|
+
build({ subjectId: '3', subjectType: 'zeta', protocol: '261007-CD45' }),
|
|
158
|
+
]
|
|
159
|
+
const idsOf = (view: ReturnType<typeof groupParticipantConversations>) =>
|
|
160
|
+
view.sections.flatMap((section) => section.conversations.map((item) => item.subjectId))
|
|
161
|
+
|
|
162
|
+
it('keeps chips and counts computed on the whole list, not on the search result', () => {
|
|
163
|
+
const view = groupParticipantConversations({
|
|
164
|
+
conversations,
|
|
165
|
+
subjectGroups: GROUPS,
|
|
166
|
+
filter: 'all',
|
|
167
|
+
searchQuery: 'k7m2',
|
|
168
|
+
})
|
|
169
|
+
expect(idsOf(view)).toEqual(['1'])
|
|
170
|
+
expect(view.showFilters).toBe(true)
|
|
171
|
+
expect(view.filters.map((filter) => [filter.subjectType, filter.unreadCount])).toEqual([
|
|
172
|
+
['all', 3],
|
|
173
|
+
['zeta', 1],
|
|
174
|
+
['alpha', 2],
|
|
175
|
+
])
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('chip of another subject plus the protocol of a conversation outside it: chips stay and the view says so', () => {
|
|
179
|
+
const view = groupParticipantConversations({
|
|
180
|
+
conversations,
|
|
181
|
+
subjectGroups: GROUPS,
|
|
182
|
+
filter: 'zeta',
|
|
183
|
+
searchQuery: '261009',
|
|
184
|
+
})
|
|
185
|
+
expect(view.sections).toEqual([])
|
|
186
|
+
expect(view.showFilters).toBe(true)
|
|
187
|
+
expect(view.hasMatchesOutsideFilter).toBe(true)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('a query that matches inside the active filter shows only those rows', () => {
|
|
191
|
+
const view = groupParticipantConversations({
|
|
192
|
+
conversations,
|
|
193
|
+
subjectGroups: GROUPS,
|
|
194
|
+
filter: 'zeta',
|
|
195
|
+
searchQuery: 'ab23',
|
|
196
|
+
})
|
|
197
|
+
expect(idsOf(view)).toEqual(['2'])
|
|
198
|
+
expect(view.hasMatchesOutsideFilter).toBe(false)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('a query that matches nothing anywhere is not an outside-filter match', () => {
|
|
202
|
+
const view = groupParticipantConversations({
|
|
203
|
+
conversations,
|
|
204
|
+
subjectGroups: GROUPS,
|
|
205
|
+
filter: 'zeta',
|
|
206
|
+
searchQuery: 'zzz',
|
|
207
|
+
})
|
|
208
|
+
expect(view.hasMatchesOutsideFilter).toBe(false)
|
|
209
|
+
expect(view.showFilters).toBe(true)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('without a query nothing is outside the filter', () => {
|
|
213
|
+
const view = groupParticipantConversations({ conversations, subjectGroups: GROUPS, filter: 'zeta' })
|
|
214
|
+
expect(view.hasMatchesOutsideFilter).toBe(false)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('a filter pointing at a subject with no conversations behaves as all', () => {
|
|
218
|
+
const view = groupParticipantConversations({
|
|
219
|
+
conversations: [build({ subjectId: '1', subjectType: 'zeta' })],
|
|
220
|
+
subjectGroups: GROUPS,
|
|
221
|
+
filter: 'alpha',
|
|
222
|
+
})
|
|
223
|
+
expect(idsOf(view)).toEqual(['1'])
|
|
224
|
+
expect(view.activeFilter).toBe('all')
|
|
225
|
+
expect(view.hasMatchesOutsideFilter).toBe(false)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('keeps a filter that still has conversations as active', () => {
|
|
229
|
+
const view = groupParticipantConversations({
|
|
230
|
+
conversations: [build({ subjectId: '1', subjectType: 'zeta' })],
|
|
231
|
+
subjectGroups: GROUPS,
|
|
232
|
+
filter: 'zeta',
|
|
233
|
+
})
|
|
234
|
+
expect(view.activeFilter).toBe('zeta')
|
|
235
|
+
})
|
|
236
|
+
})
|
|
@@ -0,0 +1,90 @@
|
|
|
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
|
+
import { filterConversationsBySearch } from './participantProtocol'
|
|
10
|
+
|
|
11
|
+
const ALL_FILTER = 'all'
|
|
12
|
+
const SECTION_AWAITING = 'awaiting'
|
|
13
|
+
const SECTION_OTHER = 'other'
|
|
14
|
+
const SECTION_CLOSED = 'closed'
|
|
15
|
+
|
|
16
|
+
function compareConversations(left: ParticipantConversationSummary, right: ParticipantConversationSummary): number {
|
|
17
|
+
if (left.lastMessageAt !== right.lastMessageAt) {
|
|
18
|
+
if (left.lastMessageAt === null) return 1
|
|
19
|
+
if (right.lastMessageAt === null) return -1
|
|
20
|
+
return left.lastMessageAt < right.lastMessageAt ? 1 : -1
|
|
21
|
+
}
|
|
22
|
+
return left.subjectLabel.localeCompare(right.subjectLabel)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function sumUnread(conversations: readonly ParticipantConversationSummary[]): number {
|
|
26
|
+
return conversations.reduce((sum, conversation) => sum + conversation.unreadCount, 0)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function resolveSectionKey(conversation: ParticipantConversationSummary, knownTypes: ReadonlySet<string>): string {
|
|
30
|
+
if (conversation.status === 'closed') return SECTION_CLOSED
|
|
31
|
+
if (conversation.awaitingParticipant) return SECTION_AWAITING
|
|
32
|
+
return knownTypes.has(conversation.subjectType) ? conversation.subjectType : SECTION_OTHER
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function buildSection(key: string, conversations: readonly ParticipantConversationSummary[]): ParticipantInboxSection {
|
|
36
|
+
const sorted = [...conversations].sort(compareConversations)
|
|
37
|
+
return { key, conversations: sorted, unreadCount: sumUnread(sorted) }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function buildFilters(
|
|
41
|
+
params: GroupParticipantConversationsParams,
|
|
42
|
+
): Pick<ParticipantInboxView, 'filters' | 'showFilters'> {
|
|
43
|
+
const groupFilters: ParticipantInboxFilter[] = []
|
|
44
|
+
for (const group of params.subjectGroups) {
|
|
45
|
+
const own = params.conversations.filter((conversation) => conversation.subjectType === group.subjectType)
|
|
46
|
+
if (own.length === 0) continue
|
|
47
|
+
groupFilters.push({ subjectType: group.subjectType, label: group.label, unreadCount: sumUnread(own) })
|
|
48
|
+
}
|
|
49
|
+
const all: ParticipantInboxFilter = {
|
|
50
|
+
subjectType: ALL_FILTER,
|
|
51
|
+
label: 'All',
|
|
52
|
+
unreadCount: sumUnread(params.conversations),
|
|
53
|
+
}
|
|
54
|
+
return { filters: [all, ...groupFilters], showFilters: groupFilters.length >= 2 }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function resolveActiveFilter(params: GroupParticipantConversationsParams): string {
|
|
58
|
+
const { filter, conversations } = params
|
|
59
|
+
if (filter === undefined || filter === ALL_FILTER) return ALL_FILTER
|
|
60
|
+
return conversations.some((conversation) => conversation.subjectType === filter) ? filter : ALL_FILTER
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function groupParticipantConversations(params: GroupParticipantConversationsParams): ParticipantInboxView {
|
|
64
|
+
const { subjectGroups } = params
|
|
65
|
+
const filter = resolveActiveFilter(params)
|
|
66
|
+
const isFiltered = filter !== ALL_FILTER
|
|
67
|
+
const searched = filterConversationsBySearch(params.conversations, params.searchQuery ?? '')
|
|
68
|
+
const visible = isFiltered ? searched.filter((conversation) => conversation.subjectType === filter) : searched
|
|
69
|
+
const knownTypes = new Set(subjectGroups.map((group) => group.subjectType))
|
|
70
|
+
|
|
71
|
+
const buckets = new Map<string, ParticipantConversationSummary[]>()
|
|
72
|
+
for (const conversation of visible) {
|
|
73
|
+
const key = resolveSectionKey(conversation, knownTypes)
|
|
74
|
+
buckets.set(key, [...(buckets.get(key) ?? []), conversation])
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const orderedKeys = [
|
|
78
|
+
SECTION_AWAITING,
|
|
79
|
+
...subjectGroups.map((group) => group.subjectType),
|
|
80
|
+
SECTION_OTHER,
|
|
81
|
+
SECTION_CLOSED,
|
|
82
|
+
]
|
|
83
|
+
const sections = orderedKeys.flatMap((key) => {
|
|
84
|
+
const bucket = buckets.get(key)
|
|
85
|
+
return bucket === undefined ? [] : [buildSection(key, bucket)]
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const hasMatchesOutsideFilter = isFiltered && visible.length === 0 && searched.length > 0
|
|
89
|
+
return { sections, hasMatchesOutsideFilter, activeFilter: filter, ...buildFilters(params) }
|
|
90
|
+
}
|
|
@@ -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
|
+
}
|