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/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/addNew.html b/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/addNew.html new file mode 100644 index 0000000000..a0d557c4da --- /dev/null +++ b/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/addNew.html @@ -0,0 +1,77 @@ + + + + + + + + + + + +
+ + + diff --git a/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/addNew.html.snap-1.png b/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/addNew.html.snap-1.png new file mode 100644 index 0000000000..c716774bee Binary files /dev/null and b/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/addNew.html.snap-1.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..39506c6300 --- /dev/null +++ b/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/decorate.html @@ -0,0 +1,80 @@ + + + + + + + + + + + +
+ + + 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..d12ac4461a 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..18229e4737 --- /dev/null +++ b/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/delete.html @@ -0,0 +1,53 @@ + + + + + + + + + + + +
+ + + 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..b947953c52 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/replace.html b/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/replace.html new file mode 100644 index 0000000000..e71ab23198 --- /dev/null +++ b/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/replace.html @@ -0,0 +1,78 @@ + + + + + + + + + + + +
+ + + diff --git a/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/replace.html.snap-1.png b/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/replace.html.snap-1.png new file mode 100644 index 0000000000..03b6aabe9b Binary files /dev/null and b/__tests__/html2/middleware/avatar/legacyAvatarMiddleware/replace.html.snap-1.png differ diff --git a/__tests__/html2/middleware/avatar/polymiddleware/addNew.html b/__tests__/html2/middleware/avatar/polymiddleware/addNew.html new file mode 100644 index 0000000000..431e4be5bb --- /dev/null +++ b/__tests__/html2/middleware/avatar/polymiddleware/addNew.html @@ -0,0 +1,80 @@ + + + + + + + + + + + +
+ + + diff --git a/__tests__/html2/middleware/avatar/polymiddleware/addNew.html.snap-1.png b/__tests__/html2/middleware/avatar/polymiddleware/addNew.html.snap-1.png new file mode 100644 index 0000000000..b371ae9009 Binary files /dev/null and b/__tests__/html2/middleware/avatar/polymiddleware/addNew.html.snap-1.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..0049b0fbc4 --- /dev/null +++ b/__tests__/html2/middleware/avatar/polymiddleware/decorate.html @@ -0,0 +1,77 @@ + + + + + + + + + + + +
+ + + 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..c3ac963ce1 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/delete.html b/__tests__/html2/middleware/avatar/polymiddleware/delete.html new file mode 100644 index 0000000000..7dcb2c7b55 --- /dev/null +++ b/__tests__/html2/middleware/avatar/polymiddleware/delete.html @@ -0,0 +1,58 @@ + + + + + + + + + + + +
+ + + 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..b947953c52 Binary files /dev/null and b/__tests__/html2/middleware/avatar/polymiddleware/delete.html.snap-1.png differ diff --git a/__tests__/html2/middleware/avatar/polymiddleware/replace.html b/__tests__/html2/middleware/avatar/polymiddleware/replace.html new file mode 100644 index 0000000000..c937ffdb99 --- /dev/null +++ b/__tests__/html2/middleware/avatar/polymiddleware/replace.html @@ -0,0 +1,77 @@ + + + + + + + + + + + +
+ + + diff --git a/__tests__/html2/middleware/avatar/polymiddleware/replace.html.snap-1.png b/__tests__/html2/middleware/avatar/polymiddleware/replace.html.snap-1.png new file mode 100644 index 0000000000..2f8f30ac89 Binary files /dev/null and b/__tests__/html2/middleware/avatar/polymiddleware/replace.html.snap-1.png differ diff --git a/packages/api-middleware/src/PolymiddlewareComposer.tsx b/packages/api-middleware/src/PolymiddlewareComposer.tsx index 4780e3fc86..db6c3e6d2e 100644 --- a/packages/api-middleware/src/PolymiddlewareComposer.tsx +++ b/packages/api-middleware/src/PolymiddlewareComposer.tsx @@ -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'; @@ -49,6 +50,21 @@ function PolymiddlewareComposer(props: PolymiddlewareComposerProps) { const activityPolymiddleware = useMemo(() => activityEnhancers.map(enhancer => () => enhancer), [activityEnhancers]); + const avatarEnhancers = useMemoWithPrevious>( + (prevAvatarEnhancers = []) => { + const avatarEnhancers = extractAvatarEnhancer(polymiddleware); + + // Checks for array equality, return previous version if nothing has changed. + return prevAvatarEnhancers.length === avatarEnhancers.length && + avatarEnhancers.every((middleware, index) => Object.is(middleware, prevAvatarEnhancers.at(index))) + ? prevAvatarEnhancers + : avatarEnhancers; + }, + [polymiddleware] + ); + + const avatarPolymiddleware = useMemo(() => avatarEnhancers.map(enhancer => () => enhancer), [avatarEnhancers]); + const errorBoxEnhancers = useMemoWithPrevious>( (prevErrorBoxEnhancers = []) => { const errorBoxEnhancers = extractErrorBoxEnhancer(polymiddleware); @@ -75,7 +91,9 @@ function PolymiddlewareComposer(props: PolymiddlewareComposerProps) { return ( - {children} + + {children} + ); } diff --git a/packages/api-middleware/src/avatarPolymiddleware.tsx b/packages/api-middleware/src/avatarPolymiddleware.tsx new file mode 100644 index 0000000000..d389a98366 --- /dev/null +++ b/packages/api-middleware/src/avatarPolymiddleware.tsx @@ -0,0 +1,92 @@ +import { validateProps } from '@msinternal/botframework-webchat-react-valibot'; +import { type WebChatActivity } from 'botframework-webchat-core'; +import React, { memo, useMemo } from 'react'; +import { any, boolean, 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< + { + readonly [__INTERNAL_DO_NOT_USE__avatarPolymiddlewareRequestStyleOptionsSymbol]: any; + readonly activity: WebChatActivity; + readonly fromUser: boolean; + }, + { 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), + fromUser: boolean() + }), + readonly() +); + +type AvatarPolymiddlewareProxyProps = Readonly>; + +// A friendlier version than the organic . +const AvatarPolymiddlewareProxy = memo(function AvatarPolymiddlewareProxy(props: AvatarPolymiddlewareProxyProps) { + const { + [__INTERNAL_DO_NOT_USE__avatarPolymiddlewareRequestStyleOptionsSymbol]: styleOptions, + activity, + fromUser + } = validateProps(avatarPolymiddlewareProxyPropsSchema, props); + + const request = useMemo( + () => + Object.freeze({ + [__INTERNAL_DO_NOT_USE__avatarPolymiddlewareRequestStyleOptionsSymbol]: styleOptions, + activity, + fromUser + }), + [activity, fromUser, 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..a38e239327 100644 --- a/packages/api/src/boot/middleware.ts +++ b/packages/api/src/boot/middleware.ts @@ -16,6 +16,20 @@ export { type ActivityPolymiddlewareRequest } from '@msinternal/botframework-webchat-api-middleware'; +export { + avatarComponent, + AvatarPolymiddlewareProxy, + createAvatarPolymiddleware, + useBuildRenderAvatarCallback, + type AvatarPolymiddleware, + type AvatarPolymiddlewareHandler, + type AvatarPolymiddlewareHandlerResult, + type AvatarPolymiddlewareProps, + type AvatarPolymiddlewareProxyProps, + type AvatarPolymiddlewareRenderer, + type AvatarPolymiddlewareRequest +} from '@msinternal/botframework-webchat-api-middleware'; + export { createErrorBoxPolymiddleware, errorBoxComponent, @@ -31,3 +45,5 @@ export { } from '@msinternal/botframework-webchat-api-middleware'; 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..815ebc42f8 100644 --- a/packages/api/src/hooks/Composer.tsx +++ b/packages/api/src/hooks/Composer.tsx @@ -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,12 +214,15 @@ 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; activityStatusMiddleware?: OneOrMany; attachmentForScreenReaderMiddleware?: OneOrMany; attachmentMiddleware?: OneOrMany; + /** + * @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. + */ avatarMiddleware?: OneOrMany; cardActionMiddleware?: OneOrMany; children?: ReactNode | ((context: ContextOf>) => ReactNode); @@ -467,14 +471,11 @@ const ComposerCore = ({ [attachmentMiddleware] ); - const patchedAvatarRenderer = useMemo( + const polymiddlewareForLegacyAvatarMiddleware = useMemo( () => - applyMiddlewareForRenderer( - 'avatar', - { strict: false }, - ...singleToArray(avatarMiddleware), - () => () => () => false - )({}), + avatarMiddleware + ? Object.freeze([createAvatarPolymiddlewareFromLegacy(...singleToArray(avatarMiddleware))]) + : EMPTY_ARRAY, [avatarMiddleware] ); @@ -530,11 +531,32 @@ const ComposerCore = ({ // 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 || []), + + // # 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, + ...polymiddlewareForLegacyAvatarMiddleware, + ...(polymiddlewareFromProps || []), activityFallbackPolymiddleware ]), - [polymiddlewareForLegacyActivityMiddleware, polymiddlewareFromProps] + [polymiddlewareForLegacyActivityMiddleware, polymiddlewareForLegacyAvatarMiddleware, polymiddlewareFromProps] ); /** @@ -555,7 +577,6 @@ const ComposerCore = ({ activityStatusRenderer: patchedActivityStatusRenderer, attachmentForScreenReaderRenderer: patchedAttachmentForScreenReaderRenderer, attachmentRenderer: patchedAttachmentRenderer, - avatarRenderer: patchedAvatarRenderer, dir: patchedDir, directLine, downscaleImageToDataURL, @@ -589,7 +610,6 @@ const ComposerCore = ({ patchedActivityStatusRenderer, patchedAttachmentForScreenReaderRenderer, patchedAttachmentRenderer, - patchedAvatarRenderer, patchedDir, patchedGrammars, patchedLocalizedStrings, 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/useCreateAvatarRenderer.ts b/packages/api/src/hooks/useCreateAvatarRenderer.ts index 086e985e3a..61a47f69f5 100644 --- a/packages/api/src/hooks/useCreateAvatarRenderer.ts +++ b/packages/api/src/hooks/useCreateAvatarRenderer.ts @@ -1,40 +1,37 @@ -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 { + __INTERNAL_DO_NOT_USE__avatarPolymiddlewareRequestStyleOptionsSymbol, + useBuildRenderAvatarCallback +} from '@msinternal/botframework-webchat-api-middleware'; import type { WebChatActivity } from 'botframework-webchat-core'; +import { useMemo, type ReactNode } from 'react'; +import useStyleOptions from './useStyleOptions'; +/** + * @deprecated Use `` or `useBuildRenderAvatarCallback` instead. This hook will be removed on or after 2028-03-16. + */ export default function useCreateAvatarRenderer(): ({ activity }: { activity: WebChatActivity; }) => false | (() => Exclude) { const [styleOptions] = useStyleOptions(); - const { avatarRenderer }: { avatarRenderer: AvatarComponentFactory } = useWebChatAPIContext(); + const buildRenderAvatar = useBuildRenderAvatarCallback(); return useMemo( () => ({ activity }) => { const { from: { role } = {} }: { from?: { role?: string } } = activity; - const result = avatarRenderer({ - activity, - fromUser: role === 'user', - styleOptions - }); - - 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.' - ); - - return () => result; - } + const renderer = buildRenderAvatar( + Object.freeze({ + activity, + fromUser: role === 'user', + [__INTERNAL_DO_NOT_USE__avatarPolymiddlewareRequestStyleOptionsSymbol]: styleOptions + }) + ); - return result; + return renderer ? (): ReactNode => renderer({}) : false; }, - [avatarRenderer, styleOptions] + [buildRenderAvatar, styleOptions] ); } 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..a547decaff --- /dev/null +++ b/packages/api/src/legacy/createAvatarPolymiddlewareFromLegacy.tsx @@ -0,0 +1,103 @@ +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 not modify the request object'); + } + + // 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, + fromUser + } = request; + + const legacyResult = legacyHandler( + Object.freeze({ + activity, + fromUser, + 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/types/AvatarMiddleware.ts b/packages/api/src/types/AvatarMiddleware.ts index 351b11f9fa..50b3c3dd40 100644 --- a/packages/api/src/types/AvatarMiddleware.ts +++ b/packages/api/src/types/AvatarMiddleware.ts @@ -1,10 +1,15 @@ 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 = [ { + [__INTERNAL_DO_NOT_USE__legacyAvatarMiddlewareOriginalRequestSymbol]: AvatarPolymiddlewareRequest; activity: WebChatActivity; fromUser: boolean; styleOptions: StrictStyleOptions; 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/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/Composer.tsx b/packages/component/src/Composer.tsx index 5b651bf1ec..485ed99c1f 100644 --- a/packages/component/src/Composer.tsx +++ b/packages/component/src/Composer.tsx @@ -11,7 +11,7 @@ import { type SendBoxToolbarMiddleware } from 'botframework-webchat-api'; import { DecoratorComposer, type DecoratorMiddleware } from 'botframework-webchat-api/decorator'; -import { type Polymiddleware } from 'botframework-webchat-api/middleware'; +import { createActivityPolymiddlewareFromLegacy, type Polymiddleware } from 'botframework-webchat-api/middleware'; import { singleToArray } from 'botframework-webchat-core'; import classNames from 'classnames'; import MarkdownIt from 'markdown-it'; @@ -368,7 +368,7 @@ const Composer = ({ const theme = useTheme(); const patchedActivityMiddleware = useMemo( - () => [...singleToArray(activityMiddleware), ...theme.activityMiddleware, ...createDefaultActivityMiddleware()], + () => [...singleToArray(activityMiddleware), ...theme.activityMiddleware], [activityMiddleware, theme.activityMiddleware] ); @@ -400,7 +400,7 @@ const Composer = ({ ); const patchedAvatarMiddleware = useMemo( - () => [...singleToArray(avatarMiddleware), ...theme.avatarMiddleware, ...createDefaultAvatarMiddleware()], + () => [...singleToArray(avatarMiddleware), ...theme.avatarMiddleware], [avatarMiddleware, theme.avatarMiddleware] ); @@ -414,7 +414,15 @@ const Composer = ({ ); const patchedPolymiddleware = useMemo( - () => Object.freeze([...(polymiddleware || []), ...theme.polymiddleware]), + () => + Object.freeze([ + ...(polymiddleware || []), + ...theme.polymiddleware, + // Polymiddleware has lower priority than legacy middleware. + // Later, we should move default middleware to a "default theme." + createActivityPolymiddlewareFromLegacy(...createDefaultActivityMiddleware()), + ...createDefaultAvatarMiddleware() + ]), [polymiddleware, theme.polymiddleware] ); diff --git a/packages/component/src/Middleware/Avatar/DefaultAvatar.tsx b/packages/component/src/Middleware/Avatar/DefaultAvatar.tsx new file mode 100644 index 0000000000..9a84697b0b --- /dev/null +++ b/packages/component/src/Middleware/Avatar/DefaultAvatar.tsx @@ -0,0 +1,61 @@ +import { validateProps } from '@msinternal/botframework-webchat-react-valibot'; +import classNames from 'classnames'; +import React, { memo } from 'react'; +import { boolean, never, object, optional, pipe, readonly, string, type InferInput } from 'valibot'; + +import ImageAvatar from '../../Avatar/ImageAvatar'; +import InitialsAvatar from '../../Avatar/InitialsAvatar'; +import { useStyleToEmotionObject } from '../../hooks/internal/styleToEmotionObject'; +import useStyleSet from '../../hooks/useStyleSet'; + +const ROOT_STYLE = { + overflow: ['hidden', 'clip'], + position: 'relative', + + '> *': { + left: 0, + position: 'absolute', + top: 0 + } +}; + +const defaultAvatarPropsSchema = pipe( + object({ + 'aria-hidden': optional(boolean(), true), + children: optional(never()), + className: optional(string()), + fromUser: boolean() + }), + readonly() +); + +type DefaultAvatarProps = InferInput; + +function DefaultAvatar(props: DefaultAvatarProps) { + const { 'aria-hidden': ariaHidden, className, fromUser } = validateProps(defaultAvatarPropsSchema, props, 'strict'); + + const [{ avatar: avatarStyleSet }] = useStyleSet(); + const rootClassName = useStyleToEmotionObject()(ROOT_STYLE) + ''; + + return ( +
+ + +
+ ); +} + +DefaultAvatar.displayName = 'DefaultAvatar'; + +export default memo(DefaultAvatar); + +export { defaultAvatarPropsSchema, type DefaultAvatarProps }; diff --git a/packages/component/src/Middleware/Avatar/createCoreMiddleware.tsx b/packages/component/src/Middleware/Avatar/createCoreMiddleware.tsx index 1b153d9933..dcd580fade 100644 --- a/packages/component/src/Middleware/Avatar/createCoreMiddleware.tsx +++ b/packages/component/src/Middleware/Avatar/createCoreMiddleware.tsx @@ -1,75 +1,27 @@ -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 ImageAvatar from '../../Avatar/ImageAvatar'; -import InitialsAvatar from '../../Avatar/InitialsAvatar'; -import { useStyleToEmotionObject } from '../../hooks/internal/styleToEmotionObject'; -import useStyleSet from '../../hooks/useStyleSet'; - -const ROOT_STYLE = { - overflow: ['hidden', 'clip'], - position: 'relative', - - '> *': { - left: 0, - position: 'absolute', - top: 0 - } -}; - -const defaultAvatarPropsSchema = pipe( - object({ - 'aria-hidden': optional(boolean()), - className: optional(string()), - fromUser: boolean() - }), - readonly() -); - -type DefaultAvatarProps = InferInput; - -function DefaultAvatar(props: DefaultAvatarProps) { - const { 'aria-hidden': ariaHidden = true, className, fromUser } = validateProps(defaultAvatarPropsSchema, props); - - const [{ avatar: avatarStyleSet }] = useStyleSet(); - const rootClassName = useStyleToEmotionObject()(ROOT_STYLE) + ''; - - return ( -
- - -
- ); +import { __INTERNAL_DO_NOT_USE__avatarPolymiddlewareRequestStyleOptionsSymbol } from 'botframework-webchat-api/internal'; +import { + avatarComponent, + createAvatarPolymiddleware, + type AvatarPolymiddleware +} from 'botframework-webchat-api/middleware'; +import DefaultAvatar from './DefaultAvatar'; + +export default function createDefaultAvatarMiddleware(): readonly AvatarPolymiddleware[] { + return Object.freeze([ + createAvatarPolymiddleware( + _next => + ({ + fromUser, + [__INTERNAL_DO_NOT_USE__avatarPolymiddlewareRequestStyleOptionsSymbol]: { + botAvatarImage, + botAvatarInitials, + userAvatarImage, + userAvatarInitials + } + }) => + (fromUser ? userAvatarImage || userAvatarInitials : botAvatarImage || botAvatarInitials) + ? avatarComponent(DefaultAvatar, Object.freeze({ fromUser })) + : undefined + ) + ]); } - -export default function createCoreAvatarMiddleware(): AvatarMiddleware[] { - return [ - () => - () => - ({ fromUser, styleOptions }) => { - const { botAvatarImage, botAvatarInitials, userAvatarImage, userAvatarInitials } = styleOptions; - - if (fromUser ? userAvatarImage || userAvatarInitials : botAvatarImage || botAvatarInitials) { - return () => ; - } - - return false; - } - ]; -} - -const MemoizedDefaultAvatar = memo(DefaultAvatar); - -export { MemoizedDefaultAvatar as DefaultAvatar, defaultAvatarPropsSchema, type DefaultAvatarProps }; diff --git a/packages/component/src/Transcript/hooks/useRenderActivityProps.ts b/packages/component/src/Transcript/hooks/useRenderActivityProps.ts index 2d4041f03a..62070d0ee3 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,20 @@ const useRenderActivityProps = (activity: WebChatActivity): RenderActivityProps const isLastInStatusGroup = lastActivityInStatusGroup === activity || typeof lastActivityInStatusGroup === 'undefined'; - const renderAvatarForSenderGroup = useMemo( - () => !!renderAvatar && renderAvatar({ activity }), - [activity, renderAvatar] - ); + const renderAvatarForSenderGroup = useMemo Exclude)>(() => { + const fromUser = activity.from?.role === 'user'; + // 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({ + [__INTERNAL_DO_NOT_USE__avatarPolymiddlewareRequestStyleOptionsSymbol]: styleOptions, + activity, + fromUser + }) + ); + + return renderer ? (): ReactNode => renderer({}) : false; + }, [activity, buildRenderAvatar, styleOptions]); const isTopSideBotNub = isZeroOrPositive(bubbleNubOffset); const isTopSideUserNub = isZeroOrPositive(bubbleFromUserNubOffset); 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/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); });