fix(mobile): full-screen media viewers + touch-pan for zoomed images (M3)

Mobile-audit batch 3. All changes mobile-gated (@media <=750px) so desktop is
unchanged.

- ModalWide: fill the phone screen (100vw/100vh, no radius) at <=750px instead
  of floating as an 85vw card. This also full-screens the file/PDF viewer and
  the avatar-crop editor on mobile (they share ModalWide) — intended.
- UserHero avatar viewer: new mobile-only ModalMobileFull class (no desktop
  effect) so it goes edge-to-edge on phones like the timeline lightbox.
- usePan: add touch support (single-finger drag, cleaned up on
  touchend/cancel/unmount) alongside the unchanged mouse path, so a zoomed image
  can be panned on a phone. Wired into ImageViewer and the MediaGallery lightbox.

Two review passes: mouse path byte-for-byte unchanged; desktop provably
unaffected; touch is gated to zoom!=1 so a non-zoomed image never hijacks
swipe/scroll.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 22:24:26 -04:00
co-authored by Claude Opus 4.8
parent d615999737
commit 836e4a6679
5 changed files with 80 additions and 4 deletions
@@ -19,7 +19,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
const { t } = useTranslation();
const saveFile = useSaveFile();
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
const { pan, cursor, onMouseDown, onTouchStart } = usePan(zoom !== 1);
const handleDownload = async () => {
const fileContent = await downloadMedia(src);
@@ -100,6 +100,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
src={src}
alt={alt}
onMouseDown={onMouseDown}
onTouchStart={onTouchStart}
/>
</Box>
</Box>
+6 -1
View File
@@ -17,6 +17,7 @@ import { UserAvatar } from '../user-avatar';
import colorMXID from '../../../util/colorMXID';
import { getMxIdLocalPart } from '../../utils/matrix';
import { BreakWord, LineClamp2, LineClamp3 } from '../../styles/Text.css';
import { ModalMobileFull } from '../../styles/Modal.css';
import { UserPresence } from '../../hooks/useUserPresence';
import { AvatarPresence, PresenceBadge } from '../presence';
import { AvatarDecoration } from '../avatar-decoration/AvatarDecoration';
@@ -83,7 +84,11 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) {
escapeDeactivates: stopPropagation,
}}
>
<Modal size="500" onContextMenu={(evt: any) => evt.stopPropagation()}>
<Modal
size="500"
className={ModalMobileFull}
onContextMenu={(evt: any) => evt.stopPropagation()}
>
<ImageViewer
src={viewAvatar}
alt={userId}