Skip to content

Commit

Permalink
Fix #3247 Add activity ID to useObserveScrollPosition and `useScrol…
Browse files Browse the repository at this point in the history
…lTo` hooks (#3372)

* Support activityID in useObserveScrollPosition and useScrollTo

* Add tests

* Add entry

* Fix ESLint

* Fix test

* Send activity ID if only available

* Fix test

Co-authored-by: Chris Whitten <[email protected]>
  • Loading branch information
compulim and cwhitten authored Aug 5, 2020
1 parent 0d8d47f commit 906de77
Show file tree
Hide file tree
Showing 17 changed files with 351 additions and 26 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

- Resolves [#3250](https://github.com/microsoft/BotFramework-WebChat/issues/3250). Added activity grouping feature, by [@compulim](https://github.com/compulim), in PR [#3365](https://github.com/microsoft/BotFramework-WebChat/pull/3365)
- Resolves [#3354](https://github.com/microsoft/BotFramework-WebChat/issues/3354). Added access key (<kbd>ALT</kbd> + <kbd>SHIFT</kbd> + <kbd>A</kbd> for Windows and <kbd>CTRL</kbd> + <kbd>OPTION</kbd> + <kbd>A</kbd> for Mac) to suggested actions, by [@compulim](https://github.com/compulim), in PR [#3367](https://github.com/microsoft/BotFramework-WebChat/pull/3367)
- Resolves [#3247](https://github.com/microsoft/BotFramework-WebChat/issues/3247). Support activity ID on `useObserveScrollPosition` and `useScrollTo` hook, by [@compulim](https://github.com/compulim), in PR [#XXX](https://github.com/microsoft/BotFramework-WebChat/pull/XXX)

### Fixed

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
178 changes: 178 additions & 0 deletions __tests__/html/hooks.useScrollTo.activityID.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
<!DOCTYPE html>
<html lang="en-US">
<head>
<script crossorigin="anonymous" src="/__dist__/testharness.js"></script>
<script crossorigin="anonymous" src="/__dist__/webchat-es5.js"></script>
<style type="text/css">
body {
background-color: #f7f7f7;
}

#app {
height: 100%;
}

.scroll-panel {
left: 0;
position: fixed;
top: 0;
}

.webchat {
box-shadow: 0 0 10px rgba(0, 0, 0, 0.05);
margin: 0 auto;
max-width: 480px;
min-width: 360px;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="text/babel" data-presets="env,stage-3,react">
const {
React: { useCallback, useLayoutEffect, useRef, useState },
WebChat: {
Components: { BasicWebChat, Composer },
createDirectLine,
hooks: { useObserveScrollPosition, useScrollTo, useSendMessage }
},
WebChatTest: {
conditions,
createDeferred,
createDirectLineWithTranscript,
createStore,
elements,
expect,
host,
pageObjects,
timeouts,
token
}
} = window;

function generateTranscript() {
const messages = [
'Do incididunt qui sit nulla dolor.',
'Ipsum dolor laborum veniam sunt irure nulla aliquip minim ad veniam culpa sit ut.',
'Labore ut ex anim in nisi enim deserunt anim minim esse veniam.',
'Lorem amet occaecat voluptate fugiat elit cillum.',
'Sit et velit qui laboris et elit eu pariatur velit occaecat.',
'Nostrud minim deserunt excepteur elit aliquip excepteur.',
'Laboris pariatur minim ad incididunt.',
'Reprehenderit sint id elit laboris nisi ipsum pariatur et id deserunt dolore.',
'Quis dolor ut dolor qui.',
'Fugiat commodo ipsum irure deserunt duis ea est amet Lorem esse eiusmod.',
'Magna occaecat enim magna laboris sunt consequat esse elit ipsum esse quis culpa amet.',
'Laborum officia est elit officia voluptate dolore elit veniam aute velit.',
'Et esse sint incididunt irure et amet deserunt consectetur dolor.',
'Ipsum adipisicing nisi nulla eiusmod commodo ad enim veniam velit.',
'Consectetur labore adipisicing do dolor dolor eiusmod sint irure in labore ullamco incididunt voluptate.',
'Duis voluptate elit tempor quis consequat incididunt excepteur anim in.',
'Aliquip cupidatat exercitation magna aute nostrud fugiat deserunt.',
'Nulla eu do duis consequat sint irure proident cupidatat duis.',
'Ipsum laborum commodo sint tempor fugiat esse est sit officia qui cupidatat nisi minim.',
'Veniam consequat ut anim consequat ea incididunt ipsum proident duis veniam irure consequat exercitation.\n\nVoluptate dolor nostrud ipsum amet in velit cupidatat veniam voluptate ipsum.\n\nPariatur ipsum eiusmod deserunt commodo elit aute in velit proident.\n\nCulpa amet deserunt excepteur ex quis Lorem commodo ipsum sint consectetur id.\n\nAliquip officia qui ea cillum duis labore consectetur sunt excepteur labore.\n\nElit adipisicing et consectetur occaecat sint nulla Lorem id anim Lorem.\n\nPariatur officia velit officia do nisi cupidatat enim nulla et sit.\n\nDuis anim Lorem reprehenderit mollit occaecat sunt.\n\nLorem cupidatat id culpa anim velit qui irure.\n\nVoluptate aute incididunt cillum culpa laborum est sunt et ea proident minim non.\n\nId Lorem eiusmod amet sint nulla velit ullamco tempor incididunt pariatur.\n\nElit elit fugiat dolore amet dolor voluptate.\n\nEa pariatur nulla dolor excepteur enim sit aliquip incididunt laboris pariatur fugiat commodo officia minim.\n\nConsequat elit amet minim consectetur tempor.\n\nPariatur excepteur consectetur adipisicing quis laborum.\n\nIn aute consectetur ullamco eiusmod reprehenderit consequat non aliquip consequat eiusmod et laboris.\n\nMagna amet nulla do nulla ea ad do occaecat adipisicing.\n\nConsequat quis laborum nisi ut exercitation reprehenderit cupidatat proident incididunt est eiusmod ea.\n\nAdipisicing aliqua elit nostrud sint magna aliqua nisi deserunt ex occaecat velit ipsum duis.\n\nEnim veniam sunt cillum voluptate laborum do.\n\nVeniam ea aute reprehenderit et ad reprehenderit non do deserunt minim eu elit.\n\nAnim irure fugiat nostrud occaecat amet sint pariatur irure cupidatat commodo fugiat Lorem minim deserunt.\n\nAd consectetur excepteur enim nisi adipisicing.\n\nMollit duis est ipsum nulla aliquip.\n\nSunt reprehenderit quis in ea eu tempor fugiat ad dolore ea adipisicing consectetur elit.\n\nConsequat minim culpa ea sit ullamco ex id exercitation.\n\nCulpa cillum non ipsum eu Lorem nostrud nostrud consequat anim in culpa nostrud.\n\nNostrud nisi quis et Lorem aliquip anim deserunt culpa.\n\nSunt cupidatat commodo quis elit consequat ullamco irure id tempor.\n\nCommodo dolor officia magna amet aliqua proident qui ipsum voluptate.\n\nDo officia cupidatat id in proident.'
];

return messages.map((text, index) => {
const fromUser = index % 2;

return {
...(fromUser
? {
channelData: {
clientActivityID: index + '',
clientTimestamp: 0,
state: 'sent'
}
}
: {}),
from: { role: fromUser ? 'user' : 'bot' },
id: index + '',
text: `#${index}: ${text}`,
timestamp: 0,
type: 'message'
};
});
}

(async function() {
const directLine = createDirectLineWithTranscript(generateTranscript());
const store = createStore();

let lastScrollPosition;

const RunFunction = ({ fn }) => {
fn();

return false;
};

const ScrollPanel = () => {
const ref = useRef();

useObserveScrollPosition(position => {
lastScrollPosition = position;
});

return <div className="scroll-panel" ref={ref}></div>;
};

const WebChat = ({ children, className }) => {
return (
<Composer directLine={directLine} store={store}>
<BasicWebChat className={className} />
{children}
<ScrollPanel />
</Composer>
);
};

const renderWithFunction = fn => {
const deferreds = [createDeferred(), createDeferred()];

ReactDOM.render(
<WebChat className="webchat">
<RunFunction fn={() => deferreds[0].resolve(fn && fn())} key={Date.now() + ''} />
</WebChat>,
document.getElementById('app'),
deferreds[1].resolve
);

return Promise.all(deferreds.map(({ promise }) => promise));
};

window.renderWithFunction = renderWithFunction;

await renderWithFunction();

await pageObjects.wait(conditions.uiConnected(), timeouts.directLine);

// Wait until the view is not sticky.
await new Promise(resolve => setTimeout(resolve, 200));
await pageObjects.wait(conditions.scrollToBottomCompleted(), timeouts.scrollToBottom);

// "12" should appears right above the send box.
await renderWithFunction(() => useScrollTo()({ activityID: '12' }));
await host.snapshot();

// When on top, it should always return the first activity in the transcript.
await renderWithFunction(() => useScrollTo()({ scrollTop: 0 }));
await new Promise(resolve => setTimeout(resolve, 200)); // Wait until "scroll" event
expect(lastScrollPosition).toHaveProperty('activityID', '0');
expect(lastScrollPosition).toHaveProperty('scrollTop', 0);
await host.snapshot();

// Since "19" is a super-long activity, it should appears on the top of the page.
await renderWithFunction(() => useScrollTo()({ activityID: '19' }));
await host.snapshot();

await host.done();
})().catch(async err => {
console.error(err);

await host.error(err);
});
</script>
</body>
</html>
7 changes: 7 additions & 0 deletions __tests__/html/hooks.useScrollTo.activityID.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* @jest-environment ./__tests__/html/__jest__/WebChatEnvironment.js
*/

describe('useScrollTo hook', () => {
test('should scroll based on activity ID', () => runHTMLTest('hooks.useScrollTo.activityID.html'));
});
10 changes: 6 additions & 4 deletions docs/HOOKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -634,7 +634,8 @@ useObserveScrollPosition(observer: (ScrollObserver? | false), deps: any[]): void
type ScrollObserver = (position: ScrollPosition) => void;

type ScrollPosition {
scrollTop: number
activityID: string;
scrollTop: number;
}
```
<!-- prettier-ignore-end -->
Expand Down Expand Up @@ -855,16 +856,17 @@ This function is for rendering typing indicator for all participants of the conv
useScrollTo(): (position: ScrollPosition, options: ScrollOptions) => void

type ScrollOptions {
behavior: 'auto' | 'smooth'
behavior: 'auto' | 'smooth';
}

type ScrollPosition {
scrollTop: number
activityID: string;
scrollTop: number;
}
```
<!-- prettier-ignore-end -->
This hook will return a function that, when called, will scroll the transcript to the specific scroll position.
This hook will return a function that, when called, will scroll the transcript to the specific scroll position. If both `activityID` and `scrollTop` is specified, `scrollTop` will be preferred since it gives higher precision.
If `options` is passed with `behavior` set to `smooth`, it will smooth-scrolling to the scroll position. Otherwise, it will jump to the scroll position instantly.
Expand Down
36 changes: 35 additions & 1 deletion packages/component/src/BasicTranscript.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import useLocalizer from './hooks/useLocalizer';
import useMemoize from './hooks/internal/useMemoize';
import useStyleOptions from './hooks/useStyleOptions';
import useStyleSet from './hooks/useStyleSet';
import useTranscriptActivityElementsRef from './hooks/internal/useTranscriptActivityElementsRef';
import useTranscriptRootElementRef from './hooks/internal/useTranscriptRootElementRef';

const ROOT_CSS = css({
'&.webchat__basic-transcript': {
Expand Down Expand Up @@ -71,7 +73,9 @@ const BasicTranscript2 = ({ className }) => {
const [{ activity: activityStyleSet }] = useStyleSet();
const [{ bubbleFromUserNubOffset, bubbleNubOffset, groupTimestamp, showAvatarInGroup }] = useStyleOptions();
const [activities] = useActivities();
const [activityElementsRef] = useTranscriptActivityElementsRef();
const [direction] = useDirection();
const [rootElementRef] = useTranscriptRootElementRef();

const createActivityRenderer = useCreateActivityRenderer();
const createActivityStatusRenderer = useCreateActivityStatusRenderer();
Expand Down Expand Up @@ -259,6 +263,15 @@ const BasicTranscript2 = ({ className }) => {
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;
}
},

// "hideTimestamp" is a render-time parameter for renderActivityStatus().
// If set, it will hide if timestamp is being shown, but it will continue to show
// retry prompt. And show the screen reader version of the timestamp.
Expand All @@ -280,9 +293,24 @@ const BasicTranscript2 = ({ className }) => {
});
});

const { current: activityElements } = activityElementsRef;

// Update activityElementRef with new sets of activity, while retaining the existing referencing element if exists.

activityElementsRef.current = renderingElements.map(({ activity: { id }, key }) => {
const existingEntry = activityElements.find(entry => entry.key === key);

return {
activityID: id,
element: existingEntry && existingEntry.element,
key
};
});

return renderingElements;
}, [
activitiesWithRenderer,
activityElementsRef,
activityTree,
bubbleFromUserNubOffset,
bubbleNubOffset,
Expand All @@ -295,7 +323,11 @@ const BasicTranscript2 = ({ className }) => {
const renderingActivities = useMemo(() => renderingElements.map(({ activity }) => activity), [renderingElements]);

return (
<div className={classNames(ROOT_CSS + '', 'webchat__basic-transcript', className + '')} dir={direction}>
<div
className={classNames(ROOT_CSS + '', 'webchat__basic-transcript', className + '')}
dir={direction}
ref={rootElementRef}
>
{/* This <section> is for live region only. Contents are made invisible through CSS. */}
<section
aria-atomic={false}
Expand All @@ -312,6 +344,7 @@ const BasicTranscript2 = ({ className }) => {
{renderingElements.map(
({
activity,
callbackRef,
key,
hideTimestamp,
renderActivity,
Expand All @@ -324,6 +357,7 @@ const BasicTranscript2 = ({ className }) => {
aria-label={activityAriaLabel} // This will be read when pressing CAPSLOCK + arrow with screen reader
className={classNames(activityStyleSet + '', 'webchat__basic-transcript__activity')}
key={key}
ref={callbackRef}
>
{renderActivity({
hideTimestamp,
Expand Down
7 changes: 7 additions & 0 deletions packages/component/src/Composer.js
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,9 @@ const Composer = ({
return typingIndicatorRenderer || createTypingIndicatorRenderer(typingIndicatorMiddleware);
}, [typingIndicatorMiddleware, typingIndicatorRenderer]);

const transcriptActivityElementsRef = useRef([]);
const transcriptRootElementRef = useRef();

// This is a heavy function, and it is expected to be only called when there is a need to recreate business logic, e.g.
// - User ID changed, causing all send* functions to be updated
// - send
Expand Down Expand Up @@ -482,7 +485,9 @@ const Composer = ({
telemetryDimensionsRef,
toastRenderer: patchedToastRenderer,
trackDimension,
transcriptActivityElementsRef,
transcriptFocusRef,
transcriptRootElementRef,
typingIndicatorRenderer: patchedTypingIndicatorRenderer,
userID,
username,
Expand Down Expand Up @@ -520,7 +525,9 @@ const Composer = ({
suggestedActionsAccessKey,
telemetryDimensionsRef,
trackDimension,
transcriptActivityElementsRef,
transcriptFocusRef,
transcriptRootElementRef,
userID,
username,
webSpeechPonyfill
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
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]
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
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
]);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import useWebChatUIContext from './useWebChatUIContext';

export default function useTranscriptActivityElementsRef() {
return [useWebChatUIContext().transcriptActivityElementsRef];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import useWebChatUIContext from './useWebChatUIContext';

export default function useTranscriptRootElementRef() {
return [useWebChatUIContext().transcriptRootElementRef];
}
Loading

0 comments on commit 906de77

Please sign in to comment.