@adatechnology/conversations-ui 0.4.2 → 0.5.0

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