import type { WebChatActivity } from 'botframework-webchat-core'; import React, { useCallback, useMemo, useRef, type ReactNode } from 'react'; import reduceIterable from '../../hooks/private/reduceIterable'; import useActivities from '../../hooks/useActivities'; import type { ActivityKeyerContextType } from './private/Context'; import ActivityKeyerContext from './private/Context'; import getActivityId from './private/getActivityId'; import getClientActivityId from './private/getClientActivityId'; import lastOf from './private/lastOf'; import someIterable from './private/someIterable'; import uniqueId from './private/uniqueId'; import useActivityKeyerContext from './private/useContext'; type ActivityIdToKeyMap = Map; type ActivityToKeyMap = Map; type ClientActivityIdToKeyMap = Map; type KeyToActivitiesMap = Map; function getTypingActivityId(activity: WebChatActivity): string | undefined { const { type } = activity; if ( (type === 'message' || type === 'typing') && 'text' in activity && typeof activity.text === 'string' && 'streamId' in activity.channelData && activity.channelData.streamId ) { return activity.channelData.streamId; } } /** * React context composer component to assign a perma-key to every activity. * This will support both `useGetActivityByKey` and `useGetKeyByActivity` custom hooks. * * Today, `activity.id` is only guaranteed for activity from others. * Not all activities sent by the local user has `activity.id`. * * To track outgoing activities, we added `activity.channelData.clientActivityId`. * * This component will create a local key, which can be used to track both * incoming and outgoing activities in a consistent way. * * Local key are only persisted in memory. On refresh, they will be a new random key. */ const ActivityKeyerComposer = ({ children }: Readonly<{ children?: ReactNode | undefined }>) => { const existingContext = useActivityKeyerContext(false); if (existingContext) { throw new Error('botframework-webchat internal: should not be nested.'); } const [activities] = useActivities(); const activityIdToKeyMapRef = useRef>(Object.freeze(new Map())); const activityToKeyMapRef = useRef>(Object.freeze(new Map())); const clientActivityIdToKeyMapRef = useRef>(Object.freeze(new Map())); const keyToActivitiesMapRef = useRef>(Object.freeze(new Map())); // TODO: [P1] `useMemoWithPrevious` to check and cache the resulting array if it hasn't changed. const activityKeysState = useMemo(() => { const { current: activityIdToKeyMap } = activityIdToKeyMapRef; const { current: activityToKeyMap } = activityToKeyMapRef; const { current: clientActivityIdToKeyMap } = clientActivityIdToKeyMapRef; const nextActivityIdToKeyMap: ActivityIdToKeyMap = new Map(); const nextActivityKeys: Set = new Set(); const nextActivityToKeyMap: ActivityToKeyMap = new Map(); const nextClientActivityIdToKeyMap: ClientActivityIdToKeyMap = new Map(); const nextKeyToActivitiesMap: KeyToActivitiesMap = new Map(); activities.forEach(activity => { const activityId = getActivityId(activity); const clientActivityId = getClientActivityId(activity); const typingActivityId = getTypingActivityId(activity); const key = (clientActivityId && clientActivityIdToKeyMap.get(clientActivityId)) || (typingActivityId && activityIdToKeyMap.get(typingActivityId)) || (activityId && activityIdToKeyMap.get(activityId)) || activityToKeyMap.get(activity) || uniqueId(); activityId && nextActivityIdToKeyMap.set(activityId, key); clientActivityId && nextClientActivityIdToKeyMap.set(clientActivityId, key); nextActivityToKeyMap.set(activity, key); nextActivityKeys.add(key); const activities = nextKeyToActivitiesMap.has(key) ? [...nextKeyToActivitiesMap.get(key)] : []; activities.push(activity); nextKeyToActivitiesMap.set(key, Object.freeze(activities)); }); activityIdToKeyMapRef.current = Object.freeze(nextActivityIdToKeyMap); activityToKeyMapRef.current = Object.freeze(nextActivityToKeyMap); clientActivityIdToKeyMapRef.current = Object.freeze(nextClientActivityIdToKeyMap); keyToActivitiesMapRef.current = Object.freeze(nextKeyToActivitiesMap); // `nextActivityKeys` could potentially same as `prevActivityKeys` despite reference differences, we should memoize it. return Object.freeze([Object.freeze([...nextActivityKeys.values()])]) as readonly [readonly string[]]; }, [activities, activityIdToKeyMapRef, activityToKeyMapRef, clientActivityIdToKeyMapRef, keyToActivitiesMapRef]); const getActivitiesByKey: (key?: string | undefined) => readonly WebChatActivity[] | undefined = useCallback( (key?: string | undefined): readonly WebChatActivity[] | undefined => key && keyToActivitiesMapRef.current.get(key), [keyToActivitiesMapRef] ); const getActivityByKey: (key?: string | undefined) => undefined | WebChatActivity = useCallback( (key?: string | undefined): undefined | WebChatActivity => lastOf(getActivitiesByKey(key)), [getActivitiesByKey] ); const getKeyByActivity: (activity?: WebChatActivity | undefined) => string | undefined = useCallback( (activity?: WebChatActivity | undefined) => activity && activityToKeyMapRef.current.get(activity), [activityToKeyMapRef] ); const getKeyByActivityId: (activityId?: string | undefined) => string | undefined = useCallback( (activityId?: string | undefined) => activityId && activityIdToKeyMapRef.current.get(activityId), [activityIdToKeyMapRef] ); const contextValue = useMemo( () => ({ activityKeysState, getActivityByKey, getActivitiesByKey, getKeyByActivity, getKeyByActivityId }), [activityKeysState, getActivitiesByKey, getActivityByKey, getKeyByActivity, getKeyByActivityId] ); const { length: numActivities } = activities; if (activityIdToKeyMapRef.current.size > numActivities) { console.warn( 'botframework-webchat internal assertion: "activityIdToKeyMap.size" should be equal or less than "activities.length".' ); } if (activityToKeyMapRef.current.size !== numActivities) { console.warn( 'botframework-webchat internal assertion: "activityToKeyMap.size" should be same as "activities.length".' ); } if (clientActivityIdToKeyMapRef.current.size > numActivities) { console.warn( 'botframework-webchat internal assertion: "clientActivityIdToKeyMap.size" should be equal or less than "activities.length".' ); } if (someIterable(keyToActivitiesMapRef.current.values(), ({ length }) => !length)) { console.warn( 'botframework-webchat internal assertion: all values in "keyToActivitiesMap" should have at least one item.' ); } if ( reduceIterable(keyToActivitiesMapRef.current.values(), (total, { length }) => total + length, 0) !== numActivities ) { console.warn( 'botframework-webchat internal assertion: "keyToActivitiesMap.size" should be same as "activities.length".' ); } if (activityKeysState[0].length !== keyToActivitiesMapRef.current.size) { console.warn( 'botframework-webchat internal assertion: "activityKeys.length" should be same as "keyToActivitiesMap.size".' ); } return {children}; }; export default ActivityKeyerComposer;