@adatechnology/conversations-ui 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/README.md +231 -0
  2. package/dist/{ConversationSimulatorPanel-DnXElfY8.d.ts → ConversationSimulatorPanel-CrfFjiWh.d.ts} +2 -268
  3. package/dist/{chunk-PJNR6UMN.js → chunk-CBYEHPX6.js} +1 -63
  4. package/dist/{chunk-4P5KMWOI.js → chunk-FAQLC3TX.js} +327 -522
  5. package/dist/{chunk-HMCOTZAX.js → chunk-HOKNKXIT.js} +1 -1
  6. package/dist/chunk-MRVFFJEH.js +64 -0
  7. package/dist/chunk-O6457XHK.js +255 -0
  8. package/dist/chunk-OVFCLRQQ.js +84 -0
  9. package/dist/flows/index.js +5 -3
  10. package/dist/index.d.ts +21 -5
  11. package/dist/index.js +783 -807
  12. package/dist/participant/index.d.ts +186 -0
  13. package/dist/participant/index.js +1708 -0
  14. package/dist/preview/index.d.ts +4 -2
  15. package/dist/preview/index.js +4 -2
  16. package/dist/quickReply.types-B4gmZsSr.d.ts +274 -0
  17. package/dist/styles.css +689 -0
  18. package/package.json +8 -4
  19. package/src/ChannelIcon.tsx +2 -0
  20. package/src/MessageText.test.tsx +76 -0
  21. package/src/MessageText.tsx +28 -12
  22. package/src/MessageTextCopiedBadge.tsx +15 -0
  23. package/src/StatusTicks.test.tsx +51 -0
  24. package/src/StatusTicks.tsx +36 -6
  25. package/src/buildOutput.test.ts +41 -0
  26. package/src/channelCapability.test.ts +17 -0
  27. package/src/conversationChannel.test.ts +30 -1
  28. package/src/conversationChannel.ts +19 -1
  29. package/src/legacyChannels.golden.test.ts +89 -0
  30. package/src/legacyDefaults.golden.test.tsx +127 -0
  31. package/src/legacyStyles.fixture.css +1121 -0
  32. package/src/legacyStyles.golden.test.ts +121 -0
  33. package/src/messageTextCopyInteraction.test.ts +75 -0
  34. package/src/messageTextCopyInteraction.ts +37 -0
  35. package/src/participant/ParticipantAttachmentItem.tsx +77 -0
  36. package/src/participant/ParticipantChannelBadges.test.tsx +59 -0
  37. package/src/participant/ParticipantChannelBadges.tsx +75 -0
  38. package/src/participant/ParticipantComposer.test.tsx +97 -0
  39. package/src/participant/ParticipantComposer.tsx +167 -0
  40. package/src/participant/ParticipantConversations.test.tsx +90 -0
  41. package/src/participant/ParticipantConversations.tsx +49 -0
  42. package/src/participant/ParticipantConversationsScreen.tsx +78 -0
  43. package/src/participant/ParticipantInbox.test.tsx +244 -0
  44. package/src/participant/ParticipantInbox.tsx +149 -0
  45. package/src/participant/ParticipantInboxRow.tsx +75 -0
  46. package/src/participant/ParticipantInboxSearchField.tsx +34 -0
  47. package/src/participant/ParticipantLoadState.tsx +27 -0
  48. package/src/participant/ParticipantMessageBubble.test.tsx +167 -0
  49. package/src/participant/ParticipantMessageBubble.tsx +185 -0
  50. package/src/participant/ParticipantProtocolBadge.tsx +74 -0
  51. package/src/participant/ParticipantProtocolView.test.tsx +208 -0
  52. package/src/participant/ParticipantThread.test.tsx +149 -0
  53. package/src/participant/ParticipantThread.tsx +184 -0
  54. package/src/participant/ParticipantThreadScreen.tsx +135 -0
  55. package/src/participant/bindAttachmentUrlResolver.test.ts +25 -0
  56. package/src/participant/bindAttachmentUrlResolver.ts +7 -0
  57. package/src/participant/index.ts +26 -0
  58. package/src/participant/participant.types.ts +52 -0
  59. package/src/participant/participantApi.types.ts +59 -0
  60. package/src/participant/participantAttachmentUrl.test.ts +32 -0
  61. package/src/participant/participantAttachmentUrl.ts +10 -0
  62. package/src/participant/participantBubbleActions.test.ts +44 -0
  63. package/src/participant/participantBubbleActions.ts +40 -0
  64. package/src/participant/participantChannels.test.ts +42 -0
  65. package/src/participant/participantChannels.ts +33 -0
  66. package/src/participant/participantDrafts.test.ts +65 -0
  67. package/src/participant/participantDrafts.ts +50 -0
  68. package/src/participant/participantFileValidation.test.ts +86 -0
  69. package/src/participant/participantFileValidation.ts +72 -0
  70. package/src/participant/participantFixtures.test-helper.ts +41 -0
  71. package/src/participant/participantGrouping.test.ts +236 -0
  72. package/src/participant/participantGrouping.ts +90 -0
  73. package/src/participant/participantInboxState.test.ts +69 -0
  74. package/src/participant/participantInboxState.ts +74 -0
  75. package/src/participant/participantLabels.ts +115 -0
  76. package/src/participant/participantLoadView.test.ts +23 -0
  77. package/src/participant/participantLoadView.ts +21 -0
  78. package/src/participant/participantMessages.test.ts +128 -0
  79. package/src/participant/participantMessages.ts +73 -0
  80. package/src/participant/participantNewMessages.test.ts +54 -0
  81. package/src/participant/participantNewMessages.ts +29 -0
  82. package/src/participant/participantProtocol.test.ts +104 -0
  83. package/src/participant/participantProtocol.ts +52 -0
  84. package/src/participant/participantProtocolCopy.test.ts +45 -0
  85. package/src/participant/participantProtocolCopy.ts +32 -0
  86. package/src/participant/participantQuickReply.test.ts +23 -0
  87. package/src/participant/participantQuickReply.ts +6 -0
  88. package/src/participant/participantScroll.test.ts +69 -0
  89. package/src/participant/participantScroll.ts +43 -0
  90. package/src/participant/participantSendController.test.ts +201 -0
  91. package/src/participant/participantSendController.ts +134 -0
  92. package/src/participant/participantSendStates.ts +31 -0
  93. package/src/participant/participantTheme.ts +20 -0
  94. package/src/participant/participantThreadState.test.ts +75 -0
  95. package/src/participant/participantThreadState.ts +59 -0
  96. package/src/participant/shouldMarkRead.test.ts +24 -0
  97. package/src/participant/shouldMarkRead.ts +16 -0
  98. package/src/participant/useDocumentVisibility.ts +18 -0
  99. package/src/participant/useParticipantConversationView.ts +60 -0
  100. package/src/participant/useParticipantDraft.ts +45 -0
  101. package/src/participant/useParticipantInbox.ts +57 -0
  102. package/src/participant/useParticipantMarkRead.ts +26 -0
  103. package/src/participant/useParticipantRevalidation.ts +36 -0
  104. package/src/participant/useParticipantSend.ts +84 -0
  105. package/src/participant/useParticipantThread.ts +116 -0
  106. package/src/participant/useParticipantThreadScroll.ts +26 -0
  107. package/src/participant/useStickToBottom.ts +77 -0
  108. package/src/preview/ConversationSimulatorPanel.tsx +8 -0
  109. package/src/styles.css +178 -0
  110. package/src/types.ts +3 -1
@@ -0,0 +1,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,26 @@
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
+ ParticipantSubjectIconRenderer,
8
+ ParticipantInboxView,
9
+ ParticipantInboxSection,
10
+ GroupParticipantConversationsParams,
11
+ } from './participant.types'
12
+ export type {
13
+ ParticipantConversationEvent,
14
+ ParticipantConversationsApi,
15
+ ParticipantLocalPendingMessage,
16
+ ParticipantPendingMessage,
17
+ ParticipantSendInput,
18
+ ParticipantSendResult,
19
+ } from './participantApi.types'
20
+ export type {
21
+ ParticipantAttachment,
22
+ ParticipantConversationPage,
23
+ ParticipantConversationSummary,
24
+ ParticipantMessage,
25
+ ParticipantSubjectRef,
26
+ } from '@adatechnology/conversation-contracts'
@@ -0,0 +1,52 @@
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
+ /** Host-drawn icon for a list row; null/undefined falls back to the subject group icon. */
12
+ export type ParticipantSubjectIconRenderer = (conversation: ParticipantConversationSummary) => ReactNode
13
+
14
+ export type ParticipantInboxSection = {
15
+ /** 'awaiting' | 'closed' | 'other' or the subjectType of a group. */
16
+ readonly key: string
17
+ /** Absent for groups: the host resolves it from subjectGroups. */
18
+ readonly label?: string
19
+ readonly conversations: readonly ParticipantConversationSummary[]
20
+ readonly unreadCount: number
21
+ }
22
+
23
+ export type ParticipantInboxFilter = {
24
+ readonly subjectType: string
25
+ readonly label: string
26
+ readonly unreadCount: number
27
+ }
28
+
29
+ export type ParticipantInboxView = {
30
+ readonly sections: readonly ParticipantInboxSection[]
31
+ readonly filters: readonly ParticipantInboxFilter[]
32
+ readonly showFilters: boolean
33
+ /** The filter actually applied: 'all' when the requested one has no conversations left. */
34
+ readonly activeFilter: string
35
+ /** The search finds conversations, but none under the active subject filter. */
36
+ readonly hasMatchesOutsideFilter: boolean
37
+ }
38
+
39
+ export type GroupParticipantConversationsParams = {
40
+ readonly conversations: readonly ParticipantConversationSummary[]
41
+ readonly subjectGroups: readonly ParticipantSubjectGroup[]
42
+ /** subjectType, or 'all'/undefined for everything. */
43
+ readonly filter?: string
44
+ /** Narrows the rows only: chips and their counts always describe the whole list. */
45
+ readonly searchQuery?: string
46
+ }
47
+
48
+ export type ParticipantConversationsClassNames = {
49
+ readonly root: string
50
+ readonly inbox: string
51
+ readonly thread: string
52
+ }
@@ -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,42 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
4
+ import {
5
+ PARTICIPANT_CHANNEL_ORDER,
6
+ resolveParticipantChannelLabel,
7
+ resolveParticipantChannels,
8
+ } from './participantChannels'
9
+
10
+ const channelsOf = (input: readonly string[] | undefined) =>
11
+ resolveParticipantChannels(input).map((descriptor) => descriptor.channel)
12
+
13
+ describe('resolveParticipantChannels', () => {
14
+ it('returns nothing for absent or empty input', () => {
15
+ expect(channelsOf(undefined)).toEqual([])
16
+ expect(channelsOf([])).toEqual([])
17
+ })
18
+
19
+ it('orders deterministically whatever the server order', () => {
20
+ expect(channelsOf(['webchat', 'email', 'whatsapp', 'app', 'portal'])).toEqual([
21
+ 'app',
22
+ 'whatsapp',
23
+ 'email',
24
+ 'portal',
25
+ 'webchat',
26
+ ])
27
+ expect(channelsOf(['whatsapp', 'app'])).toEqual(['app', 'whatsapp'])
28
+ })
29
+
30
+ it('dedupes and ignores unknown channels', () => {
31
+ expect(channelsOf(['whatsapp', 'whatsapp', 'sms', 'app'])).toEqual(['app', 'whatsapp'])
32
+ })
33
+
34
+ it('maps every channel to an icon and a label', () => {
35
+ expect(PARTICIPANT_CHANNEL_ORDER).toHaveLength(5)
36
+ const labels = PARTICIPANT_CHANNEL_ORDER.map((descriptor) =>
37
+ resolveParticipantChannelLabel(descriptor, DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS),
38
+ )
39
+ expect(labels).toEqual(['App', 'WhatsApp', 'Email', 'Portal', 'Web chat'])
40
+ expect(new Set(PARTICIPANT_CHANNEL_ORDER.map((descriptor) => descriptor.iconName)).size).toBe(5)
41
+ })
42
+ })
@@ -0,0 +1,33 @@
1
+ import type { ConversationChannel } from '@adatechnology/conversation-contracts'
2
+
3
+ import type { ParticipantConversationsLabels } from './participantLabels'
4
+
5
+ export type ParticipantChannelDescriptor = {
6
+ readonly channel: ConversationChannel
7
+ readonly iconName: 'Smartphone' | 'MessageCircle' | 'Mail' | 'Globe' | 'MessagesSquare'
8
+ readonly labelKey: 'channelApp' | 'channelWhatsapp' | 'channelEmail' | 'channelPortal' | 'channelWebchat'
9
+ }
10
+
11
+ export const PARTICIPANT_CHANNEL_ORDER: readonly ParticipantChannelDescriptor[] = [
12
+ { channel: 'app', iconName: 'Smartphone', labelKey: 'channelApp' },
13
+ { channel: 'whatsapp', iconName: 'MessageCircle', labelKey: 'channelWhatsapp' },
14
+ { channel: 'email', iconName: 'Mail', labelKey: 'channelEmail' },
15
+ { channel: 'portal', iconName: 'Globe', labelKey: 'channelPortal' },
16
+ { channel: 'webchat', iconName: 'MessagesSquare', labelKey: 'channelWebchat' },
17
+ ]
18
+
19
+ /** Stable badge order regardless of the server order; duplicates and unknown values (runtime) are dropped. */
20
+ export function resolveParticipantChannels(
21
+ channels: readonly string[] | undefined,
22
+ ): readonly ParticipantChannelDescriptor[] {
23
+ if (channels === undefined || channels.length === 0) return []
24
+ const present = new Set<string>(channels)
25
+ return PARTICIPANT_CHANNEL_ORDER.filter((descriptor) => present.has(descriptor.channel))
26
+ }
27
+
28
+ export function resolveParticipantChannelLabel(
29
+ descriptor: ParticipantChannelDescriptor,
30
+ labels: ParticipantConversationsLabels,
31
+ ): string {
32
+ return labels[descriptor.labelKey]
33
+ }
@@ -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
+ }