From 9fe19b6f83727d05b71c2f7d94afcacee1c6d1fd Mon Sep 17 00:00:00 2001 From: William Wong Date: Fri, 30 May 2025 09:51:46 +0000 Subject: [PATCH 01/38] Initial commit of state context --- .../Text/private/ActivityCopyButton.tsx | 30 +++----- .../ClipboardWritePermissionComposer.tsx | 59 ++++++++++++++++ .../private/createStateContextWithHook.tsx | 70 +++++++++++++++++++ 3 files changed, 138 insertions(+), 21 deletions(-) create mode 100644 packages/component/src/providers/ClipboardWritePermission/ClipboardWritePermissionComposer.tsx create mode 100644 packages/component/src/providers/ClipboardWritePermission/private/createStateContextWithHook.tsx 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/providers/ClipboardWritePermission/ClipboardWritePermissionComposer.tsx b/packages/component/src/providers/ClipboardWritePermission/ClipboardWritePermissionComposer.tsx new file mode 100644 index 0000000000..4bc317bcca --- /dev/null +++ b/packages/component/src/providers/ClipboardWritePermission/ClipboardWritePermissionComposer.tsx @@ -0,0 +1,59 @@ +// import { reactNode, validateProps } from 'botframework-webchat-react-valibot'; +import { validateProps } from 'botframework-webchat-api/internal'; +import React, { Fragment, memo, useCallback, useEffect, useMemo } from 'react'; +import { wrapWith } from 'react-wrap-with'; +import { object, optional, pipe, readonly, type InferInput } from 'valibot'; + +import reactNode from '../../types/internal/reactNode'; +import createStateContextWithHook from './private/createStateContextWithHook'; + +const clipboardWritePermissionComposerPropsSchema = pipe( + object({ + children: optional(reactNode()) + }), + readonly() +); + +type ClipboardWritePermissionComposerProps = InferInput; + +const { Composer: PermissionGrantedComposer, useValue: useRawPermissionGranted } = + createStateContextWithHook(false); + +function useClipboardWritePermissionHooks(): Readonly<{ + usePermissionGranted: () => readonly [boolean]; +}> { + const [permissionGranted] = useRawPermissionGranted(); + + const usePermissionGranted = useCallback(() => Object.freeze([permissionGranted] as const), [permissionGranted]); + + const hooks = useMemo(() => Object.freeze({ usePermissionGranted }), [usePermissionGranted]); + + return hooks; +} + +function ClipboardWritePermissionComposer_(props: ClipboardWritePermissionComposerProps) { + const { children } = validateProps(clipboardWritePermissionComposerPropsSchema, props); + + const [_, setPermissionGranted] = useRawPermissionGranted(); + + 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 {children}; +} + +const ClipboardWritePermissionComposer = wrapWith(PermissionGrantedComposer)(memo(ClipboardWritePermissionComposer_)); + +export default memo(ClipboardWritePermissionComposer); +export { useClipboardWritePermissionHooks }; diff --git a/packages/component/src/providers/ClipboardWritePermission/private/createStateContextWithHook.tsx b/packages/component/src/providers/ClipboardWritePermission/private/createStateContextWithHook.tsx new file mode 100644 index 0000000000..d1efd3a339 --- /dev/null +++ b/packages/component/src/providers/ClipboardWritePermission/private/createStateContextWithHook.tsx @@ -0,0 +1,70 @@ +// import { reactNode, validateProps } from 'botframework-webchat-react-valibot'; +import { validateProps } from 'botframework-webchat-api/internal'; +import React, { + createContext, + memo, + useContext, + useMemo, + useState, + type ComponentType, + type Dispatch, + type ReactNode, + type SetStateAction +} from 'react'; +import { object, optional, pipe, readonly, type InferInput } from 'valibot'; +import reactNode from '../../../types/internal/reactNode'; + +type GenericContextType = Readonly<{ + valueState: readonly [T, Dispatch>]; +}>; + +type GenericComposerProps = Readonly<{ + children?: ReactNode | undefined; + defaultValue: T; +}>; + +export default function createStateContextWithHook(defaultValue: T): Readonly<{ + Composer: ComponentType>; + useValue(): readonly [T, Dispatch>]; + '~types': { + props: GenericComposerProps; + }; +}> { + type ContextType = GenericContextType; + + const Context = createContext( + new Proxy({} as ContextType, { + get() { + throw new Error('botframework-webchat: This hook can only be used under its corresponding context.'); + } + }) + ); + + const composerPropsSchema = pipe( + object({ + children: optional(reactNode()) + }), + readonly() + ); + + type ComposerProps = InferInput; + + function Composer(props: ComposerProps) { + // const { children, defaultValue } = validateProps(composerPropsSchema, props); + const { children } = validateProps(composerPropsSchema, props); + + const valueState = useState(() => defaultValue as any); + + const context = useMemo(() => Object.freeze({ valueState }), [valueState]); + + return {children}; + } + + return Object.freeze({ + Composer: memo(Composer), + useValue: () => useContext(Context).valueState, + '~types': { + props: {} as any + } + }); +} From 41b9c0ca857a6d3e3b706f84669f29748738c416 Mon Sep 17 00:00:00 2001 From: William Wong Date: Fri, 30 May 2025 09:55:15 +0000 Subject: [PATCH 02/38] Clean up --- .../ClipboardWritePermissionComposer.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/component/src/providers/ClipboardWritePermission/ClipboardWritePermissionComposer.tsx b/packages/component/src/providers/ClipboardWritePermission/ClipboardWritePermissionComposer.tsx index 4bc317bcca..071e29b931 100644 --- a/packages/component/src/providers/ClipboardWritePermission/ClipboardWritePermissionComposer.tsx +++ b/packages/component/src/providers/ClipboardWritePermission/ClipboardWritePermissionComposer.tsx @@ -26,9 +26,7 @@ function useClipboardWritePermissionHooks(): Readonly<{ const usePermissionGranted = useCallback(() => Object.freeze([permissionGranted] as const), [permissionGranted]); - const hooks = useMemo(() => Object.freeze({ usePermissionGranted }), [usePermissionGranted]); - - return hooks; + return useMemo(() => Object.freeze({ usePermissionGranted }), [usePermissionGranted]); } function ClipboardWritePermissionComposer_(props: ClipboardWritePermissionComposerProps) { From 70c19a171ba9248c79ad2b01094821f155fcc7ab Mon Sep 17 00:00:00 2001 From: William Wong Date: Fri, 30 May 2025 11:04:34 +0000 Subject: [PATCH 03/38] Add stable state hook --- .../Text/private/ActivityCopyButton.tsx | 2 +- .../ClipboardWritePermissionComposer.tsx | 65 +++++++++++++++++++ .../private/useStableStateHook.ts | 44 +++++++++++++ .../ClipboardWritePermissionComposer.tsx | 0 .../private/createStateContextWithHook.tsx | 0 5 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 packages/component/src/providers/ClipboardWritePermissionWithStable/ClipboardWritePermissionComposer.tsx create mode 100644 packages/component/src/providers/ClipboardWritePermissionWithStable/private/useStableStateHook.ts rename packages/component/src/providers/{ClipboardWritePermission => ClipboardWritePermissionWithStateContext}/ClipboardWritePermissionComposer.tsx (100%) rename packages/component/src/providers/{ClipboardWritePermission => ClipboardWritePermissionWithStateContext}/private/createStateContextWithHook.tsx (100%) diff --git a/packages/component/src/Attachment/Text/private/ActivityCopyButton.tsx b/packages/component/src/Attachment/Text/private/ActivityCopyButton.tsx index dc9bed5aec..5aa02fcf02 100644 --- a/packages/component/src/Attachment/Text/private/ActivityCopyButton.tsx +++ b/packages/component/src/Attachment/Text/private/ActivityCopyButton.tsx @@ -8,7 +8,7 @@ import { instance, nullable, object, optional, pipe, readonly, string, type Infe import useStyleSet from '../../../hooks/useStyleSet'; import ClipboardWritePermissionComposer, { useClipboardWritePermissionHooks -} from '../../../providers/ClipboardWritePermission/ClipboardWritePermissionComposer'; +} from '../../../providers/ClipboardWritePermissionWithStable/ClipboardWritePermissionComposer'; import { useQueueStaticElement } from '../../../providers/LiveRegionTwin'; import refObject from '../../../types/internal/refObject'; import ActivityButton from './ActivityButton'; diff --git a/packages/component/src/providers/ClipboardWritePermissionWithStable/ClipboardWritePermissionComposer.tsx b/packages/component/src/providers/ClipboardWritePermissionWithStable/ClipboardWritePermissionComposer.tsx new file mode 100644 index 0000000000..1110bf40e6 --- /dev/null +++ b/packages/component/src/providers/ClipboardWritePermissionWithStable/ClipboardWritePermissionComposer.tsx @@ -0,0 +1,65 @@ +// import { reactNode, validateProps } from 'botframework-webchat-react-valibot'; +import { validateProps } from 'botframework-webchat-api/internal'; +import React, { createContext, memo, useContext, useEffect, useMemo, useState } from 'react'; +import { object, optional, pipe, readonly, type InferInput } from 'valibot'; + +import reactNode from '../../types/internal/reactNode'; +import useStableStateHook from './private/useStableStateHook'; + +const clipboardWritePermissionComposerPropsSchema = pipe( + object({ + children: optional(reactNode()) + }), + readonly() +); + +type ClipboardWritePermissionComposerProps = InferInput; + +type ClipboardWritePermissionContextType = Readonly<{ + usePermissionGranted: () => readonly [boolean]; +}>; + +const ClipboardWritePermissionContext = createContext({} as any); + +function ClipboardWritePermissionComposer(props: ClipboardWritePermissionComposerProps) { + const { children } = validateProps(clipboardWritePermissionComposerPropsSchema, props); + + const [permissionGranted, setPermissionGranted] = useState(false); + + const usePermissionGranted = useStableStateHook(permissionGranted); + + const context = useMemo( + () => + Object.freeze({ + usePermissionGranted + }), + [usePermissionGranted] + ); + + 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 ( + {children} + ); +} + +function useClipboardWritePermissionHooks(): Readonly<{ + usePermissionGranted(): readonly [boolean]; +}> { + return useContext(ClipboardWritePermissionContext); +} + +export default memo(ClipboardWritePermissionComposer); +export { useClipboardWritePermissionHooks }; diff --git a/packages/component/src/providers/ClipboardWritePermissionWithStable/private/useStableStateHook.ts b/packages/component/src/providers/ClipboardWritePermissionWithStable/private/useStableStateHook.ts new file mode 100644 index 0000000000..ae9fe81778 --- /dev/null +++ b/packages/component/src/providers/ClipboardWritePermissionWithStable/private/useStableStateHook.ts @@ -0,0 +1,44 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from 'react'; +import { createPropagation } from 'use-propagate'; +import { useRefFrom } from 'use-ref-from'; + +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 propagationRef = useRef>>(); + const valueRef = useRefFrom(value); + + if (!propagationRef.current) { + propagationRef.current = createPropagation(); + } + + const { + current: { usePropagate, useListen } + } = propagationRef; + + const propagate = usePropagate(); + + useEffect(() => propagate(value), [propagate, value]); + + const useHook = () => { + const [propagatedValue, setPropagatedValue] = useState(valueRef.current); + + useListen(setPropagatedValue); + + return useMemo( + () => Object.freeze(setValue ? ([propagatedValue, setValue] as const) : ([propagatedValue] as const)), + // eslint-disable-next-line react-hooks/exhaustive-deps + [propagatedValue, setValue] + ); + }; + + return useCallback(useHook, [useListen, setValue, valueRef]); +} diff --git a/packages/component/src/providers/ClipboardWritePermission/ClipboardWritePermissionComposer.tsx b/packages/component/src/providers/ClipboardWritePermissionWithStateContext/ClipboardWritePermissionComposer.tsx similarity index 100% rename from packages/component/src/providers/ClipboardWritePermission/ClipboardWritePermissionComposer.tsx rename to packages/component/src/providers/ClipboardWritePermissionWithStateContext/ClipboardWritePermissionComposer.tsx diff --git a/packages/component/src/providers/ClipboardWritePermission/private/createStateContextWithHook.tsx b/packages/component/src/providers/ClipboardWritePermissionWithStateContext/private/createStateContextWithHook.tsx similarity index 100% rename from packages/component/src/providers/ClipboardWritePermission/private/createStateContextWithHook.tsx rename to packages/component/src/providers/ClipboardWritePermissionWithStateContext/private/createStateContextWithHook.tsx From 48fd313b69970442563d2d078abbfdfc0223fbab Mon Sep 17 00:00:00 2001 From: William Wong Date: Fri, 30 May 2025 11:23:57 +0000 Subject: [PATCH 04/38] Add useRefWithInit --- .../private/useRefWithInit.ts | 14 ++++++++++++++ .../private/useStableStateHook.ts | 11 +++++------ 2 files changed, 19 insertions(+), 6 deletions(-) create mode 100644 packages/component/src/providers/ClipboardWritePermissionWithStable/private/useRefWithInit.ts diff --git a/packages/component/src/providers/ClipboardWritePermissionWithStable/private/useRefWithInit.ts b/packages/component/src/providers/ClipboardWritePermissionWithStable/private/useRefWithInit.ts new file mode 100644 index 0000000000..4d9a7ebfc1 --- /dev/null +++ b/packages/component/src/providers/ClipboardWritePermissionWithStable/private/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/component/src/providers/ClipboardWritePermissionWithStable/private/useStableStateHook.ts b/packages/component/src/providers/ClipboardWritePermissionWithStable/private/useStableStateHook.ts index ae9fe81778..e52e60775a 100644 --- a/packages/component/src/providers/ClipboardWritePermissionWithStable/private/useStableStateHook.ts +++ b/packages/component/src/providers/ClipboardWritePermissionWithStable/private/useStableStateHook.ts @@ -1,7 +1,9 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from 'react'; +import { useCallback, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react'; import { createPropagation } from 'use-propagate'; import { useRefFrom } from 'use-ref-from'; +import useRefWithInit from './useRefWithInit'; + export default function useStableStateHook(value: T): () => readonly [T]; export default function useStableStateHook( @@ -13,13 +15,9 @@ export default function useStableStateHook( value: T, setValue?: Dispatch> | undefined ): () => readonly [T, Dispatch>] | readonly [T] { - const propagationRef = useRef>>(); + const propagationRef = useRefWithInit>>(() => createPropagation()); const valueRef = useRefFrom(value); - if (!propagationRef.current) { - propagationRef.current = createPropagation(); - } - const { current: { usePropagate, useListen } } = propagationRef; @@ -28,6 +26,7 @@ export default function useStableStateHook( useEffect(() => propagate(value), [propagate, value]); + // One-off variable to hack around ESLint rules without disabling react-hooks/rules-of-hooks. const useHook = () => { const [propagatedValue, setPropagatedValue] = useState(valueRef.current); From bd523b40d0f9400d068eade67944fcac3c7dbeb8 Mon Sep 17 00:00:00 2001 From: William Wong Date: Fri, 30 May 2025 11:25:07 +0000 Subject: [PATCH 05/38] Simplify --- .../private/useStableStateHook.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/component/src/providers/ClipboardWritePermissionWithStable/private/useStableStateHook.ts b/packages/component/src/providers/ClipboardWritePermissionWithStable/private/useStableStateHook.ts index e52e60775a..3ccbc9c562 100644 --- a/packages/component/src/providers/ClipboardWritePermissionWithStable/private/useStableStateHook.ts +++ b/packages/component/src/providers/ClipboardWritePermissionWithStable/private/useStableStateHook.ts @@ -15,12 +15,10 @@ export default function useStableStateHook( value: T, setValue?: Dispatch> | undefined ): () => readonly [T, Dispatch>] | readonly [T] { - const propagationRef = useRefWithInit>>(() => createPropagation()); - const valueRef = useRefFrom(value); - const { current: { usePropagate, useListen } - } = propagationRef; + } = useRefWithInit>>(() => createPropagation()); + const valueRef = useRefFrom(value); const propagate = usePropagate(); From e08e942b5cef0c134db37f905dfecbfb04c52ab7 Mon Sep 17 00:00:00 2001 From: William Wong Date: Fri, 30 May 2025 11:27:57 +0000 Subject: [PATCH 06/38] Better ESLint hack --- .../private/useStableStateHook.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/component/src/providers/ClipboardWritePermissionWithStable/private/useStableStateHook.ts b/packages/component/src/providers/ClipboardWritePermissionWithStable/private/useStableStateHook.ts index 3ccbc9c562..3bbc5a9621 100644 --- a/packages/component/src/providers/ClipboardWritePermissionWithStable/private/useStableStateHook.ts +++ b/packages/component/src/providers/ClipboardWritePermissionWithStable/private/useStableStateHook.ts @@ -25,17 +25,18 @@ export default function useStableStateHook( useEffect(() => propagate(value), [propagate, value]); // One-off variable to hack around ESLint rules without disabling react-hooks/rules-of-hooks. - const useHook = () => { - const [propagatedValue, setPropagatedValue] = useState(valueRef.current); + const _useListen = useListen; + const _useMemo = useMemo; + const _useState = useState; - useListen(setPropagatedValue); + return useCallback(() => { + const [propagatedValue, setPropagatedValue] = _useState(valueRef.current); - return useMemo( + _useListen(setPropagatedValue); + + return _useMemo( () => Object.freeze(setValue ? ([propagatedValue, setValue] as const) : ([propagatedValue] as const)), - // eslint-disable-next-line react-hooks/exhaustive-deps [propagatedValue, setValue] ); - }; - - return useCallback(useHook, [useListen, setValue, valueRef]); + }, [_useMemo, _useListen, _useState, setValue, valueRef]); } From c3cd6945b234edc45ca4d1d40b7fb163e6479df7 Mon Sep 17 00:00:00 2001 From: William Wong Date: Fri, 30 May 2025 11:28:52 +0000 Subject: [PATCH 07/38] Add comment --- .../private/useStableStateHook.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/component/src/providers/ClipboardWritePermissionWithStable/private/useStableStateHook.ts b/packages/component/src/providers/ClipboardWritePermissionWithStable/private/useStableStateHook.ts index 3bbc5a9621..06bc05f570 100644 --- a/packages/component/src/providers/ClipboardWritePermissionWithStable/private/useStableStateHook.ts +++ b/packages/component/src/providers/ClipboardWritePermissionWithStable/private/useStableStateHook.ts @@ -36,6 +36,7 @@ export default function useStableStateHook( return _useMemo( () => Object.freeze(setValue ? ([propagatedValue, setValue] as const) : ([propagatedValue] as const)), + // This deps is not checked by ESLint, verify with care. [propagatedValue, setValue] ); }, [_useMemo, _useListen, _useState, setValue, valueRef]); From 73a92b1c26d6d6cfa43e11367240b8bcd8b840d1 Mon Sep 17 00:00:00 2001 From: William Wong Date: Fri, 30 May 2025 11:29:57 +0000 Subject: [PATCH 08/38] Comment --- .../private/useStableStateHook.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/component/src/providers/ClipboardWritePermissionWithStable/private/useStableStateHook.ts b/packages/component/src/providers/ClipboardWritePermissionWithStable/private/useStableStateHook.ts index 06bc05f570..c883922f56 100644 --- a/packages/component/src/providers/ClipboardWritePermissionWithStable/private/useStableStateHook.ts +++ b/packages/component/src/providers/ClipboardWritePermissionWithStable/private/useStableStateHook.ts @@ -24,7 +24,7 @@ export default function useStableStateHook( useEffect(() => propagate(value), [propagate, value]); - // One-off variable to hack around ESLint rules without disabling react-hooks/rules-of-hooks. + // Hack around ESLint rules without disabling react-hooks/rules-of-hooks. const _useListen = useListen; const _useMemo = useMemo; const _useState = useState; From d3ffd6804955ae40f43df076fd54b989ea0bbed7 Mon Sep 17 00:00:00 2001 From: William Wong Date: Fri, 30 May 2025 21:07:13 +0000 Subject: [PATCH 09/38] Use useState instead of useRef for simpler code --- .../private/useStableStateHook.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/component/src/providers/ClipboardWritePermissionWithStable/private/useStableStateHook.ts b/packages/component/src/providers/ClipboardWritePermissionWithStable/private/useStableStateHook.ts index c883922f56..1bea974574 100644 --- a/packages/component/src/providers/ClipboardWritePermissionWithStable/private/useStableStateHook.ts +++ b/packages/component/src/providers/ClipboardWritePermissionWithStable/private/useStableStateHook.ts @@ -2,8 +2,6 @@ import { useCallback, useEffect, useMemo, useState, type Dispatch, type SetState import { createPropagation } from 'use-propagate'; import { useRefFrom } from 'use-ref-from'; -import useRefWithInit from './useRefWithInit'; - export default function useStableStateHook(value: T): () => readonly [T]; export default function useStableStateHook( @@ -15,9 +13,7 @@ export default function useStableStateHook( value: T, setValue?: Dispatch> | undefined ): () => readonly [T, Dispatch>] | readonly [T] { - const { - current: { usePropagate, useListen } - } = useRefWithInit>>(() => createPropagation()); + const [{ usePropagate, useListen }] = useState(() => createPropagation()); const valueRef = useRefFrom(value); const propagate = usePropagate(); From 199012fa1af439b618e8dd157bc08a38c5abfff4 Mon Sep 17 00:00:00 2001 From: William Wong Date: Fri, 30 May 2025 21:23:30 +0000 Subject: [PATCH 10/38] Use useMemo --- .../private/useStableStateHook.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/component/src/providers/ClipboardWritePermissionWithStable/private/useStableStateHook.ts b/packages/component/src/providers/ClipboardWritePermissionWithStable/private/useStableStateHook.ts index 1bea974574..bfc9529a7c 100644 --- a/packages/component/src/providers/ClipboardWritePermissionWithStable/private/useStableStateHook.ts +++ b/packages/component/src/providers/ClipboardWritePermissionWithStable/private/useStableStateHook.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react'; +import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from 'react'; import { createPropagation } from 'use-propagate'; import { useRefFrom } from 'use-ref-from'; @@ -13,12 +13,12 @@ export default function useStableStateHook( value: T, setValue?: Dispatch> | undefined ): () => readonly [T, Dispatch>] | readonly [T] { - const [{ usePropagate, useListen }] = useState(() => createPropagation()); + const [{ usePropagate, useListen }] = useState(() => createPropagation({ allowPropagateDuringRender: true })); const valueRef = useRefFrom(value); const propagate = usePropagate(); - useEffect(() => propagate(value), [propagate, value]); + useMemo(() => propagate(value), [propagate, value]); // Hack around ESLint rules without disabling react-hooks/rules-of-hooks. const _useListen = useListen; From fd77bf958907a719b1e07f1c3a7dac6b31423ea2 Mon Sep 17 00:00:00 2001 From: William Wong Date: Fri, 30 May 2025 21:36:21 +0000 Subject: [PATCH 11/38] Refactor useStateHook --- .../private/useStableStateHook.ts | 36 +++++++++++-------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/packages/component/src/providers/ClipboardWritePermissionWithStable/private/useStableStateHook.ts b/packages/component/src/providers/ClipboardWritePermissionWithStable/private/useStableStateHook.ts index bfc9529a7c..36502db778 100644 --- a/packages/component/src/providers/ClipboardWritePermissionWithStable/private/useStableStateHook.ts +++ b/packages/component/src/providers/ClipboardWritePermissionWithStable/private/useStableStateHook.ts @@ -1,7 +1,22 @@ -import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from 'react'; +import { useCallback, useMemo, useState, type Dispatch, type RefObject, 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: RefObject +) => { + 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( @@ -21,19 +36,10 @@ export default function useStableStateHook( useMemo(() => propagate(value), [propagate, value]); // Hack around ESLint rules without disabling react-hooks/rules-of-hooks. - const _useListen = useListen; - const _useMemo = useMemo; - const _useState = useState; - - return useCallback(() => { - const [propagatedValue, setPropagatedValue] = _useState(valueRef.current); - - _useListen(setPropagatedValue); + const _useCreateHook = useCreateHook; - return _useMemo( - () => Object.freeze(setValue ? ([propagatedValue, setValue] as const) : ([propagatedValue] as const)), - // This deps is not checked by ESLint, verify with care. - [propagatedValue, setValue] - ); - }, [_useMemo, _useListen, _useState, setValue, valueRef]); + return useCallback( + () => _useCreateHook(setValue, useListen, valueRef), + [_useCreateHook, setValue, useListen, valueRef] + ); } From f01d1ccf36ce05925af1f8334a543874dd491f5a Mon Sep 17 00:00:00 2001 From: William Wong Date: Fri, 30 May 2025 22:19:44 +0000 Subject: [PATCH 12/38] Add useGetterState --- .../ClipboardWritePermissionComposer.tsx | 4 +--- .../ClipboardWritePermissionComposer.tsx | 11 ++++------- .../private/createStateContextWithHook.tsx | 4 +--- .../private/useGetterState.ts | 7 +++++++ 4 files changed, 13 insertions(+), 13 deletions(-) create mode 100644 packages/component/src/providers/ClipboardWritePermissionWithStateContext/private/useGetterState.ts diff --git a/packages/component/src/providers/ClipboardWritePermissionWithStable/ClipboardWritePermissionComposer.tsx b/packages/component/src/providers/ClipboardWritePermissionWithStable/ClipboardWritePermissionComposer.tsx index 1110bf40e6..489b8397ea 100644 --- a/packages/component/src/providers/ClipboardWritePermissionWithStable/ClipboardWritePermissionComposer.tsx +++ b/packages/component/src/providers/ClipboardWritePermissionWithStable/ClipboardWritePermissionComposer.tsx @@ -1,9 +1,7 @@ -// import { reactNode, validateProps } from 'botframework-webchat-react-valibot'; -import { validateProps } from 'botframework-webchat-api/internal'; +import { reactNode, validateProps } from 'botframework-webchat-react-valibot'; import React, { createContext, memo, useContext, useEffect, useMemo, useState } from 'react'; import { object, optional, pipe, readonly, type InferInput } from 'valibot'; -import reactNode from '../../types/internal/reactNode'; import useStableStateHook from './private/useStableStateHook'; const clipboardWritePermissionComposerPropsSchema = pipe( diff --git a/packages/component/src/providers/ClipboardWritePermissionWithStateContext/ClipboardWritePermissionComposer.tsx b/packages/component/src/providers/ClipboardWritePermissionWithStateContext/ClipboardWritePermissionComposer.tsx index 071e29b931..42d1a58ff0 100644 --- a/packages/component/src/providers/ClipboardWritePermissionWithStateContext/ClipboardWritePermissionComposer.tsx +++ b/packages/component/src/providers/ClipboardWritePermissionWithStateContext/ClipboardWritePermissionComposer.tsx @@ -1,11 +1,10 @@ -// import { reactNode, validateProps } from 'botframework-webchat-react-valibot'; -import { validateProps } from 'botframework-webchat-api/internal'; -import React, { Fragment, memo, useCallback, useEffect, useMemo } from 'react'; +import { reactNode, validateProps } from 'botframework-webchat-react-valibot'; +import React, { Fragment, memo, useEffect, useMemo } from 'react'; import { wrapWith } from 'react-wrap-with'; import { object, optional, pipe, readonly, type InferInput } from 'valibot'; -import reactNode from '../../types/internal/reactNode'; import createStateContextWithHook from './private/createStateContextWithHook'; +import useGetterState from './private/useGetterState'; const clipboardWritePermissionComposerPropsSchema = pipe( object({ @@ -22,9 +21,7 @@ const { Composer: PermissionGrantedComposer, useValue: useRawPermissionGranted } function useClipboardWritePermissionHooks(): Readonly<{ usePermissionGranted: () => readonly [boolean]; }> { - const [permissionGranted] = useRawPermissionGranted(); - - const usePermissionGranted = useCallback(() => Object.freeze([permissionGranted] as const), [permissionGranted]); + const usePermissionGranted = useGetterState(useRawPermissionGranted()); return useMemo(() => Object.freeze({ usePermissionGranted }), [usePermissionGranted]); } diff --git a/packages/component/src/providers/ClipboardWritePermissionWithStateContext/private/createStateContextWithHook.tsx b/packages/component/src/providers/ClipboardWritePermissionWithStateContext/private/createStateContextWithHook.tsx index d1efd3a339..8700994c47 100644 --- a/packages/component/src/providers/ClipboardWritePermissionWithStateContext/private/createStateContextWithHook.tsx +++ b/packages/component/src/providers/ClipboardWritePermissionWithStateContext/private/createStateContextWithHook.tsx @@ -1,5 +1,4 @@ -// import { reactNode, validateProps } from 'botframework-webchat-react-valibot'; -import { validateProps } from 'botframework-webchat-api/internal'; +import { reactNode, validateProps } from 'botframework-webchat-react-valibot'; import React, { createContext, memo, @@ -12,7 +11,6 @@ import React, { type SetStateAction } from 'react'; import { object, optional, pipe, readonly, type InferInput } from 'valibot'; -import reactNode from '../../../types/internal/reactNode'; type GenericContextType = Readonly<{ valueState: readonly [T, Dispatch>]; diff --git a/packages/component/src/providers/ClipboardWritePermissionWithStateContext/private/useGetterState.ts b/packages/component/src/providers/ClipboardWritePermissionWithStateContext/private/useGetterState.ts new file mode 100644 index 0000000000..1eea259b03 --- /dev/null +++ b/packages/component/src/providers/ClipboardWritePermissionWithStateContext/private/useGetterState.ts @@ -0,0 +1,7 @@ +import { useCallback, type Dispatch, type SetStateAction } from 'react'; + +export default function useGetterState(state: readonly [T, Dispatch>]): () => readonly [T] { + const [value] = state; + + return useCallback(() => Object.freeze([value]), [value]); +} From f76a4de077436cab6f96587940bb53fd589680d6 Mon Sep 17 00:00:00 2001 From: William Wong Date: Fri, 30 May 2025 22:21:47 +0000 Subject: [PATCH 13/38] Rename --- .../ClipboardWritePermissionComposer.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/component/src/providers/ClipboardWritePermissionWithStateContext/ClipboardWritePermissionComposer.tsx b/packages/component/src/providers/ClipboardWritePermissionWithStateContext/ClipboardWritePermissionComposer.tsx index 42d1a58ff0..e4848b0ab8 100644 --- a/packages/component/src/providers/ClipboardWritePermissionWithStateContext/ClipboardWritePermissionComposer.tsx +++ b/packages/component/src/providers/ClipboardWritePermissionWithStateContext/ClipboardWritePermissionComposer.tsx @@ -15,21 +15,21 @@ const clipboardWritePermissionComposerPropsSchema = pipe( type ClipboardWritePermissionComposerProps = InferInput; -const { Composer: PermissionGrantedComposer, useValue: useRawPermissionGranted } = +const { Composer: PermissionGrantedComposer, useValue: usePermissionGranted } = createStateContextWithHook(false); function useClipboardWritePermissionHooks(): Readonly<{ usePermissionGranted: () => readonly [boolean]; }> { - const usePermissionGranted = useGetterState(useRawPermissionGranted()); + const usePermissionGranted_ = useGetterState(usePermissionGranted()); - return useMemo(() => Object.freeze({ usePermissionGranted }), [usePermissionGranted]); + return useMemo(() => Object.freeze({ usePermissionGranted: usePermissionGranted_ }), [usePermissionGranted_]); } function ClipboardWritePermissionComposer_(props: ClipboardWritePermissionComposerProps) { const { children } = validateProps(clipboardWritePermissionComposerPropsSchema, props); - const [_, setPermissionGranted] = useRawPermissionGranted(); + const [_, setPermissionGranted] = usePermissionGranted(); useEffect(() => { let unmounted = false; From cb3070047924cce0302f4dc441fb2a1f342a9a60 Mon Sep 17 00:00:00 2001 From: William Wong Date: Fri, 30 May 2025 23:38:32 +0000 Subject: [PATCH 14/38] Clean up --- .../ClipboardWritePermissionComposer.tsx | 7 +- .../private/createBitContext.tsx | 53 +++++++++++++++ .../private/createStateContextWithHook.tsx | 68 ------------------- 3 files changed, 56 insertions(+), 72 deletions(-) create mode 100644 packages/component/src/providers/ClipboardWritePermissionWithStateContext/private/createBitContext.tsx delete mode 100644 packages/component/src/providers/ClipboardWritePermissionWithStateContext/private/createStateContextWithHook.tsx diff --git a/packages/component/src/providers/ClipboardWritePermissionWithStateContext/ClipboardWritePermissionComposer.tsx b/packages/component/src/providers/ClipboardWritePermissionWithStateContext/ClipboardWritePermissionComposer.tsx index e4848b0ab8..f492bd7180 100644 --- a/packages/component/src/providers/ClipboardWritePermissionWithStateContext/ClipboardWritePermissionComposer.tsx +++ b/packages/component/src/providers/ClipboardWritePermissionWithStateContext/ClipboardWritePermissionComposer.tsx @@ -3,7 +3,7 @@ import React, { Fragment, memo, useEffect, useMemo } from 'react'; import { wrapWith } from 'react-wrap-with'; import { object, optional, pipe, readonly, type InferInput } from 'valibot'; -import createStateContextWithHook from './private/createStateContextWithHook'; +import createBitContext from './private/createBitContext'; import useGetterState from './private/useGetterState'; const clipboardWritePermissionComposerPropsSchema = pipe( @@ -15,8 +15,7 @@ const clipboardWritePermissionComposerPropsSchema = pipe( type ClipboardWritePermissionComposerProps = InferInput; -const { Composer: PermissionGrantedComposer, useValue: usePermissionGranted } = - createStateContextWithHook(false); +const { Composer: PermissionGrantedComposer, useState: usePermissionGranted } = createBitContext(false); function useClipboardWritePermissionHooks(): Readonly<{ usePermissionGranted: () => readonly [boolean]; @@ -34,7 +33,7 @@ function ClipboardWritePermissionComposer_(props: ClipboardWritePermissionCompos useEffect(() => { let unmounted = false; - (async function () { + (async () => { if ((await navigator.permissions.query({ name: 'clipboard-write' as any })).state === 'granted') { unmounted || setPermissionGranted(true); } diff --git a/packages/component/src/providers/ClipboardWritePermissionWithStateContext/private/createBitContext.tsx b/packages/component/src/providers/ClipboardWritePermissionWithStateContext/private/createBitContext.tsx new file mode 100644 index 0000000000..6f1e18ce34 --- /dev/null +++ b/packages/component/src/providers/ClipboardWritePermissionWithStateContext/private/createBitContext.tsx @@ -0,0 +1,53 @@ +import { reactNode, validateProps } from 'botframework-webchat-react-valibot'; +import React, { + createContext, + memo, + useContext, + useMemo, + useState, + type ComponentType, + type Dispatch, + type SetStateAction +} from 'react'; +import { object, optional, pipe, readonly, type InferInput } from 'valibot'; + +type BitContextType = Readonly<{ + state: 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 AtomContext = 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 state = useState['state'][0]>(() => initialValue); + + const context = useMemo>(() => Object.freeze({ state }), [state]); + + return {children}; + } + + return Object.freeze({ + Composer: memo(BitComposer), + useState: () => useContext(AtomContext).state + }); +} diff --git a/packages/component/src/providers/ClipboardWritePermissionWithStateContext/private/createStateContextWithHook.tsx b/packages/component/src/providers/ClipboardWritePermissionWithStateContext/private/createStateContextWithHook.tsx deleted file mode 100644 index 8700994c47..0000000000 --- a/packages/component/src/providers/ClipboardWritePermissionWithStateContext/private/createStateContextWithHook.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { reactNode, validateProps } from 'botframework-webchat-react-valibot'; -import React, { - createContext, - memo, - useContext, - useMemo, - useState, - type ComponentType, - type Dispatch, - type ReactNode, - type SetStateAction -} from 'react'; -import { object, optional, pipe, readonly, type InferInput } from 'valibot'; - -type GenericContextType = Readonly<{ - valueState: readonly [T, Dispatch>]; -}>; - -type GenericComposerProps = Readonly<{ - children?: ReactNode | undefined; - defaultValue: T; -}>; - -export default function createStateContextWithHook(defaultValue: T): Readonly<{ - Composer: ComponentType>; - useValue(): readonly [T, Dispatch>]; - '~types': { - props: GenericComposerProps; - }; -}> { - type ContextType = GenericContextType; - - const Context = createContext( - new Proxy({} as ContextType, { - get() { - throw new Error('botframework-webchat: This hook can only be used under its corresponding context.'); - } - }) - ); - - const composerPropsSchema = pipe( - object({ - children: optional(reactNode()) - }), - readonly() - ); - - type ComposerProps = InferInput; - - function Composer(props: ComposerProps) { - // const { children, defaultValue } = validateProps(composerPropsSchema, props); - const { children } = validateProps(composerPropsSchema, props); - - const valueState = useState(() => defaultValue as any); - - const context = useMemo(() => Object.freeze({ valueState }), [valueState]); - - return {children}; - } - - return Object.freeze({ - Composer: memo(Composer), - useValue: () => useContext(Context).valueState, - '~types': { - props: {} as any - } - }); -} From dd5a06dd8c1fd9a6bbeccc48386e8a184dbe18a9 Mon Sep 17 00:00:00 2001 From: William Wong Date: Fri, 30 May 2025 23:41:14 +0000 Subject: [PATCH 15/38] Clean up --- .../ClipboardWritePermissionComposer.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/component/src/providers/ClipboardWritePermissionWithStateContext/ClipboardWritePermissionComposer.tsx b/packages/component/src/providers/ClipboardWritePermissionWithStateContext/ClipboardWritePermissionComposer.tsx index f492bd7180..ebad28021b 100644 --- a/packages/component/src/providers/ClipboardWritePermissionWithStateContext/ClipboardWritePermissionComposer.tsx +++ b/packages/component/src/providers/ClipboardWritePermissionWithStateContext/ClipboardWritePermissionComposer.tsx @@ -25,7 +25,7 @@ function useClipboardWritePermissionHooks(): Readonly<{ return useMemo(() => Object.freeze({ usePermissionGranted: usePermissionGranted_ }), [usePermissionGranted_]); } -function ClipboardWritePermissionComposer_(props: ClipboardWritePermissionComposerProps) { +function ClipboardWritePermissionComposer(props: ClipboardWritePermissionComposerProps) { const { children } = validateProps(clipboardWritePermissionComposerPropsSchema, props); const [_, setPermissionGranted] = usePermissionGranted(); @@ -47,7 +47,5 @@ function ClipboardWritePermissionComposer_(props: ClipboardWritePermissionCompos return {children}; } -const ClipboardWritePermissionComposer = wrapWith(PermissionGrantedComposer)(memo(ClipboardWritePermissionComposer_)); - -export default memo(ClipboardWritePermissionComposer); +export default memo(wrapWith(PermissionGrantedComposer)(memo(ClipboardWritePermissionComposer))); export { useClipboardWritePermissionHooks }; From 8ca301f5f6abc6e72f92e84f73553b262817f7dc Mon Sep 17 00:00:00 2001 From: William Wong Date: Fri, 30 May 2025 23:50:59 +0000 Subject: [PATCH 16/38] Styling --- .../ClipboardWritePermissionComposer.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/component/src/providers/ClipboardWritePermissionWithStateContext/ClipboardWritePermissionComposer.tsx b/packages/component/src/providers/ClipboardWritePermissionWithStateContext/ClipboardWritePermissionComposer.tsx index ebad28021b..c23ba3cfc7 100644 --- a/packages/component/src/providers/ClipboardWritePermissionWithStateContext/ClipboardWritePermissionComposer.tsx +++ b/packages/component/src/providers/ClipboardWritePermissionWithStateContext/ClipboardWritePermissionComposer.tsx @@ -22,7 +22,13 @@ function useClipboardWritePermissionHooks(): Readonly<{ }> { const usePermissionGranted_ = useGetterState(usePermissionGranted()); - return useMemo(() => Object.freeze({ usePermissionGranted: usePermissionGranted_ }), [usePermissionGranted_]); + return useMemo( + () => + Object.freeze({ + usePermissionGranted: usePermissionGranted_ + }), + [usePermissionGranted_] + ); } function ClipboardWritePermissionComposer(props: ClipboardWritePermissionComposerProps) { From da91646b17534f9f8315e80675b85d3df379c152 Mon Sep 17 00:00:00 2001 From: William Wong Date: Fri, 30 May 2025 23:52:53 +0000 Subject: [PATCH 17/38] Simplify --- .../private/createBitContext.tsx | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/component/src/providers/ClipboardWritePermissionWithStateContext/private/createBitContext.tsx b/packages/component/src/providers/ClipboardWritePermissionWithStateContext/private/createBitContext.tsx index 6f1e18ce34..f85f77a94d 100644 --- a/packages/component/src/providers/ClipboardWritePermissionWithStateContext/private/createBitContext.tsx +++ b/packages/component/src/providers/ClipboardWritePermissionWithStateContext/private/createBitContext.tsx @@ -3,7 +3,6 @@ import React, { createContext, memo, useContext, - useMemo, useState, type ComponentType, type Dispatch, @@ -11,9 +10,7 @@ import React, { } from 'react'; import { object, optional, pipe, readonly, type InferInput } from 'valibot'; -type BitContextType = Readonly<{ - state: readonly [T, Dispatch>]; -}>; +type BitContextType = readonly [T, Dispatch>]; const bitComposerPropsSchema = pipe( object({ @@ -39,15 +36,13 @@ export default function createBitContext(initialValue: T): Readonly<{ function BitComposer(props: BitComposerProps) { const { children } = validateProps(bitComposerPropsSchema, props); - const state = useState['state'][0]>(() => initialValue); - - const context = useMemo>(() => Object.freeze({ state }), [state]); + const context = useState[0]>(() => initialValue); return {children}; } return Object.freeze({ Composer: memo(BitComposer), - useState: () => useContext(AtomContext).state + useState: () => useContext(AtomContext) }); } From 8e93bb20568ad088889ff320472181410056afaa Mon Sep 17 00:00:00 2001 From: William Wong Date: Fri, 30 May 2025 23:53:31 +0000 Subject: [PATCH 18/38] Use state context version --- .../src/Attachment/Text/private/ActivityCopyButton.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/component/src/Attachment/Text/private/ActivityCopyButton.tsx b/packages/component/src/Attachment/Text/private/ActivityCopyButton.tsx index 5aa02fcf02..2d8d2ac5dc 100644 --- a/packages/component/src/Attachment/Text/private/ActivityCopyButton.tsx +++ b/packages/component/src/Attachment/Text/private/ActivityCopyButton.tsx @@ -8,7 +8,7 @@ import { instance, nullable, object, optional, pipe, readonly, string, type Infe import useStyleSet from '../../../hooks/useStyleSet'; import ClipboardWritePermissionComposer, { useClipboardWritePermissionHooks -} from '../../../providers/ClipboardWritePermissionWithStable/ClipboardWritePermissionComposer'; +} from '../../../providers/ClipboardWritePermissionWithStateContext/ClipboardWritePermissionComposer'; import { useQueueStaticElement } from '../../../providers/LiveRegionTwin'; import refObject from '../../../types/internal/refObject'; import ActivityButton from './ActivityButton'; From ae508a94ff39120589cb96919d83bc6efcc7005c Mon Sep 17 00:00:00 2001 From: William Wong Date: Sat, 31 May 2025 00:18:02 +0000 Subject: [PATCH 19/38] Refactor into react-context package --- package-lock.json | 37 +++++++++++- package.json | 4 ++ packages/component/package.json | 2 + .../ClipboardWritePermissionComposer.tsx | 3 +- .../ClipboardWritePermissionComposer.tsx | 4 +- packages/react-context/.eslintrc.yml | 6 ++ packages/react-context/.gitignore | 3 + packages/react-context/README.md | 0 packages/react-context/package.json | 59 +++++++++++++++++++ .../src}/createBitContext.tsx | 0 packages/react-context/src/index.ts | 4 ++ packages/react-context/src/tsconfig.json | 13 ++++ .../src}/useGetterState.ts | 0 .../src}/useRefWithInit.ts | 0 .../src}/useStableStateHook.ts | 4 +- packages/react-context/tsup.config.ts | 21 +++++++ packages/react-valibot/package.json | 3 +- 17 files changed, 153 insertions(+), 10 deletions(-) create mode 100644 packages/react-context/.eslintrc.yml create mode 100644 packages/react-context/.gitignore create mode 100644 packages/react-context/README.md create mode 100644 packages/react-context/package.json rename packages/{component/src/providers/ClipboardWritePermissionWithStateContext/private => react-context/src}/createBitContext.tsx (100%) create mode 100644 packages/react-context/src/index.ts create mode 100644 packages/react-context/src/tsconfig.json rename packages/{component/src/providers/ClipboardWritePermissionWithStateContext/private => react-context/src}/useGetterState.ts (100%) rename packages/{component/src/providers/ClipboardWritePermissionWithStable/private => react-context/src}/useRefWithInit.ts (100%) rename packages/{component/src/providers/ClipboardWritePermissionWithStable/private => react-context/src}/useStableStateHook.ts (91%) create mode 100644 packages/react-context/tsup.config.ts diff --git a/package-lock.json b/package-lock.json index 47516eaf58..5bc7075186 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,8 @@ "./packages/bundle", "./packages/test/page-object", "./packages/fluent-theme", - "./packages/test/fluent-bundle" + "./packages/test/fluent-bundle", + "packages/react-context" ], "dependencies": { "react": "16.8.6", @@ -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,35 @@ "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" + }, + "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..edebc07f9a 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "./packages/test/harness", "./packages/test/web-server", "./packages/core", + "./packages/react-context", "./packages/react-valibot", "./packages/redux-store", "./packages/styles", @@ -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/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/providers/ClipboardWritePermissionWithStable/ClipboardWritePermissionComposer.tsx b/packages/component/src/providers/ClipboardWritePermissionWithStable/ClipboardWritePermissionComposer.tsx index 489b8397ea..82f45b13ba 100644 --- a/packages/component/src/providers/ClipboardWritePermissionWithStable/ClipboardWritePermissionComposer.tsx +++ b/packages/component/src/providers/ClipboardWritePermissionWithStable/ClipboardWritePermissionComposer.tsx @@ -1,9 +1,8 @@ +import { useStableStateHook } from 'botframework-webchat-react-context'; import { reactNode, validateProps } from 'botframework-webchat-react-valibot'; import React, { createContext, memo, useContext, useEffect, useMemo, useState } from 'react'; import { object, optional, pipe, readonly, type InferInput } from 'valibot'; -import useStableStateHook from './private/useStableStateHook'; - const clipboardWritePermissionComposerPropsSchema = pipe( object({ children: optional(reactNode()) diff --git a/packages/component/src/providers/ClipboardWritePermissionWithStateContext/ClipboardWritePermissionComposer.tsx b/packages/component/src/providers/ClipboardWritePermissionWithStateContext/ClipboardWritePermissionComposer.tsx index c23ba3cfc7..b1f6b58462 100644 --- a/packages/component/src/providers/ClipboardWritePermissionWithStateContext/ClipboardWritePermissionComposer.tsx +++ b/packages/component/src/providers/ClipboardWritePermissionWithStateContext/ClipboardWritePermissionComposer.tsx @@ -1,11 +1,9 @@ +import { createBitContext, useGetterState } from 'botframework-webchat-react-context'; import { reactNode, validateProps } from 'botframework-webchat-react-valibot'; import React, { Fragment, memo, useEffect, useMemo } from 'react'; import { wrapWith } from 'react-wrap-with'; import { object, optional, pipe, readonly, type InferInput } from 'valibot'; -import createBitContext from './private/createBitContext'; -import useGetterState from './private/useGetterState'; - const clipboardWritePermissionComposerPropsSchema = pipe( object({ children: optional(reactNode()) 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..54e5e1a0f9 --- /dev/null +++ b/packages/react-context/package.json @@ -0,0 +1,59 @@ +{ + "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": {}, + "devDependencies": { + "@tsconfig/strictest": "^2.0.5", + "@types/react": "^16.14.62" + }, + "dependencies": { + "valibot": "1.1.0" + }, + "peerDependencies": { + "react": ">= 16.8.6" + } +} diff --git a/packages/component/src/providers/ClipboardWritePermissionWithStateContext/private/createBitContext.tsx b/packages/react-context/src/createBitContext.tsx similarity index 100% rename from packages/component/src/providers/ClipboardWritePermissionWithStateContext/private/createBitContext.tsx rename to packages/react-context/src/createBitContext.tsx diff --git a/packages/react-context/src/index.ts b/packages/react-context/src/index.ts new file mode 100644 index 0000000000..a3652f3e8b --- /dev/null +++ b/packages/react-context/src/index.ts @@ -0,0 +1,4 @@ +export { default as createBitContext } from './createBitContext'; +export { default as useGetterState } from './useGetterState'; +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/component/src/providers/ClipboardWritePermissionWithStateContext/private/useGetterState.ts b/packages/react-context/src/useGetterState.ts similarity index 100% rename from packages/component/src/providers/ClipboardWritePermissionWithStateContext/private/useGetterState.ts rename to packages/react-context/src/useGetterState.ts diff --git a/packages/component/src/providers/ClipboardWritePermissionWithStable/private/useRefWithInit.ts b/packages/react-context/src/useRefWithInit.ts similarity index 100% rename from packages/component/src/providers/ClipboardWritePermissionWithStable/private/useRefWithInit.ts rename to packages/react-context/src/useRefWithInit.ts diff --git a/packages/component/src/providers/ClipboardWritePermissionWithStable/private/useStableStateHook.ts b/packages/react-context/src/useStableStateHook.ts similarity index 91% rename from packages/component/src/providers/ClipboardWritePermissionWithStable/private/useStableStateHook.ts rename to packages/react-context/src/useStableStateHook.ts index 36502db778..4bbb30b580 100644 --- a/packages/component/src/providers/ClipboardWritePermissionWithStable/private/useStableStateHook.ts +++ b/packages/react-context/src/useStableStateHook.ts @@ -1,11 +1,11 @@ -import { useCallback, useMemo, useState, type Dispatch, type RefObject, type SetStateAction } from 'react'; +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: RefObject + valueRef: Readonly<{ current: T }> ) => { const [propagatedValue, setPropagatedValue] = useState(valueRef.current); 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" + } } From f0f88c330a2975a8cabd90defc22d536bc2e81ce Mon Sep 17 00:00:00 2001 From: William Wong Date: Sat, 31 May 2025 00:25:38 +0000 Subject: [PATCH 20/38] Rename to useReadonlyState --- .../ClipboardWritePermissionComposer.tsx | 4 ++-- packages/react-context/src/index.ts | 2 +- .../src/{useGetterState.ts => useReadonlyState.ts} | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) rename packages/react-context/src/{useGetterState.ts => useReadonlyState.ts} (58%) diff --git a/packages/component/src/providers/ClipboardWritePermissionWithStateContext/ClipboardWritePermissionComposer.tsx b/packages/component/src/providers/ClipboardWritePermissionWithStateContext/ClipboardWritePermissionComposer.tsx index b1f6b58462..0699f08497 100644 --- a/packages/component/src/providers/ClipboardWritePermissionWithStateContext/ClipboardWritePermissionComposer.tsx +++ b/packages/component/src/providers/ClipboardWritePermissionWithStateContext/ClipboardWritePermissionComposer.tsx @@ -1,4 +1,4 @@ -import { createBitContext, useGetterState } from 'botframework-webchat-react-context'; +import { createBitContext, useReadonlyState } from 'botframework-webchat-react-context'; import { reactNode, validateProps } from 'botframework-webchat-react-valibot'; import React, { Fragment, memo, useEffect, useMemo } from 'react'; import { wrapWith } from 'react-wrap-with'; @@ -18,7 +18,7 @@ const { Composer: PermissionGrantedComposer, useState: usePermissionGranted } = function useClipboardWritePermissionHooks(): Readonly<{ usePermissionGranted: () => readonly [boolean]; }> { - const usePermissionGranted_ = useGetterState(usePermissionGranted()); + const usePermissionGranted_ = useReadonlyState(usePermissionGranted()); return useMemo( () => diff --git a/packages/react-context/src/index.ts b/packages/react-context/src/index.ts index a3652f3e8b..1f5fc9aafa 100644 --- a/packages/react-context/src/index.ts +++ b/packages/react-context/src/index.ts @@ -1,4 +1,4 @@ export { default as createBitContext } from './createBitContext'; -export { default as useGetterState } from './useGetterState'; +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/useGetterState.ts b/packages/react-context/src/useReadonlyState.ts similarity index 58% rename from packages/react-context/src/useGetterState.ts rename to packages/react-context/src/useReadonlyState.ts index 1eea259b03..97c9e6481d 100644 --- a/packages/react-context/src/useGetterState.ts +++ b/packages/react-context/src/useReadonlyState.ts @@ -1,6 +1,6 @@ import { useCallback, type Dispatch, type SetStateAction } from 'react'; -export default function useGetterState(state: readonly [T, Dispatch>]): () => readonly [T] { +export default function useReadonlyState(state: readonly [T, Dispatch>]): () => readonly [T] { const [value] = state; return useCallback(() => Object.freeze([value]), [value]); From 6bc097660d0f7b6a6076e87a737032607edec16d Mon Sep 17 00:00:00 2001 From: William Wong Date: Sat, 31 May 2025 00:46:02 +0000 Subject: [PATCH 21/38] Switch between 2 perf strategy --- .../Text/private/ActivityCopyButton.tsx | 2 +- .../ClipboardWritePermissionComposer.tsx | 100 ++++++++++++++++++ .../ClipboardWritePermissionComposer.tsx | 62 ----------- .../ClipboardWritePermissionComposer.tsx | 55 ---------- packages/component/tsup.config.ts | 4 + 5 files changed, 105 insertions(+), 118 deletions(-) create mode 100644 packages/component/src/providers/ClipboardWritePermission/ClipboardWritePermissionComposer.tsx delete mode 100644 packages/component/src/providers/ClipboardWritePermissionWithStable/ClipboardWritePermissionComposer.tsx delete mode 100644 packages/component/src/providers/ClipboardWritePermissionWithStateContext/ClipboardWritePermissionComposer.tsx diff --git a/packages/component/src/Attachment/Text/private/ActivityCopyButton.tsx b/packages/component/src/Attachment/Text/private/ActivityCopyButton.tsx index 2d8d2ac5dc..dc9bed5aec 100644 --- a/packages/component/src/Attachment/Text/private/ActivityCopyButton.tsx +++ b/packages/component/src/Attachment/Text/private/ActivityCopyButton.tsx @@ -8,7 +8,7 @@ import { instance, nullable, object, optional, pipe, readonly, string, type Infe import useStyleSet from '../../../hooks/useStyleSet'; import ClipboardWritePermissionComposer, { useClipboardWritePermissionHooks -} from '../../../providers/ClipboardWritePermissionWithStateContext/ClipboardWritePermissionComposer'; +} from '../../../providers/ClipboardWritePermission/ClipboardWritePermissionComposer'; import { useQueueStaticElement } from '../../../providers/LiveRegionTwin'; import refObject from '../../../types/internal/refObject'; import ActivityButton from './ActivityButton'; 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/ClipboardWritePermissionWithStable/ClipboardWritePermissionComposer.tsx b/packages/component/src/providers/ClipboardWritePermissionWithStable/ClipboardWritePermissionComposer.tsx deleted file mode 100644 index 82f45b13ba..0000000000 --- a/packages/component/src/providers/ClipboardWritePermissionWithStable/ClipboardWritePermissionComposer.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { useStableStateHook } from 'botframework-webchat-react-context'; -import { reactNode, validateProps } from 'botframework-webchat-react-valibot'; -import React, { createContext, memo, useContext, useEffect, useMemo, useState } from 'react'; -import { object, optional, pipe, readonly, type InferInput } from 'valibot'; - -const clipboardWritePermissionComposerPropsSchema = pipe( - object({ - children: optional(reactNode()) - }), - readonly() -); - -type ClipboardWritePermissionComposerProps = InferInput; - -type ClipboardWritePermissionContextType = Readonly<{ - usePermissionGranted: () => readonly [boolean]; -}>; - -const ClipboardWritePermissionContext = createContext({} as any); - -function ClipboardWritePermissionComposer(props: ClipboardWritePermissionComposerProps) { - const { children } = validateProps(clipboardWritePermissionComposerPropsSchema, props); - - const [permissionGranted, setPermissionGranted] = useState(false); - - const usePermissionGranted = useStableStateHook(permissionGranted); - - const context = useMemo( - () => - Object.freeze({ - usePermissionGranted - }), - [usePermissionGranted] - ); - - 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 ( - {children} - ); -} - -function useClipboardWritePermissionHooks(): Readonly<{ - usePermissionGranted(): readonly [boolean]; -}> { - return useContext(ClipboardWritePermissionContext); -} - -export default memo(ClipboardWritePermissionComposer); -export { useClipboardWritePermissionHooks }; diff --git a/packages/component/src/providers/ClipboardWritePermissionWithStateContext/ClipboardWritePermissionComposer.tsx b/packages/component/src/providers/ClipboardWritePermissionWithStateContext/ClipboardWritePermissionComposer.tsx deleted file mode 100644 index 0699f08497..0000000000 --- a/packages/component/src/providers/ClipboardWritePermissionWithStateContext/ClipboardWritePermissionComposer.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { createBitContext, useReadonlyState } from 'botframework-webchat-react-context'; -import { reactNode, validateProps } from 'botframework-webchat-react-valibot'; -import React, { Fragment, memo, useEffect, useMemo } from 'react'; -import { wrapWith } from 'react-wrap-with'; -import { object, optional, pipe, readonly, type InferInput } from 'valibot'; - -const clipboardWritePermissionComposerPropsSchema = pipe( - object({ - children: optional(reactNode()) - }), - readonly() -); - -type ClipboardWritePermissionComposerProps = InferInput; - -const { Composer: PermissionGrantedComposer, useState: usePermissionGranted } = createBitContext(false); - -function useClipboardWritePermissionHooks(): Readonly<{ - usePermissionGranted: () => readonly [boolean]; -}> { - const usePermissionGranted_ = useReadonlyState(usePermissionGranted()); - - return useMemo( - () => - Object.freeze({ - usePermissionGranted: usePermissionGranted_ - }), - [usePermissionGranted_] - ); -} - -function ClipboardWritePermissionComposer(props: ClipboardWritePermissionComposerProps) { - const { children } = validateProps(clipboardWritePermissionComposerPropsSchema, props); - - const [_, setPermissionGranted] = 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}; -} - -export default memo(wrapWith(PermissionGrantedComposer)(memo(ClipboardWritePermissionComposer))); -export { useClipboardWritePermissionHooks }; diff --git a/packages/component/tsup.config.ts b/packages/component/tsup.config.ts index 90bc5015c0..808078a532 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', From f004838c3419e6587471240d32da3b2cb17fbbd9 Mon Sep 17 00:00:00 2001 From: William Wong Date: Mon, 2 Jun 2025 22:55:30 +0000 Subject: [PATCH 22/38] Add createRawReducer --- .../src/reducers/private/createRawReducer.ts | 20 +++++++++++++++ .../core/src/reducers/suggestedActions.ts | 19 ++------------ .../suggestedActionsOriginActivity.ts | 25 ++++--------------- 3 files changed, 27 insertions(+), 37 deletions(-) create mode 100644 packages/core/src/reducers/private/createRawReducer.ts diff --git a/packages/core/src/reducers/private/createRawReducer.ts b/packages/core/src/reducers/private/createRawReducer.ts new file mode 100644 index 0000000000..ac17f43e9e --- /dev/null +++ b/packages/core/src/reducers/private/createRawReducer.ts @@ -0,0 +1,20 @@ +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 { output, success } = safeParse(setRawStateActionSchema, action); + + if (success && output.payload.name === name) { + return output.payload.state as TState; + } + } + + return state; + }; +} + +export default createRawReducer; 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; From 55088e634c2e02e24d1e45b90d9b018007b2a3cf Mon Sep 17 00:00:00 2001 From: William Wong Date: Mon, 2 Jun 2025 23:24:32 +0000 Subject: [PATCH 23/38] Use BitContext --- .../SuggestedActionsComposer.tsx | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/redux-store/src/suggestedActions/SuggestedActionsComposer.tsx b/packages/redux-store/src/suggestedActions/SuggestedActionsComposer.tsx index f3bacb7b46..844b26a422 100644 --- a/packages/redux-store/src/suggestedActions/SuggestedActionsComposer.tsx +++ b/packages/redux-store/src/suggestedActions/SuggestedActionsComposer.tsx @@ -6,8 +6,10 @@ import { type DirectLineCardAction } from 'botframework-webchat-core'; import { 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, useEffect, useMemo } from 'react'; +import { wrapWith } from 'react-wrap-with'; import { type Action } from 'redux'; import { object, optional, pipe, readonly, safeParse, type InferInput } from 'valibot'; @@ -24,6 +26,14 @@ 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) { @@ -32,8 +42,8 @@ function SuggestedActionsComposer(props: SuggestedActionsComposerProps) { store: { dispatch } } = validateProps(suggestedActionsComposerPropsSchema, props); - const [originActivity, setOriginActivity] = useState(); - const [suggestedActions, setSuggestedActionsRaw] = useState(EMPTY_ARRAY); + const [originActivity, setOriginActivity] = useOriginActivity(); + const [suggestedActions, setSuggestedActionsRaw] = useSuggestedActionsFromBit(); const setSuggestedActions = useCallback( suggestedActions => { setOriginActivity(undefined); @@ -89,5 +99,8 @@ function SuggestedActionsComposer(props: SuggestedActionsComposerProps) { return {children}; } -export default memo(SuggestedActionsComposer); +export default wrapWith(SuggestedActionsActivityComposer)( + wrapWith(OriginActivityComposer)(memo(SuggestedActionsComposer)) +); + export { suggestedActionsComposerPropsSchema, type SuggestedActionsComposerProps }; From 4db3f18d7b1afb0d3cb60bf1a79904177f85b67d Mon Sep 17 00:00:00 2001 From: William Wong Date: Mon, 2 Jun 2025 23:34:41 +0000 Subject: [PATCH 24/38] Add deps --- .../src/suggestedActions/SuggestedActionsComposer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/redux-store/src/suggestedActions/SuggestedActionsComposer.tsx b/packages/redux-store/src/suggestedActions/SuggestedActionsComposer.tsx index 844b26a422..e78fec7bc9 100644 --- a/packages/redux-store/src/suggestedActions/SuggestedActionsComposer.tsx +++ b/packages/redux-store/src/suggestedActions/SuggestedActionsComposer.tsx @@ -49,7 +49,7 @@ function SuggestedActionsComposer(props: SuggestedActionsComposerProps) { setOriginActivity(undefined); setSuggestedActionsRaw(suggestedActions); }, - [setSuggestedActionsRaw] + [setOriginActivity, setSuggestedActionsRaw] ); // #region Replicate to Redux store From 5ef55e9ec07beb023f80bc33b2a99947ca0462de Mon Sep 17 00:00:00 2001 From: William Wong Date: Tue, 3 Jun 2025 02:30:05 +0000 Subject: [PATCH 25/38] Move clearSuggestedActionsOnPostActivity saga --- .../hooks.useSuggestedActions.html | 6 +- ...a.clearSuggestedActionsOnPostActivity.html | 100 +++++++++++++++ ...tionsOnPostActivity.whileNotConnected.html | 115 ++++++++++++++++++ .../hooks.useConnectionDetails.html | 89 ++++++++++++++ packages/api/src/hooks/index.ts | 2 +- packages/core/src/actions/postActivity.ts | 94 +++++++------- .../private/createMiddlewareActionSchemas.ts | 55 +++++++++ packages/core/src/createSagas.ts | 2 - packages/core/src/internal/index.ts | 5 + ...clearSuggestedActionsOnPostActivitySaga.js | 20 --- packages/core/src/sagas/postActivitySaga.ts | 42 ++++--- .../redux-store/src/ReduxStoreComposer.tsx | 7 +- packages/redux-store/src/index.ts | 1 + .../SuggestedActionsComposer.tsx | 21 +++- .../src/whileConnected/ConnectionDetails.ts | 9 ++ .../whileConnected/WhileConnectedComposer.tsx | 87 +++++++++++++ .../private/WhileConnectedContext.ts | 18 +++ .../whileConnected/useWhileConnectedHooks.ts | 7 ++ 18 files changed, 587 insertions(+), 93 deletions(-) create mode 100644 __tests__/html2/store/suggestedActions/saga.clearSuggestedActionsOnPostActivity.html create mode 100644 __tests__/html2/store/suggestedActions/saga.clearSuggestedActionsOnPostActivity.whileNotConnected.html create mode 100644 __tests__/html2/store/whileConnected/hooks.useConnectionDetails.html create mode 100644 packages/core/src/actions/private/createMiddlewareActionSchemas.ts delete mode 100644 packages/core/src/sagas/clearSuggestedActionsOnPostActivitySaga.js create mode 100644 packages/redux-store/src/whileConnected/ConnectionDetails.ts create mode 100644 packages/redux-store/src/whileConnected/WhileConnectedComposer.tsx create mode 100644 packages/redux-store/src/whileConnected/private/WhileConnectedContext.ts create mode 100644 packages/redux-store/src/whileConnected/useWhileConnectedHooks.ts diff --git a/__tests__/html2/store/suggestedActions/hooks.useSuggestedActions.html b/__tests__/html2/store/suggestedActions/hooks.useSuggestedActions.html index f8219140e4..c90f75e485 100644 --- a/__tests__/html2/store/suggestedActions/hooks.useSuggestedActions.html +++ b/__tests__/html2/store/suggestedActions/hooks.useSuggestedActions.html @@ -128,16 +128,16 @@ // WHEN: useSuggestedActions() is called with no suggested actions. renderResult.rerender({ suggestedActions: [] }); - // THEN: Should hide suggested actions. + // THEN: UI should hide suggested actions. await waitFor(() => expect(pageElements.allByTestId(testIds.suggestedActionButton)).toHaveLength(0)); - // THEN: Should return 0 suggested actions. + // THEN: useSuggestedActions() should return no suggested actions. renderResult.rerender(); await waitFor(() => expect(renderResult).toHaveProperty('result.current', [[], expect.any(Function), { activity: undefined }]) ); - // THEN: getState() should have 1 suggested action. + // THEN: getState() should have no suggested actions. expect(store.getState().suggestedActions).toHaveLength(0); }); 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/packages/api/src/hooks/index.ts b/packages/api/src/hooks/index.ts index e67119ddac..c08c6e212e 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 { useSuggestedActionsHooks, useWhileConnectedHooks } from 'botframework-webchat-redux-store'; export { useActiveTyping, diff --git a/packages/core/src/actions/postActivity.ts b/packages/core/src/actions/postActivity.ts index fe9cde2f26..c545324ec0 100644 --- a/packages/core/src/actions/postActivity.ts +++ b/packages/core/src/actions/postActivity.ts @@ -1,47 +1,40 @@ -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, + 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 +45,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..0d33d34361 --- /dev/null +++ b/packages/core/src/actions/private/createMiddlewareActionSchemas.ts @@ -0,0 +1,55 @@ +import { instance, literal, object, pipe, readonly, type BaseIssue, type BaseSchema } from 'valibot'; + +export default function createMiddlewareActionSchemas< + const TName extends string, + const TPayloadSchema extends BaseSchema>, + const TMetaSchema extends BaseSchema> +>(actionName: TName, payloadSchema: TPayloadSchema, metaSchema: TMetaSchema) { + return Object.freeze({ + fulfilled: { + name: `${actionName}_FULFILLED` as const, + schema: pipe( + object({ + meta: metaSchema, + payload: payloadSchema, + type: literal(`${actionName}_FULFILLED`) + }), + readonly() + ) + }, + impeded: { + name: `${actionName}_IMPEDED` as const, + schema: pipe( + object({ + meta: metaSchema, + payload: payloadSchema, + type: literal(`${actionName}_IMPEDED`) + }), + readonly() + ) + }, + pending: { + name: `${actionName}_PENDING` as const, + schema: pipe( + object({ + meta: metaSchema, + payload: payloadSchema, + type: literal(`${actionName}_PENDING`) + }), + readonly() + ) + }, + rejected: { + name: `${actionName}_REJECTED` as const, + schema: pipe( + object({ + error: literal(true), + meta: metaSchema, + payload: instance(Error), + type: literal(`${actionName}_REJECTED`) + }), + readonly() + ) + } + }); +} 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/index.ts b/packages/core/src/internal/index.ts index ed1032d833..6c1f7ffc8c 100644 --- a/packages/core/src/internal/index.ts +++ b/packages/core/src/internal/index.ts @@ -4,3 +4,8 @@ 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'; 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..4b3c2039b1 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,13 @@ function* postActivity( payload: { activity: outgoingActivity } } as PostActivityImpededAction); + // redux-saga silenced the error thrown. + if (echoed) { + console.error('botframework-webchat: Timed out while waiting for postActivity to return any values'); + } else { + console.error('botframework-webchat: Timed out while waiting for outgoing message to echo back'); + } + yield call(sleep, HARD_SEND_TIMEOUT - sendTimeout, ponyfill); throw !echoed @@ -188,6 +191,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/redux-store/src/ReduxStoreComposer.tsx b/packages/redux-store/src/ReduxStoreComposer.tsx index 053a7242e4..962d8241d1 100644 --- a/packages/redux-store/src/ReduxStoreComposer.tsx +++ b/packages/redux-store/src/ReduxStoreComposer.tsx @@ -4,6 +4,7 @@ import { object, optional, pipe, readonly, type InferInput } from 'valibot'; import reduxStoreSchema from './private/reduxStoreSchema'; import SuggestedActionsComposer from './suggestedActions/SuggestedActionsComposer'; +import WhileConnectedComposer from './whileConnected/WhileConnectedComposer'; const reduxStoreComposerPropsSchema = pipe( object({ @@ -23,7 +24,11 @@ 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..76a29f4af2 100644 --- a/packages/redux-store/src/index.ts +++ b/packages/redux-store/src/index.ts @@ -1,2 +1,3 @@ export { default as ReduxStoreComposer } from './ReduxStoreComposer'; export { default as useSuggestedActionsHooks } from './suggestedActions/useSuggestedActionsHooks'; +export { default as useWhileConnectedHooks } from './whileConnected/useWhileConnectedHooks'; diff --git a/packages/redux-store/src/suggestedActions/SuggestedActionsComposer.tsx b/packages/redux-store/src/suggestedActions/SuggestedActionsComposer.tsx index e78fec7bc9..88d92c4e8e 100644 --- a/packages/redux-store/src/suggestedActions/SuggestedActionsComposer.tsx +++ b/packages/redux-store/src/suggestedActions/SuggestedActionsComposer.tsx @@ -5,15 +5,21 @@ 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 } 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 useWhileConnectedHooks from '../whileConnected/useWhileConnectedHooks'; import SuggestedActionsContext, { type SuggestedActionsContextType } from './private/SuggestedActionsContext'; const suggestedActionsComposerPropsSchema = pipe( @@ -42,6 +48,7 @@ function SuggestedActionsComposer(props: SuggestedActionsComposerProps) { store: { dispatch } } = validateProps(suggestedActionsComposerPropsSchema, props); + const [connectionDetails] = useWhileConnectedHooks().useConnectionDetails(); const [originActivity, setOriginActivity] = useOriginActivity(); const [suggestedActions, setSuggestedActionsRaw] = useSuggestedActionsFromBit(); const setSuggestedActions = useCallback( @@ -52,6 +59,8 @@ function SuggestedActionsComposer(props: SuggestedActionsComposerProps) { [setOriginActivity, setSuggestedActionsRaw] ); + const connectionDetailsRef = useRefFrom(connectionDetails); + // #region Replicate to Redux store const handleAction = useCallback( (action: Action) => { @@ -69,6 +78,16 @@ function SuggestedActionsComposer(props: SuggestedActionsComposerProps) { setOriginActivity(originActivity); setSuggestedActionsRaw(Object.freeze(Array.from(suggestedActions))); } + } else if (action.type === POST_ACTIVITY_PENDING) { + // TODO: Add test. + if (connectionDetailsRef.current) { + const result = safeParse(postActivityPendingActionSchema, action); + + if (result.success) { + setOriginActivity(undefined); + setSuggestedActionsRaw(EMPTY_ARRAY); + } + } } }, [setOriginActivity, setSuggestedActionsRaw] diff --git a/packages/redux-store/src/whileConnected/ConnectionDetails.ts b/packages/redux-store/src/whileConnected/ConnectionDetails.ts new file mode 100644 index 0000000000..5173af3689 --- /dev/null +++ b/packages/redux-store/src/whileConnected/ConnectionDetails.ts @@ -0,0 +1,9 @@ +import { type DirectLineJSBotConnection } from 'botframework-webchat-core'; + +type ConnectionDetails = Readonly<{ + directLine: DirectLineJSBotConnection; + userId: string; + username: string; +}>; + +export { 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..65a01dad8f --- /dev/null +++ b/packages/redux-store/src/whileConnected/WhileConnectedComposer.tsx @@ -0,0 +1,87 @@ +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, useEffect, 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, type InferInput } from 'valibot'; + +import reduxStoreSchema from '../private/reduxStoreSchema'; +import { 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: { dispatch } + } = 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) { + setConnectionDetails( + Object.freeze({ + // TODO: Add valibot to underlying action. + directLine: (action as any).payload.directLine, + userId: (action as any).meta.userId, + username: (action as any).meta.username + }) + ); + } + } + }, + [connectionDetailsRef, setConnectionDetails] + ); + + 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 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); +} From 32692828bb0ce2f97e916fbc56c9d585fd9dfdf0 Mon Sep 17 00:00:00 2001 From: William Wong Date: Tue, 3 Jun 2025 05:15:55 +0000 Subject: [PATCH 26/38] Add deps --- package-lock.json | 7 ++++--- packages/react-context/package.json | 7 +++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5bc7075186..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", @@ -27,8 +28,7 @@ "./packages/bundle", "./packages/test/page-object", "./packages/fluent-theme", - "./packages/test/fluent-bundle", - "packages/react-context" + "./packages/test/fluent-bundle" ], "dependencies": { "react": "16.8.6", @@ -26674,7 +26674,8 @@ }, "devDependencies": { "@tsconfig/strictest": "^2.0.5", - "@types/react": "^16.14.62" + "@types/react": "^16.14.62", + "botframework-webchat-react-valibot": "^0.0.0-0" }, "peerDependencies": { "react": ">= 16.8.6" diff --git a/packages/react-context/package.json b/packages/react-context/package.json index 54e5e1a0f9..86c37043da 100644 --- a/packages/react-context/package.json +++ b/packages/react-context/package.json @@ -45,10 +45,13 @@ "start": "concurrently --kill-others --prefix-colors \"auto\" \"npm:start:*\"", "start:tsup": "npm run build:tsup -- --watch" }, - "localDependencies": {}, + "localDependencies": { + "botframework-webchat-react-valibot": "development" + }, "devDependencies": { "@tsconfig/strictest": "^2.0.5", - "@types/react": "^16.14.62" + "@types/react": "^16.14.62", + "botframework-webchat-react-valibot": "^0.0.0-0" }, "dependencies": { "valibot": "1.1.0" From 22fb1a326a6c0667f2596f39f93aab19f84d6810 Mon Sep 17 00:00:00 2001 From: William Wong Date: Tue, 3 Jun 2025 05:23:29 +0000 Subject: [PATCH 27/38] Change build order --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index edebc07f9a..e207fb9544 100644 --- a/package.json +++ b/package.json @@ -20,8 +20,8 @@ "./packages/test/harness", "./packages/test/web-server", "./packages/core", - "./packages/react-context", "./packages/react-valibot", + "./packages/react-context", "./packages/redux-store", "./packages/styles", "./packages/support/cldr-data-downloader", From d015b98cb83405466aad3f63ec2520037e1f3611 Mon Sep 17 00:00:00 2001 From: William Wong Date: Tue, 3 Jun 2025 05:41:23 +0000 Subject: [PATCH 28/38] Use warning instead --- packages/core/src/sagas/postActivitySaga.ts | 8 ++++++-- .../src/suggestedActions/SuggestedActionsComposer.tsx | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/core/src/sagas/postActivitySaga.ts b/packages/core/src/sagas/postActivitySaga.ts index 4b3c2039b1..bf16a6d853 100644 --- a/packages/core/src/sagas/postActivitySaga.ts +++ b/packages/core/src/sagas/postActivitySaga.ts @@ -155,9 +155,13 @@ function* postActivity( // redux-saga silenced the error thrown. if (echoed) { - console.error('botframework-webchat: Timed out while waiting for postActivity to return any values'); + console.warn('botframework-webchat: Timed out while waiting for postActivity to return any values', { + activity: outgoingActivity + }); } else { - console.error('botframework-webchat: Timed out while waiting for outgoing message to echo back'); + console.warn('botframework-webchat: Timed out while waiting for outgoing message to echo back', { + activity: outgoingActivity + }); } yield call(sleep, HARD_SEND_TIMEOUT - sendTimeout, ponyfill); diff --git a/packages/redux-store/src/suggestedActions/SuggestedActionsComposer.tsx b/packages/redux-store/src/suggestedActions/SuggestedActionsComposer.tsx index 88d92c4e8e..5c8a721b0d 100644 --- a/packages/redux-store/src/suggestedActions/SuggestedActionsComposer.tsx +++ b/packages/redux-store/src/suggestedActions/SuggestedActionsComposer.tsx @@ -79,7 +79,7 @@ function SuggestedActionsComposer(props: SuggestedActionsComposerProps) { setSuggestedActionsRaw(Object.freeze(Array.from(suggestedActions))); } } else if (action.type === POST_ACTIVITY_PENDING) { - // TODO: Add test. + // TODO: Add test for "not connected, should not clear suggested actions." if (connectionDetailsRef.current) { const result = safeParse(postActivityPendingActionSchema, action); From 5312d523bfae4d7f6d8b52aac654f0ba2b6b8a23 Mon Sep 17 00:00:00 2001 From: William Wong Date: Tue, 3 Jun 2025 06:27:36 +0000 Subject: [PATCH 29/38] Allow arbitrary suffix --- packages/core/src/actions/postActivity.ts | 17 ++-- .../private/createMiddlewareActionSchemas.ts | 85 ++++++++++++------- 2 files changed, 61 insertions(+), 41 deletions(-) diff --git a/packages/core/src/actions/postActivity.ts b/packages/core/src/actions/postActivity.ts index c545324ec0..2dbba42e43 100644 --- a/packages/core/src/actions/postActivity.ts +++ b/packages/core/src/actions/postActivity.ts @@ -16,19 +16,20 @@ const postActivityActionSchema = pipe( 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 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 postActivityFulfilledActionSchema = middlewareActionSchemas.FULFILLED.schema; +const postActivityImpededActionSchema = middlewareActionSchemas.IMPEDED.schema; +const postActivityPendingActionSchema = middlewareActionSchemas.PENDING.schema; +const postActivityRejectedActionSchema = middlewareActionSchemas.REJECTED.schema; type PostActivityAction = InferOutput; type PostActivityFulfilledAction = InferOutput; diff --git a/packages/core/src/actions/private/createMiddlewareActionSchemas.ts b/packages/core/src/actions/private/createMiddlewareActionSchemas.ts index 0d33d34361..2ce5d6b63c 100644 --- a/packages/core/src/actions/private/createMiddlewareActionSchemas.ts +++ b/packages/core/src/actions/private/createMiddlewareActionSchemas.ts @@ -1,52 +1,71 @@ -import { instance, literal, object, pipe, readonly, type BaseIssue, type BaseSchema } from 'valibot'; +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 suffixesSchema = array(allowedSuffixSchema); export default function createMiddlewareActionSchemas< const TName extends string, const TPayloadSchema extends BaseSchema>, - const TMetaSchema extends BaseSchema> ->(actionName: TName, payloadSchema: TPayloadSchema, metaSchema: TMetaSchema) { - return Object.freeze({ - fulfilled: { - name: `${actionName}_FULFILLED` as const, - schema: pipe( - object({ - meta: metaSchema, - payload: payloadSchema, - type: literal(`${actionName}_FULFILLED`) - }), - readonly() - ) - }, - impeded: { - name: `${actionName}_IMPEDED` as const, - schema: pipe( - object({ - meta: metaSchema, - payload: payloadSchema, - type: literal(`${actionName}_IMPEDED`) - }), - readonly() - ) - }, - pending: { - name: `${actionName}_PENDING` as const, + 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(suffixesSchema, 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(`${actionName}_PENDING`) + type: literal(`${prefix}_${suffix}`) }), readonly() ) - }, - rejected: { - name: `${actionName}_REJECTED` as const, + }; + } + + return Object.freeze({ + ...result, + REJECTED: { + name: `${prefix}_REJECTED` as const, schema: pipe( object({ error: literal(true), meta: metaSchema, payload: instance(Error), - type: literal(`${actionName}_REJECTED`) + type: literal(`${prefix}_REJECTED`) }), readonly() ) From f54f2482365b3ce35edb7dbacacdeca1892f499c Mon Sep 17 00:00:00 2001 From: William Wong Date: Tue, 3 Jun 2025 06:33:33 +0000 Subject: [PATCH 30/38] Rename --- .../core/src/actions/private/createMiddlewareActionSchemas.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/actions/private/createMiddlewareActionSchemas.ts b/packages/core/src/actions/private/createMiddlewareActionSchemas.ts index 2ce5d6b63c..3a6aa976d6 100644 --- a/packages/core/src/actions/private/createMiddlewareActionSchemas.ts +++ b/packages/core/src/actions/private/createMiddlewareActionSchemas.ts @@ -17,7 +17,7 @@ const allowedSuffixSchema = picklist(['FULFILLED', 'IMPEDED', 'PENDING']); type AllowedSuffix = InferOutput; -const suffixesSchema = array(allowedSuffixSchema); +const allowedSuffixesSchema = array(allowedSuffixSchema); export default function createMiddlewareActionSchemas< const TName extends string, @@ -40,7 +40,7 @@ export default function createMiddlewareActionSchemas< }>; } = {} as any; - for (const suffix of parse(suffixesSchema, suffixes)) { + for (const suffix of parse(allowedSuffixesSchema, suffixes)) { // We use allowlist to filter the suffix. // eslint-disable-next-line security/detect-object-injection result[suffix] = { From 3ac8e668f25c73cbe7ab4dafd3baabfb8800ce95 Mon Sep 17 00:00:00 2001 From: William Wong Date: Tue, 3 Jun 2025 06:46:03 +0000 Subject: [PATCH 31/38] Add comment --- .../src/suggestedActions/SuggestedActionsComposer.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/redux-store/src/suggestedActions/SuggestedActionsComposer.tsx b/packages/redux-store/src/suggestedActions/SuggestedActionsComposer.tsx index 5c8a721b0d..0e799a078f 100644 --- a/packages/redux-store/src/suggestedActions/SuggestedActionsComposer.tsx +++ b/packages/redux-store/src/suggestedActions/SuggestedActionsComposer.tsx @@ -79,6 +79,7 @@ function SuggestedActionsComposer(props: SuggestedActionsComposerProps) { setSuggestedActionsRaw(Object.freeze(Array.from(suggestedActions))); } } 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); From c2cfcd789b3cfd194bb4c5aa17644ab7244f5f5b Mon Sep 17 00:00:00 2001 From: William Wong Date: Tue, 3 Jun 2025 07:22:48 +0000 Subject: [PATCH 32/38] Rename --- packages/react-context/src/createBitContext.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-context/src/createBitContext.tsx b/packages/react-context/src/createBitContext.tsx index f85f77a94d..4e1e961505 100644 --- a/packages/react-context/src/createBitContext.tsx +++ b/packages/react-context/src/createBitContext.tsx @@ -25,7 +25,7 @@ export default function createBitContext(initialValue: T): Readonly<{ Composer: ComponentType; useState(): readonly [T, Dispatch>]; }> { - const AtomContext = createContext>( + const BitContext = createContext>( new Proxy({} as BitContextType, { get() { throw new Error('botframework-webchat: This hook can only be used under its corresponding context.'); @@ -38,11 +38,11 @@ export default function createBitContext(initialValue: T): Readonly<{ const context = useState[0]>(() => initialValue); - return {children}; + return {children}; } return Object.freeze({ Composer: memo(BitComposer), - useState: () => useContext(AtomContext) + useState: () => useContext(BitContext) }); } From 6c1d55678c461daf9cff7231f53b22d0bfda3328 Mon Sep 17 00:00:00 2001 From: William Wong Date: Tue, 3 Jun 2025 10:35:25 +0000 Subject: [PATCH 33/38] Add valibot --- .../src/whileConnected/ConnectionDetails.ts | 17 +++++++++++------ .../whileConnected/WhileConnectedComposer.tsx | 6 +++--- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/redux-store/src/whileConnected/ConnectionDetails.ts b/packages/redux-store/src/whileConnected/ConnectionDetails.ts index 5173af3689..5a70c2fd67 100644 --- a/packages/redux-store/src/whileConnected/ConnectionDetails.ts +++ b/packages/redux-store/src/whileConnected/ConnectionDetails.ts @@ -1,9 +1,14 @@ import { type DirectLineJSBotConnection } from 'botframework-webchat-core'; +import { type InferOutput, custom, object, pipe, string, undefinedable } from 'valibot'; -type ConnectionDetails = Readonly<{ - directLine: DirectLineJSBotConnection; - userId: string; - username: string; -}>; +const connectionDetailsSchema = pipe( + object({ + directLine: custom(() => true), + userId: undefinedable(string()), + username: undefinedable(string()) + }) +); -export { type ConnectionDetails }; +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 index 65a01dad8f..f699ea0202 100644 --- a/packages/redux-store/src/whileConnected/WhileConnectedComposer.tsx +++ b/packages/redux-store/src/whileConnected/WhileConnectedComposer.tsx @@ -10,10 +10,10 @@ import React, { memo, useCallback, useEffect, 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, type InferInput } from 'valibot'; +import { object, optional, parse, pipe, readonly, type InferInput } from 'valibot'; import reduxStoreSchema from '../private/reduxStoreSchema'; -import { type ConnectionDetails } from './ConnectionDetails'; +import { connectionDetailsSchema, type ConnectionDetails } from './ConnectionDetails'; import WhileConnectedContext, { type WhileConnectedContextType } from './private/WhileConnectedContext'; const whileConnectedComposerPropsSchema = pipe( @@ -53,7 +53,7 @@ function WhileConnectedComposer(props: WhileConnectedComposerProps) { } else { if (action.type === CONNECT_FULFILLING || action.type === RECONNECT_FULFILLING) { setConnectionDetails( - Object.freeze({ + parse(connectionDetailsSchema, { // TODO: Add valibot to underlying action. directLine: (action as any).payload.directLine, userId: (action as any).meta.userId, From 0df7fd921c6957288dad2671e1d32ac520f4721c Mon Sep 17 00:00:00 2001 From: William Wong Date: Tue, 3 Jun 2025 12:31:53 +0000 Subject: [PATCH 34/38] Add ReduxActionSinkComposer --- .../ReduxActionSinkComposer.tsx | 46 +++++++++++++++++++ .../SuggestedActionsComposer.tsx | 18 ++++---- .../whileConnected/WhileConnectedComposer.tsx | 22 ++++----- 3 files changed, 62 insertions(+), 24 deletions(-) create mode 100644 packages/redux-store/src/reduxActionSink/ReduxActionSinkComposer.tsx diff --git a/packages/redux-store/src/reduxActionSink/ReduxActionSinkComposer.tsx b/packages/redux-store/src/reduxActionSink/ReduxActionSinkComposer.tsx new file mode 100644 index 0000000000..d0a267fb06 --- /dev/null +++ b/packages/redux-store/src/reduxActionSink/ReduxActionSinkComposer.tsx @@ -0,0 +1,46 @@ +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; + +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 ReduxActionSinkComposerProps }; diff --git a/packages/redux-store/src/suggestedActions/SuggestedActionsComposer.tsx b/packages/redux-store/src/suggestedActions/SuggestedActionsComposer.tsx index 0e799a078f..01e329aaab 100644 --- a/packages/redux-store/src/suggestedActions/SuggestedActionsComposer.tsx +++ b/packages/redux-store/src/suggestedActions/SuggestedActionsComposer.tsx @@ -12,13 +12,14 @@ import { } 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 } 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'; @@ -45,6 +46,7 @@ const EMPTY_ARRAY = Object.freeze([]); function SuggestedActionsComposer(props: SuggestedActionsComposerProps) { const { children, + store, store: { dispatch } } = validateProps(suggestedActionsComposerPropsSchema, props); @@ -99,14 +101,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( @@ -116,7 +110,11 @@ function SuggestedActionsComposer(props: SuggestedActionsComposerProps) { const context = useMemo(() => ({ useSuggestedActions }), [useSuggestedActions]); - return {children}; + return ( + + {children} + + ); } export default wrapWith(SuggestedActionsActivityComposer)( diff --git a/packages/redux-store/src/whileConnected/WhileConnectedComposer.tsx b/packages/redux-store/src/whileConnected/WhileConnectedComposer.tsx index f699ea0202..b52899ed9d 100644 --- a/packages/redux-store/src/whileConnected/WhileConnectedComposer.tsx +++ b/packages/redux-store/src/whileConnected/WhileConnectedComposer.tsx @@ -6,13 +6,14 @@ import { } 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, useEffect, useMemo } 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, parse, pipe, readonly, 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'; @@ -31,10 +32,7 @@ const { Composer: ConnectionDetailsComposer, useState: useConnectionDetailsFromB >(undefined); function WhileConnectedComposer(props: WhileConnectedComposerProps) { - const { - children, - store: { dispatch } - } = validateProps(whileConnectedComposerPropsSchema, props); + const { children, store } = validateProps(whileConnectedComposerPropsSchema, props); const connectionDetailsState = useConnectionDetailsFromBit(); @@ -65,14 +63,6 @@ function WhileConnectedComposer(props: WhileConnectedComposerProps) { }, [connectionDetailsRef, setConnectionDetails] ); - - 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 context = useMemo( @@ -80,7 +70,11 @@ function WhileConnectedComposer(props: WhileConnectedComposerProps) { [useConnectionDetails] ); - return {children}; + return ( + + {children} + + ); } export default wrapWith(ConnectionDetailsComposer)(memo(WhileConnectedComposer)); From 32f361f06f64abae48f8401818ce49351a4a9d3f Mon Sep 17 00:00:00 2001 From: William Wong Date: Tue, 3 Jun 2025 13:37:04 +0000 Subject: [PATCH 35/38] Move sendBoxAttachments from Redux to React --- .../sendAttachmentOn/useSendBoxAttachments.js | 5 -- .../useSendBoxAttachments.deprecated.html | 9 ++ ...BoxAttachments.deprecated.html.snap-1.png} | Bin ...BoxAttachments.deprecated.html.snap-2.png} | Bin .../useSendBoxAttachments.html | 32 +++++-- .../useSendBoxAttachments.html.snap-1.png | Bin 0 -> 11864 bytes .../useSendBoxAttachments.html.snap-2.png | Bin 0 -> 44330 bytes packages/api/src/hooks/Composer.tsx | 2 - packages/api/src/hooks/index.ts | 6 +- .../src/hooks/internal/WebChatAPIContext.ts | 4 +- .../api/src/hooks/useSendBoxAttachments.ts | 28 +++---- packages/api/src/hooks/useSubmitSendBox.ts | 6 +- packages/api/src/hooks/useSuggestedActions.ts | 2 +- .../SendBox/AttachmentBar/AttachmentBar.tsx | 8 +- .../src/SendBoxToolbar/UploadButton.tsx | 4 +- .../internal/SendBox/SendBoxComposer.tsx | 4 +- .../core/src/actions/setSendBoxAttachments.ts | 29 +++++-- .../core/src/internal/actions/setRawState.ts | 15 +++- packages/core/src/internal/index.ts | 1 + .../core/src/reducers/sendBoxAttachments.ts | 19 +---- packages/core/src/types/SendBoxAttachment.ts | 35 +++++++- .../src/components/sendBox/SendBox.tsx | 6 +- .../redux-store/src/ReduxStoreComposer.tsx | 5 +- packages/redux-store/src/index.ts | 1 + .../ReduxActionSinkComposer.tsx | 4 +- .../SendBoxAttachmentsComposer.tsx | 78 ++++++++++++++++++ .../private/SendBoxAttachmentsContext.ts | 20 +++++ .../useSendBoxAttachmentsHooks.ts | 7 ++ .../SuggestedActionsComposer.tsx | 10 +++ .../whileConnected/WhileConnectedComposer.tsx | 25 ++++-- 30 files changed, 274 insertions(+), 91 deletions(-) delete mode 100644 __tests__/html/sendAttachmentOn/useSendBoxAttachments.js create mode 100644 __tests__/html2/sendBox/sendAttachmentOn/useSendBoxAttachments.deprecated.html rename __tests__/{__image_snapshots__/html/use-send-box-attachments-js-call-use-send-box-attachments-hook-should-get-set-and-upload-attachments-1-snap.png => html2/sendBox/sendAttachmentOn/useSendBoxAttachments.deprecated.html.snap-1.png} (100%) rename __tests__/{__image_snapshots__/html/use-send-box-attachments-js-call-use-send-box-attachments-hook-should-get-set-and-upload-attachments-2-snap.png => html2/sendBox/sendAttachmentOn/useSendBoxAttachments.deprecated.html.snap-2.png} (100%) rename __tests__/{html => html2/sendBox}/sendAttachmentOn/useSendBoxAttachments.html (76%) create mode 100644 __tests__/html2/sendBox/sendAttachmentOn/useSendBoxAttachments.html.snap-1.png create mode 100644 __tests__/html2/sendBox/sendAttachmentOn/useSendBoxAttachments.html.snap-2.png create mode 100644 packages/redux-store/src/sendBoxAttachments/SendBoxAttachmentsComposer.tsx create mode 100644 packages/redux-store/src/sendBoxAttachments/private/SendBoxAttachmentsContext.ts create mode 100644 packages/redux-store/src/sendBoxAttachments/useSendBoxAttachmentsHooks.ts 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/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 76% rename from __tests__/html/sendAttachmentOn/useSendBoxAttachments.html rename to __tests__/html2/sendBox/sendAttachmentOn/useSendBoxAttachments.html index 6b1c0b680a..7447ab952e 100644 --- a/__tests__/html/sendAttachmentOn/useSendBoxAttachments.html +++ b/__tests__/html2/sendBox/sendAttachmentOn/useSendBoxAttachments.html @@ -12,6 +12,7 @@
+ + + + + + + +
+ + + 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/packages/api/src/hooks/index.ts b/packages/api/src/hooks/index.ts index 51de3302ce..fc1585f681 100644 --- a/packages/api/src/hooks/index.ts +++ b/packages/api/src/hooks/index.ts @@ -72,11 +72,7 @@ import useUserID from './useUserID'; import useUsername from './useUsername'; import useVoiceSelector from './useVoiceSelector'; -export { - useSendBoxAttachmentsHooks, - useSuggestedActionsHooks, - useWhileConnectedHooks -} from 'botframework-webchat-redux-store'; +export { useSendBoxHooks, useSuggestedActionsHooks, useWhileConnectedHooks } from 'botframework-webchat-redux-store'; export { useActiveTyping, diff --git a/packages/api/src/hooks/useSendBoxAttachments.ts b/packages/api/src/hooks/useSendBoxAttachments.ts index 588e8b5555..b571dfb818 100644 --- a/packages/api/src/hooks/useSendBoxAttachments.ts +++ b/packages/api/src/hooks/useSendBoxAttachments.ts @@ -1,9 +1,9 @@ import { type SendBoxAttachment } from 'botframework-webchat-core'; -import { useSendBoxAttachmentsHooks } from 'botframework-webchat-redux-store'; +import { useSendBoxHooks } from 'botframework-webchat-redux-store'; import { type Dispatch, type SetStateAction } from 'react'; /** - * @deprecated Use `useSendBoxAttachmentsHooks().useSendBoxAttachments()` instead. This hook will be removed on or after 2027-05-30. + * @deprecated Use `useSendBoxHooks().useSendBoxAttachments()` instead. This hook will be removed on or after 2027-05-30. */ export default function useSendBoxAttachments(): readonly [ readonly SendBoxAttachment[], @@ -11,5 +11,5 @@ export default function useSendBoxAttachments(): readonly [ ] { // Provides a path for backward compatibility during deprecation. // eslint-disable-next-line local-rules/forbid-use-hook-producer - return useSendBoxAttachmentsHooks().useSendBoxAttachments(); + 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 c1f1f77010..1bd65237c1 100644 --- a/packages/api/src/hooks/useSubmitSendBox.ts +++ b/packages/api/src/hooks/useSubmitSendBox.ts @@ -1,14 +1,14 @@ import { useCallback } from 'react'; import { useRefFrom } from 'use-ref-from'; -import { useSendBoxAttachmentsHooks } from './index'; +import { useSendBoxHooks } from './index'; import useWebChatAPIContext from './internal/useWebChatAPIContext'; import useTrackEvent from './useTrackEvent'; export default function useSubmitSendBox(): (method?: string, { channelData }?: { channelData: any }) => void { // TODO: Move the logic into APIContext.submitSendBox. // eslint-disable-next-line local-rules/forbid-use-hook-producer - const [sendBoxAttachments] = useSendBoxAttachmentsHooks().useSendBoxAttachments(); + const [sendBoxAttachments] = useSendBoxHooks().useSendBoxAttachments(); const { submitSendBox } = useWebChatAPIContext(); const trackEvent = useTrackEvent(); 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 2319790226..17891475b5 100644 --- a/packages/component/src/SendBox/AttachmentBar/AttachmentBar.tsx +++ b/packages/component/src/SendBox/AttachmentBar/AttachmentBar.tsx @@ -10,7 +10,7 @@ import testIds from '../../testIds'; import styles from './AttachmentBar.module.css'; import AttachmentBarItem from './AttachmentBarItem'; -const { useSendBoxAttachmentsHooks, 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 176319affe..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 { useSendBoxAttachmentsHooks, 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] = useSendBoxAttachmentsHooks().useSendBoxAttachments(); + const [sendBoxAttachments, setSendBoxAttachments] = useSendBoxHooks().useSendBoxAttachments(); const [uiState] = useUIState(); const focus = useFocus(); const inputRef = useRef(null); diff --git a/packages/component/src/providers/internal/SendBox/SendBoxComposer.tsx b/packages/component/src/providers/internal/SendBox/SendBoxComposer.tsx index 7e9d605a49..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, useSendBoxAttachmentsHooks, 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] = useSendBoxAttachmentsHooks().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/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/internal/actions/setRawState.ts b/packages/core/src/internal/actions/setRawState.ts index dad1e2677c..b3c248b787 100644 --- a/packages/core/src/internal/actions/setRawState.ts +++ b/packages/core/src/internal/actions/setRawState.ts @@ -1,4 +1,4 @@ -import { array, 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'; @@ -16,6 +16,13 @@ const setRawStateActionSchema = pipe( }), readonly() ), + pipe( + object({ + name: literal('sendBoxValue'), + state: pipe(object({ text: string() }), readonly()) + }), + readonly() + ), pipe( object({ name: literal('suggestedActions'), @@ -44,6 +51,11 @@ export default function setRawState( 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 f499804a33..7016bae7a7 100644 --- a/packages/core/src/internal/index.ts +++ b/packages/core/src/internal/index.ts @@ -9,4 +9,5 @@ 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 index ac17f43e9e..337abe9fb9 100644 --- a/packages/core/src/reducers/private/createRawReducer.ts +++ b/packages/core/src/reducers/private/createRawReducer.ts @@ -6,10 +6,17 @@ import { SET_RAW_STATE, setRawStateActionSchema, type SetRawStateAction } from ' function createRawReducer(name: SetRawStateAction['payload']['name'], defaultState: TState) { return (state: TState = defaultState, action: Action): TState => { if (action.type === SET_RAW_STATE) { - const { output, success } = safeParse(setRawStateActionSchema, action); + const result = safeParse(setRawStateActionSchema, action); - if (success && output.payload.name === name) { - return output.payload.state as TState; + 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 } + ); } } 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/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 404d964707..2cf84a4dd0 100644 --- a/packages/fluent-theme/src/components/sendBox/SendBox.tsx +++ b/packages/fluent-theme/src/components/sendBox/SendBox.tsx @@ -31,8 +31,7 @@ const { useLocalizer, useMakeThumbnail, useRegisterFocusSendBox, - useSendBoxAttachmentsHooks, - useSendBoxValue, + useSendBoxHooks, useSendMessage, useStyleOptions, useUIState @@ -49,8 +48,8 @@ type Props = Readonly<{ function SendBox(props: Props) { const [{ hideTelephoneKeypadButton, hideUploadButton, maxMessageLength }] = useStyleOptions(); - const [attachments, setAttachments] = useSendBoxAttachmentsHooks().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/redux-store/src/ReduxStoreComposer.tsx b/packages/redux-store/src/ReduxStoreComposer.tsx index 9c460bf6df..70cef1910a 100644 --- a/packages/redux-store/src/ReduxStoreComposer.tsx +++ b/packages/redux-store/src/ReduxStoreComposer.tsx @@ -3,7 +3,7 @@ import React, { memo } from 'react'; import { object, optional, pipe, readonly, type InferInput } from 'valibot'; import reduxStoreSchema from './private/reduxStoreSchema'; -import SendBoxAttachmentsComposer from './sendBoxAttachments/SendBoxAttachmentsComposer'; +import SendBoxComposer from './sendBox/SendBoxComposer'; import SuggestedActionsComposer from './suggestedActions/SuggestedActionsComposer'; import WhileConnectedComposer from './whileConnected/WhileConnectedComposer'; @@ -27,9 +27,9 @@ function ReduxStoreComposer(props: ReduxStoreComposerProps) { return ( - + {children} - + ); } diff --git a/packages/redux-store/src/index.ts b/packages/redux-store/src/index.ts index c45ee13fa4..24113fcbd3 100644 --- a/packages/redux-store/src/index.ts +++ b/packages/redux-store/src/index.ts @@ -1,4 +1,4 @@ export { default as ReduxStoreComposer } from './ReduxStoreComposer'; -export { default as useSendBoxAttachmentsHooks } from './sendBoxAttachments/useSendBoxAttachmentsHooks'; +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/sendBoxAttachments/SendBoxAttachmentsComposer.tsx b/packages/redux-store/src/sendBox/SendBoxComposer.tsx similarity index 55% rename from packages/redux-store/src/sendBoxAttachments/SendBoxAttachmentsComposer.tsx rename to packages/redux-store/src/sendBox/SendBoxComposer.tsx index 92bbef78d4..f10711b1c6 100644 --- a/packages/redux-store/src/sendBoxAttachments/SendBoxAttachmentsComposer.tsx +++ b/packages/redux-store/src/sendBox/SendBoxComposer.tsx @@ -1,7 +1,9 @@ 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'; @@ -12,9 +14,9 @@ import { object, optional, pipe, readonly, safeParse, type InferInput } from 'va import reduxStoreSchema from '../private/reduxStoreSchema'; import ReduxActionSinkComposer, { type ReduxActionHandler } from '../reduxActionSink/ReduxActionSinkComposer'; -import SendBoxAttachmentsContext, { type SendBoxAttachmentsContextType } from './private/SendBoxAttachmentsContext'; +import SendBoxContext, { type SendBoxContextType } from './private/SendBoxContext'; -const sendBoxAttachmentsComposerPropsSchema = pipe( +const sendBoxComposerPropsSchema = pipe( object({ children: optional(reactNode()), store: reduxStoreSchema @@ -22,20 +24,23 @@ const sendBoxAttachmentsComposerPropsSchema = pipe( readonly() ); -type SendBoxAttachmentsComposerProps = InferInput; +type SendBoxComposerProps = InferInput; -const { Composer: AttachmentsConposer, useState: useSendBoxAttachments } = createBitContext< +const { Composer: SendBoxAttachmentsComposer, useState: useSendBoxAttachments } = createBitContext< readonly SendBoxAttachment[] >(Object.freeze([])); -function SendBoxAttachmentsComposer(props: SendBoxAttachmentsComposerProps) { +const { Composer: SendBoxTextValueComposer, useState: useSendBoxTextValue } = createBitContext(''); + +function SendBoxComposer(props: SendBoxComposerProps) { const { children, store, store: { dispatch } - } = validateProps(sendBoxAttachmentsComposerPropsSchema, props); + } = validateProps(sendBoxComposerPropsSchema, props); const [attachments, setAttachments] = useSendBoxAttachments(); + const [textValue, setTextValue] = useSendBoxTextValue(); // #region Replicate to Redux store const handleAction = useCallback( @@ -51,6 +56,17 @@ function SendBoxAttachmentsComposer(props: SendBoxAttachmentsComposerProps) { { 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] @@ -59,20 +75,25 @@ function SendBoxAttachmentsComposer(props: SendBoxAttachmentsComposerProps) { useMemo(() => { dispatch(setRawState('sendBoxAttachments', attachments)); }, [attachments, dispatch]); + + useMemo(() => { + dispatch(setRawState('sendBoxValue', { text: textValue })); + }, [dispatch, textValue]); // #endregion - const context = useMemo( + const context = useMemo( () => ({ - useSendBoxAttachments + useSendBoxAttachments, + useSendBoxValue: useSendBoxTextValue }), - [useSendBoxAttachments] + [useSendBoxAttachments, useSendBoxTextValue] ); return ( - {children} + {children} ); } -export default wrapWith(AttachmentsConposer)(memo(SendBoxAttachmentsComposer)); +export default wrapWith(SendBoxAttachmentsComposer)(wrapWith(SendBoxTextValueComposer)(memo(SendBoxComposer))); diff --git a/packages/redux-store/src/sendBoxAttachments/private/SendBoxAttachmentsContext.ts b/packages/redux-store/src/sendBox/private/SendBoxContext.ts similarity index 54% rename from packages/redux-store/src/sendBoxAttachments/private/SendBoxAttachmentsContext.ts rename to packages/redux-store/src/sendBox/private/SendBoxContext.ts index 1db891dfc8..b1fa3c69c0 100644 --- a/packages/redux-store/src/sendBoxAttachments/private/SendBoxAttachmentsContext.ts +++ b/packages/redux-store/src/sendBox/private/SendBoxContext.ts @@ -1,20 +1,21 @@ import { type SendBoxAttachment } from 'botframework-webchat-core'; import { createContext, type Dispatch, type SetStateAction } from 'react'; -type SendBoxAttachmentsContextType = Readonly<{ +type SendBoxContextType = Readonly<{ useSendBoxAttachments: () => readonly [ readonly SendBoxAttachment[], Dispatch> ]; + useSendBoxValue: () => readonly [string, Dispatch>]; }>; -const SendBoxAttachmentsContext = createContext( - new Proxy({} as SendBoxAttachmentsContextType, { +const SendBoxContext = createContext( + new Proxy({} as SendBoxContextType, { get() { - throw new Error('botframework-webchat: This hook can only be used under '); + throw new Error('botframework-webchat: This hook can only be used under '); } }) ); -export default SendBoxAttachmentsContext; -export { type SendBoxAttachmentsContextType }; +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/sendBoxAttachments/useSendBoxAttachmentsHooks.ts b/packages/redux-store/src/sendBoxAttachments/useSendBoxAttachmentsHooks.ts deleted file mode 100644 index 1d380d7ce2..0000000000 --- a/packages/redux-store/src/sendBoxAttachments/useSendBoxAttachmentsHooks.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { useContext } from 'react'; - -import SendBoxAttachmentsContext, { type SendBoxAttachmentsContextType } from './private/SendBoxAttachmentsContext'; - -export default function useSendBoxAttachmentsHooks(): SendBoxAttachmentsContextType { - return useContext(SendBoxAttachmentsContext); -} From 9d838e10cf118f1c33802e4bac6c839b2cdff7e4 Mon Sep 17 00:00:00 2001 From: William Wong Date: Tue, 3 Jun 2025 16:43:36 +0000 Subject: [PATCH 37/38] Fix sendBoxValue state --- packages/core/src/internal/actions/setRawState.ts | 2 +- packages/redux-store/src/sendBox/SendBoxComposer.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/internal/actions/setRawState.ts b/packages/core/src/internal/actions/setRawState.ts index b3c248b787..25feee70c6 100644 --- a/packages/core/src/internal/actions/setRawState.ts +++ b/packages/core/src/internal/actions/setRawState.ts @@ -19,7 +19,7 @@ const setRawStateActionSchema = pipe( pipe( object({ name: literal('sendBoxValue'), - state: pipe(object({ text: string() }), readonly()) + state: string() }), readonly() ), diff --git a/packages/redux-store/src/sendBox/SendBoxComposer.tsx b/packages/redux-store/src/sendBox/SendBoxComposer.tsx index f10711b1c6..a56b00d113 100644 --- a/packages/redux-store/src/sendBox/SendBoxComposer.tsx +++ b/packages/redux-store/src/sendBox/SendBoxComposer.tsx @@ -77,7 +77,7 @@ function SendBoxComposer(props: SendBoxComposerProps) { }, [attachments, dispatch]); useMemo(() => { - dispatch(setRawState('sendBoxValue', { text: textValue })); + dispatch(setRawState('sendBoxValue', textValue)); }, [dispatch, textValue]); // #endregion From 0635125297762acfbddba49ef80abd6bbffafb39 Mon Sep 17 00:00:00 2001 From: William Wong Date: Tue, 3 Jun 2025 17:09:42 +0000 Subject: [PATCH 38/38] Fix test --- .../sendBox/sendAttachmentOn/useSendBoxAttachments.html | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/__tests__/html2/sendBox/sendAttachmentOn/useSendBoxAttachments.html b/__tests__/html2/sendBox/sendAttachmentOn/useSendBoxAttachments.html index 7447ab952e..dab3627e28 100644 --- a/__tests__/html2/sendBox/sendAttachmentOn/useSendBoxAttachments.html +++ b/__tests__/html2/sendBox/sendAttachmentOn/useSendBoxAttachments.html @@ -27,7 +27,7 @@ const initialSendBoxAttachments = await pageObjects.runHook(hooks => useDeprecatedHook ? hooks.useSendBoxAttachments()[0] - : hooks.useSendBoxAttachmentsHooks().useSendBoxAttachments()[0] + : hooks.useSendBoxHooks().useSendBoxAttachments()[0] ); // THEN: It should return empty array. @@ -68,7 +68,7 @@ if (useDeprecatedHook) { hooks.useSendBoxAttachments()[1](attachmentsRef.current); } else { - hooks.useSendBoxAttachmentsHooks().useSendBoxAttachments()[1](attachmentsRef.current); + hooks.useSendBoxHooks().useSendBoxAttachments()[1](attachmentsRef.current); } }); @@ -79,7 +79,7 @@ const sendBoxAttachments = await pageObjects.runHook(hooks => useDeprecatedHook ? hooks.useSendBoxAttachments()[0] - : hooks.useSendBoxAttachmentsHooks().useSendBoxAttachments()[0] + : hooks.useSendBoxHooks().useSendBoxAttachments()[0] ); // THEN: It should return 1 attachment. @@ -98,7 +98,7 @@ const finalSendBoxAttachments = await pageObjects.runHook(hooks => useDeprecatedHook ? hooks.useSendBoxAttachments()[0] - : hooks.useSendBoxAttachmentsHooks().useSendBoxAttachments()[0] + : hooks.useSendBoxHooks().useSendBoxAttachments()[0] ); // THEN: It should return 0 attachments.