From 81b8d87bf08c7af0bedc65fb6a2a325f6b685002 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Sun, 18 Oct 2026 00:55:38 -0700 Subject: [PATCH 1/6] Narrate per-activity bot initials before each message Screen readers announced every bot message with the global botAvatarInitials, e.g. "Bot JO said:" for both a bot and a live agent. The four places which build "Bot $1 said:" (scan mode, arrow-key summary, live region, carousel) now share useActivityGreetingAlt(), which prefers the per-activity channelData.webChat.styleOptions.botAvatarInitials. useActivityStyleOptions() moves from the Fluent theme to the component (and its internal entry), so the greeting and the Fluent theme read per-activity style options the same way. --- CHANGELOG.md | 1 + .../botAvatarInitials.perActivity.html | 114 ++++++++++++++++++ packages/api/src/StyleOptions.ts | 3 + packages/bundle/src/boot/actual/internal.ts | 1 + .../src/Activity/CarouselFilmStrip.js | 8 +- .../component/src/Activity/StackedLayout.tsx | 7 +- .../src/LiveRegion/LiveRegionActivity.tsx | 10 +- .../Transcript/useActivityAccessibleName.ts | 14 +-- packages/component/src/boot/internal.ts | 1 + .../hooks/internal/useActivityGreetingAlt.ts | 27 +++++ .../hooks/internal/useActivityStyleOptions.ts | 25 ++++ .../activity/CopilotMessageHeader.tsx | 3 +- .../private/useActivityStyleOptions.ts | 19 --- 13 files changed, 183 insertions(+), 50 deletions(-) create mode 100644 __tests__/html2/accessibility/botAvatarInitials.perActivity.html create mode 100644 packages/component/src/hooks/internal/useActivityGreetingAlt.ts create mode 100644 packages/component/src/hooks/internal/useActivityStyleOptions.ts delete mode 100644 packages/fluent-theme/src/components/activity/private/useActivityStyleOptions.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d32d8a4300..8de324939c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ Legends: - Fixed the "Positive" card action button having less than 4.5:1 color contrast, fixes [#5713](https://github.com/microsoft/BotFramework-WebChat/issues/5713), in PR [#5905](https://github.com/microsoft/BotFramework-WebChat/pull/5905), by [@OEvgeny](https://github.com/OEvgeny) - Fixed screen readers announcing a single audio, video, or animation in a card as a list item, such as "1 of 1", fixes [#5722](https://github.com/microsoft/BotFramework-WebChat/issues/5722), in PR [#5903](https://github.com/microsoft/BotFramework-WebChat/pull/5903), by [@OEvgeny](https://github.com/OEvgeny) - Fixed video and animation cards disappearing in production builds when the video URL is relative or invalid, in PR [#5889](https://github.com/microsoft/BotFramework-WebChat/pull/5889), by [@OEvgeny](https://github.com/OEvgeny) +- Fixed screen readers announcing all bot messages with the same initials, such as "Bot JO said:" for both a bot and a live agent: the text before each message, the name of the message when navigating with arrow keys, and the live region now prefer the per-activity `channelData.webChat.styleOptions.botAvatarInitials` over `styleOptions.botAvatarInitials`, fixes [#5803](https://github.com/microsoft/BotFramework-WebChat/issues/5803), in PR [#XXXX](https://github.com/microsoft/BotFramework-WebChat/pull/XXXX), by [@OEvgeny](https://github.com/OEvgeny) ## [4.19.2] - 2026-10-08 diff --git a/__tests__/html2/accessibility/botAvatarInitials.perActivity.html b/__tests__/html2/accessibility/botAvatarInitials.perActivity.html new file mode 100644 index 0000000000..ed25d9aeb3 --- /dev/null +++ b/__tests__/html2/accessibility/botAvatarInitials.perActivity.html @@ -0,0 +1,114 @@ + + + + + + + + + +
+ + + + diff --git a/packages/api/src/StyleOptions.ts b/packages/api/src/StyleOptions.ts index 8f419d8e7d..b707c28940 100644 --- a/packages/api/src/StyleOptions.ts +++ b/packages/api/src/StyleOptions.ts @@ -97,6 +97,9 @@ type StrictStyleOptions = { /** * Typically rendered as two letters, e.g. 'WC' * Empty string is required when setting botAvatarImage + * + * Screen readers narrate the initials before each bot message, e.g. "Bot WC said:". + * The narration prefers per-activity initials in `channelData.webChat.styleOptions.botAvatarInitials`, e.g. for a live agent. */ botAvatarInitials: string | undefined; diff --git a/packages/bundle/src/boot/actual/internal.ts b/packages/bundle/src/boot/actual/internal.ts index 4f69ec34e2..394a48c44e 100644 --- a/packages/bundle/src/boot/actual/internal.ts +++ b/packages/bundle/src/boot/actual/internal.ts @@ -14,6 +14,7 @@ export { PartGrouping, ScreenReaderText, serializeDocumentFragmentIntoString, + useActivityStyleOptions, useCodeHighlighter, useLiveRegion, type HighlightCodeFn diff --git a/packages/component/src/Activity/CarouselFilmStrip.js b/packages/component/src/Activity/CarouselFilmStrip.js index 306cdee62c..7da96a997d 100644 --- a/packages/component/src/Activity/CarouselFilmStrip.js +++ b/packages/component/src/Activity/CarouselFilmStrip.js @@ -12,10 +12,11 @@ import CarouselFilmStripAttachment from './CarouselFilmStripAttachment'; import isZeroOrPositive from '../Utils/isZeroOrPositive'; import ScreenReaderText from '../ScreenReaderText'; import textFormatToContentType from '../Utils/textFormatToContentType'; +import useActivityGreetingAlt from '../hooks/internal/useActivityGreetingAlt.js'; import styles from './CarouselFilmStrip.module.css'; -const { useAvatarForBot, useAvatarForUser, useDirection, useLocalizer, useStyleOptions } = hooks; +const { useAvatarForBot, useAvatarForUser, useDirection, useStyleOptions } = hooks; const CarouselFilmStrip = ({ activity, @@ -30,7 +31,6 @@ const CarouselFilmStrip = ({ const [{ initials: botInitials }] = useAvatarForBot(); const [{ initials: userInitials }] = useAvatarForUser(); const [direction] = useDirection(); - const localize = useLocalizer(); const showActivityStatus = typeof renderActivityStatus === 'function'; const classNames = useStyles(styles); @@ -97,9 +97,7 @@ const CarouselFilmStrip = ({ const activityDisplayText = messageBackDisplayText || text; const fromUser = role === 'user'; - const greetingAlt = ( - fromUser ? localize('ACTIVITY_YOU_SAID_ALT') : localize('ACTIVITY_BOT_SAID_ALT', botInitials || '') - ).replace(/\s{2,}/gu, ' '); + const greetingAlt = useActivityGreetingAlt(activity, fromUser); const initials = fromUser ? userInitials : botInitials; const nubOffset = fromUser ? bubbleFromUserNubOffset : bubbleNubOffset; diff --git a/packages/component/src/Activity/StackedLayout.tsx b/packages/component/src/Activity/StackedLayout.tsx index 419980ae39..64d3a1b81f 100644 --- a/packages/component/src/Activity/StackedLayout.tsx +++ b/packages/component/src/Activity/StackedLayout.tsx @@ -19,6 +19,7 @@ import ScreenReaderText from '../ScreenReaderText'; import isZeroOrPositive from '../Utils/isZeroOrPositive'; import getFirstBaseOfSoftwareSourceCode from '../Utils/orgSchema/getFirstBaseOfSoftwareSourceCode'; import textFormatToContentType from '../Utils/textFormatToContentType'; +import useActivityGreetingAlt from '../hooks/internal/useActivityGreetingAlt.js'; import useLocalizeProgrammingLanguage from '../hooks/internal/useLocalizeProgrammingLanguage'; import { useGetLogicalGroupKey } from '../providers/ActivityLogicalGrouping'; import AttachmentRow from './AttachmentRow'; @@ -60,14 +61,10 @@ const StackedLayoutInner = memo( showAvatar, showNub }: StackedLayoutInnerProps) => { - const [{ initials: botInitials }] = useAvatarForBot(); - const localize = useLocalizer(); const classNames = useStyles(styles); + const greetingAlt = useActivityGreetingAlt(activity, fromUser); const messageThing = useMemo(() => getOrgSchemaMessage(activity.entities), [activity]); - const greetingAlt = ( - fromUser ? localize('ACTIVITY_YOU_SAID_ALT') : localize('ACTIVITY_BOT_SAID_ALT', botInitials || '') - ).replace(/\s{2,}/gu, ' '); return ( diff --git a/packages/component/src/LiveRegion/LiveRegionActivity.tsx b/packages/component/src/LiveRegion/LiveRegionActivity.tsx index 46a1630ae1..56089ef885 100644 --- a/packages/component/src/LiveRegion/LiveRegionActivity.tsx +++ b/packages/component/src/LiveRegion/LiveRegionActivity.tsx @@ -1,19 +1,17 @@ /* eslint no-magic-numbers: ["error", { "ignore": [2] }] */ -import { hooks } from 'botframework-webchat-api'; import { validateProps } from '@msinternal/botframework-webchat-react-valibot'; import classNames from 'classnames'; import React, { Fragment, useMemo } from 'react'; import { any, object, pipe, readonly, type InferInput } from 'valibot'; import { useStyleToEmotionObject } from '../hooks/internal/styleToEmotionObject'; +import useActivityGreetingAlt from '../hooks/internal/useActivityGreetingAlt.js'; import useRenderMarkdownAsHTML from '../hooks/useRenderMarkdownAsHTML'; import activityAltText from '../Utils/activityAltText'; import LiveRegionAttachments from './private/LiveRegionAttachments'; import LiveRegionSuggestedActions from './private/LiveRegionSuggestedActions'; -const { useAvatarForBot, useLocalizer } = hooks; - const ROOT_STYLE = { '&.webchat__live-region-activity': { color: 'transparent', @@ -39,21 +37,17 @@ type LiveRegionActivityProps = InferInput; function LiveRegionActivity(props: LiveRegionActivityProps) { const { activity } = validateProps(liveRegionActivityPropsSchema, props); - const [{ initials: botInitials }] = useAvatarForBot(); const { from: { role }, type } = activity; const fallbackText: string | undefined = type === 'message' ? activity.channelData['webchat:fallback-text'] : undefined; - const localize = useLocalizer(); + const greetingAlt = useActivityGreetingAlt(activity, role === 'user'); const renderMarkdownAsHTML = useRenderMarkdownAsHTML('accessible name'); const rootClassName = useStyleToEmotionObject()(ROOT_STYLE) + ''; const textAlt = useMemo(() => activityAltText(activity, renderMarkdownAsHTML), [activity, renderMarkdownAsHTML]); - const greetingAlt: string = ( - role === 'user' ? localize('ACTIVITY_YOU_SAID_ALT') : localize('ACTIVITY_BOT_SAID_ALT', botInitials || '') - ).replace(/\s{2,}/gu, ' '); const validFallbackText = fallbackText && typeof fallbackText === 'string'; return ( diff --git a/packages/component/src/Transcript/useActivityAccessibleName.ts b/packages/component/src/Transcript/useActivityAccessibleName.ts index 9e3d9d3473..3e140b358e 100644 --- a/packages/component/src/Transcript/useActivityAccessibleName.ts +++ b/packages/component/src/Transcript/useActivityAccessibleName.ts @@ -7,6 +7,7 @@ import { SEND_FAILED } from '../types/internal/SendStatus'; import activityAltText from '../Utils/activityAltText'; import tabbableElements from '../Utils/tabbableElements'; import useRenderMarkdownAsHTML from '../hooks/useRenderMarkdownAsHTML'; +import useActivityGreetingAlt from '../hooks/internal/useActivityGreetingAlt.js'; import useValueRef from '../hooks/internal/useValueRef'; enum InteractiveType { @@ -14,7 +15,7 @@ enum InteractiveType { WIDGET } -const { useAvatarForBot, useGetKeyByActivity, useLocalizer, useSendStatusByActivityKey } = hooks; +const { useGetKeyByActivity, useLocalizer, useSendStatusByActivityKey } = hooks; const ACTIVITY_NUM_ATTACHMENTS_ALT_IDS = { few: 'ACTIVITY_NUM_ATTACHMENTS_FEW_ALT', @@ -32,10 +33,8 @@ const ACTIVITY_NUM_ATTACHMENTS_ALT_IDS = { * Due to a bug in Safari, accessible name for active descendant must be a string but not a DOM element. */ export default function useActivityAccessibleName(activity: WebChatActivity, bodyRef: RefObject) { - const [{ initials: botInitials }] = useAvatarForBot(); const [interactiveType, setInteractiveType] = useState(false); const [sendStatusByActivityKey] = useSendStatusByActivityKey(); - const fromSelf = activity.from?.role === 'user'; const getKeyByActivity = useGetKeyByActivity(); const localize = useLocalizer(); const localizeWithPlural = useLocalizer({ plural: true }); @@ -53,14 +52,7 @@ export default function useActivityAccessibleName(activity: WebChatActivity, bod /** "Send failed." */ const activityInteractiveReasonSendFailedAlt = localize('ACTIVITY_INTERACTIVE_REASON_SEND_FAILED_ALT'); const activityKey = useMemo(() => getKeyByActivity(activity), [activity, getKeyByActivity]); - const greetingAlt = useMemo( - () => - (fromSelf ? localize('ACTIVITY_YOU_SAID_ALT') : localize('ACTIVITY_BOT_SAID_ALT', botInitials || '')).replace( - /\s{2,}/gu, - ' ' - ), - [botInitials, fromSelf, localize] - ); + const greetingAlt = useActivityGreetingAlt(activity, activity.from?.role === 'user'); const interactiveTypeRef = useValueRef(interactiveType); const messageTextAlt = useMemo( () => activityAltText(activity, renderMarkdownAsHTML), diff --git a/packages/component/src/boot/internal.ts b/packages/component/src/boot/internal.ts index cada18e9f3..fcfa5c24e4 100644 --- a/packages/component/src/boot/internal.ts +++ b/packages/component/src/boot/internal.ts @@ -12,6 +12,7 @@ export { useCodeHighlighter, type HighlightCodeFn } from '../hooks/internal/codeHighlighter/index'; +export { default as useActivityStyleOptions } from '../hooks/internal/useActivityStyleOptions.js'; export { default as PartGrouping } from '../Middleware/ActivityGrouping/ui/PartGrouping/PartGrouping'; export { useLiveRegion } from '../providers/LiveRegionTwin/index'; export { default as ScreenReaderText } from '../ScreenReaderText'; diff --git a/packages/component/src/hooks/internal/useActivityGreetingAlt.ts b/packages/component/src/hooks/internal/useActivityGreetingAlt.ts new file mode 100644 index 0000000000..1bd0956851 --- /dev/null +++ b/packages/component/src/hooks/internal/useActivityGreetingAlt.ts @@ -0,0 +1,27 @@ +import { hooks } from 'botframework-webchat-api'; +import { type WebChatActivity } from 'botframework-webchat-core'; +import { useMemo } from 'react'; + +import useActivityStyleOptions from './useActivityStyleOptions.js'; + +const { useLocalizer } = hooks; + +/** + * Returns the screen reader text narrated before the content of an activity, e.g. "You said:" or "Bot JD said:". + * + * Bot initials set in the per-activity `channelData.webChat.styleOptions.botAvatarInitials` take precedence over + * `styleOptions.botAvatarInitials`, so each sender is narrated with their own initials, e.g. a bot and a live agent. + */ +export default function useActivityGreetingAlt(activity: WebChatActivity, fromUser: boolean): string { + const [{ botAvatarInitials }] = useActivityStyleOptions(activity); + const localize = useLocalizer(); + + return useMemo( + () => + (fromUser + ? localize('ACTIVITY_YOU_SAID_ALT') + : localize('ACTIVITY_BOT_SAID_ALT', typeof botAvatarInitials === 'string' ? botAvatarInitials : '') + ).replace(/\s{2,}/gu, ' '), + [botAvatarInitials, fromUser, localize] + ); +} diff --git a/packages/component/src/hooks/internal/useActivityStyleOptions.ts b/packages/component/src/hooks/internal/useActivityStyleOptions.ts new file mode 100644 index 0000000000..4655bcd3aa --- /dev/null +++ b/packages/component/src/hooks/internal/useActivityStyleOptions.ts @@ -0,0 +1,25 @@ +import { hooks, type StrictStyleOptions } from 'botframework-webchat-api'; +import { type WebChatActivity } from 'botframework-webchat-core'; +import { useMemo } from 'react'; + +const { useStyleOptions } = hooks; + +/** + * Returns the style options of an activity: the per-activity `channelData.webChat.styleOptions` over `styleOptions`. + */ +export default function useActivityStyleOptions( + activity?: WebChatActivity | undefined +): readonly [Readonly] { + const [styleOptions] = useStyleOptions(); + + return useMemo]>( + () => + Object.freeze([ + { + ...styleOptions, + ...activity?.channelData?.webChat?.styleOptions + } + ]), + [activity?.channelData?.webChat?.styleOptions, styleOptions] + ); +} diff --git a/packages/fluent-theme/src/components/activity/CopilotMessageHeader.tsx b/packages/fluent-theme/src/components/activity/CopilotMessageHeader.tsx index 0ccfaca534..51168fef02 100644 --- a/packages/fluent-theme/src/components/activity/CopilotMessageHeader.tsx +++ b/packages/fluent-theme/src/components/activity/CopilotMessageHeader.tsx @@ -1,6 +1,6 @@ import { validateProps } from '@msinternal/botframework-webchat-react-valibot'; import { hooks } from 'botframework-webchat'; -import { type WebChatActivity } from 'botframework-webchat/internal.js'; +import { useActivityStyleOptions, type WebChatActivity } from 'botframework-webchat/internal.js'; import cx from 'classnames'; import React, { memo, useMemo, type CSSProperties } from 'react'; import { custom, is, object, optional, pipe, readonly, string, type InferInput } from 'valibot'; @@ -8,7 +8,6 @@ import { custom, is, object, optional, pipe, readonly, string, type InferInput } import { useStyles } from '../../styles'; import styles from './CopilotMessageHeader.module.css'; import isAIGeneratedActivity from './private/isAIGeneratedActivity'; -import useActivityStyleOptions from './private/useActivityStyleOptions'; import useFirstActivityAuthor from './private/useFirstActivityAuthor'; const { useLocalizer } = hooks; diff --git a/packages/fluent-theme/src/components/activity/private/useActivityStyleOptions.ts b/packages/fluent-theme/src/components/activity/private/useActivityStyleOptions.ts deleted file mode 100644 index adb3ffd4c7..0000000000 --- a/packages/fluent-theme/src/components/activity/private/useActivityStyleOptions.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { type StrictStyleOptions, hooks } from 'botframework-webchat'; -import { type WebChatActivity } from 'botframework-webchat/internal.js'; -import { useMemo } from 'react'; - -const { useStyleOptions } = hooks; - -export default function useActivityStyleOptions(activity?: WebChatActivity | undefined) { - const [styleOptions] = useStyleOptions(); - return useMemo]>( - () => - Object.freeze([ - { - ...styleOptions, - ...activity?.channelData?.webChat?.styleOptions - } - ]), - [activity?.channelData?.webChat?.styleOptions, styleOptions] - ); -} From 4766cdcbc32091e673b26405c8bbf98b88cd89da Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Sun, 18 Oct 2026 01:08:54 -0700 Subject: [PATCH 2/6] Narrate the time of a message when navigating with arrow keys The accessible name of each message in the transcript (aria-activedescendant) now ends with the same "Sent at ..." text as the screen reader text of its timestamp (ACTIVITY_STATUS_SEND_STATUS_ALT_SENT_AT), once the message is sent. --- CHANGELOG.md | 1 + ...essibleName.activityStatus.sendFailed.html | 10 +++-- .../accessibility/accessibleName.simple.html | 42 +++++++++++++++---- .../botAvatarInitials.perActivity.html | 8 ++-- .../Transcript/useActivityAccessibleName.ts | 26 ++++++++---- 5 files changed, 65 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8de324939c..7e32a94898 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,7 @@ Legends: - Fixed screen readers announcing a single audio, video, or animation in a card as a list item, such as "1 of 1", fixes [#5722](https://github.com/microsoft/BotFramework-WebChat/issues/5722), in PR [#5903](https://github.com/microsoft/BotFramework-WebChat/pull/5903), by [@OEvgeny](https://github.com/OEvgeny) - Fixed video and animation cards disappearing in production builds when the video URL is relative or invalid, in PR [#5889](https://github.com/microsoft/BotFramework-WebChat/pull/5889), by [@OEvgeny](https://github.com/OEvgeny) - Fixed screen readers announcing all bot messages with the same initials, such as "Bot JO said:" for both a bot and a live agent: the text before each message, the name of the message when navigating with arrow keys, and the live region now prefer the per-activity `channelData.webChat.styleOptions.botAvatarInitials` over `styleOptions.botAvatarInitials`, fixes [#5803](https://github.com/microsoft/BotFramework-WebChat/issues/5803), in PR [#XXXX](https://github.com/microsoft/BotFramework-WebChat/pull/XXXX), by [@OEvgeny](https://github.com/OEvgeny) +- Fixed screen readers not announcing the time of a message when navigating the transcript with arrow keys: the name of each sent message now ends with the same "Sent at" text as its timestamp, fixes [#5715](https://github.com/microsoft/BotFramework-WebChat/issues/5715) and [#5735](https://github.com/microsoft/BotFramework-WebChat/issues/5735), in PR [#XXXX](https://github.com/microsoft/BotFramework-WebChat/pull/XXXX), by [@OEvgeny](https://github.com/OEvgeny) ## [4.19.2] - 2026-10-08 diff --git a/__tests__/html2/accessibility/accessibleName.activityStatus.sendFailed.html b/__tests__/html2/accessibility/accessibleName.activityStatus.sendFailed.html index 3ec6adb737..e226ee34ef 100644 --- a/__tests__/html2/accessibility/accessibleName.activityStatus.sendFailed.html +++ b/__tests__/html2/accessibility/accessibleName.activityStatus.sendFailed.html @@ -1,4 +1,4 @@ - + @@ -29,7 +29,10 @@ const sendMessage = await directLine.emulateOutgoingActivity('Hello, World!'); - await directLine.emulateIncomingActivity('Aloha!'); + // The echo back of the message has a timestamp, but the message is not sent. + await sendMessage.echoBack(activity => ({ ...activity, timestamp: '2026-10-17T11:50:00Z' })); + + await directLine.emulateIncomingActivity({ text: 'Aloha!', timestamp: '2026-10-17T11:51:00Z' }); sendMessage.rejectPostActivity(new Error('artificial error')); @@ -40,8 +43,9 @@ ); expect(pageElements.activityActiveDescendantLabels().map(({ innerText }) => innerText)).toEqual([ + // The time is only narrated after the message is sent. 'You said: Hello, World! Send failed. Click to interact.', - 'Bot said: Aloha!' + 'Bot said: Aloha! Sent at October 17 at 11:51 AM' ]); }, { ignoreErrors: true } diff --git a/__tests__/html2/accessibility/accessibleName.simple.html b/__tests__/html2/accessibility/accessibleName.simple.html index a3d3ef78b3..988998d148 100644 --- a/__tests__/html2/accessibility/accessibleName.simple.html +++ b/__tests__/html2/accessibility/accessibleName.simple.html @@ -1,4 +1,4 @@ - + @@ -8,7 +8,16 @@
- +