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 ? (
-
-
+
+ setAnyHasLoaded(true)}
+ />
+ {canGoLeft && (
+
- ) : (
-
-
- {canGoLeft && (
-
-
-
- )}
- {canGoRight && (
-
-
-
- )}
-
+
)}
-
+ {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 = (
+
+ )
+ break
+ case 'image':
+ image = (
+
+ )
+ 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',
+ },
})