From d45a2c832d09257a424cb7111f4200d8adab40d6 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Wed, 28 Jan 2026 20:13:52 +0200 Subject: [PATCH] Redo web lightbox, add animations and keyboard a11y (#9481) * redo web lightbox, add animations * add rect-avi to shell * fix lingui msgs --- src/alf/atoms.ts | 4 +- src/components/Dialog/index.tsx | 4 + src/components/Dialog/index.web.tsx | 8 +- src/screens/Profile/Header/Shell.tsx | 7 +- src/view/com/lightbox/Lightbox.web.tsx | 417 +++++++++++++++---------- 5 files changed, 259 insertions(+), 181 deletions(-) diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts index 6d40962e01..e117b5effc 100644 --- a/src/alf/atoms.ts +++ b/src/alf/atoms.ts @@ -5,6 +5,8 @@ import {CARD_ASPECT_RATIO} from '#/lib/constants' import {native, platform, web} from '#/alf/util/platform' import * as Layout from '#/components/Layout' +const EXP_CURVE = 'cubic-bezier(0.16, 1, 0.3, 1)' + export const atoms = { ...baseAtoms, @@ -103,7 +105,7 @@ export const atoms = { }), // special composite animation for dialogs zoom_fade_in: web({ - animation: 'zoomIn ease-out 0.1s, fadeIn ease-out 0.1s', + animation: `zoomIn ${EXP_CURVE} 0.3s, fadeIn ${EXP_CURVE} 0.3s`, }), /** diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx index 6085b2f070..b63ab4f1c6 100644 --- a/src/components/Dialog/index.tsx +++ b/src/components/Dialog/index.tsx @@ -409,3 +409,7 @@ export function Handle({ export function Close() { return null } + +export function Backdrop() { + return null +} diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx index e212d1e969..bca8a75e96 100644 --- a/src/components/Dialog/index.web.tsx +++ b/src/components/Dialog/index.web.tsx @@ -3,8 +3,8 @@ import { FlatList, type FlatListProps, type GestureResponderEvent, + Pressable, type StyleProp, - TouchableWithoutFeedback, View, type ViewStyle, } from 'react-native' @@ -113,7 +113,7 @@ export function Outer({ - @@ -146,7 +146,7 @@ export function Outer({ {children} - + )} @@ -304,7 +304,7 @@ export function Handle() { return null } -function Backdrop() { +export function Backdrop() { const t = useTheme() const {reduceMotionEnabled} = useA11y() return ( diff --git a/src/screens/Profile/Header/Shell.tsx b/src/screens/Profile/Header/Shell.tsx index 5877ce4994..8543d45bd1 100644 --- a/src/screens/Profile/Header/Shell.tsx +++ b/src/screens/Profile/Header/Shell.tsx @@ -78,7 +78,7 @@ let ProfileHeaderShell = ({ ( uri: string, thumbRect: MeasuredDimensions | null, - type: 'circle-avi' | 'image' = 'circle-avi', + type: 'circle-avi' | 'rect-avi' | 'image' = 'circle-avi', ) => { openLightbox({ images: [ @@ -87,7 +87,7 @@ let ProfileHeaderShell = ({ thumbUri: uri, thumbRect, dimensions: - type === 'circle-avi' + type === 'circle-avi' || type === 'rect-avi' ? { // It's fine if it's actually smaller but we know it's 1:1. height: 1000, @@ -129,11 +129,12 @@ let ProfileHeaderShell = ({ } else { const modui = moderation.ui('avatar') const avatar = profile.avatar + const type = profile.associated?.labeler ? 'rect-avi' : 'circle-avi' if (avatar && !(modui.blur && modui.noOverride)) { runOnUI(() => { 'worklet' const rect = measure(aviRef) - runOnJS(_openLightbox)(avatar, rect) + runOnJS(_openLightbox)(avatar, rect, type) })() } } diff --git a/src/view/com/lightbox/Lightbox.web.tsx b/src/view/com/lightbox/Lightbox.web.tsx index ab50fbcf05..9a5dfe2a43 100644 --- a/src/view/com/lightbox/Lightbox.web.tsx +++ b/src/view/com/lightbox/Lightbox.web.tsx @@ -1,28 +1,30 @@ -import React, {useCallback, useEffect, useState} from 'react' -import { - Image, - type ImageStyle, - Pressable, - StyleSheet, - TouchableOpacity, - TouchableWithoutFeedback, - View, - type ViewStyle, -} from 'react-native' -import { - FontAwesomeIcon, - type FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' +import {useCallback, useEffect, useState} from 'react' +import {Pressable, StyleSheet, View} from 'react-native' +import {Image} from 'expo-image' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {FocusGuards, FocusScope} from 'radix-ui/internal' import {RemoveScrollBar} from 'react-remove-scroll-bar' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' -import {colors, s} from '#/lib/styles' +import {useA11y} from '#/state/a11y' import {useLightbox, useLightboxControls} from '#/state/lightbox' -import {Text} from '../util/text/Text' +import { + atoms as a, + flatten, + ThemeProvider, + useBreakpoints, + useTheme, +} from '#/alf' +import {Button} from '#/components/Button' +import {Backdrop} from '#/components/Dialog' +import { + ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeftIcon, + ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon, +} from '#/components/icons/Chevron' +import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' import {type ImageSource} from './ImageViewing/@types' -import ImageDefaultHeader from './ImageViewing/components/ImageDefaultHeader' export function Lightbox() { const {activeLightbox} = useLightbox() @@ -36,18 +38,44 @@ export function Lightbox() { const initialIndex = activeLightbox.index const imgs = activeLightbox.images return ( - <> - - - + + + + + ) } -function LightboxInner({ +function LightboxContainer({ + children, + handleBackgroundPress, +}: { + children: React.ReactNode + handleBackgroundPress: () => void +}) { + const {_} = useLingui() + FocusGuards.useFocusGuards() + return ( + + + + +
{children}
+
+
+ ) +} + +function LightboxGallery({ imgs, initialIndex = 0, onClose, @@ -56,10 +84,15 @@ function LightboxInner({ initialIndex: number onClose: () => void }) { + const t = useTheme() const {_} = useLingui() - const [index, setIndex] = useState(initialIndex) + const {reduceMotionEnabled} = useA11y() + const [index, setIndex] = useState(initialIndex) + const [hasAnyLoaded, setAnyHasLoaded] = useState(false) const [isAltExpanded, setAltExpanded] = useState(false) + const {gtPhone} = useBreakpoints() + const canGoLeft = index >= 1 const canGoRight = index < imgs.length - 1 const onPressLeft = useCallback(() => { @@ -92,95 +125,69 @@ function LightboxInner({ return () => window.removeEventListener('keydown', onKeyDown) }, [onKeyDown]) - const {isTabletOrDesktop} = useWebMediaQueries() - const btnStyle = React.useMemo(() => { - return isTabletOrDesktop ? styles.btnTablet : styles.btnMobile - }, [isTabletOrDesktop]) - const iconSize = React.useMemo(() => { - return isTabletOrDesktop ? 32 : 24 - }, [isTabletOrDesktop]) + const delayedFadeInAnim = !reduceMotionEnabled && [ + a.fade_in, + {animationDelay: '0.2s', animationFillMode: 'both'}, + ] const img = imgs[index] - const isAvi = img.type === 'circle-avi' || img.type === 'rect-avi' + return ( - - - {isAvi ? ( - - {img.alt} + + setAnyHasLoaded(true)} + /> + {canGoLeft && ( + )} - + {canGoRight && ( + + )} + {img.alt ? ( - + {img.alt} @@ -198,38 +205,125 @@ function LightboxInner({ ) : null} - - - + ) } +function LightboxGalleryItem({ + source, + alt, + type, + onLoad, + hasAnyLoaded, +}: { + source: string + alt: string | undefined + type: ImageSource['type'] + onLoad: () => void + hasAnyLoaded: boolean +}) { + const {reduceMotionEnabled} = useA11y() + const [hasLoaded, setHasLoaded] = useState(false) + const [isFirstToLoad] = useState(!hasAnyLoaded) + + /** + * We want to show a zoom/fade in animation when the lightbox first opens. + * To avoid showing it as we switch between images, we keep track in the parent + * whether any image has loaded yet. We then save what the value of this is on first + * render (as when it changes, we don't want to then *remove* then animation). when + * the image loads, if this is the first image to load, we play the animation. + * + * We also use this `hasLoaded` state to show a loading indicator. This is on a 1s + * delay and then a slow fade in to avoid flicker. -sfn + */ + const zoomInWhenReady = + !reduceMotionEnabled && + isFirstToLoad && + (hasAnyLoaded + ? [a.zoom_fade_in, {animationDuration: '0.5s'}] + : {opacity: 0}) + + const handleLoad = () => { + setHasLoaded(true) + onLoad() + } + + let image = null + switch (type) { + case 'circle-avi': + case 'rect-avi': + image = ( + {alt} + ) + break + case 'image': + image = ( + {alt} + ) + break + } + + return ( + <> + {image} + {!hasLoaded && ( + + + + )} + + ) +} + const styles = StyleSheet.create({ - mask: { - // @ts-ignore - position: 'fixed', - top: 0, - left: 0, - width: '100%', - height: '100%', - backgroundColor: '#000c', - }, - imageCenterer: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - }, - image: { - width: '100%', - height: '100%', - resizeMode: 'contain', - }, - aviCenterer: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - }, avi: { // @ts-ignore web-only maxWidth: `calc(min(400px, 100vw))`, @@ -238,49 +332,26 @@ const styles = StyleSheet.create({ padding: 16, boxSizing: 'border-box', }, - icon: { - color: colors.white, - }, closeBtn: { - position: 'absolute', - top: 10, - right: 10, - }, - btn: { - position: 'absolute', - backgroundColor: '#00000077', - justifyContent: 'center', - alignItems: 'center', - }, - btnTablet: { - width: 50, - height: 50, - borderRadius: 25, - left: 30, - right: 30, - }, - btnMobile: { - width: 44, - height: 44, - borderRadius: 22, - left: 20, + top: 20, right: 20, }, leftBtn: { + left: 20, right: 'auto', top: '50%', }, rightBtn: { + right: 20, left: 'auto', top: '50%', }, - footer: { - paddingHorizontal: 32, - paddingVertical: 24, - backgroundColor: colors.black, - }, - blurredBackground: { + blurredBackdrop: { + backgroundColor: '#00000077', + // @ts-expect-error web only -sfn backdropFilter: 'blur(10px)', - WebkitBackdropFilter: 'blur(10px)', - } as ViewStyle, + }, + blurredBackdropHover: { + backgroundColor: '#00000088', + }, })