import type { WebChatActivity } from 'botframework-webchat-core'; import React, { memo, useMemo, type ReactNode } from 'react'; import { useRefFrom } from 'use-ref-from'; import numberWithInfinity from '../../hooks/private/numberWithInfinity'; import useActivities from '../../hooks/useActivities'; import usePonyfill from '../../hooks/usePonyfill'; import useUpsertedActivities from '../../providers/ActivityListener/useUpsertedActivities'; import ActivityTypingContext, { ActivityTypingContextType } from './private/Context'; import useMemoWithPrevious from './private/useMemoWithPrevious'; import { type AllTyping } from './types/AllTyping'; const INITIAL_ALL_TYPING_STATE = Object.freeze([Object.freeze(new Map())] as const); type Props = Readonly<{ children?: ReactNode | undefined }>; function isLivestreamChunk(activity: WebChatActivity): boolean { return ( activity.type === 'typing' && 'text' in activity && typeof activity.text === 'string' && activity.channelData.streamType !== 'informative' ); } const ActivityTypingComposer = ({ children }: Props) => { const [{ Date }] = usePonyfill(); const [activities] = useActivities(); const [upsertedActivities] = useUpsertedActivities(); const activitiesRef = useRefFrom(activities); const allTypingState = useMemoWithPrevious]>( (prevAllTypingState = INITIAL_ALL_TYPING_STATE) => { const { current: activities } = activitiesRef; const nextTyping = new Map(prevAllTypingState[0]); let changed = false; const firstIndex = upsertedActivities.reduce( (firstIndex, upsertedActivity) => Math.min(firstIndex, activities.indexOf(upsertedActivity)), Infinity ); for (const activity of activities.slice(firstIndex)) { const { from, from: { id, role }, type } = activity; if (type === 'typing' && (role === 'bot' || role === 'user')) { const currentTyping = nextTyping.get(id); // TODO: When we rework on types of DLActivity, we will make sure all activities has "webChat.receivedAt", this coalesces can be removed. const receivedAt = activity.channelData.webChat?.receivedAt || Date.now(); nextTyping.set(id, { firstReceivedAt: currentTyping?.firstReceivedAt || receivedAt, lastActivityDuration: numberWithInfinity( activity.channelData.webChat?.styleOptions?.typingAnimationDuration ), lastReceivedAt: receivedAt, name: from.name, role, type: isLivestreamChunk(activity) ? 'livestream' : 'busy' // Informative message means the bot is busy. }); changed = true; } else if (type === 'message') { nextTyping.delete(id); changed = true; } } return changed ? Object.freeze([nextTyping]) : prevAllTypingState; }, [activitiesRef, upsertedActivities] ); const context = useMemo(() => ({ allTypingState }), [allTypingState]); return {children}; }; ActivityTypingComposer.displayName = 'ActivityTypingComposer'; export default memo(ActivityTypingComposer);