@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.
- package/README.md +231 -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-4P5KMWOI.js → chunk-FAQLC3TX.js} +327 -522
- package/dist/{chunk-HMCOTZAX.js → chunk-HOKNKXIT.js} +1 -1
- package/dist/chunk-MRVFFJEH.js +64 -0
- package/dist/chunk-O6457XHK.js +255 -0
- package/dist/chunk-OVFCLRQQ.js +84 -0
- package/dist/flows/index.js +5 -3
- package/dist/index.d.ts +21 -5
- package/dist/index.js +783 -807
- package/dist/participant/index.d.ts +186 -0
- package/dist/participant/index.js +1708 -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 +689 -0
- package/package.json +8 -4
- package/src/ChannelIcon.tsx +2 -0
- package/src/MessageText.test.tsx +76 -0
- package/src/MessageText.tsx +28 -12
- package/src/MessageTextCopiedBadge.tsx +15 -0
- package/src/StatusTicks.test.tsx +51 -0
- package/src/StatusTicks.tsx +36 -6
- 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/legacyChannels.golden.test.ts +89 -0
- package/src/legacyDefaults.golden.test.tsx +127 -0
- package/src/legacyStyles.fixture.css +1121 -0
- package/src/legacyStyles.golden.test.ts +121 -0
- package/src/messageTextCopyInteraction.test.ts +75 -0
- package/src/messageTextCopyInteraction.ts +37 -0
- package/src/participant/ParticipantAttachmentItem.tsx +77 -0
- package/src/participant/ParticipantChannelBadges.test.tsx +59 -0
- package/src/participant/ParticipantChannelBadges.tsx +75 -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 +49 -0
- package/src/participant/ParticipantConversationsScreen.tsx +78 -0
- package/src/participant/ParticipantInbox.test.tsx +244 -0
- package/src/participant/ParticipantInbox.tsx +149 -0
- package/src/participant/ParticipantInboxRow.tsx +75 -0
- package/src/participant/ParticipantInboxSearchField.tsx +34 -0
- package/src/participant/ParticipantLoadState.tsx +27 -0
- package/src/participant/ParticipantMessageBubble.test.tsx +167 -0
- package/src/participant/ParticipantMessageBubble.tsx +185 -0
- package/src/participant/ParticipantProtocolBadge.tsx +74 -0
- package/src/participant/ParticipantProtocolView.test.tsx +208 -0
- package/src/participant/ParticipantThread.test.tsx +149 -0
- package/src/participant/ParticipantThread.tsx +184 -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 +26 -0
- package/src/participant/participant.types.ts +52 -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/participantChannels.test.ts +42 -0
- package/src/participant/participantChannels.ts +33 -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 +236 -0
- package/src/participant/participantGrouping.ts +90 -0
- package/src/participant/participantInboxState.test.ts +69 -0
- package/src/participant/participantInboxState.ts +74 -0
- package/src/participant/participantLabels.ts +115 -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/participantProtocol.test.ts +104 -0
- package/src/participant/participantProtocol.ts +52 -0
- package/src/participant/participantProtocolCopy.test.ts +45 -0
- package/src/participant/participantProtocolCopy.ts +32 -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 +178 -0
- 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…',
|