refactor(event-readers): rebuild the read-receipts card as a self-contained Vojo overlay with per-reader timestamps
This commit is contained in:
parent
e1d8e18176
commit
ecfb02a221
2 changed files with 268 additions and 99 deletions
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue