about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/screens/Profile/Header/Shell.tsx48
-rw-r--r--src/state/lightbox.tsx2
-rw-r--r--src/view/com/lightbox/ImageViewing/@types/index.ts9
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx176
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx132
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx27
-rw-r--r--src/view/com/lightbox/ImageViewing/index.tsx278
-rw-r--r--src/view/com/profile/ProfileSubpageHeader.tsx63
-rw-r--r--src/view/com/util/images/AutoSizedImage.tsx13
-rw-r--r--src/view/com/util/images/Gallery.tsx14
-rw-r--r--src/view/com/util/images/ImageLayoutGrid.tsx37
-rw-r--r--src/view/com/util/post-embeds/index.tsx22
12 files changed, 629 insertions, 192 deletions
diff --git a/src/screens/Profile/Header/Shell.tsx b/src/screens/Profile/Header/Shell.tsx
index fe325c1e5..093b9190a 100644
--- a/src/screens/Profile/Header/Shell.tsx
+++ b/src/screens/Profile/Header/Shell.tsx
@@ -1,5 +1,12 @@
 import React, {memo} from 'react'
 import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
+import Animated, {
+  measure,
+  MeasuredDimensions,
+  runOnJS,
+  runOnUI,
+  useAnimatedRef,
+} from 'react-native-reanimated'
 import {AppBskyActorDefs, ModerationDecision} from '@atproto/api'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {msg} from '@lingui/macro'
@@ -42,6 +49,7 @@ let ProfileHeaderShell = ({
   const {openLightbox} = useLightboxControls()
   const navigation = useNavigation<NavigationProp>()
   const {isDesktop} = useWebMediaQueries()
+  const aviRef = useAnimatedRef()
 
   const onPressBack = React.useCallback(() => {
     if (navigation.canGoBack()) {
@@ -51,14 +59,14 @@ let ProfileHeaderShell = ({
     }
   }, [navigation])
 
-  const onPressAvi = React.useCallback(() => {
-    const modui = moderation.ui('avatar')
-    if (profile.avatar && !(modui.blur && modui.noOverride)) {
+  const _openLightbox = React.useCallback(
+    (uri: string, thumbRect: MeasuredDimensions | null) => {
       openLightbox({
         images: [
           {
-            uri: profile.avatar,
-            thumbUri: profile.avatar,
+            uri,
+            thumbUri: uri,
+            thumbRect,
             dimensions: {
               // It's fine if it's actually smaller but we know it's 1:1.
               height: 1000,
@@ -68,10 +76,22 @@ let ProfileHeaderShell = ({
           },
         ],
         index: 0,
-        thumbDims: null,
       })
+    },
+    [openLightbox],
+  )
+
+  const onPressAvi = React.useCallback(() => {
+    const modui = moderation.ui('avatar')
+    const avatar = profile.avatar
+    if (avatar && !(modui.blur && modui.noOverride)) {
+      runOnUI(() => {
+        'worklet'
+        const rect = measure(aviRef)
+        runOnJS(_openLightbox)(avatar, rect)
+      })()
     }
-  }, [openLightbox, profile, moderation])
+  }, [profile, moderation, _openLightbox, aviRef])
 
   const isMe = React.useMemo(
     () => currentAccount?.did === profile.did,
@@ -149,12 +169,14 @@ let ProfileHeaderShell = ({
               styles.avi,
               profile.associated?.labeler && styles.aviLabeler,
             ]}>
-            <UserAvatar
-              type={profile.associated?.labeler ? 'labeler' : 'user'}
-              size={90}
-              avatar={profile.avatar}
-              moderation={moderation.ui('avatar')}
-            />
+            <Animated.View ref={aviRef} collapsable={false}>
+              <UserAvatar
+                type={profile.associated?.labeler ? 'labeler' : 'user'}
+                size={90}
+                avatar={profile.avatar}
+                moderation={moderation.ui('avatar')}
+              />
+            </Animated.View>
           </View>
         </TouchableWithoutFeedback>
       </GrowableAvatar>
diff --git a/src/state/lightbox.tsx b/src/state/lightbox.tsx
index 06541106e..67a450991 100644
--- a/src/state/lightbox.tsx
+++ b/src/state/lightbox.tsx
@@ -1,5 +1,4 @@
 import React from 'react'
-import type {MeasuredDimensions} from 'react-native-reanimated'
 import {nanoid} from 'nanoid/non-secure'
 
 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
@@ -8,7 +7,6 @@ import {ImageSource} from '#/view/com/lightbox/ImageViewing/@types'
 export type Lightbox = {
   id: string
   images: ImageSource[]
-  thumbDims: MeasuredDimensions | null
   index: number
 }
 
diff --git a/src/view/com/lightbox/ImageViewing/@types/index.ts b/src/view/com/lightbox/ImageViewing/@types/index.ts
index dc636a449..1a3543c26 100644
--- a/src/view/com/lightbox/ImageViewing/@types/index.ts
+++ b/src/view/com/lightbox/ImageViewing/@types/index.ts
@@ -6,6 +6,9 @@
  *
  */
 
+import {TransformsStyle} from 'react-native'
+import {MeasuredDimensions} from 'react-native-reanimated'
+
 export type Dimensions = {
   width: number
   height: number
@@ -19,7 +22,13 @@ export type Position = {
 export type ImageSource = {
   uri: string
   thumbUri: string
+  thumbRect: MeasuredDimensions | null
   alt?: string
   dimensions: Dimensions | null
   type: 'image' | 'circle-avi' | 'rect-avi'
 }
+
+export type Transform = Exclude<
+  TransformsStyle['transform'],
+  string | undefined
+>
diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx
index f882dcf9e..069f9eb40 100644
--- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx
+++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx
@@ -1,23 +1,26 @@
 import React, {useState} from 'react'
-import {ActivityIndicator, StyleProp, StyleSheet, View} from 'react-native'
+import {ActivityIndicator, StyleSheet} from 'react-native'
 import {
   Gesture,
   GestureDetector,
   PanGesture,
 } from 'react-native-gesture-handler'
 import Animated, {
-  AnimatedRef,
-  measure,
   runOnJS,
+  SharedValue,
   useAnimatedReaction,
   useAnimatedRef,
   useAnimatedStyle,
   useSharedValue,
   withSpring,
 } from 'react-native-reanimated'
-import {Image, ImageStyle} from 'expo-image'
+import {Image} from 'expo-image'
 
-import type {Dimensions as ImageDimensions, ImageSource} from '../../@types'
+import type {
+  Dimensions as ImageDimensions,
+  ImageSource,
+  Transform,
+} from '../../@types'
 import {
   applyRounding,
   createTransform,
@@ -28,8 +31,6 @@ import {
   TransformMatrix,
 } from '../../transforms'
 
-const AnimatedImage = Animated.createAnimatedComponent(Image)
-
 const MIN_SCREEN_ZOOM = 2
 const MAX_ORIGINAL_IMAGE_ZOOM = 2
 
@@ -42,22 +43,35 @@ type Props = {
   onZoom: (isZoomed: boolean) => void
   isScrollViewBeingDragged: boolean
   showControls: boolean
-  safeAreaRef: AnimatedRef<View>
+  measureSafeArea: () => {
+    x: number
+    y: number
+    width: number
+    height: number
+  }
   imageAspect: number | undefined
   imageDimensions: ImageDimensions | undefined
-  imageStyle: StyleProp<ImageStyle>
   dismissSwipePan: PanGesture
+  transforms: Readonly<
+    SharedValue<{
+      scaleAndMoveTransform: Transform
+      cropFrameTransform: Transform
+      cropContentTransform: Transform
+      isResting: boolean
+      isHidden: boolean
+    }>
+  >
 }
 const ImageItem = ({
   imageSrc,
   onTap,
   onZoom,
   isScrollViewBeingDragged,
-  safeAreaRef,
+  measureSafeArea,
   imageAspect,
   imageDimensions,
-  imageStyle,
   dismissSwipePan,
+  transforms,
 }: Props) => {
   const [isScaled, setIsScaled] = useState(false)
   const committedTransform = useSharedValue(initialTransform)
@@ -95,19 +109,6 @@ const ImageItem = ({
     onZoom(nextIsScaled)
   }
 
-  const animatedStyle = useAnimatedStyle(() => {
-    // Apply the active adjustments on top of the committed transform before the gestures.
-    // This is matrix multiplication, so operations are applied in the reverse order.
-    let t = createTransform()
-    prependPan(t, panTranslation.value)
-    prependPinch(t, pinchScale.value, pinchOrigin.value, pinchTranslation.value)
-    prependTransform(t, committedTransform.value)
-    const [translateX, translateY, scale] = readTransform(t)
-    return {
-      transform: [{translateX}, {translateY: translateY}, {scale}],
-    }
-  })
-
   // On Android, stock apps prevent going "out of bounds" on pan or pinch. You should "bump" into edges.
   // If the user tried to pan too hard, this function will provide the negative panning to stay in bounds.
   function getExtraTranslationToStayInBounds(
@@ -143,10 +144,7 @@ const ImageItem = ({
   const pinch = Gesture.Pinch()
     .onStart(e => {
       'worklet'
-      const screenSize = measure(safeAreaRef)
-      if (!screenSize) {
-        return
-      }
+      const screenSize = measureSafeArea()
       pinchOrigin.value = {
         x: e.focalX - screenSize.width / 2,
         y: e.focalY - screenSize.height / 2,
@@ -154,8 +152,8 @@ const ImageItem = ({
     })
     .onChange(e => {
       'worklet'
-      const screenSize = measure(safeAreaRef)
-      if (!imageDimensions || !screenSize) {
+      const screenSize = measureSafeArea()
+      if (!imageDimensions) {
         return
       }
       // Don't let the picture zoom in so close that it gets blurry.
@@ -213,8 +211,8 @@ const ImageItem = ({
     .minPointers(isScaled ? 1 : 2)
     .onChange(e => {
       'worklet'
-      const screenSize = measure(safeAreaRef)
-      if (!imageDimensions || !screenSize) {
+      const screenSize = measureSafeArea()
+      if (!imageDimensions) {
         return
       }
 
@@ -257,8 +255,8 @@ const ImageItem = ({
     .numberOfTaps(2)
     .onEnd(e => {
       'worklet'
-      const screenSize = measure(safeAreaRef)
-      if (!imageDimensions || !imageAspect || !screenSize) {
+      const screenSize = measureSafeArea()
+      if (!imageDimensions || !imageAspect) {
         return
       }
       const [, , committedScale] = readTransform(committedTransform.value)
@@ -302,11 +300,6 @@ const ImageItem = ({
       committedTransform.value = withClampedSpring(finalTransform)
     })
 
-  const innerStyle = useAnimatedStyle(() => ({
-    width: '100%',
-    aspectRatio: imageAspect,
-  }))
-
   const composedGesture = isScrollViewBeingDragged
     ? // If the parent is not at rest, provide a no-op gesture.
       Gesture.Manual()
@@ -317,29 +310,97 @@ const ImageItem = ({
         singleTap,
       )
 
+  const containerStyle = useAnimatedStyle(() => {
+    const {scaleAndMoveTransform, isHidden} = transforms.value
+    // Apply the active adjustments on top of the committed transform before the gestures.
+    // This is matrix multiplication, so operations are applied in the reverse order.
+    let t = createTransform()
+    prependPan(t, panTranslation.value)
+    prependPinch(t, pinchScale.value, pinchOrigin.value, pinchTranslation.value)
+    prependTransform(t, committedTransform.value)
+    const [translateX, translateY, scale] = readTransform(t)
+    const manipulationTransform = [
+      {translateX},
+      {translateY: translateY},
+      {scale},
+    ]
+    const screenSize = measureSafeArea()
+    return {
+      opacity: isHidden ? 0 : 1,
+      transform: scaleAndMoveTransform.concat(manipulationTransform),
+      width: screenSize.width,
+      maxHeight: screenSize.height,
+      aspectRatio: imageAspect,
+      alignSelf: 'center',
+    }
+  })
+
+  const imageCropStyle = useAnimatedStyle(() => {
+    const {cropFrameTransform} = transforms.value
+    return {
+      flex: 1,
+      overflow: 'hidden',
+      transform: cropFrameTransform,
+    }
+  })
+
+  const imageStyle = useAnimatedStyle(() => {
+    const {cropContentTransform} = transforms.value
+    return {
+      flex: 1,
+      transform: cropContentTransform,
+    }
+  })
+
+  const [showLoader, setShowLoader] = useState(false)
+  const [hasLoaded, setHasLoaded] = useState(false)
+  useAnimatedReaction(
+    () => {
+      return transforms.value.isResting && !hasLoaded
+    },
+    (show, prevShow) => {
+      if (show && !prevShow) {
+        runOnJS(setShowLoader)(false)
+      } else if (!prevShow && show) {
+        runOnJS(setShowLoader)(true)
+      }
+    },
+  )
+
   const type = imageSrc.type
   const borderRadius =
     type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0
+
   return (
     <GestureDetector gesture={composedGesture}>
-      <Animated.View style={imageStyle} renderToHardwareTextureAndroid>
-        <Animated.View
-          ref={containerRef}
-          // Necessary to make opacity work for both children together.
-          renderToHardwareTextureAndroid
-          style={[styles.container, animatedStyle]}>
-          <ActivityIndicator size="small" color="#FFF" style={styles.loading} />
-          <AnimatedImage
-            contentFit="contain"
-            source={{uri: imageSrc.uri}}
-            placeholderContentFit="contain"
-            placeholder={{uri: imageSrc.thumbUri}}
-            style={[innerStyle, {borderRadius}]}
-            accessibilityLabel={imageSrc.alt}
-            accessibilityHint=""
-            accessibilityIgnoresInvertColors
-            cachePolicy="memory"
-          />
+      <Animated.View
+        ref={containerRef}
+        style={[styles.container]}
+        renderToHardwareTextureAndroid>
+        <Animated.View style={containerStyle}>
+          {showLoader && (
+            <ActivityIndicator
+              size="small"
+              color="#FFF"
+              style={styles.loading}
+            />
+          )}
+          <Animated.View style={imageCropStyle}>
+            <Animated.View style={imageStyle}>
+              <Image
+                contentFit="cover"
+                source={{uri: imageSrc.uri}}
+                placeholderContentFit="cover"
+                placeholder={{uri: imageSrc.thumbUri}}
+                accessibilityLabel={imageSrc.alt}
+                onLoad={() => setHasLoaded(false)}
+                style={{flex: 1, borderRadius}}
+                accessibilityHint=""
+                accessibilityIgnoresInvertColors
+                cachePolicy="memory"
+              />
+            </Animated.View>
+          </Animated.View>
         </Animated.View>
       </Animated.View>
     </GestureDetector>
@@ -358,6 +419,7 @@ const styles = StyleSheet.create({
     right: 0,
     top: 0,
     bottom: 0,
+    justifyContent: 'center',
   },
 })
 
diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx
index e876479a3..7a9a18b91 100644
--- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx
+++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx
@@ -7,26 +7,28 @@
  */
 
 import React, {useState} from 'react'
-import {ActivityIndicator, StyleProp, StyleSheet, View} from 'react-native'
+import {ActivityIndicator, StyleSheet} from 'react-native'
 import {
   Gesture,
   GestureDetector,
   PanGesture,
 } from 'react-native-gesture-handler'
 import Animated, {
-  AnimatedRef,
-  measure,
   runOnJS,
+  SharedValue,
+  useAnimatedReaction,
   useAnimatedRef,
   useAnimatedStyle,
 } from 'react-native-reanimated'
 import {useSafeAreaFrame} from 'react-native-safe-area-context'
-import {Image, ImageStyle} from 'expo-image'
+import {Image} from 'expo-image'
 
 import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
-import {Dimensions as ImageDimensions, ImageSource} from '../../@types'
-
-const AnimatedImage = Animated.createAnimatedComponent(Image)
+import {
+  Dimensions as ImageDimensions,
+  ImageSource,
+  Transform,
+} from '../../@types'
 
 const MAX_ORIGINAL_IMAGE_ZOOM = 2
 const MIN_SCREEN_ZOOM = 2
@@ -38,11 +40,24 @@ type Props = {
   onZoom: (scaled: boolean) => void
   isScrollViewBeingDragged: boolean
   showControls: boolean
-  safeAreaRef: AnimatedRef<View>
+  measureSafeArea: () => {
+    x: number
+    y: number
+    width: number
+    height: number
+  }
   imageAspect: number | undefined
   imageDimensions: ImageDimensions | undefined
-  imageStyle: StyleProp<ImageStyle>
   dismissSwipePan: PanGesture
+  transforms: Readonly<
+    SharedValue<{
+      scaleAndMoveTransform: Transform
+      cropFrameTransform: Transform
+      cropContentTransform: Transform
+      isResting: boolean
+      isHidden: boolean
+    }>
+  >
 }
 
 const ImageItem = ({
@@ -50,11 +65,11 @@ const ImageItem = ({
   onTap,
   onZoom,
   showControls,
-  safeAreaRef,
+  measureSafeArea,
   imageAspect,
   imageDimensions,
-  imageStyle,
   dismissSwipePan,
+  transforms,
 }: Props) => {
   const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
   const [scaled, setScaled] = useState(false)
@@ -67,16 +82,6 @@ const ImageItem = ({
       : 1,
   )
 
-  const animatedStyle = useAnimatedStyle(() => {
-    const screenSize = measure(safeAreaRef) ?? screenSizeDelayedForJSThreadOnly
-    return {
-      width: screenSize.width,
-      maxHeight: screenSize.height,
-      alignSelf: 'center',
-      aspectRatio: imageAspect,
-    }
-  })
-
   const scrollHandler = useAnimatedScrollHandler({
     onScroll(e) {
       const nextIsScaled = e.zoomScale > 1
@@ -114,10 +119,7 @@ const ImageItem = ({
     .numberOfTaps(2)
     .onEnd(e => {
       'worklet'
-      const screenSize = measure(safeAreaRef)
-      if (!screenSize) {
-        return
-      }
+      const screenSize = measureSafeArea()
       const {absoluteX, absoluteY} = e
       let nextZoomRect = {
         x: 0,
@@ -143,9 +145,56 @@ const ImageItem = ({
     singleTap,
   )
 
+  const containerStyle = useAnimatedStyle(() => {
+    const {scaleAndMoveTransform, isHidden} = transforms.value
+    return {
+      flex: 1,
+      transform: scaleAndMoveTransform,
+      opacity: isHidden ? 0 : 1,
+    }
+  })
+
+  const imageCropStyle = useAnimatedStyle(() => {
+    const screenSize = measureSafeArea()
+    const {cropFrameTransform} = transforms.value
+    return {
+      overflow: 'hidden',
+      transform: cropFrameTransform,
+      width: screenSize.width,
+      maxHeight: screenSize.height,
+      aspectRatio: imageAspect,
+      alignSelf: 'center',
+    }
+  })
+
+  const imageStyle = useAnimatedStyle(() => {
+    const {cropContentTransform} = transforms.value
+    return {
+      transform: cropContentTransform,
+      width: '100%',
+      aspectRatio: imageAspect,
+    }
+  })
+
+  const [showLoader, setShowLoader] = useState(false)
+  const [hasLoaded, setHasLoaded] = useState(false)
+  useAnimatedReaction(
+    () => {
+      return transforms.value.isResting && !hasLoaded
+    },
+    (show, prevShow) => {
+      if (show && !prevShow) {
+        runOnJS(setShowLoader)(false)
+      } else if (!prevShow && show) {
+        runOnJS(setShowLoader)(true)
+      }
+    },
+  )
+
   const type = imageSrc.type
   const borderRadius =
     type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0
+
   return (
     <GestureDetector gesture={composedGesture}>
       <Animated.ScrollView
@@ -156,22 +205,29 @@ const ImageItem = ({
         showsVerticalScrollIndicator={false}
         maximumZoomScale={maxZoomScale}
         onScroll={scrollHandler}
+        style={containerStyle}
         bounces={scaled}
         bouncesZoom={true}
-        style={imageStyle}
         centerContent>
-        <ActivityIndicator size="small" color="#FFF" style={styles.loading} />
-        <AnimatedImage
-          contentFit="contain"
-          source={{uri: imageSrc.uri}}
-          placeholderContentFit="contain"
-          placeholder={{uri: imageSrc.thumbUri}}
-          style={[animatedStyle, {borderRadius}]}
-          accessibilityLabel={imageSrc.alt}
-          accessibilityHint=""
-          enableLiveTextInteraction={showControls && !scaled}
-          accessibilityIgnoresInvertColors
-        />
+        {showLoader && (
+          <ActivityIndicator size="small" color="#FFF" style={styles.loading} />
+        )}
+        <Animated.View style={imageCropStyle}>
+          <Animated.View style={imageStyle}>
+            <Image
+              contentFit="contain"
+              source={{uri: imageSrc.uri}}
+              placeholderContentFit="contain"
+              placeholder={{uri: imageSrc.thumbUri}}
+              style={{flex: 1, borderRadius}}
+              accessibilityLabel={imageSrc.alt}
+              accessibilityHint=""
+              enableLiveTextInteraction={showControls && !scaled}
+              accessibilityIgnoresInvertColors
+              onLoad={() => setHasLoaded(true)}
+            />
+          </Animated.View>
+        </Animated.View>
       </Animated.ScrollView>
     </GestureDetector>
   )
diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx
index 1cd6b0020..543fad772 100644
--- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx
+++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx
@@ -1,11 +1,15 @@
 // default implementation fallback for web
 
 import React from 'react'
-import {ImageStyle, StyleProp, View} from 'react-native'
+import {View} from 'react-native'
 import {PanGesture} from 'react-native-gesture-handler'
-import {AnimatedRef} from 'react-native-reanimated'
+import {SharedValue} from 'react-native-reanimated'
 
-import {Dimensions as ImageDimensions, ImageSource} from '../../@types'
+import {
+  Dimensions as ImageDimensions,
+  ImageSource,
+  Transform,
+} from '../../@types'
 
 type Props = {
   imageSrc: ImageSource
@@ -14,11 +18,24 @@ type Props = {
   onZoom: (scaled: boolean) => void
   isScrollViewBeingDragged: boolean
   showControls: boolean
-  safeAreaRef: AnimatedRef<View>
+  measureSafeArea: () => {
+    x: number
+    y: number
+    width: number
+    height: number
+  }
   imageAspect: number | undefined
   imageDimensions: ImageDimensions | undefined
-  imageStyle: StyleProp<ImageStyle>
   dismissSwipePan: PanGesture
+  transforms: Readonly<
+    SharedValue<{
+      scaleAndMoveTransform: Transform
+      cropFrameTransform: Transform
+      cropContentTransform: Transform
+      isResting: boolean
+      isHidden: boolean
+    }>
+  >
 }
 
 const ImageItem = (_props: Props) => {
diff --git a/src/view/com/lightbox/ImageViewing/index.tsx b/src/view/com/lightbox/ImageViewing/index.tsx
index 0a01c7fb3..030c8dcf3 100644
--- a/src/view/com/lightbox/ImageViewing/index.tsx
+++ b/src/view/com/lightbox/ImageViewing/index.tsx
@@ -9,23 +9,36 @@
 // https://github.com/jobtoday/react-native-image-viewing
 
 import React, {useCallback, useState} from 'react'
-import {LayoutAnimation, Platform, StyleSheet, View} from 'react-native'
+import {
+  LayoutAnimation,
+  PixelRatio,
+  Platform,
+  StyleSheet,
+  View,
+} from 'react-native'
 import {Gesture} from 'react-native-gesture-handler'
 import PagerView from 'react-native-pager-view'
 import Animated, {
   AnimatedRef,
   cancelAnimation,
+  interpolate,
   measure,
   runOnJS,
   SharedValue,
   useAnimatedReaction,
   useAnimatedRef,
   useAnimatedStyle,
+  useDerivedValue,
   useSharedValue,
   withDecay,
   withSpring,
 } from 'react-native-reanimated'
-import {Edge, SafeAreaView} from 'react-native-safe-area-context'
+import {
+  Edge,
+  SafeAreaView,
+  useSafeAreaFrame,
+  useSafeAreaInsets,
+} from 'react-native-safe-area-context'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {Trans} from '@lingui/macro'
 
@@ -36,17 +49,24 @@ import {Lightbox} from '#/state/lightbox'
 import {Button} from '#/view/com/util/forms/Button'
 import {Text} from '#/view/com/util/text/Text'
 import {ScrollView} from '#/view/com/util/Views'
-import {ImageSource} from './@types'
+import {PlatformInfo} from '../../../../../modules/expo-bluesky-swiss-army'
+import {ImageSource, Transform} from './@types'
 import ImageDefaultHeader from './components/ImageDefaultHeader'
 import ImageItem from './components/ImageItem/ImageItem'
 
+type Rect = {x: number; y: number; width: number; height: number}
+
+const PIXEL_RATIO = PixelRatio.get()
 const EDGES =
   Platform.OS === 'android'
     ? (['top', 'bottom', 'left', 'right'] satisfies Edge[])
     : (['left', 'right'] satisfies Edge[]) // iOS, so no top/bottom safe area
 
+const SLOW_SPRING = {stiffness: 120}
+const FAST_SPRING = {stiffness: 700}
+
 export default function ImageViewRoot({
-  lightbox,
+  lightbox: nextLightbox,
   onRequestClose,
   onPressSave,
   onPressShare,
@@ -56,24 +76,70 @@ export default function ImageViewRoot({
   onPressSave: (uri: string) => void
   onPressShare: (uri: string) => void
 }) {
+  'use no memo'
   const ref = useAnimatedRef<View>()
+  const [activeLightbox, setActiveLightbox] = useState(nextLightbox)
+  const openProgress = useSharedValue(0)
+
+  if (!activeLightbox && nextLightbox) {
+    setActiveLightbox(nextLightbox)
+  }
+
+  React.useEffect(() => {
+    if (!nextLightbox) {
+      return
+    }
+
+    const canAnimate =
+      !PlatformInfo.getIsReducedMotionEnabled() &&
+      nextLightbox.images.every(img => img.dimensions && img.thumbRect)
+
+    // https://github.com/software-mansion/react-native-reanimated/issues/6677
+    requestAnimationFrame(() => {
+      openProgress.value = canAnimate ? withClampedSpring(1, SLOW_SPRING) : 1
+    })
+    return () => {
+      // https://github.com/software-mansion/react-native-reanimated/issues/6677
+      requestAnimationFrame(() => {
+        openProgress.value = canAnimate ? withClampedSpring(0, SLOW_SPRING) : 0
+      })
+    }
+  }, [nextLightbox, openProgress])
+
+  useAnimatedReaction(
+    () => openProgress.value === 0,
+    (isGone, wasGone) => {
+      if (isGone && !wasGone) {
+        runOnJS(setActiveLightbox)(null)
+      }
+    },
+  )
+
+  const onFlyAway = React.useCallback(() => {
+    'worklet'
+    openProgress.value = 0
+    runOnJS(onRequestClose)()
+  }, [onRequestClose, openProgress])
+
   return (
     // Keep it always mounted to avoid flicker on the first frame.
     <SafeAreaView
-      style={[styles.screen, !lightbox && styles.screenHidden]}
+      style={[styles.screen, !activeLightbox && styles.screenHidden]}
       edges={EDGES}
       aria-modal
       accessibilityViewIsModal
-      aria-hidden={!lightbox}>
+      aria-hidden={!activeLightbox}>
       <Animated.View ref={ref} style={{flex: 1}} collapsable={false}>
-        {lightbox && (
+        {activeLightbox && (
           <ImageView
-            key={lightbox.id}
-            lightbox={lightbox}
+            key={activeLightbox.id}
+            lightbox={activeLightbox}
             onRequestClose={onRequestClose}
             onPressSave={onPressSave}
             onPressShare={onPressShare}
+            onFlyAway={onFlyAway}
             safeAreaRef={ref}
+            openProgress={openProgress}
           />
         )}
       </Animated.View>
@@ -86,13 +152,17 @@ function ImageView({
   onRequestClose,
   onPressSave,
   onPressShare,
+  onFlyAway,
   safeAreaRef,
+  openProgress,
 }: {
   lightbox: Lightbox
   onRequestClose: () => void
   onPressSave: (uri: string) => void
   onPressShare: (uri: string) => void
+  onFlyAway: () => void
   safeAreaRef: AnimatedRef<View>
+  openProgress: SharedValue<number>
 }) {
   const {images, index: initialImageIndex} = lightbox
   const [isScaled, setIsScaled] = useState(false)
@@ -104,33 +174,41 @@ function ImageView({
   const isFlyingAway = useSharedValue(false)
 
   const containerStyle = useAnimatedStyle(() => {
-    if (isFlyingAway.value) {
+    if (openProgress.value < 1 || isFlyingAway.value) {
       return {pointerEvents: 'none'}
     }
     return {pointerEvents: 'auto'}
   })
+
   const backdropStyle = useAnimatedStyle(() => {
     const screenSize = measure(safeAreaRef)
     let opacity = 1
-    if (screenSize) {
+    if (openProgress.value < 1) {
+      opacity = Math.sqrt(openProgress.value)
+    } else if (screenSize) {
       const dragProgress = Math.min(
         Math.abs(dismissSwipeTranslateY.value) / (screenSize.height / 2),
         1,
       )
       opacity -= dragProgress
     }
+    const factor = isIOS ? 100 : 50
     return {
-      opacity,
+      opacity: Math.round(opacity * factor) / factor,
     }
   })
+
   const animatedHeaderStyle = useAnimatedStyle(() => {
     const show = showControls && dismissSwipeTranslateY.value === 0
     return {
       pointerEvents: show ? 'box-none' : 'none',
-      opacity: withClampedSpring(show ? 1 : 0),
+      opacity: withClampedSpring(
+        show && openProgress.value === 1 ? 1 : 0,
+        FAST_SPRING,
+      ),
       transform: [
         {
-          translateY: withClampedSpring(show ? 0 : -30),
+          translateY: withClampedSpring(show ? 0 : -30, FAST_SPRING),
         },
       ],
     }
@@ -140,10 +218,13 @@ function ImageView({
     return {
       flexGrow: 1,
       pointerEvents: show ? 'box-none' : 'none',
-      opacity: withClampedSpring(show ? 1 : 0),
+      opacity: withClampedSpring(
+        show && openProgress.value === 1 ? 1 : 0,
+        FAST_SPRING,
+      ),
       transform: [
         {
-          translateY: withClampedSpring(show ? 0 : 30),
+          translateY: withClampedSpring(show ? 0 : 30, FAST_SPRING),
         },
       ],
     }
@@ -172,7 +253,7 @@ function ImageView({
       if (isOut && !wasOut) {
         // Stop the animation from blocking the screen forever.
         cancelAnimation(dismissSwipeTranslateY)
-        runOnJS(onRequestClose)()
+        onFlyAway()
       }
     },
   )
@@ -209,6 +290,7 @@ function ImageView({
               isFlyingAway={isFlyingAway}
               isActive={i === imageIndex}
               dismissSwipeTranslateY={dismissSwipeTranslateY}
+              openProgress={openProgress}
             />
           </View>
         ))}
@@ -247,6 +329,7 @@ function LightboxImage({
   isActive,
   showControls,
   safeAreaRef,
+  openProgress,
   dismissSwipeTranslateY,
 }: {
   imageSrc: ImageSource
@@ -259,6 +342,7 @@ function LightboxImage({
   isFlyingAway: SharedValue<boolean>
   showControls: boolean
   safeAreaRef: AnimatedRef<View>
+  openProgress: SharedValue<number>
   dismissSwipeTranslateY: SharedValue<number>
 }) {
   const [imageAspect, imageDimensions] = useImageDimensions({
@@ -266,6 +350,65 @@ function LightboxImage({
     knownDimensions: imageSrc.dimensions,
   })
 
+  const safeFrameDelayedForJSThreadOnly = useSafeAreaFrame()
+  const safeInsetsDelayedForJSThreadOnly = useSafeAreaInsets()
+  const measureSafeArea = React.useCallback(() => {
+    'worklet'
+    let safeArea: Rect | null = measure(safeAreaRef)
+    if (!safeArea) {
+      if (_WORKLET) {
+        console.error('Expected to always be able to measure safe area.')
+      }
+      const frame = safeFrameDelayedForJSThreadOnly
+      const insets = safeInsetsDelayedForJSThreadOnly
+      safeArea = {
+        x: frame.x + insets.left,
+        y: frame.y + insets.top,
+        width: frame.width - insets.left - insets.right,
+        height: frame.height - insets.top - insets.bottom,
+      }
+    }
+    return safeArea
+  }, [
+    safeFrameDelayedForJSThreadOnly,
+    safeInsetsDelayedForJSThreadOnly,
+    safeAreaRef,
+  ])
+
+  const {thumbRect} = imageSrc
+  const transforms = useDerivedValue(() => {
+    'worklet'
+    const safeArea = measureSafeArea()
+    const dismissTranslateY =
+      isActive && openProgress.value === 1 ? dismissSwipeTranslateY.value : 0
+
+    if (openProgress.value === 0 && isFlyingAway.value) {
+      return {
+        isHidden: true,
+        isResting: false,
+        scaleAndMoveTransform: [],
+        cropFrameTransform: [],
+        cropContentTransform: [],
+      }
+    }
+
+    if (isActive && thumbRect && imageAspect && openProgress.value < 1) {
+      return interpolateTransform(
+        openProgress.value,
+        thumbRect,
+        safeArea,
+        imageAspect,
+      )
+    }
+    return {
+      isHidden: false,
+      isResting: dismissTranslateY === 0,
+      scaleAndMoveTransform: [{translateY: dismissTranslateY}],
+      cropFrameTransform: [],
+      cropContentTransform: [],
+    }
+  })
+
   const dismissSwipePan = Gesture.Pan()
     .enabled(isActive && !isScaled)
     .activeOffsetY([-10, 10])
@@ -273,14 +416,14 @@ function LightboxImage({
     .maxPointers(1)
     .onUpdate(e => {
       'worklet'
-      if (isFlyingAway.value) {
+      if (openProgress.value !== 1 || isFlyingAway.value) {
         return
       }
       dismissSwipeTranslateY.value = e.translationY
     })
     .onEnd(e => {
       'worklet'
-      if (isFlyingAway.value) {
+      if (openProgress.value !== 1 || isFlyingAway.value) {
         return
       }
       if (Math.abs(e.velocityY) > 1000) {
@@ -303,11 +446,6 @@ function LightboxImage({
       }
     })
 
-  const imageStyle = useAnimatedStyle(() => {
-    return {
-      transform: [{translateY: dismissSwipeTranslateY.value}],
-    }
-  })
   return (
     <ImageItem
       imageSrc={imageSrc}
@@ -316,11 +454,11 @@ function LightboxImage({
       onRequestClose={onRequestClose}
       isScrollViewBeingDragged={isScrollViewBeingDragged}
       showControls={showControls}
-      safeAreaRef={safeAreaRef}
+      measureSafeArea={measureSafeArea}
       imageAspect={imageAspect}
       imageDimensions={imageDimensions}
-      imageStyle={imageStyle}
       dismissSwipePan={dismissSwipePan}
+      transforms={transforms}
     />
   )
 }
@@ -476,7 +614,91 @@ const styles = StyleSheet.create({
   },
 })
 
-function withClampedSpring(value: any) {
+function interpolatePx(
+  px: number,
+  inputRange: readonly number[],
+  outputRange: readonly number[],
+) {
+  'worklet'
+  const value = interpolate(px, inputRange, outputRange)
+  return Math.round(value * PIXEL_RATIO) / PIXEL_RATIO
+}
+
+function interpolateTransform(
+  progress: number,
+  thumbnailDims: {
+    pageX: number
+    width: number
+    pageY: number
+    height: number
+  },
+  safeArea: {width: number; height: number; x: number; y: number},
+  imageAspect: number,
+): {
+  scaleAndMoveTransform: Transform
+  cropFrameTransform: Transform
+  cropContentTransform: Transform
+  isResting: boolean
+  isHidden: boolean
+} {
+  'worklet'
+  const thumbAspect = thumbnailDims.width / thumbnailDims.height
+  let uncroppedInitialWidth
+  let uncroppedInitialHeight
+  if (imageAspect > thumbAspect) {
+    uncroppedInitialWidth = thumbnailDims.height * imageAspect
+    uncroppedInitialHeight = thumbnailDims.height
+  } else {
+    uncroppedInitialWidth = thumbnailDims.width
+    uncroppedInitialHeight = thumbnailDims.width / imageAspect
+  }
+  const safeAreaAspect = safeArea.width / safeArea.height
+  let finalWidth
+  let finalHeight
+  if (safeAreaAspect > imageAspect) {
+    finalWidth = safeArea.height * imageAspect
+    finalHeight = safeArea.height
+  } else {
+    finalWidth = safeArea.width
+    finalHeight = safeArea.width / imageAspect
+  }
+  const initialScale = Math.min(
+    uncroppedInitialWidth / finalWidth,
+    uncroppedInitialHeight / finalHeight,
+  )
+  const croppedFinalWidth = thumbnailDims.width / initialScale
+  const croppedFinalHeight = thumbnailDims.height / initialScale
+  const screenCenterX = safeArea.width / 2
+  const screenCenterY = safeArea.height / 2
+  const thumbnailSafeAreaX = thumbnailDims.pageX - safeArea.x
+  const thumbnailSafeAreaY = thumbnailDims.pageY - safeArea.y
+  const thumbnailCenterX = thumbnailSafeAreaX + thumbnailDims.width / 2
+  const thumbnailCenterY = thumbnailSafeAreaY + thumbnailDims.height / 2
+  const initialTranslateX = thumbnailCenterX - screenCenterX
+  const initialTranslateY = thumbnailCenterY - screenCenterY
+  const scale = interpolate(progress, [0, 1], [initialScale, 1])
+  const translateX = interpolatePx(progress, [0, 1], [initialTranslateX, 0])
+  const translateY = interpolatePx(progress, [0, 1], [initialTranslateY, 0])
+  const cropScaleX = interpolate(
+    progress,
+    [0, 1],
+    [croppedFinalWidth / finalWidth, 1],
+  )
+  const cropScaleY = interpolate(
+    progress,
+    [0, 1],
+    [croppedFinalHeight / finalHeight, 1],
+  )
+  return {
+    isHidden: false,
+    isResting: progress === 1,
+    scaleAndMoveTransform: [{translateX}, {translateY}, {scale}],
+    cropFrameTransform: [{scaleX: cropScaleX}, {scaleY: cropScaleY}],
+    cropContentTransform: [{scaleX: 1 / cropScaleX}, {scaleY: 1 / cropScaleY}],
+  }
+}
+
+function withClampedSpring(value: any, {stiffness}: {stiffness: number}) {
   'worklet'
-  return withSpring(value, {overshootClamping: true, stiffness: 300})
+  return withSpring(value, {overshootClamping: true, stiffness})
 }
diff --git a/src/view/com/profile/ProfileSubpageHeader.tsx b/src/view/com/profile/ProfileSubpageHeader.tsx
index 5208224c5..13d14ec50 100644
--- a/src/view/com/profile/ProfileSubpageHeader.tsx
+++ b/src/view/com/profile/ProfileSubpageHeader.tsx
@@ -1,5 +1,12 @@
 import React from 'react'
 import {Pressable, StyleSheet, View} from 'react-native'
+import Animated, {
+  measure,
+  MeasuredDimensions,
+  runOnJS,
+  runOnUI,
+  useAnimatedRef,
+} from 'react-native-reanimated'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
@@ -53,6 +60,7 @@ export function ProfileSubpageHeader({
   const {openLightbox} = useLightboxControls()
   const pal = usePalette('default')
   const canGoBack = navigation.canGoBack()
+  const aviRef = useAnimatedRef()
 
   const onPressBack = React.useCallback(() => {
     if (navigation.canGoBack()) {
@@ -66,15 +74,14 @@ export function ProfileSubpageHeader({
     setDrawerOpen(true)
   }, [setDrawerOpen])
 
-  const onPressAvi = React.useCallback(() => {
-    if (
-      avatar // TODO && !(view.moderation.avatar.blur && view.moderation.avatar.noOverride)
-    ) {
+  const _openLightbox = React.useCallback(
+    (uri: string, thumbRect: MeasuredDimensions | null) => {
       openLightbox({
         images: [
           {
-            uri: avatar,
-            thumbUri: avatar,
+            uri,
+            thumbUri: uri,
+            thumbRect,
             dimensions: {
               // It's fine if it's actually smaller but we know it's 1:1.
               height: 1000,
@@ -84,10 +91,22 @@ export function ProfileSubpageHeader({
           },
         ],
         index: 0,
-        thumbDims: null,
       })
+    },
+    [openLightbox],
+  )
+
+  const onPressAvi = React.useCallback(() => {
+    if (
+      avatar // TODO && !(view.moderation.avatar.blur && view.moderation.avatar.noOverride)
+    ) {
+      runOnUI(() => {
+        'worklet'
+        const rect = measure(aviRef)
+        runOnJS(_openLightbox)(avatar, rect)
+      })()
     }
-  }, [openLightbox, avatar])
+  }, [_openLightbox, avatar, aviRef])
 
   return (
     <CenteredView style={pal.view}>
@@ -135,19 +154,21 @@ export function ProfileSubpageHeader({
           paddingBottom: 6,
           paddingHorizontal: isMobile ? 12 : 14,
         }}>
-        <Pressable
-          testID="headerAviButton"
-          onPress={onPressAvi}
-          accessibilityRole="image"
-          accessibilityLabel={_(msg`View the avatar`)}
-          accessibilityHint=""
-          style={{width: 58}}>
-          {avatarType === 'starter-pack' ? (
-            <StarterPack width={58} gradient="sky" />
-          ) : (
-            <UserAvatar type={avatarType} size={58} avatar={avatar} />
-          )}
-        </Pressable>
+        <Animated.View ref={aviRef} collapsable={false}>
+          <Pressable
+            testID="headerAviButton"
+            onPress={onPressAvi}
+            accessibilityRole="image"
+            accessibilityLabel={_(msg`View the avatar`)}
+            accessibilityHint=""
+            style={{width: 58}}>
+            {avatarType === 'starter-pack' ? (
+              <StarterPack width={58} gradient="sky" />
+            ) : (
+              <UserAvatar type={avatarType} size={58} avatar={avatar} />
+            )}
+          </Pressable>
+        </Animated.View>
         <View style={{flex: 1}}>
           {isLoading ? (
             <LoadingPlaceholder
diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx
index ce2389ce2..21f6c529e 100644
--- a/src/view/com/util/images/AutoSizedImage.tsx
+++ b/src/view/com/util/images/AutoSizedImage.tsx
@@ -1,5 +1,6 @@
 import React from 'react'
 import {DimensionValue, Pressable, View} from 'react-native'
+import Animated, {AnimatedRef, useAnimatedRef} from 'react-native-reanimated'
 import {Image} from 'expo-image'
 import {AppBskyEmbedImages} from '@atproto/api'
 import {msg} from '@lingui/macro'
@@ -92,7 +93,7 @@ export function AutoSizedImage({
   image: AppBskyEmbedImages.ViewImage
   crop?: 'none' | 'square' | 'constrained'
   hideBadge?: boolean
-  onPress?: () => void
+  onPress?: (containerRef: AnimatedRef<React.Component<{}, {}, any>>) => void
   onLongPress?: () => void
   onPressIn?: () => void
 }) {
@@ -107,12 +108,14 @@ export function AutoSizedImage({
     src: image.thumb,
     knownDimensions: image.aspectRatio ?? null,
   })
+  const containerRef = useAnimatedRef()
+
   const cropDisabled = crop === 'none'
   const isCropped = rawIsCropped && !cropDisabled
   const hasAlt = !!image.alt
 
   const contents = (
-    <>
+    <Animated.View ref={containerRef} collapsable={false}>
       <Image
         style={[a.w_full, a.h_full]}
         source={image.thumb}
@@ -185,13 +188,13 @@ export function AutoSizedImage({
           )}
         </View>
       ) : null}
-    </>
+    </Animated.View>
   )
 
   if (cropDisabled) {
     return (
       <Pressable
-        onPress={onPress}
+        onPress={() => onPress?.(containerRef)}
         onLongPress={onLongPress}
         onPressIn={onPressIn}
         // alt here is what screen readers actually use
@@ -213,7 +216,7 @@ export function AutoSizedImage({
         fullBleed={crop === 'square'}
         aspectRatio={constrained ?? 1}>
         <Pressable
-          onPress={onPress}
+          onPress={() => onPress?.(containerRef)}
           onLongPress={onLongPress}
           onPressIn={onPressIn}
           // alt here is what screen readers actually use
diff --git a/src/view/com/util/images/Gallery.tsx b/src/view/com/util/images/Gallery.tsx
index d4d7d223d..0c691ec9a 100644
--- a/src/view/com/util/images/Gallery.tsx
+++ b/src/view/com/util/images/Gallery.tsx
@@ -1,6 +1,6 @@
 import React from 'react'
 import {Pressable, StyleProp, View, ViewStyle} from 'react-native'
-import Animated, {AnimatedRef, useAnimatedRef} from 'react-native-reanimated'
+import Animated, {AnimatedRef} from 'react-native-reanimated'
 import {Image, ImageStyle} from 'expo-image'
 import {AppBskyEmbedImages} from '@atproto/api'
 import {msg} from '@lingui/macro'
@@ -19,13 +19,14 @@ interface Props {
   index: number
   onPress?: (
     index: number,
-    containerRef: AnimatedRef<React.Component<{}, {}, any>>,
+    containerRefs: AnimatedRef<React.Component<{}, {}, any>>[],
   ) => void
   onLongPress?: EventFunction
   onPressIn?: EventFunction
   imageStyle?: StyleProp<ImageStyle>
   viewContext?: PostEmbedViewContext
   insetBorderStyle?: StyleProp<ViewStyle>
+  containerRefs: AnimatedRef<React.Component<{}, {}, any>>[]
 }
 
 export function GalleryItem({
@@ -37,6 +38,7 @@ export function GalleryItem({
   onLongPress,
   viewContext,
   insetBorderStyle,
+  containerRefs,
 }: Props) {
   const t = useTheme()
   const {_} = useLingui()
@@ -45,11 +47,13 @@ export function GalleryItem({
   const hasAlt = !!image.alt
   const hideBadges =
     viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
-  const containerRef = useAnimatedRef()
   return (
-    <Animated.View style={a.flex_1} ref={containerRef}>
+    <Animated.View
+      style={a.flex_1}
+      ref={containerRefs[index]}
+      collapsable={false}>
       <Pressable
-        onPress={onPress ? () => onPress(index, containerRef) : undefined}
+        onPress={onPress ? () => onPress(index, containerRefs) : undefined}
         onPressIn={onPressIn ? () => onPressIn(index) : undefined}
         onLongPress={onLongPress ? () => onLongPress(index) : undefined}
         style={[
diff --git a/src/view/com/util/images/ImageLayoutGrid.tsx b/src/view/com/util/images/ImageLayoutGrid.tsx
index 9d6a49836..b9b966302 100644
--- a/src/view/com/util/images/ImageLayoutGrid.tsx
+++ b/src/view/com/util/images/ImageLayoutGrid.tsx
@@ -1,6 +1,6 @@
 import React from 'react'
 import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
-import {AnimatedRef} from 'react-native-reanimated'
+import {AnimatedRef, useAnimatedRef} from 'react-native-reanimated'
 import {AppBskyEmbedImages} from '@atproto/api'
 
 import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types'
@@ -11,7 +11,7 @@ interface ImageLayoutGridProps {
   images: AppBskyEmbedImages.ViewImage[]
   onPress?: (
     index: number,
-    containerRef: AnimatedRef<React.Component<{}, {}, any>>,
+    containerRefs: AnimatedRef<React.Component<{}, {}, any>>[],
   ) => void
   onLongPress?: (index: number) => void
   onPressIn?: (index: number) => void
@@ -41,7 +41,7 @@ interface ImageLayoutGridInnerProps {
   images: AppBskyEmbedImages.ViewImage[]
   onPress?: (
     index: number,
-    containerRef: AnimatedRef<React.Component<{}, {}, any>>,
+    containerRefs: AnimatedRef<React.Component<{}, {}, any>>[],
   ) => void
   onLongPress?: (index: number) => void
   onPressIn?: (index: number) => void
@@ -53,8 +53,14 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
   const gap = props.gap
   const count = props.images.length
 
+  const containerRef1 = useAnimatedRef()
+  const containerRef2 = useAnimatedRef()
+  const containerRef3 = useAnimatedRef()
+  const containerRef4 = useAnimatedRef()
+
   switch (count) {
-    case 2:
+    case 2: {
+      const containerRefs = [containerRef1, containerRef2]
       return (
         <View style={[a.flex_1, a.flex_row, gap]}>
           <View style={[a.flex_1, {aspectRatio: 1}]}>
@@ -62,6 +68,7 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
               {...props}
               index={0}
               insetBorderStyle={noCorners(['topRight', 'bottomRight'])}
+              containerRefs={containerRefs}
             />
           </View>
           <View style={[a.flex_1, {aspectRatio: 1}]}>
@@ -69,12 +76,15 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
               {...props}
               index={1}
               insetBorderStyle={noCorners(['topLeft', 'bottomLeft'])}
+              containerRefs={containerRefs}
             />
           </View>
         </View>
       )
+    }
 
-    case 3:
+    case 3: {
+      const containerRefs = [containerRef1, containerRef2, containerRef3]
       return (
         <View style={[a.flex_1, a.flex_row, gap]}>
           <View style={[a.flex_1, {aspectRatio: 1}]}>
@@ -82,6 +92,7 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
               {...props}
               index={0}
               insetBorderStyle={noCorners(['topRight', 'bottomRight'])}
+              containerRefs={containerRefs}
             />
           </View>
           <View style={[a.flex_1, {aspectRatio: 1}, gap]}>
@@ -94,6 +105,7 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
                   'bottomLeft',
                   'bottomRight',
                 ])}
+                containerRefs={containerRefs}
               />
             </View>
             <View style={[a.flex_1]}>
@@ -105,13 +117,21 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
                   'bottomLeft',
                   'topRight',
                 ])}
+                containerRefs={containerRefs}
               />
             </View>
           </View>
         </View>
       )
+    }
 
-    case 4:
+    case 4: {
+      const containerRefs = [
+        containerRef1,
+        containerRef2,
+        containerRef3,
+        containerRef4,
+      ]
       return (
         <>
           <View style={[a.flex_row, gap]}>
@@ -124,6 +144,7 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
                   'topRight',
                   'bottomRight',
                 ])}
+                containerRefs={containerRefs}
               />
             </View>
             <View style={[a.flex_1, {aspectRatio: 1.5}]}>
@@ -135,6 +156,7 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
                   'bottomLeft',
                   'bottomRight',
                 ])}
+                containerRefs={containerRefs}
               />
             </View>
           </View>
@@ -148,6 +170,7 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
                   'topRight',
                   'bottomRight',
                 ])}
+                containerRefs={containerRefs}
               />
             </View>
             <View style={[a.flex_1, {aspectRatio: 1.5}]}>
@@ -159,11 +182,13 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
                   'bottomLeft',
                   'topRight',
                 ])}
+                containerRefs={containerRefs}
               />
             </View>
           </View>
         </>
       )
+    }
 
     default:
       return null
diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx
index ea0badab0..ab2471b33 100644
--- a/src/view/com/util/post-embeds/index.tsx
+++ b/src/view/com/util/post-embeds/index.tsx
@@ -6,13 +6,12 @@ import {
   View,
   ViewStyle,
 } from 'react-native'
-import Animated, {
+import {
   AnimatedRef,
   measure,
   MeasuredDimensions,
   runOnJS,
   runOnUI,
-  useAnimatedRef,
 } from 'react-native-reanimated'
 import {Image} from 'expo-image'
 import {
@@ -69,7 +68,6 @@ export function PostEmbeds({
   viewContext?: PostEmbedViewContext
 }) {
   const {openLightbox} = useLightboxControls()
-  const containerRef = useAnimatedRef()
 
   // quote post with media
   // =
@@ -149,25 +147,25 @@ export function PostEmbeds({
       }))
       const _openLightbox = (
         index: number,
-        thumbDims: MeasuredDimensions | null,
+        thumbRects: (MeasuredDimensions | null)[],
       ) => {
         openLightbox({
-          images: items.map(item => ({
+          images: items.map((item, i) => ({
             ...item,
+            thumbRect: thumbRects[i] ?? null,
             type: 'image',
           })),
           index,
-          thumbDims,
         })
       }
       const onPress = (
         index: number,
-        ref: AnimatedRef<React.Component<{}, {}, any>>,
+        refs: AnimatedRef<React.Component<{}, {}, any>>[],
       ) => {
         runOnUI(() => {
           'worklet'
-          const dims = measure(ref)
-          runOnJS(_openLightbox)(index, dims)
+          const rects = refs.map(ref => (ref ? measure(ref) : null))
+          runOnJS(_openLightbox)(index, rects)
         })()
       }
       const onPressIn = (_: number) => {
@@ -180,7 +178,7 @@ export function PostEmbeds({
         const image = images[0]
         return (
           <ContentHider modui={moderation?.ui('contentMedia')}>
-            <Animated.View ref={containerRef} style={[a.mt_sm, style]}>
+            <View style={[a.mt_sm, style]}>
               <AutoSizedImage
                 crop={
                   viewContext === PostEmbedViewContext.ThreadHighlighted
@@ -191,13 +189,13 @@ export function PostEmbeds({
                     : 'constrained'
                 }
                 image={image}
-                onPress={() => onPress(0, containerRef)}
+                onPress={containerRef => onPress(0, [containerRef])}
                 onPressIn={() => onPressIn(0)}
                 hideBadge={
                   viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
                 }
               />
-            </Animated.View>
+            </View>
           </ContentHider>
         )
       }