@adatechnology/conversations-ui 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +169 -0
- package/dist/{ConversationSimulatorPanel-DnXElfY8.d.ts → ConversationSimulatorPanel-CrfFjiWh.d.ts} +2 -268
- package/dist/{chunk-PJNR6UMN.js → chunk-CBYEHPX6.js} +1 -63
- package/dist/{chunk-HMCOTZAX.js → chunk-HOKNKXIT.js} +1 -1
- package/dist/{chunk-4P5KMWOI.js → chunk-JXJCPN34.js} +327 -522
- package/dist/chunk-MRVFFJEH.js +64 -0
- package/dist/chunk-RJMSM3YA.js +249 -0
- package/dist/chunk-WSFGNR5R.js +55 -0
- package/dist/flows/index.js +5 -3
- package/dist/index.d.ts +9 -3
- package/dist/index.js +783 -807
- package/dist/participant/index.d.ts +162 -0
- package/dist/participant/index.js +1448 -0
- package/dist/preview/index.d.ts +4 -2
- package/dist/preview/index.js +4 -2
- package/dist/quickReply.types-B4gmZsSr.d.ts +274 -0
- package/dist/styles.css +591 -0
- package/package.json +8 -4
- package/src/ChannelIcon.tsx +2 -0
- package/src/MessageBubble.test.tsx +2 -2
- package/src/MessageText.test.tsx +59 -0
- package/src/MessageText.tsx +20 -12
- package/src/MessageTextCopiedBadge.tsx +7 -0
- package/src/StatusTicks.test.tsx +30 -0
- package/src/StatusTicks.tsx +24 -9
- package/src/buildOutput.test.ts +41 -0
- package/src/channelCapability.test.ts +17 -0
- package/src/conversationChannel.test.ts +30 -1
- package/src/conversationChannel.ts +19 -1
- package/src/participant/ParticipantAttachmentItem.tsx +77 -0
- package/src/participant/ParticipantComposer.test.tsx +97 -0
- package/src/participant/ParticipantComposer.tsx +167 -0
- package/src/participant/ParticipantConversations.test.tsx +90 -0
- package/src/participant/ParticipantConversations.tsx +42 -0
- package/src/participant/ParticipantConversationsScreen.tsx +67 -0
- package/src/participant/ParticipantInbox.test.tsx +147 -0
- package/src/participant/ParticipantInbox.tsx +168 -0
- package/src/participant/ParticipantLoadState.tsx +27 -0
- package/src/participant/ParticipantMessageBubble.test.tsx +165 -0
- package/src/participant/ParticipantMessageBubble.tsx +185 -0
- package/src/participant/ParticipantThread.test.tsx +132 -0
- package/src/participant/ParticipantThread.tsx +174 -0
- package/src/participant/ParticipantThreadScreen.tsx +135 -0
- package/src/participant/bindAttachmentUrlResolver.test.ts +25 -0
- package/src/participant/bindAttachmentUrlResolver.ts +7 -0
- package/src/participant/index.ts +25 -0
- package/src/participant/participant.types.ts +43 -0
- package/src/participant/participantApi.types.ts +59 -0
- package/src/participant/participantAttachmentUrl.test.ts +32 -0
- package/src/participant/participantAttachmentUrl.ts +10 -0
- package/src/participant/participantBubbleActions.test.ts +44 -0
- package/src/participant/participantBubbleActions.ts +40 -0
- package/src/participant/participantDrafts.test.ts +65 -0
- package/src/participant/participantDrafts.ts +50 -0
- package/src/participant/participantFileValidation.test.ts +86 -0
- package/src/participant/participantFileValidation.ts +72 -0
- package/src/participant/participantFixtures.test-helper.ts +41 -0
- package/src/participant/participantGrouping.test.ts +138 -0
- package/src/participant/participantGrouping.ts +77 -0
- package/src/participant/participantInboxState.test.ts +69 -0
- package/src/participant/participantInboxState.ts +74 -0
- package/src/participant/participantLabels.ts +86 -0
- package/src/participant/participantLoadView.test.ts +23 -0
- package/src/participant/participantLoadView.ts +21 -0
- package/src/participant/participantMessages.test.ts +128 -0
- package/src/participant/participantMessages.ts +73 -0
- package/src/participant/participantNewMessages.test.ts +54 -0
- package/src/participant/participantNewMessages.ts +29 -0
- package/src/participant/participantQuickReply.test.ts +23 -0
- package/src/participant/participantQuickReply.ts +6 -0
- package/src/participant/participantScroll.test.ts +69 -0
- package/src/participant/participantScroll.ts +43 -0
- package/src/participant/participantSendController.test.ts +201 -0
- package/src/participant/participantSendController.ts +134 -0
- package/src/participant/participantSendStates.ts +31 -0
- package/src/participant/participantTheme.ts +20 -0
- package/src/participant/participantThreadState.test.ts +75 -0
- package/src/participant/participantThreadState.ts +59 -0
- package/src/participant/shouldMarkRead.test.ts +24 -0
- package/src/participant/shouldMarkRead.ts +16 -0
- package/src/participant/useDocumentVisibility.ts +18 -0
- package/src/participant/useParticipantConversationView.ts +60 -0
- package/src/participant/useParticipantDraft.ts +45 -0
- package/src/participant/useParticipantInbox.ts +57 -0
- package/src/participant/useParticipantMarkRead.ts +26 -0
- package/src/participant/useParticipantRevalidation.ts +36 -0
- package/src/participant/useParticipantSend.ts +84 -0
- package/src/participant/useParticipantThread.ts +116 -0
- package/src/participant/useParticipantThreadScroll.ts +26 -0
- package/src/participant/useStickToBottom.ts +77 -0
- package/src/preview/ConversationSimulatorPanel.tsx +8 -0
- package/src/styles.css +160 -0
- package/src/types.ts +3 -1
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { ParticipantMessage, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
|
|
2
|
+
|
|
3
|
+
import type { ParticipantLocalPendingMessage, ParticipantPendingMessage } from './participantApi.types'
|
|
4
|
+
import type { ParticipantDraft, ParticipantSendContent } from './participantDrafts'
|
|
5
|
+
|
|
6
|
+
/** 'sent': the server accepted it; the bubble stays until the echo with the clientMessageId shows up in the messages. */
|
|
7
|
+
export type ParticipantSendEntryState = 'sending' | 'queued' | 'failed' | 'sent'
|
|
8
|
+
|
|
9
|
+
/** The bubble owns the content of a send: text and files live here until the server (or the host queue) reflects the id. */
|
|
10
|
+
export type ParticipantSendEntry = {
|
|
11
|
+
readonly clientMessageId: string
|
|
12
|
+
readonly subject: ParticipantSubjectRef
|
|
13
|
+
readonly text?: string
|
|
14
|
+
readonly files: readonly File[]
|
|
15
|
+
readonly createdAt: string
|
|
16
|
+
readonly state: ParticipantSendEntryState
|
|
17
|
+
readonly sentMessage?: ParticipantMessage
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type ParticipantSendAction =
|
|
21
|
+
| { readonly type: 'started'; readonly entry: ParticipantSendEntry }
|
|
22
|
+
| { readonly type: 'sent'; readonly clientMessageId: string; readonly message: ParticipantMessage }
|
|
23
|
+
| { readonly type: 'queued'; readonly clientMessageId: string }
|
|
24
|
+
| { readonly type: 'failed'; readonly clientMessageId: string }
|
|
25
|
+
| { readonly type: 'retried'; readonly clientMessageId: string }
|
|
26
|
+
| { readonly type: 'discarded'; readonly clientMessageId: string }
|
|
27
|
+
| { readonly type: 'reflected'; readonly knownClientMessageIds: ReadonlySet<string> }
|
|
28
|
+
|
|
29
|
+
export type CreateParticipantSendEntryParams = {
|
|
30
|
+
readonly clientMessageId: string
|
|
31
|
+
readonly subject: ParticipantSubjectRef
|
|
32
|
+
readonly content: ParticipantSendContent
|
|
33
|
+
readonly createdAt: string
|
|
34
|
+
readonly files?: readonly File[]
|
|
35
|
+
readonly state?: ParticipantSendEntryState
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type CollectKnownClientMessageIdsParams = {
|
|
39
|
+
readonly messages: readonly ParticipantMessage[]
|
|
40
|
+
readonly hostPending: readonly ParticipantPendingMessage[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createParticipantSendEntry(params: CreateParticipantSendEntryParams): ParticipantSendEntry {
|
|
44
|
+
const { clientMessageId, subject, content, createdAt, state = 'sending' } = params
|
|
45
|
+
return {
|
|
46
|
+
clientMessageId,
|
|
47
|
+
subject,
|
|
48
|
+
...(content.text ? { text: content.text } : {}),
|
|
49
|
+
files: params.files ?? content.files ?? [],
|
|
50
|
+
createdAt,
|
|
51
|
+
state,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function setState(
|
|
56
|
+
entries: readonly ParticipantSendEntry[],
|
|
57
|
+
clientMessageId: string,
|
|
58
|
+
allowedFrom: readonly ParticipantSendEntryState[],
|
|
59
|
+
next: ParticipantSendEntryState,
|
|
60
|
+
): readonly ParticipantSendEntry[] {
|
|
61
|
+
const target = entries.find((item) => item.clientMessageId === clientMessageId)
|
|
62
|
+
if (!target || !allowedFrom.includes(target.state) || target.state === next) return entries
|
|
63
|
+
return entries.map((item) => (item === target ? { ...item, state: next } : item))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function markSent(entries: readonly ParticipantSendEntry[], clientMessageId: string, message: ParticipantMessage): readonly ParticipantSendEntry[] {
|
|
67
|
+
const target = entries.find((item) => item.clientMessageId === clientMessageId)
|
|
68
|
+
if (!target || target.state === 'sent') return entries
|
|
69
|
+
return entries.map((item) => (item === target ? { ...item, state: 'sent', sentMessage: message } : item))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function remove(entries: readonly ParticipantSendEntry[], shouldRemove: (item: ParticipantSendEntry) => boolean): readonly ParticipantSendEntry[] {
|
|
73
|
+
const kept = entries.filter((item) => !shouldRemove(item))
|
|
74
|
+
return kept.length === entries.length ? entries : kept
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function participantSendReducer(
|
|
78
|
+
entries: readonly ParticipantSendEntry[],
|
|
79
|
+
action: ParticipantSendAction,
|
|
80
|
+
): readonly ParticipantSendEntry[] {
|
|
81
|
+
switch (action.type) {
|
|
82
|
+
case 'started':
|
|
83
|
+
return [...entries.filter((item) => item.clientMessageId !== action.entry.clientMessageId), action.entry]
|
|
84
|
+
case 'discarded':
|
|
85
|
+
return remove(entries, (item) => item.clientMessageId === action.clientMessageId)
|
|
86
|
+
case 'sent':
|
|
87
|
+
return markSent(entries, action.clientMessageId, action.message)
|
|
88
|
+
case 'queued':
|
|
89
|
+
return setState(entries, action.clientMessageId, ['sending'], 'queued')
|
|
90
|
+
case 'failed':
|
|
91
|
+
return setState(entries, action.clientMessageId, ['sending', 'queued'], 'failed')
|
|
92
|
+
case 'retried':
|
|
93
|
+
return setState(entries, action.clientMessageId, ['failed'], 'sending')
|
|
94
|
+
case 'reflected':
|
|
95
|
+
return remove(entries, (item) => action.knownClientMessageIds.has(item.clientMessageId))
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function findFailedEntry(entries: readonly ParticipantSendEntry[], clientMessageId: string): ParticipantSendEntry | undefined {
|
|
100
|
+
return entries.find((item) => item.clientMessageId === clientMessageId && item.state === 'failed')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function hasSendingEntry(entries: readonly ParticipantSendEntry[]): boolean {
|
|
104
|
+
return entries.some((item) => item.state === 'sending')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function collectKnownClientMessageIds(params: CollectKnownClientMessageIdsParams): ReadonlySet<string> {
|
|
108
|
+
const known = new Set<string>()
|
|
109
|
+
for (const message of params.messages) {
|
|
110
|
+
if (message.clientMessageId) known.add(message.clientMessageId)
|
|
111
|
+
}
|
|
112
|
+
for (const pending of params.hostPending) known.add(pending.clientMessageId)
|
|
113
|
+
return known
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function toLocalPending(entries: readonly ParticipantSendEntry[]): readonly ParticipantLocalPendingMessage[] {
|
|
117
|
+
return entries.map((item) => {
|
|
118
|
+
const attachments = item.files.map((file) => ({ filename: file.name, mimeType: file.type, sizeBytes: file.size }))
|
|
119
|
+
return {
|
|
120
|
+
clientMessageId: item.clientMessageId,
|
|
121
|
+
subject: item.subject,
|
|
122
|
+
createdAt: item.createdAt,
|
|
123
|
+
state: item.state,
|
|
124
|
+
...(item.text ? { text: item.text } : {}),
|
|
125
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Edit hands the content back to the field; whatever the user typed meanwhile stays after it. */
|
|
131
|
+
export function appendEntryToDraft(entry: ParticipantSendEntry, current: ParticipantDraft): ParticipantDraft {
|
|
132
|
+
const text = [entry.text, current.text].filter((part): part is string => Boolean(part)).join('\n')
|
|
133
|
+
return { text, files: [...entry.files, ...current.files] }
|
|
134
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
|
|
2
|
+
|
|
3
|
+
import { draftKey } from './participantDrafts'
|
|
4
|
+
import { participantSendReducer, type ParticipantSendAction, type ParticipantSendEntry } from './participantSendController'
|
|
5
|
+
|
|
6
|
+
/** Entries of every subject, kept above the conversation so leaving and coming back does not lose a failed message. */
|
|
7
|
+
export type ParticipantSendStates = ReadonlyMap<string, readonly ParticipantSendEntry[]>
|
|
8
|
+
|
|
9
|
+
export type ParticipantSendStatesAction = {
|
|
10
|
+
readonly subject: ParticipantSubjectRef
|
|
11
|
+
readonly action: ParticipantSendAction
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const NO_ENTRIES: readonly ParticipantSendEntry[] = []
|
|
15
|
+
|
|
16
|
+
export function selectSendEntries(states: ParticipantSendStates, subject: ParticipantSubjectRef): readonly ParticipantSendEntry[] {
|
|
17
|
+
return states.get(draftKey(subject)) ?? NO_ENTRIES
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function participantSendStatesReducer(
|
|
21
|
+
states: ParticipantSendStates,
|
|
22
|
+
{ subject, action }: ParticipantSendStatesAction,
|
|
23
|
+
): ParticipantSendStates {
|
|
24
|
+
const current = selectSendEntries(states, subject)
|
|
25
|
+
const next = participantSendReducer(current, action)
|
|
26
|
+
if (next === current) return states
|
|
27
|
+
const copy = new Map(states)
|
|
28
|
+
if (next.length === 0) copy.delete(draftKey(subject))
|
|
29
|
+
else copy.set(draftKey(subject), next)
|
|
30
|
+
return copy
|
|
31
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { CSSProperties } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { ConversationsTheme } from '../types'
|
|
4
|
+
|
|
5
|
+
const THEME_TO_VARIABLE = {
|
|
6
|
+
primaryColor: '--cv-p-accent',
|
|
7
|
+
backgroundColor: '--cv-p-surface',
|
|
8
|
+
textPrimary: '--cv-p-text',
|
|
9
|
+
textSecondary: '--cv-p-text-muted',
|
|
10
|
+
} as const
|
|
11
|
+
|
|
12
|
+
export function buildParticipantThemeStyle(theme: ConversationsTheme | undefined): CSSProperties | undefined {
|
|
13
|
+
if (!theme) return undefined
|
|
14
|
+
const style: Record<string, string> = {}
|
|
15
|
+
for (const [key, variable] of Object.entries(THEME_TO_VARIABLE)) {
|
|
16
|
+
const value = theme[key as keyof typeof THEME_TO_VARIABLE]
|
|
17
|
+
if (value) style[variable] = value
|
|
18
|
+
}
|
|
19
|
+
return Object.keys(style).length > 0 ? (style as CSSProperties) : undefined
|
|
20
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import type { ParticipantMessage } from '@adatechnology/conversation-contracts'
|
|
4
|
+
|
|
5
|
+
import { INITIAL_PARTICIPANT_THREAD_STATE, participantThreadReducer, type ParticipantThreadState } from './participantThreadState'
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
function message(id: string, createdAt: string, overrides: Partial<ParticipantMessage> = {}): ParticipantMessage {
|
|
9
|
+
return { id, direction: 'outbound', attachments: [], createdAt, ...overrides }
|
|
10
|
+
}
|
|
11
|
+
function ids(state: ParticipantThreadState): string[] {
|
|
12
|
+
return state.messages.map((item) => item.id)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('participantThreadReducer', () => {
|
|
16
|
+
it('loaded stores messages ascending and the hasMore flag', () => {
|
|
17
|
+
const state = participantThreadReducer(INITIAL_PARTICIPANT_THREAD_STATE, {
|
|
18
|
+
type: 'loaded',
|
|
19
|
+
messages: [message('m2', '2026-10-01T10:02:00.000Z'), message('m1', '2026-10-01T10:01:00.000Z')],
|
|
20
|
+
hasMore: true,
|
|
21
|
+
})
|
|
22
|
+
expect(ids(state)).toEqual(['m1', 'm2'])
|
|
23
|
+
expect(state).toMatchObject({ status: 'ready', hasMore: true })
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('olderLoaded prepends without duplicating by id', () => {
|
|
27
|
+
const first = participantThreadReducer(INITIAL_PARTICIPANT_THREAD_STATE, {
|
|
28
|
+
type: 'loaded',
|
|
29
|
+
messages: [message('m3', '2026-10-01T10:03:00.000Z'), message('m4', '2026-10-01T10:04:00.000Z')],
|
|
30
|
+
hasMore: true,
|
|
31
|
+
})
|
|
32
|
+
const next = participantThreadReducer(first, {
|
|
33
|
+
type: 'olderLoaded',
|
|
34
|
+
messages: [message('m1', '2026-10-01T10:01:00.000Z'), message('m2', '2026-10-01T10:02:00.000Z'), message('m3', '2026-10-01T10:03:00.000Z')],
|
|
35
|
+
hasMore: false,
|
|
36
|
+
})
|
|
37
|
+
expect(ids(next)).toEqual(['m1', 'm2', 'm3', 'm4'])
|
|
38
|
+
expect(next.hasMore).toBe(false)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('a revalidation keeps older pages already fetched and the existing hasMore', () => {
|
|
42
|
+
const first = participantThreadReducer(INITIAL_PARTICIPANT_THREAD_STATE, {
|
|
43
|
+
type: 'loaded',
|
|
44
|
+
messages: [message('m2', '2026-10-01T10:02:00.000Z')],
|
|
45
|
+
hasMore: true,
|
|
46
|
+
})
|
|
47
|
+
const older = participantThreadReducer(first, { type: 'olderLoaded', messages: [message('m1', '2026-10-01T10:01:00.000Z')], hasMore: false })
|
|
48
|
+
const revalidated = participantThreadReducer(older, {
|
|
49
|
+
type: 'loaded',
|
|
50
|
+
messages: [message('m2', '2026-10-01T10:02:00.000Z', { status: 'read' }), message('m3', '2026-10-01T10:03:00.000Z')],
|
|
51
|
+
hasMore: true,
|
|
52
|
+
})
|
|
53
|
+
expect(ids(revalidated)).toEqual(['m1', 'm2', 'm3'])
|
|
54
|
+
expect(revalidated.messages[1]?.status).toBe('read')
|
|
55
|
+
expect(revalidated.hasMore).toBe(false)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('sendConfirmed adds the server message once, ascending', () => {
|
|
59
|
+
const base = participantThreadReducer(INITIAL_PARTICIPANT_THREAD_STATE, {
|
|
60
|
+
type: 'loaded',
|
|
61
|
+
messages: [message('m1', '2026-10-01T10:00:00.000Z')],
|
|
62
|
+
hasMore: false,
|
|
63
|
+
})
|
|
64
|
+
const sent = message('m2', '2026-10-01T10:01:00.000Z', { clientMessageId: 'c1' })
|
|
65
|
+
const confirmed = participantThreadReducer(participantThreadReducer(base, { type: 'sendConfirmed', message: sent }), { type: 'sendConfirmed', message: sent })
|
|
66
|
+
|
|
67
|
+
expect(ids(confirmed)).toEqual(['m1', 'm2'])
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('reset returns to the initial state and failed records the error', () => {
|
|
71
|
+
const loaded = participantThreadReducer(INITIAL_PARTICIPANT_THREAD_STATE, { type: 'loaded', messages: [message('m1', '2026-10-01T10:00:00.000Z')], hasMore: false })
|
|
72
|
+
expect(participantThreadReducer(loaded, { type: 'reset' })).toEqual(INITIAL_PARTICIPANT_THREAD_STATE)
|
|
73
|
+
expect(participantThreadReducer(loaded, { type: 'failed', error: 'boom' })).toMatchObject({ status: 'error', error: 'boom' })
|
|
74
|
+
})
|
|
75
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ParticipantMessage } from '@adatechnology/conversation-contracts'
|
|
2
|
+
|
|
3
|
+
export type ParticipantThreadState = {
|
|
4
|
+
readonly status: 'idle' | 'loading' | 'ready' | 'error'
|
|
5
|
+
/** Ascending by createdAt. */
|
|
6
|
+
readonly messages: readonly ParticipantMessage[]
|
|
7
|
+
readonly hasMore: boolean
|
|
8
|
+
readonly error?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type ParticipantThreadAction =
|
|
12
|
+
| { readonly type: 'reset' }
|
|
13
|
+
| { readonly type: 'started' }
|
|
14
|
+
| { readonly type: 'loaded'; readonly messages: readonly ParticipantMessage[]; readonly hasMore: boolean }
|
|
15
|
+
| { readonly type: 'olderLoaded'; readonly messages: readonly ParticipantMessage[]; readonly hasMore: boolean }
|
|
16
|
+
| { readonly type: 'failed'; readonly error: string }
|
|
17
|
+
| { readonly type: 'sendConfirmed'; readonly message: ParticipantMessage }
|
|
18
|
+
|
|
19
|
+
export const INITIAL_PARTICIPANT_THREAD_STATE: ParticipantThreadState = {
|
|
20
|
+
status: 'idle',
|
|
21
|
+
messages: [],
|
|
22
|
+
hasMore: false,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function unionById(
|
|
26
|
+
current: readonly ParticipantMessage[],
|
|
27
|
+
incoming: readonly ParticipantMessage[],
|
|
28
|
+
): readonly ParticipantMessage[] {
|
|
29
|
+
const byId = new Map(current.map((message) => [message.id, message]))
|
|
30
|
+
for (const message of incoming) byId.set(message.id, message)
|
|
31
|
+
return [...byId.values()].sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function mergeMessages(
|
|
35
|
+
state: ParticipantThreadState,
|
|
36
|
+
incoming: readonly ParticipantMessage[],
|
|
37
|
+
hasMore: boolean,
|
|
38
|
+
): ParticipantThreadState {
|
|
39
|
+
const messages = unionById(state.messages, incoming)
|
|
40
|
+
const { error: _error, ...rest } = state
|
|
41
|
+
return { ...rest, status: 'ready', messages, hasMore }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function participantThreadReducer(state: ParticipantThreadState, action: ParticipantThreadAction): ParticipantThreadState {
|
|
45
|
+
switch (action.type) {
|
|
46
|
+
case 'reset':
|
|
47
|
+
return INITIAL_PARTICIPANT_THREAD_STATE
|
|
48
|
+
case 'started':
|
|
49
|
+
return state.status === 'ready' ? state : { ...state, status: 'loading' }
|
|
50
|
+
case 'loaded':
|
|
51
|
+
return mergeMessages(state, action.messages, state.messages.length === 0 ? action.hasMore : state.hasMore)
|
|
52
|
+
case 'olderLoaded':
|
|
53
|
+
return mergeMessages(state, action.messages, action.hasMore)
|
|
54
|
+
case 'failed':
|
|
55
|
+
return { ...state, status: 'error', error: action.error }
|
|
56
|
+
case 'sendConfirmed':
|
|
57
|
+
return { ...state, messages: unionById(state.messages, [action.message]) }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { shouldMarkParticipantRead } from './shouldMarkRead'
|
|
4
|
+
|
|
5
|
+
const SUBJECT = { subjectType: 'trip', subjectId: 'a' }
|
|
6
|
+
|
|
7
|
+
describe('shouldMarkParticipantRead', () => {
|
|
8
|
+
it('is true for the selected subject, visible, with unread messages', () => {
|
|
9
|
+
expect(shouldMarkParticipantRead({ selected: SUBJECT, subject: { ...SUBJECT }, visibilityState: 'visible', unreadCount: 2 })).toBe(true)
|
|
10
|
+
})
|
|
11
|
+
it('is false when nothing is selected', () => {
|
|
12
|
+
expect(shouldMarkParticipantRead({ selected: undefined, subject: SUBJECT, visibilityState: 'visible', unreadCount: 2 })).toBe(false)
|
|
13
|
+
})
|
|
14
|
+
it('is false when another subject is selected (id or type differs)', () => {
|
|
15
|
+
expect(shouldMarkParticipantRead({ selected: { subjectType: 'trip', subjectId: 'b' }, subject: SUBJECT, visibilityState: 'visible', unreadCount: 2 })).toBe(false)
|
|
16
|
+
expect(shouldMarkParticipantRead({ selected: { subjectType: 'invoice', subjectId: 'a' }, subject: SUBJECT, visibilityState: 'visible', unreadCount: 2 })).toBe(false)
|
|
17
|
+
})
|
|
18
|
+
it('is false while the document is hidden', () => {
|
|
19
|
+
expect(shouldMarkParticipantRead({ selected: SUBJECT, subject: SUBJECT, visibilityState: 'hidden', unreadCount: 2 })).toBe(false)
|
|
20
|
+
})
|
|
21
|
+
it('is false without unread messages', () => {
|
|
22
|
+
expect(shouldMarkParticipantRead({ selected: SUBJECT, subject: SUBJECT, visibilityState: 'visible', unreadCount: 0 })).toBe(false)
|
|
23
|
+
})
|
|
24
|
+
})
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
|
|
2
|
+
|
|
3
|
+
export type ShouldMarkParticipantReadParams = {
|
|
4
|
+
readonly selected: ParticipantSubjectRef | undefined
|
|
5
|
+
readonly subject: ParticipantSubjectRef
|
|
6
|
+
readonly visibilityState: DocumentVisibilityState
|
|
7
|
+
readonly unreadCount: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function shouldMarkParticipantRead(params: ShouldMarkParticipantReadParams): boolean {
|
|
11
|
+
const { selected, subject, visibilityState, unreadCount } = params
|
|
12
|
+
if (!selected) return false
|
|
13
|
+
if (selected.subjectType !== subject.subjectType || selected.subjectId !== subject.subjectId) return false
|
|
14
|
+
if (visibilityState !== 'visible') return false
|
|
15
|
+
return unreadCount > 0
|
|
16
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
export function useDocumentVisibility(): DocumentVisibilityState {
|
|
4
|
+
const [visibilityState, setVisibilityState] = useState<DocumentVisibilityState>(() =>
|
|
5
|
+
typeof document === 'undefined' ? 'visible' : document.visibilityState,
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
function handleVisibilityChange(): void {
|
|
10
|
+
setVisibilityState(document.visibilityState)
|
|
11
|
+
}
|
|
12
|
+
handleVisibilityChange()
|
|
13
|
+
document.addEventListener('visibilitychange', handleVisibilityChange)
|
|
14
|
+
return () => document.removeEventListener('visibilitychange', handleVisibilityChange)
|
|
15
|
+
}, [])
|
|
16
|
+
|
|
17
|
+
return visibilityState
|
|
18
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useCallback, useMemo, type Dispatch } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { ParticipantConversationSummary, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
|
|
4
|
+
|
|
5
|
+
import type { ConversationChannel } from '../conversationChannel'
|
|
6
|
+
import type { ParticipantConversationsApi, ParticipantPendingMessage } from './participantApi.types'
|
|
7
|
+
import { mergeParticipantMessages, type ParticipantTimelineItem } from './participantMessages'
|
|
8
|
+
import type { ParticipantSendAction, ParticipantSendEntry } from './participantSendController'
|
|
9
|
+
import { selectSendEntries, type ParticipantSendStates, type ParticipantSendStatesAction } from './participantSendStates'
|
|
10
|
+
import { useParticipantThread, type UseParticipantThreadResult } from './useParticipantThread'
|
|
11
|
+
|
|
12
|
+
export type UseParticipantConversationViewParams = {
|
|
13
|
+
readonly api: ParticipantConversationsApi
|
|
14
|
+
readonly subject: ParticipantSubjectRef
|
|
15
|
+
readonly conversation: ParticipantConversationSummary
|
|
16
|
+
readonly channel?: ConversationChannel
|
|
17
|
+
readonly onMarkedRead: (subject: ParticipantSubjectRef) => void
|
|
18
|
+
readonly pendingMessages?: readonly ParticipantPendingMessage[]
|
|
19
|
+
readonly sendStates: ParticipantSendStates
|
|
20
|
+
readonly dispatchSendStates: Dispatch<ParticipantSendStatesAction>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type UseParticipantConversationViewResult = {
|
|
24
|
+
readonly thread: UseParticipantThreadResult
|
|
25
|
+
readonly items: readonly ParticipantTimelineItem[]
|
|
26
|
+
readonly sendEntries: readonly ParticipantSendEntry[]
|
|
27
|
+
readonly dispatchSend: (action: ParticipantSendAction) => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function useParticipantConversationView(params: UseParticipantConversationViewParams): UseParticipantConversationViewResult {
|
|
31
|
+
const { api, subject, conversation, pendingMessages, sendStates, dispatchSendStates } = params
|
|
32
|
+
const { subjectType, subjectId } = subject
|
|
33
|
+
const sendEntries = selectSendEntries(sendStates, subject)
|
|
34
|
+
const dispatchSend = useCallback(
|
|
35
|
+
(action: ParticipantSendAction) => dispatchSendStates({ subject: { subjectType, subjectId }, action }),
|
|
36
|
+
[dispatchSendStates, subjectType, subjectId],
|
|
37
|
+
)
|
|
38
|
+
const thread = useParticipantThread({
|
|
39
|
+
api,
|
|
40
|
+
subject,
|
|
41
|
+
channel: params.channel,
|
|
42
|
+
unreadCount: conversation.unreadCount,
|
|
43
|
+
onMarkedRead: params.onMarkedRead,
|
|
44
|
+
sendEntries,
|
|
45
|
+
dispatchSend,
|
|
46
|
+
hostPending: pendingMessages,
|
|
47
|
+
})
|
|
48
|
+
const items = useMemo(
|
|
49
|
+
() =>
|
|
50
|
+
mergeParticipantMessages({
|
|
51
|
+
serverMessages: thread.messages,
|
|
52
|
+
hostPending: pendingMessages ?? [],
|
|
53
|
+
localPending: thread.localPending,
|
|
54
|
+
subject,
|
|
55
|
+
}),
|
|
56
|
+
[thread.messages, thread.localPending, pendingMessages, subject],
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
return { thread, items, sendEntries, dispatchSend }
|
|
60
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { useState, type MutableRefObject } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
getDraft,
|
|
7
|
+
setDraft,
|
|
8
|
+
takeDraftForSend,
|
|
9
|
+
type ParticipantDraft,
|
|
10
|
+
type ParticipantDrafts,
|
|
11
|
+
type ParticipantSendContent,
|
|
12
|
+
} from './participantDrafts'
|
|
13
|
+
import { appendEntryToDraft, type ParticipantSendEntry } from './participantSendController'
|
|
14
|
+
|
|
15
|
+
export type UseParticipantDraftResult = {
|
|
16
|
+
readonly draft: ParticipantDraft
|
|
17
|
+
readonly setText: (text: string) => void
|
|
18
|
+
readonly setFiles: (files: readonly File[]) => void
|
|
19
|
+
/** Clears the field synchronously and hands the content over; undefined when there is nothing to send. */
|
|
20
|
+
readonly takeForSend: () => ParticipantSendContent | undefined
|
|
21
|
+
readonly restoreFromEntry: (entry: ParticipantSendEntry) => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The ref holds the truth (read at call time, so a double tap sees the cleared field); state only re-renders. */
|
|
25
|
+
export function useParticipantDraft(draftsRef: MutableRefObject<ParticipantDrafts>, subject: ParticipantSubjectRef): UseParticipantDraftResult {
|
|
26
|
+
const [draft, setDraftState] = useState<ParticipantDraft>(() => getDraft(draftsRef.current, subject))
|
|
27
|
+
|
|
28
|
+
function replaceDrafts(next: ParticipantDrafts): void {
|
|
29
|
+
draftsRef.current = next
|
|
30
|
+
setDraftState(getDraft(next, subject))
|
|
31
|
+
}
|
|
32
|
+
function update(change: Partial<ParticipantDraft>): void {
|
|
33
|
+
replaceDrafts(setDraft(draftsRef.current, subject, { ...getDraft(draftsRef.current, subject), ...change }))
|
|
34
|
+
}
|
|
35
|
+
function takeForSend(): ParticipantSendContent | undefined {
|
|
36
|
+
const taken = takeDraftForSend(draftsRef.current, subject)
|
|
37
|
+
if (taken.content) replaceDrafts(taken.drafts)
|
|
38
|
+
return taken.content
|
|
39
|
+
}
|
|
40
|
+
function restoreFromEntry(entry: ParticipantSendEntry): void {
|
|
41
|
+
update(appendEntryToDraft(entry, getDraft(draftsRef.current, subject)))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { draft, setText: (text) => update({ text }), setFiles: (files) => update({ files }), takeForSend, restoreFromEntry }
|
|
45
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useCallback, useEffect, useReducer } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
|
|
4
|
+
|
|
5
|
+
import type { ParticipantConversationsApi } from './participantApi.types'
|
|
6
|
+
import { INITIAL_PARTICIPANT_INBOX_STATE, participantInboxReducer, type ParticipantInboxState } from './participantInboxState'
|
|
7
|
+
import { useParticipantRevalidation } from './useParticipantRevalidation'
|
|
8
|
+
|
|
9
|
+
export type UseParticipantInboxResult = ParticipantInboxState & {
|
|
10
|
+
readonly hasMore: boolean
|
|
11
|
+
readonly refresh: () => Promise<void>
|
|
12
|
+
readonly loadMore: () => Promise<void>
|
|
13
|
+
readonly markSubjectRead: (subject: ParticipantSubjectRef) => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function describeError(error: unknown): string {
|
|
17
|
+
return error instanceof Error ? error.message : 'unknown error'
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function useParticipantInbox(api: ParticipantConversationsApi): UseParticipantInboxResult {
|
|
21
|
+
const [state, dispatch] = useReducer(participantInboxReducer, INITIAL_PARTICIPANT_INBOX_STATE)
|
|
22
|
+
|
|
23
|
+
const refresh = useCallback(async (): Promise<void> => {
|
|
24
|
+
dispatch({ type: 'started' })
|
|
25
|
+
try {
|
|
26
|
+
const page = await api.listConversations()
|
|
27
|
+
dispatch({ type: 'loaded', conversations: page.data, ...(page.nextCursor ? { nextCursor: page.nextCursor } : {}) })
|
|
28
|
+
} catch (error) {
|
|
29
|
+
dispatch({ type: 'failed', error: describeError(error) })
|
|
30
|
+
}
|
|
31
|
+
}, [api])
|
|
32
|
+
|
|
33
|
+
const { nextCursor } = state
|
|
34
|
+
const loadMore = useCallback(async (): Promise<void> => {
|
|
35
|
+
if (!nextCursor) return
|
|
36
|
+
try {
|
|
37
|
+
const page = await api.listConversations({ cursor: nextCursor })
|
|
38
|
+
dispatch({ type: 'appended', conversations: page.data, ...(page.nextCursor ? { nextCursor: page.nextCursor } : {}) })
|
|
39
|
+
} catch (error) {
|
|
40
|
+
dispatch({ type: 'failed', error: describeError(error) })
|
|
41
|
+
}
|
|
42
|
+
}, [api, nextCursor])
|
|
43
|
+
|
|
44
|
+
const markSubjectRead = useCallback((subject: ParticipantSubjectRef): void => {
|
|
45
|
+
dispatch({ type: 'markedRead', subject })
|
|
46
|
+
}, [])
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
void refresh()
|
|
50
|
+
}, [refresh])
|
|
51
|
+
|
|
52
|
+
useParticipantRevalidation(api, () => {
|
|
53
|
+
void refresh()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
return { ...state, hasMore: nextCursor !== undefined, refresh, loadMore, markSubjectRead }
|
|
57
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useEffect, type MutableRefObject } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
|
|
4
|
+
|
|
5
|
+
import type { ParticipantConversationsApi } from './participantApi.types'
|
|
6
|
+
import { shouldMarkParticipantRead } from './shouldMarkRead'
|
|
7
|
+
import { useDocumentVisibility } from './useDocumentVisibility'
|
|
8
|
+
|
|
9
|
+
export type UseParticipantMarkReadParams = {
|
|
10
|
+
readonly apiRef: MutableRefObject<ParticipantConversationsApi>
|
|
11
|
+
readonly subject: ParticipantSubjectRef
|
|
12
|
+
readonly unreadCount: number
|
|
13
|
+
/** Marking again when a message arrives while the screen stays open. */
|
|
14
|
+
readonly messageCount: number
|
|
15
|
+
readonly onMarkedRead?: (subject: ParticipantSubjectRef) => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function useParticipantMarkRead(params: UseParticipantMarkReadParams): void {
|
|
19
|
+
const { apiRef, subject, unreadCount, messageCount, onMarkedRead } = params
|
|
20
|
+
const visibilityState = useDocumentVisibility()
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (!shouldMarkParticipantRead({ selected: subject, subject, visibilityState, unreadCount })) return
|
|
24
|
+
void apiRef.current.markRead(subject).then(() => onMarkedRead?.(subject), () => undefined)
|
|
25
|
+
}, [apiRef, subject, visibilityState, unreadCount, messageCount, onMarkedRead])
|
|
26
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { ParticipantConversationEvent, ParticipantConversationsApi } from './participantApi.types'
|
|
4
|
+
|
|
5
|
+
type RevalidationHandler = (event?: ParticipantConversationEvent) => void
|
|
6
|
+
|
|
7
|
+
/** Subscribes to the host when it can push events (still revalidating on reconnection, when an event may have been lost); otherwise also on focus and visibility. */
|
|
8
|
+
export function useParticipantRevalidation(api: ParticipantConversationsApi, onRevalidate: RevalidationHandler): void {
|
|
9
|
+
const handlerRef = useRef(onRevalidate)
|
|
10
|
+
handlerRef.current = onRevalidate
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
function handleFocus(): void {
|
|
14
|
+
handlerRef.current()
|
|
15
|
+
}
|
|
16
|
+
if (api.subscribe) {
|
|
17
|
+
const unsubscribe = api.subscribe((event) => handlerRef.current(event))
|
|
18
|
+
window.addEventListener('online', handleFocus)
|
|
19
|
+
return () => {
|
|
20
|
+
unsubscribe()
|
|
21
|
+
window.removeEventListener('online', handleFocus)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function handleVisibilityChange(): void {
|
|
25
|
+
if (document.visibilityState === 'visible') handlerRef.current()
|
|
26
|
+
}
|
|
27
|
+
window.addEventListener('focus', handleFocus)
|
|
28
|
+
window.addEventListener('online', handleFocus)
|
|
29
|
+
document.addEventListener('visibilitychange', handleVisibilityChange)
|
|
30
|
+
return () => {
|
|
31
|
+
window.removeEventListener('focus', handleFocus)
|
|
32
|
+
window.removeEventListener('online', handleFocus)
|
|
33
|
+
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
|
34
|
+
}
|
|
35
|
+
}, [api])
|
|
36
|
+
}
|