refactor(event-readers): rebuild the read-receipts card as a self-contained Vojo overlay with per-reader timestamps

This commit is contained in:
heaven 2026-06-15 02:57:32 +03:00
parent e1d8e18176
commit ecfb02a221
2 changed files with 268 additions and 99 deletions

View file

@ -1,21 +1,139 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, config } from 'folds';
import { color, config, toRem } from 'folds';
export const EventReaders = style([
DefaultReset,
{
height: '100%',
},
]);
// Read-receipts card — the self-styled Vojo card vocabulary (AiChatPrivacy /
// LogoutDialog): own surface/radius/border/shadow, badge header with no
// bottom-border bar. Replaces the old folds Modal + hardcoded «Seen by»
// header that read as a bare cinny dialog (and wasn't localized).
export const DialogCard = style({
width: '90vw',
maxWidth: toRem(360),
maxHeight: '70vh',
display: 'flex',
flexDirection: 'column',
minHeight: 0,
overflow: 'hidden',
backgroundColor: color.Surface.Container,
borderRadius: config.radii.R400,
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.5)',
});
export const Header = style({
paddingLeft: config.space.S400,
paddingRight: config.space.S300,
flexShrink: 0,
display: 'flex',
alignItems: 'flex-start',
gap: config.space.S300,
padding: `${config.space.S400} ${config.space.S400} ${config.space.S300}`,
});
export const Content = style({
paddingLeft: config.space.S200,
paddingBottom: config.space.S400,
export const HeaderBadge = style({
flexShrink: 0,
width: toRem(40),
height: toRem(40),
borderRadius: config.radii.R400,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: color.Primary.Container,
color: color.Primary.Main,
});
export const HeaderText = style({
flexGrow: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
gap: toRem(2),
paddingTop: toRem(2),
});
export const HeaderTitle = style({
fontSize: toRem(18),
fontWeight: 700,
lineHeight: toRem(22),
color: color.Surface.OnContainer,
});
export const HeaderSubtitle = style({
fontSize: toRem(13),
lineHeight: toRem(17),
color: color.SurfaceVariant.OnContainer,
opacity: 0.7,
});
export const BodyScroll = style({
flexGrow: 1,
minHeight: 0,
});
export const Body = style({
display: 'flex',
flexDirection: 'column',
gap: toRem(1),
padding: `0 ${config.space.S200} ${config.space.S300}`,
});
// Flat reader row — avatar + (name / read-time) column. Same flat-on-surface
// treatment as the ⋮ menu's ActionRow.
export const ReaderRow = style({
width: '100%',
display: 'flex',
alignItems: 'center',
gap: config.space.S300,
padding: `${config.space.S200} ${config.space.S300}`,
borderRadius: toRem(10),
background: 'transparent',
border: 'none',
textAlign: 'left',
font: 'inherit',
color: color.Surface.OnContainer,
cursor: 'pointer',
selectors: {
'&:hover': { backgroundColor: color.Surface.ContainerHover },
'&:active': { backgroundColor: color.Surface.ContainerActive },
'&:focus-visible': {
outline: `${toRem(2)} solid ${color.Primary.Main}`,
outlineOffset: toRem(-2),
},
},
});
export const ReaderText = style({
flexGrow: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
gap: toRem(1),
});
export const ReaderName = style({
fontSize: toRem(14),
fontWeight: 600,
lineHeight: toRem(18),
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
});
// When the reader read it — the receipt timestamp, muted tabular numerals
// like every other technical metadata line in the Dawn vocabulary.
export const ReaderTime = style({
fontSize: toRem(11.5),
lineHeight: toRem(15),
color: color.SurfaceVariant.OnContainer,
opacity: 0.65,
fontVariantNumeric: 'tabular-nums',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
});
export const EmptyState = style({
padding: `${config.space.S300} ${config.space.S300} ${config.space.S400}`,
fontSize: toRem(13.5),
lineHeight: toRem(19),
color: color.SurfaceVariant.OnContainer,
opacity: 0.7,
textAlign: 'center',
});

View file

@ -1,22 +1,21 @@
import React from 'react';
import classNames from 'classnames';
import FocusTrap from 'focus-trap-react';
import {
Avatar,
Box,
Header,
Icon,
IconButton,
Icons,
MenuItem,
Overlay,
OverlayBackdrop,
OverlayCenter,
Scroll,
Text,
as,
config,
} from 'folds';
import { Room } from 'matrix-js-sdk';
import { useTranslation } from 'react-i18next';
import { useRoomEventReaders } from '../../hooks/useRoomEventReaders';
import { getMemberDisplayName } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix';
import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../utils/time';
import * as css from './EventReaders.css';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { UserAvatar } from '../user-avatar';
@ -24,91 +23,143 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { useSpaceOptionally } from '../../hooks/useSpace';
import { getMouseEventCords } from '../../utils/dom';
import { stopPropagation } from '../../utils/keyboard';
export type EventReadersProps = {
room: Room;
eventId: string;
requestClose: () => void;
};
export const EventReaders = as<'div', EventReadersProps>(
({ className, room, eventId, requestClose, ...props }, ref) => {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const latestEventReaders = useRoomEventReaders(room, eventId);
const openProfile = useOpenUserRoomProfile();
const space = useSpaceOptionally();
const getName = (userId: string) =>
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
// Read-receipts card («Прочитали») — self-contained Vojo card (owns its
// Overlay + FocusTrap; callers just conditionally render it). Each row is a
// reader with their receipt timestamp — the receipt is the user's CURRENT
// read marker, so for older messages it reads as «read at or before this
// time»; for the common case (checking your latest message) it's exact.
// Tapping a reader closes the card and opens their profile sheet — the
// profile surface (side pane / horseshoe) renders OUTSIDE this overlay, so
// leaving the card open would hide it behind the backdrop.
export function EventReaders({ room, eventId, requestClose }: EventReadersProps) {
const { t } = useTranslation();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const latestEventReaders = useRoomEventReaders(room, eventId);
const openProfile = useOpenUserRoomProfile();
const space = useSpaceOptionally();
return (
<Box
className={classNames(css.EventReaders, className)}
direction="Column"
{...props}
ref={ref}
>
<Header className={css.Header} variant="Surface" size="600">
<Box grow="Yes">
<Text size="H3">Seen by</Text>
</Box>
<IconButton size="300" onClick={requestClose}>
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box grow="Yes">
<Scroll visibility="Hover" hideTrack size="300">
<Box className={css.Content} direction="Column">
{latestEventReaders.map((readerId) => {
const name = getName(readerId);
const avatarMxcUrl = room.getMember(readerId)?.getMxcAvatarUrl();
const avatarUrl = avatarMxcUrl
? mx.mxcUrlToHttp(
avatarMxcUrl,
100,
100,
'crop',
undefined,
false,
useAuthentication
)
: undefined;
const getName = (userId: string) =>
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
return (
<MenuItem
key={readerId}
style={{ padding: `0 ${config.space.S200}` }}
radii="400"
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
openProfile(
room.roomId,
space?.roomId,
readerId,
getMouseEventCords(event.nativeEvent),
'Bottom'
);
}}
before={
<Avatar size="200">
<UserAvatar
userId={readerId}
src={avatarUrl ?? undefined}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
}
>
<Text size="T400" truncate>
{name}
</Text>
</MenuItem>
);
})}
</Box>
</Scroll>
</Box>
</Box>
);
}
);
const formatReadTime = (ts: number): string => {
if (today(ts)) return `${t('Room.today')}, ${timeHourMinute(ts)}`;
if (yesterday(ts)) return `${t('Room.yesterday')}, ${timeHourMinute(ts)}`;
return `${timeDayMonYear(ts)}, ${timeHourMinute(ts)}`;
};
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: requestClose,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<div
className={css.DialogCard}
role="dialog"
aria-modal="true"
aria-label={t('Room.seen_by')}
>
<header className={css.Header}>
<span className={css.HeaderBadge} aria-hidden="true">
<Icon src={Icons.CheckTwice} />
</span>
<div className={css.HeaderText}>
<span className={css.HeaderTitle}>{t('Room.seen_by')}</span>
<span className={css.HeaderSubtitle}>
{t('Room.seen_by_count', { count: latestEventReaders.length })}
</span>
</div>
<IconButton
size="300"
variant="Surface"
radii="300"
onClick={requestClose}
aria-label={t('Room.close')}
>
<Icon src={Icons.Cross} />
</IconButton>
</header>
{latestEventReaders.length === 0 ? (
<div className={css.EmptyState}>{t('Room.seen_by_empty')}</div>
) : (
<Scroll
className={css.BodyScroll}
variant="Surface"
direction="Vertical"
size="300"
hideTrack
visibility="Hover"
>
<div className={css.Body}>
{latestEventReaders.map((readerId) => {
const name = getName(readerId);
const avatarMxcUrl = room.getMember(readerId)?.getMxcAvatarUrl();
const avatarUrl = avatarMxcUrl
? mx.mxcUrlToHttp(
avatarMxcUrl,
100,
100,
'crop',
undefined,
false,
useAuthentication
)
: undefined;
// `ignoreSynthesized: true` — the default prefers the
// SDK's synthetic receipt, whose ts is the moment the
// reader last POSTED, not when they read. Real m.read
// only; rows without one just show no time.
const receiptTs = room.getReadReceiptForUserId(readerId, true)?.data?.ts;
return (
<button
key={readerId}
type="button"
className={css.ReaderRow}
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
const cords = getMouseEventCords(event.nativeEvent);
requestClose();
openProfile(room.roomId, space?.roomId, readerId, cords, 'Bottom');
}}
>
<Avatar size="300">
<UserAvatar
userId={readerId}
src={avatarUrl ?? undefined}
alt={name}
renderFallback={() => <Icon size="100" src={Icons.User} filled />}
/>
</Avatar>
<span className={css.ReaderText}>
<span className={css.ReaderName}>{name}</span>
{typeof receiptTs === 'number' && receiptTs > 0 && (
<span className={css.ReaderTime}>{formatReadTime(receiptTs)}</span>
)}
</span>
</button>
);
})}
</div>
</Scroll>
)}
</div>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}