Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 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
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>
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
82 changes: 82 additions & 0 deletions packages/component/src/Transcript/LiveRegion/LongSend.tsx
Original file line number Diff line number Diff line change
@@ -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<Set<string>>(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<typeof setTimeout>[] = [];

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);
18 changes: 3 additions & 15 deletions packages/component/src/Transcript/LiveRegion/SendFailed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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();

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

/** Returns localized "Failed to send message." */
const liveRegionSendFailedAlt = localize('TRANSCRIPT_LIVE_REGION_SEND_FAILED_ALT');
Expand Down
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The hook design doesn't match our convention. Please look at HOOKS.md to see how we design our hooks.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

updated

Original file line number Diff line number Diff line change
@@ -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<string>] {
const [sendStatusByActivityKey] = useSendStatusByActivityKey();
const getActivityByKey = useGetActivityByKey();

return [
useMemo<Set<string>>(() => {
const keys = new Set<string>();

for (const [key, sendStatus] of sendStatusByActivityKey) {
if (sendStatus === status && !isPresentational(getActivityByKey(key))) {
keys.add(key);
}
}

return keys;
}, [getActivityByKey, sendStatusByActivityKey, status])
] as const;
}
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
Loading