Files
cinny/src/app/components/room-card/RoomCard.tsx
T
Lotus CIandClaude Opus 5.5 7f2e93d389
CI / Build & Quality Checks (push) Successful in 1m41s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 11s
CI / Trigger Desktop Build (push) Successful in 9s
CI / Playwright smoke (e2e) (push) Successful in 13m14s
fix(a11y): name the headless modals too — Seen by, source, file viewers (#185)
Follow-up to ce8ed89f for the modals that have no visible heading to
point at: the three "Seen by" reader lists (read-receipt pill, "is
following", message menu), View source, the text/PDF file viewers, the
room-card join error and the user-profile modal get role="dialog",
aria-modal and an aria-label; their traps move focus in (fallbackFocus
on the dialog) where the trap is local.

Verified: the receipt pill opens a "Seen by" dialog with focus on
Close; Escape closes it and focus returns to the pill.

The remaining unnamed Modal/Dialog uses are startup/loading and error
screens (config, feature check, spec versions, client root, password
reset) and wrappers around components that carry their own role (image
viewer).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 13:12:13 -04:00

417 lines
14 KiB
TypeScript

import React, { ReactNode, useCallback, useRef, useState } from 'react';
import { JoinRule, MatrixError, Room } from 'matrix-js-sdk';
import {
Avatar,
Badge,
Box,
Button,
Dialog,
Icon,
Icons,
Overlay,
OverlayBackdrop,
OverlayCenter,
Spinner,
Text,
as,
color,
config,
} from 'folds';
import classNames from 'classnames';
import FocusTrap from 'focus-trap-react';
import * as css from './style.css';
import { RoomAvatar } from '../room-avatar';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { nameInitials } from '../../utils/common';
import { millify } from '../../plugins/millify';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { onEnterOrSpace, stopPropagation } from '../../utils/keyboard';
import { RoomType, StateEvent } from '../../../types/matrix/room';
import { useJoinedRoomId } from '../../hooks/useJoinedRoomId';
import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
import { getRoomAvatarUrl, getStateEvent } from '../../utils/room';
import { useStateEventCallback } from '../../hooks/useStateEventCallback';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
type GridColumnCount = '1' | '2' | '3';
const getGridColumnCount = (gridWidth: number): GridColumnCount => {
if (gridWidth <= 498) return '1';
if (gridWidth <= 748) return '2';
return '3';
};
const setGridColumnCount = (grid: HTMLElement, count: GridColumnCount): void => {
grid.style.setProperty('grid-template-columns', `repeat(${count}, 1fr)`);
};
export function RoomCardGrid({ children }: { children: ReactNode }) {
const gridRef = useRef<HTMLDivElement>(null);
useElementSizeObserver(
useCallback(() => gridRef.current, []),
useCallback((width, _, target) => setGridColumnCount(target, getGridColumnCount(width)), []),
);
return (
<Box className={css.CardGrid} direction="Row" gap="400" wrap="Wrap" ref={gridRef}>
{children}
</Box>
);
}
export const RoomCardBase = as<'div'>(({ className, ...props }, ref) => (
<Box
direction="Column"
gap="300"
className={classNames(css.RoomCardBase, className)}
{...props}
ref={ref}
/>
));
export const RoomCardName = as<'h6'>(({ ...props }, ref) => (
<Text as="h6" size="H6" truncate {...props} ref={ref} />
));
export const RoomCardTopic = as<'p'>(({ className, ...props }, ref) => (
<Text
as="p"
size="T200"
className={classNames(css.RoomCardTopic, className)}
{...props}
priority="400"
ref={ref}
/>
));
function ErrorDialog({
title,
message,
children,
}: {
title: string;
message: string;
children: (openError: () => void) => ReactNode;
}) {
const [viewError, setViewError] = useState(false);
const closeError = () => setViewError(false);
const openError = () => setViewError(true);
return (
<>
{children(openError)}
<Overlay open={viewError} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
fallbackFocus: '#roomcard-error-dialog',
clickOutsideDeactivates: true,
onDeactivate: closeError,
escapeDeactivates: stopPropagation,
}}
>
<Dialog
id="roomcard-error-dialog"
role="dialog"
aria-modal="true"
aria-label={title}
tabIndex={-1}
variant="Surface"
>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="100">
<Text>{title}</Text>
<Text style={{ color: color.Critical.Main }} size="T300" priority="400">
{message}
</Text>
</Box>
<Button size="400" variant="Secondary" fill="Soft" onClick={closeError}>
<Text size="B400">Cancel</Text>
</Button>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
</>
);
}
type RoomCardProps = {
roomIdOrAlias: string;
allRooms: string[];
avatarUrl?: string;
name?: string;
topic?: string;
memberCount?: number;
roomType?: string;
joinRule?: string;
encrypted?: boolean;
canonicalAlias?: string;
worldReadable?: boolean;
/** The viewer's own membership over federation (leave/invite/knock/ban). */
membership?: string;
/** Larger, centered treatment for the single-room preview page. */
hero?: boolean;
viaServers?: string[];
onView?: (roomId: string) => void;
renderTopicViewer: (name: string, topic: string, requestClose: () => void) => ReactNode;
};
// Map a room's join rule to a short preview chip. Public is the common case and
// gets no chip (avoids noise); the rest tell the user how entry works.
const joinRuleLabel = (joinRule?: string): string | undefined => {
if (joinRule === JoinRule.Restricted || joinRule === 'knock_restricted') return 'Restricted';
if (joinRule === JoinRule.Knock) return 'Ask to join';
if (joinRule === JoinRule.Invite) return 'Invite only';
if (joinRule === JoinRule.Private) return 'Private';
return undefined;
};
export const RoomCard = as<'div', RoomCardProps>(
(
{
roomIdOrAlias,
allRooms,
avatarUrl,
name,
topic,
memberCount,
roomType,
joinRule,
encrypted,
canonicalAlias,
worldReadable,
membership,
hero,
viaServers,
onView,
renderTopicViewer,
...props
},
ref,
) => {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const joinedRoomId = useJoinedRoomId(allRooms, roomIdOrAlias);
const joinedRoom = mx.getRoom(joinedRoomId);
const [topicEvent, setTopicEvent] = useState(() =>
joinedRoom ? getStateEvent(joinedRoom, StateEvent.RoomTopic) : undefined,
);
// Name falls back to the alias (readable), then the alias localpart, then the
// raw id — never the raw `!id` when a nicer form exists.
const fallbackName = canonicalAlias || getMxIdLocalPart(roomIdOrAlias) || roomIdOrAlias;
const avatar = joinedRoom
? getRoomAvatarUrl(mx, joinedRoom, 96, useAuthentication)
: avatarUrl && mxcUrlToHttp(mx, avatarUrl, useAuthentication, 96, 96, 'crop');
const roomName = joinedRoom?.name || name || fallbackName;
// No topic → render a muted placeholder, NOT the raw room id.
const roomTopic = (topicEvent?.getContent().topic as string) || topic || undefined;
// Only show an alias line when it adds info beyond the displayed name.
const aliasLine = canonicalAlias && canonicalAlias !== roomName ? canonicalAlias : undefined;
const joinedMemberCount = joinedRoom?.getJoinedMemberCount() ?? memberCount;
useStateEventCallback(
mx,
useCallback(
(event) => {
if (
joinedRoom &&
event.getRoomId() === joinedRoom.roomId &&
event.getType() === StateEvent.RoomTopic
) {
setTopicEvent(getStateEvent(joinedRoom, StateEvent.RoomTopic));
}
},
[joinedRoom],
),
);
const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
useCallback(
() => mx.joinRoom(roomIdOrAlias, { viaServers }),
[mx, roomIdOrAlias, viaServers],
),
);
const [knockState, knock] = useAsyncCallback<{ room_id: string }, MatrixError, []>(
useCallback(
() => mx.knockRoom(roomIdOrAlias, { viaServers }),
[mx, roomIdOrAlias, viaServers],
),
);
// A knock-rule room can't be joined directly — request to join instead.
const isKnock = joinRule === JoinRule.Knock;
// Membership known from the summary survives reloads (unlike local knockState).
const invited = membership === 'invite';
const alreadyKnocked = membership === 'knock';
const banned = membership === 'ban';
const action = isKnock ? knock : join;
const actionState = isKnock ? knockState : joinState;
const acting =
actionState.status === AsyncStatus.Loading || actionState.status === AsyncStatus.Success;
const requested = alreadyKnocked || knockState.status === AsyncStatus.Success;
let actionLabel: string;
if (banned) actionLabel = 'Banned';
else if (invited) actionLabel = acting ? 'Joining' : 'Accept invite';
else if (isKnock)
actionLabel = requested ? 'Requested' : acting ? 'Requesting' : 'Request to join';
else actionLabel = acting ? 'Joining' : 'Join';
const actionDisabled = banned || requested || acting;
const chip = joinRuleLabel(joinRule);
const [viewTopic, setViewTopic] = useState(false);
const closeTopic = () => setViewTopic(false);
const openTopic = () => setViewTopic(true);
return (
<RoomCardBase {...props} ref={ref}>
<Box gap="200" justifyContent="SpaceBetween">
<Avatar size="500">
<RoomAvatar
roomId={roomIdOrAlias}
src={avatar ?? undefined}
alt={roomIdOrAlias}
renderFallback={() => (
<Text as="span" size="H3">
{nameInitials(roomName)}
</Text>
)}
/>
</Avatar>
{(roomType === RoomType.Space || joinedRoom?.isSpaceRoom()) && (
<Badge variant="Secondary" fill="Soft" outlined>
<Text size="L400">Space</Text>
</Badge>
)}
</Box>
<Box grow="Yes" direction="Column" gap="100">
<RoomCardName>{roomName}</RoomCardName>
{aliasLine && (
<Text size="T200" priority="300" truncate>
{aliasLine}
</Text>
)}
{roomTopic ? (
<RoomCardTopic
onClick={openTopic}
onKeyDown={onEnterOrSpace(openTopic)}
tabIndex={0}
style={hero ? { WebkitLineClamp: 10 } : undefined}
>
{roomTopic}
</RoomCardTopic>
) : (
<Text size="T200" priority="300" style={{ opacity: 0.6 }}>
No description
</Text>
)}
<Overlay open={viewTopic} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: true,
onDeactivate: closeTopic,
escapeDeactivates: stopPropagation,
}}
>
{renderTopicViewer(roomName, roomTopic ?? '', closeTopic)}
</FocusTrap>
</OverlayCenter>
</Overlay>
</Box>
{typeof joinedMemberCount === 'number' && (
<Box gap="100">
<Icon size="50" src={Icons.User} />
<Text size="T200">{`${millify(joinedMemberCount)} Members`}</Text>
</Box>
)}
{!joinedRoom && (chip || encrypted || worldReadable) && (
<Box gap="100" wrap="Wrap">
{chip && (
<Badge variant="Secondary" fill="Soft" outlined>
<Text size="L400">{chip}</Text>
</Badge>
)}
{encrypted && (
<Badge variant="Success" fill="Soft" outlined>
<Text size="L400">Encrypted</Text>
</Badge>
)}
{worldReadable && (
<Badge variant="Secondary" fill="Soft" outlined>
<Text size="L400">Readable</Text>
</Badge>
)}
</Box>
)}
{typeof joinedRoomId === 'string' && (
<Button
onClick={onView ? () => onView(joinedRoomId) : undefined}
variant="Secondary"
fill="Soft"
size="300"
>
<Text size="B300" truncate>
View
</Text>
</Button>
)}
{typeof joinedRoomId !== 'string' && actionState.status !== AsyncStatus.Error && (
<Button
onClick={action}
variant={hero ? 'Primary' : 'Secondary'}
fill={hero ? 'Solid' : undefined}
size="300"
disabled={actionDisabled}
before={acting && <Spinner size="50" variant="Secondary" fill="Soft" />}
>
<Text size="B300" truncate>
{actionLabel}
</Text>
</Button>
)}
{typeof joinedRoomId !== 'string' && actionState.status === AsyncStatus.Error && (
<Box gap="200">
<Button
onClick={action}
className={css.ActionButton}
variant="Critical"
fill="Solid"
size="300"
>
<Text size="B300" truncate>
Retry
</Text>
</Button>
<ErrorDialog
title={isKnock ? 'Request Error' : 'Join Error'}
message={actionState.error.message || 'Failed to join. Unknown Error.'}
>
{(openError) => (
<Button
onClick={openError}
className={css.ActionButton}
variant="Critical"
fill="Soft"
outlined
size="300"
>
<Text size="B300" truncate>
View Error
</Text>
</Button>
)}
</ErrorDialog>
</Box>
)}
</RoomCardBase>
);
},
);