Files
cinny/src/app/components/read-receipt-avatars/ReadReceiptAvatars.tsx
T
jaredandClaude Opus 4.8 72e7447d28 fix(mobile): stack embed cards + secondary 44px touch targets (r2)
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>
2026-07-19 01:56:55 -04:00

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>
</>
);
}