diff --git a/CHANGELOG.md b/CHANGELOG.md index fe6e929f59..dd69393c5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,11 +22,17 @@ Legends: ### Added - Added `styleOptions.richCardTitleOmitHeadingRole` (default `false`) to opt out of `style: 'heading'` on rich card titles, in PR [#5839](https://github.com/microsoft/BotFramework-WebChat/pull/5839), by [@cjennison](https://github.com/cjennison) -- Added support of Adaptive Cards `Action.Submit` action with `msteams/signin` sub-action to open sign-in link in a popup window, in PR [#5860](https://github.com/microsoft/BotFramework-WebChat/pull/5860), by [@compulim](https://github.com/compulim) - - Added `styleOptions.adaptiveCardSignInActionPopupWindowHeight/Width` for sizing the sign-in popup window - - Link to [Adaptive Cards spec](https://adaptivecards.microsoft.com/?topic=SigninSubmitActionData) - - Refer to [this test](./__tests__/html2/adaptiveCard/signInAction.html) for the reference payload - - Note: this implementation is based on observation of how Microsoft Teams behave and could deviate from their official implementation +- ~~Added support of Adaptive Cards `Action.Submit` action with `msteams/signin` sub-action to open sign-in link in a popup window, in PR [#5860](https://github.com/microsoft/BotFramework-WebChat/pull/5860), by [@compulim](https://github.com/compulim)~~ + - ~~Added `styleOptions.adaptiveCardSignInActionPopupWindowHeight/Width` for sizing the sign-in popup window~~ + - ~~Link to [Adaptive Cards spec](https://adaptivecards.microsoft.com/?topic=SigninSubmitActionData)~~ + - ~~Refer to [this test](./__tests__/html2/adaptiveCard/signInAction.html) for the reference payload~~ + - ~~Note: this implementation is based on observation of how Microsoft Teams behave and could deviate from their official implementation~~ + - Obsoleted in favor of PR [#5862](https://github.com/microsoft/BotFramework-WebChat/pull/5862) +- Added card action `webchat:callURL` and [Adaptive Card action `Action.OpenUrlDialog`](https://adaptivecards.microsoft.com/?topic=Action.OpenUrlDialog), in PR [#5862](https://github.com/microsoft/BotFramework-WebChat/pull/5862), by [@compulim](https://github.com/compulim) + - Added `styleOptions.callURLActionPopupWindowHeight/Width` for sizing the popup window + - Reference payload for Direct Line `webchat:callURL` card action can be found in [this test](./__tests__/html2/adaptiveCard/openUrlDialog/heroCard.html) + - Reference payload for Adaptive Card `Action.OpenUrlDialog` can be found in [this test](./__tests__/html2/adaptiveCard/openUrlDialog/simple.html) + - Note: the Adaptive Card implementation is based on observation of how other apps behave and could deviate from their official implementation ### Fixed diff --git a/__tests__/html2/adaptiveCard/msteams/signInSubAction.html.snap-1.png b/__tests__/html2/adaptiveCard/msteams/signInSubAction.html.snap-1.png deleted file mode 100644 index 7862e7e63a..0000000000 Binary files a/__tests__/html2/adaptiveCard/msteams/signInSubAction.html.snap-1.png and /dev/null differ diff --git a/__tests__/html2/adaptiveCard/msteams/signInSubAction.skip.html b/__tests__/html2/adaptiveCard/openUrlDialog/dialog.skip.html similarity index 100% rename from __tests__/html2/adaptiveCard/msteams/signInSubAction.skip.html rename to __tests__/html2/adaptiveCard/openUrlDialog/dialog.skip.html diff --git a/__tests__/html2/adaptiveCard/msteams/signInSubAction.disallowedScheme.html b/__tests__/html2/adaptiveCard/openUrlDialog/disallowedScheme.html similarity index 85% rename from __tests__/html2/adaptiveCard/msteams/signInSubAction.disallowedScheme.html rename to __tests__/html2/adaptiveCard/openUrlDialog/disallowedScheme.html index f685f79214..c159e60278 100644 --- a/__tests__/html2/adaptiveCard/msteams/signInSubAction.disallowedScheme.html +++ b/__tests__/html2/adaptiveCard/openUrlDialog/disallowedScheme.html @@ -52,15 +52,16 @@ { actions: [ { - title: 'Sign in', - type: 'Action.Submit', - data: { - msteams: { - type: 'signin', - value: 'mailto:johndoe@microsoft.com' - } + title: 'Sign in (Action.OpenUrlDialog)', + type: 'Action.OpenUrlDialog', + url: 'mailto:johndoe@microsoft.com', + + fallback: { + title: 'Sign in (Action.OpenUrl)', + type: 'Action.OpenUrl', + url: 'mailto:johndoe@microsoft.com' } - } + }, ], type: 'ActionSet' } @@ -101,14 +102,15 @@ }; // WHEN: The "Sign in" button is clicked. - await host.click(document.querySelector('[aria-label="Sign in"]')); + await host.click(document.querySelector('[aria-label="Sign in (Action.OpenUrlDialog)"]')); // THEN: Should not call `window.open`. expect(windowOpenCalled).toBe(false); // THEN: Should warn. expect(consoleWarnArgs).toEqual([ - 'botframework-webchat: "Action.Submit/msteams" sub-action validation error.', + 'botframework-webchat: Cannot call invalid URL.', + 'mailto:johndoe@microsoft.com', '"value" must have protocol of either "http:" or "https:"' ]); }); diff --git a/__tests__/html2/adaptiveCard/openUrlDialog/heroCard.html b/__tests__/html2/adaptiveCard/openUrlDialog/heroCard.html new file mode 100644 index 0000000000..7534010167 --- /dev/null +++ b/__tests__/html2/adaptiveCard/openUrlDialog/heroCard.html @@ -0,0 +1,110 @@ + + + + + + +
+ + + + diff --git a/__tests__/html2/adaptiveCard/msteams/signInSubAction.relativeURL.html b/__tests__/html2/adaptiveCard/openUrlDialog/relativeURL.html similarity index 87% rename from __tests__/html2/adaptiveCard/msteams/signInSubAction.relativeURL.html rename to __tests__/html2/adaptiveCard/openUrlDialog/relativeURL.html index 3884b40105..c90d8e07e2 100644 --- a/__tests__/html2/adaptiveCard/msteams/signInSubAction.relativeURL.html +++ b/__tests__/html2/adaptiveCard/openUrlDialog/relativeURL.html @@ -52,13 +52,14 @@ { actions: [ { - title: 'Sign in', - type: 'Action.Submit', - data: { - msteams: { - type: 'signin', - value: '/index.html' - } + title: 'Sign in (Action.OpenUrlDialog)', + type: 'Action.OpenUrlDialog', + url: '/index.html', + + fallback: { + title: 'Sign in (Action.OpenUrl)', + type: 'Action.OpenUrl', + url: '/index.html' } } ], @@ -101,14 +102,15 @@ }; // WHEN: The "Sign in" button is clicked. - await host.click(document.querySelector('[aria-label="Sign in"]')); + await host.click(document.querySelector('[aria-label="Sign in (Action.OpenUrlDialog)"]')); // THEN: Should not call `window.open`. expect(windowOpenCalled).toBe(false); // THEN: Should warn. expect(consoleWarnArgs).toEqual([ - 'botframework-webchat: "Action.Submit/msteams" sub-action validation error.', + 'botframework-webchat: Cannot call invalid URL.', + '/index.html', '"value" must be an absolute URL', '"value" must have protocol of either "http:" or "https:"' ]); diff --git a/__tests__/html2/adaptiveCard/msteams/signInSubAction.html b/__tests__/html2/adaptiveCard/openUrlDialog/simple.html similarity index 85% rename from __tests__/html2/adaptiveCard/msteams/signInSubAction.html rename to __tests__/html2/adaptiveCard/openUrlDialog/simple.html index 2d64094d2a..82151781b8 100644 --- a/__tests__/html2/adaptiveCard/msteams/signInSubAction.html +++ b/__tests__/html2/adaptiveCard/openUrlDialog/simple.html @@ -52,15 +52,16 @@ { actions: [ { - title: 'Sign in', - type: 'Action.Submit', - data: { - msteams: { - type: 'signin', - value: new URL('signInSubAction.skip.html', location).toString() - } + title: 'Sign in (Action.OpenUrlDialog)', + type: 'Action.OpenUrlDialog', + url: new URL('dialog.skip.html', location).toString(), + + fallback: { + title: 'Sign in (Action.OpenUrl)', + type: 'Action.OpenUrl', + url: new URL('dialog.skip.html', location).toString() } - } + }, ], type: 'ActionSet' } @@ -93,7 +94,7 @@ // Note: popup.addEventListener('close') does not dispatch. const interval = setInterval(() => { - if (popup.closed) { + if (popup?.closed) { clearInterval(interval); popupClosedDeferred.resolve(); @@ -104,7 +105,7 @@ }; // WHEN: The "Sign in" button is clicked. - await host.click(document.querySelector('[aria-label="Sign in"]')); + await host.click(document.querySelector('[aria-label="Sign in (Action.OpenUrlDialog)"]')); // THEN: Popup window should close itself. await Promise.race([ @@ -116,7 +117,7 @@ ]); expect(windowOpenArgs).toEqual([ - expect.stringContaining('signInSubAction.skip.html'), + expect.stringContaining('dialog.skip.html'), '_blank', 'height=640,popup,width=480' ]); diff --git a/__tests__/html2/adaptiveCard/openUrlDialog/simple.html.snap-1.png b/__tests__/html2/adaptiveCard/openUrlDialog/simple.html.snap-1.png new file mode 100644 index 0000000000..c3f8d61b47 Binary files /dev/null and b/__tests__/html2/adaptiveCard/openUrlDialog/simple.html.snap-1.png differ diff --git a/__tests__/html2/adaptiveCard/msteams/signInSubAction.size.html b/__tests__/html2/adaptiveCard/openUrlDialog/size.html similarity index 84% rename from __tests__/html2/adaptiveCard/msteams/signInSubAction.size.html rename to __tests__/html2/adaptiveCard/openUrlDialog/size.html index 6941a90178..ace527e222 100644 --- a/__tests__/html2/adaptiveCard/msteams/signInSubAction.size.html +++ b/__tests__/html2/adaptiveCard/openUrlDialog/size.html @@ -32,8 +32,8 @@ const { directLine, store } = createDirectLineEmulator(); const styleOptions = { - adaptiveCardSignInActionPopupWindowHeight: 480, - adaptiveCardSignInActionPopupWindowWidth: 360 + callURLActionPopupWindowHeight: 480, + callURLActionPopupWindowWidth: 360 }; renderWebChat({ directLine, store, styleOptions }, document.getElementById('webchat')); @@ -56,13 +56,14 @@ { actions: [ { - title: 'Sign in', - type: 'Action.Submit', - data: { - msteams: { - type: 'signin', - value: new URL('signInSubAction.skip.html', location) - } + title: 'Sign in (Action.OpenUrlDialog)', + type: 'Action.OpenUrlDialog', + url: new URL('dialog.skip.html', location).toString(), + + fallback: { + title: 'Sign in (Action.OpenUrl)', + type: 'Action.OpenUrl', + url: new URL('dialog.skip.html', location).toString() } } ], @@ -96,7 +97,7 @@ // Note: popup.addEventListener('close') does not dispatch. const interval = setInterval(() => { - if (popup.closed) { + if (popup?.closed) { clearInterval(interval); popupClosedDeferred.resolve(); @@ -107,7 +108,7 @@ }; // WHEN: The "Sign in" button is clicked. - await host.click(document.querySelector('[aria-label="Sign in"]')); + await host.click(document.querySelector('[aria-label="Sign in (Action.OpenUrlDialog)"]')); // THEN: Popup window should close itself. await Promise.race([ @@ -119,7 +120,7 @@ ]); expect(windowOpenArgs).toEqual([ - expect.stringContaining('signInSubAction.skip.html'), + expect.stringContaining('dialog.skip.html'), '_blank', 'height=480,popup,width=360' ]); diff --git a/__tests__/html2/adaptiveCard/openUrlDialog/suggestedActions.html b/__tests__/html2/adaptiveCard/openUrlDialog/suggestedActions.html new file mode 100644 index 0000000000..892b5cd01e --- /dev/null +++ b/__tests__/html2/adaptiveCard/openUrlDialog/suggestedActions.html @@ -0,0 +1,111 @@ + + + + + + +
+ + + + diff --git a/__tests__/html2/adaptiveCard/msteams/unknownSubAction.html b/__tests__/html2/cardAction/heroCard.webchatCallURL.html similarity index 51% rename from __tests__/html2/adaptiveCard/msteams/unknownSubAction.html rename to __tests__/html2/cardAction/heroCard.webchatCallURL.html index 1ab4e2dcce..af67ea79ca 100644 --- a/__tests__/html2/adaptiveCard/msteams/unknownSubAction.html +++ b/__tests__/html2/cardAction/heroCard.webchatCallURL.html @@ -36,35 +36,20 @@ await pageConditions.uiConnected(); + // GIVEN: A Hero card with a "webchat:callURL" button rendered as a direct card-action payload. await directLine.emulateIncomingActivity({ attachments: [ { - contentType: 'application/vnd.microsoft.card.adaptive', + contentType: 'application/vnd.microsoft.card.hero', content: { - $schema: 'http://adaptivecards.io/schemas/adaptive-card.json', - type: 'AdaptiveCard', - version: '1.2', - body: [ + buttons: [ { - text: 'Hello, World!', - type: 'TextBlock' - }, - { - actions: [ - { - title: 'Sign in', - type: 'Action.Submit', - data: { - msteams: { - type: 'imBack', - value: 'Hello, World!' - } - } - } - ], - type: 'ActionSet' + title: 'Call URL', + type: 'webchat:callURL', + value: 'https://bing.com/' } - ] + ], + title: 'Hero card' } } ], @@ -77,29 +62,23 @@ type: 'message' }); - // THEN: Should render the Adaptive Card with a "Sign in" button. await pageConditions.numActivitiesShown(1); - // GIVEN: Intercept `console.warn`. - const originalConsoleWarn = console.warn.bind(console); - let consoleWarnArgs; + // GIVEN: Intercept `window.open`. + const originalOpen = window.open.bind(window); + let windowOpenArgs; - console.warn = (...args) => { - consoleWarnArgs = args; + window.open = (...args) => { + windowOpenArgs = args; - return originalConsoleWarn(...args); + return originalOpen(...args); }; - // WHEN: The "Sign in" button is clicked. - await host.click(document.querySelector('[aria-label="Sign in"]')); + // WHEN: The "Call URL" button is clicked. + await host.click(document.querySelector('[aria-label="Call URL"]')); - // THEN: Should warn. - expect(consoleWarnArgs).toEqual([ - 'botframework-webchat: "Action.Submit/msteams" sub-action validation error.', - "Sub-action type must be \"signin\"", - '"value" must be an absolute URL', - '"value" must have protocol of either "http:" or "https:"' - ]); + // THEN: Should call `window.open` using the popup window features, instead of a plain "openUrl" dispatch. + expect(windowOpenArgs).toEqual(['https://bing.com/', '_blank', 'height=640,popup,width=480']); }); diff --git a/__tests__/html2/cardAction/webchatCallURL.heroCard.button.html b/__tests__/html2/cardAction/webchatCallURL.heroCard.button.html new file mode 100644 index 0000000000..71332fbfdd --- /dev/null +++ b/__tests__/html2/cardAction/webchatCallURL.heroCard.button.html @@ -0,0 +1,88 @@ + + + + + + +
+ + + + diff --git a/packages/api/src/StyleOptions.ts b/packages/api/src/StyleOptions.ts index 029ad8db68..e44a4f3a0d 100644 --- a/packages/api/src/StyleOptions.ts +++ b/packages/api/src/StyleOptions.ts @@ -921,6 +921,20 @@ type StrictStyleOptions = { * @default 'auto' */ showMicrophoneButton: 'auto' | 'hide' | undefined; + + /** + * `webchat:callURL` action popup window height (in pixels) + * + * @default 640 + */ + callURLActionPopupWindowHeight: number | undefined; + + /** + * `webchat:callURL` action popup window width (in pixels) + * + * @default 480 + */ + callURLActionPopupWindowWidth: number | undefined; }; // StrictStyleOptions is only used internally in Web Chat and for simplifying our code: diff --git a/packages/api/src/defaultStyleOptions.ts b/packages/api/src/defaultStyleOptions.ts index d0239b5ed5..e17c03fcb8 100644 --- a/packages/api/src/defaultStyleOptions.ts +++ b/packages/api/src/defaultStyleOptions.ts @@ -307,7 +307,10 @@ const DEFAULT_OPTIONS: StrictStyleOptions = { sendBoxAttachmentBarMaxThumbnail: 3, // Speech-to-speech options - showMicrophoneButton: 'auto' + showMicrophoneButton: 'auto', + + callURLActionPopupWindowHeight: 640, + callURLActionPopupWindowWidth: 480 }; export default DEFAULT_OPTIONS; diff --git a/packages/api/src/hooks/Composer.tsx b/packages/api/src/hooks/Composer.tsx index 4b46fd4fd0..b928abc65d 100644 --- a/packages/api/src/hooks/Composer.tsx +++ b/packages/api/src/hooks/Composer.tsx @@ -45,7 +45,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState, type ReactNod import { Provider } from 'react-redux'; import updateIn from 'simple-update-in'; -import { type StyleOptions } from '../StyleOptions'; +import { type StrictStyleOptions, type StyleOptions } from '../StyleOptions'; import errorBoxTelemetryPolymiddleware from '../errorBox/errorBoxTelemetryPolymiddleware'; import PrecompiledGlobalize from '../external/PrecompiledGlobalize'; import usePonyfill from '../hooks/usePonyfill'; @@ -133,7 +133,8 @@ function createCardActionContext({ directLine, dispatch, markAllAsAcknowledged, - ponyfill + ponyfill, + styleOptions }: { cardActionMiddleware: readonly CardActionMiddleware[]; continuous: boolean; @@ -141,12 +142,13 @@ function createCardActionContext({ dispatch: (...args: unknown[]) => unknown; markAllAsAcknowledged: () => void; ponyfill: GlobalScopePonyfill; + styleOptions: StrictStyleOptions; }) { const runMiddleware = applyMiddleware( 'card action', ...cardActionMiddleware, createDefaultCardActionMiddleware() - )({ dispatch }); + )({ dispatch, styleOptions }); return { onCardAction: (cardAction, { target }: { target?: any } = {}) => { @@ -348,16 +350,10 @@ const ComposerCore = ({ directLine, dispatch, markAllAsAcknowledged, - ponyfill + ponyfill, + styleOptions }), - [ - cardActionMiddleware, - directLine, - dispatch, - markAllAsAcknowledged, - ponyfill, - styleOptions.speechRecognitionContinuous - ] + [cardActionMiddleware, directLine, dispatch, markAllAsAcknowledged, ponyfill, styleOptions] ); const patchedSelectVoice = useMemo( diff --git a/packages/api/src/types/CardActionMiddleware.ts b/packages/api/src/types/CardActionMiddleware.ts index a3d3610395..c740651752 100644 --- a/packages/api/src/types/CardActionMiddleware.ts +++ b/packages/api/src/types/CardActionMiddleware.ts @@ -1,11 +1,17 @@ import type { DirectLineCardAction } from 'botframework-webchat-core'; import FunctionMiddleware from './FunctionMiddleware'; +import type { StrictStyleOptions } from '../StyleOptions.js'; type PerformCardAction = (cardAction: DirectLineCardAction, event?: { target: EventTarget }) => void; type CardActionMiddleware = FunctionMiddleware< - [{ dispatch: (action: any) => void }], + [ + { + dispatch: (action: any) => void; + styleOptions: StrictStyleOptions; + } + ], [ { cardAction: DirectLineCardAction; diff --git a/packages/bundle/src/adaptiveCards/AdaptiveCardsStyleOptions.ts b/packages/bundle/src/adaptiveCards/AdaptiveCardsStyleOptions.ts index 49f8e0ffe8..6e83467a61 100644 --- a/packages/bundle/src/adaptiveCards/AdaptiveCardsStyleOptions.ts +++ b/packages/bundle/src/adaptiveCards/AdaptiveCardsStyleOptions.ts @@ -33,20 +33,6 @@ type StrictAdaptiveCardsStyleOptions = { * style; see issue #4327). */ richCardTitleOmitHeadingRole: boolean | undefined; - - /** - * Adaptive Cards: sign-in action popup window height (in pixel) - * - * @default 640 - */ - adaptiveCardSignInActionPopupWindowHeight: number | undefined; - - /** - * Adaptive Cards: sign-in action popup window width (in pixel) - * - * @default 480 - */ - adaptiveCardSignInActionPopupWindowWidth: number | undefined; }; type AdaptiveCardsStyleOptions = Partial; diff --git a/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardBuilder.ts b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardBuilder.ts index b48cadffed..50fb392e38 100644 --- a/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardBuilder.ts +++ b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardBuilder.ts @@ -26,6 +26,17 @@ export interface BotFrameworkCardAction { cardAction: DirectLineCardAction; } +// Adaptive Cards own `OpenUrlAction.JsonTypeName` was not made extensible. +// @ts-expect-error +class OpenUrlDialogAction extends OpenUrlAction { + static readonly JsonTypeName: string = 'Action.OpenUrlDialog'; + + // eslint-disable-next-line class-methods-use-this + getJsonTypeName(): string { + return OpenUrlDialogAction.JsonTypeName; + } +} + function addCardAction(cardAction: DirectLineCardAction, includesOAuthButtons?: boolean) { const { type } = cardAction; let action; @@ -44,6 +55,11 @@ function addCardAction(cardAction: DirectLineCardAction, includesOAuthButtons?: }; action.title = (cardAction as { title: string }).title; + } else if (type === 'webchat:callURL') { + action = new OpenUrlDialogAction(); + + action.title = (cardAction as { title: string }).title; + action.url = cardAction.value; } else { action = new OpenUrlAction(); diff --git a/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardRenderer.tsx b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardRenderer.tsx index bf8b3bd0e8..4ea4181c34 100644 --- a/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardRenderer.tsx +++ b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardRenderer.tsx @@ -17,21 +17,7 @@ import React, { type MouseEventHandler } from 'react'; import { useRefFrom } from 'use-ref-from'; -import { - any, - boolean, - check, - literal, - object, - optional, - pipe, - readonly, - safeParse, - string, - transform, - url, - type InferInput -} from 'valibot'; +import { any, boolean, object, optional, pipe, readonly, string, type InferInput } from 'valibot'; import useAdaptiveCardsHostConfig from '../hooks/useAdaptiveCardsHostConfig'; import useAdaptiveCardsPackage from '../hooks/useAdaptiveCardsPackage'; @@ -46,30 +32,6 @@ import { directLineCardActionSchema } from './private/directLineSchema'; import renderAdaptiveCard from './private/renderAdaptiveCard'; import styles from './AdaptiveCardRenderer.module.css'; -import useStyleOptions from '../../hooks/useStyleOptions'; -import normalizeStyleOptions from '../normalizeStyleOptions'; - -const microsoftTeamsSubActionSchema = object({ - msteams: object({}) -}); - -const microsoftTeamsSignInSubActionSchema = object({ - msteams: object({ - type: literal('signin', 'Sub-action type must be "signin"'), - value: pipe( - string('"value" must be a string'), - url('"value" must be an absolute URL'), - check(value => { - try { - return ['http:', 'https:'].includes(new URL(value).protocol); - } catch { - return false; - } - }, '"value" must have protocol of either "http:" or "https:"'), - transform(value => value as any) - ) - }) -}); const { useLocalizer, usePerformCardAction, useRenderMarkdownAsHTML, useScrollToEnd, useUIState } = hooks; @@ -93,9 +55,6 @@ function AdaptiveCardRenderer(props: AdaptiveCardRendererProps) { tapAction } = validateProps(adaptiveCardRendererPropsSchema, props); - const { adaptiveCardSignInActionPopupWindowHeight, adaptiveCardSignInActionPopupWindowWidth } = normalizeStyleOptions( - useStyleOptions()[0] - ); const [{ GlobalSettings, HostConfig }] = useAdaptiveCardsPackage(); const [adaptiveCardsHostConfig] = useAdaptiveCardsHostConfig(); const [uiState] = useUIState(); @@ -180,6 +139,15 @@ function AdaptiveCardRenderer(props: AdaptiveCardRendererProps) { type: 'openUrl', value }); + } else if (actionTypeName === 'Action.OpenUrlDialog') { + const { url: value } = action as OpenUrlAction; + + performCardAction({ + image, + title, + type: 'webchat:callURL', + value + }); } else if (actionTypeName === 'Action.Submit') { const { data } = action as SubmitAction as { data: string | BotFrameworkCardAction; @@ -196,39 +164,12 @@ function AdaptiveCardRenderer(props: AdaptiveCardRendererProps) { } else if (data.__isBotFrameworkCardAction) { performCardAction(data.cardAction); } else { - const parseMSTeamsSubActionResult = safeParse(microsoftTeamsSubActionSchema, data); - - if (parseMSTeamsSubActionResult.success) { - const parseMSTeamsSignInSubActionResult = safeParse(microsoftTeamsSignInSubActionSchema, data); - - if (parseMSTeamsSignInSubActionResult.success) { - const { value } = parseMSTeamsSignInSubActionResult.output.msteams; - - window.open( - value, - '_blank', - [ - ['height', adaptiveCardSignInActionPopupWindowHeight], - ['popup', ''], - ['width', adaptiveCardSignInActionPopupWindowWidth] - ] - .map(([key, value]) => (value ? [key, encodeURIComponent(value)].join('=') : key)) - .join(',') - ); - } else { - console.warn( - 'botframework-webchat: "Action.Submit/msteams" sub-action validation error.', - ...parseMSTeamsSignInSubActionResult.issues.map(({ message }) => message) - ); - } - } else { - performCardAction({ - image, - title, - type: 'postBack', - value: data - }); - } + performCardAction({ + image, + title, + type: 'postBack', + value: data + }); } } @@ -238,13 +179,7 @@ function AdaptiveCardRenderer(props: AdaptiveCardRendererProps) { console.error(action); } }, - [ - adaptiveCardSignInActionPopupWindowHeight, - adaptiveCardSignInActionPopupWindowWidth, - disabledRef, - performCardAction, - scrollToEnd - ] + [disabledRef, performCardAction, scrollToEnd] ); // For accessibility issue #1340, `tabindex="0"` must not be set for the root container if it is not interactive. diff --git a/packages/bundle/src/adaptiveCards/defaultStyleOptions.ts b/packages/bundle/src/adaptiveCards/defaultStyleOptions.ts index f5002a8a44..d3ff62377b 100644 --- a/packages/bundle/src/adaptiveCards/defaultStyleOptions.ts +++ b/packages/bundle/src/adaptiveCards/defaultStyleOptions.ts @@ -1,8 +1,6 @@ import { type AdaptiveCardsStyleOptions } from './AdaptiveCardsStyleOptions'; const ADAPTIVE_CARDS_DEFAULT_STYLE_OPTIONS: Required = { - adaptiveCardSignInActionPopupWindowHeight: 640, - adaptiveCardSignInActionPopupWindowWidth: 480, adaptiveCardsParserMaxVersion: undefined, cardEmphasisBackgroundColor: '#F9F9F9', cardPushButtonBackgroundColor: '#0063B1', diff --git a/packages/bundle/src/adaptiveCards/hooks/internal/useParseAdaptiveCardJSON.ts b/packages/bundle/src/adaptiveCards/hooks/internal/useParseAdaptiveCardJSON.ts index cb69a37f62..7dabc731d1 100644 --- a/packages/bundle/src/adaptiveCards/hooks/internal/useParseAdaptiveCardJSON.ts +++ b/packages/bundle/src/adaptiveCards/hooks/internal/useParseAdaptiveCardJSON.ts @@ -28,7 +28,7 @@ export default function useParseAdaptiveCardJSON() { const [direction] = useDirection(); const [{ adaptiveCardsParserMaxVersion }] = useStyleOptions(); - const { AdaptiveCard, SerializationContext, Version } = adaptiveCardsPackage; + const { AdaptiveCard, OpenUrlAction, SerializationContext, Version } = adaptiveCardsPackage; const maxVersion = useMemo(() => { const maxVersion = Version.parse(adaptiveCardsParserMaxVersion, new SerializationContext()); @@ -52,6 +52,20 @@ export default function useParseAdaptiveCardJSON() { const errors = []; const serializationContext = new SerializationContext(maxVersion); + serializationContext.actionRegistry.register( + 'Action.OpenUrlDialog', + // Adaptive Cards own `OpenUrlAction.JsonTypeName` was not made extensible. + // @ts-expect-error + class OpenUrlDialogAction extends OpenUrlAction { + static readonly JsonTypeName: string = 'Action.OpenUrlDialog'; + + // eslint-disable-next-line class-methods-use-this + getJsonTypeName(): string { + return OpenUrlDialogAction.JsonTypeName; + } + } + ); + card.parse(content, serializationContext); const { eventCount } = serializationContext; @@ -70,6 +84,6 @@ export default function useParseAdaptiveCardJSON() { return card; }, - [AdaptiveCard, adaptiveCardsPackage, direction, maxVersion, SerializationContext] + [AdaptiveCard, adaptiveCardsPackage, direction, maxVersion, OpenUrlAction, SerializationContext] ); } diff --git a/packages/bundle/src/types/AdaptiveCardsPackage.ts b/packages/bundle/src/types/AdaptiveCardsPackage.ts index 6a19938960..a6913f870e 100644 --- a/packages/bundle/src/types/AdaptiveCardsPackage.ts +++ b/packages/bundle/src/types/AdaptiveCardsPackage.ts @@ -3,6 +3,7 @@ import { GlobalSettings, HorizontalAlignment, HostConfig, + OpenUrlAction, SerializationContext, TextSize, TextWeight, @@ -14,6 +15,7 @@ type AdaptiveCardsPackage = { GlobalSettings: typeof GlobalSettings; HorizontalAlignment: typeof HorizontalAlignment; HostConfig: typeof HostConfig; + OpenUrlAction: typeof OpenUrlAction; TextSize: typeof TextSize; TextWeight: typeof TextWeight; SerializationContext: typeof SerializationContext; diff --git a/packages/component/src/Middleware/CardAction/createCoreMiddleware.js b/packages/component/src/Middleware/CardAction/createCoreMiddleware.js deleted file mode 100644 index efc9315463..0000000000 --- a/packages/component/src/Middleware/CardAction/createCoreMiddleware.js +++ /dev/null @@ -1,92 +0,0 @@ -import { ie11 } from '../../Utils/detectBrowser'; - -// This code is adopted from sanitize-html/naughtyScheme. -// sanitize-html is a dependency of Web Chat but the naughtScheme function is neither exposed nor reusable. -// https://github.com/apostrophecms/sanitize-html/ -function getScheme(href) { - // Browsers ignore character codes of 32 (space) and below in a surprising - // number of situations. Start reading here: - // https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Embedded_tab - - /* eslint-disable-next-line no-control-regex */ - href = href.replace(/[\x00-\x20]+/gu, ''); - - // Clobber any comments in URLs, which the browser might - // interpret inside an XML data island, allowing - // a javascript: URL to be snuck through - href = href.replace(//gu, ''); - - // Case insensitive so we don't get faked out by JAVASCRIPT #1 - const matches = href.match(/^([a-zA-Z]+):/u); - - if (!matches) { - // Protocol-relative URL or no scheme - return; - } - - return matches[1].toLowerCase(); -} - -const ALLOWED_SCHEMES = ['data', 'http', 'https', 'ftp', 'mailto', 'sip', 'tel']; - -export default function createDefaultCardActionMiddleware() { - return [ - () => - next => - (...args) => { - const [ - { - cardAction: { type, value }, - getSignInUrl - } - ] = args; - - switch (type) { - case 'call': - case 'downloadFile': - case 'openUrl': - case 'playAudio': - case 'playVideo': - case 'showImage': - if (ALLOWED_SCHEMES.includes(getScheme(value))) { - if (ie11) { - const newWindow = window.open(); - newWindow.opener = null; - newWindow.location = value; - } else { - window.open(value, '_blank', 'noopener noreferrer'); - } - } else { - console.warn('botframework-webchat: Cannot open URL with disallowed schemes.', value); - } - - break; - - case 'signin': { - /** - * @todo TODO: [P3] We should prime the URL into the OAuthCard directly, instead of calling getSessionId on-demand - * This is to eliminate the delay between window.open() and location.href call - */ - - (async function () { - const popup = window.open(); - const url = await getSignInUrl(); - - if (['http', 'https'].includes(getScheme(url))) { - popup.location.href = url; - } else { - console.warn('botframework-webchat: Cannot open URL with disallowed schemes.', url); - - popup.close(); - } - })(); - - break; - } - - default: - return next(...args); - } - } - ]; -} diff --git a/packages/component/src/Middleware/CardAction/createCoreMiddleware.ts b/packages/component/src/Middleware/CardAction/createCoreMiddleware.ts new file mode 100644 index 0000000000..de3e6cdb49 --- /dev/null +++ b/packages/component/src/Middleware/CardAction/createCoreMiddleware.ts @@ -0,0 +1,102 @@ +import type { CardActionMiddleware } from 'botframework-webchat-api'; + +import getScheme from './private/getScheme.js'; +import { check, pipe, safeParse, string, transform, url } from 'valibot'; + +const ALLOWED_SCHEMES = ['data', 'http', 'https', 'ftp', 'mailto', 'sip', 'tel']; + +const callURLValueSchema = pipe( + string('"value" must be a string'), + url('"value" must be an absolute URL'), + check(value => { + try { + return ['http:', 'https:'].includes(new URL(value).protocol); + } catch { + return false; + } + }, '"value" must have protocol of either "http:" or "https:"'), + transform(value => value as any) +); + +// TODO: Pass styleOptions. +export default function createDefaultCardActionMiddleware(): readonly CardActionMiddleware[] { + return [ + ({ styleOptions }) => + next => + (...args) => { + const [ + { + cardAction: { type, value }, + getSignInUrl + } + ] = args; + + switch (type) { + case 'call': + case 'downloadFile': + case 'openUrl': + case 'playAudio': + case 'playVideo': + case 'showImage': + if (ALLOWED_SCHEMES.includes(getScheme(value))) { + window.open(value, '_blank', 'noopener noreferrer'); + } else { + console.warn('botframework-webchat: Cannot open URL with disallowed schemes.', value); + } + + break; + + // Currently, this is exposed as Adaptive Cards `Action.OpenUrlDialog` action. + case 'webchat:callURL': { + const callURLValueParseResult = safeParse(callURLValueSchema, value); + + if (callURLValueParseResult.success) { + window.open( + callURLValueParseResult.output, + '_blank', + [ + // Implicit allow opener/referer because we are calling into a dialog that can return result. + `height=${styleOptions.callURLActionPopupWindowHeight}`, + 'popup', + `width=${styleOptions.callURLActionPopupWindowWidth}` + ].join(',') + ); + } else { + console.warn( + 'botframework-webchat: Cannot call invalid URL.', + value, + ...callURLValueParseResult.issues.map(({ message }) => message) + ); + } + + break; + } + + case 'signin': { + /** + * @todo TODO: [P3] We should prime the URL into the OAuthCard directly, instead of calling getSessionId on-demand + * This is to eliminate the delay between window.open() and location.href call + */ + + (async function () { + const popup = window.open(); + const url = await getSignInUrl(); + + if (['http', 'https'].includes(getScheme(url))) { + popup.location.href = url; + } else { + console.warn('botframework-webchat: Cannot open URL with disallowed schemes.', url); + + popup.close(); + } + })(); + + break; + } + + default: + return next(...args); + } + } + ]; +} diff --git a/packages/component/src/Middleware/CardAction/private/cleanHref.ts b/packages/component/src/Middleware/CardAction/private/cleanHref.ts new file mode 100644 index 0000000000..8d5bfbdad7 --- /dev/null +++ b/packages/component/src/Middleware/CardAction/private/cleanHref.ts @@ -0,0 +1,48 @@ +// TODO: [P1] Should move to [launder](https://www.npmjs.com/package/launder) package. + +/*! + * (launder@1.7.1 is MIT but does not have license file, we are using the license file from sanitize-html instead.) + * + * Copyright (c) 2013, 2014, 2015 P'unk Avenue LLC + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +// This code is adopted from sanitize-html/launder. +// sanitize-html is a dependency of Web Chat but the naughtScheme function is neither exposed nor reusable. +// https://github.com/apostrophecms/sanitize-html/ + +// Strip characters browsers ignore inside URLs (control chars and +// embedded HTML comments) that are commonly used to sneak XSS +// payloads past simple scheme checks. +export default function cleanHref(href: string): string { + // Browsers ignore character codes of 32 (space) and below in a surprising + // number of situations. Start reading here: + // https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Embedded_tab + // eslint-disable-next-line no-control-regex, require-unicode-regexp + href = href.replace(/[\x00-\x20]+/g, ''); + // Clobber any comments in URLs, which the browser might + // interpret inside an XML data island, allowing + // a javascript: URL to be snuck through + // eslint-disable-next-line no-constant-condition + while (true) { + const firstIndex = href.indexOf('', firstIndex + 4); + // eslint-disable-next-line no-magic-numbers + if (lastIndex === -1) { + break; + } + // eslint-disable-next-line no-magic-numbers + href = href.substring(0, firstIndex) + href.substring(lastIndex + 3); + } + return href; +} diff --git a/packages/component/src/Middleware/CardAction/private/getScheme.ts b/packages/component/src/Middleware/CardAction/private/getScheme.ts new file mode 100644 index 0000000000..6f6072462b --- /dev/null +++ b/packages/component/src/Middleware/CardAction/private/getScheme.ts @@ -0,0 +1,13 @@ +import cleanHref from './cleanHref.js'; + +export default function getScheme(href: string): string | undefined { + // Case insensitive so we don't get faked out by JAVASCRIPT #1 + const matches = cleanHref(href).match(/^([a-zA-Z]+):/u); + + if (!matches) { + // Protocol-relative URL or no scheme + return; + } + + return matches[1].toLowerCase(); +} diff --git a/packages/component/src/SendBox/SuggestedAction.tsx b/packages/component/src/SendBox/SuggestedAction.tsx index cbb7410e21..7fe0b2e48f 100644 --- a/packages/component/src/SendBox/SuggestedAction.tsx +++ b/packages/component/src/SendBox/SuggestedAction.tsx @@ -47,7 +47,8 @@ const suggestedActionPropsSchema = pipe( literal('playVideo'), literal('postBack'), literal('showImage'), - literal('signin') + literal('signin'), + literal('webchat:callURL') ]) ), value: any() @@ -98,8 +99,9 @@ function SuggestedAction(props: SuggestedActionProps) { // Instead, we should pass a "cardAction" props. performCardAction({ displayText, text, type, value } as DirectLineCardAction, { target }); - // Since "openUrl" action do not submit, the suggested action buttons do not hide after click. - type === 'openUrl' && setSuggestedActions([]); + // Since "openUrl" and "webchat:callURL" actions do not actually submit or send any activity, + // suggested action buttons will not hide automatically after click. We need to hide it manually. + (type === 'openUrl' || type === 'webchat:callURL') && setSuggestedActions([]); scrollToEnd(); })(); diff --git a/packages/component/src/SendBox/SuggestedActions.tsx b/packages/component/src/SendBox/SuggestedActions.tsx index 2af00d1007..5891246802 100644 --- a/packages/component/src/SendBox/SuggestedActions.tsx +++ b/packages/component/src/SendBox/SuggestedActions.tsx @@ -227,7 +227,8 @@ const SuggestedActions = ({ className }: SuggestedActionsProps) => { | 'playVideo' | 'postBack' | 'showImage' - | 'signin'; + | 'signin' + | 'webchat:callURL'; value?: { [key: string]: any } | string; }; diff --git a/packages/core/src/types/external/DirectLineCardAction.ts b/packages/core/src/types/external/DirectLineCardAction.ts index 6d2e14a31c..39a6edbdc2 100644 --- a/packages/core/src/types/external/DirectLineCardAction.ts +++ b/packages/core/src/types/external/DirectLineCardAction.ts @@ -1,3 +1,5 @@ +import type { WebChatCallURLCardAction } from '../internal/WebChatCallURLCardAction.ts'; + type CardActionWithImageAndTitle = | { image: string } | { title: string } @@ -123,6 +125,7 @@ type DirectLineCardAction = | PlayVideoCardAction | PostBackCardAction | ShowImageCardAction - | SignInCardAction; + | SignInCardAction + | WebChatCallURLCardAction; export type { DirectLineCardAction }; diff --git a/packages/core/src/types/internal/WebChatCallURLCardAction.ts b/packages/core/src/types/internal/WebChatCallURLCardAction.ts new file mode 100644 index 0000000000..20a86829ec --- /dev/null +++ b/packages/core/src/types/internal/WebChatCallURLCardAction.ts @@ -0,0 +1,20 @@ +type CardActionWithImageAndTitle = + | { image: string; title?: undefined } + | { image?: undefined; title: string } + | { + image: string; + title: string; + }; + +/** + * Web Chat-only `webchat:callUrl` action represents a hyperlink to be handled by the client. + * + * The hyperlink will be opened in a popup window to indicate its modality. + * Return values from the popup are not currently handled by Web Chat. + */ +type WebChatCallURLCardAction = CardActionWithImageAndTitle & { + type: 'webchat:callURL'; + value: string; +}; + +export { WebChatCallURLCardAction }; diff --git a/packages/fluent-theme/src/components/suggestedActions/SuggestedAction.tsx b/packages/fluent-theme/src/components/suggestedActions/SuggestedAction.tsx index 038ed5506d..45935febd8 100644 --- a/packages/fluent-theme/src/components/suggestedActions/SuggestedAction.tsx +++ b/packages/fluent-theme/src/components/suggestedActions/SuggestedAction.tsx @@ -29,7 +29,8 @@ type SuggestedActionProps = Readonly<{ | 'playVideo' | 'postBack' | 'showImage' - | 'signin'; + | 'signin' + | 'webchat:callURL'; value?: any; }>; diff --git a/packages/fluent-theme/src/components/suggestedActions/SuggestedActions.tsx b/packages/fluent-theme/src/components/suggestedActions/SuggestedActions.tsx index fbec94e2e7..bd70604504 100644 --- a/packages/fluent-theme/src/components/suggestedActions/SuggestedActions.tsx +++ b/packages/fluent-theme/src/components/suggestedActions/SuggestedActions.tsx @@ -71,7 +71,8 @@ function SuggestedActions() { | 'playVideo' | 'postBack' | 'showImage' - | 'signin'; + | 'signin' + | 'webchat:callURL'; value?: { [key: string]: any } | string; };