diff --git a/src/app/components/editor/Editor.tsx b/src/app/components/editor/Editor.tsx index 0a32eaf8b..06fa9265e 100644 --- a/src/app/components/editor/Editor.tsx +++ b/src/app/components/editor/Editor.tsx @@ -66,6 +66,8 @@ type CustomEditorProps = { maxHeight?: string; editor: Editor; placeholder?: string; + /** Explicit accessible name for the textbox; falls back to the placeholder. */ + ariaLabel?: string; onKeyDown?: KeyboardEventHandler; onKeyUp?: KeyboardEventHandler; onChange?: EditorChangeHandler; @@ -82,6 +84,7 @@ export const CustomEditor = forwardRef( maxHeight = '50vh', editor, placeholder, + ariaLabel, onKeyDown, onKeyUp, onChange, @@ -139,7 +142,7 @@ export const CustomEditor = forwardRef( data-editable-name={editableName} className={css.EditorTextarea} placeholder={placeholder} - aria-label={placeholder ?? 'Message input'} + aria-label={ariaLabel ?? placeholder ?? 'Message input'} aria-multiline="true" renderPlaceholder={renderPlaceholder} renderElement={renderElement} diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 5f6b0a055..2ef6fdc2a 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -56,6 +56,7 @@ import { getMemberDisplayName, } from '../../../utils/room'; import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix'; +import { messageAriaLabel } from '../../../utils/a11y'; import { MessageLayout, MessageSpacing } from '../../../state/settings'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useRecentEmoji } from '../../../hooks/useRecentEmoji'; @@ -972,6 +973,10 @@ export const Message = React.memo( [MsgAppearClass]: playAppear, [MentionHighlightPulse]: playMentionPulse, })} + role="article" + aria-label={ + collapse ? messageAriaLabel(senderDisplayName, mEvent.getTs(), hour24Clock) : undefined + } tabIndex={0} space={messageSpacing} collapse={collapse} diff --git a/src/app/features/room/message/MessageEditor.tsx b/src/app/features/room/message/MessageEditor.tsx index bc5871891..5b4b3338e 100644 --- a/src/app/features/room/message/MessageEditor.tsx +++ b/src/app/features/room/message/MessageEditor.tsx @@ -51,7 +51,13 @@ import { UseStateProvider } from '../../../components/UseStateProvider'; import { EmojiBoard } from '../../../components/emoji-board'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { getEditedEvent, getMentionContent, trimReplyFromFormattedBody } from '../../../utils/room'; +import { + getEditedEvent, + getMemberDisplayName, + getMentionContent, + trimReplyFromFormattedBody, +} from '../../../utils/room'; +import { getMxIdLocalPart } from '../../../utils/matrix'; import { mobileOrTablet } from '../../../utils/user-agent'; import { useComposingCheck } from '../../../hooks/useComposingCheck'; @@ -66,6 +72,12 @@ export const MessageEditor = as<'div', MessageEditorProps>( ({ room, roomId, mEvent, imagePackRooms, onCancel, ...props }, ref) => { const mx = useMatrixClient(); const editor = useEditor(); + // Accessible name for the edit textbox so screen readers announce which + // message is being edited (a11y, P3-4). + const editSenderId = mEvent.getSender(); + const editSenderName = editSenderId + ? (getMemberDisplayName(room, editSenderId) ?? getMxIdLocalPart(editSenderId) ?? editSenderId) + : ''; const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline'); const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar'); const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown'); @@ -259,6 +271,7 @@ export const MessageEditor = as<'div', MessageEditorProps>( findAndReplace( text, EMOJI_REG_G, - (match, pushIndex) => ( - - - {match[0]} + (match, pushIndex) => { + const shortcode = getShortcodeFor(getHexcodeForEmoji(match[0])); + return ( + + + {match[0]} + - - ), + ); + }, (txt) => txt, ); @@ -574,10 +582,20 @@ export const getReactCustomHtmlParser = ( ); } if (htmlSrc && 'data-mx-emoticon' in props) { + const emoticonAlt = + (typeof props.alt === 'string' && props.alt) || + (typeof props.title === 'string' && props.title) || + 'emoji'; return ( - + {emoticonAlt} ); @@ -611,7 +629,6 @@ export const getReactCustomHtmlParser = ( <> {segments.map((segment, index) => { if (segment.type === 'text') { - // eslint-disable-next-line react/no-array-index-key return ( {renderTextChunk(segment.value)} ); @@ -619,7 +636,6 @@ export const getReactCustomHtmlParser = ( const raw = segment.type === 'block' ? `$$${segment.value}$$` : `$${segment.value}$`; return ( - // eslint-disable-next-line react/no-array-index-key {renderMath(segment.value, segment.type === 'block', raw, raw)} diff --git a/src/app/utils/a11y.test.ts b/src/app/utils/a11y.test.ts new file mode 100644 index 000000000..f2313c886 --- /dev/null +++ b/src/app/utils/a11y.test.ts @@ -0,0 +1,28 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import dayjs from 'dayjs'; +import { messageAriaLabel } from './a11y'; +import { timeDayMonthYear, timeHourMinute } from './time'; + +test('messageAriaLabel composes sender, date and time (24h)', () => { + const ts = dayjs('2026-07-01T14:30:00').valueOf(); + assert.equal( + messageAriaLabel('Alice', ts, true), + `Alice, ${timeDayMonthYear(ts)} ${timeHourMinute(ts, true)}`, + ); +}); + +test('messageAriaLabel honours the 12-hour clock preference', () => { + const ts = dayjs('2026-07-01T14:30:00').valueOf(); + assert.equal( + messageAriaLabel('Bob', ts, false), + `Bob, ${timeDayMonthYear(ts)} ${timeHourMinute(ts, false)}`, + ); +}); + +test('messageAriaLabel keeps the sender name verbatim as plain text', () => { + const ts = dayjs('2026-07-01T09:05:00').valueOf(); + const label = messageAriaLabel('@user:example.org', ts, true); + assert.ok(label.startsWith('@user:example.org, ')); + assert.ok(!label.includes('<')); +}); diff --git a/src/app/utils/a11y.ts b/src/app/utils/a11y.ts new file mode 100644 index 000000000..2485e9d95 --- /dev/null +++ b/src/app/utils/a11y.ts @@ -0,0 +1,14 @@ +import { timeDayMonthYear, timeHourMinute } from './time'; + +/** + * Builds a plain-text accessible label for a message row, used when the + * visible sender/timestamp header is collapsed and therefore hidden from + * assistive technology. + * + * @param sender - Sender display name (already resolved to a human string). + * @param ts - Message origin timestamp in milliseconds. + * @param hour24Clock - Whether to format the time using a 24-hour clock. + * @returns A label such as `Alice, 1 July 2026 14:30`. + */ +export const messageAriaLabel = (sender: string, ts: number, hour24Clock: boolean): string => + `${sender}, ${timeDayMonthYear(ts)} ${timeHourMinute(ts, hour24Clock)}`;