Files
cinny/src/app/features/room/RoomViewTyping.tsx
T
jared 4d7a05c0f1
CI / Build & Quality Checks (push) Successful in 11m3s
CI / Trigger Desktop Build (push) Successful in 22s
fix(a11y): review-wave fixes (P3-4)
- `?` shortcut now stopImmediatePropagation so RoomView's type-to-focus handler
  doesn't steal focus into the composer behind the dialog (and swallow Escape) —
  CONFIRMED review finding.
- Typing live region stays mounted (empty when idle) so the FIRST "X is typing"
  is reliably announced (a status region added with its text isn't always read).
- Removed a stray empty `{}` JSX expression in MediaGallery (leftover from an
  auto-fix).

Reviewer verified the rest: collapsed-message labels, focus-return
classification (4 dialogs fixed, popouts correctly left), and all aria fixes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 11:57:32 -04:00

153 lines
5.5 KiB
TypeScript

import React, { useMemo } from 'react';
import { Box, Icon, IconButton, Icons, Text, as } from 'folds';
import { Room } from 'matrix-js-sdk';
import classNames from 'classnames';
import { useSetAtom } from 'jotai';
import { roomIdToTypingMembersAtom } from '../../state/typingMembers';
import { TypingIndicator } from '../../components/typing-indicator';
import { getMemberDisplayName } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix';
import * as css from './RoomViewTyping.css';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
export type RoomViewTypingProps = {
room: Room;
};
export const RoomViewTyping = as<'div', RoomViewTypingProps>(
({ className, room, ...props }, ref) => {
const setTypingMembers = useSetAtom(roomIdToTypingMembersAtom);
const mx = useMatrixClient();
const typingMembers = useRoomTypingMember(room.roomId);
const myUserId = mx.getUserId();
const typingNames = useMemo(
() =>
typingMembers
.filter((receipt) => receipt.userId !== myUserId)
.map(
(receipt) =>
getMemberDisplayName(room, receipt.userId) ?? getMxIdLocalPart(receipt.userId),
)
.reverse(),
[typingMembers, myUserId, room],
);
// A single, non-truncated string for assistive technology to announce.
// Computed even when empty so the live region can stay mounted (below) —
// a `role="status"` region added to the DOM together with its first text
// is not reliably announced by some screen readers.
let typingAnnouncement = '';
if (typingNames.length === 1) {
typingAnnouncement = `${typingNames[0]} is typing`;
} else if (typingNames.length === 2) {
typingAnnouncement = `${typingNames[0]} and ${typingNames[1]} are typing`;
} else if (typingNames.length === 3) {
typingAnnouncement = `${typingNames[0]}, ${typingNames[1]} and ${typingNames[2]} are typing`;
} else {
typingAnnouncement = `${typingNames[0]}, ${typingNames[1]}, ${typingNames[2]} and ${
typingNames.length - 3
} others are typing`;
}
const handleDropAll = () => {
// some homeserver does not timeout typing status
// we have given option so user can drop their typing status
typingMembers.forEach((receipt) =>
setTypingMembers({
type: 'DELETE',
roomId: room.roomId,
userId: receipt.userId,
}),
);
};
return (
<div style={{ position: 'relative' }}>
{/* Persistently mounted so the FIRST "X is typing" is announced. */}
<span className={css.SrOnly} role="status" aria-live="polite" aria-atomic="true">
{typingAnnouncement}
</span>
{typingNames.length > 0 && (
<Box
className={classNames(css.RoomViewTyping, className)}
alignItems="Center"
gap="400"
{...props}
ref={ref}
>
<TypingIndicator />
<Text className={css.TypingText} size="T300" truncate aria-hidden>
{typingNames.length === 1 && (
<>
<b>{typingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{' is typing...'}
</Text>
</>
)}
{typingNames.length === 2 && (
<>
<b>{typingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{typingNames[1]}</b>
<Text as="span" size="Inherit" priority="300">
{' are typing...'}
</Text>
</>
)}
{typingNames.length === 3 && (
<>
<b>{typingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{typingNames[1]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{typingNames[2]}</b>
<Text as="span" size="Inherit" priority="300">
{' are typing...'}
</Text>
</>
)}
{typingNames.length > 3 && (
<>
<b>{typingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{typingNames[1]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{typingNames[2]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{typingNames.length - 3} others</b>
<Text as="span" size="Inherit" priority="300">
{' are typing...'}
</Text>
</>
)}
</Text>
<IconButton
title="Drop Typing Status"
aria-label="Drop typing status"
size="300"
radii="Pill"
onClick={handleDropAll}
>
<Icon size="50" src={Icons.Cross} />
</IconButton>
</Box>
)}
</div>
);
},
);