Files
cinny/src/app/features/room/reaction-viewer/ReactionViewer.tsx
T
Lotus CIandClaude Opus 5.5 1b5e6a37f5
CI / Secret scan (gitleaks) (push) Successful in 52s
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
fix(a11y): stable reaction labels — emoji glyph, custom emoji shortcode (#179)
The reaction button's aria-label used getShortcodeFor(), which returns
undefined until the lazily loaded emoji data arrives. The same button read
"🎉 reaction, 1 person" on first render and "tada reaction, 2 people" after
any later re-render. It now always uses the emoji itself (screen readers
speak it by its proper name, e.g. "party popper").

Custom (mxc) emoji were labelled just "custom emoji"; they now use the
shortcode carried on the reaction event (":lotus_blob: reaction").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-26 11:34:03 -04:00

196 lines
7.0 KiB
TypeScript

import React, { useCallback, useRef, useState } from 'react';
import classNames from 'classnames';
import {
Avatar,
Box,
Header,
Icon,
IconButton,
Icons,
Line,
MenuItem,
Scroll,
Text,
as,
config,
} from 'folds';
import { MatrixEvent, Room, RoomMember } from 'matrix-js-sdk';
import { Relations } from 'matrix-js-sdk/lib/models/relations';
import { getMemberName } from '../../../utils/room';
import { eventWithShortcode, getMxIdLocalPart } from '../../../utils/matrix';
import * as css from './ReactionViewer.css';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useRelations } from '../../../hooks/useRelations';
import { Reaction } from '../../../components/message';
import { getHexcodeForEmoji, getShortcodeFor } from '../../../plugins/emoji';
import { UserAvatar } from '../../../components/user-avatar';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useOpenUserRoomProfile } from '../../../state/hooks/userRoomProfile';
import { useSpaceOptionally } from '../../../hooks/useSpace';
import { getMouseEventCords } from '../../../utils/dom';
export type ReactionViewerProps = {
room: Room;
initialKey?: string;
relations: Relations;
requestClose: () => void;
};
export const ReactionViewer = as<'div', ReactionViewerProps>(
({ className, room, initialKey, relations, requestClose, ...props }, ref) => {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const reactions = useRelations(
relations,
useCallback((rel) => [...(rel.getSortedAnnotationsByKey() ?? [])], []),
);
const space = useSpaceOptionally();
const openProfile = useOpenUserRoomProfile();
const [selectedKey, setSelectedKey] = useState<string>(() => {
if (initialKey) return initialKey;
const defaultReaction = reactions.find((reaction) => typeof reaction[0] === 'string');
return defaultReaction ? defaultReaction[0] : '';
});
const sidebarRef = useRef<HTMLDivElement>(null);
const handleSidebarKeyDown = (e: React.KeyboardEvent) => {
const keys = reactions.map(([k]) => k).filter((k): k is string => typeof k === 'string');
const currentIdx = keys.indexOf(selectedKey);
if (e.key === 'ArrowDown') {
e.preventDefault();
const next = keys[(currentIdx + 1) % keys.length];
if (next) setSelectedKey(next);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
const prev = keys[(currentIdx - 1 + keys.length) % keys.length];
if (prev) setSelectedKey(prev);
}
};
const getName = (member: RoomMember) => getMemberName(room, member.userId);
const getReactionsForKey = (key: string): MatrixEvent[] => {
const reactSet = reactions.find(([k]) => k === key)?.[1];
if (!reactSet) return [];
return Array.from(reactSet);
};
const selectedReactions = getReactionsForKey(selectedKey);
const selectedShortcode =
selectedReactions.find(eventWithShortcode)?.getContent().shortcode ??
getShortcodeFor(getHexcodeForEmoji(selectedKey)) ??
selectedKey;
return (
<Box
className={classNames(css.ReactionViewer, className)}
direction="Row"
{...props}
ref={ref}
>
<Box
shrink="No"
className={css.Sidebar}
ref={sidebarRef}
role="listbox"
aria-label="Reactions"
tabIndex={0}
onKeyDown={handleSidebarKeyDown}
style={{ outline: 'none' }}
>
<Scroll visibility="Hover" hideTrack size="300">
<Box className={css.SidebarContent} direction="Column" gap="200">
{reactions.map(([key, evts]) => {
if (typeof key !== 'string') return null;
return (
<Reaction
key={key}
mx={mx}
reaction={key}
shortcode={Array.from(evts).find(eventWithShortcode)?.getContent().shortcode}
count={evts.size}
role="option"
aria-selected={key === selectedKey}
onClick={() => setSelectedKey(key)}
useAuthentication={useAuthentication}
/>
);
})}
</Box>
</Scroll>
</Box>
<Line variant="Surface" direction="Vertical" size="300" />
<Box grow="Yes" direction="Column">
<Header className={css.Header} variant="Surface" size="600">
<Box grow="Yes">
<Text size="H3" truncate>{`Reacted with :${selectedShortcode}:`}</Text>
</Box>
<IconButton size="300" onClick={requestClose} aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box grow="Yes">
<Scroll visibility="Hover" hideTrack size="300">
<Box className={css.Content} direction="Column">
{selectedReactions.map((mEvent) => {
const senderId = mEvent.getSender();
if (!senderId) return null;
const member = room.getMember(senderId);
const name = (member ? getName(member) : getMxIdLocalPart(senderId)) ?? senderId;
const avatarMxcUrl = member?.getMxcAvatarUrl();
const avatarUrl = avatarMxcUrl
? mx.mxcUrlToHttp(
avatarMxcUrl,
100,
100,
'crop',
undefined,
false,
useAuthentication,
)
: undefined;
return (
<MenuItem
key={senderId}
style={{ padding: `0 ${config.space.S200}` }}
radii="400"
onClick={(event) => {
openProfile(
room.roomId,
space?.roomId,
senderId,
getMouseEventCords(event.nativeEvent),
'Bottom',
);
}}
before={
<Avatar size="200">
<UserAvatar
userId={senderId}
src={avatarUrl ?? undefined}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
}
>
<Box grow="Yes">
<Text size="T400" truncate>
{name}
</Text>
</Box>
</MenuItem>
);
})}
</Box>
</Scroll>
</Box>
</Box>
</Box>
);
},
);