@adatechnology/conversations-ui 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/README.md +231 -0
  2. package/dist/{ConversationSimulatorPanel-DnXElfY8.d.ts → ConversationSimulatorPanel-CrfFjiWh.d.ts} +2 -268
  3. package/dist/{chunk-PJNR6UMN.js → chunk-CBYEHPX6.js} +1 -63
  4. package/dist/{chunk-4P5KMWOI.js → chunk-FAQLC3TX.js} +327 -522
  5. package/dist/{chunk-HMCOTZAX.js → chunk-HOKNKXIT.js} +1 -1
  6. package/dist/chunk-MRVFFJEH.js +64 -0
  7. package/dist/chunk-O6457XHK.js +255 -0
  8. package/dist/chunk-OVFCLRQQ.js +84 -0
  9. package/dist/flows/index.js +5 -3
  10. package/dist/index.d.ts +21 -5
  11. package/dist/index.js +783 -807
  12. package/dist/participant/index.d.ts +186 -0
  13. package/dist/participant/index.js +1708 -0
  14. package/dist/preview/index.d.ts +4 -2
  15. package/dist/preview/index.js +4 -2
  16. package/dist/quickReply.types-B4gmZsSr.d.ts +274 -0
  17. package/dist/styles.css +689 -0
  18. package/package.json +8 -4
  19. package/src/ChannelIcon.tsx +2 -0
  20. package/src/MessageText.test.tsx +76 -0
  21. package/src/MessageText.tsx +28 -12
  22. package/src/MessageTextCopiedBadge.tsx +15 -0
  23. package/src/StatusTicks.test.tsx +51 -0
  24. package/src/StatusTicks.tsx +36 -6
  25. package/src/buildOutput.test.ts +41 -0
  26. package/src/channelCapability.test.ts +17 -0
  27. package/src/conversationChannel.test.ts +30 -1
  28. package/src/conversationChannel.ts +19 -1
  29. package/src/legacyChannels.golden.test.ts +89 -0
  30. package/src/legacyDefaults.golden.test.tsx +127 -0
  31. package/src/legacyStyles.fixture.css +1121 -0
  32. package/src/legacyStyles.golden.test.ts +121 -0
  33. package/src/messageTextCopyInteraction.test.ts +75 -0
  34. package/src/messageTextCopyInteraction.ts +37 -0
  35. package/src/participant/ParticipantAttachmentItem.tsx +77 -0
  36. package/src/participant/ParticipantChannelBadges.test.tsx +59 -0
  37. package/src/participant/ParticipantChannelBadges.tsx +75 -0
  38. package/src/participant/ParticipantComposer.test.tsx +97 -0
  39. package/src/participant/ParticipantComposer.tsx +167 -0
  40. package/src/participant/ParticipantConversations.test.tsx +90 -0
  41. package/src/participant/ParticipantConversations.tsx +49 -0
  42. package/src/participant/ParticipantConversationsScreen.tsx +78 -0
  43. package/src/participant/ParticipantInbox.test.tsx +244 -0
  44. package/src/participant/ParticipantInbox.tsx +149 -0
  45. package/src/participant/ParticipantInboxRow.tsx +75 -0
  46. package/src/participant/ParticipantInboxSearchField.tsx +34 -0
  47. package/src/participant/ParticipantLoadState.tsx +27 -0
  48. package/src/participant/ParticipantMessageBubble.test.tsx +167 -0
  49. package/src/participant/ParticipantMessageBubble.tsx +185 -0
  50. package/src/participant/ParticipantProtocolBadge.tsx +74 -0
  51. package/src/participant/ParticipantProtocolView.test.tsx +208 -0
  52. package/src/participant/ParticipantThread.test.tsx +149 -0
  53. package/src/participant/ParticipantThread.tsx +184 -0
  54. package/src/participant/ParticipantThreadScreen.tsx +135 -0
  55. package/src/participant/bindAttachmentUrlResolver.test.ts +25 -0
  56. package/src/participant/bindAttachmentUrlResolver.ts +7 -0
  57. package/src/participant/index.ts +26 -0
  58. package/src/participant/participant.types.ts +52 -0
  59. package/src/participant/participantApi.types.ts +59 -0
  60. package/src/participant/participantAttachmentUrl.test.ts +32 -0
  61. package/src/participant/participantAttachmentUrl.ts +10 -0
  62. package/src/participant/participantBubbleActions.test.ts +44 -0
  63. package/src/participant/participantBubbleActions.ts +40 -0
  64. package/src/participant/participantChannels.test.ts +42 -0
  65. package/src/participant/participantChannels.ts +33 -0
  66. package/src/participant/participantDrafts.test.ts +65 -0
  67. package/src/participant/participantDrafts.ts +50 -0
  68. package/src/participant/participantFileValidation.test.ts +86 -0
  69. package/src/participant/participantFileValidation.ts +72 -0
  70. package/src/participant/participantFixtures.test-helper.ts +41 -0
  71. package/src/participant/participantGrouping.test.ts +236 -0
  72. package/src/participant/participantGrouping.ts +90 -0
  73. package/src/participant/participantInboxState.test.ts +69 -0
  74. package/src/participant/participantInboxState.ts +74 -0
  75. package/src/participant/participantLabels.ts +115 -0
  76. package/src/participant/participantLoadView.test.ts +23 -0
  77. package/src/participant/participantLoadView.ts +21 -0
  78. package/src/participant/participantMessages.test.ts +128 -0
  79. package/src/participant/participantMessages.ts +73 -0
  80. package/src/participant/participantNewMessages.test.ts +54 -0
  81. package/src/participant/participantNewMessages.ts +29 -0
  82. package/src/participant/participantProtocol.test.ts +104 -0
  83. package/src/participant/participantProtocol.ts +52 -0
  84. package/src/participant/participantProtocolCopy.test.ts +45 -0
  85. package/src/participant/participantProtocolCopy.ts +32 -0
  86. package/src/participant/participantQuickReply.test.ts +23 -0
  87. package/src/participant/participantQuickReply.ts +6 -0
  88. package/src/participant/participantScroll.test.ts +69 -0
  89. package/src/participant/participantScroll.ts +43 -0
  90. package/src/participant/participantSendController.test.ts +201 -0
  91. package/src/participant/participantSendController.ts +134 -0
  92. package/src/participant/participantSendStates.ts +31 -0
  93. package/src/participant/participantTheme.ts +20 -0
  94. package/src/participant/participantThreadState.test.ts +75 -0
  95. package/src/participant/participantThreadState.ts +59 -0
  96. package/src/participant/shouldMarkRead.test.ts +24 -0
  97. package/src/participant/shouldMarkRead.ts +16 -0
  98. package/src/participant/useDocumentVisibility.ts +18 -0
  99. package/src/participant/useParticipantConversationView.ts +60 -0
  100. package/src/participant/useParticipantDraft.ts +45 -0
  101. package/src/participant/useParticipantInbox.ts +57 -0
  102. package/src/participant/useParticipantMarkRead.ts +26 -0
  103. package/src/participant/useParticipantRevalidation.ts +36 -0
  104. package/src/participant/useParticipantSend.ts +84 -0
  105. package/src/participant/useParticipantThread.ts +116 -0
  106. package/src/participant/useParticipantThreadScroll.ts +26 -0
  107. package/src/participant/useStickToBottom.ts +77 -0
  108. package/src/preview/ConversationSimulatorPanel.tsx +8 -0
  109. package/src/styles.css +178 -0
  110. package/src/types.ts +3 -1
@@ -0,0 +1,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
+ }
@@ -0,0 +1,84 @@
1
+ import { useCallback, useRef, type MutableRefObject } from 'react'
2
+
3
+ import type { ParticipantMessage, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
4
+
5
+ import type { ParticipantConversationsApi } from './participantApi.types'
6
+ import type { ParticipantSendContent } from './participantDrafts'
7
+ import {
8
+ createParticipantSendEntry,
9
+ findFailedEntry,
10
+ type ParticipantSendAction,
11
+ type ParticipantSendEntry,
12
+ } from './participantSendController'
13
+
14
+ export type ParticipantSendOutcome = 'sent' | 'queued' | 'failed'
15
+
16
+ export type UseParticipantSendParams = {
17
+ readonly apiRef: MutableRefObject<ParticipantConversationsApi>
18
+ readonly subject: ParticipantSubjectRef
19
+ readonly entries: readonly ParticipantSendEntry[]
20
+ readonly dispatchSend: (action: ParticipantSendAction) => void
21
+ readonly onSentMessage: (message: ParticipantMessage) => void
22
+ }
23
+
24
+ export type UseParticipantSendResult = {
25
+ readonly send: (content: ParticipantSendContent) => Promise<ParticipantSendOutcome>
26
+ readonly retry: (clientMessageId: string) => Promise<void>
27
+ }
28
+
29
+ export function useParticipantSend(params: UseParticipantSendParams): UseParticipantSendResult {
30
+ const { apiRef, subject, dispatchSend, onSentMessage } = params
31
+ const entriesRef = useRef(params.entries)
32
+ entriesRef.current = params.entries
33
+
34
+ const deliver = useCallback(
35
+ async (entry: ParticipantSendEntry): Promise<ParticipantSendOutcome> => {
36
+ const { clientMessageId } = entry
37
+ try {
38
+ const result = await apiRef.current.sendMessage({
39
+ subject: entry.subject,
40
+ clientMessageId,
41
+ ...(entry.text ? { text: entry.text } : {}),
42
+ ...(entry.files.length > 0 ? { files: entry.files } : {}),
43
+ })
44
+ if (result.outcome === 'sent') {
45
+ onSentMessage(result.message)
46
+ dispatchSend({ type: 'sent', clientMessageId, message: result.message })
47
+ return 'sent'
48
+ }
49
+ dispatchSend({ type: 'queued', clientMessageId })
50
+ return 'queued'
51
+ } catch {
52
+ dispatchSend({ type: 'failed', clientMessageId })
53
+ return 'failed'
54
+ }
55
+ },
56
+ [apiRef, dispatchSend, onSentMessage],
57
+ )
58
+
59
+ const send = useCallback(
60
+ async (content: ParticipantSendContent): Promise<ParticipantSendOutcome> => {
61
+ const entry = createParticipantSendEntry({
62
+ clientMessageId: crypto.randomUUID(),
63
+ subject,
64
+ content,
65
+ createdAt: new Date().toISOString(),
66
+ })
67
+ dispatchSend({ type: 'started', entry })
68
+ return deliver(entry)
69
+ },
70
+ [deliver, dispatchSend, subject],
71
+ )
72
+
73
+ const retry = useCallback(
74
+ async (clientMessageId: string): Promise<void> => {
75
+ const entry = findFailedEntry(entriesRef.current, clientMessageId)
76
+ if (!entry) return
77
+ dispatchSend({ type: 'retried', clientMessageId })
78
+ await deliver({ ...entry, state: 'sending' })
79
+ },
80
+ [deliver, dispatchSend],
81
+ )
82
+
83
+ return { send, retry }
84
+ }
@@ -0,0 +1,116 @@
1
+ import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react'
2
+
3
+ import type { ParticipantMessage, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
4
+
5
+ import { channelCapabilityFor } from '../channelCapability'
6
+ import type { ConversationChannel } from '../conversationChannel'
7
+ import type {
8
+ ParticipantConversationEvent,
9
+ ParticipantConversationsApi,
10
+ ParticipantLocalPendingMessage,
11
+ ParticipantPendingMessage,
12
+ } from './participantApi.types'
13
+ import {
14
+ collectKnownClientMessageIds,
15
+ toLocalPending,
16
+ type ParticipantSendAction,
17
+ type ParticipantSendEntry,
18
+ } from './participantSendController'
19
+ import { INITIAL_PARTICIPANT_THREAD_STATE, participantThreadReducer, type ParticipantThreadState } from './participantThreadState'
20
+ import { useParticipantMarkRead } from './useParticipantMarkRead'
21
+ import { useParticipantRevalidation } from './useParticipantRevalidation'
22
+ import { useParticipantSend, type UseParticipantSendResult } from './useParticipantSend'
23
+
24
+ const THREAD_PAGE_SIZE = 30
25
+
26
+ export type UseParticipantThreadParams = {
27
+ /** Keep it stable: a new identity re-subscribes to events (it never resets the conversation). */
28
+ readonly api: ParticipantConversationsApi
29
+ readonly subject: ParticipantSubjectRef
30
+ readonly channel?: ConversationChannel
31
+ /** Unread count of this subject as the inbox knows it; drives markRead. */
32
+ readonly unreadCount?: number
33
+ readonly onMarkedRead?: (subject: ParticipantSubjectRef) => void
34
+ /** Sends of this subject, owned above the conversation so they survive leaving it. */
35
+ readonly sendEntries: readonly ParticipantSendEntry[]
36
+ readonly dispatchSend: (action: ParticipantSendAction) => void
37
+ readonly hostPending?: readonly ParticipantPendingMessage[]
38
+ }
39
+
40
+ export type UseParticipantThreadResult = ParticipantThreadState &
41
+ UseParticipantSendResult & {
42
+ readonly confirmsRead: boolean
43
+ readonly localPending: readonly ParticipantLocalPendingMessage[]
44
+ readonly refresh: () => Promise<void>
45
+ readonly loadOlder: () => Promise<void>
46
+ }
47
+
48
+ function describeError(error: unknown): string {
49
+ return error instanceof Error ? error.message : 'unknown error'
50
+ }
51
+
52
+ function isForSubject(event: ParticipantConversationEvent | undefined, subject: ParticipantSubjectRef): boolean {
53
+ if (!event || event.type === 'inbox-changed') return true
54
+ return event.subject.subjectType === subject.subjectType && event.subject.subjectId === subject.subjectId
55
+ }
56
+
57
+ export function useParticipantThread(params: UseParticipantThreadParams): UseParticipantThreadResult {
58
+ const { api, subject, channel, unreadCount = 0, onMarkedRead, sendEntries, dispatchSend, hostPending = [] } = params
59
+ const { subjectType, subjectId } = subject
60
+ const stableSubject = useMemo<ParticipantSubjectRef>(() => ({ subjectType, subjectId }), [subjectType, subjectId])
61
+ const [state, dispatch] = useReducer(participantThreadReducer, INITIAL_PARTICIPANT_THREAD_STATE)
62
+ const apiRef = useRef(api)
63
+ apiRef.current = api
64
+ const activeSubjectKey = useRef('')
65
+ const confirmsRead = channelCapabilityFor(channel).confirmsRead
66
+
67
+ const refresh = useCallback(async (): Promise<void> => {
68
+ const requestKey = `${subjectType}:${subjectId}`
69
+ dispatch({ type: 'started' })
70
+ try {
71
+ const messages = await apiRef.current.fetchMessages(stableSubject, { limit: THREAD_PAGE_SIZE })
72
+ if (activeSubjectKey.current === requestKey) {
73
+ dispatch({ type: 'loaded', messages, hasMore: messages.length >= THREAD_PAGE_SIZE })
74
+ }
75
+ } catch (error) {
76
+ if (activeSubjectKey.current === requestKey) dispatch({ type: 'failed', error: describeError(error) })
77
+ }
78
+ }, [stableSubject, subjectType, subjectId])
79
+
80
+ const oldestMessageId = state.messages[0]?.id
81
+ const loadOlder = useCallback(async (): Promise<void> => {
82
+ if (!oldestMessageId) return
83
+ const requestKey = `${subjectType}:${subjectId}`
84
+ try {
85
+ const messages = await apiRef.current.fetchMessages(stableSubject, { before: oldestMessageId, limit: THREAD_PAGE_SIZE })
86
+ if (activeSubjectKey.current === requestKey) {
87
+ dispatch({ type: 'olderLoaded', messages, hasMore: messages.length >= THREAD_PAGE_SIZE })
88
+ }
89
+ } catch (error) {
90
+ if (activeSubjectKey.current === requestKey) dispatch({ type: 'failed', error: describeError(error) })
91
+ }
92
+ }, [stableSubject, subjectType, subjectId, oldestMessageId])
93
+
94
+ const handleSentMessage = useCallback((message: ParticipantMessage) => dispatch({ type: 'sendConfirmed', message }), [])
95
+ const sending = useParticipantSend({ apiRef, subject: stableSubject, entries: sendEntries, dispatchSend, onSentMessage: handleSentMessage })
96
+
97
+ useEffect(() => {
98
+ activeSubjectKey.current = `${subjectType}:${subjectId}`
99
+ dispatch({ type: 'reset' })
100
+ void refresh()
101
+ }, [refresh, subjectType, subjectId])
102
+
103
+ useParticipantRevalidation(api, (event) => {
104
+ if (isForSubject(event, stableSubject)) void refresh()
105
+ })
106
+
107
+ useEffect(() => {
108
+ if (sendEntries.length === 0) return
109
+ dispatchSend({ type: 'reflected', knownClientMessageIds: collectKnownClientMessageIds({ messages: state.messages, hostPending }) })
110
+ }, [sendEntries, state.messages, hostPending, dispatchSend])
111
+
112
+ useParticipantMarkRead({ apiRef, subject: stableSubject, unreadCount, messageCount: state.messages.length, onMarkedRead })
113
+
114
+ const localPending = useMemo(() => toLocalPending(sendEntries), [sendEntries])
115
+ return { ...state, localPending, confirmsRead, refresh, loadOlder, ...sending }
116
+ }
@@ -0,0 +1,26 @@
1
+ import { useEffect, useRef } from 'react'
2
+
3
+ import { collectServerMessageIds, countNewIncomingMessages } from './participantNewMessages'
4
+ import type { ParticipantTimelineItem } from './participantMessages'
5
+ import { useStickToBottom, type ParticipantThreadScroll } from './useStickToBottom'
6
+
7
+ export type UseParticipantThreadScrollResult = {
8
+ readonly scroll: ParticipantThreadScroll
9
+ readonly newMessagesCount: number
10
+ }
11
+
12
+ function keyOf(item: ParticipantTimelineItem | undefined): string | undefined {
13
+ if (!item) return undefined
14
+ return item.kind === 'server' ? item.message.id : `pending:${item.pending.clientMessageId}`
15
+ }
16
+
17
+ export function useParticipantThreadScroll(items: readonly ParticipantTimelineItem[]): UseParticipantThreadScrollResult {
18
+ const { scroll, isNearBottom } = useStickToBottom({ firstKey: keyOf(items[0]), lastKey: keyOf(items[items.length - 1]) })
19
+ const seenIds = useRef<ReadonlySet<string>>(new Set())
20
+
21
+ useEffect(() => {
22
+ if (isNearBottom) seenIds.current = collectServerMessageIds(items)
23
+ }, [isNearBottom, items])
24
+
25
+ return { scroll, newMessagesCount: isNearBottom ? 0 : countNewIncomingMessages(seenIds.current, items) }
26
+ }
@@ -0,0 +1,77 @@
1
+ import { useCallback, useLayoutEffect, useRef, useState, type RefObject, type UIEvent } from 'react'
2
+
3
+ import { NEAR_BOTTOM_THRESHOLD_PX, resolveScrollAction, scrollAnchorAfterPrepend, shouldStickToBottom } from './participantScroll'
4
+
5
+ export type UseStickToBottomParams = {
6
+ readonly firstKey: string | undefined
7
+ readonly lastKey: string | undefined
8
+ }
9
+
10
+ export type ParticipantThreadScroll = {
11
+ readonly ref: RefObject<HTMLDivElement | null>
12
+ readonly onScroll: (event: UIEvent<HTMLDivElement>) => void
13
+ }
14
+
15
+ export type UseStickToBottomResult = {
16
+ readonly scroll: ParticipantThreadScroll
17
+ readonly isNearBottom: boolean
18
+ }
19
+
20
+ type Snapshot = {
21
+ hasPositioned: boolean
22
+ firstKey: string | undefined
23
+ lastKey: string | undefined
24
+ scrollTop: number
25
+ scrollHeight: number
26
+ }
27
+
28
+ export function useStickToBottom({ firstKey, lastKey }: UseStickToBottomParams): UseStickToBottomResult {
29
+ const ref = useRef<HTMLDivElement | null>(null)
30
+ const snapshot = useRef<Snapshot>({ hasPositioned: false, firstKey: undefined, lastKey: undefined, scrollTop: 0, scrollHeight: 0 })
31
+ const [isNearBottom, setIsNearBottom] = useState(true)
32
+ const isNearBottomRef = useRef(true)
33
+
34
+ const onScroll = useCallback((event: UIEvent<HTMLDivElement>): void => {
35
+ const { scrollTop, clientHeight, scrollHeight } = event.currentTarget
36
+ const isNear = shouldStickToBottom({ scrollTop, clientHeight, scrollHeight, threshold: NEAR_BOTTOM_THRESHOLD_PX })
37
+ snapshot.current.scrollTop = scrollTop
38
+ snapshot.current.scrollHeight = scrollHeight
39
+ isNearBottomRef.current = isNear
40
+ setIsNearBottom(isNear)
41
+ }, [])
42
+
43
+ useLayoutEffect(() => {
44
+ const element = ref.current
45
+ const previous = snapshot.current
46
+ if (!element) return
47
+ const action = resolveScrollAction({
48
+ hasPositioned: previous.hasPositioned,
49
+ previousFirstKey: previous.firstKey,
50
+ previousLastKey: previous.lastKey,
51
+ firstKey,
52
+ lastKey,
53
+ wasNearBottom: isNearBottomRef.current,
54
+ })
55
+ if (action === 'bottom') {
56
+ element.scrollTop = element.scrollHeight
57
+ isNearBottomRef.current = true
58
+ setIsNearBottom(true)
59
+ }
60
+ if (action === 'anchor') {
61
+ element.scrollTop = scrollAnchorAfterPrepend({
62
+ previousScrollTop: previous.scrollTop,
63
+ previousScrollHeight: previous.scrollHeight,
64
+ scrollHeight: element.scrollHeight,
65
+ })
66
+ }
67
+ snapshot.current = {
68
+ hasPositioned: previous.hasPositioned || action === 'bottom',
69
+ firstKey,
70
+ lastKey,
71
+ scrollTop: element.scrollTop,
72
+ scrollHeight: element.scrollHeight,
73
+ }
74
+ }, [firstKey, lastKey])
75
+
76
+ return { scroll: { ref, onScroll }, isNearBottom }
77
+ }
@@ -62,6 +62,14 @@ const SIMULATOR_PANEL_CHANNEL_WORDING: Readonly<Record<ConversationChannel, Chan
62
62
  destinationHint: DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS.destinationHint,
63
63
  placeholder: DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS.placeholder,
64
64
  },
65
+ [CONVERSATION_CHANNEL.APP]: {
66
+ destinationHint: 'entrega no app do contratante',
67
+ placeholder: 'Escreva como quem usa o app…',
68
+ },
69
+ [CONVERSATION_CHANNEL.PORTAL]: {
70
+ destinationHint: 'entrega no portal do contratante',
71
+ placeholder: 'Escreva como quem usa o portal…',
72
+ },
65
73
  [CONVERSATION_CHANNEL.WEBCHAT]: {
66
74
  destinationHint: 'entrega na API do chat do site',
67
75
  placeholder: 'Escreva como o visitante…',