import React, { useCallback, useMemo, useState, type ReactNode } from 'react'; import usePrevious from '../../hooks/internal/usePrevious'; import useValueRef from '../../hooks/internal/useValueRef'; import useActivities from '../../hooks/useActivities'; import findLastIndex from '../../utils/findLastIndex'; import useActivityKeys from '../ActivityKeyer/useActivityKeys'; import ActivityAcknowledgementContext, { ActivityAcknowledgementContextType } from './private/Context'; type ActivityAcknowledgement = { get acknowledged(): boolean; get read(): boolean; }; function findClosestActivityKeyIfNotExists( activityKey: string, keys: readonly string[], prevKeys: readonly string[] ): string | undefined { if (keys.includes(activityKey)) { return activityKey; } else if (!prevKeys || !activityKey) { // Initially, when the transcript was empty, there should be nothing read. return; } // If the last activity key is no longer in this activities (say, deleted), we try to find the closest one. const prevIndex = prevKeys.indexOf(activityKey); if (~prevIndex) { // List out all previously activity keys, find the closest one that is in the new transcript. const acknowledgedActivityKeys = prevKeys.slice(0, prevIndex).reverse(); for (const prevAcknowledgedActivityKey of acknowledgedActivityKeys) { if (keys.includes(prevAcknowledgedActivityKey)) { return prevAcknowledgedActivityKey; } } } // If nothing is found, return `undefined`. } const ActivityAcknowledgementComposer = ({ children }: Readonly<{ children?: ReactNode | undefined }>) => { const [activities] = useActivities(); const [allActivityKeys] = useActivityKeys(); const [rawLastAcknowledgedActivityKey, setRawLastAcknowledgedActivityKey] = useState(); const [rawLastReadActivityKey, setRawLastReadActivityKey] = useState(); const allActivityKeysRef = useValueRef(allActivityKeys); const prevAllActivityKeys = usePrevious(allActivityKeys); const lastOutgoingActivityKeyIndex = useMemo( () => findLastIndex(activities, activity => activity.from?.role === 'user'), [activities] ); // Make sure when we return "lastReadActivityKey" exists in the current transcript. const lastReadActivityKey = useMemo(() => { rawLastReadActivityKey && !~prevAllActivityKeys.includes(rawLastReadActivityKey) && console.warn( `botframework-webchat internal assertion: "rawLastReadActivityKey" of value ${rawLastReadActivityKey} should be in the "prevAllActivityKeys" array.` ); return findClosestActivityKeyIfNotExists(rawLastReadActivityKey, allActivityKeys, prevAllActivityKeys); }, [allActivityKeys, prevAllActivityKeys, rawLastReadActivityKey]); const lastReadActivityKeyRef = useValueRef(lastReadActivityKey); // Make sure when we return "lastAcknowledgedActivityKey" exists in the current transcript. const lastAcknowledgedActivityKey = useMemo(() => { rawLastAcknowledgedActivityKey && !~prevAllActivityKeys.includes(rawLastAcknowledgedActivityKey) && console.warn( `botframework-webchat internal assertion: "rawLastAcknowledgedActivityKey" of value ${rawLastAcknowledgedActivityKey} should be in the "prevAllActivityKeys" array.` ); const lastAcknowledgedActivityKey = findClosestActivityKeyIfNotExists( rawLastAcknowledgedActivityKey, allActivityKeys, prevAllActivityKeys ); // TODO: [P2] Since Direct Line may send history and does not have read receipt. // Thus, if we don't assume everything is acknowledged initially, while displaying the history, // the transcript would soon stop scrolling. // Thus, before the first outgoing activity is detected, we need to assume everything is acknowledged. return ( allActivityKeys[Math.max(allActivityKeys.indexOf(lastAcknowledgedActivityKey), lastOutgoingActivityKeyIndex)] || allActivityKeys[allActivityKeys.length - 1] ); }, [allActivityKeys, lastOutgoingActivityKeyIndex, prevAllActivityKeys, rawLastAcknowledgedActivityKey]); const activityAcknowledgements = useMemo>>(() => { const activityAcknowledgements = new Map(); const lastAcknowledgedIndex = allActivityKeys.indexOf(lastAcknowledgedActivityKey); const lastReadIndex = allActivityKeys.indexOf(lastReadActivityKey); allActivityKeys.forEach((activityKey, index) => { activityAcknowledgements.set(activityKey, { acknowledged: index <= lastAcknowledgedIndex, read: index <= lastReadIndex }); }); return Object.freeze(activityAcknowledgements); }, [allActivityKeys, lastAcknowledgedActivityKey, lastReadActivityKey]); const activityAcknowledgementsRef = useValueRef(activityAcknowledgements); const getHasAcknowledgedByActivityKey = useCallback<(activityKey: string) => boolean>( (activityKey: string) => activityAcknowledgementsRef.current.get(activityKey)?.acknowledged, [activityAcknowledgementsRef] ); // TODO: [P2] Memoize with `useMemoWithPrevious` for better memoization of arrays. const activityKeysByReadState = useMemo(() => { const index = allActivityKeys.indexOf(lastReadActivityKey); return Object.freeze([ Object.freeze(allActivityKeys.slice(0, index + 1)), Object.freeze(allActivityKeys.slice(index + 1)) ]) as readonly [readonly string[], readonly string[]]; }, [allActivityKeys, lastReadActivityKey]); const markAllAsAcknowledged = useCallback((): void => { const { current: allActivityKeys } = allActivityKeysRef; setRawLastAcknowledgedActivityKey(allActivityKeys[allActivityKeys.length - 1]); }, [allActivityKeysRef, setRawLastAcknowledgedActivityKey]); const markActivityKeyAsRead = useCallback( (activityKey: string): void => { const { current: allActivityKeys } = allActivityKeysRef; const index = allActivityKeys.indexOf(activityKey); if (!~index) { return console.warn( `botframework-webchat: Cannot mark activity with key ${activityKey} as read because it is not in the transcript.` ); } index > allActivityKeys.indexOf(lastReadActivityKeyRef.current) && setRawLastReadActivityKey(activityKey); }, [allActivityKeysRef, lastReadActivityKeyRef, setRawLastReadActivityKey] ); if (activityKeysByReadState[0].length + activityKeysByReadState[1].length !== allActivityKeys.length) { console.warn( 'botframework-webchat internal: Sum of count of read and unread activity keys MUST equals to total number of activity keys.' ); } const contextValue = useMemo( () => ({ activityKeysByReadState, getHasAcknowledgedByActivityKey, lastAcknowledgedActivityKeyState: Object.freeze([lastAcknowledgedActivityKey]) as readonly [string], lastReadActivityKeyState: Object.freeze([lastReadActivityKey]) as readonly [string], markActivityKeyAsRead, markAllAsAcknowledged }), [ activityKeysByReadState, getHasAcknowledgedByActivityKey, lastAcknowledgedActivityKey, lastReadActivityKey, markActivityKeyAsRead, markAllAsAcknowledged ] ); return ( {children} ); }; export default ActivityAcknowledgementComposer;