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