diff --git a/src/app/components/event-readers/EventReaders.css.ts b/src/app/components/event-readers/EventReaders.css.ts index 36f47b56..a2005b04 100644 --- a/src/app/components/event-readers/EventReaders.css.ts +++ b/src/app/components/event-readers/EventReaders.css.ts @@ -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', }); diff --git a/src/app/components/event-readers/EventReaders.tsx b/src/app/components/event-readers/EventReaders.tsx index fbbba81c..5c64fddb 100644 --- a/src/app/components/event-readers/EventReaders.tsx +++ b/src/app/components/event-readers/EventReaders.tsx @@ -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 ( - -
- - Seen by - - - - -
- - - - {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 ( - ) => { - openProfile( - room.roomId, - space?.roomId, - readerId, - getMouseEventCords(event.nativeEvent), - 'Bottom' - ); - }} - before={ - - } - /> - - } - > - - {name} - - - ); - })} - - - -
- ); - } -); + 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 ( + }> + + +
+
+ +
+ {t('Room.seen_by')} + + {t('Room.seen_by_count', { count: latestEventReaders.length })} + +
+ + + +
+ + {latestEventReaders.length === 0 ? ( +
{t('Room.seen_by_empty')}
+ ) : ( + +
+ {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 ( + + ); + })} +
+
+ )} +
+
+
+
+ ); +}