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';