diff --git a/.eslintrc.yml b/.eslintrc.yml index 0ac026ba64..eb12c3c9d9 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -55,7 +55,7 @@ overrides: no-unused-vars: off '@typescript-eslint/no-unused-vars': - error - - argsIgnorePattern: ^_$ + - argsIgnorePattern: ^_ varsIgnorePattern: ^_ no-empty-function: off diff --git a/.github/agents/polymiddleware-promoter.agent.md b/.github/agents/polymiddleware-promoter.agent.md new file mode 100644 index 0000000000..4a868b0ceb --- /dev/null +++ b/.github/agents/polymiddleware-promoter.agent.md @@ -0,0 +1,45 @@ +--- +name: Polymiddleware promoter +description: Upgrade middleware to polymiddleware +argument-hint: The existing middleware to upgrade +# tools: ['vscode', 'execute', 'read', 'agent', 'edit', 'search', 'web', 'todo'] # specify the tools this agent can use. If not set, all enabled tools are allowed. +--- + + + +You are a developer upgrading middleware to polymiddleware. + +Polymiddleware is newer, while middleware is older and should be upgraded. + +Middleware and polymiddleware are pattern for plug-ins and our customization story. There are 2 sides: writing the middleware and using the middleware. Web Chat write and use the middleware. 3P developers write middleware and pass it to Web Chat. + +Polymiddleware is a single middleware that process multiple types of middleware. Middleware is more like `request => (props => view) | undefined`, while polymiddleware is `init => (request => (props => view) | undefined) | undefined`. + +The middleware philosophy can be found at https://npmjs.com/package/react-chain-of-responsibility. + +When middleware receive a request, it decides if it want to process the request. If yes, it will return a React component. If no, it will pass it to the next middleware. + +Definition of polymiddleware are at `packages/api-middleware/src/index.ts`. + +Definition of middleware are scattered around but entrypoint at `packages/api/src/hooks/Composer.tsx`. + +- You MUST upgrade all the usage of existing middleware to polymiddleware +- You MUST write a legacy bridge to convert existing middleware into polymiddleware, look at `packages/api/src/legacy` +- All tests MUST be visual regression tests, expectations MUST live inside the generated PNGs +- You MUST NOT update any existing PNGs, as it means breaking existing feature +- You MUST write migration tests: write a old middleware and pass it, it should render as expected because the code went through the new legacy bridge +- You MUST write polymiddleware test: write a new polymiddleware and pass it, it should render +- For each category of test, you MUST test it in 4 different way: + 1. Add new UI that will process new type of requests + - You MUST verify existing middleware does not process that new type of request, only new polymiddleware does + 2. Delete existing UI: request processed by existing middleware should no longer process + 3. Replace UI that was processed by existing middleware, but now processed by a new middleware + 4. Decorate existing UI but wrapping the result from existing middleware, commonly with a border component +- "request" vs. "props" + - Code processing the request MUST NOT call hooks + - Code processing the request decide to render a React component or not + - Code processing the props MUST render, minimally, `` or `null`, they are processed by React + - Request SHOULD contains information about "should render or not" + - Props SHOULD contains information about "how to render" +- You MUST NOT remove the existing middleware from ``, however, print a deprecation warn-once, then bridge it to the polymiddleware +- You SHOULD NOT export the ``, `XXXProviderProps`, and `extractXXXEnhancer` diff --git a/.github/workflows/pull-request-validation.yml b/.github/workflows/pull-request-validation.yml index 5e14e26873..370f51e8ee 100644 --- a/.github/workflows/pull-request-validation.yml +++ b/.github/workflows/pull-request-validation.yml @@ -101,7 +101,7 @@ jobs: strategy: matrix: os: - - macos-latest + - macos-26 - ubuntu-latest - windows-latest runs-on: ${{ matrix.os }} diff --git a/AGENTS.md b/AGENTS.md index cf4fafc8de..b469f44c1c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,7 @@ - Prefer uppercase for acronyms instead of Pascal case, e.g. `getURL()` over `getUrl()` - The only exception is `id`, e.g. `getId()` over `getID()` - Use fewer shorthands, only allow `min`, `max`, `num` +- Prefer ternary operator over one-liner `if` statement ### Design @@ -35,6 +36,7 @@ ### Typing - TypeScript is best-effort checking, use `valibot` for strict type checking +- Use TypeScript CLI instead of `tsc` - Use `valibot` for runtime type checker, never use `zod` - Assume all externally exported functions will receive unsafe/invalid input, always check with `valibot` - Avoid `any` @@ -100,9 +102,15 @@ export { MyComponentPropsSchema, type MyComponentProps }; - Use `@testduet/given-when-then` package instead of xUnit style `describe`/`before`/`test`/`after` - Prefer integration/end-to-end testing than unit testing - Use as realistic setup as possible, such as using `msw` than mocking calls +- Use `emulateIncomingActivity` and `emulateOutgoingActivity` to emulate conversation ## PR instructions - Run new test and all of them must be green - Run `npm run precommit` to make sure it pass all linting process - Add changelog entry to `CHANGELOG.md`, follow our existing format + +## Code review + +- Code should use as much immutable (via `Object.freeze()`) as possible, DO NOT trust `readonly` +- All inputs SHOULD be validated diff --git a/CHANGELOG.md b/CHANGELOG.md index 33924543b4..b8860d13e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ Breaking changes in this release: - 💥 Root-level (unconnected) `Claim` entity is being deprecated, in PR [#5564](https://github.com/microsoft/BotFramework-WebChat/pull/5564), by [@compulim](https://github.com/compulim). It will be removed on or after 2027-08-29 - Use `entities[@id=""][@type="Message"].citation[@type="Claim"]` instead - 💥 `activityStatusMiddleware.nextVisibleActivity` and `activityStatusMiddleware.sameTimestampGroup` is removed after deprecation, in PR [#5565](https://github.com/microsoft/BotFramework-WebChat/issues/5565), by [@compulim](https://github.com/compulim) +- 💥 `avatarMiddleware` is being deprecated in favor of [`polymiddleware`](./docs/MIDDLEWARE.md). It will be removed on or after 2028-03-18, related to PR [#5779](https://github.com/microsoft/BotFramework-WebChat/pull/5779) ### Added @@ -111,8 +112,10 @@ Breaking changes in this release: - Updated `BasicSendBoxToolbar` to rely solely on `disableFileUpload`. - Added support for livestreaming via `entities[type="streaminfo"]` in PR [#5517](https://github.com/microsoft/BotFramework-WebChat/pull/5517) by [@kylerohn](https://github.com/kylerohn) and [@compulim](https://github.com/compulim) - Added `polymiddleware`, a new [universal middleware for every UIs](./docs/MIDDLEWARE.md), by [@compulim](https://github.com/compulim) in PR [#5515](https://github.com/microsoft/BotFramework-WebChat/pull/5515) and [#5566](https://github.com/microsoft/BotFramework-WebChat/pull/5566) + - Legacy middleware is prioritized over polymiddleware - Added `polymiddleware` to `` - Currently supports activity middleware and the new error box middleware + - Supports avatar middleware, by [@compulim](https://github.com/compulim) in PR [#5779](https://github.com/microsoft/BotFramework-WebChat/pull/5779) - New internal packages, by [@compulim](https://github.com/compulim) in PR [#5515](https://github.com/microsoft/BotFramework-WebChat/pull/5515) - `@msinternal/botframework-webchat-api-middleware` for middleware branch of API package - `@msinternal/botframework-webchat-debug-theme` package for enabling debugging scenarios @@ -354,6 +357,7 @@ Breaking changes in this release: - Removed legacy test harness, in PR [#5655](https://github.com/microsoft/BotFramework-WebChat/issues/5655), by [@compulim](https://github.com/compulim) - All tests are now either using `html2` test harness or simple unit tests - Legacy and `html` (html1) test harness are all migrated to `html2` +- `avatarMiddleware` is being deprecated in favor of [`polymiddleware`](./docs/MIDDLEWARE.md). It will be removed on or after 2028-03-18, related to PR [#5779](https://github.com/microsoft/BotFramework-WebChat/pull/5779) ### Fixed @@ -408,6 +412,7 @@ Breaking changes in this release: - Fixed compatibility with `create-react-app` by adding file extension to `core-js` imports, by [@compulim](https://github.com/compulim) in PR [#5680](https://github.com/microsoft/BotFramework-WebChat/pull/5680) - Fixed virtual keyboard should be collapsed after being suppressed, in iOS 26.3, by [@compulim](https://github.com/compulim) in PR [#5757](https://github.com/microsoft/BotFramework-WebChat/pull/5757) - Fixed Fluent/Copilot typing indicator animation background color, in PR [#5770](https://github.com/microsoft/BotFramework-WebChat/pull/5770), by [@OEvgeny](https://github.com/OEvgeny) +- Fixed `` should not re-render when `attachment[ForScreenReader]Middleware` is updated without noticeable different (`iterateEquals`), by [@compulim](https://github.com/compulim), in PR [#5779](https://github.com/microsoft/BotFramework-WebChat/pull/5779) ## [4.18.0] - 2024-07-10 diff --git a/__tests__/html2/activityGrouping/activityGrouping.legacyActivityMiddleware.html b/__tests__/html2/activityGrouping/activityGrouping.legacyActivityMiddleware.skip.html similarity index 59% rename from __tests__/html2/activityGrouping/activityGrouping.legacyActivityMiddleware.html rename to __tests__/html2/activityGrouping/activityGrouping.legacyActivityMiddleware.skip.html index 60b506fd2b..f5bfc7ecc3 100644 --- a/__tests__/html2/activityGrouping/activityGrouping.legacyActivityMiddleware.html +++ b/__tests__/html2/activityGrouping/activityGrouping.legacyActivityMiddleware.skip.html @@ -1,4 +1,19 @@ - + + @@ -32,57 +47,63 @@ run(async function () { await host.windowSize(undefined, 1280, document.getElementById('webchat')); - const activityMiddleware = () => next => (...renderActivityArgs) => { - const [{ activity }] = renderActivityArgs; + const activityMiddleware = + () => + next => + (...renderActivityArgs) => { + const [{ activity }] = renderActivityArgs; - const renderActivity = next(...renderActivityArgs); + const renderActivity = next(...renderActivityArgs); - if (/^1/.test(activity.id)) { - return (children, ...renderAttachmentArgs) => ( -
- {renderActivity( - (...renderAttachmentArgs) => ( -
{children(...renderAttachmentArgs)}
- ), + if (/^1/.test(activity.id)) { + return (children, ...renderAttachmentArgs) => ( +
+ {renderActivity( + (...renderAttachmentArgs) => ( +
{children(...renderAttachmentArgs)}
+ ), + ...renderActivityArgs + )} +
+ ); + } else if (/^2/.test(activity.id)) { + return (children, ...renderAttachmentArgs) => + renderActivity( + (...renderAttachmentArgs) => { + const [firstArg] = renderAttachmentArgs; + const { + attachment, + attachment: { contentType } + } = firstArg; + + if (/^image\//.test(contentType)) { + return ( +
+ {children(...renderAttachmentArgs)} +
+ {children( + { + ...firstArg, + attachment: { + ...attachment, + contentType: 'application/octet-stream' + } + }, + ...renderAttachmentArgs + )} +
+
+ ); + } + + return children(...renderAttachmentArgs); + }, ...renderActivityArgs - )} -
- ); - } else if (/^2/.test(activity.id)) { - return (children, ...renderAttachmentArgs) => - renderActivity((...renderAttachmentArgs) => { - const [firstArg] = renderAttachmentArgs; - const { - attachment, - attachment: { contentType } - } = firstArg; - - if (/^image\//.test(contentType)) { - return ( -
- {children(...renderAttachmentArgs)} -
- {children( - { - ...firstArg, - attachment: { - ...attachment, - contentType: 'application/octet-stream' - } - }, - ...renderAttachmentArgs - )} -
-
- ); - } - - return children(...renderAttachmentArgs); - }, ...renderActivityArgs); - } - - return renderActivity; - }; + ); + } + + return renderActivity; + }; WebChat.renderWebChat( { @@ -106,8 +127,7 @@ role: 'bot' }, id: '1.0', - text: - 'Decorating activity of **carousel layout**. Also decorate attachment without using attachment middleware.', + text: 'Decorating activity of **carousel layout**. Also decorate attachment without using attachment middleware.', timestamp: 0, type: 'message' }, @@ -123,8 +143,7 @@ role: 'bot' }, id: '1.1', - text: - 'Decorating activity of **stacked layout**. Also decorate attachment without using attachment middleware.', + text: 'Decorating activity of **stacked layout**. Also decorate attachment without using attachment middleware.', timestamp: 0, type: 'message' }, diff --git a/__tests__/html2/activityGrouping/activityGrouping.legacyActivityMiddleware.html.snap-1.png b/__tests__/html2/activityGrouping/activityGrouping.legacyActivityMiddleware.skip.html.snap-1.png similarity index 100% rename from __tests__/html2/activityGrouping/activityGrouping.legacyActivityMiddleware.html.snap-1.png rename to __tests__/html2/activityGrouping/activityGrouping.legacyActivityMiddleware.skip.html.snap-1.png diff --git a/__tests__/html2/middleware/avatar/backwardCompatibility.html b/__tests__/html2/middleware/avatar/backwardCompatibility.html new file mode 100644 index 0000000000..dc68db8921 --- /dev/null +++ b/__tests__/html2/middleware/avatar/backwardCompatibility.html @@ -0,0 +1,136 @@ + + + + + + +
+ + + + diff --git a/__tests__/html2/middleware/avatar/backwardCompatibility.html.snap-1.png b/__tests__/html2/middleware/avatar/backwardCompatibility.html.snap-1.png new file mode 100644 index 0000000000..b353219fa9 Binary files /dev/null and b/__tests__/html2/middleware/avatar/backwardCompatibility.html.snap-1.png differ diff --git a/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/addOrReplace.html b/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/addOrReplace.html new file mode 100644 index 0000000000..d59fbb4909 --- /dev/null +++ b/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/addOrReplace.html @@ -0,0 +1,107 @@ + + + + + + +
+ + + + diff --git a/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/addOrReplace.html.snap-1.png b/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/addOrReplace.html.snap-1.png new file mode 100644 index 0000000000..b8ebe9c68e Binary files /dev/null and b/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/addOrReplace.html.snap-1.png differ diff --git a/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/changing.html b/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/changing.html new file mode 100644 index 0000000000..cd931bb706 --- /dev/null +++ b/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/changing.html @@ -0,0 +1,117 @@ + + + + + + +
+ + + + diff --git a/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/changing.html.snap-1.png b/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/changing.html.snap-1.png new file mode 100644 index 0000000000..15faa46ff7 Binary files /dev/null and b/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/changing.html.snap-1.png differ diff --git a/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/changing.html.snap-2.png b/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/changing.html.snap-2.png new file mode 100644 index 0000000000..b8ebe9c68e Binary files /dev/null and b/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/changing.html.snap-2.png differ diff --git a/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/decorate.html b/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/decorate.html new file mode 100644 index 0000000000..d59fbb4909 --- /dev/null +++ b/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/decorate.html @@ -0,0 +1,107 @@ + + + + + + +
+ + + + diff --git a/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/decorate.html.snap-1.png b/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/decorate.html.snap-1.png new file mode 100644 index 0000000000..b8ebe9c68e Binary files /dev/null and b/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/decorate.html.snap-1.png differ diff --git a/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/delete.html b/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/delete.html new file mode 100644 index 0000000000..b69192cbbc --- /dev/null +++ b/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/delete.html @@ -0,0 +1,86 @@ + + + + + + +
+ + + + diff --git a/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/delete.html.snap-1.png b/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/delete.html.snap-1.png new file mode 100644 index 0000000000..2410f6bf15 Binary files /dev/null and b/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/delete.html.snap-1.png differ diff --git a/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/styleOptions.html b/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/styleOptions.html new file mode 100644 index 0000000000..8bc9afd476 --- /dev/null +++ b/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/styleOptions.html @@ -0,0 +1,120 @@ + + + + + + +
+ + + + diff --git a/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/styleOptions.html.snap-1.png b/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/styleOptions.html.snap-1.png new file mode 100644 index 0000000000..b8ebe9c68e Binary files /dev/null and b/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/styleOptions.html.snap-1.png differ diff --git a/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/styleOptions.html.snap-2.png b/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/styleOptions.html.snap-2.png new file mode 100644 index 0000000000..a70a3e492b Binary files /dev/null and b/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/styleOptions.html.snap-2.png differ diff --git a/__tests__/html2/middleware/avatar/polymiddleware/addOrReplace.html b/__tests__/html2/middleware/avatar/polymiddleware/addOrReplace.html new file mode 100644 index 0000000000..1f9619e39e --- /dev/null +++ b/__tests__/html2/middleware/avatar/polymiddleware/addOrReplace.html @@ -0,0 +1,106 @@ + + + + + + +
+ + + + diff --git a/__tests__/html2/middleware/avatar/polymiddleware/addOrReplace.html.snap-1.png b/__tests__/html2/middleware/avatar/polymiddleware/addOrReplace.html.snap-1.png new file mode 100644 index 0000000000..5b6eb0a9b3 Binary files /dev/null and b/__tests__/html2/middleware/avatar/polymiddleware/addOrReplace.html.snap-1.png differ diff --git a/__tests__/html2/middleware/avatar/polymiddleware/changing.html b/__tests__/html2/middleware/avatar/polymiddleware/changing.html new file mode 100644 index 0000000000..783390da4e --- /dev/null +++ b/__tests__/html2/middleware/avatar/polymiddleware/changing.html @@ -0,0 +1,113 @@ + + + + + + +
+ + + + diff --git a/__tests__/html2/middleware/avatar/polymiddleware/changing.html.snap-1.png b/__tests__/html2/middleware/avatar/polymiddleware/changing.html.snap-1.png new file mode 100644 index 0000000000..15faa46ff7 Binary files /dev/null and b/__tests__/html2/middleware/avatar/polymiddleware/changing.html.snap-1.png differ diff --git a/__tests__/html2/middleware/avatar/polymiddleware/changing.html.snap-2.png b/__tests__/html2/middleware/avatar/polymiddleware/changing.html.snap-2.png new file mode 100644 index 0000000000..a05e2fe13b Binary files /dev/null and b/__tests__/html2/middleware/avatar/polymiddleware/changing.html.snap-2.png differ diff --git a/__tests__/html2/middleware/avatar/polymiddleware/decorate.html b/__tests__/html2/middleware/avatar/polymiddleware/decorate.html new file mode 100644 index 0000000000..4fe1d7b380 --- /dev/null +++ b/__tests__/html2/middleware/avatar/polymiddleware/decorate.html @@ -0,0 +1,102 @@ + + + + + + +
+ + + + diff --git a/__tests__/html2/middleware/avatar/polymiddleware/decorate.html.snap-1.png b/__tests__/html2/middleware/avatar/polymiddleware/decorate.html.snap-1.png new file mode 100644 index 0000000000..db7331c81c Binary files /dev/null and b/__tests__/html2/middleware/avatar/polymiddleware/decorate.html.snap-1.png differ diff --git a/__tests__/html2/middleware/avatar/polymiddleware/defaultAvatar.styleOptions.html b/__tests__/html2/middleware/avatar/polymiddleware/defaultAvatar.styleOptions.html new file mode 100644 index 0000000000..ccc94f8126 --- /dev/null +++ b/__tests__/html2/middleware/avatar/polymiddleware/defaultAvatar.styleOptions.html @@ -0,0 +1,102 @@ + + + + + + +
+ + + + diff --git a/__tests__/html2/middleware/avatar/polymiddleware/defaultAvatar.styleOptions.html.snap-1.png b/__tests__/html2/middleware/avatar/polymiddleware/defaultAvatar.styleOptions.html.snap-1.png new file mode 100644 index 0000000000..f3a4c6837f Binary files /dev/null and b/__tests__/html2/middleware/avatar/polymiddleware/defaultAvatar.styleOptions.html.snap-1.png differ diff --git a/__tests__/html2/middleware/avatar/polymiddleware/defaultAvatar.styleOptions.html.snap-2.png b/__tests__/html2/middleware/avatar/polymiddleware/defaultAvatar.styleOptions.html.snap-2.png new file mode 100644 index 0000000000..01dc08b0d0 Binary files /dev/null and b/__tests__/html2/middleware/avatar/polymiddleware/defaultAvatar.styleOptions.html.snap-2.png differ diff --git a/__tests__/html2/middleware/avatar/polymiddleware/delete.html b/__tests__/html2/middleware/avatar/polymiddleware/delete.html new file mode 100644 index 0000000000..d78f89e630 --- /dev/null +++ b/__tests__/html2/middleware/avatar/polymiddleware/delete.html @@ -0,0 +1,86 @@ + + + + + + +
+ + + + diff --git a/__tests__/html2/middleware/avatar/polymiddleware/delete.html.snap-1.png b/__tests__/html2/middleware/avatar/polymiddleware/delete.html.snap-1.png new file mode 100644 index 0000000000..2410f6bf15 Binary files /dev/null and b/__tests__/html2/middleware/avatar/polymiddleware/delete.html.snap-1.png differ diff --git a/__tests__/html2/middleware/avatar/renderProxy.html b/__tests__/html2/middleware/avatar/renderProxy.html new file mode 100644 index 0000000000..3a65c0257e --- /dev/null +++ b/__tests__/html2/middleware/avatar/renderProxy.html @@ -0,0 +1,138 @@ + + + + + + +
+ + + + diff --git a/__tests__/html2/middleware/avatar/renderProxy.html.snap-1.png b/__tests__/html2/middleware/avatar/renderProxy.html.snap-1.png new file mode 100644 index 0000000000..6321eb2b6a Binary files /dev/null and b/__tests__/html2/middleware/avatar/renderProxy.html.snap-1.png differ diff --git a/__tests__/html2/middleware/avatar/useBuildRenderAvatarCallback.html b/__tests__/html2/middleware/avatar/useBuildRenderAvatarCallback.html new file mode 100644 index 0000000000..9a704b4bf8 --- /dev/null +++ b/__tests__/html2/middleware/avatar/useBuildRenderAvatarCallback.html @@ -0,0 +1,146 @@ + + + + + + +
+ + + + diff --git a/__tests__/html2/middleware/avatar/useBuildRenderAvatarCallback.html.snap-1.png b/__tests__/html2/middleware/avatar/useBuildRenderAvatarCallback.html.snap-1.png new file mode 100644 index 0000000000..6321eb2b6a Binary files /dev/null and b/__tests__/html2/middleware/avatar/useBuildRenderAvatarCallback.html.snap-1.png differ diff --git a/__tests__/html2/scrollToEndButton/scrollToEndButton.persistWhileCallingUseScrollTo.html b/__tests__/html2/scrollToEndButton/scrollToEndButton.persistWhileCallingUseScrollTo.html index b47e98155f..7d7b906a4d 100644 --- a/__tests__/html2/scrollToEndButton/scrollToEndButton.persistWhileCallingUseScrollTo.html +++ b/__tests__/html2/scrollToEndButton/scrollToEndButton.persistWhileCallingUseScrollTo.html @@ -1,22 +1,38 @@ - + - +
- diff --git a/__tests__/html2/timestamp/attachmentSendTimeout.html b/__tests__/html2/timestamp/attachmentSendTimeout.html index 04064bdbc0..523dca36b2 100644 --- a/__tests__/html2/timestamp/attachmentSendTimeout.html +++ b/__tests__/html2/timestamp/attachmentSendTimeout.html @@ -64,7 +64,11 @@ fileBlob.name = 'empty.zip'; // WHEN: Sending out the file. - await pageObjects.runHook(({ useSendFiles }) => useSendFiles()([fileBlob])); + await pageObjects.runHook(({ useSendFiles }, numCalled) => { + const sendFiles = useSendFiles(); + + numCalled || sendFiles([fileBlob]); + }); await pageConditions.numActivitiesShown(2); // THEN: It should show "Sending". diff --git a/__tests__/html2/transcript/legacyActivityMiddleware.reactionButtons.html b/__tests__/html2/transcript/legacyActivityMiddleware.reactionButtons.html index fc02670349..2fffc5a2d6 100644 --- a/__tests__/html2/transcript/legacyActivityMiddleware.reactionButtons.html +++ b/__tests__/html2/transcript/legacyActivityMiddleware.reactionButtons.html @@ -1,4 +1,4 @@ - + @@ -44,7 +44,7 @@ } } = window; - const BotActivityDecorator = ({ activityID, children }) => ( + const BotActivityDecorator = ({ activityId, children }) => (
  • @@ -58,28 +58,33 @@
); - const activityMiddleware = () => next => (...args) => { - const [ - { - activity, - activity: { - from: { role } + const activityMiddleware = + () => + next => + (...args) => { + const [ + { + activity, + activity: { + from: { role } + } } - } - ] = args; + ] = args; - if (role === 'bot') { - const { id } = activity; + const handler = next(...args); - return (...renderArgs) => ( - - {next(...args)(...renderArgs)} - - ); - } + if (role === 'bot') { + const { id } = activity; + + return (...renderArgs) => ( + + {handler(...renderArgs)} + + ); + } - return next(...args); - }; + return handler; + }; run(async function () { const now = Date.now(); diff --git a/docs/MIDDLEWARE.md b/docs/MIDDLEWARE.md index 41de6d8727..12c5eed04c 100644 --- a/docs/MIDDLEWARE.md +++ b/docs/MIDDLEWARE.md @@ -52,6 +52,8 @@ function MyChatUI() { The following are supported polymiddleware types: - Activity (PR [#5515](https://github.com/microsoft/BotFramework-WebChat/pull/5515)) +- Avatar (PR [#5779](https://github.com/microsoft/BotFramework-WebChat/pull/5779)) + - [Sample code](__tests__/html2/middleware/avatar/polymiddleware/decorate.html) - Error box (PR [#5515](https://github.com/microsoft/BotFramework-WebChat/pull/5515)) ## Recipes @@ -284,7 +286,7 @@ Over the past 7.5 years of journey, we learnt a lot. Polymiddleware combined all ### Polyfilling legacy middleware -Legacy middleware passed to deprecating props such as `activityMiddleware` will be upgraded to polymiddleware automatically and placed after other polymiddleware passed via the `polymiddleware` prop. In other words, legacy middleware has lower priority than polymiddleware. +Legacy middleware passed to deprecating props such as `activityMiddleware` will be upgraded to polymiddleware automatically and placed before other polymiddleware passed via the `polymiddleware` prop. In other words, legacy middleware has higher priority than polymiddleware. Special polymiddleware factory functions such as `createActivityPolymiddlewareFromLegacy()` allow input of legacy middleware and output as polymiddleware. This helps the transition period. However, these special factory functions is also marked as deprecated. @@ -296,7 +298,7 @@ When multiple legacy middleware are passed as an array to `createActivityPolymid ### When to use `useBuildRenderXXXCallback()` vs. ``? -The main differences are: +We recommend the proxy component if you do not care about empty rendering or element count. For example, empty `` will be emitted by proxy but not rendered by `useBuildRenderXXXCallback()`. - `useBuildRenderXXXCallback()` allows precise render control - Developers can control how the render function is being used and what to do if the polymiddleware decided not to render the activity @@ -314,8 +316,8 @@ The following table shows how polymiddleware are prioritized. | Priority | Type | Description | | -------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Highest | Error boundary |

All polymiddleware has an error boundary wrapper to control error propagation.

Error will be rendered using the `ErrorBox` polymiddleware and reported via `onTelemetry` prop.

| -| Normal | Polymiddleware | Polymiddleware passed to the `polymiddleware` prop. | -| Low | Legacy middleware | Legacy middleware passed to their corresponding prop (such as `activityMiddleware`) and upgraded automatically. | +| Normal | Legacy middleware | Legacy middleware passed to their corresponding prop (such as `activityMiddleware`) and upgraded automatically. | +| Low | Polymiddleware | Polymiddleware passed to the `polymiddleware` prop. | | Lowest | Catch-all as error | Requests not handled by any polymiddleware in the chain will be thrown as an error. | ## Deprecation dates @@ -328,7 +330,7 @@ We introduced polymiddleware in 2025-08-16. Based on our 2-year deprecation rule | Activity status | | (TBD) | | Attachment | | (TBD) | | Attachment for screen reader | | (TBD) | -| Avatar | | (TBD) | +| Avatar | PR [#5779](https://github.com/microsoft/BotFramework-WebChat/pull/5779) | 2028-03-18 | | Card action | | (TBD) | | Group activities | | (TBD) | | Scroll to end button | | (TBD) | diff --git a/package-lock.json b/package-lock.json index 5b89f3716f..a43abde56b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21168,6 +21168,7 @@ "@msinternal/base64-js": "0.0.0-0", "@msinternal/botframework-directlinejs": "0.0.0-0", "@msinternal/botframework-webchat-base": "0.0.0-0", + "@msinternal/botframework-webchat-react-hooks": "0.0.0-0", "@msinternal/botframework-webchat-react-valibot": "0.0.0-0", "@msinternal/botframework-webchat-styles": "0.0.0-0", "@msinternal/botframework-webchat-tsconfig": "0.0.0-0", @@ -21734,6 +21735,9 @@ "name": "@msinternal/botframework-webchat-react-hooks", "version": "0.0.0-0", "license": "MIT", + "devDependencies": { + "@msinternal/botframework-webchat-base": "0.0.0-0" + }, "peerDependencies": { "react": ">= 16.8.6" } diff --git a/packages/api-middleware/package.json b/packages/api-middleware/package.json index e89165124a..9b4b1aaf94 100644 --- a/packages/api-middleware/package.json +++ b/packages/api-middleware/package.json @@ -69,7 +69,7 @@ "precommit:eslint": "../../node_modules/.bin/eslint --report-unused-disable-directives --max-warnings 0", "precommit:typecheck": "tsc --project ./src --emitDeclarationOnly false --esModuleInterop true --noEmit --pretty false", "preversion": "../../scripts/npm/preversion.sh", - "start": "../../scripts/npm/notify-build.sh \"src\" \"../base/package.json\" \"../react-hooks/package.json\" \"../react-valibot/package.json\"" + "start": "../../scripts/npm/notify-build.sh \"src\" \"../react-hooks/package.json\" \"../react-valibot/package.json\"" }, "pinDependencies": { "react-wrap-with": [ diff --git a/packages/api-middleware/src/PolymiddlewareComposer.tsx b/packages/api-middleware/src/PolymiddlewareComposer.tsx index 4780e3fc86..79c6179f4f 100644 --- a/packages/api-middleware/src/PolymiddlewareComposer.tsx +++ b/packages/api-middleware/src/PolymiddlewareComposer.tsx @@ -1,4 +1,4 @@ -import { useMemoWithPrevious } from '@msinternal/botframework-webchat-react-hooks'; +import { useMemoIterable } from '@msinternal/botframework-webchat-react-hooks'; import { reactNode, validateProps } from '@msinternal/botframework-webchat-react-valibot'; import React, { memo, useMemo } from 'react'; import { @@ -15,6 +15,7 @@ import { } from 'valibot'; import { ActivityPolymiddlewareProvider, extractActivityEnhancer } from './activityPolymiddleware'; +import { AvatarPolymiddlewareProvider, extractAvatarEnhancer } from './avatarPolymiddleware'; import { ErrorBoxPolymiddlewareProvider, extractErrorBoxEnhancer } from './errorBoxPolymiddleware'; import { Polymiddleware } from './types/Polymiddleware'; @@ -34,31 +35,22 @@ type PolymiddlewareComposerProps = Readonly>( - (prevActivityEnhancers = []) => { - const activityEnhancers = extractActivityEnhancer(polymiddleware); - - // Checks for array equality, return previous version if nothing has changed. - return prevActivityEnhancers.length === activityEnhancers.length && - activityEnhancers.every((middleware, index) => Object.is(middleware, prevActivityEnhancers.at(index))) - ? prevActivityEnhancers - : activityEnhancers; - }, + const activityEnhancers = useMemoIterable>( + () => extractActivityEnhancer(polymiddleware), [polymiddleware] ); const activityPolymiddleware = useMemo(() => activityEnhancers.map(enhancer => () => enhancer), [activityEnhancers]); - const errorBoxEnhancers = useMemoWithPrevious>( - (prevErrorBoxEnhancers = []) => { - const errorBoxEnhancers = extractErrorBoxEnhancer(polymiddleware); + const avatarEnhancers = useMemoIterable>( + () => extractAvatarEnhancer(polymiddleware), + [polymiddleware] + ); - // Checks for array equality, return previous version if nothing has changed. - return prevErrorBoxEnhancers.length === errorBoxEnhancers.length && - errorBoxEnhancers.every((middleware, index) => Object.is(middleware, prevErrorBoxEnhancers.at(index))) - ? prevErrorBoxEnhancers - : errorBoxEnhancers; - }, + const avatarPolymiddleware = useMemo(() => avatarEnhancers.map(enhancer => () => enhancer), [avatarEnhancers]); + + const errorBoxEnhancers = useMemoIterable>( + () => extractErrorBoxEnhancer(polymiddleware), [polymiddleware] ); @@ -75,10 +67,14 @@ function PolymiddlewareComposer(props: PolymiddlewareComposerProps) { return ( - {children} + + {children} + ); } +PolymiddlewareComposer.displayName = 'PolymiddlewareComposer'; + export default memo(PolymiddlewareComposer); export { polymiddlewareComposerPropsSchema, type PolymiddlewareComposerProps }; diff --git a/packages/api-middleware/src/avatarPolymiddleware.tsx b/packages/api-middleware/src/avatarPolymiddleware.tsx new file mode 100644 index 0000000000..d57e789425 --- /dev/null +++ b/packages/api-middleware/src/avatarPolymiddleware.tsx @@ -0,0 +1,90 @@ +import { validateProps } from '@msinternal/botframework-webchat-react-valibot'; +import { type WebChatActivity } from 'botframework-webchat-core'; +import React, { memo, useMemo } from 'react'; +import { any, custom, object, pipe, readonly, safeParse, type InferInput } from 'valibot'; + +import templatePolymiddleware, { + type InferHandler, + type InferHandlerResult, + type InferMiddleware, + type InferProps, + type InferProviderProps, + type InferRenderer, + type InferRequest +} from './private/templatePolymiddleware'; + +// This is for bridging legacy AvatarMiddleware, will be removed when AvatarMiddleware is removed. +// Customization developers should request access to styleOptions themselves someway, says, `polymiddleware={[createCustomAvatarPolymiddleware(styleOptions)]}`. +const __INTERNAL_DO_NOT_USE__avatarPolymiddlewareRequestStyleOptionsSymbol = Symbol(); + +const { + createMiddleware: createAvatarPolymiddleware, + extractEnhancer: extractAvatarEnhancer, + Provider: AvatarPolymiddlewareProvider, + Proxy, + reactComponent: avatarComponent, + useBuildRenderCallback: useBuildRenderAvatarCallback +} = templatePolymiddleware< + { + // TODO: The `styleOptions` is only for legacy middleware. + readonly [__INTERNAL_DO_NOT_USE__avatarPolymiddlewareRequestStyleOptionsSymbol]: any; + readonly activity: WebChatActivity; + }, + { readonly children?: never } +>('avatar'); + +type AvatarPolymiddleware = InferMiddleware; +type AvatarPolymiddlewareHandler = InferHandler; +type AvatarPolymiddlewareHandlerResult = InferHandlerResult; +type AvatarPolymiddlewareProps = InferProps; +type AvatarPolymiddlewareRenderer = InferRenderer; +type AvatarPolymiddlewareRequest = InferRequest; +type AvatarPolymiddlewareProviderProps = InferProviderProps; + +const avatarPolymiddlewareProxyPropsSchema = pipe( + object({ + [__INTERNAL_DO_NOT_USE__avatarPolymiddlewareRequestStyleOptionsSymbol]: any(), + activity: custom>(value => safeParse(object({}), value).success) + }), + readonly() +); + +type AvatarPolymiddlewareProxyProps = Readonly>; + +// A friendlier version than the organic . +const AvatarPolymiddlewareProxy = memo(function AvatarPolymiddlewareProxy(props: AvatarPolymiddlewareProxyProps) { + // TODO: [P1] Proxy should not require `styleOptions`, it should read from `useStyleOptions`. + // However the `useStyleOptions` hook is in `api` which is not available in `api-middleware`. + // We should refactor `useStyleOptions` into `api-style-options` so we can make the hook available here. + const { [__INTERNAL_DO_NOT_USE__avatarPolymiddlewareRequestStyleOptionsSymbol]: styleOptions, activity } = + validateProps(avatarPolymiddlewareProxyPropsSchema, props); + + const request = useMemo( + () => + Object.freeze({ + [__INTERNAL_DO_NOT_USE__avatarPolymiddlewareRequestStyleOptionsSymbol]: styleOptions, + activity + }), + [activity, styleOptions] + ); + + return ; +}); + +export { + __INTERNAL_DO_NOT_USE__avatarPolymiddlewareRequestStyleOptionsSymbol, + avatarComponent, + AvatarPolymiddlewareProvider, + AvatarPolymiddlewareProxy, + createAvatarPolymiddleware, + extractAvatarEnhancer, + useBuildRenderAvatarCallback, + type AvatarPolymiddleware, + type AvatarPolymiddlewareHandler, + type AvatarPolymiddlewareHandlerResult, + type AvatarPolymiddlewareProps, + type AvatarPolymiddlewareProviderProps, + type AvatarPolymiddlewareProxyProps, + type AvatarPolymiddlewareRenderer, + type AvatarPolymiddlewareRequest +}; diff --git a/packages/api-middleware/src/index.ts b/packages/api-middleware/src/index.ts index 9e0a8aeb8e..80a766e350 100644 --- a/packages/api-middleware/src/index.ts +++ b/packages/api-middleware/src/index.ts @@ -12,6 +12,21 @@ export { type ActivityPolymiddlewareRequest } from './activityPolymiddleware'; +export { + __INTERNAL_DO_NOT_USE__avatarPolymiddlewareRequestStyleOptionsSymbol, + avatarComponent, + AvatarPolymiddlewareProxy, + createAvatarPolymiddleware, + useBuildRenderAvatarCallback, + type AvatarPolymiddleware, + type AvatarPolymiddlewareHandler, + type AvatarPolymiddlewareHandlerResult, + type AvatarPolymiddlewareProps, + type AvatarPolymiddlewareProxyProps, + type AvatarPolymiddlewareRenderer, + type AvatarPolymiddlewareRequest +} from './avatarPolymiddleware'; + export { createErrorBoxPolymiddleware, errorBoxComponent, @@ -27,5 +42,6 @@ export { } from './errorBoxPolymiddleware'; // TODO: [P0] Add tests for nesting `polymiddleware`. +export { __INTERNAL_DO_NOT_USE__legacyAvatarMiddlewareOriginalRequestSymbol } from './legacy/avatarMiddleware'; export { default as PolymiddlewareComposer } from './PolymiddlewareComposer'; export { type Polymiddleware } from './types/Polymiddleware'; diff --git a/packages/api-middleware/src/legacy.ts b/packages/api-middleware/src/legacy.ts index c9405239c4..db6fbf25b5 100644 --- a/packages/api-middleware/src/legacy.ts +++ b/packages/api-middleware/src/legacy.ts @@ -6,3 +6,5 @@ export { } from './legacy/activityMiddleware'; export { type LegacyAttachmentMiddleware, type LegacyRenderAttachment } from './legacy/attachmentMiddleware'; + +export { type LegacyAvatarMiddleware, type LegacyAvatarRenderer } from './legacy/avatarMiddleware'; diff --git a/packages/api-middleware/src/legacy/avatarMiddleware.ts b/packages/api-middleware/src/legacy/avatarMiddleware.ts new file mode 100644 index 0000000000..6899783972 --- /dev/null +++ b/packages/api-middleware/src/legacy/avatarMiddleware.ts @@ -0,0 +1,29 @@ +// TODO: This is moved from /api, need to revisit/rewrite everything in this file. +import { type WebChatActivity } from 'botframework-webchat-core'; +import { type ReactNode } from 'react'; +import type { AvatarPolymiddlewareRequest } from '../avatarPolymiddleware'; + +// Polymiddleware requires immutable request object. +// When bridging between legacy and polymiddlware, this symbol helps keeping the original object. +const __INTERNAL_DO_NOT_USE__legacyAvatarMiddlewareOriginalRequestSymbol = Symbol(); + +type LegacyAvatarComponentFactoryArguments = { + readonly [__INTERNAL_DO_NOT_USE__legacyAvatarMiddlewareOriginalRequestSymbol]: AvatarPolymiddlewareRequest; + readonly activity: WebChatActivity; + readonly fromUser: boolean; + readonly styleOptions: Readonly>; +}; + +type LegacyAvatarRenderer = false | (() => Exclude); + +type LegacyAvatarEnhancer = ( + next: (args: LegacyAvatarComponentFactoryArguments) => LegacyAvatarRenderer +) => (args: LegacyAvatarComponentFactoryArguments) => LegacyAvatarRenderer; + +type LegacyAvatarMiddleware = () => LegacyAvatarEnhancer; + +export { + __INTERNAL_DO_NOT_USE__legacyAvatarMiddlewareOriginalRequestSymbol, + type LegacyAvatarMiddleware, + type LegacyAvatarRenderer +}; diff --git a/packages/api-middleware/src/private/templatePolymiddleware.tsx b/packages/api-middleware/src/private/templatePolymiddleware.tsx index 9b9cd80541..db84af06b7 100644 --- a/packages/api-middleware/src/private/templatePolymiddleware.tsx +++ b/packages/api-middleware/src/private/templatePolymiddleware.tsx @@ -19,6 +19,8 @@ const isArrayOfFunction = (middleware: unknown): middleware is InferOutput = next => request => next(request); +const DEBUG_ENHANCER_SYMBOL = Symbol('OriginalEnhancer'); +const DEBUG_NAME_SYMBOL = Symbol('MiddlewareName'); const EMPTY_ARRAY = Object.freeze([]); // Following @types/react to use {} for props. @@ -54,7 +56,21 @@ function templatePolymiddleware(name: string) { // We enforce middleware to be created using factory function. Object.defineProperty(taggedEnhancer, middlewareFactoryTag, { enumerable: false }); - return init => (init === name ? taggedEnhancer : BYPASS_ENHANCER); + const middleware: TemplatedMiddleware = init => (init === name ? taggedEnhancer : BYPASS_ENHANCER); + + Object.defineProperty(middleware, DEBUG_ENHANCER_SYMBOL, { + configurable: false, + value: enhancer, + writable: false + }); + + Object.defineProperty(middleware, DEBUG_NAME_SYMBOL, { + configurable: false, + value: name, + writable: false + }); + + return middleware; }; const warnInvalidExtraction = warnOnce(`Middleware passed for extraction of "${name}" must be an array of function`); diff --git a/packages/api/src/boot/internal.ts b/packages/api/src/boot/internal.ts index 1deec63da8..add882acb8 100644 --- a/packages/api/src/boot/internal.ts +++ b/packages/api/src/boot/internal.ts @@ -1,3 +1,4 @@ +export { __INTERNAL_DO_NOT_USE__avatarPolymiddlewareRequestStyleOptionsSymbol } from '@msinternal/botframework-webchat-api-middleware'; export { default as LowPriorityDecoratorComposer } from '../decorator/internal/LowPriorityDecoratorComposer'; export { default as usePostVoiceActivity } from '../hooks/internal/usePostVoiceActivity'; export { default as useSetDictateState } from '../hooks/internal/useSetDictateState'; diff --git a/packages/api/src/boot/middleware.ts b/packages/api/src/boot/middleware.ts index 8d42eb1069..cc9f351676 100644 --- a/packages/api/src/boot/middleware.ts +++ b/packages/api/src/boot/middleware.ts @@ -16,11 +16,31 @@ export { type ActivityPolymiddlewareRequest } from '@msinternal/botframework-webchat-api-middleware'; +export { + avatarComponent, + createAvatarPolymiddleware, + type AvatarPolymiddleware, + type AvatarPolymiddlewareHandler, + type AvatarPolymiddlewareHandlerResult, + type AvatarPolymiddlewareProps, + type AvatarPolymiddlewareRenderer, + type AvatarPolymiddlewareRequest +} from '@msinternal/botframework-webchat-api-middleware'; + +export { + default as AvatarPolymiddlewareProxy, + AvatarPolymiddlewareProxyProps +} from '../middleware/AvatarPolymiddlewareProxy'; + +export { default as useBuildRenderAvatarCallback } from '../middleware/useBuildRenderAvatarCallback'; + export { createErrorBoxPolymiddleware, errorBoxComponent, ErrorBoxPolymiddlewareProxy, useBuildRenderErrorBoxCallback, + // For type portability only. + type __INTERNAL_DO_NOT_USE__avatarPolymiddlewareRequestStyleOptionsSymbol, type ErrorBoxPolymiddleware, type ErrorBoxPolymiddlewareHandler, type ErrorBoxPolymiddlewareHandlerResult, @@ -30,4 +50,11 @@ export { type ErrorBoxPolymiddlewareRequest } from '@msinternal/botframework-webchat-api-middleware'; +export { + type LegacyActivityMiddleware, + type LegacyAvatarMiddleware +} from '@msinternal/botframework-webchat-api-middleware/legacy'; + export { default as createActivityPolymiddlewareFromLegacy } from '../legacy/createActivityPolymiddlewareFromLegacy'; + +export { default as createAvatarPolymiddlewareFromLegacy } from '../legacy/createAvatarPolymiddlewareFromLegacy'; diff --git a/packages/api/src/hooks/Composer.tsx b/packages/api/src/hooks/Composer.tsx index 3b1794d86b..98a74c9ce5 100644 --- a/packages/api/src/hooks/Composer.tsx +++ b/packages/api/src/hooks/Composer.tsx @@ -4,6 +4,8 @@ import { type LegacyActivityMiddleware, type LegacyAttachmentMiddleware } from '@msinternal/botframework-webchat-api-middleware/legacy'; +import { singleToArray, type OneOrMany } from '@msinternal/botframework-webchat-base/utils'; +import { useMemoIterable } from '@msinternal/botframework-webchat-react-hooks'; import { ReduxStoreComposer } from '@msinternal/botframework-webchat-redux-store'; import { clearSuggestedActions, @@ -27,7 +29,6 @@ import { setSendBoxAttachments, setSendTimeout, setSendTypingIndicator, - singleToArray, startDictate, startSpeakingActivity, startVoiceRecording, @@ -37,7 +38,6 @@ import { submitSendBox, type DirectLineJSBotConnection, type GlobalScopePonyfill, - type OneOrMany, type WebChatActivity } from 'botframework-webchat-core'; import PropTypes from 'prop-types'; @@ -50,6 +50,7 @@ import errorBoxTelemetryPolymiddleware from '../errorBox/errorBoxTelemetryPolymi import PrecompiledGlobalize from '../external/PrecompiledGlobalize'; import usePonyfill from '../hooks/usePonyfill'; import createActivityPolymiddlewareFromLegacy from '../legacy/createActivityPolymiddlewareFromLegacy'; +import createAvatarPolymiddlewareFromLegacy from '../legacy/createAvatarPolymiddlewareFromLegacy'; import getAllLocalizedStrings from '../localization/getAllLocalizedStrings'; import { SendBoxMiddlewareProvider, type SendBoxMiddleware } from '../middleware/SendBoxMiddleware'; import { @@ -84,10 +85,10 @@ import isObject from '../utils/isObject'; import mapMap from '../utils/mapMap'; import normalizeLanguage from '../utils/normalizeLanguage'; import Tracker from './internal/Tracker'; -import useVoiceHandlers from './internal/useVoiceHandlers'; import WebChatAPIContext, { type WebChatAPIContextType } from './internal/WebChatAPIContext'; import WebChatReduxContext, { useDispatch } from './internal/WebChatReduxContext'; import defaultSelectVoice from './internal/defaultSelectVoice'; +import useVoiceHandlers from './internal/useVoiceHandlers'; import activityFallbackPolymiddleware from './middleware/activityFallbackPolymiddleware'; import applyMiddleware, { forLegacyRenderer as applyMiddlewareForLegacyRenderer, @@ -213,14 +214,17 @@ function mergeStringsOverrides(localizedStrings, language, overrideLocalizedStri type ComposerCoreProps = Readonly<{ /** - * @deprecated The `activityMiddleware` prop is being deprecated, please use `polymiddleware` instead. This prop will be removed on or after 2027-08-21. + * @deprecated Use `polymiddleware` instead. The `activityMiddleware` prop is being deprecated, please use `polymiddleware` instead. This prop will be removed on or after 2027-08-21. + */ + activityMiddleware?: OneOrMany | undefined; + activityStatusMiddleware?: OneOrMany | undefined; + attachmentForScreenReaderMiddleware?: OneOrMany | undefined; + attachmentMiddleware?: OneOrMany | undefined; + /** + * @deprecated Use `polymiddleware` instead. The `avatarMiddleware` prop is being deprecated, please use `polymiddleware` instead. This prop will be removed on or after 2028-03-16. */ - activityMiddleware?: OneOrMany; - activityStatusMiddleware?: OneOrMany; - attachmentForScreenReaderMiddleware?: OneOrMany; - attachmentMiddleware?: OneOrMany; - avatarMiddleware?: OneOrMany; - cardActionMiddleware?: OneOrMany; + avatarMiddleware?: OneOrMany | undefined; + cardActionMiddleware?: OneOrMany | undefined; children?: ReactNode | ((context: ContextOf>) => ReactNode); dir?: string; directLine: DirectLineJSBotConnection; @@ -236,9 +240,9 @@ type ComposerCoreProps = Readonly<{ quality: number ) => Promise; grammars?: any; - groupActivitiesMiddleware?: OneOrMany; + groupActivitiesMiddleware?: OneOrMany | undefined; locale?: string; - polymiddleware?: readonly Polymiddleware[]; + polymiddleware?: readonly Polymiddleware[] | undefined; onTelemetry?: (event: TelemetryMeasurementEvent) => void; overrideLocalizedStrings?: LocalizedStrings | ((strings: LocalizedStrings, language: string) => LocalizedStrings); renderMarkdown?: ( @@ -246,13 +250,13 @@ type ComposerCoreProps = Readonly<{ newLineOptions: { markdownRespectCRLF: boolean }, linkOptions: { externalLinkAlt: string } ) => string; - scrollToEndButtonMiddleware?: OneOrMany; + scrollToEndButtonMiddleware?: OneOrMany | undefined; selectVoice?: (voices: (typeof window.SpeechSynthesisVoice)[], activity: WebChatActivity) => void; sendBoxMiddleware?: readonly SendBoxMiddleware[] | undefined; sendBoxToolbarMiddleware?: readonly SendBoxToolbarMiddleware[] | undefined; sendTypingIndicator?: boolean; - toastMiddleware?: OneOrMany; - typingIndicatorMiddleware?: OneOrMany; + toastMiddleware?: OneOrMany | undefined; + typingIndicatorMiddleware?: OneOrMany | undefined; /** * Sets the state of the UI. * @@ -268,11 +272,11 @@ type ComposerCoreProps = Readonly<{ }>; const ComposerCore = ({ - activityMiddleware, + activityMiddleware: activityMiddlewareFromProps, activityStatusMiddleware, attachmentForScreenReaderMiddleware, attachmentMiddleware, - avatarMiddleware, + avatarMiddleware: avatarMiddlewareFromProps, cardActionMiddleware, children, dir, @@ -280,7 +284,7 @@ const ComposerCore = ({ disabled, downscaleImageToDataURL, grammars, - groupActivitiesMiddleware, + groupActivitiesMiddleware: groupActivitiesMiddlewareFromProps, locale, onTelemetry, overrideLocalizedStrings, @@ -338,7 +342,7 @@ const ComposerCore = ({ const cardActionContext = useMemo( () => createCardActionContext({ - cardActionMiddleware: Object.freeze([...singleToArray(cardActionMiddleware)]), + cardActionMiddleware: Object.freeze([...singleToArray(cardActionMiddleware ?? [])]), continuous: !!styleOptions.speechRecognitionContinuous, directLine, dispatch, @@ -418,7 +422,7 @@ const ComposerCore = ({ applyMiddlewareForRenderer( 'activity status', { strict: false }, - ...singleToArray(activityStatusMiddleware), + ...singleToArray(activityStatusMiddleware ?? []), () => () => () => false )({}), [activityStatusMiddleware] @@ -429,7 +433,7 @@ const ComposerCore = ({ applyMiddlewareForRenderer( 'attachment for screen reader', { strict: true }, - ...singleToArray(attachmentForScreenReaderMiddleware), + ...singleToArray(attachmentForScreenReaderMiddleware ?? []), () => () => ({ attachment }) => { @@ -453,7 +457,7 @@ const ComposerCore = ({ () => applyMiddlewareForLegacyRenderer( 'attachment', - ...singleToArray(attachmentMiddleware), + ...singleToArray(attachmentMiddleware ?? []), () => () => ({ attachment }) => { @@ -467,14 +471,18 @@ const ComposerCore = ({ [attachmentMiddleware] ); - const patchedAvatarRenderer = useMemo( - () => - applyMiddlewareForRenderer( - 'avatar', - { strict: false }, - ...singleToArray(avatarMiddleware), - () => () => () => false - )({}), + const groupActivitiesMiddleware = useMemoIterable( + () => Object.freeze(singleToArray(groupActivitiesMiddlewareFromProps ?? [])), + [groupActivitiesMiddlewareFromProps] + ); + + const avatarMiddleware = useMemoIterable( + () => singleToArray(avatarMiddlewareFromProps ?? []), + [avatarMiddlewareFromProps] + ); + + const polymiddlewareForLegacyAvatarMiddleware = useMemo( + () => createAvatarPolymiddlewareFromLegacy(...avatarMiddleware), [avatarMiddleware] ); @@ -483,7 +491,7 @@ const ComposerCore = ({ applyMiddlewareForRenderer( 'toast', { strict: false }, - ...singleToArray(toastMiddleware), + ...singleToArray(toastMiddleware ?? []), () => () => ({ notification }) => { @@ -502,7 +510,7 @@ const ComposerCore = ({ applyMiddlewareForRenderer( 'typing indicator', { strict: false }, - ...singleToArray(typingIndicatorMiddleware), + ...singleToArray(typingIndicatorMiddleware ?? []), () => () => () => false )({}), [typingIndicatorMiddleware] @@ -513,28 +521,54 @@ const ComposerCore = ({ applyMiddlewareForRenderer( 'scroll to end button', { strict: true }, - ...singleToArray(scrollToEndButtonMiddleware), + ...singleToArray(scrollToEndButtonMiddleware ?? []), () => () => () => false )() as any, [scrollToEndButtonMiddleware] ); - const polymiddlewareForLegacyActivityMiddleware = useMemo( - () => Object.freeze([createActivityPolymiddlewareFromLegacy(...singleToArray(activityMiddleware))]), + const activityMiddleware = useMemoIterable( + () => singleToArray(activityMiddlewareFromProps ?? []), + [activityMiddlewareFromProps] + ); + + const polymiddlewareForLegacyActivityMiddleware = useMemo( + () => createActivityPolymiddlewareFromLegacy(...activityMiddleware), [activityMiddleware] ); - const polymiddleware = useMemo( + const polymiddleware = useMemoIterable( () => Object.freeze([ // Error box telemetry polymiddleware is special and has a much higher priority. // This guarantees telemetry is always emitted for exception and no other polymiddleware can override this behavior. errorBoxTelemetryPolymiddleware, - ...(polymiddlewareFromProps || []), - ...polymiddlewareForLegacyActivityMiddleware, + + // # Why render legacy middleware before polymiddleware? + // + // - Legacy middleware should have high priority than defaults + // - Default middleware will be upgraded to polymiddleware, however, they should have lower priority + // - Default middleware are implemented in the `component` package, and passed via `polymiddleware` props + // - They are UI, cannot be implemented in `api` package + // + // We have a few way out, either one of the followings: + // + // - Add a new `lowPriorityPolymiddleware` props for default polymiddleware, so we can put them after legacy + // - We don't want any special treatments or any prioritization system + // - We put the upgrade logics inside both `api` and `component` package + // - `component` will upgrade legacy to polymiddleware and prioritize properly + // - Spaghetti code and it is difficult to test the logic in `api` package + // - We always render legacy middleware before polymiddleware + // - Default middleware are polymiddleware, has lower priority than legacy + // + // The simplest and logical move is #3: render legacy middleware before polymiddleware. + + ...(polymiddlewareForLegacyActivityMiddleware ? [polymiddlewareForLegacyActivityMiddleware] : []), + ...(polymiddlewareForLegacyAvatarMiddleware ? [polymiddlewareForLegacyAvatarMiddleware] : []), + ...(polymiddlewareFromProps ?? []), activityFallbackPolymiddleware ]), - [polymiddlewareForLegacyActivityMiddleware, polymiddlewareFromProps] + [polymiddlewareForLegacyActivityMiddleware, polymiddlewareForLegacyAvatarMiddleware, polymiddlewareFromProps] ); /** @@ -555,7 +589,6 @@ const ComposerCore = ({ activityStatusRenderer: patchedActivityStatusRenderer, attachmentForScreenReaderRenderer: patchedAttachmentForScreenReaderRenderer, attachmentRenderer: patchedAttachmentRenderer, - avatarRenderer: patchedAvatarRenderer, dir: patchedDir, directLine, downscaleImageToDataURL, @@ -589,7 +622,6 @@ const ComposerCore = ({ patchedActivityStatusRenderer, patchedAttachmentForScreenReaderRenderer, patchedAttachmentRenderer, - patchedAvatarRenderer, patchedDir, patchedGrammars, patchedLocalizedStrings, @@ -617,7 +649,7 @@ const ComposerCore = ({ - + {typeof children === 'function' ? children(context) : children} diff --git a/packages/api/src/hooks/internal/WebChatAPIContext.ts b/packages/api/src/hooks/internal/WebChatAPIContext.ts index a0bc434b51..f567c3dc7f 100644 --- a/packages/api/src/hooks/internal/WebChatAPIContext.ts +++ b/packages/api/src/hooks/internal/WebChatAPIContext.ts @@ -11,7 +11,6 @@ import { createContext } from 'react'; import { RenderActivityStatus } from '../../types/ActivityStatusMiddleware'; import { AttachmentForScreenReaderComponentFactory } from '../../types/AttachmentForScreenReaderMiddleware'; -import { AvatarComponentFactory } from '../../types/AvatarMiddleware'; import { PerformCardAction } from '../../types/CardActionMiddleware'; import { GroupActivities } from '../../types/GroupActivitiesMiddleware'; import LocalizedStrings from '../../types/LocalizedStrings'; @@ -25,7 +24,6 @@ export type WebChatAPIContextType = { activityStatusRenderer: RenderActivityStatus; attachmentForScreenReaderRenderer?: AttachmentForScreenReaderComponentFactory; attachmentRenderer?: LegacyRenderAttachment; - avatarRenderer: AvatarComponentFactory; clearSuggestedActions?: () => void; dir?: string; directLine?: DirectLineJSBotConnection; diff --git a/packages/api/src/hooks/internal/useDebugDeps.js b/packages/api/src/hooks/internal/useDebugDeps.js deleted file mode 100644 index e749de8060..0000000000 --- a/packages/api/src/hooks/internal/useDebugDeps.js +++ /dev/null @@ -1,30 +0,0 @@ -/* eslint no-console: "off" */ - -import { isForbiddenPropertyName } from 'botframework-webchat-core'; -import { useRef } from 'react'; - -export default function useDebugDeps(depsMap, name) { - const lastDepsMapRef = useRef({}); - - const { current: lastDepsMap } = lastDepsMapRef; - const keys = new Set([...Object.keys(depsMap), ...Object.keys(lastDepsMap)]); - const keysChanged = Array.from(keys).filter( - // Mitigation through denylisting. - // eslint-disable-next-line security/detect-object-injection - key => !isForbiddenPropertyName(key) && !Object.is(depsMap[key], lastDepsMap[key]) - ); - - if (keysChanged.length) { - console.groupCollapsed(`Changes found in ${name}`); - - keysChanged.forEach(key => { - // Mitigation through denylisting. - // eslint-disable-next-line security/detect-object-injection - isForbiddenPropertyName(key) || console.log(key, { from: lastDepsMap[key], to: depsMap[key] }); - }); - - console.groupEnd(); - } - - lastDepsMapRef.current = depsMap; -} diff --git a/packages/api/src/hooks/useCreateAvatarRenderer.ts b/packages/api/src/hooks/useCreateAvatarRenderer.ts index 086e985e3a..83671f28e6 100644 --- a/packages/api/src/hooks/useCreateAvatarRenderer.ts +++ b/packages/api/src/hooks/useCreateAvatarRenderer.ts @@ -1,40 +1,26 @@ -import { useMemo } from 'react'; -import useStyleOptions from './useStyleOptions'; -import useWebChatAPIContext from './internal/useWebChatAPIContext'; - -import type { AvatarComponentFactory } from '../types/AvatarMiddleware'; -import type { ReactNode } from 'react'; import type { WebChatActivity } from 'botframework-webchat-core'; +import { useCallback, type ReactNode } from 'react'; +import useBuildRenderAvatarCallback from '../middleware/useBuildRenderAvatarCallback'; -export default function useCreateAvatarRenderer(): ({ - activity -}: { - activity: WebChatActivity; -}) => false | (() => Exclude) { - const [styleOptions] = useStyleOptions(); - const { avatarRenderer }: { avatarRenderer: AvatarComponentFactory } = useWebChatAPIContext(); - - return useMemo( - () => - ({ activity }) => { - const { from: { role } = {} }: { from?: { role?: string } } = activity; - - const result = avatarRenderer({ - activity, - fromUser: role === 'user', - styleOptions - }); +type CreateAvatarRendererCallback = (request: { + readonly activity: WebChatActivity; +}) => false | (() => Exclude); - if (result !== false && typeof result !== 'function') { - console.warn( - 'botframework-webchat: avatarMiddleware should return a function to render the avatar, or return false if avatar should be hidden. Please refer to HOOKS.md for details.' - ); +/** + * @deprecated Use `` or `useBuildRenderAvatarCallback` instead. This hook will be removed on or after 2028-03-16. + */ +export default function useCreateAvatarRenderer(): CreateAvatarRendererCallback { + const buildRenderAvatar = useBuildRenderAvatarCallback(); - return () => result; - } + // TODO: [P1] We should move this function into `api-middleware`. + // However, it use `useStyleOptions` which is from `api` package. + // In order to do that, we need to build a new `api-style-options` package first. + return useCallback( + ({ activity }) => { + const renderer = buildRenderAvatar(Object.freeze({ activity })); - return result; - }, - [avatarRenderer, styleOptions] + return renderer ? (): ReactNode => renderer({}) : false; + }, + [buildRenderAvatar] ); } diff --git a/packages/api/src/legacy/LegacyActivityBridge.tsx b/packages/api/src/legacy/LegacyActivityBridge.tsx index 00240ee122..a9c5d7e5fc 100644 --- a/packages/api/src/legacy/LegacyActivityBridge.tsx +++ b/packages/api/src/legacy/LegacyActivityBridge.tsx @@ -72,4 +72,6 @@ function LegacyActivityBridge(props: LegacyActivityBridgeComponentProps) { return {children}; } +LegacyActivityBridge.displayName = 'LegacyActivityBridge'; + export default memo(LegacyActivityBridge); diff --git a/packages/api/src/legacy/createActivityPolymiddlewareFromLegacy.tsx b/packages/api/src/legacy/createActivityPolymiddlewareFromLegacy.tsx index 8135ee1040..a1f8c1e076 100644 --- a/packages/api/src/legacy/createActivityPolymiddlewareFromLegacy.tsx +++ b/packages/api/src/legacy/createActivityPolymiddlewareFromLegacy.tsx @@ -27,6 +27,7 @@ import { import LegacyActivityBridge from './LegacyActivityBridge'; +const DEBUG_ORIGINAL_LEGACY_MIDDLEWARE_SYMBOL = Symbol('OriginalLegacyMiddleware'); const webChatActivitySchema = custom(value => safeParse(object({}), value).success); type LegacyRenderFunction = ( @@ -87,7 +88,7 @@ function createActivityPolymiddlewareFromLegacy( ): ActivityPolymiddleware { const legacyEnhancer = composeEnhancer(...middleware.map(middleware => middleware())); - return createActivityPolymiddleware(next => { + const polymiddleware = createActivityPolymiddleware(next => { const legacyHandler = legacyEnhancer(request => { const handler = next(request); @@ -102,6 +103,14 @@ function createActivityPolymiddlewareFromLegacy( : undefined; }; }); + + Object.defineProperty(polymiddleware, DEBUG_ORIGINAL_LEGACY_MIDDLEWARE_SYMBOL, { + configurable: false, + value: Object.freeze([...middleware]), + writable: false + }); + + return polymiddleware; } export default createActivityPolymiddlewareFromLegacy; diff --git a/packages/api/src/legacy/createAvatarPolymiddlewareFromLegacy.tsx b/packages/api/src/legacy/createAvatarPolymiddlewareFromLegacy.tsx new file mode 100644 index 0000000000..e186fee70d --- /dev/null +++ b/packages/api/src/legacy/createAvatarPolymiddlewareFromLegacy.tsx @@ -0,0 +1,102 @@ +import { + __INTERNAL_DO_NOT_USE__avatarPolymiddlewareRequestStyleOptionsSymbol, + __INTERNAL_DO_NOT_USE__legacyAvatarMiddlewareOriginalRequestSymbol, + avatarComponent, + createAvatarPolymiddleware, + type AvatarPolymiddleware +} from '@msinternal/botframework-webchat-api-middleware'; +import { type LegacyAvatarMiddleware } from '@msinternal/botframework-webchat-api-middleware/legacy'; +import { composeEnhancer } from 'handler-chain'; +import React, { Fragment, memo, type ReactNode } from 'react'; +import { custom, function_, never, object, optional, pipe, readonly, safeParse, type InferInput } from 'valibot'; + +type LegacyAvatarRenderFunction = () => Exclude; + +const legacyAvatarBridgeComponentPropsSchema = pipe( + object({ + children: optional(never()), + renderFn: custom(value => safeParse(function_(), value).success) + }), + readonly() +); + +type LegacyAvatarBridgeComponentProps = Readonly< + InferInput & { children?: never } +>; + +/** + * Bridge component for the legacy avatar middleware. + * Renders the result of the legacy render function. + */ +function LegacyAvatarBridge(props: LegacyAvatarBridgeComponentProps) { + const { renderFn } = props; + + return {renderFn()}; +} + +const MemoizedLegacyAvatarBridge = memo(LegacyAvatarBridge); + +/** + * Polyfill legacy avatar middleware into a polymiddleware. + * + * @deprecated Use `polymiddleware` instead. Legacy avatar middleware is being deprecated and will be removed on or after 2027-08-16. + * @param middleware An array of legacy avatar middleware. + * @returns A polymiddleware composed by legacy avatar middleware. + */ +function createAvatarPolymiddlewareFromLegacy(...middlewares: readonly LegacyAvatarMiddleware[]): AvatarPolymiddleware { + const legacyEnhancer = composeEnhancer(...middlewares.map(middleware => middleware())); + + return createAvatarPolymiddleware(next => { + const legacyHandler = legacyEnhancer( + ({ [__INTERNAL_DO_NOT_USE__legacyAvatarMiddlewareOriginalRequestSymbol]: originalRequest }) => { + if (!originalRequest) { + // TODO: Add a test + throw new Error( + 'botframework-webchat: `avatarMiddleware` must pass the whole request object to downstreamers' + ); + } + + // Pass styleOptions through the polymiddleware chain via the internal runtime extension + // so downstream handlers (e.g. core middleware) can still read it. + const handler = next(originalRequest); + + return !!handler && ((): Exclude => handler.render({})); + } + ); + + return request => { + const { [__INTERNAL_DO_NOT_USE__avatarPolymiddlewareRequestStyleOptionsSymbol]: styleOptions, activity } = + request; + + const legacyResult = legacyHandler( + Object.freeze({ + activity, + fromUser: activity.from?.role === 'user', + styleOptions, + [__INTERNAL_DO_NOT_USE__legacyAvatarMiddlewareOriginalRequestSymbol]: request + }) + ); + + if (!legacyResult) { + return; + } + + let props: LegacyAvatarBridgeComponentProps; + + if (typeof legacyResult !== 'function') { + console.warn( + 'botframework-webchat: avatarMiddleware should return a function to render the avatar, or return false if avatar should be hidden. Please refer to HOOKS.md for details.' + ); + + props = Object.freeze({ renderFn: () => legacyResult }); + } else { + props = Object.freeze({ renderFn: legacyResult }); + } + + return avatarComponent(MemoizedLegacyAvatarBridge, props); + }; + }); +} + +export default createAvatarPolymiddlewareFromLegacy; +export { legacyAvatarBridgeComponentPropsSchema, type LegacyAvatarBridgeComponentProps }; diff --git a/packages/api/src/middleware/AvatarPolymiddlewareProxy.tsx b/packages/api/src/middleware/AvatarPolymiddlewareProxy.tsx new file mode 100644 index 0000000000..3d9908b205 --- /dev/null +++ b/packages/api/src/middleware/AvatarPolymiddlewareProxy.tsx @@ -0,0 +1,46 @@ +// We need to patch from `api-middleware`. +// +// - Props of should not need `styleOptions` +// - We should call `useStyleOptions()` to get style options +// - However, `api-middleware` is before `api`, so it do not have access to `useStyleOptions` +// +// Until we have `api-style-options`, we have to patch inside `api`. + +import { + __INTERNAL_DO_NOT_USE__avatarPolymiddlewareRequestStyleOptionsSymbol, + AvatarPolymiddlewareProxy as RawAvatarPolymiddlewareProxy +} from '@msinternal/botframework-webchat-api-middleware'; +import { validateProps } from '@msinternal/botframework-webchat-react-valibot'; +import type { WebChatActivity } from 'botframework-webchat-core'; +import React, { memo, useMemo } from 'react'; +import { custom, object, pipe, readonly, safeParse, type InferInput } from 'valibot'; +import { useStyleOptions } from '../hooks'; + +const avatarPolymiddlewareProxyPropsSchema = pipe( + object({ + activity: custom>(value => safeParse(object({}), value).success) + }), + readonly() +); + +type AvatarPolymiddlewareProxyProps = Readonly>; + +const AvatarPolymiddlewareProxy = memo((props: AvatarPolymiddlewareProxyProps) => { + const { activity } = validateProps(avatarPolymiddlewareProxyPropsSchema, props); + + const [styleOptions] = useStyleOptions(); + + const rawProps = useMemo( + () => ({ + [__INTERNAL_DO_NOT_USE__avatarPolymiddlewareRequestStyleOptionsSymbol]: styleOptions + }), + [styleOptions] + ); + + return ; +}); + +AvatarPolymiddlewareProxy.displayName = 'AvatarPolymiddlewareProxy'; + +export default AvatarPolymiddlewareProxy; +export { avatarPolymiddlewareProxyPropsSchema, type AvatarPolymiddlewareProxyProps }; diff --git a/packages/api/src/middleware/useBuildRenderAvatarCallback.ts b/packages/api/src/middleware/useBuildRenderAvatarCallback.ts new file mode 100644 index 0000000000..caf58372d5 --- /dev/null +++ b/packages/api/src/middleware/useBuildRenderAvatarCallback.ts @@ -0,0 +1,37 @@ +// We need to patch `useBuildRenderAvatarCallback()` from `api-middleware`. +// +// - Request of `useBuildRenderAvatarCallback()` should not need `styleOptions` +// - We should call `useStyleOptions()` to get style options +// - However, `api-middleware` is before `api`, so it do not have access to `useStyleOptions` +// +// Until we have `api-style-options`, we have to patch `useBuildRenderAvatarCallback()` inside `api`. + +import { + __INTERNAL_DO_NOT_USE__avatarPolymiddlewareRequestStyleOptionsSymbol, + useBuildRenderAvatarCallback as useBuildRenderAvatarCallbackRaw +} from '@msinternal/botframework-webchat-api-middleware'; +import type { WebChatActivity } from 'botframework-webchat-core'; +import { useCallback } from 'react'; +import type { ComponentRenderer } from 'react-chain-of-responsibility/preview'; +import { useRefFrom } from 'use-ref-from'; +import { useStyleOptions } from '../hooks'; + +export default function useBuildRenderAvatarCallback(): (request: { + readonly activity: WebChatActivity; +}) => ComponentRenderer<{ children?: never }> { + const [styleOptions] = useStyleOptions(); + + const renderAvatar = useBuildRenderAvatarCallbackRaw(); + const styleOptionsRef = useRefFrom(styleOptions); + + return useCallback<(request: { readonly activity: WebChatActivity }) => ReturnType>( + request => + renderAvatar( + Object.freeze({ + activity: request.activity, + [__INTERNAL_DO_NOT_USE__avatarPolymiddlewareRequestStyleOptionsSymbol]: styleOptionsRef.current + }) + ), + [renderAvatar, styleOptionsRef] + ); +} diff --git a/packages/api/src/providers/GroupActivities/GroupActivitiesComposer.tsx b/packages/api/src/providers/GroupActivities/GroupActivitiesComposer.tsx index 8dedd511a8..a32a9ab488 100644 --- a/packages/api/src/providers/GroupActivities/GroupActivitiesComposer.tsx +++ b/packages/api/src/providers/GroupActivities/GroupActivitiesComposer.tsx @@ -13,7 +13,7 @@ import isGroupingValid from './private/isGroupingValid'; type GroupActivitiesComposerProps = Readonly<{ children?: ReactNode | undefined; - groupActivitiesMiddleware: readonly GroupActivitiesMiddleware[]; + groupActivitiesMiddleware: readonly GroupActivitiesMiddleware[] | undefined; }>; function GroupActivitiesComposer({ children, groupActivitiesMiddleware }: GroupActivitiesComposerProps) { @@ -24,7 +24,7 @@ function GroupActivitiesComposer({ children, groupActivitiesMiddleware }: GroupA () => applyMiddleware( 'group activities', - ...groupActivitiesMiddleware, + ...(groupActivitiesMiddleware ?? []), ...createDefaultGroupActivitiesMiddleware({ groupTimestamp, ponyfill }), () => () => () => ({}) ), diff --git a/packages/api/src/types/AvatarMiddleware.ts b/packages/api/src/types/AvatarMiddleware.ts index 351b11f9fa..3e3c2fb0c1 100644 --- a/packages/api/src/types/AvatarMiddleware.ts +++ b/packages/api/src/types/AvatarMiddleware.ts @@ -1,10 +1,17 @@ import { type WebChatActivity } from 'botframework-webchat-core'; +import type { + __INTERNAL_DO_NOT_USE__legacyAvatarMiddlewareOriginalRequestSymbol, + AvatarPolymiddlewareRequest +} from '@msinternal/botframework-webchat-api-middleware'; import { StrictStyleOptions } from '../StyleOptions'; import ComponentMiddleware, { ComponentFactory } from './ComponentMiddleware'; type AvatarComponentFactoryArguments = [ { + // We need to keep the original polymiddleweare request while running inside legacy middleware. + // When we transit from legacy middleware back to polymiddleware, we can restore the request object. + [__INTERNAL_DO_NOT_USE__legacyAvatarMiddlewareOriginalRequestSymbol]: AvatarPolymiddlewareRequest; activity: WebChatActivity; fromUser: boolean; styleOptions: StrictStyleOptions; diff --git a/packages/base/src/utils/OneOrMany.ts b/packages/base/src/utils/OneOrMany.ts new file mode 100644 index 0000000000..b9f95411b1 --- /dev/null +++ b/packages/base/src/utils/OneOrMany.ts @@ -0,0 +1,4 @@ +/** @deprecated Will be removed on or after 2028-03-18. */ +type OneOrMany = T | readonly T[]; + +export { type OneOrMany }; diff --git a/packages/base/src/utils/index.ts b/packages/base/src/utils/index.ts index e1caad9c14..b8c0a3242f 100644 --- a/packages/base/src/utils/index.ts +++ b/packages/base/src/utils/index.ts @@ -1,5 +1,8 @@ export { default as createBuildInfo, type BuildInfo, type ReadonlyBuildInfo } from './createBuildInfo'; export { default as deprecateObject } from './deprecateObject'; export { default as isForbiddenPropertyName } from './isForbiddenPropertyName'; +export { default as iterateEquals } from './iterateEquals'; +export { type OneOrMany } from './OneOrMany'; +export { default as singleToArray } from './singleToArray'; export { default as warnOnce } from './warnOnce'; export { default as withResolvers, type PromiseWithResolvers } from './withResolvers'; diff --git a/packages/base/src/utils/iterateEquals.spec.ts b/packages/base/src/utils/iterateEquals.spec.ts new file mode 100644 index 0000000000..29c8f081dc --- /dev/null +++ b/packages/base/src/utils/iterateEquals.spec.ts @@ -0,0 +1,9 @@ +import { expect, test } from '@jest/globals'; +import iterateEquals from './iterateEquals'; + +test('comparing empty arrays', () => expect(iterateEquals([].values(), [].values())).toBe(true)); +test('comparing different arrays', () => expect(iterateEquals([1].values(), [2].values())).toBe(false)); +test('comparing bigger array to smaller array', () => expect(iterateEquals([1, 2].values(), [1].values())).toBe(false)); +test('comparing smaller array to bigger array', () => expect(iterateEquals([1].values(), [1, 2].values())).toBe(false)); +test('comparing non-empty array to empty array', () => expect(iterateEquals([1].values(), [].values())).toBe(false)); +test('comparing empty array to non-empty array', () => expect(iterateEquals([].values(), [1].values())).toBe(false)); diff --git a/packages/base/src/utils/iterateEquals.ts b/packages/base/src/utils/iterateEquals.ts new file mode 100644 index 0000000000..bb9ad460e8 --- /dev/null +++ b/packages/base/src/utils/iterateEquals.ts @@ -0,0 +1,21 @@ +const MAX_ITERATION = 1_000_000; + +export default function iterateEquals(x: Iterable, y: Iterable): boolean { + const xIterator = x[Symbol.iterator](); + const yIterator = y[Symbol.iterator](); + + for (let count = 0; count < MAX_ITERATION; count++) { + const resultX = xIterator.next(); + const resultY = yIterator.next(); + + if (resultX.done && resultY.done) { + return true; + } + + if (!Object.is(resultX.value, resultY.value)) { + break; + } + } + + return false; +} diff --git a/packages/base/src/utils/singleToArray.ts b/packages/base/src/utils/singleToArray.ts new file mode 100644 index 0000000000..b2bcdf6d17 --- /dev/null +++ b/packages/base/src/utils/singleToArray.ts @@ -0,0 +1,9 @@ +/** @deprecated Will be removed on or after 2028-03-18. */ +export default function singleToArray(singleOrArray: T | T[]): T[]; +/** @deprecated Will be removed on or after 2028-03-18. */ +export default function singleToArray(singleOrArray: T | readonly T[]): readonly T[]; + +/** @deprecated Will be removed on or after 2028-03-18. */ +export default function singleToArray(singleOrArray: T | T[]): T[] { + return singleOrArray ? (Array.isArray(singleOrArray) ? [...singleOrArray] : [singleOrArray]) : []; +} diff --git a/packages/bundle/package.json b/packages/bundle/package.json index ece75748b4..672cb8905a 100644 --- a/packages/bundle/package.json +++ b/packages/bundle/package.json @@ -153,6 +153,7 @@ "@msinternal/base64-js": "development", "@msinternal/botframework-directlinejs": "development", "@msinternal/botframework-webchat-base": "development", + "@msinternal/botframework-webchat-react-hooks": "development", "@msinternal/botframework-webchat-react-valibot": "development", "@msinternal/botframework-webchat-styles": "development", "@msinternal/botframework-webchat-tsconfig": "development", @@ -182,6 +183,7 @@ "@msinternal/base64-js": "0.0.0-0", "@msinternal/botframework-directlinejs": "0.0.0-0", "@msinternal/botframework-webchat-base": "0.0.0-0", + "@msinternal/botframework-webchat-react-hooks": "0.0.0-0", "@msinternal/botframework-webchat-react-valibot": "0.0.0-0", "@msinternal/botframework-webchat-styles": "0.0.0-0", "@msinternal/botframework-webchat-tsconfig": "0.0.0-0", diff --git a/packages/bundle/src/AddFullBundle.tsx b/packages/bundle/src/AddFullBundle.tsx index 370789f191..20a57b2e47 100644 --- a/packages/bundle/src/AddFullBundle.tsx +++ b/packages/bundle/src/AddFullBundle.tsx @@ -1,11 +1,11 @@ -import { warnOnce } from '@msinternal/botframework-webchat-base/utils'; +import { singleToArray, warnOnce, type OneOrMany } from '@msinternal/botframework-webchat-base/utils'; +import { useMemoIterable } from '@msinternal/botframework-webchat-react-hooks'; import { type AttachmentForScreenReaderMiddleware, type AttachmentMiddleware, type StyleOptions } from 'botframework-webchat-api'; import { type HTMLContentTransformMiddleware } from 'botframework-webchat-component'; -import { singleToArray, type OneOrMany } from 'botframework-webchat-core'; import React, { memo, type ReactNode } from 'react'; import AdaptiveCardsComposer from './adaptiveCards/AdaptiveCardsComposer'; @@ -46,8 +46,8 @@ function AddFullBundle({ adaptiveCardHostConfig, adaptiveCardsHostConfig, adaptiveCardsPackage, - attachmentForScreenReaderMiddleware, - attachmentMiddleware, + attachmentForScreenReaderMiddleware: attachmentForScreenReaderMiddlewareFromProps, + attachmentMiddleware: attachmentMiddlewareFromProps, children, htmlContentTransformMiddleware, nonce, @@ -57,9 +57,19 @@ function AddFullBundle({ }: AddFullBundleProps) { adaptiveCardHostConfig && adaptiveCardHostConfigDeprecation(); + const attachmentForScreenReaderMiddleware = useMemoIterable( + () => singleToArray(attachmentForScreenReaderMiddlewareFromProps), + [attachmentForScreenReaderMiddlewareFromProps] + ); + + const attachmentMiddleware = useMemoIterable( + () => singleToArray(attachmentMiddlewareFromProps), + [attachmentMiddlewareFromProps] + ); + const patchedProps = useComposerProps({ - attachmentForScreenReaderMiddleware: singleToArray(attachmentForScreenReaderMiddleware), - attachmentMiddleware: singleToArray(attachmentMiddleware), + attachmentForScreenReaderMiddleware, + attachmentMiddleware, htmlContentTransformMiddleware, renderMarkdown, styleOptions, diff --git a/packages/bundle/src/boot/actual/middleware.ts b/packages/bundle/src/boot/actual/middleware.ts index 9641816786..39662aec03 100644 --- a/packages/bundle/src/boot/actual/middleware.ts +++ b/packages/bundle/src/boot/actual/middleware.ts @@ -13,6 +13,8 @@ export { type Polymiddleware } from 'botframework-webchat-api/middleware'; +export { createActivityPolymiddlewareFromLegacy } from 'botframework-webchat-api/middleware'; + export { createErrorBoxPolymiddleware, errorBoxComponent, @@ -27,4 +29,18 @@ export { type ErrorBoxPolymiddlewareRequest } from 'botframework-webchat-api/middleware'; -export { createActivityPolymiddlewareFromLegacy } from 'botframework-webchat-api/middleware'; +export { + avatarComponent, + AvatarPolymiddlewareProxy, + createAvatarPolymiddleware, + useBuildRenderAvatarCallback, + type AvatarPolymiddleware, + type AvatarPolymiddlewareHandler, + type AvatarPolymiddlewareHandlerResult, + type AvatarPolymiddlewareProps, + type AvatarPolymiddlewareProxyProps, + type AvatarPolymiddlewareRenderer, + type AvatarPolymiddlewareRequest +} from 'botframework-webchat-api/middleware'; + +export { createAvatarPolymiddlewareFromLegacy } from 'botframework-webchat-api/middleware'; diff --git a/packages/bundle/src/useComposerProps.ts b/packages/bundle/src/useComposerProps.ts index e7eca3b7a9..f3679b054b 100644 --- a/packages/bundle/src/useComposerProps.ts +++ b/packages/bundle/src/useComposerProps.ts @@ -16,8 +16,8 @@ export default function useComposerProps({ styleOptions, styleSet }: Readonly<{ - attachmentForScreenReaderMiddleware: AttachmentForScreenReaderMiddleware[]; - attachmentMiddleware: AttachmentMiddleware[]; + attachmentForScreenReaderMiddleware: readonly AttachmentForScreenReaderMiddleware[] | undefined; + attachmentMiddleware: readonly AttachmentMiddleware[] | undefined; htmlContentTransformMiddleware: readonly HTMLContentTransformMiddleware[]; renderMarkdown?: ( markdown: string, @@ -38,12 +38,12 @@ export default function useComposerProps({ ) => string; }> { const patchedAttachmentMiddleware = useMemo( - () => [...attachmentMiddleware, createAdaptiveCardsAttachmentMiddleware()], + () => [...(attachmentMiddleware ?? []), createAdaptiveCardsAttachmentMiddleware()], [attachmentMiddleware] ); const patchedAttachmentForScreenReaderMiddleware = useMemo( - () => [...attachmentForScreenReaderMiddleware, createAdaptiveCardsAttachmentForScreenReaderMiddleware()], + () => [...(attachmentForScreenReaderMiddleware ?? []), createAdaptiveCardsAttachmentForScreenReaderMiddleware()], [attachmentForScreenReaderMiddleware] ); diff --git a/packages/component/src/Activity/Avatar.tsx b/packages/component/src/Activity/Avatar.tsx index 5bd9995f44..ba405241bb 100644 --- a/packages/component/src/Activity/Avatar.tsx +++ b/packages/component/src/Activity/Avatar.tsx @@ -2,7 +2,7 @@ import { validateProps } from '@msinternal/botframework-webchat-react-valibot'; import React, { memo } from 'react'; import { boolean, object, optional, pipe, readonly, string, type InferInput } from 'valibot'; -import { DefaultAvatar } from '../Middleware/Avatar/createCoreMiddleware'; +import DefaultAvatar from '../Middleware/Avatar/DefaultAvatar'; const avatarPropsSchema = pipe( object({ diff --git a/packages/component/src/BasicTranscript.tsx b/packages/component/src/BasicTranscript.tsx index 5b4f65647a..a80949ff6e 100644 --- a/packages/component/src/BasicTranscript.tsx +++ b/packages/component/src/BasicTranscript.tsx @@ -113,363 +113,365 @@ type ScrollToPosition = { activityID?: string; scrollTop?: number }; type InternalTranscriptProps = Readonly<{ className?: string; - terminatorRef: React.MutableRefObject; + terminatorRef: MutableRefObject; }>; // TODO: [P1] #4133 Add telemetry for computing how many re-render done so far. -const InternalTranscript = forwardRef( - ( - { className, terminatorRef }: InternalTranscriptProps, - ref: MutableRefObject | ((instance: HTMLDivElement | null) => void) - ) => { - const [activeDescendantId] = useActiveDescendantId(); - const [direction] = useDirection(); - const [focusedKey] = useFocusedKey(); - const [focusedExplicitly] = useFocusedExplicitly(); - const focusElementMapRef = useActivityElementMapRef(); - const focus = useFocus(); - const focusByActivityKey = useFocusByActivityKey(); - const focusRelativeActivity = useFocusRelativeActivity(); - const getActivityByKey = useGetActivityByKey(); - const getKeyByActivityId = useGetKeyByActivityId(); - const localize = useLocalizer(); - const rootClassName = useStyleToEmotionObject()(ROOT_STYLE) + ''; - const rootElementRef = useRef(null); - - const focusedKeyRef = useValueRef(focusedKey); - const transcriptAriaLabel = localize('TRANSCRIPT_ARIA_LABEL_ALT'); - - const callbackRef = useCallback( - (element: HTMLDivElement) => { - if (typeof ref === 'function') { - ref(element); - } else { - ref.current = element; - } +const InternalTranscript = memo( + forwardRef( + ( + { className, terminatorRef }: InternalTranscriptProps, + ref: MutableRefObject | ((instance: HTMLDivElement | null) => void) + ) => { + const [activeDescendantId] = useActiveDescendantId(); + const [direction] = useDirection(); + const [focusedKey] = useFocusedKey(); + const [focusedExplicitly] = useFocusedExplicitly(); + const focusElementMapRef = useActivityElementMapRef(); + const focus = useFocus(); + const focusByActivityKey = useFocusByActivityKey(); + const focusRelativeActivity = useFocusRelativeActivity(); + const getActivityByKey = useGetActivityByKey(); + const getKeyByActivityId = useGetKeyByActivityId(); + const localize = useLocalizer(); + const rootClassName = useStyleToEmotionObject()(ROOT_STYLE) + ''; + const rootElementRef = useRef(null); + + const focusedKeyRef = useValueRef(focusedKey); + const transcriptAriaLabel = localize('TRANSCRIPT_ARIA_LABEL_ALT'); + + const callbackRef = useCallback( + (element: HTMLDivElement) => { + if (typeof ref === 'function') { + ref(element); + } else { + ref.current = element; + } - rootElementRef.current = element; - }, - [ref, rootElementRef] - ); + rootElementRef.current = element; + }, + [ref, rootElementRef] + ); - const [numRenderingActivities] = useNumRenderingActivities(); + const [numRenderingActivities] = useNumRenderingActivities(); - const scrollToBottomScrollTo: (scrollTop: number, options?: ScrollToOptions) => void = useScrollTo(); - const scrollToBottomScrollToEnd: (options?: ScrollToOptions) => void = useScrollToEnd(); + const scrollToBottomScrollTo: (scrollTop: number, options?: ScrollToOptions) => void = useScrollTo(); + const scrollToBottomScrollToEnd: (options?: ScrollToOptions) => void = useScrollToEnd(); - const scrollTo = useCallback( - (position: ScrollToPosition, { behavior = 'auto' }: ScrollToOptions = {}) => { - if (!position) { - throw new Error( - 'botframework-webchat: First argument passed to "useScrollTo" must be a ScrollPosition object.' - ); - } + const scrollTo = useCallback( + (position: ScrollToPosition, { behavior = 'auto' }: ScrollToOptions = {}) => { + if (!position) { + throw new Error( + 'botframework-webchat: First argument passed to "useScrollTo" must be a ScrollPosition object.' + ); + } - const { activityID: activityId, scrollTop } = position; + const { activityID: activityId, scrollTop } = position; - if (typeof scrollTop !== 'undefined') { - scrollToBottomScrollTo(scrollTop, { behavior }); - } else if (typeof activityId !== 'undefined') { - const activityBoundingBoxElement = focusElementMapRef.current - .get(getKeyByActivityId(activityId)) - ?.querySelector('.webchat__basic-transcript__activity-active-descendant'); + if (typeof scrollTop !== 'undefined') { + scrollToBottomScrollTo(scrollTop, { behavior }); + } else if (typeof activityId !== 'undefined') { + const activityBoundingBoxElement = focusElementMapRef.current + .get(getKeyByActivityId(activityId)) + ?.querySelector('.webchat__basic-transcript__activity-active-descendant'); - const scrollableElement = rootElementRef.current.querySelector('.webchat__basic-transcript__scrollable'); + const scrollableElement = rootElementRef.current.querySelector('.webchat__basic-transcript__scrollable'); - if (scrollableElement && activityBoundingBoxElement) { - // ESLint conflict with TypeScript. The result of getClientRects() is not an Array but DOMRectList, and cannot be destructured. - // eslint-disable-next-line prefer-destructuring - const activityBoundingBoxElementClientRect = activityBoundingBoxElement.getClientRects()[0]; + if (scrollableElement && activityBoundingBoxElement) { + // ESLint conflict with TypeScript. The result of getClientRects() is not an Array but DOMRectList, and cannot be destructured. + // eslint-disable-next-line prefer-destructuring + const activityBoundingBoxElementClientRect = activityBoundingBoxElement.getClientRects()[0]; - // ESLint conflict with TypeScript. The result of getClientRects() is not an Array but DOMRectList, and cannot be destructured. - // eslint-disable-next-line prefer-destructuring - const scrollableElementClientRect = scrollableElement.getClientRects()[0]; + // ESLint conflict with TypeScript. The result of getClientRects() is not an Array but DOMRectList, and cannot be destructured. + // eslint-disable-next-line prefer-destructuring + const scrollableElementClientRect = scrollableElement.getClientRects()[0]; - // If either the activity or the transcript scrollable is not on DOM, we will not scroll the view. - if (activityBoundingBoxElementClientRect && scrollableElementClientRect) { - const { height: activityHeight, y: activityY } = activityBoundingBoxElementClientRect; - const { height: scrollableHeight } = scrollableElementClientRect; - const activityOffsetTop = activityY + scrollableElement.scrollTop; + // If either the activity or the transcript scrollable is not on DOM, we will not scroll the view. + if (activityBoundingBoxElementClientRect && scrollableElementClientRect) { + const { height: activityHeight, y: activityY } = activityBoundingBoxElementClientRect; + const { height: scrollableHeight } = scrollableElementClientRect; + const activityOffsetTop = activityY + scrollableElement.scrollTop; - const scrollTop = Math.min(activityOffsetTop, activityOffsetTop - scrollableHeight + activityHeight); + const scrollTop = Math.min(activityOffsetTop, activityOffsetTop - scrollableHeight + activityHeight); - scrollToBottomScrollTo(scrollTop, { behavior }); + scrollToBottomScrollTo(scrollTop, { behavior }); + } } } - } - }, - [focusElementMapRef, getKeyByActivityId, rootElementRef, scrollToBottomScrollTo] - ); + }, + [focusElementMapRef, getKeyByActivityId, rootElementRef, scrollToBottomScrollTo] + ); - const scrollToEnd = useCallback( - () => scrollToBottomScrollToEnd({ behavior: 'smooth' }), - [scrollToBottomScrollToEnd] - ); + const scrollToEnd = useCallback( + () => scrollToBottomScrollToEnd({ behavior: 'smooth' }), + [scrollToBottomScrollToEnd] + ); - const scrollRelative = useCallback( - ({ direction, displacement }: TranscriptScrollRelativeOptions) => { - const { current: rootElement } = rootElementRef; + const scrollRelative = useCallback( + ({ direction, displacement }: TranscriptScrollRelativeOptions) => { + const { current: rootElement } = rootElementRef; - if (!rootElement) { - return; - } + if (!rootElement) { + return; + } - const scrollable: HTMLElement = rootElement.querySelector('.webchat__basic-transcript__scrollable'); - let nextScrollTop: number; + const scrollable: HTMLElement = rootElement.querySelector('.webchat__basic-transcript__scrollable'); + let nextScrollTop: number; - if (typeof displacement === 'number') { - // eslint-disable-next-line no-magic-numbers - nextScrollTop = scrollable.scrollTop + (direction === 'down' ? 1 : -1) * displacement; - } else { - // eslint-disable-next-line no-magic-numbers - nextScrollTop = scrollable.scrollTop + (direction === 'down' ? 1 : -1) * scrollable.offsetHeight; - } + if (typeof displacement === 'number') { + // eslint-disable-next-line no-magic-numbers + nextScrollTop = scrollable.scrollTop + (direction === 'down' ? 1 : -1) * displacement; + } else { + // eslint-disable-next-line no-magic-numbers + nextScrollTop = scrollable.scrollTop + (direction === 'down' ? 1 : -1) * scrollable.offsetHeight; + } - scrollTo( - { - scrollTop: Math.max(0, Math.min(scrollable.scrollHeight - scrollable.offsetHeight, nextScrollTop)) - }, - { behavior: 'smooth' } - ); - }, - [rootElementRef, scrollTo] - ); - - // Since there could be multiple instances of inside the , when the developer calls `scrollXXX`, we need to call it on all instances. - // We call `useRegisterScrollXXX` to register a callback function, the `useScrollXXX` will multiplex the call into each instance of . - useRegisterScrollTo(scrollTo); - useRegisterScrollToEnd(scrollToEnd); - useRegisterScrollRelativeTranscript(scrollRelative); - - const markActivityKeyAsRead = useMarkActivityKeyAsRead(); - - const dispatchScrollPositionWithActivityId: (scrollPosition: ScrollToPosition) => void = - useDispatchScrollPosition(); - - // TODO: [P2] We should use IntersectionObserver to track what activity is in the scrollable. - // However, IntersectionObserver is not available on IE11, we need to make a limited polyfill in React style. - const handleScrollPosition = useCallback( - ({ scrollTop }: { scrollTop: number }) => { - const { current: rootElement } = rootElementRef; - - if (!rootElement) { - return; - } + scrollTo( + { + scrollTop: Math.max(0, Math.min(scrollable.scrollHeight - scrollable.offsetHeight, nextScrollTop)) + }, + { behavior: 'smooth' } + ); + }, + [rootElementRef, scrollTo] + ); - const scrollableElement = rootElement.querySelector('.webchat__basic-transcript__scrollable'); + // Since there could be multiple instances of inside the , when the developer calls `scrollXXX`, we need to call it on all instances. + // We call `useRegisterScrollXXX` to register a callback function, the `useScrollXXX` will multiplex the call into each instance of . + useRegisterScrollTo(scrollTo); + useRegisterScrollToEnd(scrollToEnd); + useRegisterScrollRelativeTranscript(scrollRelative); - // "getClientRects()" is not returning an array, thus, it is not destructurable. - // eslint-disable-next-line prefer-destructuring - const scrollableElementClientRect = scrollableElement.getClientRects()[0]; + const markActivityKeyAsRead = useMarkActivityKeyAsRead(); - // If the scrollable is not mounted, we cannot measure which activity is in view. Thus, we will not fire any events. - if (!scrollableElementClientRect) { - return; - } + const dispatchScrollPositionWithActivityId: (scrollPosition: ScrollToPosition) => void = + useDispatchScrollPosition(); - const { bottom: scrollableClientBottom } = scrollableElementClientRect; - - // Find the activity just above scroll view bottom. - // If the scroll view is already on top, get the first activity. - const activityElements = Array.from(focusElementMapRef.current.entries()); - const activityKeyJustAboveScrollBottom: string | undefined = ( - scrollableElement.scrollTop - ? activityElements - .reverse() - // Add subpixel tolerance - .find(([, element]) => { - // "getClientRects()" is not returning an array, thus, it is not destructurable. - // eslint-disable-next-line prefer-destructuring - const elementClientRect = element.getClientRects()[0]; - - // If the activity is not attached to DOM tree, we should not count it as "bottommost visible activity", as it is not visible. - return elementClientRect && elementClientRect.bottom < scrollableClientBottom + 1; - }) - : activityElements[0] - )?.[0]; - - // When the end-user slowly scrolling the view down, we will mark activity as read when the message fully appear on the screen. - activityKeyJustAboveScrollBottom && markActivityKeyAsRead(activityKeyJustAboveScrollBottom); - - if (dispatchScrollPositionWithActivityId) { - const activity = getActivityByKey(activityKeyJustAboveScrollBottom); - - dispatchScrollPositionWithActivityId({ ...(activity ? { activityID: activity.id } : {}), scrollTop }); - } - }, - [ - focusElementMapRef, - dispatchScrollPositionWithActivityId, - getActivityByKey, - markActivityKeyAsRead, - rootElementRef - ] - ); - - useObserveScrollPosition(handleScrollPosition); - - const handleTranscriptKeyDown = useCallback>( - event => { - const { target } = event; - - const fromEndOfTranscriptIndicator = target === terminatorRef.current; - const fromTranscript = target === event.currentTarget; - - if (!fromEndOfTranscriptIndicator && !fromTranscript) { - return; - } + // TODO: [P2] We should use IntersectionObserver to track what activity is in the scrollable. + // However, IntersectionObserver is not available on IE11, we need to make a limited polyfill in React style. + const handleScrollPosition = useCallback( + ({ scrollTop }: { scrollTop: number }) => { + const { current: rootElement } = rootElementRef; - let handled = true; + if (!rootElement) { + return; + } - switch (event.key) { - case 'ArrowDown': - focusRelativeActivity(fromEndOfTranscriptIndicator ? 0 : 1); - break; + const scrollableElement = rootElement.querySelector('.webchat__basic-transcript__scrollable'); - case 'ArrowUp': - // eslint-disable-next-line no-magic-numbers - focusRelativeActivity(fromEndOfTranscriptIndicator ? 0 : -1); - break; - - case 'End': - focusRelativeActivity(Infinity); - break; - - case 'Enter': - // This is capturing plain ENTER. - // When screen reader is not running, or screen reader is running outside of scan mode, the ENTER key will be captured here. - if (!fromEndOfTranscriptIndicator) { - const focusElement = focusElementMapRef.current?.get(focusedKeyRef.current); - const activityFocusTrapTarget: HTMLElement = - focusElement?.querySelector('.webchat__basic-transcript__group-focus-target') ?? - focusElement?.querySelector('.webchat__basic-transcript__activity-focus-target'); - // TODO: review focus approach: - // It is not clear how to handle focus without introducing something like context. - // Ideally we would want a way to interact with focus outside of React - // so it doesn't cause transcript re-renders while still having an ability - // to scope activity-related handlers and data in a single place. - activityFocusTrapTarget?.focus(); - } + // "getClientRects()" is not returning an array, thus, it is not destructurable. + // eslint-disable-next-line prefer-destructuring + const scrollableElementClientRect = scrollableElement.getClientRects()[0]; - break; + // If the scrollable is not mounted, we cannot measure which activity is in view. Thus, we will not fire any events. + if (!scrollableElementClientRect) { + return; + } - case 'Escape': - focus('sendBoxWithoutKeyboard'); - break; + const { bottom: scrollableClientBottom } = scrollableElementClientRect; + + // Find the activity just above scroll view bottom. + // If the scroll view is already on top, get the first activity. + const activityElements = Array.from(focusElementMapRef.current.entries()); + const activityKeyJustAboveScrollBottom: string | undefined = ( + scrollableElement.scrollTop + ? activityElements + .reverse() + // Add subpixel tolerance + .find(([, element]) => { + // "getClientRects()" is not returning an array, thus, it is not destructurable. + // eslint-disable-next-line prefer-destructuring + const elementClientRect = element.getClientRects()[0]; + + // If the activity is not attached to DOM tree, we should not count it as "bottommost visible activity", as it is not visible. + return elementClientRect && elementClientRect.bottom < scrollableClientBottom + 1; + }) + : activityElements[0] + )?.[0]; + + // When the end-user slowly scrolling the view down, we will mark activity as read when the message fully appear on the screen. + activityKeyJustAboveScrollBottom && markActivityKeyAsRead(activityKeyJustAboveScrollBottom); + + if (dispatchScrollPositionWithActivityId) { + const activity = getActivityByKey(activityKeyJustAboveScrollBottom); + + dispatchScrollPositionWithActivityId({ ...(activity ? { activityID: activity.id } : {}), scrollTop }); + } + }, + [ + focusElementMapRef, + dispatchScrollPositionWithActivityId, + getActivityByKey, + markActivityKeyAsRead, + rootElementRef + ] + ); + + useObserveScrollPosition(handleScrollPosition); + + const handleTranscriptKeyDown = useCallback>( + event => { + const { target } = event; + + const fromEndOfTranscriptIndicator = target === terminatorRef.current; + const fromTranscript = target === event.currentTarget; + + if (!fromEndOfTranscriptIndicator && !fromTranscript) { + return; + } - case 'Home': - focusRelativeActivity(-Infinity); - break; + let handled = true; - default: - handled = false; - break; - } + switch (event.key) { + case 'ArrowDown': + focusRelativeActivity(fromEndOfTranscriptIndicator ? 0 : 1); + break; - if (handled) { - event.preventDefault(); + case 'ArrowUp': + // eslint-disable-next-line no-magic-numbers + focusRelativeActivity(fromEndOfTranscriptIndicator ? 0 : -1); + break; - // If a custom HTML control wants to handle up/down arrow, we will prevent them from listening to this event to prevent bugs due to handling arrow keys twice. - event.stopPropagation(); - } - }, - [focusElementMapRef, focus, focusedKeyRef, focusRelativeActivity, terminatorRef] - ); - - const handleTranscriptKeyDownCapture = useCallback>( - event => { - const { altKey, ctrlKey, key, metaKey, target } = event; - - if ( - altKey || - (ctrlKey && key !== 'v' && key !== 'V') || - metaKey || - (!inputtableKey(key) && key !== 'Backspace') - ) { - // Ignore if one of the utility key (except SHIFT) is pressed - // E.g. CTRL-C on a link in one of the message should not jump to chat box - // E.g. "A" or "Backspace" should jump to chat box - return; - } + case 'End': + focusRelativeActivity(Infinity); + break; - // Send keystrokes to send box if we are focusing on the transcript or terminator. - if (target === event.currentTarget || target === terminatorRef.current) { - event.stopPropagation(); + case 'Enter': + // This is capturing plain ENTER. + // When screen reader is not running, or screen reader is running outside of scan mode, the ENTER key will be captured here. + if (!fromEndOfTranscriptIndicator) { + const focusElement = focusElementMapRef.current?.get(focusedKeyRef.current); + const activityFocusTrapTarget: HTMLElement = + focusElement?.querySelector('.webchat__basic-transcript__group-focus-target') ?? + focusElement?.querySelector('.webchat__basic-transcript__activity-focus-target'); + // TODO: review focus approach: + // It is not clear how to handle focus without introducing something like context. + // Ideally we would want a way to interact with focus outside of React + // so it doesn't cause transcript re-renders while still having an ability + // to scope activity-related handlers and data in a single place. + activityFocusTrapTarget?.focus(); + } - focus('sendBox'); - } - }, - [focus, terminatorRef] - ); - - useRegisterFocusTranscript(useCallback(() => focusByActivityKey(undefined), [focusByActivityKey])); - - // When the focusing activity has changed, dispatch an event to observers of "useObserveTranscriptFocus". - const dispatchTranscriptFocusByActivityKey = useDispatchTranscriptFocusByActivityKey(); - - // Dispatch a "transcript focus" event based on user selection. - // We should not dispatch "transcript focus" when a new activity come. Although the selection change, it is not initiated from the user. - useMemo( - () => dispatchTranscriptFocusByActivityKey(focusedExplicitly ? focusedKey : undefined), - [dispatchTranscriptFocusByActivityKey, focusedKey, focusedExplicitly] - ); - - // When the transcript is being focused on, we should dispatch a "transcriptfocus" event. - const handleFocus = useCallback( - // We call "focusByActivityKey" with activity key of "true". - // It means, tries to focus on anything. - ({ currentTarget, target }) => target === currentTarget && focusByActivityKey(true, false), - [focusByActivityKey] - ); - - // This is required by IE11. - // When the user clicks on and empty space (a.k.a. filler) in an empty transcript, IE11 says the focus is on the
, - // despite the fact there are no "tabIndex" attributes set on the filler. - // We need to artificially send the focus back to the transcript. - const handleFocusFiller = useCallback(() => focusByActivityKey(undefined), [focusByActivityKey]); - - // When focus into the transcript using TAB/SHIFT-TAB, scroll the focused activity into view. - useObserveFocusVisible( - rootElementRef, - useCallback(() => focusByActivityKey(undefined), [focusByActivityKey]) - ); - - const hasAnyChild = !!numRenderingActivities; - - return ( - for details. - aria-activedescendant={android ? undefined : activeDescendantId} - aria-label={transcriptAriaLabel} - className={classNames('webchat__basic-transcript', rootClassName, (className || '') + '')} - dir={direction} - onFocus={handleFocus} - onKeyDown={handleTranscriptKeyDown} - onKeyDownCapture={handleTranscriptKeyDownCapture} - ref={callbackRef} - // "aria-activedescendant" will only works with a number of roles and it must be explicitly set. - // https://www.w3.org/TR/wai-aria/#aria-activedescendant - role="group" - // For up/down arrow key navigation across activities, this component must be included in the tab sequence. - // Otherwise, "aria-activedescendant" will not be narrated when the user press up/down arrow keys. - // https://www.w3.org/TR/wai-aria-practices-1.1/#kbd_focus_activedescendant - tabIndex={0} - > - - {hasAnyChild && } - - {hasAnyChild && } - - {hasAnyChild && ( - - - - - )} - - ); - } + break; + + case 'Escape': + focus('sendBoxWithoutKeyboard'); + break; + + case 'Home': + focusRelativeActivity(-Infinity); + break; + + default: + handled = false; + break; + } + + if (handled) { + event.preventDefault(); + + // If a custom HTML control wants to handle up/down arrow, we will prevent them from listening to this event to prevent bugs due to handling arrow keys twice. + event.stopPropagation(); + } + }, + [focusElementMapRef, focus, focusedKeyRef, focusRelativeActivity, terminatorRef] + ); + + const handleTranscriptKeyDownCapture = useCallback>( + event => { + const { altKey, ctrlKey, key, metaKey, target } = event; + + if ( + altKey || + (ctrlKey && key !== 'v' && key !== 'V') || + metaKey || + (!inputtableKey(key) && key !== 'Backspace') + ) { + // Ignore if one of the utility key (except SHIFT) is pressed + // E.g. CTRL-C on a link in one of the message should not jump to chat box + // E.g. "A" or "Backspace" should jump to chat box + return; + } + + // Send keystrokes to send box if we are focusing on the transcript or terminator. + if (target === event.currentTarget || target === terminatorRef.current) { + event.stopPropagation(); + + focus('sendBox'); + } + }, + [focus, terminatorRef] + ); + + useRegisterFocusTranscript(useCallback(() => focusByActivityKey(undefined), [focusByActivityKey])); + + // When the focusing activity has changed, dispatch an event to observers of "useObserveTranscriptFocus". + const dispatchTranscriptFocusByActivityKey = useDispatchTranscriptFocusByActivityKey(); + + // Dispatch a "transcript focus" event based on user selection. + // We should not dispatch "transcript focus" when a new activity come. Although the selection change, it is not initiated from the user. + useMemo( + () => dispatchTranscriptFocusByActivityKey(focusedExplicitly ? focusedKey : undefined), + [dispatchTranscriptFocusByActivityKey, focusedKey, focusedExplicitly] + ); + + // When the transcript is being focused on, we should dispatch a "transcriptfocus" event. + const handleFocus = useCallback( + // We call "focusByActivityKey" with activity key of "true". + // It means, tries to focus on anything. + ({ currentTarget, target }) => target === currentTarget && focusByActivityKey(true, false), + [focusByActivityKey] + ); + + // This is required by IE11. + // When the user clicks on and empty space (a.k.a. filler) in an empty transcript, IE11 says the focus is on the
, + // despite the fact there are no "tabIndex" attributes set on the filler. + // We need to artificially send the focus back to the transcript. + const handleFocusFiller = useCallback(() => focusByActivityKey(undefined), [focusByActivityKey]); + + // When focus into the transcript using TAB/SHIFT-TAB, scroll the focused activity into view. + useObserveFocusVisible( + rootElementRef, + useCallback(() => focusByActivityKey(undefined), [focusByActivityKey]) + ); + + const hasAnyChild = !!numRenderingActivities; + + return ( + for details. + aria-activedescendant={android ? undefined : activeDescendantId} + aria-label={transcriptAriaLabel} + className={classNames('webchat__basic-transcript', rootClassName, (className || '') + '')} + dir={direction} + onFocus={handleFocus} + onKeyDown={handleTranscriptKeyDown} + onKeyDownCapture={handleTranscriptKeyDownCapture} + ref={callbackRef} + // "aria-activedescendant" will only works with a number of roles and it must be explicitly set. + // https://www.w3.org/TR/wai-aria/#aria-activedescendant + role="group" + // For up/down arrow key navigation across activities, this component must be included in the tab sequence. + // Otherwise, "aria-activedescendant" will not be narrated when the user press up/down arrow keys. + // https://www.w3.org/TR/wai-aria-practices-1.1/#kbd_focus_activedescendant + tabIndex={0} + > + + {hasAnyChild && } + + {hasAnyChild && } + + {hasAnyChild && ( + + + + + )} + + ); + } + ) ); InternalTranscript.displayName = 'InternalTranscript'; diff --git a/packages/component/src/Composer.tsx b/packages/component/src/Composer.tsx index 5b651bf1ec..e8fb5b4419 100644 --- a/packages/component/src/Composer.tsx +++ b/packages/component/src/Composer.tsx @@ -1,25 +1,34 @@ /* eslint-disable react/require-default-props */ +import { singleToArray } from '@msinternal/botframework-webchat-base/utils'; +import { useMemoIterable } from '@msinternal/botframework-webchat-react-hooks'; import { Composer as APIComposer, + AttachmentForScreenReaderMiddleware, extractSendBoxMiddleware, extractSendBoxToolbarMiddleware, hooks, WebSpeechPonyfillFactory, + type ActivityStatusMiddleware, type ComposerProps as APIComposerProps, + type AttachmentMiddleware, + type AvatarMiddleware, + type CardActionMiddleware, + type ScrollToEndButtonMiddleware, type SendBoxMiddleware, - type SendBoxToolbarMiddleware + type SendBoxToolbarMiddleware, + type ToastMiddleware, + type TypingIndicatorMiddleware } from 'botframework-webchat-api'; import { DecoratorComposer, type DecoratorMiddleware } from 'botframework-webchat-api/decorator'; -import { type Polymiddleware } from 'botframework-webchat-api/middleware'; -import { singleToArray } from 'botframework-webchat-core'; +import { type LegacyActivityMiddleware, type Polymiddleware } from 'botframework-webchat-api/middleware'; +import { StoreDebugAPIRegistry, type StoreDebugAPI } from 'botframework-webchat-core/internal'; import classNames from 'classnames'; import MarkdownIt from 'markdown-it'; import PropTypes from 'prop-types'; import React, { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { Composer as SayComposer } from 'react-say'; -import { StoreDebugAPIRegistry, type StoreDebugAPI } from 'botframework-webchat-core/internal'; import createDefaultAttachmentMiddleware from './Attachment/createMiddleware'; import BuiltInDecorator from './BuiltInDecorator'; import Dictation from './Dictation'; @@ -32,10 +41,10 @@ import UITracker from './hooks/internal/UITracker'; import WebChatUIContext from './hooks/internal/WebChatUIContext'; import { FocusSendBoxScope } from './hooks/sendBoxFocus'; import { ScrollRelativeTranscriptScope } from './hooks/transcriptScrollRelative'; -import createDefaultActivityMiddleware from './Middleware/Activity/createCoreMiddleware'; +import defaultActivityPolymiddleware from './Middleware/Activity/defaultActivityPolymiddleware'; import createDefaultActivityStatusMiddleware from './Middleware/ActivityStatus/createCoreMiddleware'; import createDefaultAttachmentForScreenReaderMiddleware from './Middleware/AttachmentForScreenReader/createCoreMiddleware'; -import createDefaultAvatarMiddleware from './Middleware/Avatar/createCoreMiddleware'; +import createDefaultAvatarMiddleware from './Middleware/Avatar/createDefaultAvatarPolymiddleware'; import createDefaultCardActionMiddleware from './Middleware/CardAction/createCoreMiddleware'; import createDefaultScrollToEndButtonMiddleware from './Middleware/ScrollToEndButton/createScrollToEndButtonMiddleware'; import createDefaultToastMiddleware from './Middleware/Toast/createCoreMiddleware'; @@ -367,83 +376,102 @@ const Composer = ({ const { nonce, onTelemetry } = composerProps; const theme = useTheme(); - const patchedActivityMiddleware = useMemo( - () => [...singleToArray(activityMiddleware), ...theme.activityMiddleware, ...createDefaultActivityMiddleware()], + const patchedActivityMiddleware = useMemoIterable( + () => Object.freeze([...singleToArray(activityMiddleware ?? []), ...theme.activityMiddleware]), [activityMiddleware, theme.activityMiddleware] ); - const patchedActivityStatusMiddleware = useMemo( - () => [ - ...singleToArray(activityStatusMiddleware), - ...theme.activityStatusMiddleware, - ...createDefaultActivityStatusMiddleware() - ], + const patchedActivityStatusMiddleware = useMemoIterable( + () => + Object.freeze([ + ...singleToArray(activityStatusMiddleware ?? []), + ...theme.activityStatusMiddleware, + ...createDefaultActivityStatusMiddleware() + ]), [activityStatusMiddleware, theme.activityStatusMiddleware] ); - const patchedAttachmentForScreenReaderMiddleware = useMemo( - () => [ - ...singleToArray(attachmentForScreenReaderMiddleware), - ...theme.attachmentForScreenReaderMiddleware, - ...createDefaultAttachmentForScreenReaderMiddleware() - ], + const patchedAttachmentForScreenReaderMiddleware = useMemoIterable( + () => + Object.freeze([ + ...singleToArray(attachmentForScreenReaderMiddleware ?? []), + ...theme.attachmentForScreenReaderMiddleware, + ...createDefaultAttachmentForScreenReaderMiddleware() + ]), [attachmentForScreenReaderMiddleware, theme.attachmentForScreenReaderMiddleware] ); - const patchedAttachmentMiddleware = useMemo( - () => [ - ...singleToArray(attachmentMiddleware), - ...theme.attachmentMiddleware, - ...createDefaultAttachmentMiddleware() - ], + const patchedAttachmentMiddleware = useMemoIterable( + () => + Object.freeze([ + ...singleToArray(attachmentMiddleware ?? []), + ...theme.attachmentMiddleware, + ...createDefaultAttachmentMiddleware() + ]), [attachmentMiddleware, theme.attachmentMiddleware] ); - const patchedAvatarMiddleware = useMemo( - () => [...singleToArray(avatarMiddleware), ...theme.avatarMiddleware, ...createDefaultAvatarMiddleware()], + const patchedAvatarMiddleware = useMemoIterable( + () => Object.freeze([...singleToArray(avatarMiddleware ?? []), ...theme.avatarMiddleware]), [avatarMiddleware, theme.avatarMiddleware] ); - const patchedCardActionMiddleware = useMemo( - () => [ - ...singleToArray(cardActionMiddleware), - ...theme.cardActionMiddleware, - ...createDefaultCardActionMiddleware() - ], + const patchedCardActionMiddleware = useMemoIterable( + () => + Object.freeze([ + ...singleToArray(cardActionMiddleware ?? []), + ...theme.cardActionMiddleware, + ...createDefaultCardActionMiddleware() + ]), [cardActionMiddleware, theme.cardActionMiddleware] ); - const patchedPolymiddleware = useMemo( - () => Object.freeze([...(polymiddleware || []), ...theme.polymiddleware]), - [polymiddleware, theme.polymiddleware] + const defaultAvatarPolymiddleware = useMemo(() => createDefaultAvatarMiddleware(styleOptions), [styleOptions]); + + const patchedPolymiddleware = useMemoIterable( + () => + Object.freeze([ + ...(polymiddleware || []), + ...theme.polymiddleware, + // Polymiddleware has lower priority than legacy middleware. + // Later, we should move default middleware to a "default theme." + defaultActivityPolymiddleware, + defaultAvatarPolymiddleware + ]), + [defaultAvatarPolymiddleware, polymiddleware, theme.polymiddleware] ); - const patchedToastMiddleware = useMemo( - () => [...singleToArray(toastMiddleware), ...theme.toastMiddleware, ...createDefaultToastMiddleware()], + const patchedToastMiddleware = useMemoIterable( + () => + Object.freeze([ + ...singleToArray(toastMiddleware ?? []), + ...theme.toastMiddleware, + ...createDefaultToastMiddleware() + ]), [toastMiddleware, theme.toastMiddleware] ); - const patchedTypingIndicatorMiddleware = useMemo( - () => [ - ...singleToArray(typingIndicatorMiddleware), - ...theme.typingIndicatorMiddleware, - ...createDefaultTypingIndicatorMiddleware() - ], + const patchedTypingIndicatorMiddleware = useMemoIterable( + () => + Object.freeze([ + ...singleToArray(typingIndicatorMiddleware ?? []), + ...theme.typingIndicatorMiddleware, + ...createDefaultTypingIndicatorMiddleware() + ]), [typingIndicatorMiddleware, theme.typingIndicatorMiddleware] ); - const defaultScrollToEndButtonMiddleware = useMemo(() => createDefaultScrollToEndButtonMiddleware(), []); - - const patchedScrollToEndButtonMiddleware = useMemo( - () => [ - ...singleToArray(scrollToEndButtonMiddleware), - ...theme.scrollToEndButtonMiddleware, - ...defaultScrollToEndButtonMiddleware - ], - [defaultScrollToEndButtonMiddleware, scrollToEndButtonMiddleware, theme.scrollToEndButtonMiddleware] + const patchedScrollToEndButtonMiddleware = useMemoIterable( + () => + Object.freeze([ + ...singleToArray(scrollToEndButtonMiddleware ?? []), + ...theme.scrollToEndButtonMiddleware, + ...createDefaultScrollToEndButtonMiddleware() + ]), + [scrollToEndButtonMiddleware, theme.scrollToEndButtonMiddleware] ); - const sendBoxMiddleware = useMemo( + const sendBoxMiddleware = useMemoIterable( () => Object.freeze([ ...extractSendBoxMiddleware(sendBoxMiddlewareFromProps), @@ -453,7 +481,7 @@ const Composer = ({ [sendBoxMiddlewareFromProps, theme.sendBoxMiddleware] ); - const sendBoxToolbarMiddleware = useMemo( + const sendBoxToolbarMiddleware = useMemoIterable( () => Object.freeze([ ...extractSendBoxToolbarMiddleware(sendBoxToolbarMiddlewareFromProps), diff --git a/packages/component/src/Middleware/Activity/createCoreMiddleware.tsx b/packages/component/src/Middleware/Activity/defaultActivityPolymiddleware.tsx similarity index 84% rename from packages/component/src/Middleware/Activity/createCoreMiddleware.tsx rename to packages/component/src/Middleware/Activity/defaultActivityPolymiddleware.tsx index e6b6434cd1..b76c939fd0 100644 --- a/packages/component/src/Middleware/Activity/createCoreMiddleware.tsx +++ b/packages/component/src/Middleware/Activity/defaultActivityPolymiddleware.tsx @@ -1,5 +1,6 @@ /* eslint complexity: ["error", 21] */ import { ActivityMiddleware } from 'botframework-webchat-api'; +import { createActivityPolymiddlewareFromLegacy, type Polymiddleware } from 'botframework-webchat-api/middleware'; import { getActivityLivestreamingMetadata, getOrgSchemaMessage, @@ -35,8 +36,11 @@ function shouldFilterActivity(activity, messageThing) { return false; } -export default function createCoreMiddleware(): ActivityMiddleware[] { - return [ +/** + * @deprecated Use `defaultActivityPolymiddleware` instead. The `createCoreActivityMiddleware` will be removed on or after 2028-03-18. + */ +function createCoreActivityMiddleware(): readonly ActivityMiddleware[] { + return Object.freeze([ () => next => (...args) => { @@ -84,5 +88,15 @@ export default function createCoreMiddleware(): ActivityMiddleware[] { return next(...args); } - ]; + ]); } + +const defaultActivityPolymiddleware: Polymiddleware = createActivityPolymiddlewareFromLegacy( + ...createCoreActivityMiddleware() +); + +export default defaultActivityPolymiddleware; +export { + // Exporting `createCoreActivityMiddleware()` for backward compatibility. + createCoreActivityMiddleware +}; diff --git a/packages/component/src/Middleware/Avatar/createCoreMiddleware.tsx b/packages/component/src/Middleware/Avatar/DefaultAvatar.tsx similarity index 58% rename from packages/component/src/Middleware/Avatar/createCoreMiddleware.tsx rename to packages/component/src/Middleware/Avatar/DefaultAvatar.tsx index 1b153d9933..9a84697b0b 100644 --- a/packages/component/src/Middleware/Avatar/createCoreMiddleware.tsx +++ b/packages/component/src/Middleware/Avatar/DefaultAvatar.tsx @@ -1,8 +1,7 @@ -import { AvatarMiddleware } from 'botframework-webchat-api'; import { validateProps } from '@msinternal/botframework-webchat-react-valibot'; import classNames from 'classnames'; import React, { memo } from 'react'; -import { boolean, object, optional, pipe, readonly, string, type InferInput } from 'valibot'; +import { boolean, never, object, optional, pipe, readonly, string, type InferInput } from 'valibot'; import ImageAvatar from '../../Avatar/ImageAvatar'; import InitialsAvatar from '../../Avatar/InitialsAvatar'; @@ -22,7 +21,8 @@ const ROOT_STYLE = { const defaultAvatarPropsSchema = pipe( object({ - 'aria-hidden': optional(boolean()), + 'aria-hidden': optional(boolean(), true), + children: optional(never()), className: optional(string()), fromUser: boolean() }), @@ -32,7 +32,7 @@ const defaultAvatarPropsSchema = pipe( type DefaultAvatarProps = InferInput; function DefaultAvatar(props: DefaultAvatarProps) { - const { 'aria-hidden': ariaHidden = true, className, fromUser } = validateProps(defaultAvatarPropsSchema, props); + const { 'aria-hidden': ariaHidden, className, fromUser } = validateProps(defaultAvatarPropsSchema, props, 'strict'); const [{ avatar: avatarStyleSet }] = useStyleSet(); const rootClassName = useStyleToEmotionObject()(ROOT_STYLE) + ''; @@ -54,22 +54,8 @@ function DefaultAvatar(props: DefaultAvatarProps) { ); } -export default function createCoreAvatarMiddleware(): AvatarMiddleware[] { - return [ - () => - () => - ({ fromUser, styleOptions }) => { - const { botAvatarImage, botAvatarInitials, userAvatarImage, userAvatarInitials } = styleOptions; +DefaultAvatar.displayName = 'DefaultAvatar'; - if (fromUser ? userAvatarImage || userAvatarInitials : botAvatarImage || botAvatarInitials) { - return () => ; - } +export default memo(DefaultAvatar); - return false; - } - ]; -} - -const MemoizedDefaultAvatar = memo(DefaultAvatar); - -export { MemoizedDefaultAvatar as DefaultAvatar, defaultAvatarPropsSchema, type DefaultAvatarProps }; +export { defaultAvatarPropsSchema, type DefaultAvatarProps }; diff --git a/packages/component/src/Middleware/Avatar/createDefaultAvatarPolymiddleware.tsx b/packages/component/src/Middleware/Avatar/createDefaultAvatarPolymiddleware.tsx new file mode 100644 index 0000000000..c1d7a42329 --- /dev/null +++ b/packages/component/src/Middleware/Avatar/createDefaultAvatarPolymiddleware.tsx @@ -0,0 +1,22 @@ +import type { StyleOptions } from 'botframework-webchat-api'; +import { + avatarComponent, + createAvatarPolymiddleware, + // For type portability. + type __INTERNAL_DO_NOT_USE__avatarPolymiddlewareRequestStyleOptionsSymbol +} from 'botframework-webchat-api/middleware'; +import DefaultAvatar from './DefaultAvatar'; + +function createDefaultAvatarMiddleware(styleOptions: StyleOptions | undefined) { + const { botAvatarImage, botAvatarInitials, userAvatarImage, userAvatarInitials } = styleOptions ?? {}; + + return createAvatarPolymiddleware(_next => ({ activity }) => { + const fromUser = activity.from?.role === 'user'; + + return (fromUser ? userAvatarImage || userAvatarInitials : botAvatarImage || botAvatarInitials) + ? avatarComponent(DefaultAvatar, Object.freeze({ fromUser })) + : undefined; + }); +} + +export default createDefaultAvatarMiddleware; diff --git a/packages/component/src/Transcript/hooks/useRenderActivityProps.ts b/packages/component/src/Transcript/hooks/useRenderActivityProps.ts index 2d4041f03a..4467295396 100644 --- a/packages/component/src/Transcript/hooks/useRenderActivityProps.ts +++ b/packages/component/src/Transcript/hooks/useRenderActivityProps.ts @@ -1,4 +1,6 @@ import { hooks } from 'botframework-webchat-api'; +import { __INTERNAL_DO_NOT_USE__avatarPolymiddlewareRequestStyleOptionsSymbol } from 'botframework-webchat-api/internal'; +import { useBuildRenderAvatarCallback } from 'botframework-webchat-api/middleware'; import { type WebChatActivity } from 'botframework-webchat-core'; import { useMemo, type ReactNode } from 'react'; @@ -8,7 +10,7 @@ import useFirstActivityInStatusGroup from '../../Middleware/ActivityGrouping/ui/ import useLastActivityInStatusGroup from '../../Middleware/ActivityGrouping/ui/StatusGrouping/useLastActivity'; import isZeroOrPositive from '../../Utils/isZeroOrPositive'; -const { useCreateActivityStatusRenderer, useCreateAvatarRenderer, useStyleOptions } = hooks; +const { useCreateActivityStatusRenderer, useStyleOptions } = hooks; type RenderActivityProps = { hideTimestamp: boolean; @@ -18,13 +20,15 @@ type RenderActivityProps = { }; const useRenderActivityProps = (activity: WebChatActivity): RenderActivityProps => { - const [{ bubbleFromUserNubOffset, bubbleNubOffset, groupTimestamp, showAvatarInGroup }] = useStyleOptions(); + const [styleOptions] = useStyleOptions(); const [firstActivityInSenderGroup] = useFirstActivityInSenderGroup(); const [firstActivityInStatusGroup] = useFirstActivityInStatusGroup(); const [lastActivityInSenderGroup] = useLastActivityInSenderGroup(); const [lastActivityInStatusGroup] = useLastActivityInStatusGroup(); const createActivityStatusRenderer = useCreateActivityStatusRenderer(); - const renderAvatar = useCreateAvatarRenderer(); + const buildRenderAvatar = useBuildRenderAvatarCallback(); + + const { bubbleFromUserNubOffset, bubbleNubOffset, groupTimestamp, showAvatarInGroup } = styleOptions; const hideAllTimestamps = groupTimestamp === false; const isFirstInSenderGroup = @@ -36,10 +40,13 @@ const useRenderActivityProps = (activity: WebChatActivity): RenderActivityProps const isLastInStatusGroup = lastActivityInStatusGroup === activity || typeof lastActivityInStatusGroup === 'undefined'; - const renderAvatarForSenderGroup = useMemo( - () => !!renderAvatar && renderAvatar({ activity }), - [activity, renderAvatar] - ); + const renderAvatarForSenderGroup = useMemo Exclude)>(() => { + // Pass styleOptions through the runtime object (not typed in public request) for internal use + // by the core middleware and legacy bridge handlers. + const renderer = buildRenderAvatar(Object.freeze({ activity })); + + return renderer ? (): ReactNode => renderer({}) : false; + }, [activity, buildRenderAvatar]); const isTopSideBotNub = isZeroOrPositive(bubbleNubOffset); const isTopSideUserNub = isZeroOrPositive(bubbleFromUserNubOffset); diff --git a/packages/component/src/Utils/singleToArray.ts b/packages/component/src/Utils/singleToArray.ts deleted file mode 100644 index 2e59925b9b..0000000000 --- a/packages/component/src/Utils/singleToArray.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default function singleToArray(singleOrArray: T | T[]): T[] { - return singleOrArray ? (Array.isArray(singleOrArray) ? singleOrArray : [singleOrArray]) : []; -} diff --git a/packages/component/src/boot/internal.ts b/packages/component/src/boot/internal.ts index 9064f4abf0..1dc92dd9f6 100644 --- a/packages/component/src/boot/internal.ts +++ b/packages/component/src/boot/internal.ts @@ -10,3 +10,6 @@ export { default as ScreenReaderText } from '../ScreenReaderText'; export { default as createIconComponent } from '../Utils/createIconComponent'; export { default as parseDocumentFragmentFromString } from '../Utils/parseDocumentFragmentFromString'; export { default as serializeDocumentFragmentIntoString } from '../Utils/serializeDocumentFragmentIntoString'; + +// For type portability +export { type __INTERNAL_DO_NOT_USE__avatarPolymiddlewareRequestStyleOptionsSymbol } from 'botframework-webchat-api/internal'; diff --git a/packages/component/src/index.ts b/packages/component/src/index.ts index 8d4269df49..e8fd434caa 100644 --- a/packages/component/src/index.ts +++ b/packages/component/src/index.ts @@ -3,7 +3,7 @@ export { type WebChatActivity } from 'botframework-webchat-core'; export { default as createCoreAttachmentMiddleware } from './Attachment/createMiddleware'; export { default as Context } from './hooks/internal/WebChatUIContext'; -export { default as createCoreActivityMiddleware } from './Middleware/Activity/createCoreMiddleware'; +export { createCoreActivityMiddleware } from './Middleware/Activity/defaultActivityPolymiddleware'; export { default as createCoreActivityStatusMiddleware } from './Middleware/ActivityStatus/createCoreMiddleware'; export { type HTMLContentTransformEnhancer, diff --git a/packages/core-debug-api/src/RestrictedDebugAPI.ts b/packages/core-debug-api/src/RestrictedDebugAPI.ts index 79472fe7b1..8ef030a7ad 100644 --- a/packages/core-debug-api/src/RestrictedDebugAPI.ts +++ b/packages/core-debug-api/src/RestrictedDebugAPI.ts @@ -3,7 +3,7 @@ import DebugAPI from './private/DebugAPI'; import type { BaseContext, BreakpointObject, RestrictedDebugAPIType } from './types'; // 🔒 This function must be left empty. -// eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars +// eslint-disable-next-line @typescript-eslint/no-empty-function const BREAKPOINT_FUNCTION = (__DEBUG_CONTEXT__: T) => {}; type AsGetters = { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c29a003704..fadb704911 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -6,6 +6,7 @@ import markActivity from './actions/markActivity'; import muteVoiceRecording from './actions/muteVoiceRecording'; import postActivity from './actions/postActivity'; import postVoiceActivity from './actions/postVoiceActivity'; +import type { VoiceHandler } from './actions/registerVoiceHandler'; import registerVoiceHandler from './actions/registerVoiceHandler'; import sendEvent from './actions/sendEvent'; import sendFiles from './actions/sendFiles'; @@ -20,6 +21,7 @@ import setSendBox from './actions/setSendBox'; import setSendBoxAttachments from './actions/setSendBoxAttachments'; import setSendTimeout from './actions/setSendTimeout'; import setSendTypingIndicator from './actions/setSendTypingIndicator'; +import type { VoiceState } from './actions/setVoiceState'; import setVoiceState from './actions/setVoiceState'; import startDictate from './actions/startDictate'; import startSpeakingActivity from './actions/startSpeakingActivity'; @@ -36,7 +38,6 @@ import createStore, { withDevTools as createStoreWithDevTools, withOptions as createStoreWithOptions } from './createStore'; -import OneOrMany from './types/OneOrMany'; import { parseAction } from './types/external/OrgSchema/Action'; import { parseClaim } from './types/external/OrgSchema/Claim'; import { parseCreativeWork } from './types/external/OrgSchema/CreativeWork'; @@ -47,13 +48,10 @@ import { parseVoteAction } from './types/external/OrgSchema/VoteAction'; import getActivityLivestreamingMetadata from './utils/getActivityLivestreamingMetadata'; import getOrgSchemaMessage from './utils/getOrgSchemaMessage'; import onErrorResumeNext from './utils/onErrorResumeNext'; -import singleToArray from './utils/singleToArray'; -import isVoiceActivity from './utils/voiceActivity/isVoiceActivity'; -import isVoiceTranscriptActivity from './utils/voiceActivity/isVoiceTranscriptActivity'; import getVoiceActivityRole from './utils/voiceActivity/getVoiceActivityRole'; import getVoiceActivityText from './utils/voiceActivity/getVoiceActivityText'; -import type { VoiceState } from './actions/setVoiceState'; -import type { VoiceHandler } from './actions/registerVoiceHandler'; +import isVoiceActivity from './utils/voiceActivity/isVoiceActivity'; +import isVoiceTranscriptActivity from './utils/voiceActivity/isVoiceTranscriptActivity'; export { isForbiddenPropertyName, @@ -100,6 +98,9 @@ import type { Project as OrgSchemaProject } from './types/external/OrgSchema/Pro import type { Thing as OrgSchemaThing } from './types/external/OrgSchema/Thing'; import type { UserReview as OrgSchemaUserReview } from './types/external/OrgSchema/UserReview'; +/** @deprecated */ +export { singleToArray, type OneOrMany } from '@msinternal/botframework-webchat-base/utils'; + const Constants = { ActivityClientState, DictateState }; export { @@ -144,7 +145,6 @@ export { setSendTimeout, setSendTypingIndicator, setVoiceState, - singleToArray, startDictate, startSpeakingActivity, startVoiceRecording, @@ -172,7 +172,6 @@ export type { DirectLineVideoCard, GlobalScopePonyfill, Observable, - OneOrMany, OrgSchemaAction, OrgSchemaClaim, OrgSchemaCreativeWork, diff --git a/packages/core/src/types/OneOrMany.ts b/packages/core/src/types/OneOrMany.ts deleted file mode 100644 index 905b1b6cee..0000000000 --- a/packages/core/src/types/OneOrMany.ts +++ /dev/null @@ -1,3 +0,0 @@ -type OneOrMany = T | T[]; - -export default OneOrMany; diff --git a/packages/core/src/utils/singleToArray.ts b/packages/core/src/utils/singleToArray.ts deleted file mode 100644 index 2e59925b9b..0000000000 --- a/packages/core/src/utils/singleToArray.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default function singleToArray(singleOrArray: T | T[]): T[] { - return singleOrArray ? (Array.isArray(singleOrArray) ? singleOrArray : [singleOrArray]) : []; -} diff --git a/packages/react-hooks/package.json b/packages/react-hooks/package.json index 057700add4..a8ca025a54 100644 --- a/packages/react-hooks/package.json +++ b/packages/react-hooks/package.json @@ -49,10 +49,15 @@ "precommit:eslint": "../../node_modules/.bin/eslint --report-unused-disable-directives --max-warnings 0", "precommit:typecheck": "tsc --project ./src --emitDeclarationOnly false --esModuleInterop true --noEmit --pretty false", "preversion": "../../scripts/npm/preversion.sh", - "start": "../../scripts/npm/notify-build.sh \"src\"" + "start": "../../scripts/npm/notify-build.sh \"src\" \"../base/package.json\"" + }, + "localDependencies": { + "@msinternal/botframework-webchat-base": "development" }, - "localDependencies": {}, "peerDependencies": { "react": ">= 16.8.6" + }, + "devDependencies": { + "@msinternal/botframework-webchat-base": "0.0.0-0" } } diff --git a/packages/react-hooks/src/index.ts b/packages/react-hooks/src/index.ts index 117ee2543c..8dc71742e5 100644 --- a/packages/react-hooks/src/index.ts +++ b/packages/react-hooks/src/index.ts @@ -1 +1,3 @@ +export { default as useDebugDeps } from './useDebugDeps'; +export { default as useMemoIterable } from './useMemoIterable'; export { default as useMemoWithPrevious } from './useMemoWithPrevious'; diff --git a/packages/react-hooks/src/useDebugDeps.ts b/packages/react-hooks/src/useDebugDeps.ts new file mode 100644 index 0000000000..5793794349 --- /dev/null +++ b/packages/react-hooks/src/useDebugDeps.ts @@ -0,0 +1,26 @@ +/* eslint no-console: "off" */ + +import { useRef } from 'react'; + +export default function useDebugDeps(depsObject: Record, name: string): void { + const depsMap = Object.freeze(new Map(Object.entries(depsObject))); + const prevDepsMapRef = useRef | undefined>(); + + const { current: prevDepsMap } = prevDepsMapRef; + + // Ignores initial rendering. + if (prevDepsMap) { + const keys = new Set([...depsMap.keys(), ...prevDepsMap.keys()]); + const keysChanged = Array.from(keys).filter(key => !Object.is(depsMap.get(key), prevDepsMap.get(key))); + + if (keysChanged.length) { + console.groupCollapsed(`Changes found in ${name}`); + + keysChanged.forEach(key => console.log(key, { from: prevDepsMap.get(key), to: depsMap.get(key) })); + + console.groupEnd(); + } + } + + prevDepsMapRef.current = depsMap; +} diff --git a/packages/react-hooks/src/useMemoIterable.ts b/packages/react-hooks/src/useMemoIterable.ts new file mode 100644 index 0000000000..87d5fb73c7 --- /dev/null +++ b/packages/react-hooks/src/useMemoIterable.ts @@ -0,0 +1,11 @@ +import { iterateEquals } from '@msinternal/botframework-webchat-base/utils'; +import { type DependencyList } from 'react'; +import useMemoWithPrevious from './useMemoWithPrevious'; + +export default function useMemoIterable>(factory: () => T, deps: DependencyList) { + return useMemoWithPrevious(prevValue => { + const value = factory(); + + return typeof prevValue !== 'undefined' && iterateEquals(value, prevValue) ? prevValue : value; + }, deps); +} diff --git a/packages/test/page-object/src/globals/testHelpers/activityGrouping/ActivityGroupingSurface.js b/packages/test/page-object/src/globals/testHelpers/activityGrouping/ActivityGroupingSurface.js index 97e0782380..b6093d354b 100644 --- a/packages/test/page-object/src/globals/testHelpers/activityGrouping/ActivityGroupingSurface.js +++ b/packages/test/page-object/src/globals/testHelpers/activityGrouping/ActivityGroupingSurface.js @@ -1,7 +1,7 @@ import PropTypes from 'prop-types'; -import ActivityGroupingContext from './ActivityGroupingContext'; import createDirectLineWithTranscript from '../createDirectLineWithTranscript'; +import ActivityGroupingContext from './ActivityGroupingContext'; // Use React from window (UMD) instead of import. const { React: { useEffect, useMemo, useState } = {} } = window; @@ -23,22 +23,6 @@ const URL_QUERY_MAPPING = { wd: 'hide' }; -function createCustomActivityMiddleware(attachmentLayout) { - return () => - next => - (arg0, ...args) => - next( - { - ...arg0, - activity: { - ...arg0.activity, - ...(attachmentLayout && arg0.activity.from.role === 'bot' ? { attachmentLayout } : {}) - } - }, - ...args - ); -} - function generateURL(state) { const params = {}; @@ -143,7 +127,15 @@ const ActivityGroupingSurface = ({ children }) => { let directLine; (async function () { - directLine = await createDirectLineWithTranscript(transcriptName); + directLine = await createDirectLineWithTranscript(transcriptName, { + patchActivity: activity => { + if ((attachmentLayout === 'carousel' || attachmentLayout === 'stacked') && activity.from?.role === 'bot') { + return Object.freeze({ ...activity, attachmentLayout }); + } + + return activity; + } + }); aborted || setDirectLine(directLine); })(); @@ -152,15 +144,7 @@ const ActivityGroupingSurface = ({ children }) => { aborted = true; directLine && directLine.end(); }; - }, [setDirectLine, transcriptName]); - - const activityMiddleware = useMemo( - () => - attachmentLayout === 'carousel' || attachmentLayout === 'stacked' - ? createCustomActivityMiddleware(attachmentLayout) - : undefined, - [attachmentLayout] - ); + }, [attachmentLayout, setDirectLine, transcriptName]); const styleOptions = useMemo( () => ({ @@ -223,7 +207,6 @@ const ActivityGroupingSurface = ({ children }) => { const context = useMemo( () => ({ ...contextState, - activityMiddleware, directLine, setAttachmentLayout, setBotAvatarInitials, @@ -242,7 +225,6 @@ const ActivityGroupingSurface = ({ children }) => { url }), [ - activityMiddleware, contextState, directLine, setAttachmentLayout, diff --git a/packages/test/page-object/src/globals/testHelpers/createDirectLineWithTranscript.js b/packages/test/page-object/src/globals/testHelpers/createDirectLineWithTranscript.js index c1e1fe0b72..a8218f28ce 100644 --- a/packages/test/page-object/src/globals/testHelpers/createDirectLineWithTranscript.js +++ b/packages/test/page-object/src/globals/testHelpers/createDirectLineWithTranscript.js @@ -36,10 +36,13 @@ function createUpdateRelativeTimestamp(now, { Date }) { export default function createDirectLineWithTranscript( activitiesOrFilename, - { overridePostActivity, ponyfill: { Date } = { Date: window.Date } } = {} + { overridePostActivity, patchActivity: patchActivityFromOptions, ponyfill: { Date } = { Date: window.Date } } = {} ) { const now = Date.now(); - const patchActivity = createUpdateRelativeTimestamp(now, { Date }); + const patchActivity = activity => + createUpdateRelativeTimestamp(now, { Date })( + patchActivityFromOptions ? patchActivityFromOptions(activity) : activity + ); const connectionStatusDeferredObservable = createDeferredObservable(() => { connectionStatusDeferredObservable.next(0); }); diff --git a/packages/test/page-object/src/globals/testHelpers/createRunHookActivityMiddleware.js b/packages/test/page-object/src/globals/testHelpers/createRunHookActivityMiddleware.js index 3ecac51882..eda97b3ad2 100644 --- a/packages/test/page-object/src/globals/testHelpers/createRunHookActivityMiddleware.js +++ b/packages/test/page-object/src/globals/testHelpers/createRunHookActivityMiddleware.js @@ -1,5 +1,7 @@ const RunHook = ({ fn, resolve }) => { - resolve(fn(window.WebChat.hooks)); + const numCalledRef = window.React.useRef(0); + + resolve(fn(window.WebChat.hooks, numCalledRef.current++)); return false; };