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>
This commit is contained in:
@@ -7,6 +7,7 @@ import * as css from './ImageViewer.css';
|
|||||||
import { useZoom } from '../../hooks/useZoom';
|
import { useZoom } from '../../hooks/useZoom';
|
||||||
import { usePan } from '../../hooks/usePan';
|
import { usePan } from '../../hooks/usePan';
|
||||||
import { downloadMedia } from '../../utils/matrix';
|
import { downloadMedia } from '../../utils/matrix';
|
||||||
|
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||||
|
|
||||||
export type ImageViewerProps = {
|
export type ImageViewerProps = {
|
||||||
alt: string;
|
alt: string;
|
||||||
@@ -35,7 +36,13 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
|||||||
>
|
>
|
||||||
<Header className={css.ImageViewerHeader} size="400">
|
<Header className={css.ImageViewerHeader} size="400">
|
||||||
<Box grow="Yes" alignItems="Center" gap="200">
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
<IconButton size="300" radii="300" onClick={requestClose} aria-label="Close">
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
className={MobileTouchTarget}
|
||||||
|
onClick={requestClose}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
<Icon size="50" src={Icons.ArrowLeft} />
|
<Icon size="50" src={Icons.ArrowLeft} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Text size="T300" truncate>
|
<Text size="T300" truncate>
|
||||||
@@ -48,12 +55,18 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
|||||||
outlined={zoom < 1}
|
outlined={zoom < 1}
|
||||||
size="300"
|
size="300"
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
|
className={MobileTouchTarget}
|
||||||
onClick={zoomOut}
|
onClick={zoomOut}
|
||||||
aria-label="Zoom Out"
|
aria-label="Zoom Out"
|
||||||
>
|
>
|
||||||
<Icon size="50" src={Icons.Minus} />
|
<Icon size="50" src={Icons.Minus} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Chip variant="SurfaceVariant" radii="Pill" onClick={() => setZoom(zoom === 1 ? 2 : 1)}>
|
<Chip
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
radii="Pill"
|
||||||
|
className={MobileTouchTarget}
|
||||||
|
onClick={() => setZoom(zoom === 1 ? 2 : 1)}
|
||||||
|
>
|
||||||
<Text size="B300">{Math.round(zoom * 100)}%</Text>
|
<Text size="B300">{Math.round(zoom * 100)}%</Text>
|
||||||
</Chip>
|
</Chip>
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -61,6 +74,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
|||||||
outlined={zoom > 1}
|
outlined={zoom > 1}
|
||||||
size="300"
|
size="300"
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
|
className={MobileTouchTarget}
|
||||||
onClick={zoomIn}
|
onClick={zoomIn}
|
||||||
aria-label="Zoom In"
|
aria-label="Zoom In"
|
||||||
>
|
>
|
||||||
@@ -70,6 +84,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
|||||||
variant="Primary"
|
variant="Primary"
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
radii="300"
|
radii="300"
|
||||||
|
className={MobileTouchTarget}
|
||||||
before={<Icon size="50" src={Icons.Download} />}
|
before={<Icon size="50" src={Icons.Download} />}
|
||||||
>
|
>
|
||||||
<Text size="B300">{t('Organisms.ImageViewer.download')}</Text>
|
<Text size="B300">{t('Organisms.ImageViewer.download')}</Text>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { stopPropagation } from '../../utils/keyboard';
|
|||||||
import { useModalStyle } from '../../hooks/useModalStyle';
|
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||||
import { useMemberAvatar } from '../../hooks/useMemberAvatar';
|
import { useMemberAvatar } from '../../hooks/useMemberAvatar';
|
||||||
import { useRoomMembersChange } from '../../hooks/useRoomMemberChange';
|
import { useRoomMembersChange } from '../../hooks/useRoomMemberChange';
|
||||||
|
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||||
import * as css from './ReadReceiptAvatars.css';
|
import * as css from './ReadReceiptAvatars.css';
|
||||||
|
|
||||||
const MAX_DISPLAY = 5;
|
const MAX_DISPLAY = 5;
|
||||||
@@ -92,7 +93,7 @@ export function ReadReceiptAvatars({
|
|||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
title={tooltipNames}
|
title={tooltipNames}
|
||||||
aria-label={tooltipNames}
|
aria-label={tooltipNames}
|
||||||
className={css.ReceiptTrigger}
|
className={`${css.ReceiptTrigger} ${MobileTouchTarget}`}
|
||||||
>
|
>
|
||||||
{/* Pill wrapper ensures visibility on any wallpaper/background */}
|
{/* Pill wrapper ensures visibility on any wallpaper/background */}
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -23,6 +23,19 @@ export const UrlPreviewWide = style({
|
|||||||
width: 'min(38rem, 94vw)',
|
width: 'min(38rem, 94vw)',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The Twitch/Twitter/TikTok-fallback cards lay their header/thumbnail out BESIDE
|
||||||
|
// the content as direct children of the UrlPreview flex row; on a phone that
|
||||||
|
// squeezes both. Stack them vertically on narrow viewports only. `UrlPreview`'s
|
||||||
|
// Box has no explicit direction (browser default row), so this override wins
|
||||||
|
// with nothing to compete against, and desktop (>750px) is unchanged.
|
||||||
|
export const StackOnMobile = style({
|
||||||
|
'@media': {
|
||||||
|
'(max-width: 750px)': {
|
||||||
|
flexDirection: 'column',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const UrlPreviewImg = style([
|
export const UrlPreviewImg = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
config,
|
config,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import { ImageOverlay } from '../ImageOverlay';
|
import { ImageOverlay } from '../ImageOverlay';
|
||||||
|
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { UrlPreview, UrlPreviewContent, UrlPreviewDescription, UrlPreviewImg } from './UrlPreview';
|
import { UrlPreview, UrlPreviewContent, UrlPreviewDescription, UrlPreviewImg } from './UrlPreview';
|
||||||
@@ -726,6 +727,7 @@ function TwitterCard({
|
|||||||
size="300"
|
size="300"
|
||||||
radii="300"
|
radii="300"
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
|
className={MobileTouchTarget}
|
||||||
onClick={() => setExpanded(false)}
|
onClick={() => setExpanded(false)}
|
||||||
aria-label="Collapse post"
|
aria-label="Collapse post"
|
||||||
>
|
>
|
||||||
@@ -780,6 +782,7 @@ function TwitterCard({
|
|||||||
<Chip
|
<Chip
|
||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
|
className={MobileTouchTarget}
|
||||||
onClick={() => setExpanded(true)}
|
onClick={() => setExpanded(true)}
|
||||||
before={<Icon size="50" src={Icons.Play} />}
|
before={<Icon size="50" src={Icons.Play} />}
|
||||||
>
|
>
|
||||||
@@ -1249,6 +1252,7 @@ function MediaEmbedCard({
|
|||||||
<Chip
|
<Chip
|
||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
|
className={MobileTouchTarget}
|
||||||
onClick={enterFullscreen}
|
onClick={enterFullscreen}
|
||||||
aria-label="Fullscreen"
|
aria-label="Fullscreen"
|
||||||
>
|
>
|
||||||
@@ -1259,6 +1263,7 @@ function MediaEmbedCard({
|
|||||||
size="300"
|
size="300"
|
||||||
radii="300"
|
radii="300"
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
|
className={MobileTouchTarget}
|
||||||
onClick={() => setPlaying(false)}
|
onClick={() => setPlaying(false)}
|
||||||
aria-label="Close player"
|
aria-label="Close player"
|
||||||
>
|
>
|
||||||
@@ -1407,6 +1412,7 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
|
|||||||
<Chip
|
<Chip
|
||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
|
className={MobileTouchTarget}
|
||||||
onClick={enterFullscreen}
|
onClick={enterFullscreen}
|
||||||
aria-label="Fullscreen"
|
aria-label="Fullscreen"
|
||||||
>
|
>
|
||||||
@@ -1416,6 +1422,7 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
|
|||||||
size="300"
|
size="300"
|
||||||
radii="300"
|
radii="300"
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
|
className={MobileTouchTarget}
|
||||||
onClick={() => setPlaying(false)}
|
onClick={() => setPlaying(false)}
|
||||||
aria-label="Close player"
|
aria-label="Close player"
|
||||||
>
|
>
|
||||||
@@ -2069,7 +2076,14 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
|||||||
// player chrome / tweet content isn't cramped or clipped.
|
// player chrome / tweet content isn't cramped or clipped.
|
||||||
const embed = parseMediaEmbed(url, window.location.hostname);
|
const embed = parseMediaEmbed(url, window.location.hostname);
|
||||||
const wide = !!embed || isTwitterTweet(url);
|
const wide = !!embed || isTwitterTweet(url);
|
||||||
const cardClass = wide ? previewCss.UrlPreviewWide : undefined;
|
// Twitter/Twitch/TikTok(fallback) cards render header/thumbnail beside content
|
||||||
|
// in the card flex row; stack them on phones (no-op for the single-column
|
||||||
|
// embed cards). Desktop keeps the row layout.
|
||||||
|
const stackOnMobile = isTwitter(url) || isTwitch(url) || isTikTok(url);
|
||||||
|
const cardClass =
|
||||||
|
[wide && previewCss.UrlPreviewWide, stackOnMobile && previewCss.StackOnMobile]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ') || undefined;
|
||||||
|
|
||||||
const renderContent = (prev: IPreviewUrlResponse): React.ReactNode => {
|
const renderContent = (prev: IPreviewUrlResponse): React.ReactNode => {
|
||||||
// Embeddable media (YouTube/Vimeo/TikTok/Dailymotion/Streamable/Twitch/
|
// Embeddable media (YouTube/Vimeo/TikTok/Dailymotion/Streamable/Twitch/
|
||||||
|
|||||||
Reference in New Issue
Block a user