diff --git a/CHANGELOG.md b/CHANGELOG.md index 33924543b4..00357e3188 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ Breaking changes in this release: ### Added +- Resolves screen reader not announcing when a message is being sent. Added live region narration of `Sending message.` via a new `LiveRegionSendSending` component, by [@isherstneva](https://github.com/isherstneva) - (Experimental) Added pre-chat message with starter prompts in Fluent UI, in PR [#5255](https://github.com/microsoft/BotFramework-WebChat/issues/5255) and [#5263](https://github.com/microsoft/BotFramework-WebChat/issues/5263), by [@compulim](https://github.com/compulim) - (Experimental) Added `isPrimary` props to Fluent UI send box. When set, will wire up with `useSendBoxValue` and works with starter prompts in pre-chat message, in PR [#5257](https://github.com/microsoft/BotFramework-WebChat/issues/5257), by [@compulim](https://github.com/compulim) - (Experimental) Expand Fluent theme support to activities and transcript, in PR [#5258](https://github.com/microsoft/BotFramework-WebChat/pull/5258), by [@OEvgeny](https://github.com/OEvgeny) diff --git a/__tests__/html2/accessibility/liveRegion/activityStatus.longSend.html b/__tests__/html2/accessibility/liveRegion/activityStatus.longSend.html new file mode 100644 index 0000000000..03a51b705c --- /dev/null +++ b/__tests__/html2/accessibility/liveRegion/activityStatus.longSend.html @@ -0,0 +1,60 @@ + + + + + + + + + +
+ + + diff --git a/packages/api/src/boot/hook.ts b/packages/api/src/boot/hook.ts index a4e8108168..d2596325d5 100644 --- a/packages/api/src/boot/hook.ts +++ b/packages/api/src/boot/hook.ts @@ -32,6 +32,7 @@ export { useGroupActivities, useGroupActivitiesByName, useGroupTimestamp, + useIsSending, useLanguage, useLastAcknowledgedActivityKey, useLastReadActivityKey, diff --git a/packages/api/src/hooks/index.ts b/packages/api/src/hooks/index.ts index f206ca7ec3..d0891a61e1 100644 --- a/packages/api/src/hooks/index.ts +++ b/packages/api/src/hooks/index.ts @@ -30,6 +30,7 @@ import useGetKeyByActivityId from './useGetKeyByActivityId'; import useGetSendTimeoutForActivity from './useGetSendTimeoutForActivity'; import useGrammars from './useGrammars'; import useGroupTimestamp from './useGroupTimestamp'; +import useIsSending from './useIsSending'; import useLanguage from './useLanguage'; import useLastAcknowledgedActivityKey from './useLastAcknowledgedActivityKey'; import useLastReadActivityKey from './useLastReadActivityKey'; @@ -113,6 +114,7 @@ export { useGroupActivities, useGroupActivitiesByName, useGroupTimestamp, + useIsSending, useLanguage, useLastAcknowledgedActivityKey, useLastReadActivityKey, diff --git a/packages/api/src/hooks/useIsSending.ts b/packages/api/src/hooks/useIsSending.ts new file mode 100644 index 0000000000..e157afef34 --- /dev/null +++ b/packages/api/src/hooks/useIsSending.ts @@ -0,0 +1 @@ +export { default as useIsSending } from '../providers/ActivitySendStatus/useIsSending'; diff --git a/packages/api/src/localization/en-US.json b/packages/api/src/localization/en-US.json index 7e22ba4c18..2ac6157f48 100644 --- a/packages/api/src/localization/en-US.json +++ b/packages/api/src/localization/en-US.json @@ -189,6 +189,8 @@ "TRANSCRIPT_LIVE_REGION_SUGGESTED_ACTIONS_WITH_ACCESS_KEY_LABEL_ALT": "Message has suggested actions. Press $1 to select them.", "_TRANSCRIPT_LIVE_REGION_SUGGESTED_ACTIONS_WITH_ACCESS_KEY_LABEL_ALT.comment": "$1 will be \"ACCESS_KEY_ALT\".", "TRANSCRIPT_LIVE_REGION_SEND_FAILED_ALT": "Failed to send message.", + "TRANSCRIPT_LIVE_REGION_SEND_SENDING_ALT": "Sending message.", + "_TRANSCRIPT_LIVE_REGION_SEND_SENDING_ALT.comment": "This is for screen reader. When the user sends a message, the live region will announce this string to indicate the message is being sent.", "TRANSCRIPT_LIVE_REGION_NEW_MESSAGES_ALT": "New messages available. Press $1 to focus the \"$2\" button.", "_TRANSCRIPT_LIVE_REGION_NEW_MESSAGES_ALT.comment": "$1 will be \"ACCESS_KEY_ALT\".", "TRANSCRIPT_MORE_MESSAGES": "More messages", diff --git a/packages/api/src/providers/ActivitySendStatus/ActivitySendStatusComposer.tsx b/packages/api/src/providers/ActivitySendStatus/ActivitySendStatusComposer.tsx index 8bcbad8148..e01b66b4b1 100644 --- a/packages/api/src/providers/ActivitySendStatus/ActivitySendStatusComposer.tsx +++ b/packages/api/src/providers/ActivitySendStatus/ActivitySendStatusComposer.tsx @@ -1,7 +1,8 @@ -import React, { useEffect, useMemo, useRef, type ReactNode } from 'react'; import { querySendStatusFromOutgoingActivity } from 'botframework-webchat-core/activity'; +import { isPresentational } from 'botframework-webchat-core/internal'; +import React, { useEffect, useMemo, useRef, type ReactNode } from 'react'; -import { useActivities, usePonyfill } from '../../hooks/index'; +import { useActivities, useGetActivityByKey, usePonyfill } from '../../hooks/index'; import useForceRender from '../../hooks/internal/useForceRender'; import useGetSendTimeoutForActivity from '../../hooks/useGetSendTimeoutForActivity'; import type { SendStatus } from '../../types/SendStatus'; @@ -10,15 +11,18 @@ import useGetKeyByActivity from '../ActivityKeyer/useGetKeyByActivity'; import type { ActivitySendStatusContextType } from './private/Context'; import ActivitySendStatusContext from './private/Context'; import isMapEqual from './private/isMapEqual'; +import type { ActivitySendStatusSubContextType } from './private/SubContext'; +import ActivitySendStatusSubContext from './private/SubContext'; // Magic numbers for `expiryByActivityKey`. const EXPIRY_SEND_FAILED = -Infinity; const EXPIRY_SENT = Infinity; const ActivitySendStatusComposer = ({ children }: Readonly<{ children?: ReactNode | undefined }>) => { - const [activities] = useActivities(); const [{ clearTimeout, Date, setTimeout }] = usePonyfill(); + const [activities] = useActivities(); const forceRender = useForceRender(); + const getActivityByKey = useGetActivityByKey(); const getKeyByActivity = useGetKeyByActivity(); const getSendTimeoutForActivity = useGetSendTimeoutForActivity(); const sendStatusByActivityKeyRef = useRef>(Object.freeze(new Map())); @@ -93,11 +97,30 @@ const ActivitySendStatusComposer = ({ children }: Readonly<{ children?: ReactNod [sendStatusByActivityKey] ); + const isSendingState = useMemo( + () => + Object.freeze([ + sendStatusByActivityKey.entries().some(([activityKey, status]) => { + if (status === 'sending') { + const activity = getActivityByKey(activityKey); + + return activity && !isPresentational(activity); + } + }) + ]), + [getActivityByKey, sendStatusByActivityKey] + ); + const context = useMemo( - () => ({ sendStatusByActivityKeyState }), + () => Object.freeze({ sendStatusByActivityKeyState }), [sendStatusByActivityKeyState] ); + const subContext = useMemo( + () => Object.freeze({ isSendingState }), + [isSendingState] + ); + // Finds the closest expiry. This is the time we should recompute `sendStatusByActivityKey`. const nextExpiry = Array.from(expiryByActivityKey.values()) // Ignores activities which are already marked as `"send failed"`, because the magic number its `-Infinity`. @@ -120,7 +143,11 @@ const ActivitySendStatusComposer = ({ children }: Readonly<{ children?: ReactNod } }, [clearTimeout, Date, forceRender, nextExpiry, setTimeout]); - return {children}; + return ( + + {children} + + ); }; export default ActivitySendStatusComposer; diff --git a/packages/api/src/providers/ActivitySendStatus/private/Context.ts b/packages/api/src/providers/ActivitySendStatus/private/Context.ts index 7646b36407..de4b5bb33d 100644 --- a/packages/api/src/providers/ActivitySendStatus/private/Context.ts +++ b/packages/api/src/providers/ActivitySendStatus/private/Context.ts @@ -3,7 +3,7 @@ import { createContext } from 'react'; import type { SendStatus } from '../../../types/SendStatus'; type ActivitySendStatusContextType = { - sendStatusByActivityKeyState: readonly [ReadonlyMap]; + readonly sendStatusByActivityKeyState: readonly [ReadonlyMap]; }; const ActivitySendStatusContext = createContext( diff --git a/packages/api/src/providers/ActivitySendStatus/private/SubContext.ts b/packages/api/src/providers/ActivitySendStatus/private/SubContext.ts new file mode 100644 index 0000000000..9d55458cb8 --- /dev/null +++ b/packages/api/src/providers/ActivitySendStatus/private/SubContext.ts @@ -0,0 +1,22 @@ +import { createContext, useContext } from 'react'; + +// Smaller context for lesser chance of update. +type ActivitySendStatusSubContextType = { + readonly isSendingState: readonly [boolean]; +}; + +const ActivitySendStatusSubContext = createContext( + new Proxy({} as ActivitySendStatusSubContextType, { + get() { + throw new Error('botframework-webchat internal: This hook can only be used under .'); + } + }) +); + +function useActivitySendStatusSubContext(): ActivitySendStatusSubContextType { + return useContext(ActivitySendStatusSubContext); +} + +export default ActivitySendStatusSubContext; + +export { useActivitySendStatusSubContext, type ActivitySendStatusSubContextType }; diff --git a/packages/api/src/providers/ActivitySendStatus/useIsSending.ts b/packages/api/src/providers/ActivitySendStatus/useIsSending.ts new file mode 100644 index 0000000000..66826cc114 --- /dev/null +++ b/packages/api/src/providers/ActivitySendStatus/useIsSending.ts @@ -0,0 +1,10 @@ +import { useActivitySendStatusSubContext } from './private/SubContext'; + +/** + * Returns `true` if there is at least one outgoing activity currently in the `"sending"` state, otherwise `false`. + * + * Note: presentational activities (e.g. event activity or message activity without visible contents) are excluded. + */ +export default function useIsSending(): readonly [boolean] { + return useActivitySendStatusSubContext().isSendingState; +} diff --git a/packages/component/src/Transcript/LiveRegion/LongSend.tsx b/packages/component/src/Transcript/LiveRegion/LongSend.tsx new file mode 100644 index 0000000000..10e4058f38 --- /dev/null +++ b/packages/component/src/Transcript/LiveRegion/LongSend.tsx @@ -0,0 +1,44 @@ +import { hooks } from 'botframework-webchat-api'; +import { memo, useEffect, useState } from 'react'; + +import { useLiveRegion } from '../../providers/LiveRegionTwin'; + +const { useIsSending, useLocalizer, usePonyfill } = hooks; + +const SENDING_ANNOUNCEMENT_INTERVAL = 3_000; + +/** + * React component to narrate "Sending message." into the live region repeatedly every 3 seconds, + * but only while there are outgoing activities stuck in the `sending` state with none timed out. + * + * Fast sends (acknowledged by the server within 3 seconds) stay silent to avoid noisy + * announcements. Slow or stalled sends get narrated so the user knows what is happening. + */ +const LiveRegionLongSend = () => { + const [{ clearInterval, setInterval }] = usePonyfill(); + const [isSending] = useIsSending(); + const localize = useLocalizer(); + + const liveRegionSendSendingAlt = localize('TRANSCRIPT_LIVE_REGION_SEND_SENDING_ALT'); + + // Invalidate will queue the announcement. + const [tick, setTick] = useState(); + + useEffect(() => { + if (!isSending) { + return; + } + + const interval = setInterval(() => setTick({}), SENDING_ANNOUNCEMENT_INTERVAL); + + return () => clearInterval(interval); + }, [clearInterval, isSending, setInterval]); + + useLiveRegion(() => (tick ? liveRegionSendSendingAlt : false), [liveRegionSendSendingAlt, tick]); + + return null; +}; + +LiveRegionLongSend.displayName = 'LiveRegionLongSend'; + +export default memo(LiveRegionLongSend); diff --git a/packages/component/src/Transcript/LiveRegion/SendFailed.tsx b/packages/component/src/Transcript/LiveRegion/SendFailed.tsx index 3af37838ad..d4474d4031 100644 --- a/packages/component/src/Transcript/LiveRegion/SendFailed.tsx +++ b/packages/component/src/Transcript/LiveRegion/SendFailed.tsx @@ -1,10 +1,10 @@ import { hooks } from 'botframework-webchat-api'; +import { isPresentational } from 'botframework-webchat-core/internal'; import { memo, useMemo } from 'react'; import usePrevious from '../../hooks/internal/usePrevious'; import { useLiveRegion } from '../../providers/LiveRegionTwin'; import { SEND_FAILED } from '../../types/internal/SendStatus'; -import isPresentational from './isPresentational'; const { useGetActivityByKey, useLocalizer, useSendStatusByActivityKey } = hooks; @@ -31,13 +31,17 @@ const LiveRegionSendFailed = () => { */ const activityKeysOfSendFailed = useMemo>( () => - Array.from(sendStatusByActivityKey).reduce( - (activityKeysOfSendFailed, [key, sendStatus]) => - sendStatus === SEND_FAILED && !isPresentational(getActivityByKey(key)) - ? activityKeysOfSendFailed.add(key) - : activityKeysOfSendFailed, - new Set() - ), + Array.from(sendStatusByActivityKey).reduce((activityKeysOfSendFailed, [key, sendStatus]) => { + if (sendStatus === SEND_FAILED) { + const activity = getActivityByKey(key); + + if (activity && !isPresentational(activity)) { + activityKeysOfSendFailed.add(key); + } + } + + return activityKeysOfSendFailed; + }, new Set()), [getActivityByKey, sendStatusByActivityKey] ); diff --git a/packages/component/src/Transcript/LiveRegionTranscript.tsx b/packages/component/src/Transcript/LiveRegionTranscript.tsx index a56adb2524..36e2640bad 100644 --- a/packages/component/src/Transcript/LiveRegionTranscript.tsx +++ b/packages/component/src/Transcript/LiveRegionTranscript.tsx @@ -9,6 +9,7 @@ import useLocalizeAccessKey from '../hooks/internal/useLocalizeAccessKey'; import useSuggestedActionsAccessKey from '../hooks/internal/useSuggestedActionsAccessKey'; import { useQueueStaticElement } from '../providers/LiveRegionTwin'; import LiveRegionSendFailed from './LiveRegion/SendFailed'; +import LiveRegionLongSend from './LiveRegion/LongSend'; import isPresentational from './LiveRegion/isPresentational'; import useTypistNames from './useTypistNames'; @@ -130,7 +131,12 @@ const LiveRegionTranscript = ({ activityElementMapRef }: LiveRegionTranscriptPro useMemo(() => typingIndicator && queueStaticElement(typingIndicator), [queueStaticElement, typingIndicator]); - return ; + return ( + + + + + ); }; LiveRegionTranscript.displayName = 'LiveRegionTranscript'; diff --git a/packages/core/src/internal/index.ts b/packages/core/src/internal/index.ts index 75c39b5f43..c9cff32e8a 100644 --- a/packages/core/src/internal/index.ts +++ b/packages/core/src/internal/index.ts @@ -5,7 +5,8 @@ export { type SetRawStateAction } from './actions/setRawState'; -export { default as StoreDebugAPIRegistry } from './StoreDebugAPIRegistry'; export { type StoreDebugAPI } from '../types/StoreDebugAPI'; +export { default as isPresentational } from '../utils/isPresentational'; +export { default as StoreDebugAPIRegistry } from './StoreDebugAPIRegistry'; export { RestrictedDebugAPI, type InferPublic } from '@msinternal/botframework-webchat-core-debug-api'; diff --git a/packages/component/src/Transcript/LiveRegion/isPresentational.ts b/packages/core/src/utils/isPresentational.ts similarity index 94% rename from packages/component/src/Transcript/LiveRegion/isPresentational.ts rename to packages/core/src/utils/isPresentational.ts index c2b42a6455..ec66bcdeac 100644 --- a/packages/component/src/Transcript/LiveRegion/isPresentational.ts +++ b/packages/core/src/utils/isPresentational.ts @@ -1,4 +1,4 @@ -import type { WebChatActivity } from 'botframework-webchat-core'; +import type { WebChatActivity } from '../types/WebChatActivity'; /** * Determines if the rendering activity is presentational or not.