@adatechnology/conversations-ui 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +169 -0
- package/dist/{ConversationSimulatorPanel-DnXElfY8.d.ts → ConversationSimulatorPanel-CrfFjiWh.d.ts} +2 -268
- package/dist/{chunk-PJNR6UMN.js → chunk-CBYEHPX6.js} +1 -63
- package/dist/{chunk-HMCOTZAX.js → chunk-HOKNKXIT.js} +1 -1
- package/dist/{chunk-4P5KMWOI.js → chunk-JXJCPN34.js} +327 -522
- package/dist/chunk-MRVFFJEH.js +64 -0
- package/dist/chunk-RJMSM3YA.js +249 -0
- package/dist/chunk-WSFGNR5R.js +55 -0
- package/dist/flows/index.js +5 -3
- package/dist/index.d.ts +9 -3
- package/dist/index.js +783 -807
- package/dist/participant/index.d.ts +162 -0
- package/dist/participant/index.js +1448 -0
- package/dist/preview/index.d.ts +4 -2
- package/dist/preview/index.js +4 -2
- package/dist/quickReply.types-B4gmZsSr.d.ts +274 -0
- package/dist/styles.css +591 -0
- package/package.json +9 -5
- package/src/ChannelIcon.tsx +2 -0
- package/src/MessageBubble.test.tsx +2 -2
- package/src/MessageText.test.tsx +59 -0
- package/src/MessageText.tsx +20 -12
- package/src/MessageTextCopiedBadge.tsx +7 -0
- package/src/StatusTicks.test.tsx +30 -0
- package/src/StatusTicks.tsx +24 -9
- package/src/buildOutput.test.ts +41 -0
- package/src/channelCapability.test.ts +17 -0
- package/src/conversationChannel.test.ts +30 -1
- package/src/conversationChannel.ts +19 -1
- package/src/participant/ParticipantAttachmentItem.tsx +77 -0
- package/src/participant/ParticipantComposer.test.tsx +97 -0
- package/src/participant/ParticipantComposer.tsx +167 -0
- package/src/participant/ParticipantConversations.test.tsx +90 -0
- package/src/participant/ParticipantConversations.tsx +42 -0
- package/src/participant/ParticipantConversationsScreen.tsx +67 -0
- package/src/participant/ParticipantInbox.test.tsx +147 -0
- package/src/participant/ParticipantInbox.tsx +168 -0
- package/src/participant/ParticipantLoadState.tsx +27 -0
- package/src/participant/ParticipantMessageBubble.test.tsx +165 -0
- package/src/participant/ParticipantMessageBubble.tsx +185 -0
- package/src/participant/ParticipantThread.test.tsx +132 -0
- package/src/participant/ParticipantThread.tsx +174 -0
- package/src/participant/ParticipantThreadScreen.tsx +135 -0
- package/src/participant/bindAttachmentUrlResolver.test.ts +25 -0
- package/src/participant/bindAttachmentUrlResolver.ts +7 -0
- package/src/participant/index.ts +25 -0
- package/src/participant/participant.types.ts +43 -0
- package/src/participant/participantApi.types.ts +59 -0
- package/src/participant/participantAttachmentUrl.test.ts +32 -0
- package/src/participant/participantAttachmentUrl.ts +10 -0
- package/src/participant/participantBubbleActions.test.ts +44 -0
- package/src/participant/participantBubbleActions.ts +40 -0
- package/src/participant/participantDrafts.test.ts +65 -0
- package/src/participant/participantDrafts.ts +50 -0
- package/src/participant/participantFileValidation.test.ts +86 -0
- package/src/participant/participantFileValidation.ts +72 -0
- package/src/participant/participantFixtures.test-helper.ts +41 -0
- package/src/participant/participantGrouping.test.ts +138 -0
- package/src/participant/participantGrouping.ts +77 -0
- package/src/participant/participantInboxState.test.ts +69 -0
- package/src/participant/participantInboxState.ts +74 -0
- package/src/participant/participantLabels.ts +86 -0
- package/src/participant/participantLoadView.test.ts +23 -0
- package/src/participant/participantLoadView.ts +21 -0
- package/src/participant/participantMessages.test.ts +128 -0
- package/src/participant/participantMessages.ts +73 -0
- package/src/participant/participantNewMessages.test.ts +54 -0
- package/src/participant/participantNewMessages.ts +29 -0
- package/src/participant/participantQuickReply.test.ts +23 -0
- package/src/participant/participantQuickReply.ts +6 -0
- package/src/participant/participantScroll.test.ts +69 -0
- package/src/participant/participantScroll.ts +43 -0
- package/src/participant/participantSendController.test.ts +201 -0
- package/src/participant/participantSendController.ts +134 -0
- package/src/participant/participantSendStates.ts +31 -0
- package/src/participant/participantTheme.ts +20 -0
- package/src/participant/participantThreadState.test.ts +75 -0
- package/src/participant/participantThreadState.ts +59 -0
- package/src/participant/shouldMarkRead.test.ts +24 -0
- package/src/participant/shouldMarkRead.ts +16 -0
- package/src/participant/useDocumentVisibility.ts +18 -0
- package/src/participant/useParticipantConversationView.ts +60 -0
- package/src/participant/useParticipantDraft.ts +45 -0
- package/src/participant/useParticipantInbox.ts +57 -0
- package/src/participant/useParticipantMarkRead.ts +26 -0
- package/src/participant/useParticipantRevalidation.ts +36 -0
- package/src/participant/useParticipantSend.ts +84 -0
- package/src/participant/useParticipantThread.ts +116 -0
- package/src/participant/useParticipantThreadScroll.ts +26 -0
- package/src/participant/useStickToBottom.ts +77 -0
- package/src/preview/ConversationSimulatorPanel.tsx +8 -0
- package/src/styles.css +160 -0
- package/src/types.ts +3 -1
|
@@ -0,0 +1,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
|
+
})
|