Mobile follow-ups round 2 (survey findings deferred from the mobile audit), reviewed by 2 agents on the staged diff (both SHIP). - URL-preview cards: the Twitch / Twitter / TikTok-fallback cards render their thumbnail/header BESIDE the content as direct children of the UrlPreview flex row, which squeezes both on a phone. Add `StackOnMobile` (@media max-width:750px -> flex-direction:column) scoped to those variants via cardClass. folds Box has no default `direction`, so the override wins uncontested; desktop (>750px) is unchanged. No-op for the single-column embed cards (MediaEmbedCard/TikTokEmbedCard). - 44px touch targets (MobileTouchTarget, @media max-width:750px) on the otherwise ~28px controls: embed-player Close/Collapse/Fullscreen/View-post buttons; image-viewer close/zoom/download; the read-receipt "seen by" pill. Deferred (rationale, not built): PiP resize handles + fullscreen button — enlarging four 24px corners to 44px would swallow a ~160px mobile PiP and block "Return to call"; presence dot is a non-interactive status indicator. Gates: tsc 0, eslint 0, prettier clean, 856/856 tests, build ok. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
131 lines
4.2 KiB
TypeScript
131 lines
4.2 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { Room } from 'matrix-js-sdk';
|
|
import {
|
|
Icon,
|
|
Icons,
|
|
Modal,
|
|
Overlay,
|
|
OverlayBackdrop,
|
|
OverlayCenter,
|
|
Text,
|
|
color,
|
|
config,
|
|
} from 'folds';
|
|
import FocusTrap from 'focus-trap-react';
|
|
import { useSetting } from '../../state/hooks/settings';
|
|
import { settingsAtom } from '../../state/settings';
|
|
import { getMemberName } from '../../utils/room';
|
|
import { UserAvatar } from '../user-avatar';
|
|
import { StackedAvatar } from '../stacked-avatar';
|
|
import { EventReaders } from '../event-readers';
|
|
import { stopPropagation } from '../../utils/keyboard';
|
|
import { useModalStyle } from '../../hooks/useModalStyle';
|
|
import { useMemberAvatar } from '../../hooks/useMemberAvatar';
|
|
import { useRoomMembersChange } from '../../hooks/useRoomMemberChange';
|
|
import { MobileTouchTarget } from '../../styles/mobile.css';
|
|
import * as css from './ReadReceiptAvatars.css';
|
|
|
|
const MAX_DISPLAY = 5;
|
|
|
|
function ReceiptStackedAvatar({ room, userId }: { room: Room; userId: string }) {
|
|
const { name, avatarUrl } = useMemberAvatar(room, userId);
|
|
return (
|
|
<StackedAvatar title={name} variant="SurfaceVariant" size="200" radii="Pill">
|
|
<UserAvatar
|
|
userId={userId}
|
|
src={avatarUrl}
|
|
alt={name}
|
|
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
|
/>
|
|
</StackedAvatar>
|
|
);
|
|
}
|
|
|
|
export function ReadReceiptAvatars({
|
|
room,
|
|
eventId,
|
|
userIds,
|
|
}: {
|
|
room: Room;
|
|
eventId: string;
|
|
userIds: string[];
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
|
const modalStyle = useModalStyle(360);
|
|
|
|
// The tooltip names below are read from room member state at render time.
|
|
// Re-render on a member-state change of any displayed reader so a display-name
|
|
// update shows live (each avatar handles its own via useMemberAvatar). Uses the
|
|
// shared member-change store — one global listener for the whole app (PERF-3).
|
|
useRoomMembersChange(room.roomId, userIds);
|
|
|
|
if (userIds.length === 0) return null;
|
|
|
|
const displayed = userIds.slice(0, MAX_DISPLAY);
|
|
const extra = userIds.length - MAX_DISPLAY;
|
|
const tooltipNames =
|
|
userIds
|
|
.slice(0, 5)
|
|
.map((id) => getMemberName(room, id))
|
|
.join(', ') + (extra > 0 ? ` +${extra} more` : '');
|
|
|
|
return (
|
|
<>
|
|
<Overlay open={open} backdrop={<OverlayBackdrop />}>
|
|
<OverlayCenter>
|
|
<FocusTrap
|
|
focusTrapOptions={{
|
|
initialFocus: false,
|
|
onDeactivate: () => setOpen(false),
|
|
clickOutsideDeactivates: true,
|
|
escapeDeactivates: stopPropagation,
|
|
}}
|
|
>
|
|
<Modal variant="Surface" size="300" style={modalStyle}>
|
|
<EventReaders room={room} eventId={eventId} requestClose={() => setOpen(false)} />
|
|
</Modal>
|
|
</FocusTrap>
|
|
</OverlayCenter>
|
|
</Overlay>
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen(true)}
|
|
title={tooltipNames}
|
|
aria-label={tooltipNames}
|
|
className={`${css.ReceiptTrigger} ${MobileTouchTarget}`}
|
|
>
|
|
{/* Pill wrapper ensures visibility on any wallpaper/background */}
|
|
<span
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
backgroundColor: lotusTerminal
|
|
? 'color-mix(in srgb, var(--lt-accent-cyan) 7%, transparent)'
|
|
: color.SurfaceVariant.Container,
|
|
border: lotusTerminal
|
|
? `${config.borderWidth.B300} solid color-mix(in srgb, var(--lt-accent-cyan) 30%, transparent)`
|
|
: `${config.borderWidth.B300} solid transparent`,
|
|
boxShadow: lotusTerminal ? 'var(--lt-box-glow-cyan)' : 'none',
|
|
borderRadius: config.radii.Pill,
|
|
padding: `${config.space.S100} ${config.space.S200}`,
|
|
gap: '0px',
|
|
}}
|
|
>
|
|
{displayed.map((userId) => (
|
|
<ReceiptStackedAvatar key={userId} room={room} userId={userId} />
|
|
))}
|
|
{extra > 0 && (
|
|
<Text
|
|
size="T200"
|
|
style={{ paddingLeft: '4px', color: color.SurfaceVariant.OnContainer }}
|
|
>
|
|
+{extra}
|
|
</Text>
|
|
)}
|
|
</span>
|
|
</button>
|
|
</>
|
|
);
|
|
}
|