diff --git a/CHANGELOG.md b/CHANGELOG.md index 38c68edd10..7df24785e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,24 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### Added + +- Resolves [#2745](https://github.com/microsoft/BotFramework-WebChat/issues/2745). Added new `inline` layout to suggested actions, by [@compulim](https://github.com/compulim) in PR [#3641](https://github.com/microsoft/BotFramework-WebChat/pull/3641) +- Added new style options to customize auto-scroll, by [@compulim](https://github.com/compulim) in PR [#XXX](https://github.com/microsoft/BotFramework-WebChat/pull/XXX) + - Set `autoScrollSnapOnActivity` to `true` to pause auto-scroll after more than one activity is shown, or a number to pause after X number of activities + - Set `autoScrollSnapOnPage` to `true` to pause auto-scroll when a page is filled, or a number between `0` and `1` to pause after % of page is filled + - Set `autoScrollSnapOnActivityOffset` and `autoScrollSnapOnPageOffset` to a number (in pixels) to overscroll/underscroll after the pause +- Supports multiple transcripts in a single composition, by [@compulim](https://github.com/compulim) in PR [#XXX](https://github.com/microsoft/BotFramework-WebChat/pull/XXX) + +### Fixed + +- Fixes [#3278](https://github.com/microsoft/BotFramework-WebChat/issues/3278). Update `HOOKS.md` verbiage, by [@corinagum](https://github.com/corinagum) in PR [#3564](https://github.com/microsoft/BotFramework-WebChat/pull/3564) +- Fixes [#3534](https://github.com/microsoft/BotFramework-WebChat/issues/3534). Remove 2020 deprecations, by [@corinagum](https://github.com/corinagum) in PR [#3564](https://github.com/microsoft/BotFramework-WebChat/pull/3564) +- Fixes [#3561](https://github.com/microsoft/BotFramework-WebChat/issues/3561). Remove MyGet mentions from samples, by [@corinagum](https://github.com/corinagum) in PR [#3564](https://github.com/microsoft/BotFramework-WebChat/pull/3564) +- Fixes [#3537](https://github.com/microsoft/BotFramework-WebChat/issues/3537). Fix some carousels improperly using aria-roledescription, by [@corinagum](https://github.com/corinagum) in PR [#3599](https://github.com/microsoft/BotFramework-WebChat/pull/3599) +- Fixes [#3483](https://github.com/microsoft/BotFramework-WebChat/issues/3483). IE11 anchors fixed to open securely without 'noreferrer' or 'noopener', by [@corinagum](https://github.com/corinagum) in PR [#3607](https://github.com/microsoft/BotFramework-WebChat/pull/3607) +- Fixes [#3565](https://github.com/microsoft/BotFramework-WebChat/issues/3565). Allow strikethrough `` on sanitize markdown, by [@corinagum](https://github.com/corinagum) in PR [#3646](https://github.com/microsoft/BotFramework-WebChat/pull/3646) + ### Changed - Bumped all dependencies to the latest versions, by [@compulim](https://github.com/compulim) in PR [#3594](https://github.com/microsoft/BotFramework-WebChat/pull/3594) @@ -70,19 +88,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - [`whatwg-fetch@3.4.1`](https://npmjs.com/package/whatwg-fetch) - [#3392](https://github.com/microsoft/BotFramework-WebChat/issues/3392) Bumped Adaptive Cards to the 2.5.0, by [@corinagum](https://github.com/corinagum) in PR [#3630](https://github.com/microsoft/BotFramework-WebChat/pull/3630) -### Added - -- Resolves [#2745](https://github.com/microsoft/BotFramework-WebChat/issues/2745). Added new `inline` layout to suggested actions, by [@compulim](https://github.com/compulim) in PR [#3641](https://github.com/microsoft/BotFramework-WebChat/pull/3641) - -### Fixed - -- Fixes [#3278](https://github.com/microsoft/BotFramework-WebChat/issues/3278). Update `HOOKS.md` verbiage, by [@corinagum](https://github.com/corinagum) in PR [#3564](https://github.com/microsoft/BotFramework-WebChat/pull/3564) -- Fixes [#3534](https://github.com/microsoft/BotFramework-WebChat/issues/3534). Remove 2020 deprecations, by [@corinagum](https://github.com/corinagum) in PR [#3564](https://github.com/microsoft/BotFramework-WebChat/pull/3564) -- Fixes [#3561](https://github.com/microsoft/BotFramework-WebChat/issues/3561). Remove MyGet mentions from samples, by [@corinagum](https://github.com/corinagum) in PR [#3564](https://github.com/microsoft/BotFramework-WebChat/pull/3564) -- Fixes [#3537](https://github.com/microsoft/BotFramework-WebChat/issues/3537). Fix some carousels improperly using aria-roledescription, by [@corinagum](https://github.com/corinagum) in PR [#3599](https://github.com/microsoft/BotFramework-WebChat/pull/3599) -- Fixes [#3483](https://github.com/microsoft/BotFramework-WebChat/issues/3483). IE11 anchors fixed to open securely without 'noreferrer' or 'noopener', by [@corinagum](https://github.com/corinagum) in PR [#3607](https://github.com/microsoft/BotFramework-WebChat/pull/3607) -- Fixes [#3565](https://github.com/microsoft/BotFramework-WebChat/issues/3565). Allow strikethrough `` on sanitize markdown, by [@corinagum](https://github.com/corinagum) in PR [#3646](https://github.com/microsoft/BotFramework-WebChat/pull/3646) - ### Samples - Fixes [#3473](https://github.com/microsoft/BotFramework-WebChat/issues/3473). Fix samples using activityMiddleware (from 4.10.0 breaking changes), by [@corinagum](https://github.com/corinagum) in PR [#3601](https://github.com/microsoft/BotFramework-WebChat/pull/3601) diff --git a/__tests__/__image_snapshots__/html/auto-scroll-snap-activity-and-page-js-auto-scroll-with-activity-and-page-snap-behavior-should-scroll-correctly-1-snap.png b/__tests__/__image_snapshots__/html/auto-scroll-snap-activity-and-page-js-auto-scroll-with-activity-and-page-snap-behavior-should-scroll-correctly-1-snap.png new file mode 100644 index 0000000000..a63d5b929b Binary files /dev/null and b/__tests__/__image_snapshots__/html/auto-scroll-snap-activity-and-page-js-auto-scroll-with-activity-and-page-snap-behavior-should-scroll-correctly-1-snap.png differ diff --git a/__tests__/__image_snapshots__/html/auto-scroll-snap-activity-js-auto-scroll-with-activity-snap-behavior-should-scroll-correctly-1-snap.png b/__tests__/__image_snapshots__/html/auto-scroll-snap-activity-js-auto-scroll-with-activity-snap-behavior-should-scroll-correctly-1-snap.png new file mode 100644 index 0000000000..f2a9820058 Binary files /dev/null and b/__tests__/__image_snapshots__/html/auto-scroll-snap-activity-js-auto-scroll-with-activity-snap-behavior-should-scroll-correctly-1-snap.png differ diff --git a/__tests__/__image_snapshots__/html/auto-scroll-snap-default-js-auto-scroll-with-default-snap-behavior-should-scroll-correctly-1-snap.png b/__tests__/__image_snapshots__/html/auto-scroll-snap-default-js-auto-scroll-with-default-snap-behavior-should-scroll-correctly-1-snap.png new file mode 100644 index 0000000000..dd3c0d49eb Binary files /dev/null and b/__tests__/__image_snapshots__/html/auto-scroll-snap-default-js-auto-scroll-with-default-snap-behavior-should-scroll-correctly-1-snap.png differ diff --git a/__tests__/__image_snapshots__/html/auto-scroll-snap-page-js-auto-scroll-with-page-snap-behavior-should-scroll-correctly-1-snap.png b/__tests__/__image_snapshots__/html/auto-scroll-snap-page-js-auto-scroll-with-page-snap-behavior-should-scroll-correctly-1-snap.png new file mode 100644 index 0000000000..969e8410ae Binary files /dev/null and b/__tests__/__image_snapshots__/html/auto-scroll-snap-page-js-auto-scroll-with-page-snap-behavior-should-scroll-correctly-1-snap.png differ diff --git a/__tests__/html/autoScroll.acknowledgement.html b/__tests__/html/autoScroll.acknowledgement.html new file mode 100644 index 0000000000..ac53b6841d --- /dev/null +++ b/__tests__/html/autoScroll.acknowledgement.html @@ -0,0 +1,172 @@ + + + + + + + + +
+
+ +
+ +
+ + + diff --git a/__tests__/html/autoScroll.acknowledgement.js b/__tests__/html/autoScroll.acknowledgement.js new file mode 100644 index 0000000000..7e1f59b13e --- /dev/null +++ b/__tests__/html/autoScroll.acknowledgement.js @@ -0,0 +1,7 @@ +/** + * @jest-environment ./__tests__/html/__jest__/WebChatEnvironment.js + */ + +describe('Auto-scroll acknowledgement logic', () => { + test('should acknowledge activities correctly', () => runHTMLTest('autoScroll.acknowledgement.html')); +}); diff --git a/__tests__/html/autoScroll.snap.activity.html b/__tests__/html/autoScroll.snap.activity.html new file mode 100644 index 0000000000..a1ab8edcd2 --- /dev/null +++ b/__tests__/html/autoScroll.snap.activity.html @@ -0,0 +1,132 @@ + + + + + + + + +
+ + + diff --git a/__tests__/html/autoScroll.snap.activity.js b/__tests__/html/autoScroll.snap.activity.js new file mode 100644 index 0000000000..34e7b0485a --- /dev/null +++ b/__tests__/html/autoScroll.snap.activity.js @@ -0,0 +1,7 @@ +/** + * @jest-environment ./__tests__/html/__jest__/WebChatEnvironment.js + */ + +describe('Auto-scroll with "activity" snap behavior', () => { + test('should scroll correctly', () => runHTMLTest('autoScroll.snap.activity.html')); +}); diff --git a/__tests__/html/autoScroll.snap.activityAndPage.html b/__tests__/html/autoScroll.snap.activityAndPage.html new file mode 100644 index 0000000000..266ce61ce0 --- /dev/null +++ b/__tests__/html/autoScroll.snap.activityAndPage.html @@ -0,0 +1,145 @@ + + + + + + + + +
+ + + diff --git a/__tests__/html/autoScroll.snap.activityAndPage.js b/__tests__/html/autoScroll.snap.activityAndPage.js new file mode 100644 index 0000000000..5642fc3dd8 --- /dev/null +++ b/__tests__/html/autoScroll.snap.activityAndPage.js @@ -0,0 +1,7 @@ +/** + * @jest-environment ./__tests__/html/__jest__/WebChatEnvironment.js + */ + +describe('Auto-scroll with "activity" and "page" snap behavior', () => { + test('should scroll correctly', () => runHTMLTest('autoScroll.snap.activityAndPage.html')); +}); diff --git a/__tests__/html/autoScroll.snap.default.html b/__tests__/html/autoScroll.snap.default.html new file mode 100644 index 0000000000..38d61cd427 --- /dev/null +++ b/__tests__/html/autoScroll.snap.default.html @@ -0,0 +1,115 @@ + + + + + + + + +
+ + + diff --git a/__tests__/html/autoScroll.snap.default.js b/__tests__/html/autoScroll.snap.default.js new file mode 100644 index 0000000000..703d7f40b6 --- /dev/null +++ b/__tests__/html/autoScroll.snap.default.js @@ -0,0 +1,7 @@ +/** + * @jest-environment ./__tests__/html/__jest__/WebChatEnvironment.js + */ + +describe('Auto-scroll with "default" snap behavior', () => { + test('should scroll correctly', () => runHTMLTest('autoScroll.snap.default.html')); +}); diff --git a/__tests__/html/autoScroll.snap.page.html b/__tests__/html/autoScroll.snap.page.html new file mode 100644 index 0000000000..c8eb933f59 --- /dev/null +++ b/__tests__/html/autoScroll.snap.page.html @@ -0,0 +1,119 @@ + + + + + + + + +
+ + + diff --git a/__tests__/html/autoScroll.snap.page.js b/__tests__/html/autoScroll.snap.page.js new file mode 100644 index 0000000000..0900ca2582 --- /dev/null +++ b/__tests__/html/autoScroll.snap.page.js @@ -0,0 +1,7 @@ +/** + * @jest-environment ./__tests__/html/__jest__/WebChatEnvironment.js + */ + +describe('Auto-scroll with "page" snap behavior', () => { + test('should scroll correctly', () => runHTMLTest('autoScroll.snap.page.html')); +}); diff --git a/packages/api/src/defaultStyleOptions.js b/packages/api/src/defaultStyleOptions.js index bcf0ce5531..29a2f6f379 100644 --- a/packages/api/src/defaultStyleOptions.js +++ b/packages/api/src/defaultStyleOptions.js @@ -200,7 +200,13 @@ const DEFAULT_OPTIONS = { toastWarnColor: '#3B3A39', // Emoji - emojiSet: true // true || false || { ':)' : '😊'} + emojiSet: true, // true || false || { ':)' : '😊'} + + // Auto-scroll behavior + autoScrollSnapOnActivity: false, // true to pause scroll after 1 activity is received, specifying a number will pause after X number of activities + autoScrollSnapOnActivityOffset: 0, // Specify number of pixels to overscroll or underscroll after pause + autoScrollSnapOnPage: false, // true to pause scroll after activities filled the page, specifying a number (0 to 1) will pause after % of page is filled + autoScrollSnapOnPageoffset: 0 // Specify number of pixels to overscroll or underscroll after pause }; export default DEFAULT_OPTIONS; diff --git a/packages/component/package-lock.json b/packages/component/package-lock.json index 8178e66801..9b6e4fef4b 100644 --- a/packages/component/package-lock.json +++ b/packages/component/package-lock.json @@ -6279,22 +6279,15 @@ } }, "react-scroll-to-bottom": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/react-scroll-to-bottom/-/react-scroll-to-bottom-4.0.0.tgz", - "integrity": "sha512-7lOy14XbBdweO+8LMRp7F9nP0xWtf0mAt+I5rm1hbNZq5JcXJRn0dsZjz9ySDLleH+zMlyhPwinqFDmEEUdHtA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/react-scroll-to-bottom/-/react-scroll-to-bottom-4.1.0.tgz", + "integrity": "sha512-atWRfdhLFCG2dK4kAsCDCYvl327DDEi5BMO0kIU4D12u2o2UX+GmlKO8m7sPW86Vb3MlqL3+Gawv/79VQ90tXQ==", "requires": { "classnames": "2.2.6", "create-emotion": "10.0.27", "math-random": "2.0.1", "prop-types": "15.7.2", "simple-update-in": "2.2.0" - }, - "dependencies": { - "math-random": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/math-random/-/math-random-2.0.1.tgz", - "integrity": "sha512-oIEbWiVDxDpl5tIF4S6zYS9JExhh3bun3uLb3YAinHPTlRtW4g1S66LtJrJ4Npq8dgIa8CLK5iPVah5n4n0s2w==" - } } }, "read-pkg": { diff --git a/packages/component/package.json b/packages/component/package.json index 92a59accc4..00aceb2274 100644 --- a/packages/component/package.json +++ b/packages/component/package.json @@ -62,7 +62,7 @@ "react-film": "3.0.0", "react-redux": "7.2.2", "react-say": "2.0.2-master.ee7cd76", - "react-scroll-to-bottom": "4.0.0", + "react-scroll-to-bottom": "4.1.0", "redux": "4.0.5", "remark": "10.0.1", "sanitize-html": "1.27.5", diff --git a/packages/component/src/BasicTranscript.js b/packages/component/src/BasicTranscript.js index 11a808d5b9..49c89ac3c4 100644 --- a/packages/component/src/BasicTranscript.js +++ b/packages/component/src/BasicTranscript.js @@ -1,7 +1,15 @@ /* eslint no-magic-numbers: ["error", { "ignore": [-1, 0, 1] }] */ import { hooks } from 'botframework-webchat-api'; -import { Panel as ScrollToBottomPanel, useAnimatingToEnd, useSticky } from 'react-scroll-to-bottom'; +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 React, { useCallback, useMemo, useRef } from 'react'; @@ -16,12 +24,14 @@ import removeInline from './Utils/removeInline'; import ScreenReaderActivity from './ScreenReaderActivity'; import ScrollToEndButton from './Activity/ScrollToEndButton'; import SpeakActivity from './Activity/Speak'; +import useAcknowledgedActivity from './hooks/internal/useAcknowledgedActivity'; +import useDispatchScrollPosition from './hooks/internal/useDispatchScrollPosition'; import useFocus from './hooks/useFocus'; import useMemoize from './hooks/internal/useMemoize'; +import useRegisterScrollTo from './hooks/internal/useRegisterScrollTo'; +import useRegisterScrollToEnd from './hooks/internal/useRegisterScrollToEnd'; import useStyleSet from './hooks/useStyleSet'; import useStyleToEmotionObject from './hooks/internal/useStyleToEmotionObject'; -import useTranscriptActivityElementsRef from './hooks/internal/useTranscriptActivityElementsRef'; -import useTranscriptRootElementRef from './hooks/internal/useTranscriptRootElementRef'; const { useActivities, @@ -75,16 +85,15 @@ function validateAllActivitiesTagged(activities, bins) { return activities.every(activity => bins.some(bin => bin.includes(activity))); } -const BasicTranscript = ({ className }) => { +const InternalTranscript = ({ activityElementsRef, className }) => { const [{ activity: activityStyleSet }] = useStyleSet(); const [ { bubbleFromUserNubOffset, bubbleNubOffset, groupTimestamp, internalLiveRegionFadeAfter, showAvatarInGroup } ] = useStyleOptions(); const [activities] = useActivities(); - const [activityElementsRef] = useTranscriptActivityElementsRef(); const [direction] = useDirection(); - const [rootElementRef] = useTranscriptRootElementRef(); const rootClassName = useStyleToEmotionObject()(ROOT_STYLE) + ''; + const rootElementRef = useRef(); const createActivityRenderer = useCreateActivityRenderer(); const createActivityStatusRenderer = useCreateActivityStatusRenderer(); @@ -293,6 +302,7 @@ const BasicTranscript = ({ className }) => { renderActivity, renderActivityStatus, renderAvatar, + role, // TODO: [P2] #2858 We should use core/definitions/speakingActivity for this predicate instead shouldSpeak: activity.channelData && activity.channelData.speak, @@ -306,10 +316,11 @@ const BasicTranscript = ({ className }) => { // Update activityElementRef with new sets of activity, while retaining the existing referencing element if exists. - activityElementsRef.current = renderingElements.map(({ activity: { id }, key }) => { + activityElementsRef.current = renderingElements.map(({ activity, activity: { id }, key }) => { const existingEntry = activityElements.find(entry => entry.key === key); return { + activity, activityID: id, element: existingEntry && existingEntry.element, key @@ -331,6 +342,92 @@ const BasicTranscript = ({ className }) => { 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] + ); + + useRegisterScrollTo(scrollTo); + useRegisterScrollToEnd(scrollToBottomScrollToEnd); + + 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); + return (
{ {renderingElements.map( - ({ - activity, - callbackRef, - key, - hideTimestamp, - renderActivity, - renderActivityStatus, - renderAvatar, - shouldSpeak, - showCallout - }) => ( + ( + { + activity, + callbackRef, + key, + hideTimestamp, + renderActivity, + renderActivityStatus, + renderAvatar, + role, + shouldSpeak, + showCallout + }, + index + ) => (
  • @@ -385,11 +490,14 @@ const BasicTranscript = ({ className }) => { ); }; -BasicTranscript.defaultProps = { +InternalTranscript.defaultProps = { className: '' }; -BasicTranscript.propTypes = { +InternalTranscript.propTypes = { + activityElementsRef: PropTypes.shape({ + current: PropTypes.array.isRequired + }).isRequired, className: PropTypes.string }; @@ -495,7 +603,7 @@ const InternalTranscriptScrollable = ({ activities, children }) => { }, [activities, allActivitiesRead, animatingToEnd, hideScrollToEndButton, lastReadActivityIdRef, sticky]); return ( - +
      { ))}
    - + ); }; @@ -527,4 +635,103 @@ InternalTranscriptScrollable.propTypes = { children: PropTypes.arrayOf(PropTypes.element).isRequired }; +const BasicTranscript = ({ className }) => { + const [ + { autoScrollSnapOnActivity, autoScrollSnapOnActivityOffset, autoScrollSnapOnPage, autoScrollSnapOnPageOffset } + ] = useStyleOptions(); + const [lastAcknowledgedActivity] = useAcknowledgedActivity(); + const activityElementsRef = useRef([]); + + const lastAcknowledgedActivityRef = useRef(lastAcknowledgedActivity); + + lastAcknowledgedActivityRef.current = lastAcknowledgedActivity; + + const scroller = 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); + } else { + return Infinity; + } + }, + [ + activityElementsRef, + autoScrollSnapOnActivity, + autoScrollSnapOnActivityOffset, + autoScrollSnapOnPage, + autoScrollSnapOnPageOffset, + lastAcknowledgedActivityRef + ] + ); + + return ( + + + + ); +}; + +BasicTranscript.defaultProps = { + className: '' +}; + +BasicTranscript.propTypes = { + className: PropTypes.string +}; + export default BasicTranscript; diff --git a/packages/component/src/Composer.js b/packages/component/src/Composer.js index 5368f0693d..df24451ab0 100644 --- a/packages/component/src/Composer.js +++ b/packages/component/src/Composer.js @@ -1,6 +1,5 @@ import { Composer as APIComposer, hooks } from 'botframework-webchat-api'; import { Composer as SayComposer } from 'react-say'; -import { Composer as ScrollToBottomComposer } from 'react-scroll-to-bottom'; import createEmotion from 'create-emotion'; import createStyleSet from './Styles/createStyleSet'; import MarkdownIt from 'markdown-it'; @@ -79,9 +78,9 @@ const ComposerCore = ({ const [styleOptions] = useStyleOptions(); const internalMarkdownIt = useMemo(() => new MarkdownIt(), []); const sendBoxFocusRef = useRef(); - const transcriptActivityElementsRef = useRef([]); const transcriptFocusRef = useRef(); - const transcriptRootElementRef = useRef(); + const scrollToCallbacksRef = useRef([]); + const scrollToEndCallbacksRef = useRef([]); const dictationOnError = useCallback(err => { console.error(err); @@ -134,52 +133,77 @@ const ComposerCore = ({ }; }, [referenceGrammarID, webSpeechPonyfillFactory]); + const scrollPositionObserversRef = useRef([]); + const [numScrollPositionObservers, setNumScrollPositionObservers] = useState(0); + + const dispatchScrollPosition = useCallback( + event => scrollPositionObserversRef.current.forEach(observer => observer(event)), + [scrollPositionObserversRef] + ); + + const observeScrollPosition = useCallback( + observer => { + scrollPositionObserversRef.current = [...scrollPositionObserversRef.current, observer]; + setNumScrollPositionObservers(scrollPositionObserversRef.current.length); + + return () => { + scrollPositionObserversRef.current = scrollPositionObserversRef.current.filter(target => target !== observer); + setNumScrollPositionObservers(scrollPositionObserversRef.current.length); + }; + }, + [scrollPositionObserversRef, setNumScrollPositionObservers] + ); + const context = useMemo( () => ({ ...focusContext, dictateAbortable, + dispatchScrollPosition, internalMarkdownItState: [internalMarkdownIt], internalRenderMarkdownInline, nonce, + numScrollPositionObservers, + observeScrollPosition, renderMarkdown, + scrollToCallbacksRef, + scrollToEndCallbacksRef, sendBoxFocusRef, setDictateAbortable, styleSet: patchedStyleSet, styleToEmotionObject, suggestedActionsAccessKey, - transcriptActivityElementsRef, transcriptFocusRef, - transcriptRootElementRef, webSpeechPonyfill }), [ dictateAbortable, + dispatchScrollPosition, focusContext, internalMarkdownIt, internalRenderMarkdownInline, nonce, + numScrollPositionObservers, + observeScrollPosition, patchedStyleSet, renderMarkdown, + scrollToCallbacksRef, + scrollToEndCallbacksRef, sendBoxFocusRef, setDictateAbortable, styleToEmotionObject, suggestedActionsAccessKey, - transcriptActivityElementsRef, transcriptFocusRef, - transcriptRootElementRef, webSpeechPonyfill ] ); return ( - - - - {children} - - - - + + + {children} + + + ); }; @@ -279,19 +303,17 @@ const Composer = ({ typingIndicatorMiddleware={patchedTypingIndicatorMiddleware} {...composerProps} > - - - {children} - {onTelemetry && } - - + + {children} + {onTelemetry && } + ); diff --git a/packages/component/src/Utils/findLastIndex.js b/packages/component/src/Utils/findLastIndex.js new file mode 100644 index 0000000000..173e050d06 --- /dev/null +++ b/packages/component/src/Utils/findLastIndex.js @@ -0,0 +1,11 @@ +export default function findLastIndex(array, predicate) { + const index = [...array].reverse().findIndex(predicate); + + if (~index) { + const { length } = array || []; + + return length - index - 1; + } + + return index; +} diff --git a/packages/component/src/Utils/findLastIndex.spec.js b/packages/component/src/Utils/findLastIndex.spec.js new file mode 100644 index 0000000000..a2ac068736 --- /dev/null +++ b/packages/component/src/Utils/findLastIndex.spec.js @@ -0,0 +1,29 @@ +import findLastIndex from './findLastIndex'; + +describe('find last index', () => { + test('of an existing element should return the index of last occurrence', () => { + const actual = findLastIndex([1, 2, 3, 2, 1], value => value === 2); + + expect(actual).toBe(3); + }); + + test('of a non-existing element should return -1', () => { + const actual = findLastIndex([1, 2, 3, 2, 1], value => value === 4); + + expect(actual).toBe(-1); + }); + + test('of an empty array should return -1', () => { + const actual = findLastIndex([], value => value === 1); + + expect(actual).toBe(-1); + }); + + test('without a predicate should throw', () => { + expect(() => findLastIndex([])).toThrowError('not a function'); + }); + + test('without an array should throw', () => { + expect(() => findLastIndex(undefined, value => value === 1)).toThrowError(); + }); +}); diff --git a/packages/component/src/hooks/internal/useAcknowledgedActivity.js b/packages/component/src/hooks/internal/useAcknowledgedActivity.js new file mode 100644 index 0000000000..29b9e06b63 --- /dev/null +++ b/packages/component/src/hooks/internal/useAcknowledgedActivity.js @@ -0,0 +1,61 @@ +import { hooks } from 'botframework-webchat-api'; +import { useMemo, useRef } from 'react'; +import { useSticky } from 'react-scroll-to-bottom'; + +import findLastIndex from '../../Utils/findLastIndex'; +import getActivityUniqueId from '../../Utils/getActivityUniqueId'; +import useChanged from './useChanged'; + +const { useActivities } = hooks; + +// Acknowledged means either: +// 1. The user sent a message +// - We don't need a condition here. When Web Chat send the message, it will scroll to bottom, and it will trigger condition 2 below. +// 2. At the bottom of the transcript, scrolled from a non-bottom scroll position +// - If the transcript is already at the bottom, the user need to scroll up and then go back down +// - What happen if we are relaxing "scrolled from a non-bottom scroll position": +// 1. The condition will become solely "at the bottom of the transcript" +// 2. Auto-scroll will always scroll the transcript to the bottom +// 3. Web Chat will always acknowledge all activities as it is at the bottom +// 4. Acknowledge flag become useless + +// Note: When Web Chat is loaded, there are no activities acknowledged, we need to assume all arriving activities are acknowledged until end-user sent their first activity. +// Activities loaded initially could be from conversation history. Without assuming acknowledgement, Web Chat will not scroll initially (as everything is not acknowledged). +// Better, the chat adapter should let Web Chat know if the activity is loaded from history or not. + +// TODO: [P2] Move the "conversation history acknowledgement" logic mentioned above to polyfill of chat adapters. +// 1. Chat adapter should send "acknowledged" as part of "channelData" +// 2. If "acknowledged" is "undefined", we set it to: +// a. true, if there are no egress activities yet +// b. Otherwise, false +export default function useAcknowledgedActivity() { + const [activities] = useActivities(); + const [sticky] = useSticky(); + const lastStickyActivityIDRef = useRef(); + + const stickyChanged = useChanged(sticky); + const stickyChangedToSticky = stickyChanged && sticky; + + const lastStickyActivityID = useMemo(() => { + if (stickyChangedToSticky) { + lastStickyActivityIDRef.current = getActivityUniqueId(activities[activities.length - 1] || {}); + } + }, [activities, lastStickyActivityIDRef, stickyChangedToSticky]); + + return useMemo(() => { + const lastStickyActivityIndex = activities.findIndex( + activity => getActivityUniqueId(activity) === lastStickyActivityID + ); + + const lastEgressActivityIndex = findLastIndex(activities, ({ from: { role } = {} }) => role === 'user'); + + // As described above, if no activities were acknowledged thru egress activity, we will assume everything is acknowledged. + const lastAcknowledgedActivityIndex = !~lastEgressActivityIndex + ? activities.length - 1 + : Math.max(lastStickyActivityIndex, lastEgressActivityIndex); + + const lastAcknowledgedActivity = activities[lastAcknowledgedActivityIndex]; + + return [lastAcknowledgedActivity]; + }, [activities, lastStickyActivityID, stickyChangedToSticky]); +} diff --git a/packages/component/src/hooks/internal/useChanged.js b/packages/component/src/hooks/internal/useChanged.js new file mode 100644 index 0000000000..6b88476ab8 --- /dev/null +++ b/packages/component/src/hooks/internal/useChanged.js @@ -0,0 +1,10 @@ +import { useRef } from 'react'; + +export default function useChanged(value) { + const prevValueRef = useRef(value); + const changed = value !== prevValueRef.current; + + prevValueRef.current = value; + + return changed; +} diff --git a/packages/component/src/hooks/internal/useDispatchScrollPosition.js b/packages/component/src/hooks/internal/useDispatchScrollPosition.js new file mode 100644 index 0000000000..3cbb53fc93 --- /dev/null +++ b/packages/component/src/hooks/internal/useDispatchScrollPosition.js @@ -0,0 +1,7 @@ +import useWebChatUIContext from './useWebChatUIContext'; + +export default function useDispatchScrollPosition() { + const { dispatchScrollPosition, numScrollPositionObservers } = useWebChatUIContext(); + + return numScrollPositionObservers ? dispatchScrollPosition : undefined; +} diff --git a/packages/component/src/hooks/internal/useGetTranscriptActivityElementByID.js b/packages/component/src/hooks/internal/useGetTranscriptActivityElementByID.js deleted file mode 100644 index ed2bdfd92f..0000000000 --- a/packages/component/src/hooks/internal/useGetTranscriptActivityElementByID.js +++ /dev/null @@ -1,16 +0,0 @@ -import { useCallback } from 'react'; - -import useTranscriptActivityElementsRef from './useTranscriptActivityElementsRef'; - -export default function useGetTranscriptActivityElementByID() { - const [activityElementsRef] = useTranscriptActivityElementsRef(); - - return useCallback( - activityID => { - const { element } = activityElementsRef.current.find(entry => entry.activityID === activityID) || {}; - - return element; - }, - [activityElementsRef] - ); -} diff --git a/packages/component/src/hooks/internal/useGetTranscriptScrollableElement.js b/packages/component/src/hooks/internal/useGetTranscriptScrollableElement.js deleted file mode 100644 index 1c140aee76..0000000000 --- a/packages/component/src/hooks/internal/useGetTranscriptScrollableElement.js +++ /dev/null @@ -1,11 +0,0 @@ -import { useCallback } from 'react'; - -import useTranscriptRootElementRef from './useTranscriptRootElementRef'; - -export default function useGetTranscriptScrollableElement() { - const [rootElementRef] = useTranscriptRootElementRef(); - - return useCallback(() => rootElementRef.current.querySelector('.webchat__basic-transcript__scrollable'), [ - rootElementRef - ]); -} diff --git a/packages/component/src/hooks/internal/useRegisterScrollTo.js b/packages/component/src/hooks/internal/useRegisterScrollTo.js new file mode 100644 index 0000000000..5e8102fe9b --- /dev/null +++ b/packages/component/src/hooks/internal/useRegisterScrollTo.js @@ -0,0 +1,18 @@ +import { useEffect } from 'react'; + +import removeInline from '../../Utils/removeInline'; +import useWebChatUIContext from './useWebChatUIContext'; + +export default function useRegisterScrollTo(callback) { + const { scrollToCallbacksRef } = useWebChatUIContext(); + + useEffect(() => { + if (callback) { + const { current: scrollToCallbacks } = scrollToCallbacksRef; + + scrollToCallbacks.push(callback); + + return () => removeInline(scrollToCallbacks, callback); + } + }, [callback, scrollToCallbacksRef]); +} diff --git a/packages/component/src/hooks/internal/useRegisterScrollToEnd.js b/packages/component/src/hooks/internal/useRegisterScrollToEnd.js new file mode 100644 index 0000000000..4033ecf856 --- /dev/null +++ b/packages/component/src/hooks/internal/useRegisterScrollToEnd.js @@ -0,0 +1,16 @@ +import { useEffect } from 'react'; + +import removeInline from '../../Utils/removeInline'; +import useWebChatUIContext from './useWebChatUIContext'; + +export default function useRegisterScrollTo(callback) { + const { scrollToEndCallbacksRef } = useWebChatUIContext(); + + useEffect(() => { + const { current: scrollToEndCallbacks } = scrollToEndCallbacksRef; + + scrollToEndCallbacks.push(callback); + + return () => removeInline(scrollToEndCallbacks, callback); + }, [scrollToEndCallbacksRef]); +} diff --git a/packages/component/src/hooks/internal/useTranscriptRootElementRef.js b/packages/component/src/hooks/internal/useTranscriptRootElementRef.js deleted file mode 100644 index 828d9dc2d3..0000000000 --- a/packages/component/src/hooks/internal/useTranscriptRootElementRef.js +++ /dev/null @@ -1,5 +0,0 @@ -import useWebChatUIContext from './useWebChatUIContext'; - -export default function useTranscriptRootElementRef() { - return [useWebChatUIContext().transcriptRootElementRef]; -} diff --git a/packages/component/src/hooks/useObserveScrollPosition.js b/packages/component/src/hooks/useObserveScrollPosition.js index 9ecd586652..609cd6ef1c 100644 --- a/packages/component/src/hooks/useObserveScrollPosition.js +++ b/packages/component/src/hooks/useObserveScrollPosition.js @@ -1,13 +1,7 @@ -import { useCallback } from 'react'; -import { useObserveScrollPosition as useScrollToBottomObserveScrollPosition } from 'react-scroll-to-bottom'; - -import useGetTranscriptScrollableElement from './internal/useGetTranscriptScrollableElement'; -import useTranscriptActivityElementsRef from './internal/useTranscriptActivityElementsRef'; +import { useEffect } from 'react'; +import useWebChatUIContext from './internal/useWebChatUIContext'; export default function useObserveScrollPosition(observer, deps) { - const getTranscriptScrollableElement = useGetTranscriptScrollableElement(); - const [activityElementsRef] = useTranscriptActivityElementsRef(); - if (typeof observer !== 'function') { observer = undefined; console.warn('botframework-webchat: First argument passed to "useObserveScrollPosition" must be a function.'); @@ -17,34 +11,8 @@ export default function useObserveScrollPosition(observer, deps) { ); } - const effectCallback = useCallback( - ({ scrollTop }) => { - const scrollable = getTranscriptScrollableElement(); - const [{ height: offsetHeight } = {}] = scrollable.getClientRects(); - - // Find the activity just above scroll view bottom. - // If the scroll view is already on top, get the first activity. - const entry = scrollable.scrollTop - ? [...activityElementsRef.current].reverse().find(({ element }) => { - if (!element) { - return false; - } - - const [{ y } = {}] = element.getClientRects(); - - return y < offsetHeight; - }) - : activityElementsRef.current[0]; - - const { activityID } = entry || {}; - - observer && observer({ ...(activityID ? { activityID } : {}), scrollTop }); - }, - // This hook is very similar to useEffect, which internally use useCallback. - // The "deps" is treated as the dependencies for the useCallback. - /* eslint-disable-next-line react-hooks/exhaustive-deps */ - [activityElementsRef, getTranscriptScrollableElement, ...(deps || [])] - ); + const { observeScrollPosition } = useWebChatUIContext(); - useScrollToBottomObserveScrollPosition(effectCallback); + /* eslint-disable-next-line react-hooks/exhaustive-deps */ + useEffect(() => observer && observeScrollPosition(observer), [...(deps || []), observer, observeScrollPosition]); } diff --git a/packages/component/src/hooks/useScrollTo.js b/packages/component/src/hooks/useScrollTo.js index e2261893b7..8f7dc5f46e 100644 --- a/packages/component/src/hooks/useScrollTo.js +++ b/packages/component/src/hooks/useScrollTo.js @@ -1,45 +1,11 @@ import { useCallback } from 'react'; -import { useScrollTo as useScrollToBottomScrollTo } from 'react-scroll-to-bottom'; -import useGetTranscriptScrollableElement from './internal/useGetTranscriptScrollableElement'; -import useGetTranscriptActivityElementByID from './internal/useGetTranscriptActivityElementByID'; +import useWebChatUIContext from './internal/useWebChatUIContext'; export default function useScrollTo() { - const getActivityElementByID = useGetTranscriptActivityElementByID(); - const getScrollableElement = useGetTranscriptScrollableElement(); - const scrollTo = useScrollToBottomScrollTo(); + const { scrollToCallbacksRef } = useWebChatUIContext(); - return 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') { - scrollTo(scrollTop, { behavior }); - } else if (typeof activityID !== 'undefined') { - const activityElement = getActivityElementByID(activityID); - - if (activityElement) { - const scrollableElement = getScrollableElement(); - 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 - ); - - scrollTo(scrollTop, { behavior }); - } - } - }, - [getActivityElementByID, getScrollableElement, scrollTo] - ); + return useCallback((...args) => scrollToCallbacksRef.current.forEach(callback => callback(...args)), [ + scrollToCallbacksRef + ]); } diff --git a/packages/component/src/hooks/useScrollToEnd.js b/packages/component/src/hooks/useScrollToEnd.js index 250ec50906..54c147eb9a 100644 --- a/packages/component/src/hooks/useScrollToEnd.js +++ b/packages/component/src/hooks/useScrollToEnd.js @@ -1,9 +1,11 @@ import { useCallback } from 'react'; -import { useScrollToEnd as useScrollToBottomScrollToEnd } from 'react-scroll-to-bottom'; +import useWebChatUIContext from './internal/useWebChatUIContext'; export default function useScrollToEnd() { - const scrollToEnd = useScrollToBottomScrollToEnd(); + const { scrollToEndCallbacksRef } = useWebChatUIContext(); - return useCallback(() => scrollToEnd({ behavior: 'smooth' }), [scrollToEnd]); + return useCallback(() => scrollToEndCallbacksRef.current.forEach(callback => callback({ behavior: 'smooth' })), [ + scrollToEndCallbacksRef + ]); } diff --git a/packages/testharness/src/utils/createDirectLineWithTranscript.js b/packages/testharness/src/utils/createDirectLineWithTranscript.js index 3215bd7914..1430500a53 100644 --- a/packages/testharness/src/utils/createDirectLineWithTranscript.js +++ b/packages/testharness/src/utils/createDirectLineWithTranscript.js @@ -1,3 +1,5 @@ +import Observable from 'core-js/features/observable'; + import createDeferredObservable from './createDeferredObservable'; import loadTranscriptAsset from './loadTranscriptAsset'; import shareObservable from './shareObservable'; @@ -23,7 +25,7 @@ function updateRelativeTimestamp(now, activity) { }; } -export default function createDirectLineWithTranscript(activitiesOrFilename) { +export default function createDirectLineWithTranscript(activitiesOrFilename, { echo = true } = {}) { const now = Date.now(); const patchActivity = updateRelativeTimestamp.bind(null, now); const connectionStatusDeferredObservable = createDeferredObservable(() => { @@ -31,7 +33,7 @@ export default function createDirectLineWithTranscript(activitiesOrFilename) { }); const activityDeferredObservable = createDeferredObservable(() => { - (async function() { + (async function () { connectionStatusDeferredObservable.next(1); connectionStatusDeferredObservable.next(2); @@ -58,6 +60,19 @@ export default function createDirectLineWithTranscript(activitiesOrFilename) { connectionStatus$: shareObservable(connectionStatusDeferredObservable.observable), connectionStatusDeferredObservable, end: () => {}, - postActivity: () => {} + postActivity: activity => { + const id = Math.random().toString(36).substr(2, 5); + + if (echo) { + activityDeferredObservable.next( + patchActivity({ + ...activity, + id + }) + ); + } + + return Observable.from([id]); + } }; }