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/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/component/src/Transcript/LiveRegion/LongSend.tsx b/packages/component/src/Transcript/LiveRegion/LongSend.tsx new file mode 100644 index 0000000000..06c2891e9a --- /dev/null +++ b/packages/component/src/Transcript/LiveRegion/LongSend.tsx @@ -0,0 +1,82 @@ +import { hooks } from 'botframework-webchat-api'; +import { memo, useEffect, useRef, useState } from 'react'; + +import { useLiveRegion } from '../../providers/LiveRegionTwin'; +import { SENDING } from '../../types/internal/SendStatus'; +import useActivityKeysOfSendStatus from './useActivityKeysOfSendStatus'; + +const { useLocalizer, usePonyfill } = hooks; + +const SENDING_ANNOUNCEMENT_DELAY = 3000; + +/** + * React component to narrate "Sending message." into the live region, but only when the + * outgoing activity has been stuck in the `sending` state for at least 3 seconds. + * + * 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. + * + * Presentational activities (e.g. `event` or `typing`) are excluded to reduce noise. + */ +const LiveRegionLongSend = () => { + const localize = useLocalizer(); + const [{ clearTimeout, setTimeout }] = usePonyfill(); + + const liveRegionSendSendingAlt = localize('TRANSCRIPT_LIVE_REGION_SEND_SENDING_ALT'); + + /** Keys we have already announced "Sending message." for — prevents repeated announcements. */ + const announcedKeysRef = useRef>(new Set()); + + /** Monotonic counter; incrementing it causes `useLiveRegion` to queue the announcement. */ + const [tick, setTick] = useState(0); + + /** Keys of outgoing non-presentational activities that are currently in the sending state. */ + const [sendingKeys] = useActivityKeysOfSendStatus(SENDING); + + /** + * Arm a per-key timer when a key newly enters `sendingKeys`. + * Cancel all pending timers when a key leaves (cleanup handles this via deps change). + * Clean up the `announcedKeysRef` for keys that are no longer sending. + */ + useEffect(() => { + // Prune announced keys that are no longer sending. + for (const key of Array.from(announcedKeysRef.current)) { + if (!sendingKeys.has(key)) { + announcedKeysRef.current.delete(key); + } + } + + if (!sendingKeys.size) { + return; + } + + const timeouts: ReturnType[] = []; + + for (const key of sendingKeys) { + if (announcedKeysRef.current.has(key)) { + continue; + } + + const timeout = setTimeout(() => { + if (!sendingKeys.has(key)) { + return; + } + + announcedKeysRef.current.add(key); + setTick(t => t + 1); + }, SENDING_ANNOUNCEMENT_DELAY); + + timeouts.push(timeout); + } + + return () => timeouts.forEach(id => clearTimeout(id)); + }, [clearTimeout, sendingKeys, setTimeout]); + + 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..1e7571a43a 100644 --- a/packages/component/src/Transcript/LiveRegion/SendFailed.tsx +++ b/packages/component/src/Transcript/LiveRegion/SendFailed.tsx @@ -4,9 +4,9 @@ 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'; +import useActivityKeysOfSendStatus from './useActivityKeysOfSendStatus'; -const { useGetActivityByKey, useLocalizer, useSendStatusByActivityKey } = hooks; +const { useLocalizer } = hooks; /** * React component to on-demand narrate "Failed to send message" at the end of the live region. @@ -19,8 +19,6 @@ const { useGetActivityByKey, useLocalizer, useSendStatusByActivityKey } = hooks; * Thus, we need to use a live region "footnote" to indicate the message was failed to send. */ const LiveRegionSendFailed = () => { - const [sendStatusByActivityKey] = useSendStatusByActivityKey(); - const getActivityByKey = useGetActivityByKey(); const localize = useLocalizer(); /** @@ -29,17 +27,7 @@ const LiveRegionSendFailed = () => { * Activities which are presentational, such as `event` or `typing`, are ignored to reduce confusions. * "Failed to send message" should not be narrated for presentational activities. */ - const activityKeysOfSendFailed = useMemo>( - () => - Array.from(sendStatusByActivityKey).reduce( - (activityKeysOfSendFailed, [key, sendStatus]) => - sendStatus === SEND_FAILED && !isPresentational(getActivityByKey(key)) - ? activityKeysOfSendFailed.add(key) - : activityKeysOfSendFailed, - new Set() - ), - [getActivityByKey, sendStatusByActivityKey] - ); + const [activityKeysOfSendFailed] = useActivityKeysOfSendStatus(SEND_FAILED); /** Returns localized "Failed to send message." */ const liveRegionSendFailedAlt = localize('TRANSCRIPT_LIVE_REGION_SEND_FAILED_ALT'); diff --git a/packages/component/src/Transcript/LiveRegion/useActivityKeysOfSendStatus.ts b/packages/component/src/Transcript/LiveRegion/useActivityKeysOfSendStatus.ts new file mode 100644 index 0000000000..7c6beec283 --- /dev/null +++ b/packages/component/src/Transcript/LiveRegion/useActivityKeysOfSendStatus.ts @@ -0,0 +1,32 @@ +import { hooks } from 'botframework-webchat-api'; +import { useMemo } from 'react'; + +import type { SendStatus } from '../../types/internal/SendStatus'; +import isPresentational from './isPresentational'; + +const { useGetActivityByKey, useSendStatusByActivityKey } = hooks; + +/** + * Returns the set of keys of outgoing non-presentational activities that currently + * have the given send status. + * + * Presentational activities (e.g. `event` or `typing`) are excluded to reduce noise. + */ +export default function useActivityKeysOfSendStatus(status: SendStatus): readonly [Set] { + const [sendStatusByActivityKey] = useSendStatusByActivityKey(); + const getActivityByKey = useGetActivityByKey(); + + return [ + useMemo>(() => { + const keys = new Set(); + + for (const [key, sendStatus] of sendStatusByActivityKey) { + if (sendStatus === status && !isPresentational(getActivityByKey(key))) { + keys.add(key); + } + } + + return keys; + }, [getActivityByKey, sendStatusByActivityKey, status]) + ] as const; +} 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';