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);
+}