Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
385f011
fix: announce sending status to screen readers via live region
isherstneva Mar 25, 2026
6523036
fix: use React.Fragment instead of fragment shorthand
isherstneva Mar 25, 2026
98695bb
test: advance clock past live region fade before final snapshot
isherstneva Mar 25, 2026
6b936e5
revert: restore original test, snapshot needs regeneration
isherstneva Mar 25, 2026
a37c870
test: update snapshots to include sending live region announcement
isherstneva Mar 25, 2026
c4a883b
test: update snapshots for sending live region announcement
isherstneva Mar 25, 2026
0e46c91
test: fix assertion tests for sending live region announcement
isherstneva Mar 26, 2026
6c24bc6
test: wait for async telemetry exception event in unknownActivity test
isherstneva Mar 26, 2026
41bd39b
test: stabilize snapshot tests with missing image/scroll waits
isherstneva Mar 26, 2026
533fe17
fix: delay sending announcement to 3 seconds to avoid noise on fast s…
isherstneva Mar 26, 2026
a48a1b5
test: regenerate snapshots after 3s sending announcement delay
isherstneva Mar 26, 2026
9c84327
fix: add required curly braces to if statements (curly lint rule)
isherstneva Mar 26, 2026
66cee21
refactor: rename SendSending to LongSend; revert unrelated test changes
isherstneva Mar 26, 2026
fb9fb48
test: rename sendSending test to longSend
isherstneva Mar 26, 2026
b274a28
ci: retrigger
isherstneva Mar 26, 2026
ae3cfd3
ci: retrigger
isherstneva Mar 26, 2026
0f23db2
ci: retrigger
isherstneva Mar 26, 2026
5fc301c
ci: retrigger
isherstneva Mar 26, 2026
26e9a2d
refactor: extract useActivityKeysOfSendStatus hook to eliminate dupli…
isherstneva Mar 26, 2026
a7d57db
refactor: return readonly tuple from useActivityKeysOfSendStatus per …
isherstneva Mar 26, 2026
36f7844
Add `useIsSending`
compulim Apr 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="en-US">
<head>
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
<script crossorigin="anonymous" src="/test-harness.js"></script>
<script crossorigin="anonymous" src="/test-page-object.js"></script>
<script crossorigin="anonymous" src="/__dist__/webchat-es5.js"></script>
</head>
<body>
<main id="webchat"></main>
<script>
run(
async function () {
const { directLine, store } = testHelpers.createDirectLineEmulator();

WebChat.renderWebChat(
{
directLine,
store,
styleOptions: {
sendTimeout: 20000
}
},
document.getElementById('webchat')
);

await pageConditions.uiConnected();

const { disconnect, flush } = pageObjects.observeLiveRegion();

try {
// Emulate outgoing activity but do not acknowledge it, keeping it in "sending" state.
await directLine.emulateOutgoingActivity('Hello, World!');

const liveRegionText = [];

await pageConditions.became(
'live region narrated sending message',
() => {
try {
liveRegionText.push(...flush());

expect(liveRegionText).toContain('Sending message.');

return true;
} catch (err) {
return false;
}
},
4000
);
} finally {
disconnect();
}
},
{ ignoreErrors: true }
);
</script>
</body>
</html>
1 change: 1 addition & 0 deletions packages/api/src/boot/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export {
useGroupActivities,
useGroupActivitiesByName,
useGroupTimestamp,
useIsSending,
useLanguage,
useLastAcknowledgedActivityKey,
useLastReadActivityKey,
Expand Down
2 changes: 2 additions & 0 deletions packages/api/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -113,6 +114,7 @@ export {
useGroupActivities,
useGroupActivitiesByName,
useGroupTimestamp,
useIsSending,
useLanguage,
useLastAcknowledgedActivityKey,
useLastReadActivityKey,
Expand Down
1 change: 1 addition & 0 deletions packages/api/src/hooks/useIsSending.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as useIsSending } from '../providers/ActivitySendStatus/useIsSending';
2 changes: 2 additions & 0 deletions packages/api/src/localization/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct the wording “screen reader” → “screen readers” for grammatical correctness in the translator comment.

Suggested change
"_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_SEND_SENDING_ALT.comment": "This is for screen readers. When the user sends a message, the live region will announce this string to indicate the message is being sent.",

Copilot uses AI. Check for mistakes.
"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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<ReadonlyMap<string, SendStatus>>(Object.freeze(new Map()));
Expand Down Expand Up @@ -93,11 +97,30 @@ const ActivitySendStatusComposer = ({ children }: Readonly<{ children?: ReactNod
[sendStatusByActivityKey]
);

const isSendingState = useMemo<readonly [boolean]>(
() =>
Object.freeze([
sendStatusByActivityKey.entries().some(([activityKey, status]) => {
if (status === 'sending') {
const activity = getActivityByKey(activityKey);

return activity && !isPresentational(activity);
}
})
]),
[getActivityByKey, sendStatusByActivityKey]
);

const context = useMemo<ActivitySendStatusContextType>(
() => ({ sendStatusByActivityKeyState }),
() => Object.freeze({ sendStatusByActivityKeyState }),
[sendStatusByActivityKeyState]
);

const subContext = useMemo<ActivitySendStatusSubContextType>(
() => 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`.
Expand All @@ -120,7 +143,11 @@ const ActivitySendStatusComposer = ({ children }: Readonly<{ children?: ReactNod
}
}, [clearTimeout, Date, forceRender, nextExpiry, setTimeout]);

return <ActivitySendStatusContext.Provider value={context}>{children}</ActivitySendStatusContext.Provider>;
return (
<ActivitySendStatusContext.Provider value={context}>
<ActivitySendStatusSubContext.Provider value={subContext}>{children}</ActivitySendStatusSubContext.Provider>
</ActivitySendStatusContext.Provider>
);
};

export default ActivitySendStatusComposer;
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { createContext } from 'react';
import type { SendStatus } from '../../../types/SendStatus';

type ActivitySendStatusContextType = {
sendStatusByActivityKeyState: readonly [ReadonlyMap<string, SendStatus>];
readonly sendStatusByActivityKeyState: readonly [ReadonlyMap<string, SendStatus>];
};

const ActivitySendStatusContext = createContext<ActivitySendStatusContextType>(
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ActivitySendStatusSubContextType>(
new Proxy({} as ActivitySendStatusSubContextType, {
get() {
throw new Error('botframework-webchat internal: This hook can only be used under <ActivitySendStatusComposer>.');
}
})
);

function useActivitySendStatusSubContext(): ActivitySendStatusSubContextType {
return useContext(ActivitySendStatusSubContext);
}

export default ActivitySendStatusSubContext;

export { useActivitySendStatusSubContext, type ActivitySendStatusSubContextType };
10 changes: 10 additions & 0 deletions packages/api/src/providers/ActivitySendStatus/useIsSending.ts
Original file line number Diff line number Diff line change
@@ -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;
}
44 changes: 44 additions & 0 deletions packages/component/src/Transcript/LiveRegion/LongSend.tsx
Original file line number Diff line number Diff line change
@@ -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<object | undefined>();

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);
20 changes: 12 additions & 8 deletions packages/component/src/Transcript/LiveRegion/SendFailed.tsx
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -31,13 +31,17 @@ const LiveRegionSendFailed = () => {
*/
const activityKeysOfSendFailed = useMemo<Set<string>>(
() =>
Array.from(sendStatusByActivityKey).reduce(
(activityKeysOfSendFailed, [key, sendStatus]) =>
sendStatus === SEND_FAILED && !isPresentational(getActivityByKey(key))
? activityKeysOfSendFailed.add(key)
: activityKeysOfSendFailed,
new Set<string>()
),
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<string>()),
[getActivityByKey, sendStatusByActivityKey]
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -130,7 +131,12 @@ const LiveRegionTranscript = ({ activityElementMapRef }: LiveRegionTranscriptPro

useMemo(() => typingIndicator && queueStaticElement(typingIndicator), [queueStaticElement, typingIndicator]);

return <LiveRegionSendFailed />;
return (
<React.Fragment>
<LiveRegionSendFailed />
<LiveRegionLongSend />
</React.Fragment>
);
};

LiveRegionTranscript.displayName = 'LiveRegionTranscript';
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/internal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
Loading