diff --git a/__tests__/hooks/useSendBoxValue.js b/__tests__/hooks/useSendBoxValue.js deleted file mode 100644 index 5a43f66452..0000000000 --- a/__tests__/hooks/useSendBoxValue.js +++ /dev/null @@ -1,26 +0,0 @@ -import { timeouts } from '../constants.json'; - -import uiConnected from '../setup/conditions/uiConnected'; - -// selenium-webdriver API doc: -// https://seleniumhq.github.io/selenium/docs/api/javascript/module/selenium-webdriver/index_exports_WebDriver.html - -jest.setTimeout(timeouts.test); - -test('getter should get the send box text', async () => { - const { driver, pageObjects } = await setupWebDriver(); - - await driver.wait(uiConnected(), timeouts.directLine); - - await pageObjects.typeInSendBox('Hello, World!'); - await expect(pageObjects.runHook('useSendBoxValue', [], result => result[0])).resolves.toBe('Hello, World!'); -}); - -test('setter should set the send box text', async () => { - const { driver, pageObjects } = await setupWebDriver(); - - await driver.wait(uiConnected(), timeouts.directLine); - - await pageObjects.runHook('useSendBoxValue', [], result => result[1]('Hello, World!')); - await expect(pageObjects.getSendBoxText()).resolves.toBe('Hello, World!'); -}); diff --git a/__tests__/html/sendAttachmentOn/useSendBoxAttachments.js b/__tests__/html/sendAttachmentOn/useSendBoxAttachments.js deleted file mode 100644 index 37b978959b..0000000000 --- a/__tests__/html/sendAttachmentOn/useSendBoxAttachments.js +++ /dev/null @@ -1,5 +0,0 @@ -/** @jest-environment ./packages/test/harness/src/host/jest/WebDriverEnvironment.js */ - -describe('Call useSendBoxAttachments hook', () => { - test('should get/set and upload attachments', () => runHTML('sendAttachmentOn/useSendBoxAttachments')); -}); diff --git a/__tests__/html2/hooks/useSendBoxHooks/useSendBoxValue.getter.html b/__tests__/html2/hooks/useSendBoxHooks/useSendBoxValue.getter.html new file mode 100644 index 0000000000..23db3d4ea5 --- /dev/null +++ b/__tests__/html2/hooks/useSendBoxHooks/useSendBoxValue.getter.html @@ -0,0 +1,58 @@ + + + + + + + + + + + + +
+ + + diff --git a/__tests__/html2/hooks/useSendBoxHooks/useSendBoxValue.setter.html b/__tests__/html2/hooks/useSendBoxHooks/useSendBoxValue.setter.html new file mode 100644 index 0000000000..345e483766 --- /dev/null +++ b/__tests__/html2/hooks/useSendBoxHooks/useSendBoxValue.setter.html @@ -0,0 +1,59 @@ + + + + + + + + + + + + +
+ + + diff --git a/__tests__/html2/sendBox/sendAttachmentOn/useSendBoxAttachments.deprecated.html b/__tests__/html2/sendBox/sendAttachmentOn/useSendBoxAttachments.deprecated.html new file mode 100644 index 0000000000..33d1999053 --- /dev/null +++ b/__tests__/html2/sendBox/sendAttachmentOn/useSendBoxAttachments.deprecated.html @@ -0,0 +1,9 @@ + + + + + + + diff --git a/__tests__/__image_snapshots__/html/use-send-box-attachments-js-call-use-send-box-attachments-hook-should-get-set-and-upload-attachments-1-snap.png b/__tests__/html2/sendBox/sendAttachmentOn/useSendBoxAttachments.deprecated.html.snap-1.png similarity index 100% rename from __tests__/__image_snapshots__/html/use-send-box-attachments-js-call-use-send-box-attachments-hook-should-get-set-and-upload-attachments-1-snap.png rename to __tests__/html2/sendBox/sendAttachmentOn/useSendBoxAttachments.deprecated.html.snap-1.png diff --git a/__tests__/__image_snapshots__/html/use-send-box-attachments-js-call-use-send-box-attachments-hook-should-get-set-and-upload-attachments-2-snap.png b/__tests__/html2/sendBox/sendAttachmentOn/useSendBoxAttachments.deprecated.html.snap-2.png similarity index 100% rename from __tests__/__image_snapshots__/html/use-send-box-attachments-js-call-use-send-box-attachments-hook-should-get-set-and-upload-attachments-2-snap.png rename to __tests__/html2/sendBox/sendAttachmentOn/useSendBoxAttachments.deprecated.html.snap-2.png diff --git a/__tests__/html/sendAttachmentOn/useSendBoxAttachments.html b/__tests__/html2/sendBox/sendAttachmentOn/useSendBoxAttachments.html similarity index 77% rename from __tests__/html/sendAttachmentOn/useSendBoxAttachments.html rename to __tests__/html2/sendBox/sendAttachmentOn/useSendBoxAttachments.html index 6b1c0b680a..dab3627e28 100644 --- a/__tests__/html/sendAttachmentOn/useSendBoxAttachments.html +++ b/__tests__/html2/sendBox/sendAttachmentOn/useSendBoxAttachments.html @@ -12,6 +12,7 @@
diff --git a/__tests__/html2/store/suggestedActions/saga.clearSuggestedActionsOnPostActivity.html b/__tests__/html2/store/suggestedActions/saga.clearSuggestedActionsOnPostActivity.html new file mode 100644 index 0000000000..d377006528 --- /dev/null +++ b/__tests__/html2/store/suggestedActions/saga.clearSuggestedActionsOnPostActivity.html @@ -0,0 +1,100 @@ + + + + + + + + + + + +
+ + + + diff --git a/__tests__/html2/store/suggestedActions/saga.clearSuggestedActionsOnPostActivity.whileNotConnected.html b/__tests__/html2/store/suggestedActions/saga.clearSuggestedActionsOnPostActivity.whileNotConnected.html new file mode 100644 index 0000000000..9436cf479b --- /dev/null +++ b/__tests__/html2/store/suggestedActions/saga.clearSuggestedActionsOnPostActivity.whileNotConnected.html @@ -0,0 +1,115 @@ + + + + + + + + + + + +
+ + + + diff --git a/__tests__/html2/store/whileConnected/hooks.useConnectionDetails.html b/__tests__/html2/store/whileConnected/hooks.useConnectionDetails.html new file mode 100644 index 0000000000..17e5ec1f42 --- /dev/null +++ b/__tests__/html2/store/whileConnected/hooks.useConnectionDetails.html @@ -0,0 +1,89 @@ + + + + + + + + + + + +
+ + + + diff --git a/package-lock.json b/package-lock.json index 47516eaf58..8904d3f770 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "./packages/test/harness", "./packages/test/web-server", "./packages/core", + "./packages/react-context", "./packages/react-valibot", "./packages/redux-store", "./packages/styles", @@ -7061,6 +7062,10 @@ "resolved": "packages/fluent-theme", "link": true }, + "node_modules/botframework-webchat-react-context": { + "resolved": "packages/react-context", + "link": true + }, "node_modules/botframework-webchat-react-valibot": { "resolved": "packages/react-valibot", "link": true @@ -25107,6 +25112,7 @@ "babel-plugin-istanbul": "^7.0.0", "babel-plugin-transform-inline-environment-variables": "^0.4.4", "botframework-webchat-base": "0.0.0-0", + "botframework-webchat-react-context": "^0.0.0-0", "botframework-webchat-react-valibot": "^0.0.0-0", "botframework-webchat-styles": "0.0.0-0", "concurrently": "^9.1.2", @@ -26659,6 +26665,36 @@ "webpack-cli": "^6.0.1" } }, + "packages/react-context": { + "name": "botframework-webchat-react-context", + "version": "0.0.0-0", + "license": "MIT", + "dependencies": { + "valibot": "1.1.0" + }, + "devDependencies": { + "@tsconfig/strictest": "^2.0.5", + "@types/react": "^16.14.62", + "botframework-webchat-react-valibot": "^0.0.0-0" + }, + "peerDependencies": { + "react": ">= 16.8.6" + } + }, + "packages/react-context/node_modules/valibot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.1.0.tgz", + "integrity": "sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw==", + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "packages/react-types": { "name": "botframework-webchat-react-types", "version": "0.0.0-0", diff --git a/package.json b/package.json index f8265b4d13..e207fb9544 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "./packages/test/web-server", "./packages/core", "./packages/react-valibot", + "./packages/react-context", "./packages/redux-store", "./packages/styles", "./packages/support/cldr-data-downloader", @@ -61,6 +62,7 @@ "precommit:eslint:fluent-theme": "cd packages && cd fluent-theme && npm run precommit:eslint", "precommit:eslint:isomorphic-react-dom": "cd packages && cd isomorphic-react-dom && npm run precommit:eslint", "precommit:eslint:isomorphic-react": "cd packages && cd isomorphic-react && npm run precommit:eslint", + "precommit:eslint:react-context": "cd packages && cd react-context && npm run precommit:eslint", "precommit:eslint:react-valibot": "cd packages && cd react-valibot && npm run precommit:eslint", "precommit:eslint:redux-store": "cd packages && cd redux-store && npm run precommit:eslint", "precommit:eslint:styles": "cd packages && cd styles && npm run precommit:eslint", @@ -75,6 +77,7 @@ "precommit:typecheck:component": "cd packages && cd component && npm run precommit:typecheck", "precommit:typecheck:core": "cd packages && cd core && npm run precommit:typecheck", "precommit:typecheck:fluent-theme": "cd packages && cd fluent-theme && npm run precommit:typecheck", + "precommit:typecheck:react-context": "cd packages && cd react-context && npm run precommit:typecheck", "precommit:typecheck:react-valibot": "cd packages && cd react-valibot && npm run precommit:typecheck", "precommit:typecheck:redux-store": "cd packages && cd redux-store && npm run precommit:typecheck", "prepare": "husky", @@ -85,6 +88,7 @@ "start:core": "cd packages && cd core && npm start", "start:directlinespeech": "cd packages && cd directlinespeech && npm start", "start:fluent-theme": "cd packages && cd fluent-theme && npm start", + "start:react-context": "cd packages && cd react-context && npm start", "start:react-valibot": "cd packages && cd react-valibot && npm start", "start:redux-store": "cd packages && cd redux-store && npm start", "start:server": "serve -p 5000", diff --git a/packages/api/src/hooks/Composer.tsx b/packages/api/src/hooks/Composer.tsx index 21e1d64276..bd239d2ff1 100644 --- a/packages/api/src/hooks/Composer.tsx +++ b/packages/api/src/hooks/Composer.tsx @@ -17,7 +17,6 @@ import { setLanguage, setNotification, setSendBox, - setSendBoxAttachments, setSendTimeout, setSendTypingIndicator, singleToArray, @@ -104,7 +103,6 @@ const DISPATCHERS = { setDictateState, setNotification, setSendBox, - setSendBoxAttachments, setSendTimeout, startDictate, startSpeakingActivity, diff --git a/packages/api/src/hooks/index.ts b/packages/api/src/hooks/index.ts index e67119ddac..fc1585f681 100644 --- a/packages/api/src/hooks/index.ts +++ b/packages/api/src/hooks/index.ts @@ -72,7 +72,7 @@ import useUserID from './useUserID'; import useUsername from './useUsername'; import useVoiceSelector from './useVoiceSelector'; -export { useSuggestedActionsHooks } from 'botframework-webchat-redux-store'; +export { useSendBoxHooks, useSuggestedActionsHooks, useWhileConnectedHooks } from 'botframework-webchat-redux-store'; export { useActiveTyping, diff --git a/packages/api/src/hooks/internal/WebChatAPIContext.ts b/packages/api/src/hooks/internal/WebChatAPIContext.ts index 0396a90010..1aaa314ede 100644 --- a/packages/api/src/hooks/internal/WebChatAPIContext.ts +++ b/packages/api/src/hooks/internal/WebChatAPIContext.ts @@ -3,8 +3,7 @@ import { type Observable, type WebChatActivity, type sendFiles, - type sendMessage, - type setSendBoxAttachments + type sendMessage } from 'botframework-webchat-core'; import { createContext, type ComponentType } from 'react'; @@ -78,7 +77,6 @@ export type WebChatAPIContextType = { setDictateState?: (dictateState: number) => void; setNotification?: (notification: Notification) => void; setSendBox?: (value: string) => void; - setSendBoxAttachments?: (...args: Parameters) => void; setSendTimeout?: (timeout: number) => void; startDictate?: () => void; startSpeakingActivity?: () => void; diff --git a/packages/api/src/hooks/useSendBoxAttachments.ts b/packages/api/src/hooks/useSendBoxAttachments.ts index 82ee0cbd1f..b571dfb818 100644 --- a/packages/api/src/hooks/useSendBoxAttachments.ts +++ b/packages/api/src/hooks/useSendBoxAttachments.ts @@ -1,23 +1,15 @@ -import type { SendBoxAttachment } from 'botframework-webchat-core'; -import { useMemo } from 'react'; - -import { useSelector } from './internal/WebChatReduxContext'; -import useWebChatAPIContext from './internal/useWebChatAPIContext'; +import { type SendBoxAttachment } from 'botframework-webchat-core'; +import { useSendBoxHooks } from 'botframework-webchat-redux-store'; +import { type Dispatch, type SetStateAction } from 'react'; +/** + * @deprecated Use `useSendBoxHooks().useSendBoxAttachments()` instead. This hook will be removed on or after 2027-05-30. + */ export default function useSendBoxAttachments(): readonly [ readonly SendBoxAttachment[], - // TODO: This should be Dispatch>, however Redux doesn't support this signature. - // When we move out of Redux, we should change it. - (attachments: readonly SendBoxAttachment[]) => void + Dispatch> ] { - // TODO: We should use the selector from "core" package. - const sendBoxAttachments = useSelector( - ({ sendBoxAttachments }) => sendBoxAttachments as readonly SendBoxAttachment[] - ); - const { setSendBoxAttachments } = useWebChatAPIContext(); - - return useMemo( - () => Object.freeze([sendBoxAttachments, setSendBoxAttachments]), - [sendBoxAttachments, setSendBoxAttachments] - ); + // Provides a path for backward compatibility during deprecation. + // eslint-disable-next-line local-rules/forbid-use-hook-producer + return useSendBoxHooks().useSendBoxAttachments(); } diff --git a/packages/api/src/hooks/useSendBoxValue.ts b/packages/api/src/hooks/useSendBoxValue.ts index aa7611d9ac..6e509b7645 100644 --- a/packages/api/src/hooks/useSendBoxValue.ts +++ b/packages/api/src/hooks/useSendBoxValue.ts @@ -1,6 +1,11 @@ -import { useSelector } from './internal/WebChatReduxContext'; -import useWebChatAPIContext from './internal/useWebChatAPIContext'; +import { useSendBoxHooks } from 'botframework-webchat-redux-store'; +import { type Dispatch, type SetStateAction } from 'react'; -export default function useSendBoxValue(): [string, (value: string) => void] { - return [useSelector(({ sendBoxValue }) => sendBoxValue), useWebChatAPIContext().setSendBox]; +/** + * @deprecated Use `useSendBoxHooks().useSendBoxValue()` instead. This hook will be removed on or after 2027-05-30. + */ +export default function useSendBoxValue(): readonly [string, Dispatch>] { + // Provides a path for backward compatibility during deprecation. + // eslint-disable-next-line local-rules/forbid-use-hook-producer + return useSendBoxHooks().useSendBoxValue(); } diff --git a/packages/api/src/hooks/useSubmitSendBox.ts b/packages/api/src/hooks/useSubmitSendBox.ts index 07fc997ad2..1bd65237c1 100644 --- a/packages/api/src/hooks/useSubmitSendBox.ts +++ b/packages/api/src/hooks/useSubmitSendBox.ts @@ -1,12 +1,14 @@ import { useCallback } from 'react'; import { useRefFrom } from 'use-ref-from'; +import { useSendBoxHooks } from './index'; import useWebChatAPIContext from './internal/useWebChatAPIContext'; -import useSendBoxAttachments from './useSendBoxAttachments'; import useTrackEvent from './useTrackEvent'; export default function useSubmitSendBox(): (method?: string, { channelData }?: { channelData: any }) => void { - const [sendBoxAttachments] = useSendBoxAttachments(); + // TODO: Move the logic into APIContext.submitSendBox. + // eslint-disable-next-line local-rules/forbid-use-hook-producer + const [sendBoxAttachments] = useSendBoxHooks().useSendBoxAttachments(); const { submitSendBox } = useWebChatAPIContext(); const trackEvent = useTrackEvent(); diff --git a/packages/api/src/hooks/useSuggestedActions.ts b/packages/api/src/hooks/useSuggestedActions.ts index 2dd8f16212..4cef30f00b 100644 --- a/packages/api/src/hooks/useSuggestedActions.ts +++ b/packages/api/src/hooks/useSuggestedActions.ts @@ -1,7 +1,7 @@ import { useSuggestedActionsHooks } from 'botframework-webchat-redux-store'; /** - * @deprecated Use `useSuggestedActionsHooks().useSuggestedActions` instead. This hook will be removed on or after 2027-05-30. + * @deprecated Use `useSuggestedActionsHooks().useSuggestedActions()` instead. This hook will be removed on or after 2027-05-30. */ export default function useSuggestedActions() { // Provides a path for backward compatibility during deprecation. diff --git a/packages/component/package.json b/packages/component/package.json index 170563eb7e..891f741d83 100644 --- a/packages/component/package.json +++ b/packages/component/package.json @@ -72,6 +72,7 @@ "botframework-webchat-api": "production", "botframework-webchat-base": "development", "botframework-webchat-core": "production", + "botframework-webchat-react-context": "development", "botframework-webchat-react-valibot": "development", "botframework-webchat-styles": "development" }, @@ -131,6 +132,7 @@ "babel-plugin-istanbul": "^7.0.0", "babel-plugin-transform-inline-environment-variables": "^0.4.4", "botframework-webchat-base": "0.0.0-0", + "botframework-webchat-react-context": "^0.0.0-0", "botframework-webchat-react-valibot": "^0.0.0-0", "botframework-webchat-styles": "0.0.0-0", "concurrently": "^9.1.2", diff --git a/packages/component/src/Attachment/Text/private/ActivityCopyButton.tsx b/packages/component/src/Attachment/Text/private/ActivityCopyButton.tsx index 47a17b2fea..dc9bed5aec 100644 --- a/packages/component/src/Attachment/Text/private/ActivityCopyButton.tsx +++ b/packages/component/src/Attachment/Text/private/ActivityCopyButton.tsx @@ -1,10 +1,14 @@ import { hooks } from 'botframework-webchat-api'; import { validateProps } from 'botframework-webchat-react-valibot'; import classNames from 'classnames'; -import React, { memo, useCallback, useEffect, useRef, useState } from 'react'; +import React, { memo, useCallback, useEffect, useRef } from 'react'; +import { wrapWith } from 'react-wrap-with'; import { instance, nullable, object, optional, pipe, readonly, string, type InferInput } from 'valibot'; import useStyleSet from '../../../hooks/useStyleSet'; +import ClipboardWritePermissionComposer, { + useClipboardWritePermissionHooks +} from '../../../providers/ClipboardWritePermission/ClipboardWritePermissionComposer'; import { useQueueStaticElement } from '../../../providers/LiveRegionTwin'; import refObject from '../../../types/internal/refObject'; import ActivityButton from './ActivityButton'; @@ -23,11 +27,11 @@ type ActivityCopyButtonProps = InferInput; const COPY_ICON_URL = `data:image/svg+xml;utf8,${encodeURIComponent('')}`; -const ActivityCopyButton = (props: ActivityCopyButtonProps) => { +function ActivityCopyButton(props: ActivityCopyButtonProps) { const { className, targetRef } = validateProps(activityCopyButtonPropsSchema, props); const [{ activityButton, activityCopyButton }] = useStyleSet(); - const [permissionGranted, setPermissionGranted] = useState(false); + const [permissionGranted] = useClipboardWritePermissionHooks().usePermissionGranted(); const [uiState] = useUIState(); const buttonRef = useRef(null); const localize = useLocalizer(); @@ -73,20 +77,6 @@ const ActivityCopyButton = (props: ActivityCopyButtonProps) => { queueStaticElement(
{copiedText}
); }, [buttonRef, copiedText, queueStaticElement, targetRef]); - useEffect(() => { - let unmounted = false; - - (async function () { - if ((await navigator.permissions.query({ name: 'clipboard-write' as any })).state === 'granted') { - unmounted || setPermissionGranted(true); - } - })(); - - return () => { - unmounted = true; - }; - }, [setPermissionGranted]); - return ( { {copiedText} ); -}; - -ActivityCopyButton.displayName = 'ActivityCopyButton'; +} -export default memo(ActivityCopyButton); +export default memo(wrapWith(ClipboardWritePermissionComposer)(ActivityCopyButton)); export { activityCopyButtonPropsSchema, type ActivityCopyButtonProps }; diff --git a/packages/component/src/Dictation.js b/packages/component/src/Dictation.js index 48a1efecd4..aec0126621 100644 --- a/packages/component/src/Dictation.js +++ b/packages/component/src/Dictation.js @@ -15,7 +15,7 @@ const { useDictateState, useEmitTypingIndicator, useLanguage, - useSendBoxValue, + useSendBoxHooks, useSendTypingIndicator, useShouldSpeakIncomingActivity, useStopDictate, @@ -31,7 +31,7 @@ const { const Dictation = ({ onError }) => { const [, setDictateAbortable] = useSettableDictateAbortable(); const [, setDictateInterims] = useDictateInterims(); - const [, setSendBox] = useSendBoxValue(); + const [, setSendBox] = useSendBoxHooks().useSendBoxValue(); const [, setShouldSpeakIncomingActivity] = useShouldSpeakIncomingActivity(); const [{ SpeechGrammarList, SpeechRecognition } = {}] = useWebSpeechPonyfill(); const [{ speechRecognitionContinuous }] = useStyleOptions(); diff --git a/packages/component/src/SendBox/AttachmentBar/AttachmentBar.tsx b/packages/component/src/SendBox/AttachmentBar/AttachmentBar.tsx index 086281a6e5..17891475b5 100644 --- a/packages/component/src/SendBox/AttachmentBar/AttachmentBar.tsx +++ b/packages/component/src/SendBox/AttachmentBar/AttachmentBar.tsx @@ -1,16 +1,16 @@ import { hooks } from 'botframework-webchat-api'; import { validateProps } from 'botframework-webchat-react-valibot'; +import { useStyles } from 'botframework-webchat-styles/react'; import cx from 'classnames'; import React, { memo, useCallback, useMemo } from 'react'; import { useRefFrom } from 'use-ref-from'; -import { useStyles } from 'botframework-webchat-styles/react'; import { object, optional, pipe, readonly, string, type InferInput } from 'valibot'; -import styles from './AttachmentBar.module.css'; import testIds from '../../testIds'; +import styles from './AttachmentBar.module.css'; import AttachmentBarItem from './AttachmentBarItem'; -const { useSendBoxAttachments, useStyleOptions } = hooks; +const { useSendBoxHooks, useStyleOptions } = hooks; const sendBoxAttachmentBarPropsSchema = pipe( object({ @@ -24,7 +24,7 @@ type SendBoxAttachmentBarProps = InferInput void { - const [, setSendBox] = useSendBoxValue(); + // TODO: Move useMicrophoneButtonClick() into useSendBoxHooks(). + // eslint-disable-next-line local-rules/forbid-use-hook-producer + const [, setSendBox] = useSendBoxHooks().useSendBoxValue(); const [, setShouldSpeakIncomingActivity] = useShouldSpeakIncomingActivity(); const [dictateInterims] = useDictateInterims(); const [dictateState] = useDictateState(); diff --git a/packages/component/src/SendBox/TextBox.tsx b/packages/component/src/SendBox/TextBox.tsx index df2be620f7..a79ea769f9 100644 --- a/packages/component/src/SendBox/TextBox.tsx +++ b/packages/component/src/SendBox/TextBox.tsx @@ -5,8 +5,8 @@ import React, { useCallback, useMemo, useRef } from 'react'; import AccessibleInputText from '../Utils/AccessibleInputText'; import navigableEvent from '../Utils/TypeFocusSink/navigableEvent'; import { ie11 } from '../Utils/detectBrowser'; -import { useRegisterFocusSendBox, type SendBoxFocusOptions } from '../hooks/sendBoxFocus'; import { useStyleToEmotionObject } from '../hooks/internal/styleToEmotionObject'; +import { useRegisterFocusSendBox, type SendBoxFocusOptions } from '../hooks/sendBoxFocus'; import useScrollDown from '../hooks/useScrollDown'; import useScrollUp from '../hooks/useScrollUp'; import useStyleSet from '../hooks/useStyleSet'; @@ -17,7 +17,7 @@ import AutoResizeTextArea from './AutoResizeTextArea'; import type { MutableRefObject } from 'react'; import testIds from '../testIds'; -const { useLocalizer, usePonyfill, useSendBoxValue, useStopDictate, useStyleOptions, useUIState } = hooks; +const { useLocalizer, usePonyfill, useSendBoxHooks, useStopDictate, useStyleOptions, useUIState } = hooks; const ROOT_STYLE = { '&.webchat__send-box-text-box': { @@ -56,7 +56,9 @@ function useTextBoxSubmit(): SubmitTextBoxFunction { } function useTextBoxValue(): [string, (textBoxValue: string) => void] { - const [value, setValue] = useSendBoxValue(); + // TODO: Move speech-related feature into useSendBoxHooks(). + // eslint-disable-next-line local-rules/forbid-use-hook-producer + const [value, setValue] = useSendBoxHooks().useSendBoxValue(); const stopDictate = useStopDictate(); const setter = useCallback<(nextValue: string) => void>( @@ -80,7 +82,7 @@ const SingleLineTextBox = withEmoji(AccessibleInputText); const MultiLineTextBox = withEmoji(AutoResizeTextArea); const TextBox = ({ className = '' }: Readonly<{ className?: string | undefined }>) => { - const [value, setValue] = useSendBoxValue(); + const [value, setValue] = useSendBoxHooks().useSendBoxValue(); const [{ sendBoxTextBox: sendBoxTextBoxStyleSet }] = useStyleSet(); const [{ emojiSet, sendBoxTextWrap }] = useStyleOptions(); const [{ setTimeout }] = usePonyfill(); diff --git a/packages/component/src/SendBoxToolbar/UploadButton.tsx b/packages/component/src/SendBoxToolbar/UploadButton.tsx index 1b4a1715e2..2473768c98 100644 --- a/packages/component/src/SendBoxToolbar/UploadButton.tsx +++ b/packages/component/src/SendBoxToolbar/UploadButton.tsx @@ -14,7 +14,7 @@ import useStyleSet from '../hooks/useStyleSet'; import useSubmit from '../providers/internal/SendBox/useSubmit'; import AttachmentIcon from './Assets/AttachmentIcon'; -const { useSendBoxAttachments, useLocalizer, useStyleOptions, useUIState } = hooks; +const { useSendBoxHooks, useLocalizer, useStyleOptions, useUIState } = hooks; const ROOT_STYLE = { '&.webchat__upload-button': { @@ -50,7 +50,7 @@ function UploadButton(props: UploadButtonProps) { const [{ sendAttachmentOn, uploadAccept, uploadMultiple }] = useStyleOptions(); const [{ uploadButton: uploadButtonStyleSet }] = useStyleSet(); const [inputKey, setInputKey] = useState(0); - const [sendBoxAttachments, setSendBoxAttachments] = useSendBoxAttachments(); + const [sendBoxAttachments, setSendBoxAttachments] = useSendBoxHooks().useSendBoxAttachments(); const [uiState] = useUIState(); const focus = useFocus(); const inputRef = useRef(null); diff --git a/packages/component/src/providers/ClipboardWritePermission/ClipboardWritePermissionComposer.tsx b/packages/component/src/providers/ClipboardWritePermission/ClipboardWritePermissionComposer.tsx new file mode 100644 index 0000000000..448160450a --- /dev/null +++ b/packages/component/src/providers/ClipboardWritePermission/ClipboardWritePermissionComposer.tsx @@ -0,0 +1,100 @@ +import { createBitContext, useReadonlyState, useStableStateHook } from 'botframework-webchat-react-context'; +import { reactNode, validateProps } from 'botframework-webchat-react-valibot'; +import React, { + createContext, + memo, + useContext, + useEffect, + useMemo, + useState, + type Dispatch, + type SetStateAction +} from 'react'; +import { wrapWith } from 'react-wrap-with'; +import { object, optional, pipe, readonly, type InferInput } from 'valibot'; + +declare const WEBCHAT_PERF_CONTEXT: 'bit ocontext' | 'stable state'; + +const clipboardWritePermissionComposerPropsSchema = pipe( + object({ + children: optional(reactNode()) + }), + readonly() +); + +type ClipboardWritePermissionComposerProps = InferInput; + +type ClipboardWritePermissionContextType = Readonly<{ + usePermissionGranted: () => readonly [boolean]; +}>; + +const ClipboardWritePermissionContext = createContext( + new Proxy({} as any, { + get() { + throw new Error('botframework-webchat: This hook can only bs used under '); + } + }) +); + +const { Composer: PermissionGrantedComposer, useState: usePermissionGrantedFromBit } = createBitContext(false); + +function ClipboardWritePermissionComposer(props: ClipboardWritePermissionComposerProps) { + const { children } = validateProps(clipboardWritePermissionComposerPropsSchema, props); + + let setPermissionGranted: Dispatch>; + let usePermissionGranted: () => readonly [boolean]; + + if (WEBCHAT_PERF_CONTEXT === 'stable state') { + // eslint-disable-next-line react-hooks/rules-of-hooks + const [permissionGranted, setPermissionGrantedFromState] = useState(false); + + setPermissionGranted = setPermissionGrantedFromState; + // eslint-disable-next-line react-hooks/rules-of-hooks + usePermissionGranted = useStableStateHook(permissionGranted); + } else { + // eslint-disable-next-line prefer-destructuring, react-hooks/rules-of-hooks + setPermissionGranted = usePermissionGrantedFromBit()[1]; + // eslint-disable-next-line react-hooks/rules-of-hooks + usePermissionGranted = useReadonlyState(usePermissionGrantedFromBit()); + } + + const context = useMemo( + () => + Object.freeze({ + usePermissionGranted + }), + [usePermissionGranted] + ); + + useEffect(() => { + let unmounted = false; + + (async () => { + if ((await navigator.permissions.query({ name: 'clipboard-write' as any })).state === 'granted') { + unmounted || setPermissionGranted(true); + } + })(); + + return () => { + unmounted = true; + }; + }, [setPermissionGranted]); + + return ( + {children} + ); +} + +function useClipboardWritePermissionHooks(): Readonly<{ + usePermissionGranted(): readonly [boolean]; +}> { + return useContext(ClipboardWritePermissionContext); +} + +export default memo( + WEBCHAT_PERF_CONTEXT === 'stable state' + ? ClipboardWritePermissionComposer + : wrapWith(PermissionGrantedComposer)(memo(ClipboardWritePermissionComposer)) +); + +export { useClipboardWritePermissionHooks }; diff --git a/packages/component/src/providers/internal/SendBox/SendBoxComposer.tsx b/packages/component/src/providers/internal/SendBox/SendBoxComposer.tsx index 1eb85beecd..b4bf71da8d 100644 --- a/packages/component/src/providers/internal/SendBox/SendBoxComposer.tsx +++ b/packages/component/src/providers/internal/SendBox/SendBoxComposer.tsx @@ -11,7 +11,7 @@ import { useLiveRegion } from '../../../providers/LiveRegionTwin'; import SendBoxContext from './private/Context'; import { type ContextType, type SendError } from './private/types'; -const { useConnectivityStatus, useLocalizer, useSendBoxAttachments, useSendBoxValue, useSubmitSendBox } = hooks; +const { useConnectivityStatus, useLocalizer, useSendBoxHooks, useSubmitSendBox } = hooks; const SUBMIT_ERROR_MESSAGE_STYLE = { '&.webchat__submit-error-message': { @@ -51,10 +51,10 @@ type SendBoxComposerProps = Readonly<{ children?: ReactNode | undefined }>; // TODO: [P2] Complete this component. const SendBoxComposer = ({ children }: SendBoxComposerProps) => { - const [attachments] = useSendBoxAttachments(); + const [attachments] = useSendBoxHooks().useSendBoxAttachments(); const [connectivityStatus] = useConnectivityStatus(); const [error, setError] = useState(false); - const [sendBoxValue] = useSendBoxValue(); + const [sendBoxValue] = useSendBoxHooks().useSendBoxValue(); const apiSubmitSendBox = useSubmitSendBox(); const focus = useFocus(); const localize = useLocalizer(); diff --git a/packages/component/tsup.config.ts b/packages/component/tsup.config.ts index 1aeafe89d3..c0e1db2920 100644 --- a/packages/component/tsup.config.ts +++ b/packages/component/tsup.config.ts @@ -6,6 +6,10 @@ import { decoratorStyleContent as decoratorStyleContentPlaceholder } from './src const config: typeof baseConfig = { ...baseConfig, + esbuildOptions(options) { + options.define.WEBCHAT_PERF_CONTEXT = JSON.stringify('bit context'); + // options.define.WEBCHAT_PERF_CONTEXT = JSON.stringify('stable state'); + }, entry: { 'botframework-webchat-component': './src/index.ts', 'botframework-webchat-component.internal': './src/internal.ts', diff --git a/packages/core/src/actions/postActivity.ts b/packages/core/src/actions/postActivity.ts index fe9cde2f26..2dbba42e43 100644 --- a/packages/core/src/actions/postActivity.ts +++ b/packages/core/src/actions/postActivity.ts @@ -1,47 +1,41 @@ -import type { WebChatActivity } from '../types/WebChatActivity'; - -type PostActivityActionType = 'DIRECT_LINE/POST_ACTIVITY'; -type PostActivityFulfilledActionType = 'DIRECT_LINE/POST_ACTIVITY_FULFILLED'; -type PostActivityImpededActionType = 'DIRECT_LINE/POST_ACTIVITY_IMPEDED'; -type PostActivityPendingActionType = 'DIRECT_LINE/POST_ACTIVITY_PENDING'; -type PostActivityRejectedActionType = 'DIRECT_LINE/POST_ACTIVITY_REJECTED'; - -type PostActivityAction = { - meta: { method: string }; - payload: { activity: WebChatActivity }; - type: PostActivityActionType; -}; +import { custom, literal, object, pipe, readonly, string, type InferOutput } from 'valibot'; -type PostActivityFulfilledAction = { - meta: { clientActivityID: string; method: string }; - payload: { activity: WebChatActivity }; - type: PostActivityFulfilledActionType; -}; +import { type WebChatActivity } from '../types/WebChatActivity'; +import createMiddlewareActionSchemas from './private/createMiddlewareActionSchemas'; -type PostActivityImpededAction = { - meta: { clientActivityID: string; method: string }; - payload: { activity: WebChatActivity }; - type: PostActivityImpededActionType; -}; +const POST_ACTIVITY = 'DIRECT_LINE/POST_ACTIVITY'; -type PostActivityPendingAction = { - meta: { clientActivityID: string; method: string }; - payload: { activity: WebChatActivity }; - type: PostActivityPendingActionType; -}; +const postActivityActionSchema = pipe( + object({ + meta: pipe(object({ method: string() }), readonly()), + payload: pipe(object({ activity: custom(() => true) }), readonly()), + type: literal(POST_ACTIVITY) + }), + readonly() +); -type PostActivityRejectedAction = { - error: true; - meta: { clientActivityID: string; method: string }; - payload: Error; - type: PostActivityRejectedActionType; -}; +const middlewareActionSchemas = createMiddlewareActionSchemas( + POST_ACTIVITY, + ['FULFILLED', 'IMPEDED', 'PENDING'], + pipe(object({ activity: custom(() => true) }), readonly()), + pipe(object({ clientActivityID: string(), method: string() }), readonly()) +); + +const POST_ACTIVITY_FULFILLED = middlewareActionSchemas.FULFILLED.name; +const POST_ACTIVITY_IMPEDED = middlewareActionSchemas.IMPEDED.name; +const POST_ACTIVITY_PENDING = middlewareActionSchemas.PENDING.name; +const POST_ACTIVITY_REJECTED = middlewareActionSchemas.REJECTED.name; + +const postActivityFulfilledActionSchema = middlewareActionSchemas.FULFILLED.schema; +const postActivityImpededActionSchema = middlewareActionSchemas.IMPEDED.schema; +const postActivityPendingActionSchema = middlewareActionSchemas.PENDING.schema; +const postActivityRejectedActionSchema = middlewareActionSchemas.REJECTED.schema; -const POST_ACTIVITY: PostActivityActionType = 'DIRECT_LINE/POST_ACTIVITY'; -const POST_ACTIVITY_FULFILLED: PostActivityFulfilledActionType = `${POST_ACTIVITY}_FULFILLED`; -const POST_ACTIVITY_IMPEDED: PostActivityImpededActionType = `${POST_ACTIVITY}_IMPEDED`; -const POST_ACTIVITY_PENDING: PostActivityPendingActionType = `${POST_ACTIVITY}_PENDING`; -const POST_ACTIVITY_REJECTED: PostActivityRejectedActionType = `${POST_ACTIVITY}_REJECTED`; +type PostActivityAction = InferOutput; +type PostActivityFulfilledAction = InferOutput; +type PostActivityImpededAction = InferOutput; +type PostActivityPendingAction = InferOutput; +type PostActivityRejectedAction = InferOutput; function postActivity(activity: WebChatActivity, method = 'keyboard'): PostActivityAction { return { @@ -52,11 +46,20 @@ function postActivity(activity: WebChatActivity, method = 'keyboard'): PostActiv } export default postActivity; -export { POST_ACTIVITY, POST_ACTIVITY_FULFILLED, POST_ACTIVITY_IMPEDED, POST_ACTIVITY_PENDING, POST_ACTIVITY_REJECTED }; -export type { - PostActivityAction, - PostActivityFulfilledAction, - PostActivityImpededAction, - PostActivityPendingAction, - PostActivityRejectedAction +export { + POST_ACTIVITY, + POST_ACTIVITY_FULFILLED, + POST_ACTIVITY_IMPEDED, + POST_ACTIVITY_PENDING, + POST_ACTIVITY_REJECTED, + postActivityActionSchema, + postActivityFulfilledActionSchema, + postActivityImpededActionSchema, + postActivityPendingActionSchema, + postActivityRejectedActionSchema, + type PostActivityAction, + type PostActivityFulfilledAction, + type PostActivityImpededAction, + type PostActivityPendingAction, + type PostActivityRejectedAction }; diff --git a/packages/core/src/actions/private/createMiddlewareActionSchemas.ts b/packages/core/src/actions/private/createMiddlewareActionSchemas.ts new file mode 100644 index 0000000000..3a6aa976d6 --- /dev/null +++ b/packages/core/src/actions/private/createMiddlewareActionSchemas.ts @@ -0,0 +1,74 @@ +import { + array, + instance, + literal, + object, + parse, + picklist, + pipe, + readonly, + type BaseIssue, + type BaseSchema, + type InferOutput +} from 'valibot'; + +// No dangerous value such as: constructor, prototype, etc. +const allowedSuffixSchema = picklist(['FULFILLED', 'IMPEDED', 'PENDING']); + +type AllowedSuffix = InferOutput; + +const allowedSuffixesSchema = array(allowedSuffixSchema); + +export default function createMiddlewareActionSchemas< + const TName extends string, + const TPayloadSchema extends BaseSchema>, + const TMetaSchema extends BaseSchema>, + const TSuffix extends AllowedSuffix +>(prefix: TName, suffixes: readonly TSuffix[], payloadSchema: TPayloadSchema, metaSchema: TMetaSchema) { + const result: { + [K in TSuffix]: Readonly<{ + name: `${TName}_${K}`; + schema: BaseSchema< + unknown, + { + meta: InferOutput; + payload: InferOutput; + type: `${TName}_${K}`; + }, + BaseIssue + >; + }>; + } = {} as any; + + for (const suffix of parse(allowedSuffixesSchema, suffixes)) { + // We use allowlist to filter the suffix. + // eslint-disable-next-line security/detect-object-injection + result[suffix] = { + name: `${prefix}_${suffix}` as const, + schema: pipe( + object({ + meta: metaSchema, + payload: payloadSchema, + type: literal(`${prefix}_${suffix}`) + }), + readonly() + ) + }; + } + + return Object.freeze({ + ...result, + REJECTED: { + name: `${prefix}_REJECTED` as const, + schema: pipe( + object({ + error: literal(true), + meta: metaSchema, + payload: instance(Error), + type: literal(`${prefix}_REJECTED`) + }), + readonly() + ) + } + }); +} diff --git a/packages/core/src/actions/setSendBox.ts b/packages/core/src/actions/setSendBox.ts index 28de1a8ef3..a277038808 100644 --- a/packages/core/src/actions/setSendBox.ts +++ b/packages/core/src/actions/setSendBox.ts @@ -1,10 +1,22 @@ +import { literal, object, pipe, string, type InferOutput } from 'valibot'; + const SET_SEND_BOX = 'WEB_CHAT/SET_SEND_BOX'; -export default function setSendBox(text) { +const setSendBoxActionSchema = pipe( + object({ + payload: pipe(object({ text: string() })), + type: literal(SET_SEND_BOX) + }) +); + +type SetSendBoxAction = InferOutput; + +function setSendBox(text: string): SetSendBoxAction { return { - type: SET_SEND_BOX, - payload: { text } + payload: { text }, + type: SET_SEND_BOX }; } -export { SET_SEND_BOX }; +export default setSendBox; +export { SET_SEND_BOX, setSendBoxActionSchema, type SetSendBoxAction }; diff --git a/packages/core/src/actions/setSendBoxAttachments.ts b/packages/core/src/actions/setSendBoxAttachments.ts index 98f3b55eb9..6f0de98e7e 100644 --- a/packages/core/src/actions/setSendBoxAttachments.ts +++ b/packages/core/src/actions/setSendBoxAttachments.ts @@ -1,12 +1,25 @@ -import type { SendBoxAttachment } from '../types/SendBoxAttachment'; +import { array, literal, object, parse, pipe, readonly, type InferOutput } from 'valibot'; -const SET_SEND_BOX_ATTACHMENTS = 'WEB_CHAT/SET_SEND_BOX_ATTACHMENTS'; +import { sendBoxAttachmentSchema, type SendBoxAttachment } from '../types/SendBoxAttachment'; -export default function setSendBoxAttachments(attachments: readonly SendBoxAttachment[]) { - return { - type: SET_SEND_BOX_ATTACHMENTS, - payload: { attachments } - }; +const SET_SEND_BOX_ATTACHMENTS = 'WEB_CHAT/SET_SEND_BOX_ATTACHMENTS' as const; + +const setSendBoxAttachmentsActionSchema = pipe( + object({ + payload: pipe(array(sendBoxAttachmentSchema), readonly()), + type: literal(SET_SEND_BOX_ATTACHMENTS) + }), + readonly() +); + +type SetSendBoxAttachmentsAction = InferOutput; + +function setSendBoxAttachments(attachments: readonly SendBoxAttachment[]) { + return parse(setSendBoxAttachmentsActionSchema, { + payload: attachments, + type: SET_SEND_BOX_ATTACHMENTS + }); } -export { SET_SEND_BOX_ATTACHMENTS }; +export default setSendBoxAttachments; +export { SET_SEND_BOX_ATTACHMENTS, setSendBoxAttachmentsActionSchema, type SetSendBoxAttachmentsAction }; diff --git a/packages/core/src/createSagas.ts b/packages/core/src/createSagas.ts index 916b62598d..46c5631666 100644 --- a/packages/core/src/createSagas.ts +++ b/packages/core/src/createSagas.ts @@ -2,7 +2,6 @@ import { type Saga } from 'redux-saga'; import { fork } from 'redux-saga/effects'; import actionSinkSaga from './sagas/actionSinkSaga'; -import clearSuggestedActionsOnPostActivitySaga from './sagas/clearSuggestedActionsOnPostActivitySaga'; import connectionStatusToNotificationSaga from './sagas/connectionStatusToNotificationSaga'; import connectionStatusUpdateSaga from './sagas/connectionStatusUpdateSaga'; import connectSaga from './sagas/connectSaga'; @@ -35,7 +34,6 @@ export default function createSagas({ ponyfill }: CreateSagasOptions): Saga { // TODO: [P2] Since fork() silently catches all exceptions, we need to find a way to console.error them out. yield fork(actionSinkSaga); - yield fork(clearSuggestedActionsOnPostActivitySaga); yield fork(connectionStatusToNotificationSaga); yield fork(connectionStatusUpdateSaga); yield fork(connectSaga); diff --git a/packages/core/src/internal/actions/setRawState.ts b/packages/core/src/internal/actions/setRawState.ts index ea0892b7d4..25feee70c6 100644 --- a/packages/core/src/internal/actions/setRawState.ts +++ b/packages/core/src/internal/actions/setRawState.ts @@ -1,5 +1,6 @@ -import { literal, object, pipe, readonly, union, type InferOutput } from 'valibot'; +import { array, literal, object, pipe, readonly, string, union, type InferOutput } from 'valibot'; +import { sendBoxAttachmentSchema } from '../../types/SendBoxAttachment'; import { suggestedActionsStateSchema } from '../types/suggestedActions'; import { suggestedActionsOriginActivityStateSchema } from '../types/suggestedActionsOriginActivity'; @@ -8,6 +9,20 @@ const SET_RAW_STATE = 'WEB_CHAT_INTERNAL/SET_RAW_STATE' as const; const setRawStateActionSchema = pipe( object({ payload: union([ + pipe( + object({ + name: literal('sendBoxAttachments'), + state: pipe(array(sendBoxAttachmentSchema), readonly()) + }), + readonly() + ), + pipe( + object({ + name: literal('sendBoxValue'), + state: string() + }), + readonly() + ), pipe( object({ name: literal('suggestedActions'), @@ -31,6 +46,16 @@ const setRawStateActionSchema = pipe( type SetRawStateAction = InferOutput; // Due to limitation of TypeScript, we need to specify overloading functions. +export default function setRawState( + name: 'sendBoxAttachments', + state: (SetRawStateAction['payload'] & { name: typeof name })['state'] +): SetRawStateAction; + +export default function setRawState( + name: 'sendBoxValue', + state: (SetRawStateAction['payload'] & { name: typeof name })['state'] +): SetRawStateAction; + export default function setRawState( name: 'suggestedActions', state: (SetRawStateAction['payload'] & { name: typeof name })['state'] diff --git a/packages/core/src/internal/index.ts b/packages/core/src/internal/index.ts index ed1032d833..7016bae7a7 100644 --- a/packages/core/src/internal/index.ts +++ b/packages/core/src/internal/index.ts @@ -4,3 +4,10 @@ export { setRawStateActionSchema, type SetRawStateAction } from './actions/setRawState'; + +export { CONNECT_FULFILLING } from '../actions/connect'; +export { DISCONNECT_PENDING } from '../actions/disconnect'; +export { POST_ACTIVITY_PENDING, postActivityPendingActionSchema } from '../actions/postActivity'; +export { RECONNECT_FULFILLING, RECONNECT_PENDING } from '../actions/reconnect'; +export { SET_SEND_BOX, setSendBoxActionSchema } from '../actions/setSendBox'; +export { SET_SEND_BOX_ATTACHMENTS, setSendBoxAttachmentsActionSchema } from '../actions/setSendBoxAttachments'; diff --git a/packages/core/src/reducers/private/createRawReducer.ts b/packages/core/src/reducers/private/createRawReducer.ts new file mode 100644 index 0000000000..337abe9fb9 --- /dev/null +++ b/packages/core/src/reducers/private/createRawReducer.ts @@ -0,0 +1,27 @@ +import { type Action } from 'redux'; +import { safeParse } from 'valibot'; + +import { SET_RAW_STATE, setRawStateActionSchema, type SetRawStateAction } from '../../internal/actions/setRawState'; + +function createRawReducer(name: SetRawStateAction['payload']['name'], defaultState: TState) { + return (state: TState = defaultState, action: Action): TState => { + if (action.type === SET_RAW_STATE) { + const result = safeParse(setRawStateActionSchema, action); + + if (result.success) { + if (result.output.payload.name === name) { + return result.output.payload.state as TState; + } + } else { + console.warn( + `botframework-webchat: Received action of type "${action.type}" but its content is not valid, ignoring.`, + { result } + ); + } + } + + return state; + }; +} + +export default createRawReducer; diff --git a/packages/core/src/reducers/sendBoxAttachments.ts b/packages/core/src/reducers/sendBoxAttachments.ts index 7a8f929532..f978201e62 100644 --- a/packages/core/src/reducers/sendBoxAttachments.ts +++ b/packages/core/src/reducers/sendBoxAttachments.ts @@ -1,17 +1,6 @@ -import { SET_SEND_BOX_ATTACHMENTS } from '../actions/setSendBoxAttachments'; -import type { SendBoxAttachment } from '../types/SendBoxAttachment'; +import { type SendBoxAttachment } from '../types/SendBoxAttachment'; +import createRawReducer from './private/createRawReducer'; -const DEFAULT_STATE: readonly SendBoxAttachment[] = Object.freeze([]); +const sendBoxAttachments = createRawReducer('sendBoxAttachments', Object.freeze([])); -export default function sendBoxAttachments(state = DEFAULT_STATE, { payload, type }): readonly SendBoxAttachment[] { - switch (type) { - case SET_SEND_BOX_ATTACHMENTS: - state = payload.attachments; - break; - - default: - break; - } - - return state; -} +export default sendBoxAttachments; diff --git a/packages/core/src/reducers/sendBoxValue.js b/packages/core/src/reducers/sendBoxValue.js deleted file mode 100644 index f65f8f361b..0000000000 --- a/packages/core/src/reducers/sendBoxValue.js +++ /dev/null @@ -1,16 +0,0 @@ -import { SET_SEND_BOX } from '../actions/setSendBox'; - -const DEFAULT_STATE = ''; - -export default function sendBoxValue(state = DEFAULT_STATE, { payload, type }) { - switch (type) { - case SET_SEND_BOX: - state = payload.text; - break; - - default: - break; - } - - return state; -} diff --git a/packages/core/src/reducers/sendBoxValue.ts b/packages/core/src/reducers/sendBoxValue.ts new file mode 100644 index 0000000000..61afd6fcdc --- /dev/null +++ b/packages/core/src/reducers/sendBoxValue.ts @@ -0,0 +1,5 @@ +import createRawReducer from './private/createRawReducer'; + +const sendBoxValue = createRawReducer('sendBoxValue', ''); + +export default sendBoxValue; diff --git a/packages/core/src/reducers/suggestedActions.ts b/packages/core/src/reducers/suggestedActions.ts index 1b65a140fe..52c1fef516 100644 --- a/packages/core/src/reducers/suggestedActions.ts +++ b/packages/core/src/reducers/suggestedActions.ts @@ -1,21 +1,6 @@ -import { type Action } from 'redux'; -import { parse } from 'valibot'; - -import { SET_RAW_STATE, setRawStateActionSchema } from '../internal/actions/setRawState'; import { type SuggestedActionsState } from '../internal/types/suggestedActions'; +import createRawReducer from './private/createRawReducer'; -const DEFAULT_STATE: SuggestedActionsState = Object.freeze([]); - -function suggestedActions(state: SuggestedActionsState = DEFAULT_STATE, action: Action): SuggestedActionsState { - if (action.type === SET_RAW_STATE) { - const parsedAction = parse(setRawStateActionSchema, action); - - if (parsedAction.payload.name === 'suggestedActions') { - ({ state } = parsedAction.payload); - } - } - - return state; -} +const suggestedActions = createRawReducer('suggestedActions', Object.freeze([])); export default suggestedActions; diff --git a/packages/core/src/reducers/suggestedActionsOriginActivity.ts b/packages/core/src/reducers/suggestedActionsOriginActivity.ts index 48b0873744..5e5fe9e946 100644 --- a/packages/core/src/reducers/suggestedActionsOriginActivity.ts +++ b/packages/core/src/reducers/suggestedActionsOriginActivity.ts @@ -1,24 +1,9 @@ -import { type Action } from 'redux'; -import { parse } from 'valibot'; - -import { SET_RAW_STATE, setRawStateActionSchema } from '../internal/actions/setRawState'; import { type SuggestedActionsOriginActivityState } from '../internal/types/suggestedActionsOriginActivity'; +import createRawReducer from './private/createRawReducer'; -const DEFAULT_STATE: SuggestedActionsOriginActivityState = Object.freeze({ activity: undefined }); - -function suggestedActionsOriginActivity( - state: SuggestedActionsOriginActivityState = DEFAULT_STATE, - action: Action -): SuggestedActionsOriginActivityState { - if (action.type === SET_RAW_STATE) { - const parsedAction = parse(setRawStateActionSchema, action); - - if (parsedAction.payload.name === 'suggestedActionsOriginActivity') { - ({ state } = parsedAction.payload); - } - } - - return state; -} +const suggestedActionsOriginActivity = createRawReducer( + 'suggestedActionsOriginActivity', + Object.freeze({ activity: undefined }) +); export default suggestedActionsOriginActivity; diff --git a/packages/core/src/sagas/clearSuggestedActionsOnPostActivitySaga.js b/packages/core/src/sagas/clearSuggestedActionsOnPostActivitySaga.js deleted file mode 100644 index 754dc96f7d..0000000000 --- a/packages/core/src/sagas/clearSuggestedActionsOnPostActivitySaga.js +++ /dev/null @@ -1,20 +0,0 @@ -import { put, takeEvery } from 'redux-saga/effects'; - -import { POST_ACTIVITY_PENDING } from '../actions/postActivity'; -import setSuggestedActions from '../actions/setSuggestedActions'; -import whileConnected from './effects/whileConnected'; - -function* clearSuggestedActions() { - yield put(setSuggestedActions()); -} - -function* clearSuggestedActionsOnPostActivity() { - yield takeEvery( - ({ payload, type }) => type === POST_ACTIVITY_PENDING && payload.activity.type === 'message', - clearSuggestedActions - ); -} - -export default function* clearSuggestedActionsOnPostActivitySaga() { - yield whileConnected(clearSuggestedActionsOnPostActivity); -} diff --git a/packages/core/src/sagas/postActivitySaga.ts b/packages/core/src/sagas/postActivitySaga.ts index 7bf8ab2e9b..bf16a6d853 100644 --- a/packages/core/src/sagas/postActivitySaga.ts +++ b/packages/core/src/sagas/postActivitySaga.ts @@ -1,36 +1,32 @@ import { all, call, cancelled, put, race, select, take, takeEvery } from 'redux-saga/effects'; -import { INCOMING_ACTIVITY } from '../actions/incomingActivity'; +import { INCOMING_ACTIVITY, type IncomingActivityAction } from '../actions/incomingActivity'; import { POST_ACTIVITY, POST_ACTIVITY_FULFILLED, POST_ACTIVITY_IMPEDED, POST_ACTIVITY_PENDING, - POST_ACTIVITY_REJECTED + POST_ACTIVITY_REJECTED, + type PostActivityAction, + type PostActivityFulfilledAction, + type PostActivityImpededAction, + type PostActivityPendingAction, + type PostActivityRejectedAction } from '../actions/postActivity'; -import dateToLocaleISOString from '../utils/dateToLocaleISOString'; -import deleteKey from '../utils/deleteKey'; import languageSelector from '../selectors/language'; -import observeOnce from './effects/observeOnce'; import sendTimeoutSelector from '../selectors/sendTimeout'; +import { type DirectLineActivity } from '../types/external/DirectLineActivity'; +import { type DirectLineJSBotConnection } from '../types/external/DirectLineJSBotConnection'; +import { type GlobalScopePonyfill } from '../types/GlobalScopePonyfill'; +import { type WebChatOutgoingActivity } from '../types/internal/WebChatOutgoingActivity'; +import { type WebChatActivity } from '../types/WebChatActivity'; +import dateToLocaleISOString from '../utils/dateToLocaleISOString'; +import deleteKey from '../utils/deleteKey'; import sleep from '../utils/sleep'; import uniqueID from '../utils/uniqueID'; +import observeOnce from './effects/observeOnce'; import whileConnected from './effects/whileConnected'; -import type { DirectLineActivity } from '../types/external/DirectLineActivity'; -import type { DirectLineJSBotConnection } from '../types/external/DirectLineJSBotConnection'; -import type { GlobalScopePonyfill } from '../types/GlobalScopePonyfill'; -import type { IncomingActivityAction } from '../actions/incomingActivity'; -import type { - PostActivityAction, - PostActivityFulfilledAction, - PostActivityImpededAction, - PostActivityPendingAction, - PostActivityRejectedAction -} from '../actions/postActivity'; -import type { WebChatActivity } from '../types/WebChatActivity'; -import type { WebChatOutgoingActivity } from '../types/internal/WebChatOutgoingActivity'; - // After 5 minutes, the saga will stop from listening for echo backs and consider the outgoing message as permanently undeliverable. // This value must be equals to or larger than the user-defined `styleOptions.sendTimeout`. const HARD_SEND_TIMEOUT = 300000; @@ -157,6 +153,17 @@ function* postActivity( payload: { activity: outgoingActivity } } as PostActivityImpededAction); + // redux-saga silenced the error thrown. + if (echoed) { + console.warn('botframework-webchat: Timed out while waiting for postActivity to return any values', { + activity: outgoingActivity + }); + } else { + console.warn('botframework-webchat: Timed out while waiting for outgoing message to echo back', { + activity: outgoingActivity + }); + } + yield call(sleep, HARD_SEND_TIMEOUT - sendTimeout, ponyfill); throw !echoed @@ -188,6 +195,7 @@ function* postActivity( } export default function* postActivitySaga(ponyfill: GlobalScopePonyfill) { + // TODO: If posting activity programmatically while disconnected, it should dispatch POST_ACTIVITY_REJECTED instead of silently failed. yield whileConnected(function* postActivityWhileConnected({ directLine, userID, diff --git a/packages/core/src/types/SendBoxAttachment.ts b/packages/core/src/types/SendBoxAttachment.ts index 2016177bb1..7550452791 100644 --- a/packages/core/src/types/SendBoxAttachment.ts +++ b/packages/core/src/types/SendBoxAttachment.ts @@ -1,4 +1,31 @@ -export type SendBoxAttachment = Readonly<{ - blob: Blob | File; - thumbnailURL?: URL; -}>; +import { + blob, + custom, + file, + instance, + object, + optional, + pipe, + readonly, + safeParse, + transform, + union, + type InferOutput +} from 'valibot'; + +const sendBoxAttachmentSchema = pipe( + object({ + blob: union([blob(), file()]), + thumbnailURL: optional( + pipe( + custom(value => safeParse(instance(URL), value).success), + transform(value => new URL(value)) + ) + ) + }), + readonly() +); + +type SendBoxAttachment = InferOutput; + +export { sendBoxAttachmentSchema, type SendBoxAttachment }; diff --git a/packages/fluent-theme/src/components/preChatActivity/StarterPromptsCardAction.tsx b/packages/fluent-theme/src/components/preChatActivity/StarterPromptsCardAction.tsx index 36ae191c9d..0087a14fc8 100644 --- a/packages/fluent-theme/src/components/preChatActivity/StarterPromptsCardAction.tsx +++ b/packages/fluent-theme/src/components/preChatActivity/StarterPromptsCardAction.tsx @@ -7,7 +7,7 @@ import { useStyles } from '../../styles/index.js'; import testIds from '../../testIds.js'; import styles from './StarterPromptsCardAction.module.css'; -const { useFocus, useRenderMarkdownAsHTML, useSendBoxValue, useUIState } = hooks; +const { useFocus, useRenderMarkdownAsHTML, useSendBoxHooks, useUIState } = hooks; const { MonochromeImageMasker } = Components; type Props = Readonly<{ @@ -16,7 +16,7 @@ type Props = Readonly<{ }>; const StarterPromptsCardAction = ({ className, messageBackAction }: Props) => { - const [_, setSendBoxValue] = useSendBoxValue(); + const [_, setSendBoxValue] = useSendBoxHooks().useSendBoxValue(); const [uiState] = useUIState(); const classNames = useStyles(styles); const focus = useFocus(); diff --git a/packages/fluent-theme/src/components/sendBox/SendBox.tsx b/packages/fluent-theme/src/components/sendBox/SendBox.tsx index 2e21031466..2cf84a4dd0 100644 --- a/packages/fluent-theme/src/components/sendBox/SendBox.tsx +++ b/packages/fluent-theme/src/components/sendBox/SendBox.tsx @@ -1,4 +1,4 @@ -import { hooks, Components, type SendBoxFocusOptions } from 'botframework-webchat-component'; +import { Components, hooks, type SendBoxFocusOptions } from 'botframework-webchat-component'; import cx from 'classnames'; import React, { memo, @@ -31,8 +31,7 @@ const { useLocalizer, useMakeThumbnail, useRegisterFocusSendBox, - useSendBoxAttachments, - useSendBoxValue, + useSendBoxHooks, useSendMessage, useStyleOptions, useUIState @@ -49,8 +48,8 @@ type Props = Readonly<{ function SendBox(props: Props) { const [{ hideTelephoneKeypadButton, hideUploadButton, maxMessageLength }] = useStyleOptions(); - const [attachments, setAttachments] = useSendBoxAttachments(); - const [globalMessage, setGlobalMessage] = useSendBoxValue(); + const [attachments, setAttachments] = useSendBoxHooks().useSendBoxAttachments(); + const [globalMessage, setGlobalMessage] = useSendBoxHooks().useSendBoxValue(); const [localMessage, setLocalMessage] = useState(''); const [telephoneKeypadShown] = useTelephoneKeypadShown(); const [uiState] = useUIState(); diff --git a/packages/react-context/.eslintrc.yml b/packages/react-context/.eslintrc.yml new file mode 100644 index 0000000000..1aa1350134 --- /dev/null +++ b/packages/react-context/.eslintrc.yml @@ -0,0 +1,6 @@ +extends: + - ../../.eslintrc.production.yml + +# This package is compatible with web browser. +env: + browser: true diff --git a/packages/react-context/.gitignore b/packages/react-context/.gitignore new file mode 100644 index 0000000000..18976f7c55 --- /dev/null +++ b/packages/react-context/.gitignore @@ -0,0 +1,3 @@ +/*.tgz +/dist/ +/node_modules/ diff --git a/packages/react-context/README.md b/packages/react-context/README.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/react-context/package.json b/packages/react-context/package.json new file mode 100644 index 0000000000..86c37043da --- /dev/null +++ b/packages/react-context/package.json @@ -0,0 +1,62 @@ +{ + "name": "botframework-webchat-react-context", + "version": "0.0.0-0", + "description": "The botframework-webchat react-context package", + "types": "./dist/botframework-webchat-react-context.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/botframework-webchat-react-context.d.mts", + "default": "./dist/botframework-webchat-react-context.mjs" + }, + "require": { + "types": "./dist/botframework-webchat-react-context.d.ts", + "default": "./dist/botframework-webchat-react-context.js" + } + } + }, + "author": "Microsoft Corporation", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/microsoft/BotFramework-WebChat.git" + }, + "bugs": { + "url": "https://github.com/microsoft/BotFramework-WebChat/issues" + }, + "files": [ + "./dist/**/*", + "./src/**/*" + ], + "homepage": "https://github.com/microsoft/BotFramework-WebChat/tree/main/packages/react-context#readme", + "private": true, + "scripts": { + "build": "npm run build:tsup", + "build:tsup": "tsup --config ./tsup.config.ts", + "bump": "npm run bump:prod && npm run bump:dev && (npm audit fix || exit 0)", + "bump:dev": "PACKAGES_TO_BUMP=$(cat package.json | jq -r '(.pinDependencies // {}) as $P | (.localDependencies // {} | keys) as $L | (.devDependencies // {}) | to_entries | map(select(.key as $K | $L | contains([$K]) | not)) | map(.key + \"@\" + ($P[.key] // [\"latest\"])[0]) | join(\" \")') && [ ! -z \"$PACKAGES_TO_BUMP\" ] && npm install $PACKAGES_TO_BUMP || true", + "bump:prod": "PACKAGES_TO_BUMP=$(cat package.json | jq -r '(.pinDependencies // {}) as $P | (.localDependencies // {} | keys) as $L | (.dependencies // {}) | to_entries | map(select(.key as $K | $L | contains([$K]) | not)) | map(.key + \"@\" + ($P[.key] // [\"latest\"])[0]) | join(\" \")') && [ ! -z \"$PACKAGES_TO_BUMP\" ] && npm install --save-exact $PACKAGES_TO_BUMP || true", + "eslint": "npm run precommit", + "postversion": "cat package.json | jq '.version as $V | (.localDependencies // {} | with_entries(select(.value == \"production\") | { key: .key, value: $V })) as $L1 | (.localDependencies // {} | with_entries(select(.value == \"development\") | { key: .key, value: $V })) as $L2 | ((.dependencies // {}) + $L1 | to_entries | sort_by(.key) | from_entries) as $D1 | ((.devDependencies // {}) + $L2 | to_entries | sort_by(.key) | from_entries) as $D2 | . + { dependencies: $D1, devDependencies: $D2 }' > package-temp.json && mv package-temp.json package.json", + "precommit": "npm run precommit:eslint -- src && npm run precommit:typecheck", + "precommit:eslint": "../../node_modules/.bin/eslint --report-unused-disable-directives --max-warnings 0", + "precommit:typecheck": "tsc --project ./src --emitDeclarationOnly false --esModuleInterop true --noEmit --pretty false", + "preversion": "cat package.json | jq '(.localDependencies // {} | to_entries | map([if .value == \"production\" then \"dependencies\" else \"devDependencies\" end, .key])) as $P | delpaths($P)' > package-temp.json && mv package-temp.json package.json", + "start": "concurrently --kill-others --prefix-colors \"auto\" \"npm:start:*\"", + "start:tsup": "npm run build:tsup -- --watch" + }, + "localDependencies": { + "botframework-webchat-react-valibot": "development" + }, + "devDependencies": { + "@tsconfig/strictest": "^2.0.5", + "@types/react": "^16.14.62", + "botframework-webchat-react-valibot": "^0.0.0-0" + }, + "dependencies": { + "valibot": "1.1.0" + }, + "peerDependencies": { + "react": ">= 16.8.6" + } +} diff --git a/packages/react-context/src/createBitContext.tsx b/packages/react-context/src/createBitContext.tsx new file mode 100644 index 0000000000..4e1e961505 --- /dev/null +++ b/packages/react-context/src/createBitContext.tsx @@ -0,0 +1,48 @@ +import { reactNode, validateProps } from 'botframework-webchat-react-valibot'; +import React, { + createContext, + memo, + useContext, + useState, + type ComponentType, + type Dispatch, + type SetStateAction +} from 'react'; +import { object, optional, pipe, readonly, type InferInput } from 'valibot'; + +type BitContextType = readonly [T, Dispatch>]; + +const bitComposerPropsSchema = pipe( + object({ + children: optional(reactNode()) + }), + readonly() +); + +type BitComposerProps = InferInput; + +export default function createBitContext(initialValue: T): Readonly<{ + Composer: ComponentType; + useState(): readonly [T, Dispatch>]; +}> { + const BitContext = createContext>( + new Proxy({} as BitContextType, { + get() { + throw new Error('botframework-webchat: This hook can only be used under its corresponding context.'); + } + }) + ); + + function BitComposer(props: BitComposerProps) { + const { children } = validateProps(bitComposerPropsSchema, props); + + const context = useState[0]>(() => initialValue); + + return {children}; + } + + return Object.freeze({ + Composer: memo(BitComposer), + useState: () => useContext(BitContext) + }); +} diff --git a/packages/react-context/src/index.ts b/packages/react-context/src/index.ts new file mode 100644 index 0000000000..1f5fc9aafa --- /dev/null +++ b/packages/react-context/src/index.ts @@ -0,0 +1,4 @@ +export { default as createBitContext } from './createBitContext'; +export { default as useReadonlyState } from './useReadonlyState'; +export { default as useRefWithInit } from './useRefWithInit'; +export { default as useStableStateHook } from './useStableStateHook'; diff --git a/packages/react-context/src/tsconfig.json b/packages/react-context/src/tsconfig.json new file mode 100644 index 0000000000..55c36e1fbe --- /dev/null +++ b/packages/react-context/src/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "jsx": "react", + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "skipLibCheck": true, + "target": "ESNext", + "types": [] + }, + "extends": "@tsconfig/strictest" +} diff --git a/packages/react-context/src/useReadonlyState.ts b/packages/react-context/src/useReadonlyState.ts new file mode 100644 index 0000000000..97c9e6481d --- /dev/null +++ b/packages/react-context/src/useReadonlyState.ts @@ -0,0 +1,7 @@ +import { useCallback, type Dispatch, type SetStateAction } from 'react'; + +export default function useReadonlyState(state: readonly [T, Dispatch>]): () => readonly [T] { + const [value] = state; + + return useCallback(() => Object.freeze([value]), [value]); +} diff --git a/packages/react-context/src/useRefWithInit.ts b/packages/react-context/src/useRefWithInit.ts new file mode 100644 index 0000000000..4d9a7ebfc1 --- /dev/null +++ b/packages/react-context/src/useRefWithInit.ts @@ -0,0 +1,14 @@ +import { useRef } from 'react'; + +// useRef() does not support init function like useMemo(). +export default function useRefWithInit(fn: () => T): ReturnType> { + const initializedRef = useRef(false); + const ref = useRef(); + + if (!initializedRef.current) { + ref.current = fn(); + initializedRef.current = true; + } + + return ref; +} diff --git a/packages/react-context/src/useStableStateHook.ts b/packages/react-context/src/useStableStateHook.ts new file mode 100644 index 0000000000..4bbb30b580 --- /dev/null +++ b/packages/react-context/src/useStableStateHook.ts @@ -0,0 +1,45 @@ +import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from 'react'; +import { createPropagation } from 'use-propagate'; +import { useRefFrom } from 'use-ref-from'; + +const useCreateHook = ( + setValue: Dispatch> | undefined, + useListen: (listener: (value: T) => void) => void, + valueRef: Readonly<{ current: T }> +) => { + const [propagatedValue, setPropagatedValue] = useState(valueRef.current); + + useListen(setPropagatedValue); + + return useMemo( + () => Object.freeze(setValue ? ([propagatedValue, setValue] as const) : ([propagatedValue] as const)), + [propagatedValue, setValue] + ); +}; + +export default function useStableStateHook(value: T): () => readonly [T]; + +export default function useStableStateHook( + value: T, + setValue: Dispatch> +): () => readonly [T, Dispatch>]; + +export default function useStableStateHook( + value: T, + setValue?: Dispatch> | undefined +): () => readonly [T, Dispatch>] | readonly [T] { + const [{ usePropagate, useListen }] = useState(() => createPropagation({ allowPropagateDuringRender: true })); + const valueRef = useRefFrom(value); + + const propagate = usePropagate(); + + useMemo(() => propagate(value), [propagate, value]); + + // Hack around ESLint rules without disabling react-hooks/rules-of-hooks. + const _useCreateHook = useCreateHook; + + return useCallback( + () => _useCreateHook(setValue, useListen, valueRef), + [_useCreateHook, setValue, useListen, valueRef] + ); +} diff --git a/packages/react-context/tsup.config.ts b/packages/react-context/tsup.config.ts new file mode 100644 index 0000000000..e5e2125e61 --- /dev/null +++ b/packages/react-context/tsup.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'tsup'; +import baseConfig from '../../tsup.base.config'; + +const config: typeof baseConfig = { + ...baseConfig, + entry: { + 'botframework-webchat-react-context': './src/index.ts' + } +}; + +export default defineConfig([ + { + ...config, + format: 'esm' + }, + { + ...config, + format: 'cjs', + target: [...config.target, 'es2019'] + } +]); diff --git a/packages/react-valibot/package.json b/packages/react-valibot/package.json index f18101d7b7..0b74e4713a 100644 --- a/packages/react-valibot/package.json +++ b/packages/react-valibot/package.json @@ -55,6 +55,5 @@ }, "peerDependencies": { "react": ">= 16.8.6" - }, - "main": "index.js" + } } diff --git a/packages/redux-store/src/ReduxStoreComposer.tsx b/packages/redux-store/src/ReduxStoreComposer.tsx index 053a7242e4..70cef1910a 100644 --- a/packages/redux-store/src/ReduxStoreComposer.tsx +++ b/packages/redux-store/src/ReduxStoreComposer.tsx @@ -3,7 +3,9 @@ import React, { memo } from 'react'; import { object, optional, pipe, readonly, type InferInput } from 'valibot'; import reduxStoreSchema from './private/reduxStoreSchema'; +import SendBoxComposer from './sendBox/SendBoxComposer'; import SuggestedActionsComposer from './suggestedActions/SuggestedActionsComposer'; +import WhileConnectedComposer from './whileConnected/WhileConnectedComposer'; const reduxStoreComposerPropsSchema = pipe( object({ @@ -23,7 +25,13 @@ type ReduxStoreComposerProps = InferInput; function ReduxStoreComposer(props: ReduxStoreComposerProps) { const { children, store } = validateProps(reduxStoreComposerPropsSchema, props); - return {children}; + return ( + + + {children} + + + ); } export default memo(ReduxStoreComposer); diff --git a/packages/redux-store/src/index.ts b/packages/redux-store/src/index.ts index 9ae00a060e..24113fcbd3 100644 --- a/packages/redux-store/src/index.ts +++ b/packages/redux-store/src/index.ts @@ -1,2 +1,4 @@ export { default as ReduxStoreComposer } from './ReduxStoreComposer'; +export { default as useSendBoxHooks } from './sendBox/useSendBoxHooks'; export { default as useSuggestedActionsHooks } from './suggestedActions/useSuggestedActionsHooks'; +export { default as useWhileConnectedHooks } from './whileConnected/useWhileConnectedHooks'; diff --git a/packages/redux-store/src/reduxActionSink/ReduxActionSinkComposer.tsx b/packages/redux-store/src/reduxActionSink/ReduxActionSinkComposer.tsx new file mode 100644 index 0000000000..de5a7e2ebd --- /dev/null +++ b/packages/redux-store/src/reduxActionSink/ReduxActionSinkComposer.tsx @@ -0,0 +1,48 @@ +import { reactNode, validateProps } from 'botframework-webchat-react-valibot'; +import React, { Fragment, memo, useCallback, useEffect } from 'react'; +import { type Action } from 'redux'; +import { useRefFrom } from 'use-ref-from'; +import { custom, function_, object, optional, pipe, readonly, safeParse, type InferInput } from 'valibot'; + +import reduxStoreSchema from '../private/reduxStoreSchema'; + +const reduxActionSinkComposerPropsSchema = pipe( + object({ + children: optional(reactNode()), + onAction: custom<(action: Action) => void>(value => safeParse(function_(), value).success), + store: reduxStoreSchema + }), + readonly() +); + +type ReduxActionSinkComposerProps = InferInput; + +type ReduxActionHandler = ReduxActionSinkComposerProps['onAction']; + +function ReduxActionSinkComposer(props: ReduxActionSinkComposerProps) { + const { + children, + onAction, + store: { dispatch } + } = validateProps(reduxActionSinkComposerPropsSchema, props); + + const onActionRef = useRefFrom(onAction); + + // #region Replicate to Redux store + const handleAction = useCallback(action => onActionRef.current?.(action), [onActionRef]); + + useEffect(() => { + dispatch({ payload: { sink: handleAction }, type: 'WEB_CHAT_INTERNAL/REGISTER_ACTION_SINK' }); + + return () => { + dispatch({ payload: { sink: handleAction }, type: 'WEB_CHAT_INTERNAL/UNREGISTER_ACTION_SINK' }); + }; + }, [dispatch, handleAction]); + // #endregion + + return {children}; +} + +export default memo(ReduxActionSinkComposer); + +export { reduxActionSinkComposerPropsSchema, type ReduxActionHandler, type ReduxActionSinkComposerProps }; diff --git a/packages/redux-store/src/sendBox/SendBoxComposer.tsx b/packages/redux-store/src/sendBox/SendBoxComposer.tsx new file mode 100644 index 0000000000..a56b00d113 --- /dev/null +++ b/packages/redux-store/src/sendBox/SendBoxComposer.tsx @@ -0,0 +1,99 @@ +import { type SendBoxAttachment } from 'botframework-webchat-core'; +import { + SET_SEND_BOX, + SET_SEND_BOX_ATTACHMENTS, + setRawState, + setSendBoxActionSchema, + setSendBoxAttachmentsActionSchema +} from 'botframework-webchat-core/internal'; +import { createBitContext } from 'botframework-webchat-react-context'; +import { reactNode, validateProps } from 'botframework-webchat-react-valibot'; +import React, { memo, useCallback, useMemo } from 'react'; +import { wrapWith } from 'react-wrap-with'; +import { object, optional, pipe, readonly, safeParse, type InferInput } from 'valibot'; + +import reduxStoreSchema from '../private/reduxStoreSchema'; +import ReduxActionSinkComposer, { type ReduxActionHandler } from '../reduxActionSink/ReduxActionSinkComposer'; +import SendBoxContext, { type SendBoxContextType } from './private/SendBoxContext'; + +const sendBoxComposerPropsSchema = pipe( + object({ + children: optional(reactNode()), + store: reduxStoreSchema + }), + readonly() +); + +type SendBoxComposerProps = InferInput; + +const { Composer: SendBoxAttachmentsComposer, useState: useSendBoxAttachments } = createBitContext< + readonly SendBoxAttachment[] +>(Object.freeze([])); + +const { Composer: SendBoxTextValueComposer, useState: useSendBoxTextValue } = createBitContext(''); + +function SendBoxComposer(props: SendBoxComposerProps) { + const { + children, + store, + store: { dispatch } + } = validateProps(sendBoxComposerPropsSchema, props); + + const [attachments, setAttachments] = useSendBoxAttachments(); + const [textValue, setTextValue] = useSendBoxTextValue(); + + // #region Replicate to Redux store + const handleAction = useCallback( + action => { + if (action.type === SET_SEND_BOX_ATTACHMENTS) { + const result = safeParse(setSendBoxAttachmentsActionSchema, action); + + if (result.success) { + setAttachments(result.output.payload); + } else { + console.warn( + `botframework-webchat: Received action of type "${action.type}" but its content is not valid, ignoring.`, + { result } + ); + } + } else if (action.type === SET_SEND_BOX) { + const result = safeParse(setSendBoxActionSchema, action); + + if (result.success) { + setTextValue(result.output.payload.text); + } else { + console.warn( + `botframework-webchat: Received action of type "${action.type}" but its content is not valid, ignoring.`, + { result } + ); + } + } + }, + [setAttachments] + ); + + useMemo(() => { + dispatch(setRawState('sendBoxAttachments', attachments)); + }, [attachments, dispatch]); + + useMemo(() => { + dispatch(setRawState('sendBoxValue', textValue)); + }, [dispatch, textValue]); + // #endregion + + const context = useMemo( + () => ({ + useSendBoxAttachments, + useSendBoxValue: useSendBoxTextValue + }), + [useSendBoxAttachments, useSendBoxTextValue] + ); + + return ( + + {children} + + ); +} + +export default wrapWith(SendBoxAttachmentsComposer)(wrapWith(SendBoxTextValueComposer)(memo(SendBoxComposer))); diff --git a/packages/redux-store/src/sendBox/private/SendBoxContext.ts b/packages/redux-store/src/sendBox/private/SendBoxContext.ts new file mode 100644 index 0000000000..b1fa3c69c0 --- /dev/null +++ b/packages/redux-store/src/sendBox/private/SendBoxContext.ts @@ -0,0 +1,21 @@ +import { type SendBoxAttachment } from 'botframework-webchat-core'; +import { createContext, type Dispatch, type SetStateAction } from 'react'; + +type SendBoxContextType = Readonly<{ + useSendBoxAttachments: () => readonly [ + readonly SendBoxAttachment[], + Dispatch> + ]; + useSendBoxValue: () => readonly [string, Dispatch>]; +}>; + +const SendBoxContext = createContext( + new Proxy({} as SendBoxContextType, { + get() { + throw new Error('botframework-webchat: This hook can only be used under '); + } + }) +); + +export default SendBoxContext; +export { type SendBoxContextType }; diff --git a/packages/redux-store/src/sendBox/useSendBoxHooks.ts b/packages/redux-store/src/sendBox/useSendBoxHooks.ts new file mode 100644 index 0000000000..1720d043c8 --- /dev/null +++ b/packages/redux-store/src/sendBox/useSendBoxHooks.ts @@ -0,0 +1,7 @@ +import { useContext } from 'react'; + +import SendBoxContext, { type SendBoxContextType } from './private/SendBoxContext'; + +export default function useSendBoxHooks(): SendBoxContextType { + return useContext(SendBoxContext); +} diff --git a/packages/redux-store/src/suggestedActions/SuggestedActionsComposer.tsx b/packages/redux-store/src/suggestedActions/SuggestedActionsComposer.tsx index f3bacb7b46..4b172a65ba 100644 --- a/packages/redux-store/src/suggestedActions/SuggestedActionsComposer.tsx +++ b/packages/redux-store/src/suggestedActions/SuggestedActionsComposer.tsx @@ -5,13 +5,22 @@ import { WebChatActivity, type DirectLineCardAction } from 'botframework-webchat-core'; -import { setRawState } from 'botframework-webchat-core/internal'; +import { + POST_ACTIVITY_PENDING, + postActivityPendingActionSchema, + setRawState +} from 'botframework-webchat-core/internal'; +import { createBitContext } from 'botframework-webchat-react-context'; import { reactNode, validateProps } from 'botframework-webchat-react-valibot'; -import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import React, { memo, useCallback, useMemo } from 'react'; +import { wrapWith } from 'react-wrap-with'; import { type Action } from 'redux'; +import { useRefFrom } from 'use-ref-from'; import { object, optional, pipe, readonly, safeParse, type InferInput } from 'valibot'; import reduxStoreSchema from '../private/reduxStoreSchema'; +import ReduxActionSinkComposer from '../reduxActionSink/ReduxActionSinkComposer'; +import useWhileConnectedHooks from '../whileConnected/useWhileConnectedHooks'; import SuggestedActionsContext, { type SuggestedActionsContextType } from './private/SuggestedActionsContext'; const suggestedActionsComposerPropsSchema = pipe( @@ -24,24 +33,36 @@ const suggestedActionsComposerPropsSchema = pipe( type SuggestedActionsComposerProps = InferInput; +const { Composer: OriginActivityComposer, useState: useOriginActivity } = createBitContext( + undefined +); + +const { Composer: SuggestedActionsActivityComposer, useState: useSuggestedActionsFromBit } = createBitContext< + readonly DirectLineCardAction[] +>(Object.freeze([])); + const EMPTY_ARRAY = Object.freeze([]); function SuggestedActionsComposer(props: SuggestedActionsComposerProps) { const { children, + store, store: { dispatch } } = validateProps(suggestedActionsComposerPropsSchema, props); - const [originActivity, setOriginActivity] = useState(); - const [suggestedActions, setSuggestedActionsRaw] = useState(EMPTY_ARRAY); + const [connectionDetails] = useWhileConnectedHooks().useConnectionDetails(); + const [originActivity, setOriginActivity] = useOriginActivity(); + const [suggestedActions, setSuggestedActionsRaw] = useSuggestedActionsFromBit(); const setSuggestedActions = useCallback( suggestedActions => { setOriginActivity(undefined); setSuggestedActionsRaw(suggestedActions); }, - [setSuggestedActionsRaw] + [setOriginActivity, setSuggestedActionsRaw] ); + const connectionDetailsRef = useRefFrom(connectionDetails); + // #region Replicate to Redux store const handleAction = useCallback( (action: Action) => { @@ -58,6 +79,27 @@ function SuggestedActionsComposer(props: SuggestedActionsComposerProps) { setOriginActivity(originActivity); setSuggestedActionsRaw(Object.freeze(Array.from(suggestedActions))); + } else { + console.warn( + `botframework-webchat: Received action of type "${action.type}" but its content is not valid, ignoring.`, + { result } + ); + } + } else if (action.type === POST_ACTIVITY_PENDING) { + // TODO: This catcher has no alternatives in React hook, that means, once we remove Redux store, this would stop working. + // TODO: Add test for "not connected, should not clear suggested actions." + if (connectionDetailsRef.current) { + const result = safeParse(postActivityPendingActionSchema, action); + + if (result.success) { + setOriginActivity(undefined); + setSuggestedActionsRaw(EMPTY_ARRAY); + } else { + console.warn( + `botframework-webchat: Received action of type "${action.type}" but its content is not valid, ignoring.`, + { result } + ); + } } } }, @@ -69,14 +111,6 @@ function SuggestedActionsComposer(props: SuggestedActionsComposerProps) { () => dispatch(setRawState('suggestedActionsOriginActivity', { activity: originActivity })), [dispatch, originActivity] ); - - useEffect(() => { - dispatch({ payload: { sink: handleAction }, type: 'WEB_CHAT_INTERNAL/REGISTER_ACTION_SINK' }); - - return () => { - dispatch({ payload: { sink: handleAction }, type: 'WEB_CHAT_INTERNAL/UNREGISTER_ACTION_SINK' }); - }; - }, [dispatch, handleAction]); // #endregion const useSuggestedActions = useCallback( @@ -86,8 +120,15 @@ function SuggestedActionsComposer(props: SuggestedActionsComposerProps) { const context = useMemo(() => ({ useSuggestedActions }), [useSuggestedActions]); - return {children}; + return ( + + {children} + + ); } -export default memo(SuggestedActionsComposer); +export default wrapWith(SuggestedActionsActivityComposer)( + wrapWith(OriginActivityComposer)(memo(SuggestedActionsComposer)) +); + export { suggestedActionsComposerPropsSchema, type SuggestedActionsComposerProps }; diff --git a/packages/redux-store/src/whileConnected/ConnectionDetails.ts b/packages/redux-store/src/whileConnected/ConnectionDetails.ts new file mode 100644 index 0000000000..5a70c2fd67 --- /dev/null +++ b/packages/redux-store/src/whileConnected/ConnectionDetails.ts @@ -0,0 +1,14 @@ +import { type DirectLineJSBotConnection } from 'botframework-webchat-core'; +import { type InferOutput, custom, object, pipe, string, undefinedable } from 'valibot'; + +const connectionDetailsSchema = pipe( + object({ + directLine: custom(() => true), + userId: undefinedable(string()), + username: undefinedable(string()) + }) +); + +type ConnectionDetails = InferOutput; + +export { connectionDetailsSchema, type ConnectionDetails }; diff --git a/packages/redux-store/src/whileConnected/WhileConnectedComposer.tsx b/packages/redux-store/src/whileConnected/WhileConnectedComposer.tsx new file mode 100644 index 0000000000..94c457ee13 --- /dev/null +++ b/packages/redux-store/src/whileConnected/WhileConnectedComposer.tsx @@ -0,0 +1,88 @@ +import { + CONNECT_FULFILLING, + DISCONNECT_PENDING, + RECONNECT_FULFILLING, + RECONNECT_PENDING +} from 'botframework-webchat-core/internal'; +import { createBitContext, useReadonlyState } from 'botframework-webchat-react-context'; +import { reactNode, validateProps } from 'botframework-webchat-react-valibot'; +import React, { memo, useCallback, useMemo } from 'react'; +import { wrapWith } from 'react-wrap-with'; +import { type Action } from 'redux'; +import { useRefFrom } from 'use-ref-from'; +import { object, optional, pipe, readonly, safeParse, type InferInput } from 'valibot'; + +import reduxStoreSchema from '../private/reduxStoreSchema'; +import ReduxActionSinkComposer from '../reduxActionSink/ReduxActionSinkComposer'; +import { connectionDetailsSchema, type ConnectionDetails } from './ConnectionDetails'; +import WhileConnectedContext, { type WhileConnectedContextType } from './private/WhileConnectedContext'; + +const whileConnectedComposerPropsSchema = pipe( + object({ + children: optional(reactNode()), + store: reduxStoreSchema + }), + readonly() +); + +type WhileConnectedComposerProps = InferInput; + +const { Composer: ConnectionDetailsComposer, useState: useConnectionDetailsFromBit } = createBitContext< + ConnectionDetails | undefined +>(undefined); + +function WhileConnectedComposer(props: WhileConnectedComposerProps) { + const { children, store } = validateProps(whileConnectedComposerPropsSchema, props); + + const connectionDetailsState = useConnectionDetailsFromBit(); + + const [connectionDetails, setConnectionDetails] = connectionDetailsState; + + const connectionDetailsRef = useRefFrom(connectionDetails); + const useConnectionDetails = useReadonlyState(connectionDetailsState); + + // #region Replicate to Redux store + const handleAction = useCallback<(action: Action) => void>( + action => { + if (connectionDetailsRef.current) { + if (action.type === DISCONNECT_PENDING || action.type === RECONNECT_PENDING) { + setConnectionDetails(undefined); + } + } else { + if (action.type === CONNECT_FULFILLING || action.type === RECONNECT_FULFILLING) { + const result = safeParse(connectionDetailsSchema, { + // TODO: Add valibot to underlying action. + directLine: (action as any).payload.directLine, + userId: (action as any).meta.userId, + username: (action as any).meta.username + }); + + if (result.success) { + setConnectionDetails(result.output); + } else { + console.warn( + `botframework-webchat: Received action of type "${action.type}" but its content is not valid, ignoring.`, + { result } + ); + } + } + } + }, + [connectionDetailsRef, setConnectionDetails] + ); + // #endregion + + const context = useMemo( + () => Object.freeze({ useConnectionDetails }), + [useConnectionDetails] + ); + + return ( + + {children} + + ); +} + +export default wrapWith(ConnectionDetailsComposer)(memo(WhileConnectedComposer)); +export { whileConnectedComposerPropsSchema, type WhileConnectedComposerProps }; diff --git a/packages/redux-store/src/whileConnected/private/WhileConnectedContext.ts b/packages/redux-store/src/whileConnected/private/WhileConnectedContext.ts new file mode 100644 index 0000000000..de8d471d1c --- /dev/null +++ b/packages/redux-store/src/whileConnected/private/WhileConnectedContext.ts @@ -0,0 +1,18 @@ +import { createContext } from 'react'; + +import { type ConnectionDetails } from '../ConnectionDetails'; + +type WhileConnectedContextType = Readonly<{ + useConnectionDetails(): readonly [ConnectionDetails | undefined]; +}>; + +const WhileConnectedContext = createContext( + new Proxy({} as WhileConnectedContextType, { + get() { + throw new Error('botframework-webchat: This hook can only be used under '); + } + }) +); + +export default WhileConnectedContext; +export { type WhileConnectedContextType }; diff --git a/packages/redux-store/src/whileConnected/useWhileConnectedHooks.ts b/packages/redux-store/src/whileConnected/useWhileConnectedHooks.ts new file mode 100644 index 0000000000..f71fb4105e --- /dev/null +++ b/packages/redux-store/src/whileConnected/useWhileConnectedHooks.ts @@ -0,0 +1,7 @@ +import { useContext } from 'react'; + +import WhileConnectedContext, { type WhileConnectedContextType } from './private/WhileConnectedContext'; + +export default function useWhileConnectedHooks(): WhileConnectedContextType { + return useContext(WhileConnectedContext); +}