diff --git a/.changelog/5898-fix-5803-sender-initials.md b/.changelog/5898-fix-5803-sender-initials.md new file mode 100644 index 0000000000..3906df9912 --- /dev/null +++ b/.changelog/5898-fix-5803-sender-initials.md @@ -0,0 +1,4 @@ +### Fixed + +- Fixed [#5803](https://github.com/microsoft/BotFramework-WebChat/issues/5803), 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`, in PR [#5898](https://github.com/microsoft/BotFramework-WebChat/pull/5898), by [@OEvgeny](https://github.com/OEvgeny) +- Fixed [#5715](https://github.com/microsoft/BotFramework-WebChat/issues/5715) and [#5735](https://github.com/microsoft/BotFramework-WebChat/issues/5735), 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, in PR [#5898](https://github.com/microsoft/BotFramework-WebChat/pull/5898), by [@OEvgeny](https://github.com/OEvgeny) 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.link.html b/__tests__/html2/accessibility/accessibleName.link.html index b2ad5111ea..a24d551675 100644 --- a/__tests__/html2/accessibility/accessibleName.link.html +++ b/__tests__/html2/accessibility/accessibleName.link.html @@ -38,28 +38,35 @@ ); // GIVEN: a message without links. - await directLine.emulateIncomingActivity({ text: 'Hello, World!', type: 'message' }); + await directLine.emulateIncomingActivity({ + text: 'Hello, World!', + timestamp: '2026-10-17T11:50:00Z', + type: 'message' + }); await pageConditions.numActivitiesShown(1); - // THEN: the accessible name has no interactive footnote. + // THEN: the accessible name has no interactive footnote, only the time the message was sent. await waitFor(async () => - expect(await getActivityAccessibleName(pageElements.activities()[0])).toBe('Bot said: Hello, World!') + expect(await getActivityAccessibleName(pageElements.activities()[0])).toBe( + 'Bot said: Hello, World! Sent at October 17 at 11:50 AM' + ) ); // GIVEN: a message with a link. await directLine.emulateIncomingActivity({ text: 'Read the [documentation](https://example.com/docs).', + timestamp: '2026-10-17T11:51:00Z', type: 'message' }); await pageConditions.numActivitiesShown(2); - // THEN: the accessible name tells the message has links and can be interacted with. + // THEN: the accessible name tells the message has links and can be interacted with, followed by the time. // Zero-width spaces wrap every link for line breaking, screen readers do not pause on them. await waitFor(async () => expect((await getActivityAccessibleName(pageElements.activities()[1])).replace(/\u200b/gu, '')).toBe( - 'Bot said: Read the documentation. One or more links in the message. Click to interact.' + 'Bot said: Read the documentation. One or more links in the message. Click to interact. Sent at October 17 at 11:51 AM' ) ); }); 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 @@
- + + + + + +
+ + + + diff --git a/packages/api/src/StyleOptions.ts b/packages/api/src/StyleOptions.ts index 23a596a438..ac95c4b0ff 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 8fe7a71f98..5c7632ebd1 100644 --- a/packages/component/src/Activity/CarouselFilmStrip.js +++ b/packages/component/src/Activity/CarouselFilmStrip.js @@ -12,6 +12,7 @@ import CarouselFilmStripAttachment from './CarouselFilmStripAttachment'; import isZeroOrPositive from '../Utils/isZeroOrPositive'; import ScreenReaderText from '../ScreenReaderText'; import textFormatToContentType from '../Utils/textFormatToContentType'; +import useLocalizeActivityGreetingAlt from '../hooks/internal/useLocalizeActivityGreetingAlt.js'; import styles from './CarouselFilmStrip.module.css'; @@ -127,9 +128,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] = useLocalizeActivityGreetingAlt()(activity); 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 c87e17b270..377fcea483 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 useLocalizeActivityGreetingAlt from '../hooks/internal/useLocalizeActivityGreetingAlt.js'; import useLocalizeProgrammingLanguage from '../hooks/internal/useLocalizeProgrammingLanguage'; import { useGetLogicalGroupKey } from '../providers/ActivityLogicalGrouping'; import AttachmentRow from './AttachmentRow'; @@ -60,14 +61,11 @@ const StackedLayoutInner = memo( showAvatar, showNub }: StackedLayoutInnerProps) => { - const [{ initials: botInitials }] = useAvatarForBot(); const localize = useLocalizer(); const classNames = useStyles(styles); + const [greetingAlt] = useLocalizeActivityGreetingAlt()(activity); 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 4b1ee99ee6..2a0f235748 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, type InferReactProps } from '@msinternal/botframework-webchat-react-valibot'; import classNames from 'classnames'; import React, { Fragment, useMemo } from 'react'; import { any, object, pipe, readonly } from 'valibot'; import { useStyleToEmotionObject } from '../hooks/internal/styleToEmotionObject'; +import useLocalizeActivityGreetingAlt from '../hooks/internal/useLocalizeActivityGreetingAlt.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,14 @@ type LiveRegionActivityProps = InferReactProps 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'; // Lines are `role="none"`: on Windows, generic `
` and `

` are exposed as "group", screen readers would read "group" before each line. diff --git a/packages/component/src/Transcript/useActivityAccessibleName.ts b/packages/component/src/Transcript/useActivityAccessibleName.ts index 017531d176..41dc185c97 100644 --- a/packages/component/src/Transcript/useActivityAccessibleName.ts +++ b/packages/component/src/Transcript/useActivityAccessibleName.ts @@ -3,15 +3,16 @@ import { useEffect, useMemo, useState } from 'react'; import type { RefObject } from 'react'; import type { WebChatActivity } from 'botframework-webchat-core'; -import { SEND_FAILED } from '../types/internal/SendStatus'; +import { SEND_FAILED, SENT } from '../types/internal/SendStatus'; import activityAltText from '../Utils/activityAltText'; import tabbableElements from '../Utils/tabbableElements'; import useRenderMarkdownAsHTML from '../hooks/useRenderMarkdownAsHTML'; +import useLocalizeActivityGreetingAlt from '../hooks/internal/useLocalizeActivityGreetingAlt.js'; import useValueRef from '../hooks/internal/useValueRef'; type InteractiveType = 'link' | 'widget'; -const { useAvatarForBot, useGetKeyByActivity, useLocalizer, useSendStatusByActivityKey } = hooks; +const { useDateFormatter, useGetKeyByActivity, useLocalizer, useSendStatusByActivityKey } = hooks; const ACTIVITY_NUM_ATTACHMENTS_ALT_IDS = { few: 'ACTIVITY_NUM_ATTACHMENTS_FEW_ALT', @@ -28,11 +29,13 @@ 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(); +export default function useActivityAccessibleName( + activity: WebChatActivity, + bodyRef: RefObject +): readonly [string] { const [interactiveType, setInteractiveType] = useState(false); const [sendStatusByActivityKey] = useSendStatusByActivityKey(); - const fromSelf = activity.from?.role === 'user'; + const formatDate = useDateFormatter(); const getKeyByActivity = useGetKeyByActivity(); const localize = useLocalizer(); const localizeWithPlural = useLocalizer({ plural: true }); @@ -50,14 +53,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] = useLocalizeActivityGreetingAlt()(activity); const interactiveTypeRef = useValueRef(interactiveType); const messageTextAlt = useMemo( () => activityAltText(activity, renderMarkdownAsHTML), @@ -68,9 +64,18 @@ export default function useActivityAccessibleName(activity: WebChatActivity, bod [localizeWithPlural, numAttachments] ); - const isSendFailed = useMemo( - () => sendStatusByActivityKey.get(activityKey) === SEND_FAILED, - [activityKey, sendStatusByActivityKey] + // Like the activity status, an activity without a send status is sent, e.g. from the bot. + const sendStatus = sendStatusByActivityKey.get(activityKey) || SENT; + + const isSendFailed = sendStatus === SEND_FAILED; + + /** "Sent at October 17 at 4:00 PM", only after the activity is sent, like the timestamp in the activity status. */ + const sentAtAlt = useMemo( + () => + activity.timestamp && sendStatus === SENT + ? localize('ACTIVITY_STATUS_SEND_STATUS_ALT_SENT_AT', formatDate(activity.timestamp)) + : '', + [activity.timestamp, formatDate, localize, sendStatus] ); const accessibleName = useMemo( @@ -86,7 +91,8 @@ export default function useActivityAccessibleName(activity: WebChatActivity, bod : interactiveType === 'widget' ? activityInteractiveReasonInteractiveContentAlt : '', - interactiveType || isSendFailed ? activityInteractiveFootNoteAlt : '' + interactiveType || isSendFailed ? activityInteractiveFootNoteAlt : '', + sentAtAlt ] .filter(Boolean) .join(' '), @@ -99,7 +105,8 @@ export default function useActivityAccessibleName(activity: WebChatActivity, bod interactiveType, isSendFailed, messageTextAlt, - numAttachmentsAlt + numAttachmentsAlt, + sentAtAlt ] ); @@ -110,5 +117,5 @@ export default function useActivityAccessibleName(activity: WebChatActivity, bod interactiveTypeRef.current !== hasWidgets && setInteractiveType(hasLinks ? 'link' : hasWidgets ? 'widget' : false); }, [bodyRef, interactiveTypeRef, setInteractiveType]); - return [accessibleName]; + return useMemo(() => Object.freeze([accessibleName] as const), [accessibleName]); } diff --git a/packages/component/src/boot/internal.ts b/packages/component/src/boot/internal.ts index 08a7b0c45c..2faa3f1041 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, useQueueStaticElement } from '../providers/LiveRegionTwin/index'; export { default as ScreenReaderText } from '../ScreenReaderText'; diff --git a/packages/component/src/hooks/internal/useActivityStyleOptions.ts b/packages/component/src/hooks/internal/useActivityStyleOptions.ts new file mode 100644 index 0000000000..64afaf2d84 --- /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([ + Object.freeze({ + ...styleOptions, + ...activity?.channelData?.webChat?.styleOptions + }) + ]), + [activity?.channelData?.webChat?.styleOptions, styleOptions] + ); +} diff --git a/packages/component/src/hooks/internal/useLocalizeActivityGreetingAlt.ts b/packages/component/src/hooks/internal/useLocalizeActivityGreetingAlt.ts new file mode 100644 index 0000000000..b4ac9583a1 --- /dev/null +++ b/packages/component/src/hooks/internal/useLocalizeActivityGreetingAlt.ts @@ -0,0 +1,33 @@ +import { hooks } from 'botframework-webchat-api'; +import { getVoiceActivityRole, type WebChatActivity } from 'botframework-webchat-core'; +import { useCallback } from 'react'; + +const { useLocalizer, useStyleOptions } = hooks; + +/** + * Returns a function to get 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 useLocalizeActivityGreetingAlt(): (activity: WebChatActivity) => readonly [string] { + const [{ botAvatarInitials }] = useStyleOptions(); + const localize = useLocalizer(); + + return useCallback( + (activity: WebChatActivity) => { + // Merged as in useActivityStyleOptions, so a per-activity `null` hides the initials here too. + const { botAvatarInitials: initials } = { botAvatarInitials, ...activity.channelData?.webChat?.styleOptions }; + + return Object.freeze([ + // Transcripts of what the user said in a voice conversation come from the bot. + (activity.from?.role === 'user' || getVoiceActivityRole(activity) === 'user' + ? localize('ACTIVITY_YOU_SAID_ALT') + : localize('ACTIVITY_BOT_SAID_ALT', typeof initials === 'string' ? initials : '') + ).replace(/\s{2,}/gu, ' ') + ] as const); + }, + [botAvatarInitials, localize] + ); +} 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] - ); -}