botframework-webchat-component
Version:
React component of botframework-webchat
1,122 lines (940 loc) • 43.8 kB
JavaScript
/* eslint no-magic-numbers: ["error", { "ignore": [-1, 0, 1, 2, 5, 36] }] */
import { hooks } from 'botframework-webchat-api';
import {
Composer as ReactScrollToBottomComposer,
Panel as ReactScrollToBottomPanel,
useAnimatingToEnd,
useObserveScrollPosition,
useScrollTo,
useScrollToEnd,
useSticky
} from 'react-scroll-to-bottom';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import random from 'math-random';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import BasicTypingIndicator from './BasicTypingIndicator';
import Fade from './Utils/Fade';
import FocusRedirector from './Utils/FocusRedirector';
import getActivityUniqueId from './Utils/getActivityUniqueId';
import getTabIndex from './Utils/TypeFocusSink/getTabIndex';
import inputtableKey from './Utils/TypeFocusSink/inputtableKey';
import intersectionOf from './Utils/intersectionOf';
import isZeroOrPositive from './Utils/isZeroOrPositive';
import removeInline from './Utils/removeInline';
import ScreenReaderActivity from './ScreenReaderActivity';
import ScreenReaderText from './ScreenReaderText';
import ScrollToEndButton from './Activity/ScrollToEndButton';
import SpeakActivity from './Activity/Speak';
import tabbableElements from './Utils/tabbableElements';
import useAcknowledgedActivity from './hooks/internal/useAcknowledgedActivity';
import useDispatchScrollPosition from './hooks/internal/useDispatchScrollPosition';
import useDispatchTranscriptFocus from './hooks/internal/useDispatchTranscriptFocus';
import useFocus from './hooks/useFocus';
import useMemoize from './hooks/internal/useMemoize';
import useRegisterFocusTranscript from './hooks/internal/useRegisterFocusTranscript';
import useRegisterScrollRelative from './hooks/internal/useRegisterScrollRelative';
import useRegisterScrollTo from './hooks/internal/useRegisterScrollTo';
import useRegisterScrollToEnd from './hooks/internal/useRegisterScrollToEnd';
import useStyleSet from './hooks/useStyleSet';
import useStyleToEmotionObject from './hooks/internal/useStyleToEmotionObject';
import useUniqueId from './hooks/internal/useUniqueId';
const {
useActivities,
useCreateActivityRenderer,
useCreateActivityStatusRenderer,
useCreateAvatarRenderer,
useDirection,
useGroupActivities,
useLocalizer,
useStyleOptions
} = hooks;
const ROOT_STYLE = {
'&.webchat__basic-transcript': {
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
// Make sure to set "position: relative" here to form another stacking context for the scroll-to-end button.
// Stacking context help isolating elements that use "z-index" from global pollution.
// https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Positioning/Understanding_z_index/The_stacking_context
position: 'relative',
'& .webchat__basic-transcript__filler': {
flex: 1
},
'& .webchat__basic-transcript__scrollable': {
display: 'flex',
flexDirection: 'column',
overflowX: 'hidden',
WebkitOverflowScrolling: 'touch'
},
'& .webchat__basic-transcript__transcript': {
listStyleType: 'none'
}
}
};
function validateAllActivitiesTagged(activities, bins) {
return activities.every(activity => bins.some(bin => bin.includes(activity)));
}
const InternalTranscript = ({ activityElementsRef, className }) => {
const [{ basicTranscript: basicTranscriptStyleSet }] = useStyleSet();
const [
{ bubbleFromUserNubOffset, bubbleNubOffset, groupTimestamp, internalLiveRegionFadeAfter, showAvatarInGroup }
] = useStyleOptions();
const [focusedActivityKey, setFocusedActivityKey] = useState();
const [activities] = useActivities();
const [direction] = useDirection();
const createActivityRenderer = useCreateActivityRenderer();
const createActivityStatusRenderer = useCreateActivityStatusRenderer();
const createAvatarRenderer = useCreateAvatarRenderer();
const focus = useFocus();
const groupActivities = useGroupActivities();
const localize = useLocalizer();
const rootClassName = useStyleToEmotionObject()(ROOT_STYLE) + '';
const rootElementRef = useRef();
const terminatorRef = useRef();
const activityInteractiveAlt = localize('ACTIVITY_INTERACTIVE_LABEL_ALT');
const terminatorText = localize('TRANSCRIPT_TERMINATOR_TEXT');
const transcriptAriaLabel = localize('TRANSCRIPT_ARIA_LABEL_ALT');
const transcriptRoleDescription = localize('TRANSCRIPT_ARIA_ROLE_ALT');
const hideAllTimestamps = groupTimestamp === false;
// Gets renderer for every activity.
// Activities that are not visible will return a falsy renderer.
// Converted from createActivityRenderer({ activity, nextVisibleActivity }) to createActivityRenderer(activity, nextVisibleActivity).
// This is for the memoization function to cache the arguments. Memoizer can only cache literal arguments.
const createActivityRendererWithLiteralArgs = useCallback(
(activity, nextVisibleActivity) => createActivityRenderer({ activity, nextVisibleActivity }),
[createActivityRenderer]
);
// Create a memoized context of the createActivityRenderer function.
const activitiesWithRenderer = useMemoize(
createActivityRendererWithLiteralArgs,
createActivityRendererWithLiteralArgsMemoized => {
// All calls to createActivityRendererWithLiteralArgsMemoized() in this function will be memoized (LRU = 1).
// In the next render cycle, calls to createActivityRendererWithLiteralArgsMemoized() might return the memoized result instead.
// This is an improvement to React useMemo(), because it only allows 1 memoization.
// useMemoize() allows any number of memoization.
const activitiesWithRenderer = [];
let nextVisibleActivity;
for (let index = activities.length - 1; index >= 0; index--) {
const activity = activities[index];
const renderActivity = createActivityRendererWithLiteralArgsMemoized(activity, nextVisibleActivity);
if (renderActivity) {
activitiesWithRenderer.splice(0, 0, {
activity,
renderActivity
});
nextVisibleActivity = activity;
}
}
return activitiesWithRenderer;
},
[activities]
);
const visibleActivities = useMemo(() => activitiesWithRenderer.map(({ activity }) => activity), [
activitiesWithRenderer
]);
// Tag activities based on types.
// The default implementation tag into 2 types: sender and status.
const { activitiesGroupBySender, activitiesGroupByStatus } = useMemo(() => {
const { sender: activitiesGroupBySender, status: activitiesGroupByStatus } = groupActivities({
activities: visibleActivities
});
if (!validateAllActivitiesTagged(visibleActivities, activitiesGroupBySender)) {
console.warn(
'botframework-webchat: Not every activities are grouped in the "sender" property. Please fix "groupActivitiesMiddleware" and group every activities.'
);
}
if (!validateAllActivitiesTagged(visibleActivities, activitiesGroupByStatus)) {
console.warn(
'botframework-webchat: Not every activities are grouped in the "status" property. Please fix "groupActivitiesMiddleware" and group every activities.'
);
}
return {
activitiesGroupBySender,
activitiesGroupByStatus
};
}, [groupActivities, visibleActivities]);
// Create a tree of activities with 2 dimensions: sender, followed by status.
const activityTree = useMemo(() => {
const visibleActivitiesPendingGrouping = [...visibleActivities];
const activityTree = [];
while (visibleActivitiesPendingGrouping.length) {
const [activity] = visibleActivitiesPendingGrouping;
const senderTree = [];
const activitiesWithSameSender = activitiesGroupBySender.find(activities => activities.includes(activity));
activityTree.push(senderTree);
activitiesWithSameSender.forEach(activity => {
const activitiesWithSameStatus = activitiesGroupByStatus.find(activities => activities.includes(activity));
const activitiesWithSameSenderAndStatus = intersectionOf(
visibleActivitiesPendingGrouping,
activitiesWithSameSender,
activitiesWithSameStatus
);
if (activitiesWithSameSenderAndStatus.length) {
senderTree.push(activitiesWithSameSenderAndStatus);
removeInline(visibleActivitiesPendingGrouping, ...activitiesWithSameSenderAndStatus);
}
});
}
// Assertion: All activities in visibleActivities, must be assigned to the activityTree
if (
!visibleActivities.every(activity =>
activityTree.some(activitiesWithSameSender =>
activitiesWithSameSender.some(activitiesWithSameSenderAndStatus =>
activitiesWithSameSenderAndStatus.includes(activity)
)
)
)
) {
console.warn('botframework-webchat internal: Not all visible activities are grouped in the activityTree.', {
visibleActivities,
activityTree
});
}
return activityTree;
}, [activitiesGroupBySender, activitiesGroupByStatus, visibleActivities]);
// Flatten the tree back into an array with information related to rendering.
const renderingElements = useMemo(() => {
const renderingElements = [];
const topSideBotNub = isZeroOrPositive(bubbleNubOffset);
const topSideUserNub = isZeroOrPositive(bubbleFromUserNubOffset);
activityTree.forEach(activitiesWithSameSender => {
const [[firstActivity]] = activitiesWithSameSender;
const renderAvatar = createAvatarRenderer({ activity: firstActivity });
activitiesWithSameSender.forEach((activitiesWithSameSenderAndStatus, indexWithinSenderGroup) => {
const firstInSenderGroup = !indexWithinSenderGroup;
const lastInSenderGroup = indexWithinSenderGroup === activitiesWithSameSender.length - 1;
activitiesWithSameSenderAndStatus.forEach((activity, indexWithinSenderAndStatusGroup) => {
// We only show the timestamp at the end of the sender group. But we always show the "Send failed, retry" prompt.
const renderActivityStatus = createActivityStatusRenderer({
activity
});
const firstInSenderAndStatusGroup = !indexWithinSenderAndStatusGroup;
const lastInSenderAndStatusGroup =
indexWithinSenderAndStatusGroup === activitiesWithSameSenderAndStatus.length - 1;
const { renderActivity } = activitiesWithRenderer.find(entry => entry.activity === activity);
const key = getActivityUniqueId(activity) || renderingElements.length;
const {
channelData: { messageBack: { displayText: messageBackDisplayText } = {} } = {},
from: { role },
text
} = activity;
const topSideNub = role === 'user' ? topSideUserNub : topSideBotNub;
let showCallout;
// Depends on different "showAvatarInGroup" setting, we will show the avatar in different positions.
if (showAvatarInGroup === 'sender') {
if (topSideNub) {
showCallout = firstInSenderGroup && firstInSenderAndStatusGroup;
} else {
showCallout = lastInSenderGroup && lastInSenderAndStatusGroup;
}
} else if (showAvatarInGroup === 'status') {
if (topSideNub) {
showCallout = firstInSenderAndStatusGroup;
} else {
showCallout = lastInSenderAndStatusGroup;
}
} else {
showCallout = true;
}
const focusActivity = () => {
setFocusedActivityKey(getActivityUniqueId(activity));
// IE11 need to manually focus on the transcript.
const { current: rootElement } = rootElementRef;
rootElement && rootElement.focus();
};
renderingElements.push({
activity,
// After the element is mounted, set it to activityElementsRef.
callbackRef: activityElement => {
const entry = activityElementsRef.current.find(({ activityID }) => activityID === activity.id);
if (entry) {
entry.element = activityElement;
}
},
// Calling this function will put the focus on the transcript and the activity.
focusActivity,
// When a child of the activity receives focus, notify the transcript to set the aria-activedescendant to this activity.
handleFocus: () => {
setFocusedActivityKey(getActivityUniqueId(activity));
},
handleKeyDown: event => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
setFocusedActivityKey(getActivityUniqueId(activity));
const { current } = rootElementRef;
current && current.focus();
}
},
// For accessibility: when the user press up/down arrow keys, we put a visual focus indicator around the focused activity.
// We should do the same for mouse, that is why we have the click handler here.
// We are doing it in event capture phase to prevent other components from stopping event propagation to us.
handleMouseDownCapture: ({ target }) => {
const tabIndex = getTabIndex(target);
if (typeof tabIndex !== 'number' || tabIndex < 0 || target.getAttribute('aria-disabled') === 'true') {
focusActivity();
}
},
// "hideTimestamp" is a render-time parameter for renderActivityStatus().
// If true, it will hide the timestamp, but it will continue to show the
// retry prompt. And show the screen reader version of the timestamp.
hideTimestamp:
hideAllTimestamps || indexWithinSenderAndStatusGroup !== activitiesWithSameSenderAndStatus.length - 1,
key,
// When "liveRegionKey" changes, it will show up in the live region momentarily.
liveRegionKey: key + '|' + (messageBackDisplayText || text),
renderActivity,
renderActivityStatus,
renderAvatar,
role,
// TODO: [P2] #2858 We should use core/definitions/speakingActivity for this predicate instead
shouldSpeak: activity.channelData && activity.channelData.speak,
showCallout
});
});
});
});
const { current: activityElements } = activityElementsRef;
// Update activityElementRef with new sets of activity, while retaining the existing referencing element if exists.
activityElementsRef.current = renderingElements.map(({ activity, activity: { id }, elementId, key }) => {
const existingEntry = activityElements.find(entry => entry.key === key);
return {
activity,
activityID: id,
ariaLabelID: existingEntry
? existingEntry.ariaLabelID
: `webchat__basic-transcript__activity-label-${random().toString(36).substr(2, 5)}`,
element: existingEntry && existingEntry.element,
elementId,
key
};
});
// There must be one focused (a.k.a. aria-activedescendant) designated. We default it to the last one.
if (!renderingElements.find(({ focused }) => focused)) {
const lastElement = renderingElements[renderingElements.length - 1];
if (lastElement) {
lastElement.focused = true;
}
}
return renderingElements;
}, [
activitiesWithRenderer,
activityElementsRef,
activityTree,
bubbleFromUserNubOffset,
bubbleNubOffset,
createActivityStatusRenderer,
createAvatarRenderer,
hideAllTimestamps,
rootElementRef,
showAvatarInGroup
]);
const renderingActivities = useMemo(() => renderingElements.map(({ activity }) => activity), [renderingElements]);
const scrollToBottomScrollTo = useScrollTo();
const scrollToBottomScrollToEnd = useScrollToEnd();
const scrollTo = useCallback(
(position, { behavior = 'auto' } = {}) => {
if (!position) {
throw new Error(
'botframework-webchat: First argument passed to "useScrollTo" must be a ScrollPosition object.'
);
}
const { activityID, scrollTop } = position;
if (typeof scrollTop !== 'undefined') {
scrollToBottomScrollTo(scrollTop, { behavior });
} else if (typeof activityID !== 'undefined') {
const { current: rootElement } = rootElementRef;
const { element: activityElement } =
activityElementsRef.current.find(entry => entry.activityID === activityID) || {};
const scrollableElement = rootElement.querySelector('.webchat__basic-transcript__scrollable');
if (scrollableElement && activityElement) {
const [{ height: activityElementHeight, y: activityElementY }] = activityElement.getClientRects();
const [{ height: scrollableHeight }] = scrollableElement.getClientRects();
const activityElementOffsetTop = activityElementY + scrollableElement.scrollTop;
const scrollTop = Math.min(
activityElementOffsetTop,
activityElementOffsetTop - scrollableHeight + activityElementHeight
);
scrollToBottomScrollTo(scrollTop, { behavior });
}
}
},
[activityElementsRef, rootElementRef, scrollToBottomScrollTo]
);
const scrollRelative = useCallback(
(direction, { displacement } = {}) => {
const { current: rootElement } = rootElementRef;
if (!rootElement) {
return;
}
const scrollable = rootElement.querySelector('.webchat__basic-transcript__scrollable');
let nextScrollTop;
if (typeof displacement === 'number') {
nextScrollTop = scrollable.scrollTop + (direction === 'down' ? 1 : -1) * displacement;
} else {
nextScrollTop = scrollable.scrollTop + (direction === 'down' ? 1 : -1) * scrollable.offsetHeight;
}
scrollTo(
{
scrollTop: Math.max(0, Math.min(scrollable.scrollHeight - scrollable.offsetHeight, nextScrollTop))
},
{ behavior: 'smooth' }
);
},
[rootElementRef, scrollTo]
);
// Since there could be multiple instances of <BasicTranscript> inside the <Composer>, when the developer calls `scrollXXX`, we need to call it on all instances.
// We call `useRegisterScrollXXX` to register a callback function, the `useScrollXXX` will multiplex the call into each instance of <BasicTranscript>.
useRegisterScrollTo(scrollTo);
useRegisterScrollToEnd(scrollToBottomScrollToEnd);
useRegisterScrollRelative(scrollRelative);
const dispatchScrollPosition = useDispatchScrollPosition();
const patchedDispatchScrollPosition = useMemo(() => {
if (!dispatchScrollPosition) {
return;
}
return ({ scrollTop }) => {
const { current: rootElement } = rootElementRef;
if (!rootElement) {
return;
}
const scrollableElement = rootElement.querySelector('.webchat__basic-transcript__scrollable');
const [{ height: offsetHeight } = {}] = scrollableElement.getClientRects();
// Find the activity just above scroll view bottom.
// If the scroll view is already on top, get the first activity.
const entry = scrollableElement.scrollTop
? [...activityElementsRef.current].reverse().find(({ element }) => {
if (!element) {
return false;
}
const [{ y } = {}] = element.getClientRects();
return y < offsetHeight;
})
: activityElementsRef.current[0];
const { activityID } = entry || {};
dispatchScrollPosition({ ...(activityID ? { activityID } : {}), scrollTop });
};
}, [activityElementsRef, dispatchScrollPosition, rootElementRef]);
useObserveScrollPosition(patchedDispatchScrollPosition);
const [lastInteractedActivity] = useAcknowledgedActivity();
const indexOfLastInteractedActivity = activities.indexOf(lastInteractedActivity);
// Create a new ID for aria-activedescendant every time the active descendant change.
// In our design, the transcript will only have 1 focused activity and it has an ID. Other blurred activities will not have ID assigned.
// This help with performance.
// But browser usually do noop if the value of aria-activedescendant doesn't change.
// That means, if we assign the same ID to another element, browser will do noop.
// We need to generate a new ID so the browser see there is a change in aria-activedescendant value and perform accordingly.
const activeDescendantElementId = useMemo(
() => focusedActivityKey && `webchat__basic-transcript__active-descendant-${random().toString(36).substr(2, 5)}`,
[focusedActivityKey]
);
const scrollActiveDescendantIntoView = useCallback(() => {
const activeDescendant = activeDescendantElementId && document.getElementById(activeDescendantElementId);
// Don't scroll active descendant into view if the focus is already inside it.
// Otherwise, given the focus is on the send box, clicking on any <input> inside the Adaptive Cards may cause the view to move.
// This UX is not desirable because click should not cause scroll.
if (activeDescendant && !activeDescendant.contains(document.activeElement)) {
// Checks if scrollIntoView support options or not.
// - https://github.com/Modernizr/Modernizr/issues/1568#issuecomment-419457972
// - https://stackoverflow.com/questions/46919627/is-it-possible-to-test-for-scrollintoview-browser-compatibility
if ('scrollBehavior' in document.documentElement.style) {
activeDescendant.scrollIntoView({ block: 'nearest' });
} else {
// This is for browser that does not support options passed to scrollIntoView(), possibly IE11.
const scrollableElement = rootElementRef.current.querySelector('.webchat__basic-transcript__scrollable');
const scrollTopAtTopSide = activeDescendant.offsetTop;
const scrollTopAtBottomSide = activeDescendant.offsetTop + activeDescendant.offsetHeight;
if (scrollTopAtTopSide < scrollableElement.scrollTop) {
scrollableElement.scrollTop = scrollTopAtTopSide;
} else if (scrollTopAtBottomSide > scrollableElement.scrollTop + scrollableElement.offsetHeight) {
scrollableElement.scrollTop = scrollTopAtBottomSide - scrollableElement.offsetHeight;
}
}
}
}, [activeDescendantElementId, rootElementRef]);
const handleTranscriptFocus = useCallback(
event => {
const { currentTarget, target } = event;
// When focus is placed on the transcript, scroll active descendant into the view.
currentTarget === target && scrollActiveDescendantIntoView();
},
[scrollActiveDescendantIntoView]
);
// After new aria-activedescendant is assigned, we will need to scroll it into view.
// User agent will scroll automatically for focusing element, but not for aria-activedescendant.
// We need to do the scrolling manually.
useEffect(() => scrollActiveDescendantIntoView(), [scrollActiveDescendantIntoView]);
// If any activities has changed, reset the active descendant.
useEffect(() => setFocusedActivityKey(undefined), [activities, setFocusedActivityKey]);
const focusRelativeActivity = useCallback(
delta => {
if (isNaN(delta) || !renderingElements.length) {
return setFocusedActivityKey(undefined);
}
const index = renderingElements.findIndex(({ key }) => key === focusedActivityKey);
const nextIndex = ~index
? Math.max(0, Math.min(renderingElements.length - 1, index + delta))
: renderingElements.length - 1;
const nextFocusedActivity = renderingElements[nextIndex];
setFocusedActivityKey(nextFocusedActivity.key);
rootElementRef.current && rootElementRef.current.focus();
},
[focusedActivityKey, renderingElements, rootElementRef, setFocusedActivityKey]
);
const handleTranscriptKeyDown = useCallback(
event => {
const { target } = event;
const fromEndOfTranscriptIndicator = target === terminatorRef.current;
const fromTranscript = target === event.currentTarget;
if (!fromEndOfTranscriptIndicator && !fromTranscript) {
return;
}
let handled = true;
switch (event.key) {
case 'ArrowDown':
focusRelativeActivity(fromEndOfTranscriptIndicator ? 0 : 1);
break;
case 'ArrowUp':
focusRelativeActivity(fromEndOfTranscriptIndicator ? 0 : -1);
break;
case 'End':
focusRelativeActivity(Infinity);
break;
case 'Enter':
if (!fromEndOfTranscriptIndicator) {
const focusedActivityEntry = renderingElements.find(({ key }) => key === focusedActivityKey);
if (focusedActivityEntry) {
const { element: focusedActivityElement } =
activityElementsRef.current.find(({ activity }) => activity === focusedActivityEntry.activity) || {};
if (focusedActivityElement) {
const [firstTabbableElement] = tabbableElements(focusedActivityElement).filter(
({ className }) => className !== 'webchat__basic-transcript__activity-sentinel'
);
firstTabbableElement && firstTabbableElement.focus();
}
}
}
break;
case 'Escape':
focus('sendBoxWithoutKeyboard');
break;
case 'Home':
focusRelativeActivity(-Infinity);
break;
default:
handled = false;
break;
}
if (handled) {
event.preventDefault();
// If a custom HTML control wants to handle up/down arrow, we will prevent them from listening to this event to prevent bugs due to handling arrow keys twice.
event.stopPropagation();
}
},
[focusedActivityKey, activityElementsRef, focusRelativeActivity, focus, terminatorRef, renderingElements]
);
const labelId = useUniqueId('webchat__basic-transcript__label');
// If SHIFT-TAB from "End of transcript" indicator, if focusedActivityKey is not set (or no longer exists), set it the the bottommost activity.
const setBottommostFocusedActivityKeyIfNeeded = useCallback(() => {
if (!~renderingElements.findIndex(({ key }) => key === focusedActivityKey)) {
const { key: lastActivityKey } = renderingElements[renderingElements.length - 1] || {};
setFocusedActivityKey(lastActivityKey);
}
}, [focusedActivityKey, renderingElements, setFocusedActivityKey]);
const handleTranscriptKeyDownCapture = useCallback(
event => {
const { altKey, ctrlKey, key, metaKey, target } = event;
if (altKey || (ctrlKey && key !== 'v') || metaKey || (!inputtableKey(key) && key !== 'Backspace')) {
// Ignore if one of the utility key (except SHIFT) is pressed
// E.g. CTRL-C on a link in one of the message should not jump to chat box
// E.g. "A" or "Backspace" should jump to chat box
return;
}
// Send keystrokes to send box if we are focusing on the transcript or terminator.
if (target === event.currentTarget || target === terminatorRef.current) {
event.stopPropagation();
focus('sendBox');
}
},
[focus]
);
const focusTranscriptCallback = useCallback(() => rootElementRef.current && rootElementRef.current.focus(), [
rootElementRef
]);
useRegisterFocusTranscript(focusTranscriptCallback);
const handleFocusActivity = useCallback(
key => {
setFocusedActivityKey(key);
rootElementRef.current && rootElementRef.current.focus();
},
[setFocusedActivityKey]
);
// When the focusing activity has changed, dispatch an event to observers of "useObserveTranscriptFocus".
const dispatchTranscriptFocus = useDispatchTranscriptFocus();
const focusedActivity = useMemo(() => {
const { activity } = renderingElements.find(({ key }) => key === focusedActivityKey) || {};
return activity;
}, [focusedActivityKey, renderingElements]);
useMemo(() => dispatchTranscriptFocus && dispatchTranscriptFocus({ activity: focusedActivity }), [
dispatchTranscriptFocus,
focusedActivity
]);
// This is required by IE11.
// When the user clicks on and empty space (a.k.a. filler) in an empty transcript, IE11 says the focus is on the <div className="filler">,
// despite the fact there are no "tabIndex" attributes set on the filler.
// We need to artificially send the focus back to the transcript.
const handleFocusFiller = useCallback(() => {
const { current } = rootElementRef;
current && current.focus();
}, [rootElementRef]);
return (
<div
aria-activedescendant={focusedActivityKey ? activeDescendantElementId : undefined}
aria-labelledby={labelId}
className={classNames(
'webchat__basic-transcript',
basicTranscriptStyleSet + '',
rootClassName,
(className || '') + ''
)}
dir={direction}
onFocus={handleTranscriptFocus}
onKeyDown={handleTranscriptKeyDown}
onKeyDownCapture={handleTranscriptKeyDownCapture}
ref={rootElementRef}
// "aria-activedescendant" will only works with a number of roles and it must be explicitly set.
// https://www.w3.org/TR/wai-aria/#aria-activedescendant
role="group"
// For up/down arrow key navigation across activities, this component must be included in the tab sequence.
// Otherwise, "aria-activedescendant" will not be narrated when the user press up/down arrow keys.
// https://www.w3.org/TR/wai-aria-practices-1.1/#kbd_focus_activedescendant
tabIndex={0}
>
<ScreenReaderText id={labelId} text={transcriptAriaLabel} />
{/* This <section> is for live region only. Content is made invisible through CSS. */}
<section
aria-atomic={false}
aria-live="polite"
aria-relevant="additions"
aria-roledescription={transcriptRoleDescription}
role="log"
>
{renderingElements.map(({ activity, liveRegionKey }) => (
<Fade fadeAfter={internalLiveRegionFadeAfter} key={liveRegionKey}>
{() => <ScreenReaderActivity activity={activity} />}
</Fade>
))}
</section>
<InternalTranscriptScrollable
activities={renderingActivities}
onFocusActivity={handleFocusActivity}
onFocusFiller={handleFocusFiller}
terminatorRef={terminatorRef}
>
{renderingElements.map(
(
{
activity,
callbackRef,
focusActivity,
handleFocus,
handleKeyDown,
handleMouseDownCapture,
hideTimestamp,
key,
renderActivity,
renderActivityStatus,
renderAvatar,
role,
shouldSpeak,
showCallout
},
index
) => {
const { ariaLabelID, element } =
activityElementsRef.current.find(entry => entry.activity === activity) || {};
const activeDescendant = focusedActivityKey === key;
const isContentInteractive = !!(element
? tabbableElements(element.querySelector('.webchat__basic-transcript__activity-box')).length
: 0);
return (
<li
aria-labelledby={ariaLabelID}
className={classNames('webchat__basic-transcript__activity', {
'webchat__basic-transcript__activity--acknowledged': index <= indexOfLastInteractedActivity,
'webchat__basic-transcript__activity--from-bot': role !== 'user',
'webchat__basic-transcript__activity--from-user': role === 'user'
})}
// Set "id" for valid for accessibility.
/* eslint-disable-next-line react/forbid-dom-props */
id={activeDescendant ? activeDescendantElementId : undefined}
key={key}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
onMouseDownCapture={handleMouseDownCapture}
ref={callbackRef}
>
<ScreenReaderActivity activity={activity} id={ariaLabelID} renderAttachments={false}>
{!!isContentInteractive && <p>{activityInteractiveAlt}</p>}
</ScreenReaderActivity>
<FocusRedirector
className="webchat__basic-transcript__activity-sentinel"
onFocus={focusActivity}
redirectRef={rootElementRef}
/>
<div className="webchat__basic-transcript__activity-box">
{renderActivity({
hideTimestamp,
renderActivityStatus,
renderAvatar,
showCallout
})}
</div>
{shouldSpeak && <SpeakActivity activity={activity} />}
<FocusRedirector
className="webchat__basic-transcript__activity-sentinel"
onFocus={focusActivity}
redirectRef={rootElementRef}
/>
<div
className={classNames('webchat__basic-transcript__activity-indicator', {
'webchat__basic-transcript__activity-indicator--first': !index,
'webchat__basic-transcript__activity-indicator--focus': activeDescendant
})}
/>
</li>
);
}
)}
</InternalTranscriptScrollable>
{!!renderingElements.length && (
<React.Fragment>
<FocusRedirector
className="webchat__basic-transcript__sentinel"
onFocus={setBottommostFocusedActivityKeyIfNeeded}
redirectRef={rootElementRef}
/>
<div className="webchat__basic-transcript__terminator" ref={terminatorRef} tabIndex={0}>
<div className="webchat__basic-transcript__terminator-body">
<div className="webchat__basic-transcript__terminator-text">{terminatorText}</div>
</div>
</div>
</React.Fragment>
)}
<div className="webchat__basic-transcript__focus-indicator" />
</div>
);
};
InternalTranscript.defaultProps = {
className: ''
};
InternalTranscript.propTypes = {
activityElementsRef: PropTypes.shape({
current: PropTypes.array.isRequired
}).isRequired,
className: PropTypes.string
};
const InternalScreenReaderTranscript = ({ renderingElements }) => {
const localize = useLocalizer();
const [internalLiveRegionFadeAfter] = useStyleOptions();
const transcriptRoleDescription = localize('TRANSCRIPT_ARIA_ROLE_ALT');
return (
<section
aria-atomic={false}
aria-live="polite"
aria-relevant="additions"
aria-roledescription={transcriptRoleDescription}
role="log"
>
{renderingElements.map(({ activity, liveRegionKey }) => (
<Fade fadeAfter={internalLiveRegionFadeAfter} key={liveRegionKey}>
{() => <ScreenReaderActivity activity={activity} />}
</Fade>
))}
</section>
);
};
InternalScreenReaderTranscript.propTypes = {
renderingElements: PropTypes.arrayOf(
PropTypes.shape({
activity: PropTypes.any,
liveRegionKey: PropTypes.string
})
).isRequired
};
// Separating high-frequency hooks to improve performance.
const InternalTranscriptScrollable = ({ activities, children, onFocusActivity, onFocusFiller, terminatorRef }) => {
const [{ activities: activitiesStyleSet }] = useStyleSet();
const [{ hideScrollToEndButton }] = useStyleOptions();
const [animatingToEnd] = useAnimatingToEnd();
const [sticky] = useSticky();
const lastVisibleActivityId = getActivityUniqueId(activities[activities.length - 1] || {}); // Activity ID of the last visible activity in the list.
const localize = useLocalizer();
const scrollToEndButtonRef = useRef();
const lastReadActivityIdRef = useRef(lastVisibleActivityId);
const transcriptRoleDescription = localize('TRANSCRIPT_ARIA_ROLE_ALT');
const allActivitiesRead = lastVisibleActivityId === lastReadActivityIdRef.current;
const handleScrollToEndButtonClick = useCallback(() => {
// After the "New message" button is clicked, focus on the first unread activity.
const index = activities.findIndex(({ id }) => id === lastReadActivityIdRef.current);
if (~index) {
const firstUnreadActivity = activities[index + 1];
if (firstUnreadActivity) {
return onFocusActivity(getActivityUniqueId(firstUnreadActivity));
}
}
const { current } = terminatorRef;
current && current.focus();
}, [activities, lastReadActivityIdRef, onFocusActivity, terminatorRef]);
if (sticky) {
// If it is sticky, the user is at the bottom of the transcript, everything is read.
// So mark the activity ID as read.
lastReadActivityIdRef.current = lastVisibleActivityId;
}
// Finds where we should render the "New messages" button, in index. Returns -1 to hide the button.
const renderSeparatorAfterIndex = useMemo(() => {
// Don't show the button if:
// - All activities have been read
// - Currently animating towards bottom
// - "New messages" button must not flash when: 1. Type "help", 2. Scroll to top, 3. Type "help" again, 4. Expect the "New messages" button not flashy
// - Hidden by style options
// - It is already at the bottom (sticky)
// Any changes to this logic, verify:
// - "New messages" button should persist while programmatically scrolling to mid-point of the transcript:
// 1. Type "help"
// 2. Type "proactive", then immediately scroll to top
// Expect: the "New messages" button should appear
// 3. Run hook "useScrollTo({ scrollTop: 500 })"
// Expect: when the scroll is animating to 500px, the "New messages" button should kept on the screen
// - "New messages" button must not flashy:
// 1. Type "help"
// 2. Scroll to top
// Expect: no "New messages" button is shown
// 3. Type "help" again
// Expect: "New messages" button must not flash-appear
if (allActivitiesRead || animatingToEnd || hideScrollToEndButton || sticky) {
return -1;
}
return activities.findIndex(activity => getActivityUniqueId(activity) === lastReadActivityIdRef.current);
}, [activities, allActivitiesRead, animatingToEnd, hideScrollToEndButton, lastReadActivityIdRef, sticky]);
return (
<React.Fragment>
{renderSeparatorAfterIndex !== -1 && (
<ScrollToEndButton onClick={handleScrollToEndButtonClick} ref={scrollToEndButtonRef} />
)}
{!!React.Children.count(children) && (
<FocusRedirector className="webchat__basic-transcript__sentinel" redirectRef={terminatorRef} />
)}
<ReactScrollToBottomPanel className="webchat__basic-transcript__scrollable">
<div aria-hidden={true} className="webchat__basic-transcript__filler" onFocus={onFocusFiller} />
<ul
aria-roledescription={transcriptRoleDescription}
className={classNames(activitiesStyleSet + '', 'webchat__basic-transcript__transcript')}
role="list"
>
{children}
</ul>
<BasicTypingIndicator />
</ReactScrollToBottomPanel>
</React.Fragment>
);
};
InternalTranscriptScrollable.propTypes = {
activities: PropTypes.array.isRequired,
children: PropTypes.any.isRequired,
onFocusActivity: PropTypes.func.isRequired,
onFocusFiller: PropTypes.func.isRequired,
terminatorRef: PropTypes.any.isRequired
};
const SetScroller = ({ activityElementsRef, scrollerRef }) => {
const [
{ autoScrollSnapOnActivity, autoScrollSnapOnActivityOffset, autoScrollSnapOnPage, autoScrollSnapOnPageOffset }
] = useStyleOptions();
const [lastAcknowledgedActivity] = useAcknowledgedActivity();
const lastAcknowledgedActivityRef = useRef(lastAcknowledgedActivity);
lastAcknowledgedActivityRef.current = lastAcknowledgedActivity;
scrollerRef.current = useCallback(
({ offsetHeight, scrollTop }) => {
const patchedAutoScrollSnapOnActivity =
typeof autoScrollSnapOnActivity === 'number'
? Math.max(0, autoScrollSnapOnActivity)
: autoScrollSnapOnActivity
? 1
: 0;
const patchedAutoScrollSnapOnPage =
typeof autoScrollSnapOnPage === 'number'
? Math.max(0, Math.min(1, autoScrollSnapOnPage))
: autoScrollSnapOnPage
? 1
: 0;
const patchedAutoScrollSnapOnActivityOffset =
typeof autoScrollSnapOnActivityOffset === 'number' ? autoScrollSnapOnActivityOffset : 0;
const patchedAutoScrollSnapOnPageOffset =
typeof autoScrollSnapOnPageOffset === 'number' ? autoScrollSnapOnPageOffset : 0;
if (patchedAutoScrollSnapOnActivity || patchedAutoScrollSnapOnPage) {
const { current: lastAcknowledgedActivity } = lastAcknowledgedActivityRef;
const values = [];
if (patchedAutoScrollSnapOnActivity) {
const { element: nthUnacknowledgedActivityElement } =
activityElementsRef.current[
activityElementsRef.current.findIndex(({ activity }) => activity === lastAcknowledgedActivity) +
patchedAutoScrollSnapOnActivity
] || {};
if (nthUnacknowledgedActivityElement) {
values.push(
nthUnacknowledgedActivityElement.offsetTop +
nthUnacknowledgedActivityElement.offsetHeight -
offsetHeight -
scrollTop +
patchedAutoScrollSnapOnActivityOffset
);
}
}
if (patchedAutoScrollSnapOnPage) {
const { element: firstUnacknowledgedActivityElement } =
activityElementsRef.current[
activityElementsRef.current.findIndex(({ activity }) => activity === lastAcknowledgedActivity) + 1
] || {};
if (firstUnacknowledgedActivityElement) {
values.push(
firstUnacknowledgedActivityElement.offsetTop -
scrollTop -
offsetHeight * (1 - patchedAutoScrollSnapOnPage) +
patchedAutoScrollSnapOnPageOffset
);
}
}
return values.reduce((minValue, value) => Math.min(minValue, value), Infinity);
}
return Infinity;
},
[
activityElementsRef,
autoScrollSnapOnActivity,
autoScrollSnapOnActivityOffset,
autoScrollSnapOnPage,
autoScrollSnapOnPageOffset,
lastAcknowledgedActivityRef
]
);
return false;
};
const BasicTranscript = ({ className }) => {
const activityElementsRef = useRef([]);
const scrollerRef = useRef(() => Infinity);
const scroller = useCallback((...args) => scrollerRef.current(...args), [scrollerRef]);
return (
<ReactScrollToBottomComposer scroller={scroller}>
<SetScroller activityElementsRef={activityElementsRef} scrollerRef={scrollerRef} />
<InternalTranscript activityElementsRef={activityElementsRef} className={className} />
</ReactScrollToBottomComposer>
);
};
BasicTranscript.defaultProps = {
className: ''
};
BasicTranscript.propTypes = {
className: PropTypes.string
};
export default BasicTranscript;