diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cf2fc6f59..e1f8113098 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Notes: web developers are advised to use [`~` (tilde range)](https://github.com/ - Please refer to PR [#5330](https://github.com/microsoft/BotFramework-WebChat/pull/5330) for details - HTML sanitizer is moved from `renderMarkdown` to HTML content transformer middleware, please refer to PR [#5338](https://github.com/microsoft/BotFramework-WebChat/pull/5338) - If you customized `renderMarkdown` with a custom HTML sanitizer, please move the HTML sanitizer to the new HTML content transformer middleware +- `useGroupActivities` hook is being deprecated in favor of the `useGroupActivitiesByName` hook. The hook will be removed on or after 2027-05-04 ### Added @@ -86,6 +87,10 @@ Notes: web developers are advised to use [`~` (tilde range)](https://github.com/ - Added sliding dots typing indicator in Fluent theme, in PR [#5447](https://github.com/microsoft/BotFramework-WebChat/pull/5447) and PR [#5448](https://github.com/microsoft/BotFramework-WebChat/pull/5448), by [@compulim](https://github.com/compulim) - (Experimental) Add an ability to pass `completion` prop into Fluent send box and expose the component, in PR [#5466](https://github.com/microsoft/BotFramework-WebChat/pull/5466), by [@OEvgeny](https://github.com/OEvgeny) - Added feedback form for like/dislike button when `feedbackActionsPlacement` is `"activity-actions"`, in PR [#5460](https://github.com/microsoft/BotFramework-WebChat/pull/5460), PR [#5469](https://github.com/microsoft/BotFramework-WebChat/pull/5469), and PR [5470](https://github.com/microsoft/BotFramework-WebChat/pull/5470) by [@lexi-taylor](https://github.com/lexi-taylor) and [@OEvgeny](https://github.com/OEvgeny) +- Added multi-dimensional grouping, `styleOptions.groupActivitiesBy`, and `useGroupActivitiesByName` hook, in PR [#5471](https://github.com/microsoft/BotFramework-WebChat/pull/5471), by [@compulim](https://github.com/compulim) + - Existing behavior will be kept and activities will be grouped by `sender` followed by `status` + - `useGroupActivitiesByName` is favored over the existing `useGroupActivities` hook for performance reason + - Middleware which support the new grouping name init argument should only compute the grouping if they match the grouping name, or the grouping name is not specified, otherwise, should do nothing and call the downstream middleware ### Changed diff --git a/__tests__/customizableAvatar.js b/__tests__/customizableAvatar.js deleted file mode 100644 index e6f1c44710..0000000000 --- a/__tests__/customizableAvatar.js +++ /dev/null @@ -1,347 +0,0 @@ -import { imageSnapshotOptions, timeouts } from './constants.json'; - -import minNumActivitiesShown from './setup/conditions/minNumActivitiesShown'; -import uiConnected from './setup/conditions/uiConnected'; - -// selenium-webdriver API doc: -// https://seleniumhq.github.io/selenium/docs/api/javascript/module/selenium-webdriver/index_exports_WebDriver.html - -jest.setTimeout(timeouts.test); - -describe('customizable avatar', () => { - const createDefaultProps = () => ({ - avatarMiddleware: () => next => args => { - const { activity } = args; - const { text = '' } = activity; - - if (~text.indexOf('override avatar')) { - return () => - React.createElement( - 'div', - { - style: { - alignItems: 'center', - backgroundColor: 'Red', - borderRadius: 4, - color: 'White', - display: 'flex', - fontFamily: "'Calibri', 'Helvetica Neue', 'Arial', 'sans-serif'", - height: 128, - justifyContent: 'center', - width: '100%' - } - }, - React.createElement('div', {}, activity.from.role) - ); - } else if (~text.indexOf('no avatar')) { - return false; - } - - return next(args); - }, - styleOptions: { - botAvatarBackgroundColor: '#77F', - botAvatarInitials: 'WC', - userAvatarBackgroundColor: '#F77', - userAvatarInitials: 'WW' - } - }); - - const createFullCustomizedProps = args => { - const props = createDefaultProps(args); - - return { - ...props, - styleOptions: { - ...props.styleOptions, - bubbleBorderColor: 'Black', - bubbleBorderRadius: 10, - bubbleFromUserBorderColor: 'Black', - bubbleFromUserBorderRadius: 10, - bubbleFromUserNubOffset: 5, - bubbleFromUserNubSize: 10, - bubbleNubOffset: 5, - bubbleNubSize: 10 - } - }; - }; - - test('with default avatar', async () => { - const props = createDefaultProps(); - const { driver, pageObjects } = await setupWebDriver({ - height: 768, - props, - // TODO: [P1] #2954 Currently, offline MockBot has bugs that randomize the activity order. - useProductionBot: true - }); - - await driver.wait(uiConnected(), timeouts.directLine); - await pageObjects.sendMessageViaSendBox('normal'); - await driver.wait(minNumActivitiesShown(2), timeouts.directLine); - await pageObjects.sendMessageViaSendBox('override avatar'); - await driver.wait(minNumActivitiesShown(4), timeouts.directLine); - await pageObjects.sendMessageViaSendBox('no avatar'); - await driver.wait(minNumActivitiesShown(6), timeouts.directLine); - - await expect(driver.takeScreenshot()).resolves.toMatchImageSnapshot(imageSnapshotOptions); - - await pageObjects.updateProps({ - ...props, - styleOptions: { - ...props.styleOptions, - botAvatarInitials: undefined, - userAvatarInitials: undefined - } - }); - - await expect(driver.takeScreenshot()).resolves.toMatchImageSnapshot(imageSnapshotOptions); - }); - - test('with default avatar, bubble nub, and round bubble', async () => { - const props = createFullCustomizedProps(); - const { driver, pageObjects } = await setupWebDriver({ - height: 768, - props, - // TODO: [P1] #2954 Currently, offline MockBot has bugs that randomize the activity order. - useProductionBot: true - }); - - await driver.wait(uiConnected(), timeouts.directLine); - await pageObjects.sendMessageViaSendBox('normal'); - await driver.wait(minNumActivitiesShown(2), timeouts.directLine); - await pageObjects.sendMessageViaSendBox('override avatar'); - await driver.wait(minNumActivitiesShown(4), timeouts.directLine); - await pageObjects.sendMessageViaSendBox('no avatar'); - await driver.wait(minNumActivitiesShown(6), timeouts.directLine); - - await expect(driver.takeScreenshot()).resolves.toMatchImageSnapshot(imageSnapshotOptions); - - await pageObjects.updateProps({ - ...props, - styleOptions: { - ...props.styleOptions, - botAvatarInitials: undefined, - userAvatarInitials: undefined - } - }); - - await expect(driver.takeScreenshot()).resolves.toMatchImageSnapshot(imageSnapshotOptions); - }); - - test('with default avatar only on one side', async () => { - let props = createDefaultProps(); - - props = { ...props, styleOptions: { ...props.styleOptions, userAvatarInitials: undefined } }; - - const { driver, pageObjects } = await setupWebDriver({ - props, - // TODO: [P1] #2954 Currently, offline MockBot has bugs that randomize the activity order. - useProductionBot: true - }); - - await driver.wait(uiConnected(), timeouts.directLine); - await pageObjects.sendMessageViaSendBox('normal'); - await driver.wait(minNumActivitiesShown(2), timeouts.directLine); - - await expect(driver.takeScreenshot()).resolves.toMatchImageSnapshot(imageSnapshotOptions); - - props = createDefaultProps(); - props = { ...props, styleOptions: { ...props.styleOptions, botAvatarInitials: undefined } }; - - await pageObjects.updateProps({ - ...props, - styleOptions: { - ...props.styleOptions, - botAvatarInitials: undefined - } - }); - - await expect(driver.takeScreenshot()).resolves.toMatchImageSnapshot(imageSnapshotOptions); - }); - - test('with default avatar, bubble nub, and round bubble only on one side', async () => { - let props = createFullCustomizedProps(); - - props = { ...props, styleOptions: { ...props.styleOptions, userAvatarInitials: undefined } }; - - const { driver, pageObjects } = await setupWebDriver({ - props, - // TODO: [P1] #2954 Currently, offline MockBot has bugs that randomize the activity order. - useProductionBot: true - }); - - await driver.wait(uiConnected(), timeouts.directLine); - await pageObjects.sendMessageViaSendBox('normal'); - await driver.wait(minNumActivitiesShown(2), timeouts.directLine); - - await expect(driver.takeScreenshot()).resolves.toMatchImageSnapshot(imageSnapshotOptions); - - props = createFullCustomizedProps(); - props = { ...props, styleOptions: { ...props.styleOptions, botAvatarInitials: undefined } }; - - await pageObjects.updateProps({ - ...props, - styleOptions: { - ...props.styleOptions, - botAvatarInitials: undefined - } - }); - - await expect(driver.takeScreenshot()).resolves.toMatchImageSnapshot(imageSnapshotOptions); - }); - - describe('in RTL', () => { - test('with default avatar', async () => { - const props = { - ...createDefaultProps(), - locale: 'ar-EG' - }; - - const { driver, pageObjects } = await setupWebDriver({ - height: 768, - props, - // TODO: [P1] #2954 Currently, offline MockBot has bugs that randomize the activity order. - useProductionBot: true - }); - - await driver.wait(uiConnected(), timeouts.directLine); - await pageObjects.sendMessageViaSendBox('normal'); - await driver.wait(minNumActivitiesShown(2), timeouts.directLine); - await pageObjects.sendMessageViaSendBox('override avatar'); - await driver.wait(minNumActivitiesShown(4), timeouts.directLine); - await pageObjects.sendMessageViaSendBox('no avatar'); - await driver.wait(minNumActivitiesShown(6), timeouts.directLine); - - await expect(driver.takeScreenshot()).resolves.toMatchImageSnapshot(imageSnapshotOptions); - - await pageObjects.updateProps({ - ...props, - styleOptions: { - ...props.styleOptions, - botAvatarInitials: undefined, - userAvatarInitials: undefined - } - }); - - await expect(driver.takeScreenshot()).resolves.toMatchImageSnapshot(imageSnapshotOptions); - }); - - test('with default avatar, bubble nub, and round bubble', async () => { - const props = { - ...createFullCustomizedProps(), - locale: 'ar-EG' - }; - - const { driver, pageObjects } = await setupWebDriver({ - height: 768, - props, - // TODO: [P1] #2954 Currently, offline MockBot has bugs that randomize the activity order. - useProductionBot: true - }); - - await driver.wait(uiConnected(), timeouts.directLine); - await pageObjects.sendMessageViaSendBox('normal'); - await driver.wait(minNumActivitiesShown(2), timeouts.directLine); - await pageObjects.sendMessageViaSendBox('override avatar'); - await driver.wait(minNumActivitiesShown(4), timeouts.directLine); - await pageObjects.sendMessageViaSendBox('no avatar'); - await driver.wait(minNumActivitiesShown(6), timeouts.directLine); - - await expect(driver.takeScreenshot()).resolves.toMatchImageSnapshot(imageSnapshotOptions); - - await pageObjects.updateProps({ - ...props, - styleOptions: { - ...props.styleOptions, - botAvatarInitials: undefined, - userAvatarInitials: undefined - } - }); - - await expect(driver.takeScreenshot()).resolves.toMatchImageSnapshot(imageSnapshotOptions); - }); - - test('with default avatar only on one side', async () => { - let props = createDefaultProps(); - - props = { ...props, locale: 'ar-EG', styleOptions: { ...props.styleOptions, userAvatarInitials: undefined } }; - - const { driver, pageObjects } = await setupWebDriver({ - props, - // TODO: [P1] #2954 Currently, offline MockBot has bugs that randomize the activity order. - useProductionBot: true - }); - - await driver.wait(uiConnected(), timeouts.directLine); - await pageObjects.sendMessageViaSendBox('normal'); - await driver.wait(minNumActivitiesShown(2), timeouts.directLine); - - await expect(driver.takeScreenshot()).resolves.toMatchImageSnapshot(imageSnapshotOptions); - - props = createDefaultProps(); - props = { ...props, styleOptions: { ...props.styleOptions, botAvatarInitials: undefined } }; - - await pageObjects.updateProps({ - ...props, - styleOptions: { - ...props.styleOptions, - botAvatarInitials: undefined - } - }); - - await expect(driver.takeScreenshot()).resolves.toMatchImageSnapshot(imageSnapshotOptions); - }); - - test('with default avatar, bubble nub, and round bubble only on one side', async () => { - let props = createFullCustomizedProps(); - - props = { ...props, locale: 'ar-EG', styleOptions: { ...props.styleOptions, userAvatarInitials: undefined } }; - - const { driver, pageObjects } = await setupWebDriver({ - props, - // TODO: [P1] #2954 Currently, offline MockBot has bugs that randomize the activity order. - useProductionBot: true - }); - - await driver.wait(uiConnected(), timeouts.directLine); - await pageObjects.sendMessageViaSendBox('normal'); - await driver.wait(minNumActivitiesShown(2), timeouts.directLine); - - await expect(driver.takeScreenshot()).resolves.toMatchImageSnapshot(imageSnapshotOptions); - - props = createFullCustomizedProps(); - props = { ...props, styleOptions: { ...props.styleOptions, botAvatarInitials: undefined } }; - - await pageObjects.updateProps({ - ...props, - styleOptions: { - ...props.styleOptions, - botAvatarInitials: undefined - } - }); - - await expect(driver.takeScreenshot()).resolves.toMatchImageSnapshot(imageSnapshotOptions); - }); - }); -}); - -test('customize size and roundness of avatar', async () => { - const { driver, pageObjects } = await setupWebDriver({ - props: { - styleOptions: { - avatarBorderRadius: '20%', - avatarSize: 64, - botAvatarInitials: 'WC', - userAvatarInitials: 'WW' - } - }, - // TODO: [P1] #2954 Currently, offline MockBot has bugs that randomize the activity order. - useProductionBot: true - }); - - await driver.wait(uiConnected(), timeouts.directLine); - await pageObjects.sendMessageViaSendBox('normal'); - await driver.wait(minNumActivitiesShown(2), timeouts.directLine); - - await expect(driver.takeScreenshot()).resolves.toMatchImageSnapshot(imageSnapshotOptions); -}); diff --git a/__tests__/html/hooks/useTypingIndicatorVisible/getter.botTyping.html b/__tests__/html/hooks/useTypingIndicatorVisible/getter.botTyping.html index 5307f391fd..bb4c50d142 100644 --- a/__tests__/html/hooks/useTypingIndicatorVisible/getter.botTyping.html +++ b/__tests__/html/hooks/useTypingIndicatorVisible/getter.botTyping.html @@ -15,14 +15,16 @@ run(async function () { const { ReactDOM: { render }, + testHelpers: { createDirectLineEmulator }, WebChat: { Components: { BasicWebChat, Composer }, hooks: { useTypingIndicatorVisible } } } = window; // Imports in UMD fashion. - const directLine = WebChat.createDirectLine({ token: await testHelpers.token.fetchDirectLineToken() }); - const store = testHelpers.createStore(); + const clock = lolex.createClock(); + + const { directLine, store } = createDirectLineEmulator({ ponyfill: clock }); let typingIndicatorVisible; @@ -33,15 +35,30 @@ }; render( - + , document.getElementById('webchat') ); + await pageConditions.webChatRendered(); + + clock.tick(600); + await pageConditions.uiConnected(); - await pageObjects.sendMessageViaSendBox('typing 1'); + + await ( + await directLine.actPostActivity(() => { + pageObjects.sendMessageViaSendBox('typing 1'); + }) + ).resolveAll(); + + await directLine.emulateIncomingActivity('Typing indicator should go away after 5 seconds.'); + await directLine.emulateIncomingActivity({ + from: { id: 'bot', role: 'bot' }, + type: 'typing' + }); await pageConditions.numActivitiesShown(2); expect(typingIndicatorVisible).toBe(true); diff --git a/__tests__/html/renderActivity.profiling.html b/__tests__/html/renderActivity.profiling.html index e38b7e5ec6..edb4b12a2e 100644 --- a/__tests__/html/renderActivity.profiling.html +++ b/__tests__/html/renderActivity.profiling.html @@ -136,13 +136,13 @@ const data = []; for (const entry of commits.values()) { - const { - commit, + const { + commit, 'Transcript nested-update actual': transcriptNestedUpdate = 0, 'Transcript update actual': transcriptUpdate = 0, 'Activity update actual': activityUpdate = 0, 'Activity mount actual': acivityMount = 0, - activitiesCount + activitiesCount } = entry; if (acivityMount || transcriptNestedUpdate || transcriptUpdate || activityUpdate) { data.push({ index: data.length, commit, activityUpdate, acivityMount, transcriptNestedUpdate, transcriptUpdate, activitiesCount }) @@ -285,7 +285,7 @@ .attr("clip-path", clip) .attr("fill", "LightSteelBlue") .attr("d", areaTU(data, x)); - + const pathAM = svg.append("path") .attr("clip-path", clip) .attr("fill", "LavenderBlush") diff --git a/__tests__/html/sendAttachmentOn/useSendFiles.binary.html b/__tests__/html/sendAttachmentOn/useSendFiles.binary.html index beb4330da7..ee9f548555 100644 --- a/__tests__/html/sendAttachmentOn/useSendFiles.binary.html +++ b/__tests__/html/sendAttachmentOn/useSendFiles.binary.html @@ -35,8 +35,8 @@ await pageObjects.runHook(({ useSendFiles }) => useSendFiles()([fileBlob])); // THEN: It should send the file without thumbnail. - await pageConditions.allOutgoingActivitiesSent(); await pageConditions.numActivitiesShown(3); + await pageConditions.allOutgoingActivitiesSent(); await host.snapshot(); }); diff --git a/__tests__/html/upload.image.html b/__tests__/html/upload.image.html index 952370af58..f180320d4d 100644 --- a/__tests__/html/upload.image.html +++ b/__tests__/html/upload.image.html @@ -21,6 +21,7 @@ await pageConditions.uiConnected(); await pageObjects.uploadFile('seaofthieves.jpg'); + await pageConditions.allOutgoingActivitiesSent(); await pageConditions.numActivitiesShown(2); await host.snapshot(); diff --git a/__tests__/html/upload/uploadPlainTextFile.html b/__tests__/html/upload/uploadPlainTextFile.html index dd09c4febd..0a75d2a870 100644 --- a/__tests__/html/upload/uploadPlainTextFile.html +++ b/__tests__/html/upload/uploadPlainTextFile.html @@ -27,6 +27,7 @@ await pageConditions.uiConnected(); await pageObjects.uploadFile('empty.txt'); + await pageConditions.allOutgoingActivitiesSent(); await pageConditions.minNumActivitiesShown(2); await pageConditions.allImagesLoaded(); diff --git a/__tests__/html/upload/uploadZipFile.html b/__tests__/html/upload/uploadZipFile.html index f4d7b88d18..d62b85d14e 100644 --- a/__tests__/html/upload/uploadZipFile.html +++ b/__tests__/html/upload/uploadZipFile.html @@ -27,6 +27,7 @@ await pageConditions.uiConnected(); await pageObjects.uploadFile('empty.zip'); + await pageConditions.allOutgoingActivitiesSent(); await pageConditions.minNumActivitiesShown(2); await pageConditions.allImagesLoaded(); diff --git a/__tests__/html/upload/uploadZipFileWithContentURL.html b/__tests__/html/upload/uploadZipFileWithContentURL.html index e0a2570726..41160d9d1f 100644 --- a/__tests__/html/upload/uploadZipFileWithContentURL.html +++ b/__tests__/html/upload/uploadZipFileWithContentURL.html @@ -52,6 +52,7 @@ await pageConditions.uiConnected(); await pageObjects.uploadFile('empty.zip'); + await pageConditions.allOutgoingActivitiesSent(); await pageConditions.minNumActivitiesShown(2); await pageConditions.allImagesLoaded(); diff --git a/__tests__/html/upload/uploadZipFileWithoutContentURL.html b/__tests__/html/upload/uploadZipFileWithoutContentURL.html index 69e648ecbf..a98bc1d8a7 100644 --- a/__tests__/html/upload/uploadZipFileWithoutContentURL.html +++ b/__tests__/html/upload/uploadZipFileWithoutContentURL.html @@ -52,6 +52,7 @@ await pageConditions.uiConnected(); await pageObjects.uploadFile('empty.zip'); + await pageConditions.allOutgoingActivitiesSent(); await pageConditions.minNumActivitiesShown(2); await pageConditions.allImagesLoaded(); diff --git a/__tests__/html/upload/withWebWorker/customThumbnailQuality.html b/__tests__/html/upload/withWebWorker/customThumbnailQuality.html index 65a1a9c286..062568d45d 100644 --- a/__tests__/html/upload/withWebWorker/customThumbnailQuality.html +++ b/__tests__/html/upload/withWebWorker/customThumbnailQuality.html @@ -28,6 +28,7 @@ await pageConditions.uiConnected(); await pageObjects.uploadFile('seaofthieves.jpg'); + await pageConditions.allOutgoingActivitiesSent(); await pageConditions.minNumActivitiesShown(2); await pageConditions.allImagesLoaded(); diff --git a/__tests__/html/upload/withWebWorker/customThumbnailSize.html b/__tests__/html/upload/withWebWorker/customThumbnailSize.html index 228a666295..33f1e347ae 100644 --- a/__tests__/html/upload/withWebWorker/customThumbnailSize.html +++ b/__tests__/html/upload/withWebWorker/customThumbnailSize.html @@ -30,6 +30,7 @@ await pageConditions.uiConnected(); await pageObjects.uploadFile('seaofthieves.jpg'); + await pageConditions.allOutgoingActivitiesSent(); await pageConditions.minNumActivitiesShown(2); await pageConditions.allImagesLoaded(); diff --git a/__tests__/html/upload/withWebWorker/withTelemetry.html b/__tests__/html/upload/withWebWorker/withTelemetry.html index 5bed759952..935b90703d 100644 --- a/__tests__/html/upload/withWebWorker/withTelemetry.html +++ b/__tests__/html/upload/withWebWorker/withTelemetry.html @@ -43,6 +43,7 @@ await pageConditions.uiConnected(); await pageObjects.uploadFile('seaofthieves.jpg'); + await pageConditions.allOutgoingActivitiesSent(); await pageConditions.minNumActivitiesShown(2); await pageConditions.allImagesLoaded(); diff --git a/__tests__/html/upload/withWebWorker/withoutThumbnail.html b/__tests__/html/upload/withWebWorker/withoutThumbnail.html index 5a4d5c8534..a55b7c93c2 100644 --- a/__tests__/html/upload/withWebWorker/withoutThumbnail.html +++ b/__tests__/html/upload/withWebWorker/withoutThumbnail.html @@ -28,6 +28,7 @@ await pageConditions.uiConnected(); await pageObjects.uploadFile('seaofthieves.jpg'); + await pageConditions.allOutgoingActivitiesSent(); await pageConditions.minNumActivitiesShown(2); await pageConditions.allImagesLoaded(); diff --git a/__tests__/html/upload/withoutWebWorker/customThumbnailQuality.html b/__tests__/html/upload/withoutWebWorker/customThumbnailQuality.html index e3b3bc039c..80e8bd419d 100644 --- a/__tests__/html/upload/withoutWebWorker/customThumbnailQuality.html +++ b/__tests__/html/upload/withoutWebWorker/customThumbnailQuality.html @@ -30,6 +30,7 @@ await pageConditions.uiConnected(); await pageObjects.uploadFile('seaofthieves.jpg'); + await pageConditions.allOutgoingActivitiesSent(); await pageConditions.minNumActivitiesShown(2); await pageConditions.allImagesLoaded(); diff --git a/__tests__/html/upload/withoutWebWorker/customThumbnailSize.html b/__tests__/html/upload/withoutWebWorker/customThumbnailSize.html index 374fbdab3d..3a5411bfba 100644 --- a/__tests__/html/upload/withoutWebWorker/customThumbnailSize.html +++ b/__tests__/html/upload/withoutWebWorker/customThumbnailSize.html @@ -32,6 +32,7 @@ await pageConditions.uiConnected(); await pageObjects.uploadFile('seaofthieves.jpg'); + await pageConditions.allOutgoingActivitiesSent(); await pageConditions.minNumActivitiesShown(2); await pageConditions.allImagesLoaded(); diff --git a/__tests__/html/upload/withoutWebWorker/simple.html b/__tests__/html/upload/withoutWebWorker/simple.html index db277691c0..445484d4af 100644 --- a/__tests__/html/upload/withoutWebWorker/simple.html +++ b/__tests__/html/upload/withoutWebWorker/simple.html @@ -29,6 +29,7 @@ await pageConditions.uiConnected(); await pageObjects.uploadFile('seaofthieves.jpg'); + await pageConditions.allOutgoingActivitiesSent(); await pageConditions.minNumActivitiesShown(2); await pageConditions.allImagesLoaded(); diff --git a/__tests__/html2/avatar/layout.customSizeAndRoundness.html b/__tests__/html2/avatar/layout.customSizeAndRoundness.html new file mode 100644 index 0000000000..8c5b257b4d --- /dev/null +++ b/__tests__/html2/avatar/layout.customSizeAndRoundness.html @@ -0,0 +1,56 @@ + + + + + + + + + + + +
+ + + diff --git a/__tests__/__image_snapshots__/chrome-docker/customizable-avatar-js-customize-size-and-roundness-of-avatar-1-snap.png b/__tests__/html2/avatar/layout.customSizeAndRoundness.html.snap-1.png similarity index 100% rename from __tests__/__image_snapshots__/chrome-docker/customizable-avatar-js-customize-size-and-roundness-of-avatar-1-snap.png rename to __tests__/html2/avatar/layout.customSizeAndRoundness.html.snap-1.png diff --git a/__tests__/html2/avatar/layout.default.html b/__tests__/html2/avatar/layout.default.html new file mode 100644 index 0000000000..78a28ae013 --- /dev/null +++ b/__tests__/html2/avatar/layout.default.html @@ -0,0 +1,76 @@ + + + + + + + + + + + +
+ + + diff --git a/__tests__/__image_snapshots__/chrome-docker/customizable-avatar-js-customizable-avatar-with-default-avatar-1-snap.png b/__tests__/html2/avatar/layout.default.html.snap-1.png similarity index 100% rename from __tests__/__image_snapshots__/chrome-docker/customizable-avatar-js-customizable-avatar-with-default-avatar-1-snap.png rename to __tests__/html2/avatar/layout.default.html.snap-1.png diff --git a/__tests__/__image_snapshots__/chrome-docker/customizable-avatar-js-customizable-avatar-with-default-avatar-2-snap.png b/__tests__/html2/avatar/layout.default.html.snap-2.png similarity index 100% rename from __tests__/__image_snapshots__/chrome-docker/customizable-avatar-js-customizable-avatar-with-default-avatar-2-snap.png rename to __tests__/html2/avatar/layout.default.html.snap-2.png diff --git a/__tests__/html2/avatar/layout.default.oneSide.html b/__tests__/html2/avatar/layout.default.oneSide.html new file mode 100644 index 0000000000..32f5800652 --- /dev/null +++ b/__tests__/html2/avatar/layout.default.oneSide.html @@ -0,0 +1,66 @@ + + + + + + + + + + + +
+ + + diff --git a/__tests__/__image_snapshots__/chrome-docker/customizable-avatar-js-customizable-avatar-with-default-avatar-only-on-one-side-1-snap.png b/__tests__/html2/avatar/layout.default.oneSide.html.snap-1.png similarity index 100% rename from __tests__/__image_snapshots__/chrome-docker/customizable-avatar-js-customizable-avatar-with-default-avatar-only-on-one-side-1-snap.png rename to __tests__/html2/avatar/layout.default.oneSide.html.snap-1.png diff --git a/__tests__/__image_snapshots__/chrome-docker/customizable-avatar-js-customizable-avatar-with-default-avatar-only-on-one-side-2-snap.png b/__tests__/html2/avatar/layout.default.oneSide.html.snap-2.png similarity index 100% rename from __tests__/__image_snapshots__/chrome-docker/customizable-avatar-js-customizable-avatar-with-default-avatar-only-on-one-side-2-snap.png rename to __tests__/html2/avatar/layout.default.oneSide.html.snap-2.png diff --git a/__tests__/html2/avatar/layout.default.oneSide.rtl.html b/__tests__/html2/avatar/layout.default.oneSide.rtl.html new file mode 100644 index 0000000000..1dad2c772c --- /dev/null +++ b/__tests__/html2/avatar/layout.default.oneSide.rtl.html @@ -0,0 +1,9 @@ + + + + + + + diff --git a/__tests__/__image_snapshots__/chrome-docker/customizable-avatar-js-customizable-avatar-in-rtl-with-default-avatar-only-on-one-side-1-snap.png b/__tests__/html2/avatar/layout.default.oneSide.rtl.html.snap-1.png similarity index 100% rename from __tests__/__image_snapshots__/chrome-docker/customizable-avatar-js-customizable-avatar-in-rtl-with-default-avatar-only-on-one-side-1-snap.png rename to __tests__/html2/avatar/layout.default.oneSide.rtl.html.snap-1.png diff --git a/__tests__/__image_snapshots__/chrome-docker/customizable-avatar-js-customizable-avatar-in-rtl-with-default-avatar-only-on-one-side-2-snap.png b/__tests__/html2/avatar/layout.default.oneSide.rtl.html.snap-2.png similarity index 100% rename from __tests__/__image_snapshots__/chrome-docker/customizable-avatar-js-customizable-avatar-in-rtl-with-default-avatar-only-on-one-side-2-snap.png rename to __tests__/html2/avatar/layout.default.oneSide.rtl.html.snap-2.png diff --git a/__tests__/html2/avatar/layout.default.rtl.html b/__tests__/html2/avatar/layout.default.rtl.html new file mode 100644 index 0000000000..c042c300c9 --- /dev/null +++ b/__tests__/html2/avatar/layout.default.rtl.html @@ -0,0 +1,9 @@ + + + + + + + diff --git a/__tests__/__image_snapshots__/chrome-docker/customizable-avatar-js-customizable-avatar-in-rtl-with-default-avatar-1-snap.png b/__tests__/html2/avatar/layout.default.rtl.html.snap-1.png similarity index 100% rename from __tests__/__image_snapshots__/chrome-docker/customizable-avatar-js-customizable-avatar-in-rtl-with-default-avatar-1-snap.png rename to __tests__/html2/avatar/layout.default.rtl.html.snap-1.png diff --git a/__tests__/__image_snapshots__/chrome-docker/customizable-avatar-js-customizable-avatar-in-rtl-with-default-avatar-2-snap.png b/__tests__/html2/avatar/layout.default.rtl.html.snap-2.png similarity index 100% rename from __tests__/__image_snapshots__/chrome-docker/customizable-avatar-js-customizable-avatar-in-rtl-with-default-avatar-2-snap.png rename to __tests__/html2/avatar/layout.default.rtl.html.snap-2.png diff --git a/__tests__/html2/avatar/layout.fullCustomized.html b/__tests__/html2/avatar/layout.fullCustomized.html new file mode 100644 index 0000000000..710f0c600b --- /dev/null +++ b/__tests__/html2/avatar/layout.fullCustomized.html @@ -0,0 +1,75 @@ + + + + + + + + + + + +
+ + + diff --git a/__tests__/__image_snapshots__/chrome-docker/customizable-avatar-js-customizable-avatar-with-default-avatar-bubble-nub-and-round-bubble-1-snap.png b/__tests__/html2/avatar/layout.fullCustomized.html.snap-1.png similarity index 100% rename from __tests__/__image_snapshots__/chrome-docker/customizable-avatar-js-customizable-avatar-with-default-avatar-bubble-nub-and-round-bubble-1-snap.png rename to __tests__/html2/avatar/layout.fullCustomized.html.snap-1.png diff --git a/__tests__/__image_snapshots__/chrome-docker/customizable-avatar-js-customizable-avatar-with-default-avatar-bubble-nub-and-round-bubble-2-snap.png b/__tests__/html2/avatar/layout.fullCustomized.html.snap-2.png similarity index 100% rename from __tests__/__image_snapshots__/chrome-docker/customizable-avatar-js-customizable-avatar-with-default-avatar-bubble-nub-and-round-bubble-2-snap.png rename to __tests__/html2/avatar/layout.fullCustomized.html.snap-2.png diff --git a/__tests__/html2/avatar/layout.fullCustomized.oneSide.html b/__tests__/html2/avatar/layout.fullCustomized.oneSide.html new file mode 100644 index 0000000000..7d3008f6ac --- /dev/null +++ b/__tests__/html2/avatar/layout.fullCustomized.oneSide.html @@ -0,0 +1,66 @@ + + + + + + + + + + + +
+ + + diff --git a/__tests__/__image_snapshots__/chrome-docker/customizable-avatar-js-customizable-avatar-with-default-avatar-bubble-nub-and-round-bubble-only-on-one-side-1-snap.png b/__tests__/html2/avatar/layout.fullCustomized.oneSide.html.snap-1.png similarity index 100% rename from __tests__/__image_snapshots__/chrome-docker/customizable-avatar-js-customizable-avatar-with-default-avatar-bubble-nub-and-round-bubble-only-on-one-side-1-snap.png rename to __tests__/html2/avatar/layout.fullCustomized.oneSide.html.snap-1.png diff --git a/__tests__/__image_snapshots__/chrome-docker/customizable-avatar-js-customizable-avatar-with-default-avatar-bubble-nub-and-round-bubble-only-on-one-side-2-snap.png b/__tests__/html2/avatar/layout.fullCustomized.oneSide.html.snap-2.png similarity index 100% rename from __tests__/__image_snapshots__/chrome-docker/customizable-avatar-js-customizable-avatar-with-default-avatar-bubble-nub-and-round-bubble-only-on-one-side-2-snap.png rename to __tests__/html2/avatar/layout.fullCustomized.oneSide.html.snap-2.png diff --git a/__tests__/html2/avatar/layout.fullCustomized.oneSide.rtl.html b/__tests__/html2/avatar/layout.fullCustomized.oneSide.rtl.html new file mode 100644 index 0000000000..040d31990d --- /dev/null +++ b/__tests__/html2/avatar/layout.fullCustomized.oneSide.rtl.html @@ -0,0 +1,9 @@ + + + + + + + diff --git a/__tests__/__image_snapshots__/chrome-docker/customizable-avatar-js-customizable-avatar-in-rtl-with-default-avatar-bubble-nub-and-round-bubble-only-on-one-side-1-snap.png b/__tests__/html2/avatar/layout.fullCustomized.oneSide.rtl.html.snap-1.png similarity index 100% rename from __tests__/__image_snapshots__/chrome-docker/customizable-avatar-js-customizable-avatar-in-rtl-with-default-avatar-bubble-nub-and-round-bubble-only-on-one-side-1-snap.png rename to __tests__/html2/avatar/layout.fullCustomized.oneSide.rtl.html.snap-1.png diff --git a/__tests__/__image_snapshots__/chrome-docker/customizable-avatar-js-customizable-avatar-in-rtl-with-default-avatar-bubble-nub-and-round-bubble-only-on-one-side-2-snap.png b/__tests__/html2/avatar/layout.fullCustomized.oneSide.rtl.html.snap-2.png similarity index 100% rename from __tests__/__image_snapshots__/chrome-docker/customizable-avatar-js-customizable-avatar-in-rtl-with-default-avatar-bubble-nub-and-round-bubble-only-on-one-side-2-snap.png rename to __tests__/html2/avatar/layout.fullCustomized.oneSide.rtl.html.snap-2.png diff --git a/__tests__/html2/avatar/layout.fullCustomized.rtl.html b/__tests__/html2/avatar/layout.fullCustomized.rtl.html new file mode 100644 index 0000000000..f82f327ce0 --- /dev/null +++ b/__tests__/html2/avatar/layout.fullCustomized.rtl.html @@ -0,0 +1,9 @@ + + + + + + + diff --git a/__tests__/__image_snapshots__/chrome-docker/customizable-avatar-js-customizable-avatar-in-rtl-with-default-avatar-bubble-nub-and-round-bubble-1-snap.png b/__tests__/html2/avatar/layout.fullCustomized.rtl.html.snap-1.png similarity index 100% rename from __tests__/__image_snapshots__/chrome-docker/customizable-avatar-js-customizable-avatar-in-rtl-with-default-avatar-bubble-nub-and-round-bubble-1-snap.png rename to __tests__/html2/avatar/layout.fullCustomized.rtl.html.snap-1.png diff --git a/__tests__/__image_snapshots__/chrome-docker/customizable-avatar-js-customizable-avatar-in-rtl-with-default-avatar-bubble-nub-and-round-bubble-2-snap.png b/__tests__/html2/avatar/layout.fullCustomized.rtl.html.snap-2.png similarity index 100% rename from __tests__/__image_snapshots__/chrome-docker/customizable-avatar-js-customizable-avatar-in-rtl-with-default-avatar-bubble-nub-and-round-bubble-2-snap.png rename to __tests__/html2/avatar/layout.fullCustomized.rtl.html.snap-2.png diff --git a/__tests__/html2/avatar/setup.js b/__tests__/html2/avatar/setup.js new file mode 100644 index 0000000000..fb8bf5f2f5 --- /dev/null +++ b/__tests__/html2/avatar/setup.js @@ -0,0 +1,59 @@ +const createDefaultProps = (extraProps) => ({ + avatarMiddleware: () => next => args => { + const { activity } = args; + const { text = '' } = activity; + + if (~text.indexOf('override avatar')) { + return () => + React.createElement( + 'div', + { + style: { + alignItems: 'center', + backgroundColor: 'Red', + borderRadius: 4, + color: 'White', + display: 'flex', + fontFamily: "'Calibri', 'Helvetica Neue', 'Arial', 'sans-serif'", + height: 128, + justifyContent: 'center', + width: '100%' + } + }, + React.createElement('div', {}, activity.from.role) + ); + } else if (~text.indexOf('no avatar')) { + return false; + } + + return next(args); + }, + styleOptions: { + botAvatarBackgroundColor: '#77F', + botAvatarInitials: 'WC', + userAvatarBackgroundColor: '#F77', + userAvatarInitials: 'WW' + }, + ...extraProps +}); + +const createFullCustomizedProps = args => { + const props = createDefaultProps(args); + + return { + ...props, + styleOptions: { + ...props.styleOptions, + bubbleBorderColor: 'Black', + bubbleBorderRadius: 10, + bubbleFromUserBorderColor: 'Black', + bubbleFromUserBorderRadius: 10, + bubbleFromUserNubOffset: 5, + bubbleFromUserNubSize: 10, + bubbleNubOffset: 5, + bubbleNubSize: 10 + } + }; +}; + +export { createDefaultProps, createFullCustomizedProps }; diff --git a/__tests__/html2/grouping/customGrouping.html b/__tests__/html2/grouping/customGrouping.html new file mode 100644 index 0000000000..1a1b213398 --- /dev/null +++ b/__tests__/html2/grouping/customGrouping.html @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + +
+ + + diff --git a/__tests__/html2/grouping/customGrouping.html.snap-1.png b/__tests__/html2/grouping/customGrouping.html.snap-1.png new file mode 100644 index 0000000000..129fe25984 Binary files /dev/null and b/__tests__/html2/grouping/customGrouping.html.snap-1.png differ diff --git a/__tests__/html2/grouping/disableAll.html b/__tests__/html2/grouping/disableAll.html new file mode 100644 index 0000000000..bdea2c6894 --- /dev/null +++ b/__tests__/html2/grouping/disableAll.html @@ -0,0 +1,112 @@ + + + + + + + + + + + + + +
+ + + diff --git a/__tests__/html2/grouping/disableAll.html.snap-1.png b/__tests__/html2/grouping/disableAll.html.snap-1.png new file mode 100644 index 0000000000..90caa97814 Binary files /dev/null and b/__tests__/html2/grouping/disableAll.html.snap-1.png differ diff --git a/__tests__/html2/grouping/disableSender.html b/__tests__/html2/grouping/disableSender.html new file mode 100644 index 0000000000..2570e2b1bc --- /dev/null +++ b/__tests__/html2/grouping/disableSender.html @@ -0,0 +1,110 @@ + + + + + + + + + + + + + +
+ + + diff --git a/__tests__/html2/grouping/disableSender.html.snap-1.png b/__tests__/html2/grouping/disableSender.html.snap-1.png new file mode 100644 index 0000000000..26b761bd82 Binary files /dev/null and b/__tests__/html2/grouping/disableSender.html.snap-1.png differ diff --git a/__tests__/html2/grouping/disableStatus.html b/__tests__/html2/grouping/disableStatus.html new file mode 100644 index 0000000000..b4a03102af --- /dev/null +++ b/__tests__/html2/grouping/disableStatus.html @@ -0,0 +1,110 @@ + + + + + + + + + + + + + +
+ + + diff --git a/__tests__/html2/grouping/disableStatus.html.snap-1.png b/__tests__/html2/grouping/disableStatus.html.snap-1.png new file mode 100644 index 0000000000..e22fc9f1c3 Binary files /dev/null and b/__tests__/html2/grouping/disableStatus.html.snap-1.png differ diff --git a/__tests__/html2/grouping/disableStatus.perSender.html b/__tests__/html2/grouping/disableStatus.perSender.html new file mode 100644 index 0000000000..2ca2619f39 --- /dev/null +++ b/__tests__/html2/grouping/disableStatus.perSender.html @@ -0,0 +1,111 @@ + + + + + + + + + + + + + +
+ + + diff --git a/__tests__/html2/grouping/disableStatus.perSender.html.snap-1.png b/__tests__/html2/grouping/disableStatus.perSender.html.snap-1.png new file mode 100644 index 0000000000..b5ad324946 Binary files /dev/null and b/__tests__/html2/grouping/disableStatus.perSender.html.snap-1.png differ diff --git a/__tests__/html2/grouping/extraneousGroup.html b/__tests__/html2/grouping/extraneousGroup.html new file mode 100644 index 0000000000..043bce0f48 --- /dev/null +++ b/__tests__/html2/grouping/extraneousGroup.html @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + +
+ + + diff --git a/__tests__/html2/grouping/extraneousGroup.html.snap-1.png b/__tests__/html2/grouping/extraneousGroup.html.snap-1.png new file mode 100644 index 0000000000..eeae3e4b76 Binary files /dev/null and b/__tests__/html2/grouping/extraneousGroup.html.snap-1.png differ diff --git a/__tests__/html2/grouping/fluentTheme.html b/__tests__/html2/grouping/fluentTheme.html new file mode 100644 index 0000000000..eb3b773135 --- /dev/null +++ b/__tests__/html2/grouping/fluentTheme.html @@ -0,0 +1,125 @@ + + + + + + + + + + + + + + + +
+ + + diff --git a/__tests__/html2/grouping/fluentTheme.html.snap-1.png b/__tests__/html2/grouping/fluentTheme.html.snap-1.png new file mode 100644 index 0000000000..1b75147711 Binary files /dev/null and b/__tests__/html2/grouping/fluentTheme.html.snap-1.png differ diff --git a/__tests__/html2/grouping/grouping.css b/__tests__/html2/grouping/grouping.css new file mode 100644 index 0000000000..8008466144 --- /dev/null +++ b/__tests__/html2/grouping/grouping.css @@ -0,0 +1,37 @@ +.grouping { + border-style: solid; + border-width: 2px; + margin: 2px; + position: relative; +} + +.grouping::after { + font-family: sans-serif; + font-size: smaller; + height: calc(1em + 2px); + padding: 2px 6px; + position: absolute; + top: 0; +} + +.grouping--sender { + border-color: #e00; +} + +.grouping--sender::after { + background-color: #e00; + color: white; + content: 'Sender'; + right: -2px; +} + +.grouping--status { + border-color: green; +} + +.grouping--status::after { + background-color: green; + color: white; + content: 'Status'; + left: -2px; +} diff --git a/__tests__/html2/grouping/groupingBorder.html b/__tests__/html2/grouping/groupingBorder.html new file mode 100644 index 0000000000..46ea8cae59 --- /dev/null +++ b/__tests__/html2/grouping/groupingBorder.html @@ -0,0 +1,109 @@ + + + + + + + + + + + + + +
+ + + diff --git a/__tests__/html2/grouping/groupingBorder.html.snap-1.png b/__tests__/html2/grouping/groupingBorder.html.snap-1.png new file mode 100644 index 0000000000..45400e705c Binary files /dev/null and b/__tests__/html2/grouping/groupingBorder.html.snap-1.png differ diff --git a/__tests__/html2/grouping/noSuchGroup.html b/__tests__/html2/grouping/noSuchGroup.html new file mode 100644 index 0000000000..0ef9031c78 --- /dev/null +++ b/__tests__/html2/grouping/noSuchGroup.html @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + +
+ + + diff --git a/__tests__/html2/grouping/noSuchGroup.html.snap-1.png b/__tests__/html2/grouping/noSuchGroup.html.snap-1.png new file mode 100644 index 0000000000..5fdd0b7197 Binary files /dev/null and b/__tests__/html2/grouping/noSuchGroup.html.snap-1.png differ diff --git a/__tests__/html2/markdown/math/layout.scroll.html b/__tests__/html2/markdown/math/layout.scroll.html index 8c283e2382..1a4e3ed018 100644 --- a/__tests__/html2/markdown/math/layout.scroll.html +++ b/__tests__/html2/markdown/math/layout.scroll.html @@ -75,6 +75,7 @@ }); await pageConditions.numActivitiesShown(1); + await pageConditions.scrollStabilized(); await host.snapshot('local'); diff --git a/docs/HOOKS.md b/docs/HOOKS.md index 04a9018c96..df2197d4df 100644 --- a/docs/HOOKS.md +++ b/docs/HOOKS.md @@ -14,10 +14,10 @@ Web Chat exposes APIs through React Hooks. This API surface enables us to freely We design our hooks largely with two basic shapes: -- Actions, these are functions that you can call at any time to perform a side-effect -- Properties, these are getter functions with an optional setter - - This is same as [React State Hook pattern](https://reactjs.org/docs/hooks-state.html), but setters are optional - - If the value is changed, React will call your render function again +- Actions, these are functions that you can call at any time to perform a side-effect +- Properties, these are getter functions with an optional setter + - This is same as [React State Hook pattern](https://reactjs.org/docs/hooks-state.html), but setters are optional + - If the value is changed, React will call your render function again ### Actions @@ -51,95 +51,97 @@ setSendBoxValue('Hello, World!'); Following is the list of hooks supported by Web Chat API. -- [`useActiveTyping`](#useactivetyping) -- [`useActivities`](#useactivities) -- [`useActivityKeysByRead`](#useactivitykeysbyread) -- [`useAdaptiveCardsHostConfig`](#useadaptivecardshostconfig) -- [`useAdaptiveCardsPackage`](#useadaptivecardspackage) -- [`useAvatarForBot`](#useavatarforbot) -- [`useAvatarForUser`](#useavatarforuser) -- [`useByteFormatter`](#useByteFormatter) -- [`useConnectivityStatus`](#useconnectivitystatus) -- [`useCreateActivityRenderer`](#usecreateactivityrenderer) -- [`useCreateActivityStatusRenderer`](#usecreateactivitystatusrenderer) -- [`useCreateAttachmentForScreenReaderRenderer`](#useCreateAttachmentForScreenReaderRenderer) -- [`useCreateAttachmentRenderer`](#usecreateattachmentrenderer) -- [`useCreateAvatarRenderer`](#usecreateavatarrenderer) -- [`useDateFormatter`](#useDateFormatter) -- [`useDebouncedNotification`](#usedebouncednotification) -- [`useDictateAbortable`](#usedictateabortable) -- [`useDictateInterims`](#usedictateinterims) -- [`useDictateState`](#usedictatestate) -- [`useDirection`](#useDirection) -- [`useDisabled`](#usedisabled) -- [`useDismissNotification`](#usedismissnotification) -- [`useEmitTypingIndicator`](#useemittypingindicator) -- [`useFocus`](#usefocus) -- [`useFocusSendBox`](#usefocussendbox) -- [`useGetActivitiesByKey`](#usegetactivitiesbykey) -- [`useGetActivityByKey`](#usegetactivitybykey) -- [`useGetHasAcknowledgedByActivityKey`](#usegethasacknowledgedbyactivitykey) -- [`useGetKeyByActivity`](#usegetkeybyactivity) -- [`useGetKeyByActivityId`](#usegetkeybyactivityid) -- [`useGetSendTimeoutForActivity`](#usegetsendtimeoutforactivity) -- [`useGrammars`](#usegrammars) -- [`useGroupTimestamp`](#usegrouptimestamp) -- [`useLanguage`](#uselanguage) -- [`useLastAcknowledgedActivityKey`](#uselastacknowledgedactivitykey) -- [`useLastReadActivityKey`](#uselastreadactivitykey) -- [`useLastTypingAt`](#uselasttypingat) (Deprecated) -- [`useLocalize`](#uselocalize) (Deprecated) -- [`useLocalizer`](#useLocalizer) -- [`useMarkActivityAsSpoken`](#usemarkactivityasspoken) -- [`useMarkActivityKeyAsRead`](#usemarkactivitykeyasread) -- [`useMarkAllAsAcknowledged`](#usemarkallasacknowledged) -- [`useNotification`](#usenotification) -- [`useObserveScrollPosition`](#useobservescrollposition) -- [`useObserveTranscriptFocus`](#useobservetranscriptfocus) -- [`usePerformCardAction`](#useperformcardaction) -- [`usePostActivity`](#usepostactivity) -- [`useReferenceGrammarID`](#usereferencegrammarid) -- [`useRelativeTimeFormatter`](#useRelativeTimeFormatter) -- [`useRenderActivity`](#userenderactivity) (Deprecated) -- [`useRenderActivityStatus`](#userenderactivitystatus) (Deprecated) -- [`useRenderAttachment`](#userenderattachment) -- [`useRenderAvatar`](#userenderavatar) (Deprecated) -- [`useRenderMarkdownAsHTML`](#userendermarkdownashtml) -- [`useRenderToast`](#userendertoast) -- [`useRenderTypingIndicator`](#userendertypingindicator) -- [`useScrollDown`](#usescrolldown) -- [`useScrollTo`](#usescrollto) -- [`useScrollToEnd`](#usescrolltoend) -- [`useScrollUp`](#usescrollup) -- [`useSendBoxAttachments`](#usesendboxattachments) -- [`useSendBoxValue`](#usesendboxvalue) -- [`useSendEvent`](#usesendevent) -- [`useSendFiles`](#usesendfiles) (Deprecated) -- [`useSendMessage`](#usesendmessage) -- [`useSendMessageBack`](#usesendmessageback) -- [`useSendPostBack`](#usesendpostback) -- [`useSendTimeoutForActivity`](#usesendtimeoutforactivity) (Deprecated) -- [`useSendTypingIndicator`](#usesendtypingindicator) -- [`useSendStatusByActivityKey`](#usesendstatusbyactivitykey) -- [`useSetNotification`](#usesetnotification) -- [`useShouldReduceMotion`](#useshouldreducemotion) -- [`useShouldSpeakIncomingActivity`](#useshouldspeakincomingactivity) -- [`useStartDictate`](#usestartdictate) -- [`useStopDictate`](#usestopdictate) -- [`useStyleOptions`](#usestyleoptions) -- [`useStyleSet`](#usestyleset) -- [`useSubmitSendBox`](#usesubmitsendbox) -- [`useSuggestedActions`](#usesuggestedactions) -- [`useTimeoutForSend`](#usetimeoutforsend) -- [`useTrackDimension`](#usetrackdimension) -- [`useTrackEvent`](#usetrackevent) -- [`useTrackException`](#usetrackexception) -- [`useTrackTiming`](#usetracktiming) -- [`useUIState`](#useuistate) -- [`useUserID`](#useuserid) -- [`useUsername`](#useusername) -- [`useVoiceSelector`](#usevoiceselector) -- [`useWebSpeechPonyfill`](#usewebspeechponyfill) +- [`useActiveTyping`](#useactivetyping) +- [`useActivities`](#useactivities) +- [`useActivityKeysByRead`](#useactivitykeysbyread) +- [`useAdaptiveCardsHostConfig`](#useadaptivecardshostconfig) +- [`useAdaptiveCardsPackage`](#useadaptivecardspackage) +- [`useAvatarForBot`](#useavatarforbot) +- [`useAvatarForUser`](#useavatarforuser) +- [`useByteFormatter`](#useByteFormatter) +- [`useConnectivityStatus`](#useconnectivitystatus) +- [`useCreateActivityRenderer`](#usecreateactivityrenderer) +- [`useCreateActivityStatusRenderer`](#usecreateactivitystatusrenderer) +- [`useCreateAttachmentForScreenReaderRenderer`](#useCreateAttachmentForScreenReaderRenderer) +- [`useCreateAttachmentRenderer`](#usecreateattachmentrenderer) +- [`useCreateAvatarRenderer`](#usecreateavatarrenderer) +- [`useDateFormatter`](#useDateFormatter) +- [`useDebouncedNotification`](#usedebouncednotification) +- [`useDictateAbortable`](#usedictateabortable) +- [`useDictateInterims`](#usedictateinterims) +- [`useDictateState`](#usedictatestate) +- [`useDirection`](#useDirection) +- [`useDisabled`](#usedisabled) +- [`useDismissNotification`](#usedismissnotification) +- [`useEmitTypingIndicator`](#useemittypingindicator) +- [`useFocus`](#usefocus) +- [`useFocusSendBox`](#usefocussendbox) +- [`useGetActivitiesByKey`](#usegetactivitiesbykey) +- [`useGetActivityByKey`](#usegetactivitybykey) +- [`useGetHasAcknowledgedByActivityKey`](#usegethasacknowledgedbyactivitykey) +- [`useGetKeyByActivity`](#usegetkeybyactivity) +- [`useGetKeyByActivityId`](#usegetkeybyactivityid) +- [`useGetSendTimeoutForActivity`](#usegetsendtimeoutforactivity) +- [`useGrammars`](#usegrammars) +- [`useGroupActivities`](#usegroupactivities) (Deprecated) +- [`useGroupActivitiesByName`](#usegroupactivitiesbyname) +- [`useGroupTimestamp`](#usegrouptimestamp) +- [`useLanguage`](#uselanguage) +- [`useLastAcknowledgedActivityKey`](#uselastacknowledgedactivitykey) +- [`useLastReadActivityKey`](#uselastreadactivitykey) +- [`useLastTypingAt`](#uselasttypingat) (Deprecated) +- [`useLocalize`](#uselocalize) (Deprecated) +- [`useLocalizer`](#useLocalizer) +- [`useMarkActivityAsSpoken`](#usemarkactivityasspoken) +- [`useMarkActivityKeyAsRead`](#usemarkactivitykeyasread) +- [`useMarkAllAsAcknowledged`](#usemarkallasacknowledged) +- [`useNotification`](#usenotification) +- [`useObserveScrollPosition`](#useobservescrollposition) +- [`useObserveTranscriptFocus`](#useobservetranscriptfocus) +- [`usePerformCardAction`](#useperformcardaction) +- [`usePostActivity`](#usepostactivity) +- [`useReferenceGrammarID`](#usereferencegrammarid) +- [`useRelativeTimeFormatter`](#useRelativeTimeFormatter) +- [`useRenderActivity`](#userenderactivity) (Deprecated) +- [`useRenderActivityStatus`](#userenderactivitystatus) (Deprecated) +- [`useRenderAttachment`](#userenderattachment) +- [`useRenderAvatar`](#userenderavatar) (Deprecated) +- [`useRenderMarkdownAsHTML`](#userendermarkdownashtml) +- [`useRenderToast`](#userendertoast) +- [`useRenderTypingIndicator`](#userendertypingindicator) +- [`useScrollDown`](#usescrolldown) +- [`useScrollTo`](#usescrollto) +- [`useScrollToEnd`](#usescrolltoend) +- [`useScrollUp`](#usescrollup) +- [`useSendBoxAttachments`](#usesendboxattachments) +- [`useSendBoxValue`](#usesendboxvalue) +- [`useSendEvent`](#usesendevent) +- [`useSendFiles`](#usesendfiles) (Deprecated) +- [`useSendMessage`](#usesendmessage) +- [`useSendMessageBack`](#usesendmessageback) +- [`useSendPostBack`](#usesendpostback) +- [`useSendTimeoutForActivity`](#usesendtimeoutforactivity) (Deprecated) +- [`useSendTypingIndicator`](#usesendtypingindicator) +- [`useSendStatusByActivityKey`](#usesendstatusbyactivitykey) +- [`useSetNotification`](#usesetnotification) +- [`useShouldReduceMotion`](#useshouldreducemotion) +- [`useShouldSpeakIncomingActivity`](#useshouldspeakincomingactivity) +- [`useStartDictate`](#usestartdictate) +- [`useStopDictate`](#usestopdictate) +- [`useStyleOptions`](#usestyleoptions) +- [`useStyleSet`](#usestyleset) +- [`useSubmitSendBox`](#usesubmitsendbox) +- [`useSuggestedActions`](#usesuggestedactions) +- [`useTimeoutForSend`](#usetimeoutforsend) +- [`useTrackDimension`](#usetrackdimension) +- [`useTrackEvent`](#usetrackevent) +- [`useTrackException`](#usetrackexception) +- [`useTrackTiming`](#usetracktiming) +- [`useUIState`](#useuistate) +- [`useUserID`](#useuserid) +- [`useUsername`](#useusername) +- [`useVoiceSelector`](#usevoiceselector) +- [`useWebSpeechPonyfill`](#usewebspeechponyfill) ## `useActiveTyping` @@ -169,8 +171,8 @@ The `expireAfter` argument can override the inactivity timer. If `expireAfter` i The `type` property will tell if the participant is livestreaming or busy preparing its response: -- `busy` indicates the participant is busy preparing the response -- `livestream` indicates the participant is sending its response as it is being prepared +- `busy` indicates the participant is busy preparing the response +- `livestream` indicates the participant is sending its response as it is being prepared > This hook will trigger render of your component if one or more typing information is expired or removed. @@ -270,14 +272,14 @@ useConnectivityStatus(): [string] This hook will return the Direct Line connectivity status: -- `connected`: Connected -- `connectingslow`: Connecting is incomplete and more than 15 seconds have passed -- `error`: Connection error -- `notconnected`: Not connected, related to invalid credentials -- `reconnected`: Reconnected after interruption -- `reconnecting`: Reconnecting after interruption -- `sagaerror`: Errors on JavaScript renderer; please see the browser's console -- `uninitialized`: Initial connectivity state; never connected and not attempting to connect. +- `connected`: Connected +- `connectingslow`: Connecting is incomplete and more than 15 seconds have passed +- `error`: Connection error +- `notconnected`: Not connected, related to invalid credentials +- `reconnected`: Reconnected after interruption +- `reconnecting`: Reconnecting after interruption +- `sagaerror`: Errors on JavaScript renderer; please see the browser's console +- `uninitialized`: Initial connectivity state; never connected and not attempting to connect. ## `useCreateActivityRenderer` @@ -306,8 +308,8 @@ If the activity middleware wants to hide the activity, it must return `false` in For `renderActivityStatus` and `renderAvatar`, it could be one of the followings: -- `false`: Do not render activity status or avatar. -- `() => React.Element`: Render activity status or avatar by calling this function. +- `false`: Do not render activity status or avatar. +- `() => React.Element`: Render activity status or avatar by calling this function. If `showCallout` is truthy, the activity should render the bubble nub and an avatar. The activity should call [`useStyleOptions`](#usestyleoptions) to get the styling for the bubble nub, including but not limited to: fill and outline color, offset from top/bottom, size. @@ -449,11 +451,11 @@ useDictateState(): [string] This hook will return one of the following dictation states: -- `IDLE`: Recognition engine is idle; not recognizing -- `WILL_START`: Will start recognition after synthesis completed -- `STARTING`: Recognition engine is starting; not accepting any inputs -- `DICTATING`: Recognition engine is accepting input -- `STOPPING`: Recognition engine is stopping; not accepting any inputs +- `IDLE`: Recognition engine is idle; not recognizing +- `WILL_START`: Will start recognition after synthesis completed +- `STARTING`: Recognition engine is starting; not accepting any inputs +- `DICTATING`: Recognition engine is accepting input +- `STOPPING`: Recognition engine is stopping; not accepting any inputs > Please refer to `Constants.DictateState` in `botframework-webchat-core` for up-to-date details. @@ -469,8 +471,8 @@ useDirection(): [string] This hook will return one of two language directions: -- `ltr` or otherwise: Web Chat UI will display as left-to-right -- `rtl`: Web Chat UI will display as right-to-left +- `ltr` or otherwise: Web Chat UI will display as left-to-right +- `rtl`: Web Chat UI will display as right-to-left This value will be automatically configured based on the `locale` of Web Chat. @@ -522,11 +524,11 @@ When called, This hook will return a function that can be called to set the focu Please use this function with cautions. When changing focus programmatically, user may lose focus on what they were working on. Also, this may affect accessibility. -- `main` will focus on transcript. - - We do not provide any visual cues when focusing on transcript, this may affect accessibility and usability, please use with cautions. -- `sendBox` will focus on send box. - - This will activate the virtual keyboard if your device have one. -- `sendBoxWithoutKeyboard` will focus on send box, without activating the virtual keyboard. +- `main` will focus on transcript. + - We do not provide any visual cues when focusing on transcript, this may affect accessibility and usability, please use with cautions. +- `sendBox` will focus on send box. + - This will activate the virtual keyboard if your device have one. +- `sendBoxWithoutKeyboard` will focus on send box, without activating the virtual keyboard. ## `useFocusSendBox` @@ -626,6 +628,37 @@ This hook will return grammars for speech-to-text. Grammars is a list of words p To modify this value, change the value in the style options prop passed to Web Chat. +## `useGroupActivities` + +> This function is deprecated and will be removed on or after 2027-05-04. Developers should migrate to [`useGroupActivitiesByName`](#usegroupactivitiesbyname) for performance reason. + + +```js +useGroupActivities(): ({ + activities: readonly WebChatActivity[]; +}) => { + [key: string]: WebChatActivity[][]; +}; +``` + + +This hook will return a callback function. When called with `activities`, the callback function will run the `groupActivitiesMiddleware` and will return all groupings. + +## `useGroupActivitiesByName` + + +```js +useGroupActivitiesByName(): ( + activities: readonly WebChatActivity[], + name: string +) => WebChatActivity[][]; +``` + + +This hook will return a callback function. When called with `activities`, the callback function will run the `groupActivitiesMiddleware` for the specified grouping name. + +Unlike the [`useGroupActivities`](#usegroupactivities) hook which provide result for all groupings, this hook only provide result for the specified grouping name and the grouping name must be one of the name specified in `styleOptions.groupActivitiesBy`. + ## `useGroupTimestamp` @@ -896,9 +929,9 @@ When called, this function will post the activity on behalf of the user, to the You can use this function to send any type of activity to the bot, however we highly recommend limiting the [activity types](https://github.com/microsoft/BotFramework-WebChat/tree/main/docs/ACTIVITYTYPES.md) to one of the following: -- `event` -- `message` -- `typing` +- `event` +- `message` +- `typing` ## `useReferenceGrammarID` @@ -1066,9 +1099,9 @@ useRenderTypingIndicator(): This function is for rendering typing indicator for all participants of the conversation. This function is a composition of `typingIndicatorMiddleware`, which is passed as a prop to `` or ``. The caller will pass the following arguments: -- `activeTyping` lists of participants who are actively typing. -- `typing` lists participants who did not explicitly stopped typing. This list is a superset of `activeTyping`. -- `visible` indicates whether typing indicator should be shown in normal case. This is based on participants in `activeTyping` and their `role` (role not equal to `"user"`). +- `activeTyping` lists of participants who are actively typing. +- `typing` lists participants who did not explicitly stopped typing. This list is a superset of `activeTyping`. +- `visible` indicates whether typing indicator should be shown in normal case. This is based on participants in `activeTyping` and their `role` (role not equal to `"user"`). ## `useScrollDown` @@ -1173,8 +1206,8 @@ useSendFiles(): (files: (Blob | File)[]) => void When called, this function will send a message activity with one or more [File](https://developer.mozilla.org/en-US/docs/Web/API/File) attachments to the bot, including these operations: -- Convert [File](https://developer.mozilla.org/en-US/docs/Web/API/File) into object URL -- Generate thumbnail and will use a Web Worker and an offscreen canvas if supported +- Convert [File](https://developer.mozilla.org/en-US/docs/Web/API/File) into object URL +- Generate thumbnail and will use a Web Worker and an offscreen canvas if supported If you are using an `ArrayBuffer`, you can use `FileReader` to convert it into a blob before calling [`URL.createObjectURL`](https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL). @@ -1421,11 +1454,11 @@ useUIState(): ['blueprint' | 'disabled' | undefined] This hook will return whether the UI should be rendered in blueprint mode, as disabled, or normally. This can be set via the `uiState` props. -- `"blueprint"` will render as few UI elements as possible and should be non-functional - - Useful for loading scenarios -- `"disabled"` will render most UI elements as non-functional - - Scrolling may continue to trigger read acknowledgements -- `undefined` will render normally +- `"blueprint"` will render as few UI elements as possible and should be non-functional + - Useful for loading scenarios +- `"disabled"` will render most UI elements as non-functional + - Scrolling may continue to trigger read acknowledgements +- `undefined` will render normally Note: `uiState` props that precedence over the deprecated `disabled` props. @@ -1490,8 +1523,8 @@ These are hooks specific provide specific user experience. These are hooks that are specific for the microphone button. -- [`useMicrophoneButtonClick`](#usemicrophonebuttonclick) -- [`useMicrophoneButtonDisabled`](#usemicrophonebuttondisabled) +- [`useMicrophoneButtonClick`](#usemicrophonebuttonclick) +- [`useMicrophoneButtonDisabled`](#usemicrophonebuttondisabled) ### `useMicrophoneButtonClick` @@ -1519,7 +1552,7 @@ This value can be partly controllable through Web Chat props. These are hooks that are specific for the send box. -- [`useSendBoxSpeechInterimsVisible`](#usesendboxspeechinterimsvisible) +- [`useSendBoxSpeechInterimsVisible`](#usesendboxspeechinterimsvisible) ### `useSendBoxSpeechInterimsVisible` @@ -1535,8 +1568,8 @@ This hook will return whether the send box should show speech interims. These are hooks that are specific to the text box in the send box. -- [`useTextBoxSubmit`](#usetextboxsubmit) -- [`useTextBoxValue`](#usetextboxvalue) +- [`useTextBoxSubmit`](#usetextboxsubmit) +- [`useTextBoxValue`](#usetextboxvalue) ### `useTextBoxSubmit` @@ -1569,7 +1602,7 @@ The setter function will call the setter of [`useSendBoxValue`](#usesendboxvalue These are hooks that are specific to the typing indicator. -- [`useTypingIndicatorVisible`](#usetypingindicatorvisible) +- [`useTypingIndicatorVisible`](#usetypingindicatorvisible) ### `useTypingIndicatorVisible` @@ -1668,10 +1701,10 @@ Multiple activities could share the same activity key if they are revision of ea Following hooks are designed to help navigating between activity, activity ID and activity keys: -- [`useGetActivitiesByKey`](#usegetactivitiesbykey) -- [`useGetActivityByKey`](#usegetactivitybykey) -- [`useGetKeyByActivity`](#usegetkeybyactivity) -- [`useGetKeyByActivityId`](#usegetkeybyactivityid) +- [`useGetActivitiesByKey`](#usegetactivitiesbykey) +- [`useGetActivityByKey`](#usegetactivitybykey) +- [`useGetKeyByActivity`](#usegetkeybyactivity) +- [`useGetKeyByActivityId`](#usegetkeybyactivityid) ## What is acknowledged activity? diff --git a/package-lock.json b/package-lock.json index 515579dbca..739984f787 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19076,6 +19076,31 @@ "react-dom": ">=16.6.0" } }, + "node_modules/react-wrap-with": { + "version": "0.2.0-main.62328fb", + "resolved": "https://registry.npmjs.org/react-wrap-with/-/react-wrap-with-0.2.0-main.62328fb.tgz", + "integrity": "sha512-9AeIj7sI6XTnZL4837gzVJ2hOz0tVDDwSnqMtPBzQpUOtbpJlBs7Gn1TedtDOZIZgzOUwNc6toQTktgWYGJvBQ==", + "license": "MIT", + "dependencies": { + "react-wrap-with": "^0.2.0-main.62328fb", + "type-fest": "^4.26.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/react-wrap-with/node_modules/type-fest": { + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.40.1.tgz", + "integrity": "sha512-9YvLNnORDpI+vghLU/Nf+zSv0kL47KbVJ1o3sKgoTefl6i+zebxbiDQWoe/oWWqPhIgQdRZRT1KA9sCPL810SA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/read-pkg": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz", @@ -24699,6 +24724,7 @@ "react-redux": "7.2.9", "react-say": "2.1.0", "react-scroll-to-bottom": "4.2.1-main.53844f5", + "react-wrap-with": "^0.2.0-main.62328fb", "redux": "5.0.1", "simple-update-in": "2.2.0", "use-propagate": "0.2.1", diff --git a/packages/api/src/StyleOptions.ts b/packages/api/src/StyleOptions.ts index b55d1f91ce..8fee5fd7b0 100644 --- a/packages/api/src/StyleOptions.ts +++ b/packages/api/src/StyleOptions.ts @@ -952,6 +952,15 @@ type StyleOptions = { * @see https://github.com/microsoft/BotFramework-WebChat/pull/5426 */ speechRecognitionContinuous?: boolean | undefined; + + /** + * Defines how activities are being grouped by (in the order of appearance in the array). Default to `['sender', 'status']` or `sender,status` in CSS. + * + * Values are key of result of `groupActivitiesMiddleware`. The default implementation of `groupActivitiesMiddleware` has `sender` and `status`. + * + * To add new groupings, configure `groupActivitiesMiddleware` to output extra groups. Then, add the group names to `styleOptions.groupActivitiesBy`. + */ + groupActivitiesBy?: readonly string[] | undefined; }; // StrictStyleOptions is only used internally in Web Chat and for simplifying our code: diff --git a/packages/api/src/decorator.ts b/packages/api/src/decorator.ts new file mode 100644 index 0000000000..27e0307f02 --- /dev/null +++ b/packages/api/src/decorator.ts @@ -0,0 +1,16 @@ +// Decorator general + +export { default as DecoratorComposer } from './decorator/DecoratorComposer'; +export { + type DecoratorMiddleware, + type DecoratorMiddlewareInit, + type DecoratorMiddlewareTypes +} from './decorator/types'; + +// ActivityBorderDecorator + +export { default as ActivityBorderDecorator } from './decorator/ActivityBorder/ActivityBorderDecorator'; + +// ActivityGroupingDecorator + +export { default as ActivityGroupingDecorator } from './decorator/ActivityGrouping/ActivityGroupingDecorator'; diff --git a/packages/api/src/decorator/ActivityBorder/ActivityBorderDecorator.tsx b/packages/api/src/decorator/ActivityBorder/ActivityBorderDecorator.tsx new file mode 100644 index 0000000000..b141279e14 --- /dev/null +++ b/packages/api/src/decorator/ActivityBorder/ActivityBorderDecorator.tsx @@ -0,0 +1,48 @@ +import { getActivityLivestreamingMetadata, type WebChatActivity } from 'botframework-webchat-core'; +import React, { memo, useMemo, type ReactNode } from 'react'; +import PassthroughFallback from '../private/PassthroughFallback'; +import { + ActivityBorderDecoratorMiddlewareProxy, + type ActivityBorderDecoratorMiddlewareRequest +} from './private/ActivityBorderDecoratorMiddleware'; + +const supportedActivityRoles: ActivityBorderDecoratorMiddlewareRequest['from'][] = [ + 'bot', + 'channel', + 'user', + undefined +]; + +type ActivityBorderDecoratorProps = Readonly<{ + activity?: WebChatActivity | undefined; + children?: ReactNode | undefined; +}>; + +function ActivityBorderDecorator({ activity, children }: ActivityBorderDecoratorProps) { + const request = useMemo(() => { + const { type } = getActivityLivestreamingMetadata(activity) || {}; + + return { + livestreamingState: + type === 'final activity' + ? 'completing' + : type === 'informative message' + ? 'preparing' + : type === 'interim activity' + ? 'ongoing' + : type === 'contentless' + ? undefined // No bubble is shown for "contentless" livestream, should not decorate. + : undefined, + from: supportedActivityRoles.includes(activity?.from?.role) ? activity?.from?.role : undefined + }; + }, [activity]); + + return ( + + {children} + + ); +} + +export default memo(ActivityBorderDecorator); +export { type ActivityBorderDecoratorProps }; diff --git a/packages/api/src/decorator/ActivityBorder/private/ActivityBorderDecoratorMiddleware.ts b/packages/api/src/decorator/ActivityBorder/private/ActivityBorderDecoratorMiddleware.ts new file mode 100644 index 0000000000..8d4620c674 --- /dev/null +++ b/packages/api/src/decorator/ActivityBorder/private/ActivityBorderDecoratorMiddleware.ts @@ -0,0 +1,51 @@ +import type { EmptyObject } from 'type-fest'; +import templateMiddleware from '../../private/templateMiddleware'; +import { type activityBorderDecoratorTypeName } from '../types'; + +type Request = Readonly<{ + /** + * Decorate the activity as it participate in a livestreaming session. + * + * - `"completing"` - decorate as the livestreaming is completing + * - `"ongoing"` - decorate as the livestreaming is ongoing + * - `"preparing"` - decorate as the livestreaming is being prepared + * - `undefined` - not participated in a livestreaming session + */ + livestreamingState: 'completing' | 'ongoing' | 'preparing' | undefined; + + /** + * Gets the role of the sender for the activity. + * + * - `"bot"` - the sender is a bot or other users + * - `"channel"` - the sender is the channel service + * - `"user"` - the sender is the current user + * - `undefined` - the sender is unknown + */ + from: 'bot' | 'channel' | `user` | undefined; +}>; + +type Props = EmptyObject; + +const { + initMiddleware: initActivityBorderDecoratorMiddleware, + Provider: ActivityBorderDecoratorMiddlewareProvider, + Proxy: ActivityBorderDecoratorMiddlewareProxy, + // False positive, `types` is used for its typing. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + types +} = templateMiddleware('ActivityBorderDecoratorMiddleware'); + +type ActivityBorderDecoratorMiddleware = typeof types.middleware; +type ActivityBorderDecoratorMiddlewareInit = typeof types.init; +type ActivityBorderDecoratorMiddlewareProps = typeof types.props; +type ActivityBorderDecoratorMiddlewareRequest = typeof types.request; + +export { + ActivityBorderDecoratorMiddlewareProvider, + ActivityBorderDecoratorMiddlewareProxy, + initActivityBorderDecoratorMiddleware, + type ActivityBorderDecoratorMiddleware, + type ActivityBorderDecoratorMiddlewareInit, + type ActivityBorderDecoratorMiddlewareProps, + type ActivityBorderDecoratorMiddlewareRequest +}; diff --git a/packages/api/src/decorator/ActivityBorder/types.ts b/packages/api/src/decorator/ActivityBorder/types.ts new file mode 100644 index 0000000000..62e754ed24 --- /dev/null +++ b/packages/api/src/decorator/ActivityBorder/types.ts @@ -0,0 +1 @@ +export const activityBorderDecoratorTypeName = 'activity border' as const; diff --git a/packages/api/src/decorator/ActivityGrouping/ActivityGroupingDecorator.tsx b/packages/api/src/decorator/ActivityGrouping/ActivityGroupingDecorator.tsx new file mode 100644 index 0000000000..3d14af2772 --- /dev/null +++ b/packages/api/src/decorator/ActivityGrouping/ActivityGroupingDecorator.tsx @@ -0,0 +1,31 @@ +import { type WebChatActivity } from 'botframework-webchat-core'; +import React, { memo, useMemo, type ReactNode } from 'react'; + +import PassthroughFallback from '../private/PassthroughFallback'; +import { + ActivityGroupingDecoratorMiddlewareProxy, + type ActivityGroupingDecoratorMiddlewareRequest +} from './private/ActivityGroupingDecoratorMiddleware'; + +type ActivityGroupingDecoratorProps = Readonly<{ + activities: readonly WebChatActivity[]; + children?: ReactNode | undefined; + groupingName: string; +}>; + +function ActivityGroupingDecorator({ activities, children, groupingName }: ActivityGroupingDecoratorProps) { + const request = useMemo(() => ({ groupingName }), [groupingName]); + + return ( + + {children} + + ); +} + +export default memo(ActivityGroupingDecorator); +export { type ActivityGroupingDecoratorProps }; diff --git a/packages/api/src/decorator/ActivityGrouping/private/ActivityGroupingDecoratorMiddleware.ts b/packages/api/src/decorator/ActivityGrouping/private/ActivityGroupingDecoratorMiddleware.ts new file mode 100644 index 0000000000..e8be671482 --- /dev/null +++ b/packages/api/src/decorator/ActivityGrouping/private/ActivityGroupingDecoratorMiddleware.ts @@ -0,0 +1,38 @@ +import { type WebChatActivity } from 'botframework-webchat-core'; +import templateMiddleware from '../../private/templateMiddleware'; +import { type activityGroupingDecoratorTypeName } from '../types'; + +type Request = Readonly<{ + /** + * Name of the grouping from the result of `groupActivitesMiddleware()`. + */ + groupingName: string; +}>; + +type Props = Readonly<{ + activities: readonly WebChatActivity[]; +}>; + +const { + initMiddleware: initActivityGroupingDecoratorMiddleware, + Provider: ActivityGroupingDecoratorMiddlewareProvider, + Proxy: ActivityGroupingDecoratorMiddlewareProxy, + // False positive, `types` is used for its typing. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + types +} = templateMiddleware('ActivityGroupingDecoratorMiddleware'); + +type ActivityGroupingDecoratorMiddleware = typeof types.middleware; +type ActivityGroupingDecoratorMiddlewareInit = typeof types.init; +type ActivityGroupingDecoratorMiddlewareProps = typeof types.props; +type ActivityGroupingDecoratorMiddlewareRequest = typeof types.request; + +export { + ActivityGroupingDecoratorMiddlewareProvider, + ActivityGroupingDecoratorMiddlewareProxy, + initActivityGroupingDecoratorMiddleware, + type ActivityGroupingDecoratorMiddleware, + type ActivityGroupingDecoratorMiddlewareInit, + type ActivityGroupingDecoratorMiddlewareProps, + type ActivityGroupingDecoratorMiddlewareRequest +}; diff --git a/packages/api/src/decorator/ActivityGrouping/types.ts b/packages/api/src/decorator/ActivityGrouping/types.ts new file mode 100644 index 0000000000..59dbd0b014 --- /dev/null +++ b/packages/api/src/decorator/ActivityGrouping/types.ts @@ -0,0 +1 @@ +export const activityGroupingDecoratorTypeName = 'activity grouping' as const; diff --git a/packages/api/src/decorator/DecoratorComposer.tsx b/packages/api/src/decorator/DecoratorComposer.tsx new file mode 100644 index 0000000000..02b0cc1bdb --- /dev/null +++ b/packages/api/src/decorator/DecoratorComposer.tsx @@ -0,0 +1,22 @@ +import React, { Fragment, memo, type ReactNode } from 'react'; +import InternalDecoratorComposer from './internal/InternalDecoratorComposer'; +import { type DecoratorMiddleware } from './types'; + +type DecoratorComposerProps = Readonly<{ + children?: ReactNode | undefined; + middleware?: readonly DecoratorMiddleware[] | undefined; +}>; + +function DecoratorComposer({ children, middleware }: DecoratorComposerProps) { + return middleware ? ( + + {children} + + ) : ( + // We can't return `children` unless we are not using memo(). + {children} + ); +} + +export default memo(DecoratorComposer); +export { type DecoratorComposerProps }; diff --git a/packages/api/src/decorator/index.ts b/packages/api/src/decorator/index.ts deleted file mode 100644 index fbe3f8230b..0000000000 --- a/packages/api/src/decorator/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { DecoratorComposer } from './private/DecoratorComposer'; -export { default as ActivityDecorator } from './private/ActivityDecorator'; -export { type DecoratorMiddleware } from './private/createDecoratorComposer'; -export { default as ActivityDecoratorRequest } from './private/activityDecoratorRequest'; diff --git a/packages/api/src/decorator/internal/InternalDecoratorComposer.tsx b/packages/api/src/decorator/internal/InternalDecoratorComposer.tsx new file mode 100644 index 0000000000..827d432eba --- /dev/null +++ b/packages/api/src/decorator/internal/InternalDecoratorComposer.tsx @@ -0,0 +1,61 @@ +import React, { memo, useContext, useMemo, type ReactNode } from 'react'; +import { + ActivityBorderDecoratorMiddlewareProvider, + initActivityBorderDecoratorMiddleware +} from '../ActivityBorder/private/ActivityBorderDecoratorMiddleware'; +import { activityBorderDecoratorTypeName } from '../ActivityBorder/types'; +import { + ActivityGroupingDecoratorMiddlewareProvider, + initActivityGroupingDecoratorMiddleware +} from '../ActivityGrouping/private/ActivityGroupingDecoratorMiddleware'; +import { activityGroupingDecoratorTypeName } from '../ActivityGrouping/types'; +import DecoratorComposerContext from '../private/DecoratorComposerContext'; +import { type DecoratorMiddleware } from '../types'; + +type InternalDecoratorComposerProps = Readonly<{ + children?: ReactNode | undefined; + middleware: readonly DecoratorMiddleware[]; + priority: 'low' | 'normal'; +}>; + +const EMPTY_ARRAY = Object.freeze([]); + +function InternalDecoratorComposer({ + children, + middleware: middlewareFromProps = EMPTY_ARRAY, + priority +}: InternalDecoratorComposerProps) { + const existingContext = useContext(DecoratorComposerContext); + const middleware = useMemo( + () => + priority === 'low' + ? Object.freeze([...existingContext.middleware, ...middlewareFromProps]) + : Object.freeze([...middlewareFromProps, ...existingContext.middleware]), + [existingContext, middlewareFromProps, priority] + ); + + const activityBorderMiddleware = useMemo( + () => initActivityBorderDecoratorMiddleware(middleware, activityBorderDecoratorTypeName), + [middleware] + ); + + const activityGroupingMiddleware = useMemo( + () => initActivityGroupingDecoratorMiddleware(middleware, activityGroupingDecoratorTypeName), + [middleware] + ); + + const context = useMemo(() => ({ middleware }), [middleware]); + + return ( + + + + {children} + + + + ); +} + +export default memo(InternalDecoratorComposer); +export { type InternalDecoratorComposerProps }; diff --git a/packages/api/src/decorator/internal/LowPriorityDecoratorComposer.tsx b/packages/api/src/decorator/internal/LowPriorityDecoratorComposer.tsx new file mode 100644 index 0000000000..83fd0a7528 --- /dev/null +++ b/packages/api/src/decorator/internal/LowPriorityDecoratorComposer.tsx @@ -0,0 +1,19 @@ +import React, { memo, type ReactNode } from 'react'; +import { type DecoratorMiddleware } from '../types'; +import InternalDecoratorComposer from './InternalDecoratorComposer'; + +type LowPriorityDecoratorComposerProps = Readonly<{ + children?: ReactNode | undefined; + middleware: readonly DecoratorMiddleware[]; +}>; + +function LowPriorityDecoratorDecomposer({ children, middleware }: LowPriorityDecoratorComposerProps) { + return ( + + {children} + + ); +} + +export default memo(LowPriorityDecoratorDecomposer); +export { type LowPriorityDecoratorComposerProps }; diff --git a/packages/api/src/decorator/private/ActivityBorderDecoratorMiddleware.ts b/packages/api/src/decorator/private/ActivityBorderDecoratorMiddleware.ts deleted file mode 100644 index c5b61dc19c..0000000000 --- a/packages/api/src/decorator/private/ActivityBorderDecoratorMiddleware.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { type EmptyObject } from 'type-fest'; - -import ActivityDecoratorRequest from './activityDecoratorRequest'; -import templateMiddleware from './templateMiddleware'; - -const { - initMiddleware: initActivityBorderDecoratorMiddleware, - Provider: ActivityBorderDecoratorMiddlewareProvider, - Proxy: ActivityBorderDecoratorMiddlewareProxy, - // False positive, `types` is used for its typing. - // eslint-disable-next-line @typescript-eslint/no-unused-vars - types -} = templateMiddleware( - 'ActivityBorderDecoratorMiddleware' -); - -type ActivityBorderDecoratorMiddleware = typeof types.middleware; -type ActivityBorderDecoratorMiddlewareInit = typeof types.init; -type ActivityBorderDecoratorMiddlewareProps = typeof types.props; -type ActivityBorderDecoratorMiddlewareRequest = typeof types.request; - -const activityBorderDecoratorTypeName = 'activity border' as const; - -export { - ActivityBorderDecoratorMiddlewareProvider, - ActivityBorderDecoratorMiddlewareProxy, - activityBorderDecoratorTypeName, - initActivityBorderDecoratorMiddleware, - type ActivityBorderDecoratorMiddleware, - type ActivityBorderDecoratorMiddlewareInit, - type ActivityBorderDecoratorMiddlewareProps, - type ActivityBorderDecoratorMiddlewareRequest -}; diff --git a/packages/api/src/decorator/private/ActivityDecorator.tsx b/packages/api/src/decorator/private/ActivityDecorator.tsx deleted file mode 100644 index ec28cf77f9..0000000000 --- a/packages/api/src/decorator/private/ActivityDecorator.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { getActivityLivestreamingMetadata, type WebChatActivity } from 'botframework-webchat-core'; -import React, { Fragment, memo, useMemo, type ReactNode } from 'react'; -import { ActivityDecoratorRequest } from '..'; -import { ActivityBorderDecoratorMiddlewareProxy } from './ActivityBorderDecoratorMiddleware'; - -const ActivityDecoratorFallback = memo(({ children }) => {children}); - -ActivityDecoratorFallback.displayName = 'ActivityDecoratorFallback'; - -const supportedActivityRoles: ActivityDecoratorRequest['from'][] = ['bot', 'channel', 'user', undefined]; - -function ActivityDecorator({ activity, children }: Readonly<{ activity?: WebChatActivity; children?: ReactNode }>) { - const request = useMemo(() => { - const { type } = getActivityLivestreamingMetadata(activity) || {}; - - return { - livestreamingState: - type === 'final activity' - ? 'completing' - : type === 'informative message' - ? 'preparing' - : type === 'interim activity' - ? 'ongoing' - : type === 'contentless' - ? undefined // No bubble is shown for "contentless" livestream, should not decorate. - : undefined, - from: supportedActivityRoles.includes(activity?.from?.role) ? activity?.from?.role : undefined - }; - }, [activity]); - - return ( - - {children} - - ); -} - -export default memo(ActivityDecorator); diff --git a/packages/api/src/decorator/private/DecoratorComposer.tsx b/packages/api/src/decorator/private/DecoratorComposer.tsx deleted file mode 100644 index e767e69fee..0000000000 --- a/packages/api/src/decorator/private/DecoratorComposer.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { memo } from 'react'; -import createDecoratorComposer from './createDecoratorComposer'; - -export const DecoratorComposer = memo(createDecoratorComposer()); - -DecoratorComposer.displayName = 'DecoratorComposer'; diff --git a/packages/api/src/decorator/private/DecoratorComposerContext.ts b/packages/api/src/decorator/private/DecoratorComposerContext.ts new file mode 100644 index 0000000000..1774acd804 --- /dev/null +++ b/packages/api/src/decorator/private/DecoratorComposerContext.ts @@ -0,0 +1,16 @@ +import { createContext } from 'react'; + +import { type DecoratorMiddleware } from '../types'; + +type DecoratorComposerContextType = Readonly<{ + middleware: readonly DecoratorMiddleware[]; +}>; + +const DecoratorComposerContext = createContext( + Object.freeze({ + middleware: Object.freeze([]) + }) +); + +export default DecoratorComposerContext; +export { type DecoratorComposerContextType }; diff --git a/packages/api/src/decorator/private/PassthroughFallback.tsx b/packages/api/src/decorator/private/PassthroughFallback.tsx new file mode 100644 index 0000000000..ab77133134 --- /dev/null +++ b/packages/api/src/decorator/private/PassthroughFallback.tsx @@ -0,0 +1,12 @@ +import React, { Fragment, memo, type ReactNode } from 'react'; + +type PassthroughFallbackProps = Readonly<{ + children?: ReactNode | undefined; +}>; + +function PassthroughFallback({ children }: PassthroughFallbackProps) { + return {children}; +} + +export default memo(PassthroughFallback); +export { type PassthroughFallbackProps }; diff --git a/packages/api/src/decorator/private/activityDecoratorRequest.ts b/packages/api/src/decorator/private/activityDecoratorRequest.ts deleted file mode 100644 index eb90bddd11..0000000000 --- a/packages/api/src/decorator/private/activityDecoratorRequest.ts +++ /dev/null @@ -1,23 +0,0 @@ -type ActivityDecoratorRequestType = { - /** - * Decorate the activity as it participate in a livestreaming session. - * - * - `"completing"` - decorate as the livestreaming is completing - * - `"ongoing"` - decorate as the livestreaming is ongoing - * - `"preparing"` - decorate as the livestreaming is being prepared - * - `undefined` - not participated in a livestreaming session - */ - livestreamingState: 'completing' | 'ongoing' | 'preparing' | undefined; - - /** - * Gets the role of the sender for the activity. - * - * - `"bot"` - the sender is a bot or other users - * - `"channel"` - the sender is the channel service - * - `"user"` - the sender is the current user - * - `undefined` - the sender is unknown - */ - from: 'bot' | 'channel' | `user` | undefined; -}; - -export default ActivityDecoratorRequestType; diff --git a/packages/api/src/decorator/private/createDecoratorComposer.tsx b/packages/api/src/decorator/private/createDecoratorComposer.tsx deleted file mode 100644 index 19fa55ad27..0000000000 --- a/packages/api/src/decorator/private/createDecoratorComposer.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import React, { useMemo, type ReactNode } from 'react'; -import { - ActivityBorderDecoratorMiddlewareProvider, - activityBorderDecoratorTypeName, - initActivityBorderDecoratorMiddleware, - type ActivityBorderDecoratorMiddleware -} from './ActivityBorderDecoratorMiddleware'; - -type DecoratorMiddlewareInit = typeof activityBorderDecoratorTypeName; - -export type DecoratorComposerComponent = ( - props: Readonly<{ - children?: ReactNode | undefined; - middleware?: readonly DecoratorMiddleware[] | undefined; - }> -) => React.JSX.Element; - -export type DecoratorMiddleware = ( - init: DecoratorMiddlewareInit -) => ReturnType | false; - -const EMPTY_ARRAY = []; - -export default (): DecoratorComposerComponent => - ({ children, middleware = EMPTY_ARRAY }) => { - const borderMiddlewares = useMemo( - () => initActivityBorderDecoratorMiddleware(middleware, activityBorderDecoratorTypeName), - [middleware] - ); - - return ( - - {children} - - ); - }; diff --git a/packages/api/src/decorator/private/templateMiddleware.ts b/packages/api/src/decorator/private/templateMiddleware.ts index d039cbf30b..0d345490b9 100644 --- a/packages/api/src/decorator/private/templateMiddleware.ts +++ b/packages/api/src/decorator/private/templateMiddleware.ts @@ -9,7 +9,9 @@ const EMPTY_ARRAY = Object.freeze([]); // Following @types/react to use {} for props. // eslint-disable-next-line @typescript-eslint/no-empty-object-type -export default function templateMiddleware(name: string) { +export default function templateMiddleware( + name: string +) { type Middleware = ComponentMiddleware; const middlewareSchema = array(pipe(any(), function_())); @@ -20,14 +22,14 @@ export default function templateMiddleware[], - init?: Init + middleware: readonly MiddlewareWithInit, Init>[], + init: Init ): readonly Middleware[] => { if (middleware) { if (isMiddleware(middleware)) { return Object.freeze( middleware - ?.map(middleware => middleware(init)) + .map(middleware => middleware(init) as ReturnType) .filter((enhancer): enhancer is ReturnType => !!enhancer) .map(enhancer => () => enhancer) ); diff --git a/packages/api/src/decorator/types.ts b/packages/api/src/decorator/types.ts new file mode 100644 index 0000000000..f326d5ff17 --- /dev/null +++ b/packages/api/src/decorator/types.ts @@ -0,0 +1,15 @@ +import { type ActivityBorderDecoratorMiddleware } from './ActivityBorder/private/ActivityBorderDecoratorMiddleware'; +import { type activityBorderDecoratorTypeName } from './ActivityBorder/types'; +import { type ActivityGroupingDecoratorMiddleware } from './ActivityGrouping/private/ActivityGroupingDecoratorMiddleware'; +import { type activityGroupingDecoratorTypeName } from './ActivityGrouping/types'; + +export type DecoratorMiddlewareTypes = { + [activityBorderDecoratorTypeName]: ReturnType; + [activityGroupingDecoratorTypeName]: ReturnType; +}; + +export type DecoratorMiddlewareInit = keyof DecoratorMiddlewareTypes; + +export interface DecoratorMiddleware { + (init: keyof DecoratorMiddlewareTypes): DecoratorMiddlewareTypes[typeof init] | false; +} diff --git a/packages/api/src/defaultStyleOptions.ts b/packages/api/src/defaultStyleOptions.ts index cda952a509..5303f9d583 100644 --- a/packages/api/src/defaultStyleOptions.ts +++ b/packages/api/src/defaultStyleOptions.ts @@ -309,7 +309,9 @@ const DEFAULT_OPTIONS: Required = { feedbackActionsPlacement: 'activity-status' as const, // Speech recognition - speechRecognitionContinuous: false + speechRecognitionContinuous: false, + + groupActivitiesBy: ['sender', 'status'] }; export default DEFAULT_OPTIONS; diff --git a/packages/api/src/hooks/Composer.tsx b/packages/api/src/hooks/Composer.tsx index b4671ec3dc..929a1b2355 100644 --- a/packages/api/src/hooks/Composer.tsx +++ b/packages/api/src/hooks/Composer.tsx @@ -77,7 +77,6 @@ import applyMiddleware, { forRenderer as applyMiddlewareForRenderer } from './middleware/applyMiddleware'; import createDefaultCardActionMiddleware from './middleware/createDefaultCardActionMiddleware'; -import createDefaultGroupActivitiesMiddleware from './middleware/createDefaultGroupActivitiesMiddleware'; import useMarkAllAsAcknowledged from './useMarkAllAsAcknowledged'; import ErrorBoundary from './utils/ErrorBoundary'; import observableToPromise from './utils/observableToPromise'; @@ -85,6 +84,7 @@ import observableToPromise from './utils/observableToPromise'; // PrecompileGlobalize is a generated file and is not ES module. TypeScript don't work with UMD. // @ts-ignore import PrecompiledGlobalize from '../external/PrecompiledGlobalize'; +import GroupActivitiesComposer from '../providers/GroupActivities/GroupActivitiesComposer'; import { parseUIState } from './validation/uiState'; // List of Redux actions factory we are hoisting as Web Chat functions @@ -173,26 +173,6 @@ function createCardActionContext({ }; } -function createGroupActivitiesContext({ - groupActivitiesMiddleware, - groupTimestamp, - ponyfill -}: { - groupActivitiesMiddleware: readonly GroupActivitiesMiddleware[]; - groupTimestamp: boolean | number; - ponyfill: GlobalScopePonyfill; -}) { - const runMiddleware = applyMiddleware( - 'group activities', - ...groupActivitiesMiddleware, - createDefaultGroupActivitiesMiddleware({ groupTimestamp, ponyfill }) - ); - - return { - groupActivities: runMiddleware({}) - }; -} - function mergeStringsOverrides(localizedStrings, language, overrideLocalizedStrings) { if (!overrideLocalizedStrings) { return localizedStrings; @@ -368,16 +348,6 @@ const ComposerCore = ({ [locale, selectVoice] ); - const groupActivitiesContext = useMemo( - () => - createGroupActivitiesContext({ - groupActivitiesMiddleware: Object.freeze([...singleToArray(groupActivitiesMiddleware)]), - groupTimestamp: patchedStyleOptions.groupTimestamp, - ponyfill - }), - [groupActivitiesMiddleware, patchedStyleOptions.groupTimestamp, ponyfill] - ); - const hoistedDispatchers = useMemo( () => mapMap( @@ -561,7 +531,6 @@ const ComposerCore = ({ const context = useMemo>>( () => ({ ...cardActionContext, - ...groupActivitiesContext, ...hoistedDispatchers, activityRenderer: patchedActivityRenderer, activityStatusRenderer: patchedActivityStatusRenderer, @@ -594,7 +563,6 @@ const ComposerCore = ({ cardActionContext, directLine, downscaleImageToDataURL, - groupActivitiesContext, hoistedDispatchers, internalErrorBoxClass, locale, @@ -630,7 +598,9 @@ const ComposerCore = ({ - {typeof children === 'function' ? children(context) : children} + + {typeof children === 'function' ? children(context) : children} + diff --git a/packages/api/src/hooks/index.ts b/packages/api/src/hooks/index.ts index 2768ef5b4b..b3bf3ba5c0 100644 --- a/packages/api/src/hooks/index.ts +++ b/packages/api/src/hooks/index.ts @@ -26,7 +26,8 @@ import useGetKeyByActivity from './useGetKeyByActivity'; import useGetKeyByActivityId from './useGetKeyByActivityId'; import useGetSendTimeoutForActivity from './useGetSendTimeoutForActivity'; import useGrammars from './useGrammars'; -import useGroupActivities from './useGroupActivities'; +import useGroupActivities from '../providers/GroupActivities/useGroupActivities'; +import useGroupActivitiesByName from '../providers/GroupActivities/useGroupActivitiesByName'; import useGroupTimestamp from './useGroupTimestamp'; import useLanguage from './useLanguage'; import useLastAcknowledgedActivityKey from './useLastAcknowledgedActivityKey'; @@ -101,6 +102,7 @@ export { useGetSendTimeoutForActivity, useGrammars, useGroupActivities, + useGroupActivitiesByName, useGroupTimestamp, useLanguage, useLastAcknowledgedActivityKey, diff --git a/packages/api/src/hooks/internal/SendBoxMiddleware.ts b/packages/api/src/hooks/internal/SendBoxMiddleware.ts index 952122f01a..8ae65f8576 100644 --- a/packages/api/src/hooks/internal/SendBoxMiddleware.ts +++ b/packages/api/src/hooks/internal/SendBoxMiddleware.ts @@ -7,7 +7,7 @@ const { // False positive, `types` is used for its typing. // eslint-disable-next-line @typescript-eslint/no-unused-vars types -} = templateMiddleware('sendBoxMiddleware'); +} = templateMiddleware('sendBoxMiddleware'); type SendBoxMiddleware = typeof types.middleware; type SendBoxMiddlewareProps = typeof types.props; diff --git a/packages/api/src/hooks/internal/SendBoxToolbarMiddleware.ts b/packages/api/src/hooks/internal/SendBoxToolbarMiddleware.ts index 022e521d03..bd2819dc09 100644 --- a/packages/api/src/hooks/internal/SendBoxToolbarMiddleware.ts +++ b/packages/api/src/hooks/internal/SendBoxToolbarMiddleware.ts @@ -7,7 +7,7 @@ const { // False positive, `types` is used for its typing. // eslint-disable-next-line @typescript-eslint/no-unused-vars types -} = templateMiddleware('sendBoxToolbarMiddleware'); +} = templateMiddleware('sendBoxToolbarMiddleware'); type SendBoxToolbarMiddleware = typeof types.middleware; type SendBoxToolbarMiddlewareProps = typeof types.props; diff --git a/packages/api/src/hooks/middleware/concatMiddleware.spec.js b/packages/api/src/hooks/middleware/concatMiddleware.spec.js index 7cb35bfa3d..58de2d9387 100644 --- a/packages/api/src/hooks/middleware/concatMiddleware.spec.js +++ b/packages/api/src/hooks/middleware/concatMiddleware.spec.js @@ -53,3 +53,23 @@ test('one middleware ran twice by a single upstream middleware', () => { expect(work(1)).toEqual(210); }); + +test('a middleware return undefined after setup should be skipped', () => { + const enhancer = concatMiddleware( + () => next => value => next(value + 2), + () => undefined, + () => next => value => next(value * 3) + ); + + expect(enhancer()(value => value)(5)).toEqual(21); // (5 + 2) * 3 +}); + +test('an undefined middleware should be skipped', () => { + const enhancer = concatMiddleware( + () => next => value => next(value + 2), + undefined, + () => next => value => next(value * 3) + ); + + expect(enhancer()(value => value)(5)).toEqual(21); // (5 + 2) * 3 +}); diff --git a/packages/api/src/hooks/middleware/concatMiddleware.ts b/packages/api/src/hooks/middleware/concatMiddleware.ts index 620d3927ec..237f50d8b4 100644 --- a/packages/api/src/hooks/middleware/concatMiddleware.ts +++ b/packages/api/src/hooks/middleware/concatMiddleware.ts @@ -6,10 +6,11 @@ export default function concatMiddleware( ...middleware: Middleware[] ): Middleware { return setupArgs => { - const setup = middleware.reduce( - (setup, middleware) => (middleware ? [...setup, middleware(setupArgs)] : setup), - [] - ); + const setup = middleware.reduce((setup, middleware) => { + const enhancer = middleware?.(setupArgs); + + return enhancer ? [...setup, enhancer] : setup; + }, []); return last => { const stack = setup.slice(); diff --git a/packages/api/src/hooks/useGroupActivities.ts b/packages/api/src/hooks/useGroupActivities.ts deleted file mode 100644 index 5d47c639f2..0000000000 --- a/packages/api/src/hooks/useGroupActivities.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { WebChatActivity } from 'botframework-webchat-core'; - -import useWebChatAPIContext from './internal/useWebChatAPIContext'; - -export default function useGroupActivities(): ({ activities }: { activities: WebChatActivity[] }) => { - sender: WebChatActivity[][]; - status: WebChatActivity[][]; -} { - return useWebChatAPIContext().groupActivities; -} diff --git a/packages/api/src/internal.ts b/packages/api/src/internal.ts index 296f8dc199..f26f23fe29 100644 --- a/packages/api/src/internal.ts +++ b/packages/api/src/internal.ts @@ -1,3 +1,4 @@ +import LowPriorityDecoratorComposer from './decorator/internal/LowPriorityDecoratorComposer'; import useSetDictateState from './hooks/internal/useSetDictateState'; -export { useSetDictateState }; +export { LowPriorityDecoratorComposer, useSetDictateState }; diff --git a/packages/api/src/providers/GroupActivities/GroupActivitiesComposer.tsx b/packages/api/src/providers/GroupActivities/GroupActivitiesComposer.tsx new file mode 100644 index 0000000000..16d843eb46 --- /dev/null +++ b/packages/api/src/providers/GroupActivities/GroupActivitiesComposer.tsx @@ -0,0 +1,115 @@ +import { type WebChatActivity } from 'botframework-webchat-core'; +import React, { memo, useCallback, useMemo, type ReactNode } from 'react'; +import { useRefFrom } from 'use-ref-from'; + +import applyMiddleware from '../../hooks/middleware/applyMiddleware'; +import useStyleOptions from '../../hooks/useStyleOptions'; +import type GroupActivitiesMiddleware from '../../types/GroupActivitiesMiddleware'; +import { type GroupActivities } from '../../types/GroupActivitiesMiddleware'; +import usePonyfill from '../Ponyfill/usePonyfill'; +import createDefaultGroupActivitiesMiddleware from './private/createDefaultGroupActivitiesMiddleware'; +import GroupActivitiesContext, { type GroupActivitiesContextType } from './private/GroupActivitiesContext'; +import isGroupingValid from './private/isGroupingValid'; + +type GroupActivitiesComposerProps = Readonly<{ + children?: ReactNode | undefined; + groupActivitiesMiddleware: readonly GroupActivitiesMiddleware[]; +}>; + +function GroupActivitiesComposer({ children, groupActivitiesMiddleware }: GroupActivitiesComposerProps) { + const [ponyfill] = usePonyfill(); + const [{ groupActivitiesBy, groupTimestamp }] = useStyleOptions(); + + const runMiddleware = useMemo<(type?: string | undefined) => GroupActivities>( + () => + applyMiddleware( + 'group activities', + ...groupActivitiesMiddleware, + ...createDefaultGroupActivitiesMiddleware({ groupTimestamp, ponyfill }), + () => () => () => ({}) + ), + [groupActivitiesMiddleware, groupTimestamp, ponyfill] + ); + + const runAllMiddleware = useMemo(() => runMiddleware(), [runMiddleware]); + + const groupActivities: GroupActivities = useCallback( + ({ activities }: { activities: readonly WebChatActivity[] }) => { + const results: Readonly<{ + [key: string]: readonly (readonly WebChatActivity[])[]; + }> = runAllMiddleware({ activities }) || Object.freeze({}); + const validatedResults = new Map(); + + for (const [name, result] of Object.entries(results)) { + if (isGroupingValid(activities, result)) { + validatedResults.set(name, result); + } else { + validatedResults.set( + name, + activities.map(activity => Object.freeze([activity])) + ); + } + } + + return Object.fromEntries(validatedResults); + }, + [runAllMiddleware] + ); + + const groupActivitiesByGroup: Map = useMemo( + () => + new Map( + groupActivitiesBy.map(groupingName => [groupingName, runMiddleware(groupingName)]) + ), + [groupActivitiesBy, runMiddleware] + ); + + const groupActivitiesByGroupRef = useRefFrom(groupActivitiesByGroup); + const groupActivitiesByRef = useRefFrom(groupActivitiesBy); + + const groupActivitiesByName = useCallback< + (activities: readonly WebChatActivity[], groupingName: string) => readonly (readonly WebChatActivity[])[] + >( + (activities, groupingName) => { + const group = groupActivitiesByGroupRef.current.get(groupingName); + + if (group) { + const result: ReadonlyMap = new Map( + Object.entries(group({ activities }) || {}) + ); + + if (result.has(groupingName)) { + const groupingResult = result.get(groupingName); + + if (isGroupingValid(activities, groupingResult)) { + return groupingResult; + } + } else { + console.warn( + `botframework-webchat: groupActivitiesMiddleware('${groupingName}') does not return any results` + ); + } + } else { + console.warn( + `botframework-webchat: useGroupActivitiesBy can only be called with one of ${groupActivitiesByRef.current}, however "${groupingName}" was passed instead` + ); + } + + return Object.freeze(activities.map(activity => Object.freeze([activity]))); + }, + [groupActivitiesByGroupRef, groupActivitiesByRef] + ); + + const context = useMemo( + () => ({ + groupActivities, + groupActivitiesByName + }), + [groupActivities, groupActivitiesByName] + ); + + return {children}; +} + +export default memo(GroupActivitiesComposer); +export { type GroupActivitiesComposerProps }; diff --git a/packages/api/src/providers/GroupActivities/private/GroupActivitiesContext.ts b/packages/api/src/providers/GroupActivities/private/GroupActivitiesContext.ts new file mode 100644 index 0000000000..7e8d40f978 --- /dev/null +++ b/packages/api/src/providers/GroupActivities/private/GroupActivitiesContext.ts @@ -0,0 +1,23 @@ +import { type WebChatActivity } from 'botframework-webchat-core'; +import { createContext } from 'react'; + +type GroupActivitiesContextType = { + groupActivities: (options: { activities: readonly WebChatActivity[] }) => Readonly<{ + [key: string]: readonly (readonly WebChatActivity[])[]; + }>; + groupActivitiesByName: ( + activities: readonly WebChatActivity[], + groupingName: string + ) => readonly (readonly WebChatActivity[])[]; +}; + +const GroupActivitiesContext = createContext( + new Proxy({} as GroupActivitiesContextType, { + get() { + throw new Error('botframework-webchat: This hook can only be used under '); + } + }) +); + +export default GroupActivitiesContext; +export { type GroupActivitiesContextType }; diff --git a/packages/api/src/hooks/middleware/createDefaultGroupActivitiesMiddleware.ts b/packages/api/src/providers/GroupActivities/private/createDefaultGroupActivitiesMiddleware.ts similarity index 61% rename from packages/api/src/hooks/middleware/createDefaultGroupActivitiesMiddleware.ts rename to packages/api/src/providers/GroupActivities/private/createDefaultGroupActivitiesMiddleware.ts index 85f163b76e..fa68dd2a85 100644 --- a/packages/api/src/hooks/middleware/createDefaultGroupActivitiesMiddleware.ts +++ b/packages/api/src/providers/GroupActivities/private/createDefaultGroupActivitiesMiddleware.ts @@ -1,9 +1,9 @@ -import GroupActivitiesMiddleware from '../../types/GroupActivitiesMiddleware'; +import { type GlobalScopePonyfill, type WebChatActivity } from 'botframework-webchat-core'; -import type { GlobalScopePonyfill, WebChatActivity } from 'botframework-webchat-core'; -import type { SendStatus } from '../../types/SendStatus'; +import type GroupActivitiesMiddleware from '../../../types/GroupActivitiesMiddleware'; +import { type SendStatus } from '../../../types/SendStatus'; -function bin(items: T[], grouping: (last: T, current: T) => boolean): T[][] { +function bin(items: readonly T[], grouping: (last: T, current: T) => boolean): readonly (readonly T[])[] { let lastBin: T[]; const bins: T[][] = []; let lastItem: T; @@ -19,7 +19,11 @@ function bin(items: T[], grouping: (last: T, current: T) => boolean): T[][] { lastItem = item; } - return bins; + for (const bin in bins) { + Object.freeze(bin); + } + + return Object.freeze(bins); } function sending(activity: WebChatActivity): SendStatus | undefined { @@ -67,14 +71,23 @@ function shouldGroupSender(x: WebChatActivity, y: WebChatActivity): boolean { export default function createDefaultGroupActivitiesMiddleware({ groupTimestamp, ponyfill -}: { +}: Readonly<{ groupTimestamp: boolean | number; ponyfill: GlobalScopePonyfill; -}): GroupActivitiesMiddleware { - return () => - () => - ({ activities }) => ({ - sender: bin(activities, shouldGroupSender), - status: bin(activities, createShouldGroupTimestamp(groupTimestamp, ponyfill)) - }); +}>): readonly GroupActivitiesMiddleware[] { + return Object.freeze([ + type => + type === 'sender' || typeof type === 'undefined' + ? next => + ({ activities }) => ({ ...next({ activities }), sender: bin(activities, shouldGroupSender) }) + : undefined, + type => + type === 'status' || typeof type === 'undefined' + ? next => + ({ activities }) => ({ + ...next({ activities }), + status: bin(activities, createShouldGroupTimestamp(groupTimestamp, ponyfill)) + }) + : undefined + ]); } diff --git a/packages/api/src/providers/GroupActivities/private/isGroupingValid.ts b/packages/api/src/providers/GroupActivities/private/isGroupingValid.ts new file mode 100644 index 0000000000..3c21db354b --- /dev/null +++ b/packages/api/src/providers/GroupActivities/private/isGroupingValid.ts @@ -0,0 +1,41 @@ +import { type WebChatActivity } from 'botframework-webchat-core'; + +export default function isGroupingValid( + source: readonly WebChatActivity[], + bins: readonly (readonly WebChatActivity[])[] +): boolean { + const set = new Set(source); + + if (source.length !== set.size) { + console.warn('botframework-webchat: Cannot validate activity grouping because some activities are duplicated'); + + return false; + } + + for (const bin of bins) { + for (const activityInBin of bin) { + if (!set.has(activityInBin)) { + console.warn( + 'botframework-webchat: All binned items must be originate from the source list, check groupingActivityMiddleware to make sure it bin from the source list', + { + activityInBin + } + ); + + return false; + } + + set.delete(activityInBin); + } + } + + if (set.size) { + console.warn( + 'botframework-webchat: Not every activity is binned, check groupingActivityMiddleware to make sure it is binning every activity passed' + ); + + return false; + } + + return true; +} diff --git a/packages/api/src/providers/GroupActivities/private/useGroupActivitiesContext.ts b/packages/api/src/providers/GroupActivities/private/useGroupActivitiesContext.ts new file mode 100644 index 0000000000..31c88a66a1 --- /dev/null +++ b/packages/api/src/providers/GroupActivities/private/useGroupActivitiesContext.ts @@ -0,0 +1,6 @@ +import { useContext } from 'react'; +import GroupActivitiesContext, { type GroupActivitiesContextType } from './GroupActivitiesContext'; + +export default function useGroupActivitiesContext(): GroupActivitiesContextType { + return useContext(GroupActivitiesContext); +} diff --git a/packages/api/src/providers/GroupActivities/useGroupActivities.ts b/packages/api/src/providers/GroupActivities/useGroupActivities.ts new file mode 100644 index 0000000000..ee9e94e05d --- /dev/null +++ b/packages/api/src/providers/GroupActivities/useGroupActivities.ts @@ -0,0 +1,20 @@ +import { type WebChatActivity } from 'botframework-webchat-core'; + +import useGroupActivitiesContext from './private/useGroupActivitiesContext'; + +type GroupedActivities = readonly (readonly WebChatActivity[])[]; + +/** + * This hook will return a callback function. When called with `activities`, the callback function will run the `groupActivitiesMiddleware` and will return all groupings. + * + * @deprecated This function is deprecated and will be removed on or after 2027-05-04. Developers should migrate to [`useGroupActivitiesByName`](#usegroupactivitiesbyname) for performance reason. + */ +export default function useGroupActivities(): ({ + activities +}: Readonly<{ + activities: readonly WebChatActivity[]; +}>) => Readonly<{ + [key: string]: GroupedActivities; +}> { + return useGroupActivitiesContext().groupActivities; +} diff --git a/packages/api/src/providers/GroupActivities/useGroupActivitiesByName.ts b/packages/api/src/providers/GroupActivities/useGroupActivitiesByName.ts new file mode 100644 index 0000000000..440307121a --- /dev/null +++ b/packages/api/src/providers/GroupActivities/useGroupActivitiesByName.ts @@ -0,0 +1,17 @@ +import { type WebChatActivity } from 'botframework-webchat-core'; + +import useGroupActivitiesContext from './private/useGroupActivitiesContext'; + +/** + * This hook will return a callback function. When called with `activities`, the callback function will run the `groupActivitiesMiddleware` for the specified grouping name. + * + * Unlike the [`useGroupActivities`](#usegroupactivities) hook which provide result for all groupings, this hook only provide result for the specified grouping name and the grouping name must be one of the name specified in `styleOptions.groupActivitiesBy`. + * + * @returns Result of `groupActivitiesMiddleware` for the specified grouping name + */ +export default function useGroupActivitiesBy(): ( + activities: readonly WebChatActivity[], + name: string +) => readonly (readonly WebChatActivity[])[] { + return useGroupActivitiesContext().groupActivitiesByName; +} diff --git a/packages/api/src/types/GroupActivitiesMiddleware.ts b/packages/api/src/types/GroupActivitiesMiddleware.ts index 4e335a49d7..f31a5cd721 100644 --- a/packages/api/src/types/GroupActivitiesMiddleware.ts +++ b/packages/api/src/types/GroupActivitiesMiddleware.ts @@ -2,21 +2,17 @@ import type { WebChatActivity } from 'botframework-webchat-core'; import FunctionMiddleware, { CallFunction } from './FunctionMiddleware'; +type GroupedActivities = readonly (readonly WebChatActivity[])[]; + type GroupActivities = CallFunction< - [{ activities: WebChatActivity[] }], - { - sender: WebChatActivity[][]; - status: WebChatActivity[][]; - } + [Readonly<{ activities: readonly WebChatActivity[] }>], + { [key: string]: GroupedActivities } >; type GroupActivitiesMiddleware = FunctionMiddleware< - [], - [{ activities: WebChatActivity[] }], - { - sender: WebChatActivity[][]; - status: WebChatActivity[][]; - } + [string], + [Readonly<{ activities: readonly WebChatActivity[] }>], + { [key: string]: GroupedActivities } >; export default GroupActivitiesMiddleware; diff --git a/packages/api/tsup.config.ts b/packages/api/tsup.config.ts index b9ff3588e6..e73e3f242d 100644 --- a/packages/api/tsup.config.ts +++ b/packages/api/tsup.config.ts @@ -5,8 +5,8 @@ const config: typeof baseConfig = { ...baseConfig, entry: { 'botframework-webchat-api': './src/index.ts', - 'botframework-webchat-api.internal': './src/internal.ts', - 'botframework-webchat-api.decorator': './src/decorator/index.ts' + 'botframework-webchat-api.decorator': './src/decorator.ts', + 'botframework-webchat-api.internal': './src/internal.ts' } }; diff --git a/packages/bundle/src/module/exports-minimal.ts b/packages/bundle/src/module/exports-minimal.ts index 246ac57436..67da30cbac 100644 --- a/packages/bundle/src/module/exports-minimal.ts +++ b/packages/bundle/src/module/exports-minimal.ts @@ -1,8 +1,8 @@ import { StrictStyleOptions, StyleOptions } from 'botframework-webchat-api'; import * as apiDecorator from 'botframework-webchat-api/decorator'; import { WebChatDecorator } from 'botframework-webchat-component/decorator'; -import { Constants, createStore, createStoreWithDevTools, createStoreWithOptions } from 'botframework-webchat-core'; import * as internal from 'botframework-webchat-component/internal'; +import { Constants, createStore, createStoreWithDevTools, createStoreWithOptions } from 'botframework-webchat-core'; import ReactWebChat, { Components, @@ -78,4 +78,4 @@ export { withEmoji }; -export type { StrictStyleOptions, StyleOptions }; +export { type StrictStyleOptions, type StyleOptions }; diff --git a/packages/component/package.json b/packages/component/package.json index 361003d217..4e52a6fcbe 100644 --- a/packages/component/package.json +++ b/packages/component/package.json @@ -163,6 +163,7 @@ "react-redux": "7.2.9", "react-say": "2.1.0", "react-scroll-to-bottom": "4.2.1-main.53844f5", + "react-wrap-with": "^0.2.0-main.62328fb", "redux": "5.0.1", "simple-update-in": "2.2.0", "use-propagate": "0.2.1", diff --git a/packages/component/src/Activity/StackedLayout.tsx b/packages/component/src/Activity/StackedLayout.tsx index 7f91b43b4b..8a4e80638e 100644 --- a/packages/component/src/Activity/StackedLayout.tsx +++ b/packages/component/src/Activity/StackedLayout.tsx @@ -1,7 +1,7 @@ /* eslint complexity: ["error", 50] */ import { hooks } from 'botframework-webchat-api'; -import { ActivityDecorator } from 'botframework-webchat-api/decorator'; +import { ActivityBorderDecorator } from 'botframework-webchat-api/decorator'; import classNames from 'classnames'; import React, { memo } from 'react'; @@ -161,7 +161,7 @@ const StackedLayout = ({ fromUser={fromUser} nub={showNub || (hasAvatar || hasNub ? 'hidden' : false)} > - + {renderAttachment({ activity, attachment: { @@ -169,7 +169,7 @@ const StackedLayout = ({ contentType: textFormatToContentType('textFormat' in activity ? activity.textFormat : undefined) } })} - + )} diff --git a/packages/component/src/BasicTranscript.tsx b/packages/component/src/BasicTranscript.tsx index f2ef7bdfdd..f7e574f3e1 100644 --- a/packages/component/src/BasicTranscript.tsx +++ b/packages/component/src/BasicTranscript.tsx @@ -2,6 +2,18 @@ /* eslint-disable @typescript-eslint/no-use-before-define */ import { hooks } from 'botframework-webchat-api'; +import classNames from 'classnames'; +import React, { + forwardRef, + Fragment, + memo, + useCallback, + useMemo, + useRef, + type KeyboardEventHandler, + type MutableRefObject, + type ReactNode +} from 'react'; import { Composer as ReactScrollToBottomComposer, Panel as ReactScrollToBottomPanel, @@ -10,55 +22,51 @@ import { useScrollToEnd, useSticky } from 'react-scroll-to-bottom'; -import classNames from 'classnames'; -import React, { forwardRef, Fragment, memo, useCallback, useMemo, useRef } from 'react'; - -import type { ActivityElementMap } from './Transcript/types'; -import type { FC, KeyboardEventHandler, MutableRefObject, ReactNode } from 'react'; -import type { WebChatActivity } from 'botframework-webchat-core'; +import { wrapWith } from 'react-wrap-with'; -import { android } from './Utils/detectBrowser'; import BasicTypingIndicator from './BasicTypingIndicator'; +import ChatHistoryBox from './ChatHistory/ChatHistoryBox'; +import ChatHistoryToolbar from './ChatHistory/ChatHistoryToolbar'; +import ScrollToEndButton from './ChatHistory/private/ScrollToEndButton'; +import ActivityTree from './Transcript/ActivityTree'; +import LiveRegionTranscript from './Transcript/LiveRegionTranscript'; +import { type ActivityElementMap } from './Transcript/types'; import FocusRedirector from './Utils/FocusRedirector'; import inputtableKey from './Utils/TypeFocusSink/inputtableKey'; -import isZeroOrPositive from './Utils/isZeroOrPositive'; -import LiveRegionTranscript from './Transcript/LiveRegionTranscript'; -import TranscriptFocusComposer from './providers/TranscriptFocus/TranscriptFocusComposer'; -import useActiveDescendantId from './providers/TranscriptFocus/useActiveDescendantId'; -import useActivityTreeWithRenderer from './providers/ActivityTree/useActivityTreeWithRenderer'; +import { android } from './Utils/detectBrowser'; +import { useStyleToEmotionObject } from './hooks/internal/styleToEmotionObject'; import useDispatchScrollPosition from './hooks/internal/useDispatchScrollPosition'; import useDispatchTranscriptFocusByActivityKey from './hooks/internal/useDispatchTranscriptFocusByActivityKey'; -import useFocus from './hooks/useFocus'; -import useFocusByActivityKey from './providers/TranscriptFocus/useFocusByActivityKey'; -import useFocusedActivityKey from './providers/TranscriptFocus/useFocusedActivityKey'; -import useFocusedExplicitly from './providers/TranscriptFocus/useFocusedExplicitly'; -import useFocusRelativeActivity from './providers/TranscriptFocus/useFocusRelativeActivity'; -import ChatHistoryBox from './ChatHistory/ChatHistoryBox'; -import ChatHistoryToolbar from './ChatHistory/ChatHistoryToolbar'; -import ScrollToEndButton from './ChatHistory/private/ScrollToEndButton'; +import useNonce from './hooks/internal/useNonce'; import useObserveFocusVisible from './hooks/internal/useObserveFocusVisible'; import usePrevious from './hooks/internal/usePrevious'; import useRegisterFocusTranscript from './hooks/internal/useRegisterFocusTranscript'; import useRegisterScrollTo from './hooks/internal/useRegisterScrollTo'; import useRegisterScrollToEnd from './hooks/internal/useRegisterScrollToEnd'; -import useStyleSet from './hooks/useStyleSet'; -import { useStyleToEmotionObject } from './hooks/internal/styleToEmotionObject'; import useUniqueId from './hooks/internal/useUniqueId'; import useValueRef from './hooks/internal/useValueRef'; -import TranscriptActivity from './TranscriptActivity'; -import useMemoized from './hooks/internal/useMemoized'; import { useRegisterScrollRelativeTranscript, type TranscriptScrollRelativeOptions } from './hooks/transcriptScrollRelative'; -import useNonce from './hooks/internal/useNonce'; +import useFocus from './hooks/useFocus'; +import useStyleSet from './hooks/useStyleSet'; +import ChatHistoryDOMComposer from './providers/ChatHistoryDOM/ChatHistoryDOMComposer'; +import useActivityElementMapRef from './providers/ChatHistoryDOM/useActivityElementRef'; +import GroupedRenderingActivitiesComposer from './providers/GroupedRenderingActivities/GroupedRenderingActivitiesComposer'; +import useNumRenderingActivities from './providers/GroupedRenderingActivities/useNumRenderingActivities'; +import RenderingActivitiesComposer from './providers/RenderingActivities/RenderingActivitiesComposer'; +import TranscriptFocusComposer from './providers/TranscriptFocus/TranscriptFocusComposer'; +import useActiveDescendantId from './providers/TranscriptFocus/useActiveDescendantId'; +import useFocusByActivityKey from './providers/TranscriptFocus/useFocusByActivityKey'; +import useFocusRelativeActivity from './providers/TranscriptFocus/useFocusRelativeActivity'; +import useFocusedActivityKey from './providers/TranscriptFocus/useFocusedActivityKey'; +import useFocusedExplicitly from './providers/TranscriptFocus/useFocusedExplicitly'; const { useActivityKeys, - useCreateAvatarRenderer, useDirection, useGetActivityByKey, - useGetKeyByActivity, useGetKeyByActivityId, useLastAcknowledgedActivityKey, useLocalizer, @@ -99,27 +107,23 @@ type ScrollToOptions = { behavior?: ScrollBehavior }; type ScrollToPosition = { activityID?: string; scrollTop?: number }; type InternalTranscriptProps = Readonly<{ - activityElementMapRef: MutableRefObject; className?: string; terminatorRef: React.MutableRefObject; }>; // TODO: [P1] #4133 Add telemetry for computing how many re-render done so far. const InternalTranscript = forwardRef( - ({ activityElementMapRef, className, terminatorRef }, ref) => { + ({ className, terminatorRef }: InternalTranscriptProps, ref) => { const [{ basicTranscript: basicTranscriptStyleSet }] = useStyleSet(); - const [{ bubbleFromUserNubOffset, bubbleNubOffset, groupTimestamp, showAvatarInGroup }] = useStyleOptions(); const [activeDescendantId] = useActiveDescendantId(); - const [activityWithRendererTree] = useActivityTreeWithRenderer(); const [direction] = useDirection(); const [focusedActivityKey] = useFocusedActivityKey(); const [focusedExplicitly] = useFocusedExplicitly(); - const createAvatarRenderer = useCreateAvatarRenderer(); + const activityElementMapRef = useActivityElementMapRef(); const focus = useFocus(); const focusByActivityKey = useFocusByActivityKey(); const focusRelativeActivity = useFocusRelativeActivity(); const getActivityByKey = useGetActivityByKey(); - const getKeyByActivity = useGetKeyByActivity(); const getKeyByActivityId = useGetKeyByActivityId(); const localize = useLocalizer(); const rootClassName = useStyleToEmotionObject()(ROOT_STYLE) + ''; @@ -127,7 +131,6 @@ const InternalTranscript = forwardRef( const terminatorLabelId = useUniqueId('webchat__basic-transcript__terminator-label'); const focusedActivityKeyRef = useValueRef(focusedActivityKey); - const hideAllTimestamps = groupTimestamp === false; const terminatorText = localize('TRANSCRIPT_TERMINATOR_TEXT'); const transcriptAriaLabel = localize('TRANSCRIPT_ARIA_LABEL_ALT'); @@ -144,84 +147,7 @@ const InternalTranscript = forwardRef( [ref, rootElementRef] ); - const createAvatarRendererMemoized = useMemoized( - (activity: WebChatActivity) => createAvatarRenderer({ activity }), - [createAvatarRenderer] - ); - - // Flatten the tree back into an array with information related to rendering. - const renderingElements = useMemo(() => { - const renderingElements: ReactNode[] = []; - const topSideBotNub = isZeroOrPositive(bubbleNubOffset); - const topSideUserNub = isZeroOrPositive(bubbleFromUserNubOffset); - - activityWithRendererTree.forEach(entriesWithSameSender => { - const [[{ activity: firstActivity }]] = entriesWithSameSender; - const renderAvatar = createAvatarRendererMemoized(firstActivity); - - entriesWithSameSender.forEach((entriesWithSameSenderAndStatus, indexWithinSenderGroup) => { - const firstInSenderGroup = !indexWithinSenderGroup; - const lastInSenderGroup = indexWithinSenderGroup === entriesWithSameSender.length - 1; - - entriesWithSameSenderAndStatus.forEach(({ activity, renderActivity }, indexWithinSenderAndStatusGroup) => { - // We only show the timestamp at the end of the sender group. But we always show the "Send failed, retry" prompt. - const firstInSenderAndStatusGroup = !indexWithinSenderAndStatusGroup; - const key: string = getKeyByActivity(activity); - const lastInSenderAndStatusGroup = - indexWithinSenderAndStatusGroup === entriesWithSameSenderAndStatus.length - 1; - const topSideNub = activity.from?.role === 'user' ? topSideUserNub : topSideBotNub; - - let showCallout: boolean; - - // Depending on the "showAvatarInGroup" setting, the avatar will render 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; - } - - renderingElements.push( - - ); - }); - }); - }); - - return renderingElements; - }, [ - activityElementMapRef, - activityWithRendererTree, - bubbleFromUserNubOffset, - bubbleNubOffset, - createAvatarRendererMemoized, - getKeyByActivity, - hideAllTimestamps, - showAvatarInGroup - ]); + const [numRenderingActivities] = useNumRenderingActivities(); const scrollToBottomScrollTo: (scrollTop: number, options?: ScrollToOptions) => void = useScrollTo(); const scrollToBottomScrollToEnd: (options?: ScrollToOptions) => void = useScrollToEnd(); @@ -497,7 +423,7 @@ const InternalTranscript = forwardRef( useCallback(() => focusByActivityKey(undefined), [focusByActivityKey]) ); - const hasAnyChild = !!React.Children.count(renderingElements); + const hasAnyChild = !!numRenderingActivities; return (
( {hasAnyChild && } - {renderingElements} + {hasAnyChild && } - {!!renderingElements.length && ( + {hasAnyChild && (
; // Separating high-frequency hooks to improve performance. -const InternalTranscriptScrollable: FC = ({ children, onFocusFiller }) => { +const InternalTranscriptScrollable = ({ children, onFocusFiller }: InternalTranscriptScrollableProps) => { const [{ activities: activitiesStyleSet }] = useStyleSet(); const [sticky]: [boolean] = useSticky(); const localize = useLocalizer(); @@ -584,7 +510,9 @@ const InternalTranscriptScrollable: FC = ({ c [markAllAsAcknowledged, stickyChangedToTrue] ); - const hasAnyChild = !!React.Children.count(children); + // We need to check if `children` is `false` or not. + // If `children` is `false`, React.Children.count(children) will still return 1 (truthy). + const hasAnyChild = !!children && !!React.Children.count(children); return ( @@ -727,34 +655,33 @@ type BasicTranscriptProps = Readonly<{ className: string; }>; -const BasicTranscript: FC = ({ className = '' }) => { - const activityElementMapRef = useRef(new Map()); +const BasicTranscript = ({ className = '' }: BasicTranscriptProps) => { + const [{ stylesRoot }] = useStyleOptions(); + const [nonce] = useNonce(); + const activityElementMapRef = useActivityElementMapRef(); const containerRef = useRef(); + const terminatorRef = useRef(); - const [nonce] = useNonce(); const scroller = useScroller(activityElementMapRef); - - const [{ stylesRoot }] = useStyleOptions(); const styleOptions = useMemo(() => ({ stylesRoot }), [stylesRoot]); - const terminatorRef = useRef(); - return ( - - - - - - - - + + + + + + + + + + + + ); }; -export default memo(BasicTranscript); +export default wrapWith(ChatHistoryDOMComposer)(memo(BasicTranscript)); +export { type BasicTranscriptProps }; diff --git a/packages/component/src/BuiltInDecorator.tsx b/packages/component/src/BuiltInDecorator.tsx new file mode 100644 index 0000000000..cc9c230317 --- /dev/null +++ b/packages/component/src/BuiltInDecorator.tsx @@ -0,0 +1,17 @@ +import { LowPriorityDecoratorComposer } from 'botframework-webchat-api/internal'; +import React, { memo, type ReactNode } from 'react'; + +import createDefaultActivityGroupingDecoratorMiddleware from './Middleware/ActivityGrouping/createDefaultActivityGroupingDecoratorMiddleware'; + +const middleware = Object.freeze([...createDefaultActivityGroupingDecoratorMiddleware()] as const); + +type BuiltInDecoratorProps = Readonly<{ + readonly children?: ReactNode | undefined; +}>; + +function BuiltInDecorator({ children }: BuiltInDecoratorProps) { + return {children}; +} + +export default memo(BuiltInDecorator); +export { type BuiltInDecoratorProps }; diff --git a/packages/component/src/ChatHistory/private/ScrollToEndButton.tsx b/packages/component/src/ChatHistory/private/ScrollToEndButton.tsx index dfb8f651ad..0bf0f06dac 100644 --- a/packages/component/src/ChatHistory/private/ScrollToEndButton.tsx +++ b/packages/component/src/ChatHistory/private/ScrollToEndButton.tsx @@ -3,18 +3,13 @@ import React, { Fragment, memo, MutableRefObject, useCallback, useMemo } from 'r import { useAnimatingToEnd, useAtEnd, useScrollToEnd, useSticky } from 'react-scroll-to-bottom'; import { useRefFrom } from 'use-ref-from'; -import useActivityTreeWithRenderer from '../../providers/ActivityTree/useActivityTreeWithRenderer'; +import useRenderingActivityKeys from '../../providers/RenderingActivities/useRenderingActivityKeys'; import useFocusByActivityKey from '../../providers/TranscriptFocus/useFocusByActivityKey'; -const { - useActivityKeysByRead, - useCreateScrollToEndButtonRenderer, - useGetKeyByActivity, - useMarkActivityKeyAsRead, - useStyleOptions -} = hooks; +const { useActivityKeysByRead, useCreateScrollToEndButtonRenderer, useMarkActivityKeyAsRead, useStyleOptions } = hooks; const useScrollToEndRenderResult = (terminatorRef: MutableRefObject) => { + const [renderingActivityKeys] = useRenderingActivityKeys(); const [sticky]: [boolean] = useSticky(); const [animatingToEnd]: [boolean] = useAnimatingToEnd(); const [atEnd]: [boolean] = useAtEnd(); @@ -47,14 +42,6 @@ const useScrollToEndRenderResult = (terminatorRef: MutableRefObject( - () => flattenedActivityTreeWithRenderer.map(({ activity }) => getKeyByActivity(activity)), - [flattenedActivityTreeWithRenderer, getKeyByActivity] - ); - const renderingActivityKeysRef = useRefFrom(renderingActivityKeys); // To prevent flashy button, we are not waiting for another render loop to update the `[readActivityKeys, unreadActivityKeys]` state. diff --git a/packages/component/src/Composer.tsx b/packages/component/src/Composer.tsx index 30d8b8981e..0aae0a6986 100644 --- a/packages/component/src/Composer.tsx +++ b/packages/component/src/Composer.tsx @@ -10,7 +10,7 @@ import { initSendBoxToolbarMiddleware, WebSpeechPonyfillFactory } from 'botframework-webchat-api'; -import { DecoratorComposer } from 'botframework-webchat-api/decorator'; +import { DecoratorComposer, type DecoratorMiddleware } from 'botframework-webchat-api/decorator'; import { singleToArray } from 'botframework-webchat-core'; import classNames from 'classnames'; import MarkdownIt from 'markdown-it'; @@ -19,6 +19,7 @@ import React, { memo, useCallback, useMemo, useRef, useState, type ReactNode } f import { Composer as SayComposer } from 'react-say'; import createDefaultAttachmentMiddleware from './Attachment/createMiddleware'; +import BuiltInDecorator from './BuiltInDecorator'; import Dictation from './Dictation'; import ErrorBox from './ErrorBox'; import { @@ -39,7 +40,6 @@ import createDefaultCardActionMiddleware from './Middleware/CardAction/createCor import createDefaultScrollToEndButtonMiddleware from './Middleware/ScrollToEndButton/createScrollToEndButtonMiddleware'; import createDefaultToastMiddleware from './Middleware/Toast/createCoreMiddleware'; import createDefaultTypingIndicatorMiddleware from './Middleware/TypingIndicator/createCoreMiddleware'; -import ActivityTreeComposer from './providers/ActivityTree/ActivityTreeComposer'; import CustomElementsComposer from './providers/CustomElements/CustomElementsComposer'; import HTMLContentTransformComposer from './providers/HTMLContentTransformCOR/HTMLContentTransformComposer'; import { type HTMLContentTransformMiddleware } from './providers/HTMLContentTransformCOR/private/HTMLContentTransformContext'; @@ -109,15 +109,13 @@ const ComposerCoreUI = memo(({ children }: ComposerCoreUIProps) => { - - - {/* When is finalized, it will be using an independent instance that lives inside . */} - - {children} - - - - + + {/* When is finalized, it will be using an independent instance that lives inside . */} + + {children} + + + @@ -130,6 +128,7 @@ ComposerCoreUI.displayName = 'ComposerCoreUI'; type ComposerCoreProps = Readonly<{ children?: ReactNode; + decoratorMiddleware?: readonly DecoratorMiddleware[] | undefined; extraStyleSet?: any; htmlContentTransformMiddleware?: readonly HTMLContentTransformMiddleware[] | undefined; nonce?: string; @@ -327,6 +326,7 @@ const Composer = ({ avatarMiddleware, cardActionMiddleware, children, + decoratorMiddleware, extraStyleSet, htmlContentTransformMiddleware, renderMarkdown, @@ -423,8 +423,8 @@ const Composer = ({ const sendBoxMiddleware = useMemo( () => Object.freeze([ - ...initSendBoxMiddleware(sendBoxMiddlewareFromProps), - ...initSendBoxMiddleware(theme.sendBoxMiddleware), + ...initSendBoxMiddleware(sendBoxMiddlewareFromProps, undefined), + ...initSendBoxMiddleware(theme.sendBoxMiddleware, undefined), ...createDefaultSendBoxMiddleware() ]), [sendBoxMiddlewareFromProps, theme.sendBoxMiddleware] @@ -433,8 +433,8 @@ const Composer = ({ const sendBoxToolbarMiddleware = useMemo( () => Object.freeze([ - ...initSendBoxToolbarMiddleware(sendBoxToolbarMiddlewareFromProps), - ...initSendBoxToolbarMiddleware(theme.sendBoxToolbarMiddleware), + ...initSendBoxToolbarMiddleware(sendBoxToolbarMiddlewareFromProps, undefined), + ...initSendBoxToolbarMiddleware(theme.sendBoxToolbarMiddleware, undefined), ...createDefaultSendBoxToolbarMiddleware() ]), [sendBoxToolbarMiddlewareFromProps, theme.sendBoxToolbarMiddleware] @@ -460,26 +460,28 @@ const Composer = ({ typingIndicatorMiddleware={patchedTypingIndicatorMiddleware} {...composerProps} > - - - - - - {children} - {onTelemetry && } - - - - - + + + + + + + {children} + {onTelemetry && } + + + + + + ); }; @@ -497,5 +499,4 @@ Composer.propTypes = { }; export default Composer; - export type { ComposerProps }; diff --git a/packages/component/src/Middleware/ActivityGrouping/createDefaultActivityGroupingDecoratorMiddleware.tsx b/packages/component/src/Middleware/ActivityGrouping/createDefaultActivityGroupingDecoratorMiddleware.tsx new file mode 100644 index 0000000000..8040afb345 --- /dev/null +++ b/packages/component/src/Middleware/ActivityGrouping/createDefaultActivityGroupingDecoratorMiddleware.tsx @@ -0,0 +1,22 @@ +import { + type DecoratorMiddleware, + type DecoratorMiddlewareInit, + type DecoratorMiddlewareTypes +} from 'botframework-webchat-api/decorator'; +import RenderActivityGrouping from './ui/RenderActivityGrouping'; +import SenderGrouping from './ui/SenderGrouping/SenderGrouping'; +import StatusGrouping from './ui/StatusGrouping/StatusGrouping'; + +export default function createDefaultActivityGroupingDecoratorMiddleware(): readonly DecoratorMiddleware[] { + return Object.freeze([ + (init: DecoratorMiddlewareInit) => + init === 'activity grouping' && + ((() => + ({ groupingName }) => + groupingName === 'sender' + ? SenderGrouping + : groupingName === 'status' + ? StatusGrouping + : RenderActivityGrouping) satisfies DecoratorMiddlewareTypes['activity grouping']) + ]); +} diff --git a/packages/component/src/Middleware/ActivityGrouping/ui/RenderActivityGrouping.tsx b/packages/component/src/Middleware/ActivityGrouping/ui/RenderActivityGrouping.tsx new file mode 100644 index 0000000000..26337165c6 --- /dev/null +++ b/packages/component/src/Middleware/ActivityGrouping/ui/RenderActivityGrouping.tsx @@ -0,0 +1,33 @@ +import { hooks } from 'botframework-webchat-api'; +import { type WebChatActivity } from 'botframework-webchat-core'; +import React, { Fragment, memo } from 'react'; +import useGetRenderActivityCallback from '../../../providers/RenderingActivities/useGetRenderActivityCallback'; +import TranscriptActivity from '../../../Transcript/TranscriptActivity'; + +const { useGetKeyByActivity } = hooks; + +type RenderActivityGroupingProps = Readonly<{ + activities: readonly WebChatActivity[]; +}>; + +const RenderActivityGrouping = ({ activities }: RenderActivityGroupingProps) => { + const getKeyByActivity = useGetKeyByActivity(); + const getRenderActivityCallback = useGetRenderActivityCallback(); + + return ( + + {activities.map(activity => ( + + ))} + + ); +}; + +RenderActivityGrouping.displayName = 'RenderActivityGrouping'; + +export default memo(RenderActivityGrouping); +export { type RenderActivityGroupingProps }; diff --git a/packages/component/src/Middleware/ActivityGrouping/ui/SenderGrouping/SenderGrouping.tsx b/packages/component/src/Middleware/ActivityGrouping/ui/SenderGrouping/SenderGrouping.tsx new file mode 100644 index 0000000000..385f181449 --- /dev/null +++ b/packages/component/src/Middleware/ActivityGrouping/ui/SenderGrouping/SenderGrouping.tsx @@ -0,0 +1,49 @@ +import { type WebChatActivity } from 'botframework-webchat-core'; +import React, { memo, useMemo } from 'react'; +import { any, array, minLength, object, optional, parse, pipe, readonly, transform, type InferOutput } from 'valibot'; +import reactNode from '../../../../types/internal/reactNode'; +import SenderGroupingContext, { type SenderGroupingContextType } from './private/SenderGroupingContext'; + +const senderGroupingPropsSchema = pipe( + object({ + activities: pipe( + array( + pipe( + any(), + transform(value => value as WebChatActivity) + ) + ), + minLength(1, 'botframework-webchat: "activities" must have at least 1 activity'), + readonly() + ), + children: optional(reactNode()) + }), + readonly() +); + +type SenderGroupingProps = InferOutput; + +const SenderGrouping = (props: SenderGroupingProps) => { + const { activities, children } = parse(senderGroupingPropsSchema, props); + + // "activities" props must have at least 1 activity, first/last must not be undefined. + const firstActivity = activities[0] as WebChatActivity; + // eslint-disable-next-line no-magic-numbers + const lastActivity = activities.at(-1) as WebChatActivity; + + const context = useMemo( + () => + Object.freeze({ + firstActivityState: Object.freeze<[WebChatActivity]>([firstActivity]), + lastActivityState: Object.freeze<[WebChatActivity]>([lastActivity]) + }), + [firstActivity, lastActivity] + ); + + return {children}; +}; + +SenderGrouping.displayName = 'SenderGrouping'; + +export default memo(SenderGrouping); +export { type SenderGroupingProps }; diff --git a/packages/component/src/Middleware/ActivityGrouping/ui/SenderGrouping/private/SenderGroupingContext.ts b/packages/component/src/Middleware/ActivityGrouping/ui/SenderGrouping/private/SenderGroupingContext.ts new file mode 100644 index 0000000000..e5450de0e1 --- /dev/null +++ b/packages/component/src/Middleware/ActivityGrouping/ui/SenderGrouping/private/SenderGroupingContext.ts @@ -0,0 +1,18 @@ +import { type WebChatActivity } from 'botframework-webchat-core'; +import { createContext } from 'react'; + +type SenderGroupingContextType = Readonly<{ + firstActivityState: readonly [WebChatActivity]; + lastActivityState: readonly [WebChatActivity]; +}>; + +const EMPTY_STATE = Object.freeze([undefined] as const); + +// SenderGroupingContext may not available if `styleOptions.groupActivitiesBy` does not have `sender`. +const SenderGroupingContext = createContext({ + firstActivityState: EMPTY_STATE, + lastActivityState: EMPTY_STATE +}); + +export default SenderGroupingContext; +export { type SenderGroupingContextType }; diff --git a/packages/component/src/Middleware/ActivityGrouping/ui/SenderGrouping/private/useSenderGroupingContext.ts b/packages/component/src/Middleware/ActivityGrouping/ui/SenderGrouping/private/useSenderGroupingContext.ts new file mode 100644 index 0000000000..6fa3a8c8dd --- /dev/null +++ b/packages/component/src/Middleware/ActivityGrouping/ui/SenderGrouping/private/useSenderGroupingContext.ts @@ -0,0 +1,6 @@ +import { useContext } from 'react'; +import SenderGroupingContext, { type SenderGroupingContextType } from './SenderGroupingContext'; + +export default function useSenderGroupingContext(): SenderGroupingContextType { + return useContext(SenderGroupingContext); +} diff --git a/packages/component/src/Middleware/ActivityGrouping/ui/SenderGrouping/useFirstActivity.ts b/packages/component/src/Middleware/ActivityGrouping/ui/SenderGrouping/useFirstActivity.ts new file mode 100644 index 0000000000..eaac8defd0 --- /dev/null +++ b/packages/component/src/Middleware/ActivityGrouping/ui/SenderGrouping/useFirstActivity.ts @@ -0,0 +1,6 @@ +import { type WebChatActivity } from 'botframework-webchat-core'; +import useSenderGroupingContext from './private/useSenderGroupingContext'; + +export default function useFirstActivity(): readonly [WebChatActivity] { + return useSenderGroupingContext().firstActivityState; +} diff --git a/packages/component/src/Middleware/ActivityGrouping/ui/SenderGrouping/useLastActivity.ts b/packages/component/src/Middleware/ActivityGrouping/ui/SenderGrouping/useLastActivity.ts new file mode 100644 index 0000000000..4e2805de62 --- /dev/null +++ b/packages/component/src/Middleware/ActivityGrouping/ui/SenderGrouping/useLastActivity.ts @@ -0,0 +1,6 @@ +import { type WebChatActivity } from 'botframework-webchat-core'; +import useSenderGroupingContext from './private/useSenderGroupingContext'; + +export default function useLastActivity(): readonly [WebChatActivity] { + return useSenderGroupingContext().lastActivityState; +} diff --git a/packages/component/src/Middleware/ActivityGrouping/ui/StatusGrouping/StatusGrouping.tsx b/packages/component/src/Middleware/ActivityGrouping/ui/StatusGrouping/StatusGrouping.tsx new file mode 100644 index 0000000000..e2bfa0cfca --- /dev/null +++ b/packages/component/src/Middleware/ActivityGrouping/ui/StatusGrouping/StatusGrouping.tsx @@ -0,0 +1,49 @@ +import { type WebChatActivity } from 'botframework-webchat-core'; +import React, { memo, useMemo } from 'react'; +import { any, array, minLength, object, optional, parse, pipe, readonly, transform, type InferOutput } from 'valibot'; +import reactNode from '../../../../types/internal/reactNode'; +import StatusGroupingContext, { type StatusGroupingContextType } from './private/StatusGroupingContext'; + +const statusGroupingPropsSchema = pipe( + object({ + activities: pipe( + array( + pipe( + any(), + transform(value => value as WebChatActivity) + ) + ), + minLength(1, 'botframework-webchat: "activities" must have at least 1 activity'), + readonly() + ), + children: optional(reactNode()) + }), + readonly() +); + +type StatusGroupingProps = InferOutput; + +const StatusGrouping = (props: StatusGroupingProps) => { + const { activities, children } = parse(statusGroupingPropsSchema, props); + + // "activities" props must have at least 1 activity, first/last must not be undefined. + const firstActivity = activities[0] as WebChatActivity; + // eslint-disable-next-line no-magic-numbers + const lastActivity = activities.at(-1) as WebChatActivity; + + const context = useMemo( + () => + Object.freeze({ + firstActivityState: Object.freeze<[WebChatActivity]>([firstActivity]), + lastActivityState: Object.freeze<[WebChatActivity]>([lastActivity]) + }), + [firstActivity, lastActivity] + ); + + return {children}; +}; + +StatusGrouping.displayName = 'StatusGrouping'; + +export default memo(StatusGrouping); +export { type StatusGroupingProps }; diff --git a/packages/component/src/Middleware/ActivityGrouping/ui/StatusGrouping/private/StatusGroupingContext.ts b/packages/component/src/Middleware/ActivityGrouping/ui/StatusGrouping/private/StatusGroupingContext.ts new file mode 100644 index 0000000000..687d35a1cb --- /dev/null +++ b/packages/component/src/Middleware/ActivityGrouping/ui/StatusGrouping/private/StatusGroupingContext.ts @@ -0,0 +1,18 @@ +import { type WebChatActivity } from 'botframework-webchat-core'; +import { createContext } from 'react'; + +type StatusGroupingContextType = Readonly<{ + firstActivityState: readonly [WebChatActivity]; + lastActivityState: readonly [WebChatActivity]; +}>; + +const EMPTY_STATE = Object.freeze([undefined] as const); + +// StatusGroupingContext may not available if `styleOptions.groupActivitiesBy` does not have `status`. +const StatusGroupingContext = createContext({ + firstActivityState: EMPTY_STATE, + lastActivityState: EMPTY_STATE +}); + +export default StatusGroupingContext; +export { type StatusGroupingContextType }; diff --git a/packages/component/src/Middleware/ActivityGrouping/ui/StatusGrouping/private/useStatusGroupingContext.ts b/packages/component/src/Middleware/ActivityGrouping/ui/StatusGrouping/private/useStatusGroupingContext.ts new file mode 100644 index 0000000000..61e0e39a01 --- /dev/null +++ b/packages/component/src/Middleware/ActivityGrouping/ui/StatusGrouping/private/useStatusGroupingContext.ts @@ -0,0 +1,6 @@ +import { useContext } from 'react'; +import StatusGroupingContext, { type StatusGroupingContextType } from './StatusGroupingContext'; + +export default function useStatusGroupingContext(): StatusGroupingContextType { + return useContext(StatusGroupingContext); +} diff --git a/packages/component/src/Middleware/ActivityGrouping/ui/StatusGrouping/useFirstActivity.ts b/packages/component/src/Middleware/ActivityGrouping/ui/StatusGrouping/useFirstActivity.ts new file mode 100644 index 0000000000..dcb715cb54 --- /dev/null +++ b/packages/component/src/Middleware/ActivityGrouping/ui/StatusGrouping/useFirstActivity.ts @@ -0,0 +1,6 @@ +import { type WebChatActivity } from 'botframework-webchat-core'; +import useStatusGroupingContext from './private/useStatusGroupingContext'; + +export default function useFirstActivity(): readonly [WebChatActivity] { + return useStatusGroupingContext().firstActivityState; +} diff --git a/packages/component/src/Middleware/ActivityGrouping/ui/StatusGrouping/useLastActivity.ts b/packages/component/src/Middleware/ActivityGrouping/ui/StatusGrouping/useLastActivity.ts new file mode 100644 index 0000000000..c26f7202b6 --- /dev/null +++ b/packages/component/src/Middleware/ActivityGrouping/ui/StatusGrouping/useLastActivity.ts @@ -0,0 +1,6 @@ +import { type WebChatActivity } from 'botframework-webchat-core'; +import useStatusGroupingContext from './private/useStatusGroupingContext'; + +export default function useLastActivity(): readonly [WebChatActivity] { + return useStatusGroupingContext().lastActivityState; +} diff --git a/packages/component/src/Transcript/ActivityTree.tsx b/packages/component/src/Transcript/ActivityTree.tsx new file mode 100644 index 0000000000..d6b50504a3 --- /dev/null +++ b/packages/component/src/Transcript/ActivityTree.tsx @@ -0,0 +1,35 @@ +import { ActivityGroupingDecorator } from 'botframework-webchat-api/decorator'; +import React, { Fragment, memo } from 'react'; + +import { type GroupedRenderingActivities } from '../providers/GroupedRenderingActivities/GroupedRenderingActivities'; +import useGroupedRenderingActivities from '../providers/GroupedRenderingActivities/useGroupedRenderingActivities'; + +type ActivityGroupProps = Readonly<{ + group: GroupedRenderingActivities; +}>; + +const ActivityTreeGroup = memo(({ group }: ActivityGroupProps) => ( + + {group.children.map(child => ( + + ))} + +)); + +ActivityTreeGroup.displayName = 'ActivityTreeGroup'; + +const ActivityTree = () => { + const [group] = useGroupedRenderingActivities(); + + return ( + + {group.map(child => ( + + ))} + + ); +}; + +ActivityTree.displayName = 'ActivityTree'; + +export default memo(ActivityTree); diff --git a/packages/component/src/Transcript/TranscriptActivity.tsx b/packages/component/src/Transcript/TranscriptActivity.tsx new file mode 100644 index 0000000000..fec94f1286 --- /dev/null +++ b/packages/component/src/Transcript/TranscriptActivity.tsx @@ -0,0 +1,105 @@ +import { hooks, type ActivityComponentFactory } from 'botframework-webchat-api'; +import { type WebChatActivity } from 'botframework-webchat-core'; +import React, { memo, useCallback, useMemo } from 'react'; + +import useFirstActivityInSenderGroup from '../Middleware/ActivityGrouping/ui/SenderGrouping/useFirstActivity'; +import useLastActivityInSenderGroup from '../Middleware/ActivityGrouping/ui/SenderGrouping/useLastActivity'; +import useFirstActivityInStatusGroup from '../Middleware/ActivityGrouping/ui/StatusGrouping/useFirstActivity'; +import useLastActivityInStatusGroup from '../Middleware/ActivityGrouping/ui/StatusGrouping/useLastActivity'; +import useActivityElementMapRef from '../providers/ChatHistoryDOM/useActivityElementRef'; +import isZeroOrPositive from '../Utils/isZeroOrPositive'; +import ActivityRow from './ActivityRow'; + +const { useCreateActivityStatusRenderer, useCreateAvatarRenderer, useGetKeyByActivity, useStyleOptions } = hooks; + +type TranscriptActivityProps = Readonly<{ + activity: WebChatActivity; + renderActivity: Exclude, false>; +}>; + +const TranscriptActivity = ({ activity, renderActivity }: TranscriptActivityProps) => { + const [{ bubbleFromUserNubOffset, bubbleNubOffset, groupTimestamp, showAvatarInGroup }] = useStyleOptions(); + const [firstActivityInSenderGroup] = useFirstActivityInSenderGroup(); + const [firstActivityInStatusGroup] = useFirstActivityInStatusGroup(); + const [lastActivityInSenderGroup] = useLastActivityInSenderGroup(); + const [lastActivityInStatusGroup] = useLastActivityInStatusGroup(); + const activityElementMapRef = useActivityElementMapRef(); + const createActivityStatusRenderer = useCreateActivityStatusRenderer(); + const getKeyByActivity = useGetKeyByActivity(); + const renderAvatar = useCreateAvatarRenderer(); + + const activityKey: string = useMemo(() => getKeyByActivity(activity), [activity, getKeyByActivity]); + const hideAllTimestamps = groupTimestamp === false; + const isFirstInSenderGroup = + firstActivityInSenderGroup === activity || typeof firstActivityInSenderGroup === 'undefined'; + const isFirstInStatusGroup = + firstActivityInStatusGroup === activity || typeof firstActivityInStatusGroup === 'undefined'; + const isLastInSenderGroup = + lastActivityInSenderGroup === activity || typeof lastActivityInSenderGroup === 'undefined'; + const isLastInStatusGroup = + lastActivityInStatusGroup === activity || typeof lastActivityInStatusGroup === 'undefined'; + const renderAvatarForSenderGroup = useMemo( + () => !!renderAvatar && renderAvatar({ activity }), + [activity, renderAvatar] + ); + const isTopSideBotNub = isZeroOrPositive(bubbleNubOffset); + const isTopSideUserNub = isZeroOrPositive(bubbleFromUserNubOffset); + const renderActivityStatus = useMemo( + () => + createActivityStatusRenderer({ + activity, + nextVisibleActivity: undefined + }), + [activity, createActivityStatusRenderer] + ); + + const activityCallbackRef = useCallback( + (activityElement: HTMLElement) => { + activityElement + ? activityElementMapRef.current.set(activityKey, activityElement) + : activityElementMapRef.current.delete(activityKey); + }, + [activityElementMapRef, activityKey] + ); + const hideTimestamp = hideAllTimestamps || !isLastInStatusGroup; + const isTopSideNub = activity.from?.role === 'user' ? isTopSideUserNub : isTopSideBotNub; + + let showCallout: boolean; + + // Depending on the "showAvatarInGroup" setting, the avatar will render in different positions. + if (showAvatarInGroup === 'sender') { + if (isTopSideNub) { + showCallout = isFirstInSenderGroup && isFirstInStatusGroup; + } else { + showCallout = isLastInSenderGroup && isLastInStatusGroup; + } + } else if (showAvatarInGroup === 'status') { + if (isTopSideNub) { + showCallout = isFirstInStatusGroup; + } else { + showCallout = isLastInStatusGroup; + } + } else { + showCallout = true; + } + + const children = useMemo( + () => + renderActivity({ + hideTimestamp, + renderActivityStatus, + renderAvatar: renderAvatarForSenderGroup, + showCallout + }), + [hideTimestamp, renderActivity, renderActivityStatus, renderAvatarForSenderGroup, showCallout] + ); + + return ( + + {children} + + ); +}; + +export default memo(TranscriptActivity); +export { type TranscriptActivityProps }; diff --git a/packages/component/src/TranscriptActivity.tsx b/packages/component/src/TranscriptActivity.tsx deleted file mode 100644 index 3aef0b65fb..0000000000 --- a/packages/component/src/TranscriptActivity.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React, { memo, useCallback, useMemo } from 'react'; -import { hooks, type ActivityComponentFactory } from 'botframework-webchat-api'; -import type { ActivityElementMap } from './Transcript/types'; -import type { MutableRefObject, ReactNode } from 'react'; -import type { WebChatActivity } from 'botframework-webchat-core'; -import ActivityRow from './Transcript/ActivityRow'; - -const { useCreateActivityStatusRenderer } = hooks; - -function TranscriptActivity({ - activityElementMapRef, - activityKey, - activity, - hideTimestamp, - renderActivity, - renderAvatar, - showCallout -}: Readonly<{ - activityElementMapRef: MutableRefObject; - activityKey: string; - activity: WebChatActivity; - hideTimestamp: boolean; - renderActivity: Exclude, false>; - renderAvatar: false | (() => Exclude); - showCallout: boolean; -}>) { - const createActivityStatusRenderer = useCreateActivityStatusRenderer(); - const activityCallbackRef = useCallback( - (activityElement: HTMLElement) => { - activityElement - ? activityElementMapRef.current.set(activityKey, activityElement) - : activityElementMapRef.current.delete(activityKey); - }, - [activityElementMapRef, activityKey] - ); - - const renderActivityStatus = useMemo( - () => - createActivityStatusRenderer({ - activity, - nextVisibleActivity: undefined - }), - [activity, createActivityStatusRenderer] - ); - - const children = useMemo( - () => - renderActivity({ - hideTimestamp, - renderActivityStatus, - renderAvatar, - showCallout - }), - [hideTimestamp, renderActivity, renderActivityStatus, renderAvatar, showCallout] - ); - - return ( - - {children} - - ); -} - -export default memo(TranscriptActivity); diff --git a/packages/component/src/decorator/index.ts b/packages/component/src/decorator/index.ts index 382b48a18f..6bd18d65f9 100644 --- a/packages/component/src/decorator/index.ts +++ b/packages/component/src/decorator/index.ts @@ -1 +1 @@ -export { default as WebChatDecorator } from './private/Decorator'; +export { default as WebChatDecorator } from './private/WebChatDecorator'; diff --git a/packages/component/src/decorator/private/Decorator.tsx b/packages/component/src/decorator/private/WebChatDecorator.tsx similarity index 65% rename from packages/component/src/decorator/private/Decorator.tsx rename to packages/component/src/decorator/private/WebChatDecorator.tsx index e681b24ef4..defb349993 100644 --- a/packages/component/src/decorator/private/Decorator.tsx +++ b/packages/component/src/decorator/private/WebChatDecorator.tsx @@ -1,28 +1,30 @@ import { DecoratorComposer, type DecoratorMiddleware } from 'botframework-webchat-api/decorator'; import React, { memo, type ReactNode } from 'react'; -import ThemeProvider from '../../providers/Theme/ThemeProvider'; import BorderFlair from './BorderFlair'; import BorderLoader from './BorderLoader'; -import createStyles from './createStyles'; +import WebChatTheme from './WebChatTheme'; -const middleware: DecoratorMiddleware[] = [ +const middleware: readonly DecoratorMiddleware[] = Object.freeze([ init => init === 'activity border' && (next => request => (request.livestreamingState === 'completing' ? BorderFlair : next(request))), init => init === 'activity border' && (next => request => (request.livestreamingState === 'preparing' ? BorderLoader : next(request))) -]; +]); -const styles = createStyles(); +type WebChatDecoratorProps = Readonly<{ + readonly children?: ReactNode | undefined; +}>; -function WebChatDecorator({ children }: Readonly<{ readonly children?: ReactNode | undefined }>) { +function WebChatDecorator({ children }: WebChatDecoratorProps) { return ( - + {children} - + ); } export default memo(WebChatDecorator); +export { type WebChatDecoratorProps }; diff --git a/packages/component/src/decorator/private/WebChatTheme.tsx b/packages/component/src/decorator/private/WebChatTheme.tsx new file mode 100644 index 0000000000..db6b0d6168 --- /dev/null +++ b/packages/component/src/decorator/private/WebChatTheme.tsx @@ -0,0 +1,17 @@ +import React, { memo, type ReactNode } from 'react'; + +import ThemeProvider from '../../providers/Theme/ThemeProvider'; +import createStyles from './createStyles'; + +type WebChatThemeProps = Readonly<{ + readonly children?: ReactNode | undefined; +}>; + +const styles = createStyles(); + +function WebChatTheme({ children }: WebChatThemeProps) { + return {children}; +} + +export default memo(WebChatTheme); +export { type WebChatThemeProps }; diff --git a/packages/component/src/hooks/internal/useMemoWithPrevious.ts b/packages/component/src/hooks/internal/useMemoWithPrevious.ts index 31368d5bed..bdca53b35a 100644 --- a/packages/component/src/hooks/internal/useMemoWithPrevious.ts +++ b/packages/component/src/hooks/internal/useMemoWithPrevious.ts @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef } from 'react'; import type { DependencyList } from 'react'; -export default function useMemoWithPrevious(factory: (prevValue: T) => T, deps: DependencyList): T { +export default function useMemoWithPrevious(factory: (prevValue: T | undefined) => T, deps: DependencyList): T { const prevValueRef = useRef(); // We are building a `useMemo`-like hook, `deps` is passed as-is and `factory` is not one fo the dependencies. // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/packages/component/src/providers/ActivityTree/ActivityTreeComposer.tsx b/packages/component/src/providers/ActivityTree/ActivityTreeComposer.tsx deleted file mode 100644 index d5569b92ce..0000000000 --- a/packages/component/src/providers/ActivityTree/ActivityTreeComposer.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { hooks, type ActivityComponentFactory } from 'botframework-webchat-api'; -import type { WebChatActivity } from 'botframework-webchat-core'; -import React, { useMemo, type ReactNode } from 'react'; - -import useMemoWithPrevious from '../../hooks/internal/useMemoWithPrevious'; -import ActivityTreeContext from './private/Context'; -import { ActivityWithRenderer, ReadonlyActivityTree } from './private/types'; -import useActivitiesWithRenderer from './private/useActivitiesWithRenderer'; -import useActivityTreeWithRenderer from './private/useActivityTreeWithRenderer'; -import useActivityTreeContext from './private/useContext'; - -import type { ActivityTreeContextType } from './private/Context'; - -type ActivityTreeComposerProps = Readonly<{ children?: ReactNode | undefined }>; - -const { useActivities, useActivityKeys, useCreateActivityRenderer, useGetActivitiesByKey, useGetKeyByActivity } = hooks; - -const ActivityTreeComposer = ({ children }: ActivityTreeComposerProps) => { - const existingContext = useActivityTreeContext(false); - - if (existingContext) { - throw new Error('botframework-webchat internal: should not be nested.'); - } - - const [rawActivities] = useActivities(); - const getActivitiesByKey = useGetActivitiesByKey(); - const getKeyByActivity = useGetKeyByActivity(); - const activityKeys = useActivityKeys(); - - const activities = useMemo(() => { - const activities: WebChatActivity[] = []; - - if (!activityKeys) { - return rawActivities; - } - - for (const activity of rawActivities) { - // If an activity has multiple revisions, display the latest revision only at the position of the first revision. - - // "Activities with same key" means "multiple revisions of same activity." - const activitiesWithSameKey = getActivitiesByKey(getKeyByActivity(activity)); - - // TODO: We may want to send all revisions of activity to the middleware so they can render UI to see previous revisions. - activitiesWithSameKey?.[0] === activity && - activities.push(activitiesWithSameKey[activitiesWithSameKey.length - 1]); - } - - return Object.freeze(activities); - }, [activityKeys, getActivitiesByKey, getKeyByActivity, rawActivities]); - - const createActivityRenderer: ActivityComponentFactory = useCreateActivityRenderer(); - - const activitiesWithRenderer = useActivitiesWithRenderer(activities, createActivityRenderer); - - const activityTreeWithRenderer = useActivityTreeWithRenderer(activitiesWithRenderer); - - const flattenedActivityTreeWithRenderer = useMemoWithPrevious>( - prevFlattenedActivityTree => { - const nextFlattenedActivityTree = Object.freeze( - activityTreeWithRenderer.reduce( - (intermediate, entriesWithSameSender) => - entriesWithSameSender.reduce( - (intermediate, entriesWithSameSenderAndStatus) => - entriesWithSameSenderAndStatus.reduce((intermediate, entry) => { - intermediate.push(entry); - - return intermediate; - }, intermediate), - intermediate - ), - [] - ) - ); - - return nextFlattenedActivityTree.length === prevFlattenedActivityTree?.length && - nextFlattenedActivityTree.every((item, index) => item === prevFlattenedActivityTree[+index]) - ? prevFlattenedActivityTree - : nextFlattenedActivityTree; - }, - [activityTreeWithRenderer] - ); - - const contextValue: ActivityTreeContextType = useMemo( - () => ({ - activityTreeWithRendererState: Object.freeze([activityTreeWithRenderer]) as readonly [ReadonlyActivityTree], - flattenedActivityTreeWithRendererState: Object.freeze([flattenedActivityTreeWithRenderer]) as readonly [ - readonly ActivityWithRenderer[] - ] - }), - [activityTreeWithRenderer, flattenedActivityTreeWithRenderer] - ); - - return {children}; -}; - -export default ActivityTreeComposer; diff --git a/packages/component/src/providers/ActivityTree/private/Context.ts b/packages/component/src/providers/ActivityTree/private/Context.ts deleted file mode 100644 index dcd1c99b1d..0000000000 --- a/packages/component/src/providers/ActivityTree/private/Context.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { createContext } from 'react'; - -import type { ActivityWithRenderer, ReadonlyActivityTree } from './types'; - -type ActivityTreeContextType = { - activityTreeWithRendererState: readonly [ReadonlyActivityTree]; - flattenedActivityTreeWithRendererState: readonly [readonly ActivityWithRenderer[]]; -}; - -export default createContext(undefined); - -export type { ActivityTreeContextType }; diff --git a/packages/component/src/providers/ActivityTree/private/types.ts b/packages/component/src/providers/ActivityTree/private/types.ts deleted file mode 100644 index b0f40b0ed9..0000000000 --- a/packages/component/src/providers/ActivityTree/private/types.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { ActivityComponentFactory } from 'botframework-webchat-api'; -import type { WebChatActivity } from 'botframework-webchat-core'; - -type ActivityWithRenderer = { - activity: WebChatActivity; - renderActivity: Exclude, false>; -}; - -type ActivityTree = ActivityWithRenderer[][][]; -type ReadonlyActivityTree = readonly (readonly (readonly ActivityWithRenderer[])[])[]; - -export type { ActivityTree, ActivityWithRenderer, ReadonlyActivityTree }; diff --git a/packages/component/src/providers/ActivityTree/private/useActivitiesWithRenderer.ts b/packages/component/src/providers/ActivityTree/private/useActivitiesWithRenderer.ts deleted file mode 100644 index 82cc58f07e..0000000000 --- a/packages/component/src/providers/ActivityTree/private/useActivitiesWithRenderer.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { useMemo } from 'react'; -import type { WebChatActivity } from 'botframework-webchat-core'; - -import type { ActivityWithRenderer } from './types'; -import useMemoized from '../../../hooks/internal/useMemoized'; - -export default function useActivitiesWithRenderer( - activities: readonly WebChatActivity[], - createActivityRenderer -): readonly ActivityWithRenderer[] { - // Create a memoized context of the createActivityRenderer function. - const createActivityRendererMemoized = useMemoized( - (activity: WebChatActivity, nextVisibleActivity: WebChatActivity) => - createActivityRenderer({ activity, nextVisibleActivity }), - [createActivityRenderer] - ); - - const entries = useMemo(() => { - const activitiesWithRenderer: ActivityWithRenderer[] = []; - let nextVisibleActivity: WebChatActivity; - - for (let index = activities.length - 1; index >= 0; index--) { - const activity = activities[+index]; - const renderActivity = createActivityRendererMemoized(activity, nextVisibleActivity); - - if (renderActivity) { - activitiesWithRenderer.splice(0, 0, { - activity, - renderActivity - }); - - nextVisibleActivity = activity; - } - } - - return Object.freeze(activitiesWithRenderer); - }, [activities, createActivityRendererMemoized]); - - return entries; -} diff --git a/packages/component/src/providers/ActivityTree/private/useActivityTreeWithRenderer.ts b/packages/component/src/providers/ActivityTree/private/useActivityTreeWithRenderer.ts deleted file mode 100644 index db0f8c2a02..0000000000 --- a/packages/component/src/providers/ActivityTree/private/useActivityTreeWithRenderer.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { hooks } from 'botframework-webchat-api'; -import { useMemo } from 'react'; - -import type { WebChatActivity } from 'botframework-webchat-core'; -import intersectionOf from '../../../Utils/intersectionOf'; -import removeInline from '../../../Utils/removeInline'; -import type { ActivityWithRenderer, ReadonlyActivityTree } from './types'; - -const { useGroupActivities } = hooks; - -function validateAllEntriesTagged(entries: readonly T[], bins: readonly (readonly T[])[]): boolean { - return entries.every(entry => bins.some(bin => bin.includes(entry))); -} - -// Activity tree is a multidimensional array, while activities is a 1D array. -// - The first dimension of the array contains activities with same sender; -// - The second dimension of the array contains activities with same status. - -// [ -// [ -// // Both messages are from bot and is sent as a batch, we will group them as an array. -// 'Bot: Hello!' -// 'Bot: What can I help today?' -// ], -// [ -// 'User: What is the weather?' -// ], -// [ -// 'Bot: Let me look it up... hold on.' -// ], -// [ -// // This message is in a different group because it is more than a few seconds apart from the previous message. -// 'Bot: Here is the weather forecast.' -// ] -// ] - -function useActivityTreeWithRenderer(entries: readonly ActivityWithRenderer[]): ReadonlyActivityTree { - const groupActivities = useGroupActivities(); - const entryMap: Map = useMemo( - () => new Map(entries.map(entry => [entry.activity, entry])), - [entries] - ); - - // We bin activities in 2 different ways: - // - `activitiesBySender` is a 2D array containing activities with same sender - // - `activitiesByStatus` is a 2D array containing activities with same status - // Both arrays should contains all activities. - - const { entriesBySender, entriesByStatus } = useMemo<{ - entriesBySender: readonly (readonly ActivityWithRenderer[])[]; - entriesByStatus: readonly (readonly ActivityWithRenderer[])[]; - }>(() => { - const visibleActivities = [...entryMap.keys()]; - - const groupActivitiesResult = groupActivities({ activities: visibleActivities }); - - const activitiesBySender = groupActivitiesResult?.sender || []; - const activitiesByStatus = groupActivitiesResult?.status || []; - - const [entriesBySender, entriesByStatus] = [activitiesBySender, activitiesByStatus].map(bins => - bins.map(bin => bin.map(activity => entryMap.get(activity))) - ); - - if (!validateAllEntriesTagged(visibleActivities, activitiesBySender)) { - console.warn( - 'botframework-webchat: Not every activities are grouped in the "sender" property. Please fix "groupActivitiesMiddleware" and group every activities.' - ); - } - - if (!validateAllEntriesTagged(visibleActivities, activitiesByStatus)) { - console.warn( - 'botframework-webchat: Not every activities are grouped in the "status" property. Please fix "groupActivitiesMiddleware" and group every activities.' - ); - } - - return { - entriesBySender, - entriesByStatus - }; - }, [entryMap, groupActivities]); - - // Create a tree of activities with 2 dimensions: sender, followed by status. - - const activityTree: ReadonlyActivityTree = useMemo(() => { - const entriesPendingGrouping = [...entries]; - const activityTree: (readonly (readonly ActivityWithRenderer[])[])[] = []; - - while (entriesPendingGrouping.length) { - let found: boolean; - const entriesWithSameSender = entriesBySender.find(bin => bin.includes(entriesPendingGrouping[0])); - const senderTree: (readonly ActivityWithRenderer[])[] = []; - - entriesWithSameSender?.forEach(entry => { - const entriesWithSameStatus = entriesByStatus.find(bin => bin.includes(entry)); - - const entriesWithSameSenderAndStatus = intersectionOf( - entriesPendingGrouping, - entriesWithSameSender, - entriesWithSameStatus - ); - - if (entriesWithSameSenderAndStatus.length) { - senderTree.push(Object.freeze(entriesWithSameSenderAndStatus)); - removeInline(entriesPendingGrouping, ...entriesWithSameSenderAndStatus); - - found = true; - } - }); - - // If the entry is not grouped by the middleware, just put the entry in its own bin. - found || senderTree.push(Object.freeze([entriesPendingGrouping.shift()])); - - activityTree.push(Object.freeze(senderTree)); - } - - // Assertion: All entries must be assigned to the activityTree. - if ( - !entries.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.', { - entries, - activityTree - }); - } - - return Object.freeze(activityTree); - }, [entriesBySender, entriesByStatus, entries]); - - return activityTree; -} - -export type { ActivityWithRenderer }; - -export default useActivityTreeWithRenderer; diff --git a/packages/component/src/providers/ActivityTree/private/useContext.ts b/packages/component/src/providers/ActivityTree/private/useContext.ts deleted file mode 100644 index d543fe4a18..0000000000 --- a/packages/component/src/providers/ActivityTree/private/useContext.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { useContext } from 'react'; - -import ActivityTreeContext from './Context'; - -import type { ActivityTreeContextType } from './Context'; - -export default function useActivityTreeContext(thrownOnUndefined = true): ActivityTreeContextType { - const contextValue = useContext(ActivityTreeContext); - - if (thrownOnUndefined && !contextValue) { - throw new Error('botframework-webchat internal: This hook can only be used under .'); - } - - return contextValue; -} diff --git a/packages/component/src/providers/ActivityTree/useActivityTreeWithRenderer.ts b/packages/component/src/providers/ActivityTree/useActivityTreeWithRenderer.ts deleted file mode 100644 index dda028c7df..0000000000 --- a/packages/component/src/providers/ActivityTree/useActivityTreeWithRenderer.ts +++ /dev/null @@ -1,16 +0,0 @@ -import useActivityTreeContext from './private/useContext'; - -import type { ActivityWithRenderer, ReadonlyActivityTree } from './private/types'; - -export default function useActivityTreeWithRenderer(options?: { flat?: false }): readonly [ReadonlyActivityTree]; -export default function useActivityTreeWithRenderer(options: { - flat: true; -}): readonly [readonly ActivityWithRenderer[]]; - -export default function useActivityTreeWithRenderer(options: { flat?: boolean } = {}) { - const context = useActivityTreeContext(); - - return options?.flat === true - ? context.flattenedActivityTreeWithRendererState - : context.activityTreeWithRendererState; -} diff --git a/packages/component/src/providers/ChatHistoryDOM/ChatHistoryDOMComposer.tsx b/packages/component/src/providers/ChatHistoryDOM/ChatHistoryDOMComposer.tsx new file mode 100644 index 0000000000..4213b5b87f --- /dev/null +++ b/packages/component/src/providers/ChatHistoryDOM/ChatHistoryDOMComposer.tsx @@ -0,0 +1,18 @@ +import React, { memo, useMemo, useRef, type ReactNode } from 'react'; +import ChatHistoryDOMContext, { type ChatHistoryDOMContextType } from './private/ChatHistoryDOMContext'; + +type ChatHistoryDOMComposerProps = Readonly<{ + children?: ReactNode | undefined; +}>; + +const ChatHistoryDOMComposer = ({ children }: ChatHistoryDOMComposerProps) => { + const activityElementRef = useRef>(new Map()); + const context = useMemo(() => ({ activityElementRef }), [activityElementRef]); + + return {children}; +}; + +ChatHistoryDOMComposer.displayName = 'ChatHistoryDOMComposer'; + +export default memo(ChatHistoryDOMComposer); +export { type ChatHistoryDOMComposerProps }; diff --git a/packages/component/src/providers/ChatHistoryDOM/private/ChatHistoryDOMContext.ts b/packages/component/src/providers/ChatHistoryDOM/private/ChatHistoryDOMContext.ts new file mode 100644 index 0000000000..f068eca2be --- /dev/null +++ b/packages/component/src/providers/ChatHistoryDOM/private/ChatHistoryDOMContext.ts @@ -0,0 +1,11 @@ +import { type MutableRefObject } from 'react'; +import createContextAndHook from '../../createContextAndHook'; + +type ChatHistoryDOMContextType = Readonly<{ + activityElementRef: MutableRefObject>; +}>; + +const { contextComponentType, useContext } = createContextAndHook('ChatHistoryDOMContext'); + +export default contextComponentType; +export { useContext as useChatHistoryDOMContext, type ChatHistoryDOMContextType }; diff --git a/packages/component/src/providers/ChatHistoryDOM/useActivityElementRef.ts b/packages/component/src/providers/ChatHistoryDOM/useActivityElementRef.ts new file mode 100644 index 0000000000..516c5e9c5a --- /dev/null +++ b/packages/component/src/providers/ChatHistoryDOM/useActivityElementRef.ts @@ -0,0 +1,6 @@ +import { type RefObject } from 'react'; +import { useChatHistoryDOMContext } from './private/ChatHistoryDOMContext'; + +export default function useActivityElementMapRef(): RefObject> { + return useChatHistoryDOMContext().activityElementRef; +} diff --git a/packages/component/src/providers/GroupedRenderingActivities/GroupedRenderingActivities.ts b/packages/component/src/providers/GroupedRenderingActivities/GroupedRenderingActivities.ts new file mode 100644 index 0000000000..ee5a4f0e98 --- /dev/null +++ b/packages/component/src/providers/GroupedRenderingActivities/GroupedRenderingActivities.ts @@ -0,0 +1,10 @@ +import { type WebChatActivity } from 'botframework-webchat-core'; + +type GroupedRenderingActivities = Readonly<{ + activities: readonly WebChatActivity[]; + children: readonly GroupedRenderingActivities[]; + key: string; + groupingName: string | undefined; +}>; + +export { type GroupedRenderingActivities }; diff --git a/packages/component/src/providers/GroupedRenderingActivities/GroupedRenderingActivitiesComposer.tsx b/packages/component/src/providers/GroupedRenderingActivities/GroupedRenderingActivitiesComposer.tsx new file mode 100644 index 0000000000..614cdd7428 --- /dev/null +++ b/packages/component/src/providers/GroupedRenderingActivities/GroupedRenderingActivitiesComposer.tsx @@ -0,0 +1,87 @@ +import { hooks } from 'botframework-webchat-api'; +import { type WebChatActivity } from 'botframework-webchat-core'; +import React, { memo, useMemo } from 'react'; +import { object, optional, parse, pipe, readonly, type InferOutput } from 'valibot'; + +import reactNode from '../../types/internal/reactNode'; +import useRenderingActivities from '../RenderingActivities/useRenderingActivities'; +import { type GroupedRenderingActivities } from './GroupedRenderingActivities'; +import GroupedRenderingActivitiesContext, { + type GroupedRenderingActivitiesContextType +} from './private/GroupedRenderingActivitiesContext'; + +const { useGetKeyByActivity, useGroupActivitiesByName, useStyleOptions } = hooks; + +const groupedRenderingActivitiesComposerPropsSchema = pipe( + object({ + children: optional(reactNode()) + }), + readonly() +); + +type GroupedRenderingActivitiesComposerProps = InferOutput; + +const GroupedRenderingActivitiesComposer = (props: GroupedRenderingActivitiesComposerProps) => { + const { children } = parse(groupedRenderingActivitiesComposerPropsSchema, props); + + const [{ groupActivitiesBy }] = useStyleOptions(); + const [activities] = useRenderingActivities(); + const getKeyByActivity = useGetKeyByActivity(); + const groupActivitiesByName = useGroupActivitiesByName(); + + const numRenderingActivitiesState = useMemo( + () => Object.freeze([activities.length] as const), + [activities] + ); + + const groupedRenderingActivitiesState = useMemo(() => { + const run = ( + activities: readonly WebChatActivity[], + groups: readonly string[] + ): readonly GroupedRenderingActivities[] => { + const [name, ...nextNames] = groups; + + if (typeof name === 'undefined') { + return Object.freeze([ + Object.freeze({ + activities, + children: Object.freeze([]), + key: getKeyByActivity(activities[0]), + groupingName: undefined + }) + ]); + } + + return Object.freeze( + groupActivitiesByName(activities, name).map(grouping => + Object.freeze({ + activities: grouping, + children: run(grouping, nextNames), + groupingName: name, + key: getKeyByActivity(grouping[0]) + } satisfies GroupedRenderingActivities) + ) + ); + }; + + return Object.freeze([run(activities, groupActivitiesBy)]); + }, [activities, getKeyByActivity, groupActivitiesBy, groupActivitiesByName]); + + const context = useMemo( + () => + Object.freeze({ + groupedRenderingActivitiesState, + numRenderingActivitiesState + }), + [groupedRenderingActivitiesState, numRenderingActivitiesState] + ); + + return ( + {children} + ); +}; + +GroupedRenderingActivitiesComposer.displayName = 'GroupedRenderingActivitiesComposer'; + +export default memo(GroupedRenderingActivitiesComposer); +export { type GroupedRenderingActivitiesComposerProps }; diff --git a/packages/component/src/providers/GroupedRenderingActivities/private/GroupedRenderingActivitiesContext.ts b/packages/component/src/providers/GroupedRenderingActivities/private/GroupedRenderingActivitiesContext.ts new file mode 100644 index 0000000000..caa4d5c779 --- /dev/null +++ b/packages/component/src/providers/GroupedRenderingActivities/private/GroupedRenderingActivitiesContext.ts @@ -0,0 +1,14 @@ +import createContextAndHook from '../../createContextAndHook'; +import { type GroupedRenderingActivities } from '../GroupedRenderingActivities'; + +type GroupedRenderingActivitiesContextType = Readonly<{ + groupedRenderingActivitiesState: readonly [readonly GroupedRenderingActivities[]]; + numRenderingActivitiesState: readonly [number]; +}>; + +const { contextComponentType, useContext } = createContextAndHook( + 'GroupedRenderingActivitiesContext' +); + +export default contextComponentType; +export { useContext as useGroupedRenderingActivitiesContext, type GroupedRenderingActivitiesContextType }; diff --git a/packages/component/src/providers/GroupedRenderingActivities/private/group.spec.ts b/packages/component/src/providers/GroupedRenderingActivities/private/group.spec.ts new file mode 100644 index 0000000000..3a715073d4 --- /dev/null +++ b/packages/component/src/providers/GroupedRenderingActivities/private/group.spec.ts @@ -0,0 +1,29 @@ +import group from './group'; + +test('when nothing is passed should return empty array', () => { + expect( + group([], () => { + throw new Error('Should not call'); + }) + ).toEqual([]); +}); + +test('when a single item is passed should return it as grouped', () => { + expect(group([1], () => [1])).toEqual([[1]]); +}); + +test('when two separate items is passed should return it ungrouped', () => { + expect(group([1, 2], jest.fn().mockReturnValueOnce([1]).mockReturnValueOnce([2]))).toEqual([[1], [2]]); +}); + +test('with 3 items in 2 groups should return it grouped properly', () => { + expect(group([1, 2, 3], jest.fn().mockReturnValueOnce([1, 3]).mockReturnValueOnce([2]))).toEqual([[1, 3], [2]]); +}); + +test('when return items not in the array should be ignored', () => { + expect(group([1, 2], jest.fn().mockReturnValueOnce([1, 3]).mockReturnValueOnce([2]))).toEqual([[1], [2]]); +}); + +test('when grouping with undefined', () => { + expect(group([1, 2], () => undefined)).toEqual([[1], [2]]); +}); diff --git a/packages/component/src/providers/GroupedRenderingActivities/private/group.ts b/packages/component/src/providers/GroupedRenderingActivities/private/group.ts new file mode 100644 index 0000000000..621982bdb1 --- /dev/null +++ b/packages/component/src/providers/GroupedRenderingActivities/private/group.ts @@ -0,0 +1,19 @@ +import pick from './pick'; + +export default function group( + ungrouped: Iterable, + getItemsOfSameGroup: (item: T) => Iterable | undefined +): readonly (readonly T[])[] { + let processing: readonly T[] = Array.from(ungrouped); + const result: (readonly T[])[] = []; + + while (processing.length) { + const [value] = processing; + const [left, right] = pick(processing, [value, ...(getItemsOfSameGroup(value) || [])]); + + processing = left; + result.push(right); + } + + return Object.freeze(result); +} diff --git a/packages/component/src/providers/GroupedRenderingActivities/private/pick.spec.ts b/packages/component/src/providers/GroupedRenderingActivities/private/pick.spec.ts new file mode 100644 index 0000000000..e68b5e1ecf --- /dev/null +++ b/packages/component/src/providers/GroupedRenderingActivities/private/pick.spec.ts @@ -0,0 +1,33 @@ +import pick from './pick'; + +test('when no item should return empty', () => { + expect(pick([], [])).toEqual([[], []]); +}); + +test('when picking non-existing item should return empty', () => { + expect(pick([], [1])).toEqual([[], []]); +}); + +test('when picking an item should remove item', () => { + expect(pick([1], [1])).toEqual([[], [1]]); +}); + +test('when item do not exist should not remove item', () => { + expect(pick([1], [2])).toEqual([[1], []]); +}); + +test('when item exist should remove item', () => { + expect(pick([1, 2, 3], [2])).toEqual([[1, 3], [2]]); +}); + +test('when not picking anything should return all items', () => { + expect(pick([1, 2, 3], [])).toEqual([[1, 2, 3], []]); +}); + +test('when picking two items should pick properly', () => { + expect(pick([1, 2, 3], [2, 3])).toEqual([[1], [2, 3]]); +}); + +test('when picking undefined', () => { + expect(pick([1], undefined)).toEqual([[1], []]); +}); diff --git a/packages/component/src/providers/GroupedRenderingActivities/private/pick.ts b/packages/component/src/providers/GroupedRenderingActivities/private/pick.ts new file mode 100644 index 0000000000..77d339ba50 --- /dev/null +++ b/packages/component/src/providers/GroupedRenderingActivities/private/pick.ts @@ -0,0 +1,22 @@ +export default function pick(array: Iterable, pick: Iterable): readonly [readonly T[], readonly T[]] { + if (!pick) { + return Object.freeze([Object.freeze(Array.from(array)), Object.freeze([])]); + } + + const pickArray = Array.from(pick); + + const [left, right] = Array.from(array).reduce( + ([left, right], item) => { + if (pickArray.includes(item)) { + right.push(item); + } else { + left.push(item); + } + + return [left, right]; + }, + [[], []] + ); + + return Object.freeze([Object.freeze(left), Object.freeze(right)]); +} diff --git a/packages/component/src/providers/GroupedRenderingActivities/useGroupedRenderingActivities.ts b/packages/component/src/providers/GroupedRenderingActivities/useGroupedRenderingActivities.ts new file mode 100644 index 0000000000..0ea8912dab --- /dev/null +++ b/packages/component/src/providers/GroupedRenderingActivities/useGroupedRenderingActivities.ts @@ -0,0 +1,6 @@ +import { type GroupedRenderingActivities } from './GroupedRenderingActivities'; +import { useGroupedRenderingActivitiesContext } from './private/GroupedRenderingActivitiesContext'; + +export default function useGroupedRenderingActivities(): readonly [readonly GroupedRenderingActivities[]] { + return useGroupedRenderingActivitiesContext().groupedRenderingActivitiesState; +} diff --git a/packages/component/src/providers/GroupedRenderingActivities/useNumRenderingActivities.ts b/packages/component/src/providers/GroupedRenderingActivities/useNumRenderingActivities.ts new file mode 100644 index 0000000000..f213160762 --- /dev/null +++ b/packages/component/src/providers/GroupedRenderingActivities/useNumRenderingActivities.ts @@ -0,0 +1,5 @@ +import { useGroupedRenderingActivitiesContext } from './private/GroupedRenderingActivitiesContext'; + +export default function useNumRenderingActivities(): readonly [number] { + return useGroupedRenderingActivitiesContext().numRenderingActivitiesState; +} diff --git a/packages/component/src/providers/RenderingActivities/RenderingActivitiesComposer.tsx b/packages/component/src/providers/RenderingActivities/RenderingActivitiesComposer.tsx new file mode 100644 index 0000000000..4f0f077fd6 --- /dev/null +++ b/packages/component/src/providers/RenderingActivities/RenderingActivitiesComposer.tsx @@ -0,0 +1,84 @@ +import { hooks, type ActivityComponentFactory } from 'botframework-webchat-api'; +import { type WebChatActivity } from 'botframework-webchat-core'; +import React, { memo, useMemo, type ReactNode } from 'react'; + +import RenderingActivitiesContext, { type RenderingActivitiesContextType } from './private/RenderingActivitiesContext'; +import useInternalActivitiesWithRenderer from './private/useInternalActivitiesWithRenderer'; + +type RenderingActivitiesComposerProps = Readonly<{ + children?: ReactNode | undefined; +}>; + +const { useActivities, useActivityKeys, useCreateActivityRenderer, useGetActivitiesByKey, useGetKeyByActivity } = hooks; + +const RenderingActivitiesComposer = ({ children }: RenderingActivitiesComposerProps) => { + const [activities] = useActivities(); + const activityKeys = useActivityKeys(); + const createActivityRenderer = useCreateActivityRenderer(); + const getActivitiesByKey = useGetActivitiesByKey(); + const getKeyByActivity = useGetKeyByActivity(); + + // TODO: Should move this logic into a new . + // The grouping would only show the latest one but it has access to previous. + const activitiesOfLatestRevision = useMemo(() => { + const activitiesOfLatestRevision: WebChatActivity[] = []; + + if (!activityKeys) { + return activities; + } + + for (const activity of activities) { + // If an activity has multiple revisions, display the latest revision only at the position of the first revision. + + // "Activities with same key" means "multiple revisions of same activity." + const activitiesWithSameKey = getActivitiesByKey(getKeyByActivity(activity)); + + // TODO: We may want to send all revisions of activity to the middleware so they can render UI to see previous revisions. + activitiesWithSameKey?.[0] === activity && + activitiesOfLatestRevision.push(activitiesWithSameKey[activitiesWithSameKey.length - 1]); + } + + return Object.freeze(activitiesOfLatestRevision); + }, [activityKeys, getActivitiesByKey, getKeyByActivity, activities]); + + const activitiesWithRenderer = useInternalActivitiesWithRenderer(activitiesOfLatestRevision, createActivityRenderer); + + const renderingActivitiesState = useMemo( + () => Object.freeze([activitiesWithRenderer.map(({ activity }) => activity)] as const), + [activitiesWithRenderer] + ); + + const renderingActivityKeysState = useMemo(() => { + const keys = Object.freeze(renderingActivitiesState[0].map(activity => getKeyByActivity(activity))); + + if (keys.some(key => !key)) { + throw new Error('botframework-webchat internal: activitiesWithRenderer[].activity must have activity key'); + } + + return Object.freeze([keys] as const); + }, [renderingActivitiesState, getKeyByActivity]); + + const renderActivityCallbackMap = useMemo< + ReadonlyMap, false>> + >( + () => + Object.freeze(new Map(activitiesWithRenderer.map(({ activity, renderActivity }) => [activity, renderActivity]))), + [activitiesWithRenderer] + ); + + const contextValue: RenderingActivitiesContextType = useMemo( + () => ({ + renderActivityCallbackMap, + renderingActivitiesState, + renderingActivityKeysState + }), + [renderActivityCallbackMap, renderingActivitiesState, renderingActivityKeysState] + ); + + return {children}; +}; + +RenderingActivitiesComposer.displayName = 'RenderingActivitiesComposer'; + +export default memo(RenderingActivitiesComposer); +export { type RenderingActivitiesComposerProps }; diff --git a/packages/component/src/providers/RenderingActivities/private/RenderingActivitiesContext.ts b/packages/component/src/providers/RenderingActivities/private/RenderingActivitiesContext.ts new file mode 100644 index 0000000000..86371ab958 --- /dev/null +++ b/packages/component/src/providers/RenderingActivities/private/RenderingActivitiesContext.ts @@ -0,0 +1,15 @@ +import { type ActivityComponentFactory } from 'botframework-webchat-api'; +import { type WebChatActivity } from 'botframework-webchat-core'; +import createContextAndHook from '../../createContextAndHook'; + +type RenderingActivitiesContextType = Readonly<{ + renderActivityCallbackMap: ReadonlyMap, false>>; + renderingActivitiesState: readonly [readonly WebChatActivity[]]; + renderingActivityKeysState: readonly [readonly string[]]; +}>; + +const { contextComponentType, useContext } = + createContextAndHook('RenderingActivitiesContext'); + +export default contextComponentType; +export { useContext as useRenderingActivitiesContext, type RenderingActivitiesContextType }; diff --git a/packages/component/src/providers/RenderingActivities/private/useInternalActivitiesWithRenderer.ts b/packages/component/src/providers/RenderingActivities/private/useInternalActivitiesWithRenderer.ts new file mode 100644 index 0000000000..069d398eb4 --- /dev/null +++ b/packages/component/src/providers/RenderingActivities/private/useInternalActivitiesWithRenderer.ts @@ -0,0 +1,78 @@ +import { type ActivityComponentFactory } from 'botframework-webchat-api'; +import { type WebChatActivity } from 'botframework-webchat-core'; +import { useMemo } from 'react'; + +import useMemoWithPrevious from '../../../hooks/internal/useMemoWithPrevious'; + +type ActivityWithRenderer = Readonly<{ + activity: WebChatActivity; + renderActivity: Exclude, false>; +}>; + +type Call = Readonly<{ + activity: WebChatActivity; + nextVisibleActivity: WebChatActivity; + renderActivity: Exclude, false>; +}>; + +type Run = Readonly<{ + createActivityRenderer: ActivityComponentFactory; + calls: readonly Call[]; +}>; + +export default function useInternalActivitiesWithRenderer( + activities: readonly WebChatActivity[], + createActivityRenderer: ActivityComponentFactory +): readonly ActivityWithRenderer[] { + const run = useMemoWithPrevious( + prevRun => { + if (prevRun && !Object.is(prevRun.createActivityRenderer, createActivityRenderer)) { + // If `createActivityRenderer` changed, invalidate the cache. + prevRun = undefined; + } + + const calls: Call[] = []; + let nextVisibleActivity: undefined | WebChatActivity; + + for (let index = activities.length - 1; index >= 0; index--) { + const activity = activities[+index]; + + const prevEntry = prevRun?.calls.find( + entry => Object.is(activity, entry.activity) && Object.is(nextVisibleActivity, entry.nextVisibleActivity) + ); + + if (prevEntry) { + calls.unshift(prevEntry); + + nextVisibleActivity = activity; + } else { + const renderActivity = createActivityRenderer({ activity, nextVisibleActivity }); + + if (renderActivity) { + calls.unshift( + Object.freeze({ + activity, + nextVisibleActivity, + renderActivity + }) + ); + + nextVisibleActivity = activity; + } + } + } + + return Object.freeze({ createActivityRenderer, calls: Object.freeze(calls) }); + }, + [activities, createActivityRenderer] + ); + + return useMemo( + () => + run.calls.map(call => ({ + activity: call.activity, + renderActivity: call.renderActivity + })), + [run] + ); +} diff --git a/packages/component/src/providers/RenderingActivities/useGetRenderActivityCallback.ts b/packages/component/src/providers/RenderingActivities/useGetRenderActivityCallback.ts new file mode 100644 index 0000000000..9b19da7118 --- /dev/null +++ b/packages/component/src/providers/RenderingActivities/useGetRenderActivityCallback.ts @@ -0,0 +1,11 @@ +import { type ActivityComponentFactory } from 'botframework-webchat-api'; +import { type WebChatActivity } from 'botframework-webchat-core'; + +import { useRenderingActivitiesContext } from './private/RenderingActivitiesContext'; + +export default function useGetRenderActivityCallback() { + const { renderActivityCallbackMap } = useRenderingActivitiesContext(); + + return (activity: WebChatActivity): Exclude, false> | undefined => + renderActivityCallbackMap.get(activity); +} diff --git a/packages/component/src/providers/RenderingActivities/useRenderingActivities.ts b/packages/component/src/providers/RenderingActivities/useRenderingActivities.ts new file mode 100644 index 0000000000..208ef3b022 --- /dev/null +++ b/packages/component/src/providers/RenderingActivities/useRenderingActivities.ts @@ -0,0 +1,7 @@ +import { type WebChatActivity } from 'botframework-webchat-core'; + +import { useRenderingActivitiesContext } from './private/RenderingActivitiesContext'; + +export default function useRenderingActivities(): readonly [readonly WebChatActivity[]] { + return useRenderingActivitiesContext().renderingActivitiesState; +} diff --git a/packages/component/src/providers/RenderingActivities/useRenderingActivityKeys.ts b/packages/component/src/providers/RenderingActivities/useRenderingActivityKeys.ts new file mode 100644 index 0000000000..226ea596db --- /dev/null +++ b/packages/component/src/providers/RenderingActivities/useRenderingActivityKeys.ts @@ -0,0 +1,5 @@ +import { useRenderingActivitiesContext } from './private/RenderingActivitiesContext'; + +export default function useRenderingActivityKeys(): readonly [readonly string[]] { + return useRenderingActivitiesContext().renderingActivityKeysState; +} diff --git a/packages/component/src/providers/Theme/ThemeProvider.tsx b/packages/component/src/providers/Theme/ThemeProvider.tsx index ba74bb18b8..2d7cf0ed44 100644 --- a/packages/component/src/providers/Theme/ThemeProvider.tsx +++ b/packages/component/src/providers/Theme/ThemeProvider.tsx @@ -7,13 +7,13 @@ type Props = Readonly<{ children?: ReactNode | undefined } & Partial; }>; -function last(array: ArrayLike) { - return array[array.length - 1]; -} - function uniqueId(count = Infinity) { return ( random() @@ -33,10 +24,9 @@ function uniqueId(count = Infinity) { ); } -const TranscriptFocusComposer: FC = ({ children, containerRef }) => { - const [flattenedActivityTree] = useActivityTreeWithRenderer({ flat: true }); +const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComposerProps) => { + const [renderingActivityKeys] = useRenderingActivityKeys(); const [_, setRawFocusedActivityKey, rawFocusedActivityKeyRef] = useStateRef(); - const getKeyByActivity = useGetKeyByActivity(); // As we need to use IDREF for `aria-activedescendant`, // this prefix will differentiate multiple instances of transcript on the same page. @@ -48,11 +38,6 @@ const TranscriptFocusComposer: FC = ({ children, c [prefix] ); - const renderingActivityKeys = useMemo( - () => Object.freeze(flattenedActivityTree.map(({ activity }) => getKeyByActivity(activity))), - [flattenedActivityTree, getKeyByActivity] - ); - const renderingActivityKeysRef = useValueRef(renderingActivityKeys); // While the transcript or any descendants are not focused, if the transcript is updated, reset the user-selected active descendant. @@ -66,7 +51,9 @@ const TranscriptFocusComposer: FC = ({ children, c const { current: rawFocusedActivityKey } = rawFocusedActivityKeyRef; const focusedActivityKey = useMemo( - () => (renderingActivityKeys.includes(rawFocusedActivityKey) ? rawFocusedActivityKey : last(renderingActivityKeys)), + () => + // eslint-disable-next-line no-magic-numbers + renderingActivityKeys.includes(rawFocusedActivityKey) ? rawFocusedActivityKey : renderingActivityKeys.at(-1), [renderingActivityKeys, rawFocusedActivityKey] ); @@ -97,7 +84,8 @@ const TranscriptFocusComposer: FC = ({ children, c const activeDescendantId = getDescendantIdByActivityKey( activityKey === false ? // If "activityKey" is false, it means "focus nothing and reset it to the last activity". - last(renderingActivityKeysRef.current) + // eslint-disable-next-line no-magic-numbers + renderingActivityKeysRef.current.at(-1) : activityKey && activityKey !== true ? // If "activity" is not "undefined" and not "true", it means "focus on this activity". activityKey @@ -168,6 +156,8 @@ const TranscriptFocusComposer: FC = ({ children, c return {children}; }; +TranscriptFocusComposer.displayName = 'TranscriptFocusComposer'; + TranscriptFocusComposer.propTypes = { // PropTypes is not fully compatible with TypeScript. // @ts-ignore @@ -176,4 +166,4 @@ TranscriptFocusComposer.propTypes = { }).isRequired }; -export default TranscriptFocusComposer; +export default memo(TranscriptFocusComposer); diff --git a/packages/component/src/providers/createContextAndHook.ts b/packages/component/src/providers/createContextAndHook.ts new file mode 100644 index 0000000000..17704cded0 --- /dev/null +++ b/packages/component/src/providers/createContextAndHook.ts @@ -0,0 +1,23 @@ +import { createContext, useContext as reactUseContext, type Context } from 'react'; + +export default function createContextAndHook( + displayName: string +): Readonly<{ + contextComponentType: Context; + useContext: () => T; +}> { + const contextComponentType = createContext( + new Proxy({} as T, { + get() { + throw new Error(`botframework-webchat: This hook can only be used under <${displayName}>`); + } + }) + ); + + contextComponentType.displayName = displayName; + + return { + contextComponentType, + useContext: (): T => reactUseContext(contextComponentType) + }; +} diff --git a/packages/component/src/types/internal/mutableRefObject.ts b/packages/component/src/types/internal/mutableRefObject.ts new file mode 100644 index 0000000000..efe7758fa8 --- /dev/null +++ b/packages/component/src/types/internal/mutableRefObject.ts @@ -0,0 +1,33 @@ +import { + any, + check, + object, + pipe, + safeParse, + type BaseIssue, + type BaseSchema, + type ErrorMessage, + type ObjectIssue, + type ObjectSchema +} from 'valibot'; + +function mutableRefObject>>( + baseSchema: TInput +): ObjectSchema<{ current: TInput }, undefined>; + +function mutableRefObject< + TInput extends BaseSchema>, + const TMessage extends ErrorMessage | undefined +>(baseSchema: TInput, message: TMessage): ObjectSchema<{ current: TInput }, TMessage>; + +function mutableRefObject< + TInput extends BaseSchema>, + const TMessage extends ErrorMessage | undefined +>(baseSchema: TInput, message?: TMessage): BaseSchema> { + return pipe( + any(), + check(value => safeParse(object({ current: baseSchema }, message), value).success) + ); +} + +export default mutableRefObject; diff --git a/packages/component/src/types/internal/reactNode.ts b/packages/component/src/types/internal/reactNode.ts new file mode 100644 index 0000000000..cbb9e06d61 --- /dev/null +++ b/packages/component/src/types/internal/reactNode.ts @@ -0,0 +1,16 @@ +import { type ReactNode } from 'react'; +import { custom, type CustomIssue, type CustomSchema, type ErrorMessage } from 'valibot'; + +function reactNode(): CustomSchema; + +function reactNode< + const TMessage extends ErrorMessage | undefined = ErrorMessage | undefined +>(message: TMessage): CustomSchema; + +function reactNode< + const TMessage extends ErrorMessage | undefined = ErrorMessage | undefined +>(message?: TMessage): CustomSchema { + return custom(() => true, message); +} + +export default reactNode; diff --git a/packages/fluent-theme/src/private/FluentThemeProvider.tsx b/packages/fluent-theme/src/private/FluentThemeProvider.tsx index 39a4e38685..2f80e593d1 100644 --- a/packages/fluent-theme/src/private/FluentThemeProvider.tsx +++ b/packages/fluent-theme/src/private/FluentThemeProvider.tsx @@ -1,5 +1,10 @@ import { type ActivityMiddleware, type StyleOptions, type TypingIndicatorMiddleware } from 'botframework-webchat-api'; -import { DecoratorComposer, DecoratorMiddleware } from 'botframework-webchat-api/decorator'; +import { + DecoratorComposer, + DecoratorMiddleware, + type DecoratorMiddlewareInit, + type DecoratorMiddlewareTypes +} from 'botframework-webchat-api/decorator'; import { Components } from 'botframework-webchat-component'; import { WebChatDecorator } from 'botframework-webchat-component/decorator'; import React, { memo, type ReactNode } from 'react'; @@ -18,7 +23,10 @@ import VariantComposer, { VariantList } from './VariantComposer'; const { ThemeProvider } = Components; -type Props = Readonly<{ children?: ReactNode | undefined; variant?: VariantList | undefined }>; +type FluentThemeProviderProps = Readonly<{ + children?: ReactNode | undefined; + variant?: VariantList | undefined; +}>; const activityMiddleware: readonly ActivityMiddleware[] = Object.freeze([ () => @@ -45,11 +53,14 @@ const activityMiddleware: readonly ActivityMiddleware[] = Object.freeze([ const sendBoxMiddleware = [() => () => () => PrimarySendBox]; -const decoratorMiddleware: DecoratorMiddleware[] = [ - init => +const decoratorMiddleware: readonly DecoratorMiddleware[] = Object.freeze([ + (init: DecoratorMiddlewareInit) => init === 'activity border' && - (next => request => (request.livestreamingState === 'preparing' ? ActivityLoader : next(request))) -]; + ((next => request => + request.livestreamingState === 'preparing' + ? ActivityLoader + : next(request)) satisfies DecoratorMiddlewareTypes['activity border']) +]); const styles = createStyles(); @@ -64,26 +75,29 @@ const typingIndicatorMiddleware = Object.freeze([ args[0].visible ? : next(...args) ] satisfies TypingIndicatorMiddleware[]); -const FluentThemeProvider = ({ children, variant = 'fluent' }: Props) => ( - - - - - - - {children} - - - - - - -); +function FluentThemeProvider({ children, variant = 'fluent' }: FluentThemeProviderProps) { + return ( + + + + + + + {children} + + + + + + + ); +} export default memo(FluentThemeProvider); +export { type FluentThemeProviderProps }; diff --git a/packages/test/harness/src/host/common/host/snapshot.js b/packages/test/harness/src/host/common/host/snapshot.js index a62a6cb7fc..5728a26501 100644 --- a/packages/test/harness/src/host/common/host/snapshot.js +++ b/packages/test/harness/src/host/common/host/snapshot.js @@ -7,7 +7,7 @@ const takeStabilizedScreenshot = require('../takeStabilizedScreenshot'); const testRoot = join(__dirname, '../../../../../../../__tests__/html/'); module.exports = webDriver => - async function snapshot(mode) { + async function snapshot(mode, options) { await allImagesCompleted(webDriver); const screenshot = await takeStabilizedScreenshot(webDriver); @@ -25,5 +25,7 @@ module.exports = webDriver => } ); - await checkAccessibilty(webDriver)(); + if (!options?.skipCheckAccessibility) { + await checkAccessibilty(webDriver)(); + } }; diff --git a/packages/test/harness/src/host/dev/hostOverrides/snapshot.js b/packages/test/harness/src/host/dev/hostOverrides/snapshot.js index 94429b965d..9636ed5e52 100644 --- a/packages/test/harness/src/host/dev/hostOverrides/snapshot.js +++ b/packages/test/harness/src/host/dev/hostOverrides/snapshot.js @@ -5,7 +5,7 @@ const takeStabilizedScreenshot = require('../../common/takeStabilizedScreenshot' // In dev mode, we output the screenshot in console instead of checking against a PNG file. module.exports = webDriver => - async function snapshot() { + async function snapshot(_, options) { await allImagesCompleted(webDriver); const base64 = await takeStabilizedScreenshot(webDriver); @@ -29,5 +29,7 @@ module.exports = webDriver => base64 ); - await checkAccessibility(webDriver)(); + if (!options?.skipCheckAccessibility) { + await checkAccessibility(webDriver)(); + } };