@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.
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 +8 -4
  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,135 @@
1
+ import { useEffect, useMemo, useState, type Dispatch, type MutableRefObject } from 'react'
2
+
3
+ import type { ParticipantConversationSummary, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
4
+
5
+ import { ParticipantThread } from './ParticipantThread'
6
+ import type { ParticipantConversationsScreenProps } from './ParticipantConversationsScreen'
7
+ import { bindAttachmentUrlResolver } from './bindAttachmentUrlResolver'
8
+ import type { ParticipantDrafts } from './participantDrafts'
9
+ import type { ParticipantConversationsLabels } from './participantLabels'
10
+ import type { ParticipantConversationsApi } from './participantApi.types'
11
+ import { findFailedEntry, hasSendingEntry } from './participantSendController'
12
+ import type { ParticipantSendStates, ParticipantSendStatesAction } from './participantSendStates'
13
+ import { useParticipantDraft } from './useParticipantDraft'
14
+ import { useParticipantConversationView } from './useParticipantConversationView'
15
+ import { useParticipantThreadScroll } from './useParticipantThreadScroll'
16
+
17
+ export type ParticipantThreadScreenProps = Omit<ParticipantConversationsScreenProps, 'selected' | 'labels'> & {
18
+ readonly selected: ParticipantSubjectRef
19
+ readonly labels: ParticipantConversationsLabels
20
+ /** Holds the immutable drafts of every subject; replaced, never mutated. */
21
+ readonly draftsRef: MutableRefObject<ParticipantDrafts>
22
+ /** Sends of every subject; they live above the conversation so a failed message survives leaving it. */
23
+ readonly sendStates: ParticipantSendStates
24
+ readonly dispatchSendStates: Dispatch<ParticipantSendStatesAction>
25
+ }
26
+
27
+ type OpenedState = { readonly status: 'idle' | 'loading' | 'missing' } | { readonly status: 'found'; readonly conversation: ParticipantConversationSummary }
28
+
29
+ function useOpenedConversation(api: ParticipantConversationsApi, subject: ParticipantSubjectRef, isNeeded: boolean): OpenedState {
30
+ const [opened, setOpened] = useState<OpenedState>({ status: 'idle' })
31
+ const { subjectType, subjectId } = subject
32
+ const canOpen = api.openConversation !== undefined
33
+
34
+ useEffect(() => {
35
+ if (!isNeeded || !canOpen) return
36
+ let isCurrent = true
37
+ setOpened({ status: 'loading' })
38
+ api.openConversation?.({ subjectType, subjectId }).then(
39
+ (conversation) => isCurrent && setOpened({ status: 'found', conversation }),
40
+ () => isCurrent && setOpened({ status: 'missing' }),
41
+ )
42
+ return () => {
43
+ isCurrent = false
44
+ }
45
+ }, [api, canOpen, isNeeded, subjectType, subjectId])
46
+
47
+ return opened
48
+ }
49
+
50
+ function NotFound({ labels, onBack }: { readonly labels: ParticipantConversationsLabels; readonly onBack?: () => void }) {
51
+ return (
52
+ <div className="cv-p cv-p-thread">
53
+ <header className="cv-p-thread__header">
54
+ {onBack ? (
55
+ <button type="button" className="cv-p-button cv-p-thread__back" onClick={onBack}>
56
+ <span aria-hidden="true">‹</span>
57
+ <span className="cv-p-sr-only">{labels.back}</span>
58
+ </button>
59
+ ) : null}
60
+ </header>
61
+ <p className="cv-p-empty">{labels.notFound}</p>
62
+ </div>
63
+ )
64
+ }
65
+
66
+ export function ParticipantThreadScreen(props: ParticipantThreadScreenProps) {
67
+ const { api, selected, inbox, labels } = props
68
+ const listed = inbox.conversations.find(
69
+ (item) => item.subjectType === selected.subjectType && item.subjectId === selected.subjectId,
70
+ )
71
+ const opened = useOpenedConversation(api, selected, listed === undefined)
72
+ const conversation = listed ?? (opened.status === 'found' ? opened.conversation : undefined)
73
+
74
+ if (conversation) return <LoadedThread {...props} conversation={conversation} />
75
+ const isResolving = opened.status === 'loading' || (api.openConversation !== undefined && opened.status === 'idle')
76
+ const isListLoading = inbox.status === 'idle' || inbox.status === 'loading'
77
+ if (isResolving || (api.openConversation === undefined && isListLoading)) return <div className="cv-p cv-p-thread" aria-busy="true" />
78
+ return <NotFound labels={labels} onBack={props.onBack} />
79
+ }
80
+
81
+ type LoadedThreadProps = ParticipantThreadScreenProps & { readonly conversation: ParticipantConversationSummary }
82
+
83
+ function LoadedThread(props: LoadedThreadProps) {
84
+ const { api, selected, conversation, draftsRef, inbox } = props
85
+ const { draft, setText, setFiles, takeForSend, restoreFromEntry } = useParticipantDraft(draftsRef, selected)
86
+ const { thread, items, sendEntries, dispatchSend } = useParticipantConversationView({
87
+ ...props,
88
+ subject: selected,
89
+ onMarkedRead: inbox.markSubjectRead,
90
+ })
91
+ const resolveAttachmentUrl = useMemo(() => bindAttachmentUrlResolver(api), [api])
92
+ const { scroll, newMessagesCount } = useParticipantThreadScroll(items)
93
+
94
+ function handleSend(): void {
95
+ const content = takeForSend()
96
+ if (content) void thread.send(content)
97
+ }
98
+
99
+ function handleEdit(clientMessageId: string): void {
100
+ const entry = findFailedEntry(sendEntries, clientMessageId)
101
+ if (!entry) return
102
+ restoreFromEntry(entry)
103
+ dispatchSend({ type: 'discarded', clientMessageId })
104
+ }
105
+
106
+ return (
107
+ <ParticipantThread
108
+ conversation={conversation}
109
+ items={items}
110
+ hasMore={thread.hasMore}
111
+ labels={props.labels}
112
+ resolveAttachmentUrl={resolveAttachmentUrl}
113
+ draft={{ value: draft.text, onChange: setText, files: draft.files, onFilesChange: setFiles }}
114
+ onSend={handleSend}
115
+ status={thread.status}
116
+ refresh={() => void thread.refresh()}
117
+ scroll={scroll}
118
+ newMessagesCount={newMessagesCount}
119
+ isSending={hasSendingEntry(sendEntries)}
120
+ channel={props.channel}
121
+ locale={props.locale}
122
+ onBack={props.onBack}
123
+ onOpenSubject={props.onOpenSubject}
124
+ onLoadOlder={() => void thread.loadOlder()}
125
+ pendingActions={{
126
+ retryLocal: (clientMessageId) => void thread.retry(clientMessageId),
127
+ discardLocal: (clientMessageId) => dispatchSend({ type: 'discarded', clientMessageId }),
128
+ editLocal: handleEdit,
129
+ ...(props.onRetryPending ? { retryHost: props.onRetryPending } : {}),
130
+ }}
131
+ quickReplies={props.quickReplies}
132
+ renderSubjectCard={props.renderSubjectCard}
133
+ />
134
+ )
135
+ }
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import type { ParticipantAttachment } from '@adatechnology/conversation-contracts'
4
+
5
+ import type { ParticipantConversationsApi } from './participantApi.types'
6
+ import { bindAttachmentUrlResolver } from './bindAttachmentUrlResolver'
7
+
8
+ class ClassAdapter {
9
+ readonly baseUrl = 'https://files.example'
10
+
11
+ async resolveAttachmentUrl(attachment: ParticipantAttachment, disposition: 'inline' | 'attachment' = 'inline'): Promise<string> {
12
+ return `${this.baseUrl}/${attachment.id}?disposition=${disposition}`
13
+ }
14
+ }
15
+
16
+ describe('bindAttachmentUrlResolver', () => {
17
+ it('keeps the adapter as this, which a detached method reference would lose', async () => {
18
+ const adapter = new ClassAdapter()
19
+ const attachment: ParticipantAttachment = { id: 'a1', kind: 'image', filename: 'a.png', mimeType: 'image/png', sizeBytes: 1 }
20
+ const resolve = bindAttachmentUrlResolver(adapter as unknown as ParticipantConversationsApi)
21
+
22
+ expect(await resolve(attachment, 'attachment')).toBe('https://files.example/a1?disposition=attachment')
23
+ expect(await resolve(attachment)).toBe('https://files.example/a1?disposition=inline')
24
+ })
25
+ })
@@ -0,0 +1,7 @@
1
+ import type { ResolveParticipantAttachmentUrl } from './ParticipantAttachmentItem'
2
+ import type { ParticipantConversationsApi } from './participantApi.types'
3
+
4
+ /** A detached method reference loses `this`; adapters written as classes break without the wrapper. */
5
+ export function bindAttachmentUrlResolver(api: ParticipantConversationsApi): ResolveParticipantAttachmentUrl {
6
+ return (attachment, disposition) => api.resolveAttachmentUrl(attachment, disposition)
7
+ }
@@ -0,0 +1,25 @@
1
+ export { ParticipantConversations, type ParticipantConversationsProps } from './ParticipantConversations'
2
+ export { groupParticipantConversations } from './participantGrouping'
3
+ export { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS, type ParticipantConversationsLabels } from './participantLabels'
4
+ export type {
5
+ ParticipantConversationsClassNames,
6
+ ParticipantSubjectGroup,
7
+ ParticipantInboxView,
8
+ ParticipantInboxSection,
9
+ GroupParticipantConversationsParams,
10
+ } from './participant.types'
11
+ export type {
12
+ ParticipantConversationEvent,
13
+ ParticipantConversationsApi,
14
+ ParticipantLocalPendingMessage,
15
+ ParticipantPendingMessage,
16
+ ParticipantSendInput,
17
+ ParticipantSendResult,
18
+ } from './participantApi.types'
19
+ export type {
20
+ ParticipantAttachment,
21
+ ParticipantConversationPage,
22
+ ParticipantConversationSummary,
23
+ ParticipantMessage,
24
+ ParticipantSubjectRef,
25
+ } from '@adatechnology/conversation-contracts'
@@ -0,0 +1,43 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ import type { ParticipantConversationSummary } from '@adatechnology/conversation-contracts'
4
+
5
+ export type ParticipantSubjectGroup = {
6
+ readonly subjectType: string
7
+ readonly label: string
8
+ readonly icon?: ReactNode
9
+ }
10
+
11
+ export type ParticipantInboxSection = {
12
+ /** 'awaiting' | 'closed' | 'other' or the subjectType of a group. */
13
+ readonly key: string
14
+ /** Absent for groups: the host resolves it from subjectGroups. */
15
+ readonly label?: string
16
+ readonly conversations: readonly ParticipantConversationSummary[]
17
+ readonly unreadCount: number
18
+ }
19
+
20
+ export type ParticipantInboxFilter = {
21
+ readonly subjectType: string
22
+ readonly label: string
23
+ readonly unreadCount: number
24
+ }
25
+
26
+ export type ParticipantInboxView = {
27
+ readonly sections: readonly ParticipantInboxSection[]
28
+ readonly filters: readonly ParticipantInboxFilter[]
29
+ readonly showFilters: boolean
30
+ }
31
+
32
+ export type GroupParticipantConversationsParams = {
33
+ readonly conversations: readonly ParticipantConversationSummary[]
34
+ readonly subjectGroups: readonly ParticipantSubjectGroup[]
35
+ /** subjectType, or 'all'/undefined for everything. */
36
+ readonly filter?: string
37
+ }
38
+
39
+ export type ParticipantConversationsClassNames = {
40
+ readonly root: string
41
+ readonly inbox: string
42
+ readonly thread: string
43
+ }
@@ -0,0 +1,59 @@
1
+ import type {
2
+ ParticipantAttachment,
3
+ ParticipantConversationPage,
4
+ ParticipantConversationSummary,
5
+ ParticipantMessage,
6
+ ParticipantSubjectRef,
7
+ } from '@adatechnology/conversation-contracts'
8
+
9
+ export type ParticipantSendInput = {
10
+ readonly subject: ParticipantSubjectRef
11
+ /** Generated by the package (crypto.randomUUID). The host uses it as Idempotency-Key; a resend repeats it. */
12
+ readonly clientMessageId: string
13
+ readonly text?: string
14
+ readonly files?: readonly File[]
15
+ }
16
+
17
+ export type ParticipantSendResult =
18
+ | { readonly outcome: 'sent'; readonly message: ParticipantMessage }
19
+ | { readonly outcome: 'queued' }
20
+
21
+ export type ParticipantConversationEvent =
22
+ | { readonly type: 'inbox-changed' }
23
+ | { readonly type: 'conversation-changed'; readonly subject: ParticipantSubjectRef }
24
+
25
+ export interface ParticipantConversationsApi {
26
+ listConversations(params?: { readonly cursor?: string }): Promise<ParticipantConversationPage>
27
+ fetchMessages(
28
+ subject: ParticipantSubjectRef,
29
+ params?: { readonly before?: string; readonly limit?: number },
30
+ ): Promise<readonly ParticipantMessage[]>
31
+ /** Throwing means failed; the package shows a retry affordance and repeats the SAME clientMessageId. */
32
+ sendMessage(input: ParticipantSendInput): Promise<ParticipantSendResult>
33
+ /** Marks the other side's messages of THAT subject as read, never "all". */
34
+ markRead(subject: ParticipantSubjectRef): Promise<void>
35
+ resolveAttachmentUrl(attachment: ParticipantAttachment, disposition?: 'inline' | 'attachment'): Promise<string>
36
+ openConversation?(subject: ParticipantSubjectRef): Promise<ParticipantConversationSummary>
37
+ subscribe?(listener: (event: ParticipantConversationEvent) => void): () => void
38
+ }
39
+
40
+ type ParticipantPendingAttachment = {
41
+ readonly filename: string
42
+ readonly mimeType: string
43
+ readonly sizeBytes: number
44
+ }
45
+
46
+ /** Message the host keeps in its durable queue and hands back to the package. */
47
+ export type ParticipantPendingMessage = {
48
+ readonly clientMessageId: string
49
+ readonly subject: ParticipantSubjectRef
50
+ readonly text?: string
51
+ readonly attachments?: readonly ParticipantPendingAttachment[]
52
+ readonly createdAt: string
53
+ readonly state: 'queued' | 'failed'
54
+ }
55
+
56
+ /** Message still living only in the memory of the screen: being sent, queued by the host and not yet reflected, sent but not yet echoed, or failed. */
57
+ export type ParticipantLocalPendingMessage = Omit<ParticipantPendingMessage, 'state'> & {
58
+ readonly state: 'sending' | 'queued' | 'failed' | 'sent'
59
+ }
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { isSafeAttachmentUrl } from './participantAttachmentUrl'
4
+
5
+ describe('isSafeAttachmentUrl', () => {
6
+ it('accepts https, http and blob', () => {
7
+ expect(isSafeAttachmentUrl('https://cdn.example.com/a.png?sig=1')).toBe(true)
8
+ expect(isSafeAttachmentUrl('http://localhost:3000/a.png')).toBe(true)
9
+ expect(isSafeAttachmentUrl('blob:https://app.example.com/0b1c')).toBe(true)
10
+ })
11
+
12
+ it('rejects script and data schemes in any casing or with surrounding whitespace', () => {
13
+ expect(isSafeAttachmentUrl('javascript:alert(1)')).toBe(false)
14
+ expect(isSafeAttachmentUrl('JaVaScRiPt:alert(1)')).toBe(false)
15
+ expect(isSafeAttachmentUrl(' javascript:alert(1)')).toBe(false)
16
+ expect(isSafeAttachmentUrl('java\tscript:alert(1)')).toBe(false)
17
+ expect(isSafeAttachmentUrl('data:text/html,<script>alert(1)</script>')).toBe(false)
18
+ expect(isSafeAttachmentUrl('vbscript:msgbox(1)')).toBe(false)
19
+ })
20
+
21
+ it('accepts a safe scheme in mixed case and with surrounding whitespace', () => {
22
+ expect(isSafeAttachmentUrl(' HTTPS://cdn.example.com/a.png ')).toBe(true)
23
+ })
24
+
25
+ it('rejects empty and relative urls', () => {
26
+ expect(isSafeAttachmentUrl('')).toBe(false)
27
+ expect(isSafeAttachmentUrl(' ')).toBe(false)
28
+ expect(isSafeAttachmentUrl('/files/a.png')).toBe(false)
29
+ expect(isSafeAttachmentUrl('a.png')).toBe(false)
30
+ expect(isSafeAttachmentUrl('//evil.example.com/a.png')).toBe(false)
31
+ })
32
+ })
@@ -0,0 +1,10 @@
1
+ const SAFE_ATTACHMENT_PROTOCOLS: ReadonlySet<string> = new Set(['https:', 'http:', 'blob:'])
2
+
3
+ /** The resolved URL comes from the host: only network and blob schemes reach href/src; relative urls are refused. */
4
+ export function isSafeAttachmentUrl(url: string): boolean {
5
+ try {
6
+ return SAFE_ATTACHMENT_PROTOCOLS.has(new URL(url.trim()).protocol)
7
+ } catch {
8
+ return false
9
+ }
10
+ }
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import type { ParticipantTimelineItem } from './participantMessages'
4
+ import { resolveParticipantBubbleActions } from './participantBubbleActions'
5
+
6
+ const SUBJECT = { subjectType: 'trip', subjectId: 'a' }
7
+ const noop = (): void => undefined
8
+
9
+ function pendingItem(origin: 'host' | 'local', displayState: 'sending' | 'queued' | 'failed'): ParticipantTimelineItem {
10
+ return {
11
+ kind: 'pending',
12
+ origin,
13
+ displayState,
14
+ pending: { clientMessageId: 'c1', subject: SUBJECT, createdAt: '2026-10-01T10:00:00.000Z', state: displayState === 'sending' ? 'failed' : displayState },
15
+ }
16
+ }
17
+
18
+ const HANDLERS = { retryLocal: noop, discardLocal: noop, editLocal: noop }
19
+
20
+ describe('resolveParticipantBubbleActions', () => {
21
+ it('a failed local pending offers retry, discard and edit', () => {
22
+ const actions = resolveParticipantBubbleActions(pendingItem('local', 'failed'), HANDLERS)
23
+ expect(Object.keys(actions).sort()).toEqual(['onDiscard', 'onEdit', 'onRetry'])
24
+ })
25
+
26
+ it('a pending that is not failed offers nothing', () => {
27
+ expect(resolveParticipantBubbleActions(pendingItem('local', 'sending'), HANDLERS)).toEqual({})
28
+ expect(resolveParticipantBubbleActions(pendingItem('local', 'queued'), HANDLERS)).toEqual({})
29
+ })
30
+
31
+ it('a failed host pending offers retry only when the host gave onRetryPending, never discard or edit', () => {
32
+ expect(resolveParticipantBubbleActions(pendingItem('host', 'failed'), HANDLERS)).toEqual({})
33
+ const withHost = resolveParticipantBubbleActions(pendingItem('host', 'failed'), { ...HANDLERS, retryHost: noop })
34
+ expect(Object.keys(withHost)).toEqual(['onRetry'])
35
+ })
36
+
37
+ it('a failed server message never offers any action', () => {
38
+ const item: ParticipantTimelineItem = {
39
+ kind: 'server',
40
+ message: { id: 'm1', direction: 'inbound', attachments: [], createdAt: '2026-10-01T10:00:00.000Z', status: 'failed', clientMessageId: 'c1' },
41
+ }
42
+ expect(resolveParticipantBubbleActions(item, { ...HANDLERS, retryHost: noop })).toEqual({})
43
+ })
44
+ })
@@ -0,0 +1,40 @@
1
+ import type { ParticipantTimelineItem } from './participantMessages'
2
+
3
+ export type ParticipantBubbleActions = {
4
+ readonly onRetry?: () => void
5
+ readonly onDiscard?: () => void
6
+ readonly onEdit?: () => void
7
+ }
8
+
9
+ export type ParticipantBubbleHandlers = {
10
+ readonly retryLocal: () => void
11
+ readonly discardLocal: () => void
12
+ readonly editLocal: () => void
13
+ /** Absent when the host did not pass onRetryPending: there is no way to resend what is in its queue. */
14
+ readonly retryHost?: () => void
15
+ }
16
+
17
+ /** Same handlers, addressed by clientMessageId; the thread binds them to each bubble. */
18
+ export type ParticipantPendingActions = {
19
+ readonly retryLocal: (clientMessageId: string) => void
20
+ readonly discardLocal: (clientMessageId: string) => void
21
+ readonly editLocal: (clientMessageId: string) => void
22
+ readonly retryHost?: (clientMessageId: string) => void
23
+ }
24
+
25
+ export function bindPendingActions(actions: ParticipantPendingActions, clientMessageId: string): ParticipantBubbleHandlers {
26
+ const { retryHost } = actions
27
+ return {
28
+ retryLocal: () => actions.retryLocal(clientMessageId),
29
+ discardLocal: () => actions.discardLocal(clientMessageId),
30
+ editLocal: () => actions.editLocal(clientMessageId),
31
+ ...(retryHost ? { retryHost: () => retryHost(clientMessageId) } : {}),
32
+ }
33
+ }
34
+
35
+ /** Only a failed pending can act; a failed server message has no way to be resent, so it just says "Failed". */
36
+ export function resolveParticipantBubbleActions(item: ParticipantTimelineItem, handlers: ParticipantBubbleHandlers): ParticipantBubbleActions {
37
+ if (item.kind === 'server' || item.displayState !== 'failed') return {}
38
+ if (item.origin === 'host') return handlers.retryHost ? { onRetry: handlers.retryHost } : {}
39
+ return { onRetry: handlers.retryLocal, onDiscard: handlers.discardLocal, onEdit: handlers.editLocal }
40
+ }
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { clearDraft, takeDraftForSend, draftKey, EMPTY_PARTICIPANT_DRAFT, getDraft, setDraft } from './participantDrafts'
4
+
5
+ const TRIP_A = { subjectType: 'trip', subjectId: 'a' }
6
+ const TRIP_B = { subjectType: 'trip', subjectId: 'b' }
7
+ const INVOICE_A = { subjectType: 'invoice', subjectId: 'a' }
8
+
9
+ describe('participantDrafts', () => {
10
+ it('builds the key from subject type and id', () => {
11
+ expect(draftKey(TRIP_A)).toBe('trip:a')
12
+ expect(draftKey(INVOICE_A)).not.toBe(draftKey(TRIP_A))
13
+ })
14
+
15
+ it('returns an empty draft for an unknown subject', () => {
16
+ expect(getDraft(new Map(), TRIP_A)).toEqual({ text: '', files: [] })
17
+ expect(getDraft(new Map(), TRIP_A)).toBe(EMPTY_PARTICIPANT_DRAFT)
18
+ })
19
+
20
+ it('does not leak a draft to another subject', () => {
21
+ const drafts = setDraft(new Map(), TRIP_A, { text: 'hello', files: [] })
22
+
23
+ expect(getDraft(drafts, TRIP_A).text).toBe('hello')
24
+ expect(getDraft(drafts, TRIP_B).text).toBe('')
25
+ expect(getDraft(drafts, INVOICE_A).text).toBe('')
26
+ })
27
+
28
+ it('is immutable and survives navigating away and back', () => {
29
+ const original = new Map()
30
+ const file = new File(['x'], 'a.png', { type: 'image/png' })
31
+ const withDraft = setDraft(original, TRIP_A, { text: 'hello', files: [file] })
32
+ const afterVisitingB = setDraft(withDraft, TRIP_B, { text: 'other', files: [] })
33
+
34
+ expect(original.size).toBe(0)
35
+ expect(withDraft).not.toBe(afterVisitingB)
36
+ expect(getDraft(afterVisitingB, TRIP_A)).toEqual({ text: 'hello', files: [file] })
37
+ expect(getDraft(withDraft, TRIP_B).text).toBe('')
38
+ })
39
+
40
+ it('clears only the sent subject', () => {
41
+ const both = setDraft(setDraft(new Map(), TRIP_A, { text: 'a', files: [] }), TRIP_B, { text: 'b', files: [] })
42
+ const cleared = clearDraft(both, TRIP_A)
43
+
44
+ expect(getDraft(cleared, TRIP_A).text).toBe('')
45
+ expect(getDraft(cleared, TRIP_B).text).toBe('b')
46
+ expect(getDraft(both, TRIP_A).text).toBe('a')
47
+ })
48
+
49
+ it('taking the draft for a send clears the field at once and returns trimmed text and files', () => {
50
+ const file = new File(['x'], 'a.png')
51
+ const drafts = setDraft(new Map(), TRIP_A, { text: ' hello ', files: [file] })
52
+ const taken = takeDraftForSend(drafts, TRIP_A)
53
+
54
+ expect(taken.content).toEqual({ text: 'hello', files: [file] })
55
+ expect(getDraft(taken.drafts, TRIP_A)).toEqual({ text: '', files: [] })
56
+ expect(getDraft(drafts, TRIP_A).text).toBe(' hello ')
57
+ })
58
+
59
+ it('text typed after the tap belongs to the next send, never to the one in flight', () => {
60
+ const first = takeDraftForSend(setDraft(new Map(), TRIP_A, { text: 'one', files: [] }), TRIP_A)
61
+ const typed = setDraft(first.drafts, TRIP_A, { text: 'two', files: [] })
62
+
63
+ expect(takeDraftForSend(typed, TRIP_A).content).toEqual({ text: 'two' })
64
+ })
65
+ })
@@ -0,0 +1,50 @@
1
+ import type { ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
2
+
3
+ export type ParticipantDraft = {
4
+ readonly text: string
5
+ readonly files: readonly File[]
6
+ }
7
+
8
+ export type ParticipantDrafts = ReadonlyMap<string, ParticipantDraft>
9
+
10
+ export const EMPTY_PARTICIPANT_DRAFT: ParticipantDraft = { text: '', files: [] }
11
+
12
+ export function draftKey(subject: ParticipantSubjectRef): string {
13
+ return `${subject.subjectType}:${subject.subjectId}`
14
+ }
15
+
16
+ export function getDraft(drafts: ParticipantDrafts, subject: ParticipantSubjectRef): ParticipantDraft {
17
+ return drafts.get(draftKey(subject)) ?? EMPTY_PARTICIPANT_DRAFT
18
+ }
19
+
20
+ export function setDraft(drafts: ParticipantDrafts, subject: ParticipantSubjectRef, draft: ParticipantDraft): ParticipantDrafts {
21
+ return new Map(drafts).set(draftKey(subject), draft)
22
+ }
23
+
24
+ export function clearDraft(drafts: ParticipantDrafts, subject: ParticipantSubjectRef): ParticipantDrafts {
25
+ const next = new Map(drafts)
26
+ next.delete(draftKey(subject))
27
+ return next
28
+ }
29
+
30
+ export type ParticipantSendContent = {
31
+ readonly text?: string
32
+ readonly files?: readonly File[]
33
+ }
34
+
35
+ export type TakeDraftForSendResult = {
36
+ readonly drafts: ParticipantDrafts
37
+ /** Undefined when there was nothing to send, so a second tap on send does nothing. */
38
+ readonly content?: ParticipantSendContent
39
+ }
40
+
41
+ /** The field is cleared the instant the user taps send; the bubble becomes the owner of the content. */
42
+ export function takeDraftForSend(drafts: ParticipantDrafts, subject: ParticipantSubjectRef): TakeDraftForSendResult {
43
+ const draft = getDraft(drafts, subject)
44
+ const text = draft.text.trim()
45
+ if (!text && draft.files.length === 0) return { drafts }
46
+ return {
47
+ drafts: clearDraft(drafts, subject),
48
+ content: { ...(text ? { text } : {}), ...(draft.files.length > 0 ? { files: draft.files } : {}) },
49
+ }
50
+ }
@@ -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
+ })