Compare commits

...

53 Commits

Author SHA1 Message Date
Dan Abramov b2203ac964 This sucks, everything sucks 2024-11-02 22:39:26 +00:00
Dan Abramov 68578f3860 Fix display of other images in the gallery 2024-11-02 22:32:32 +00:00
Dan Abramov ea8ae4e7f7 Animate crop 2024-11-02 20:06:45 +00:00
Dan Abramov 394aadd14e Give interpolateFromThumbnail control over style 2024-11-02 19:09:50 +00:00
Dan Abramov 95615d47f6 Control width/height from interpolation 2024-11-02 19:06:31 +00:00
Dan Abramov b3f4338d21 Fix gestures outside image bounds 2024-11-02 18:56:01 +00:00
Dan Abramov 01b350175e Round avatars on mobile web 2024-11-02 15:43:20 +00:00
Dan Abramov 852b8ae271 Rename shapes to semantic meanings
It looks like after all I do want to fork based on those.
2024-11-02 15:43:20 +00:00
Dan Abramov d600cc3343 Preserve shape in lightbox 2024-11-02 15:43:20 +00:00
Dan Abramov e04f8341fd Animate feed/SP avatars too 2024-11-02 15:43:20 +00:00
Dan Abramov 77e71cf353 Animate profile avis on open too 2024-11-02 15:43:20 +00:00
Dan Abramov 092494f891 Adjust velocity factor 2024-11-02 15:43:05 +00:00
Dan Abramov ca945a9871 More foolproof calculation 2024-11-02 11:09:12 +00:00
Dan Abramov b2019519d4 Fix calculation 2024-11-02 04:27:48 +00:00
Dan Abramov 4949b7cdc5 Respect reduce motion 2024-11-02 04:17:28 +00:00
Dan Abramov 24283427fd Support animating out of different index 2024-11-02 04:08:23 +00:00
Dan Abramov b472d54b34 Fix animation to wrong index 2024-11-02 03:40:26 +00:00
Dan Abramov e8ec306839 Handle different thumbnail aspects 2024-11-02 03:17:01 +00:00
Dan Abramov e01f69132b Prevent gestures during animation 2024-11-02 02:57:39 +00:00
Dan Abramov d8c1354e96 Make flying away nice 2024-11-02 02:52:35 +00:00
Dan Abramov 58e261676b Opacity for controls 2024-11-02 01:28:18 +00:00
Dan Abramov dcf72ae381 Fix type 2024-11-02 01:18:29 +00:00
Dan Abramov 0032aa1620 Half-broken dismiss animation 2024-11-02 01:12:37 +00:00
Dan Abramov f08e542995 Key lightbox by id 2024-11-02 00:49:53 +00:00
Dan Abramov 68ab910d5a Conslidate ImageView props 2024-11-02 00:46:28 +00:00
Dan Abramov de1a6fe14d Prevent OOB scroll on iOS when zoomed in 2024-11-02 00:35:56 +00:00
Dan Abramov aeda009b2f FIX DA CALCULATION!!!!!!
Holy shit this took me a while. Basically we should be treating the original image center as the scale origin rather than the scaled image center. Otherwise expanding feels unsteady.
2024-11-02 00:35:56 +00:00
Dan Abramov e1d1735f56 Combine two functions 2024-11-02 00:35:56 +00:00
Dan Abramov 03679c4e51 Extract a function 2024-11-02 00:35:56 +00:00
Dan Abramov 4fb92ab059 Animate transform 2024-11-02 00:35:56 +00:00
Dan Abramov d35fcca01e Animate opening 2024-11-02 00:35:56 +00:00
Dan Abramov 0be8c55780 Integrate dismissal with root lightbox view 2024-11-02 00:35:56 +00:00
Dan Abramov fe7d97b0bf Move swipe gesture handling upwards 2024-11-02 00:35:56 +00:00
Dan Abramov 9c28b3a491 Lift the swipe animated value upwards 2024-11-02 00:35:56 +00:00
Dan Abramov 6ee2b05ac0 Move LightboxFooter into the implementation file 2024-11-02 00:35:34 +00:00
Dan Abramov ab6877795f Refactor LightboxFooter props 2024-11-02 00:08:31 +00:00
Dan Abramov 00ff654134 Unindent 2024-11-01 23:50:20 +00:00
Dan Abramov fd27242ce8 Unify lightbox types 2024-11-01 23:49:12 +00:00
Dan Abramov 431c81981c Refactor lightbox footer to render prop 2024-11-01 23:42:42 +00:00
Dan Abramov 5e1a2fcf06 Use safe aspect for lightbox calculations 2024-11-01 22:53:14 +00:00
Dan Abramov c95f8e8659 Handle division by zero in the hook 2024-11-01 22:53:14 +00:00
Dan Abramov 79a61adef7 Wire up known dimensions to the lightbox 2024-11-01 22:53:14 +00:00
Dan Abramov 0ae5033147 Make lightbox use the same cache 2024-11-01 22:53:14 +00:00
Dan Abramov 6b478644e2 Move useImageDimensions into image-sizes 2024-11-01 22:53:14 +00:00
Dan Abramov fe33a21d3e Factor useImageDimensions out of useImageAspectRatio 2024-11-01 22:53:14 +00:00
Dan Abramov 412426bac3 Derive aspect on the fly 2024-11-01 22:53:14 +00:00
Dan Abramov 56740a3d26 Rm unused fields 2024-11-01 22:53:14 +00:00
Dan Abramov af11271ff7 Make image-sizes LRU
Code is copy paste from useImageDimensions.ts
2024-11-01 22:53:14 +00:00
Dan Abramov 33f4ab2697 Rewrite image-sizes.fetch to avoid mixing async styles 2024-11-01 22:53:14 +00:00
Dan Abramov 71c4add42e Handle src change 2024-11-01 22:53:14 +00:00
Dan Abramov fc0f7bbf9c Check cache early 2024-11-01 22:53:14 +00:00
Dan Abramov 9b076032cc Use explicit values when useImageAspectRatio doesn't know
It's not very good that you can't distingiush when we haven't loaded vs when we're certain. This shifts the burden of dealing with missing values to the caller.
2024-11-01 22:53:14 +00:00
Dan Abramov c4c01955cc Remove useless memo 2024-11-01 22:53:14 +00:00
16 changed files with 949 additions and 663 deletions
+79 -21
View File
@@ -1,35 +1,93 @@
import {useEffect, useState} from 'react'
import {Image} from 'react-native'
import type {Dimensions} from '#/lib/media/types'
const sizes: Map<string, Dimensions> = new Map()
type CacheStorageItem<T> = {key: string; value: T}
const createCache = <T>(cacheSize: number) => ({
_storage: [] as CacheStorageItem<T>[],
get(key: string) {
const {value} =
this._storage.find(({key: storageKey}) => storageKey === key) || {}
return value
},
set(key: string, value: T) {
if (this._storage.length >= cacheSize) {
this._storage.shift()
}
this._storage.push({key, value})
},
})
const sizes = createCache<Dimensions>(50)
const activeRequests: Map<string, Promise<Dimensions>> = new Map()
export function get(uri: string): Dimensions | undefined {
return sizes.get(uri)
}
export async function fetch(uri: string): Promise<Dimensions> {
const Dimensions = sizes.get(uri)
if (Dimensions) {
return Dimensions
export function fetch(uri: string): Promise<Dimensions> {
const dims = sizes.get(uri)
if (dims) {
return Promise.resolve(dims)
}
const activeRequest = activeRequests.get(uri)
if (activeRequest) {
return activeRequest
}
const prom = new Promise<Dimensions>((resolve, reject) => {
Image.getSize(
uri,
(width: number, height: number) => {
const size = {width, height}
sizes.set(uri, size)
resolve(size)
},
(err: any) => {
console.error('Failed to fetch image dimensions for', uri, err)
reject(new Error('Could not fetch dimensions'))
},
)
}).finally(() => {
activeRequests.delete(uri)
})
activeRequests.set(uri, prom)
return prom
}
export function useImageDimensions({
src,
knownDimensions,
}: {
src: string
knownDimensions: Dimensions | null
}): [number | undefined, Dimensions | undefined] {
const [dims, setDims] = useState(() => knownDimensions ?? get(src))
const [prevSrc, setPrevSrc] = useState(src)
if (src !== prevSrc) {
setDims(knownDimensions ?? get(src))
setPrevSrc(src)
}
const prom =
activeRequests.get(uri) ||
new Promise<Dimensions>(resolve => {
Image.getSize(
uri,
(width: number, height: number) => resolve({width, height}),
(err: any) => {
console.error('Failed to fetch image dimensions for', uri, err)
resolve({width: 0, height: 0})
},
)
useEffect(() => {
let aborted = false
if (dims !== undefined) return
fetch(src).then(newDims => {
if (aborted) return
setDims(newDims)
})
activeRequests.set(uri, prom)
const res = await prom
activeRequests.delete(uri)
sizes.set(uri, res)
return res
return () => {
aborted = true
}
}, [dims, setDims, src])
let aspectRatio: number | undefined
if (dims) {
aspectRatio = dims.width / dims.height
if (Number.isNaN(aspectRatio)) {
aspectRatio = undefined
}
}
return [aspectRatio, dims]
}
+41 -9
View File
@@ -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,16 +59,39 @@ let ProfileHeaderShell = ({
}
}, [navigation])
const _openLightbox = React.useCallback(
(uri: string, thumbRect: MeasuredDimensions | null) => {
openLightbox({
images: [
{
uri,
thumbUri: uri,
thumbRect,
dimensions: {
// It's fine if it's actually smaller but we know it's 1:1.
height: 1000,
width: 1000,
},
type: 'circle-avi',
},
],
index: 0,
})
},
[openLightbox],
)
const onPressAvi = React.useCallback(() => {
const modui = moderation.ui('avatar')
if (profile.avatar && !(modui.blur && modui.noOverride)) {
openLightbox({
type: 'profile-image',
profile: profile,
thumbDims: null,
})
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,
@@ -131,7 +162,8 @@ let ProfileHeaderShell = ({
accessibilityRole="image"
accessibilityLabel={_(msg`View ${profile.handle}'s avatar`)}
accessibilityHint="">
<View
<Animated.View
ref={aviRef}
style={[
t.atoms.bg,
{borderColor: t.atoms.bg.backgroundColor},
@@ -144,7 +176,7 @@ let ProfileHeaderShell = ({
avatar={profile.avatar}
moderation={moderation.ui('avatar')}
/>
</View>
</Animated.View>
</TouchableWithoutFeedback>
</GrowableAvatar>
</View>
+14 -24
View File
@@ -1,30 +1,15 @@
import React from 'react'
import type {MeasuredDimensions} from 'react-native-reanimated'
import {AppBskyActorDefs} from '@atproto/api'
import {nanoid} from 'nanoid/non-secure'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {ImageSource} from '#/view/com/lightbox/ImageViewing/@types'
type ProfileImageLightbox = {
type: 'profile-image'
profile: AppBskyActorDefs.ProfileViewDetailed
thumbDims: null
}
type ImagesLightboxItem = {
uri: string
thumbUri: string
alt?: string
}
type ImagesLightbox = {
type: 'images'
images: ImagesLightboxItem[]
thumbDims: MeasuredDimensions | null
export type Lightbox = {
id: string
images: ImageSource[]
index: number
}
type Lightbox = ProfileImageLightbox | ImagesLightbox
const LightboxContext = React.createContext<{
activeLightbox: Lightbox | null
}>({
@@ -32,7 +17,7 @@ const LightboxContext = React.createContext<{
})
const LightboxControlContext = React.createContext<{
openLightbox: (lightbox: Lightbox) => void
openLightbox: (lightbox: Omit<Lightbox, 'id'>) => void
closeLightbox: () => boolean
}>({
openLightbox: () => {},
@@ -44,9 +29,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
null,
)
const openLightbox = useNonReactiveCallback((lightbox: Lightbox) => {
setActiveLightbox(lightbox)
})
const openLightbox = useNonReactiveCallback(
(lightbox: Omit<Lightbox, 'id'>) => {
setActiveLightbox({
...lightbox,
id: nanoid(),
})
},
)
const closeLightbox = useNonReactiveCallback(() => {
let wasActive = !!activeLightbox
@@ -6,6 +6,8 @@
*
*/
import {MeasuredDimensions} from 'react-native-reanimated'
export type Dimensions = {
width: number
height: number
@@ -16,4 +18,11 @@ export type Position = {
y: number
}
export type ImageSource = {uri: string; thumbUri: string; alt?: string}
export type ImageSource = {
uri: string
thumbUri: string
thumbRect: MeasuredDimensions | null
alt?: string
dimensions: Dimensions | null
type: 'image' | 'circle-avi' | 'rect-avi'
}
@@ -1,19 +1,27 @@
import React, {useState} from 'react'
import {ActivityIndicator, Dimensions, StyleSheet} from 'react-native'
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
import {
ActivityIndicator,
Dimensions,
StyleProp,
StyleSheet,
} from 'react-native'
import {
Gesture,
GestureDetector,
PanGesture,
} from 'react-native-gesture-handler'
import Animated, {
runOnJS,
useAnimatedReaction,
useAnimatedRef,
useAnimatedStyle,
useSharedValue,
withDecay,
withSpring,
} from 'react-native-reanimated'
import {Image} from 'expo-image'
import {Image, ImageStyle} from 'expo-image'
import {useImageDimensions} from '#/lib/media/image-sizes'
import type {Dimensions as ImageDimensions, ImageSource} from '../../@types'
import useImageDimensions from '../../hooks/useImageDimensions'
import {
applyRounding,
createTransform,
@@ -41,24 +49,29 @@ type Props = {
onRequestClose: () => void
onTap: () => void
onZoom: (isZoomed: boolean) => void
isScrollViewBeingDragged: boolean
isPagingAndroid: boolean
showControls: boolean
dismissSwipePan: PanGesture
animatedStyle: StyleProp<ImageStyle>
}
const ImageItem = ({
imageSrc,
onTap,
onZoom,
onRequestClose,
isScrollViewBeingDragged,
isPagingAndroid,
dismissSwipePan,
animatedStyle,
}: Props) => {
const [isScaled, setIsScaled] = useState(false)
const imageDimensions = useImageDimensions(imageSrc)
const [imageAspect, imageDimensions] = useImageDimensions({
src: imageSrc.uri,
knownDimensions: imageSrc.dimensions,
})
const committedTransform = useSharedValue(initialTransform)
const panTranslation = useSharedValue({x: 0, y: 0})
const pinchOrigin = useSharedValue({x: 0, y: 0})
const pinchScale = useSharedValue(1)
const pinchTranslation = useSharedValue({x: 0, y: 0})
const dismissSwipeTranslateY = useSharedValue(0)
const containerRef = useAnimatedRef()
// Keep track of when we're entering or leaving scaled rendering.
@@ -89,7 +102,7 @@ const ImageItem = ({
onZoom(nextIsScaled)
}
const animatedStyle = useAnimatedStyle(() => {
const animatedContainerStyle = 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()
@@ -97,19 +110,8 @@ const ImageItem = ({
prependPinch(t, pinchScale.value, pinchOrigin.value, pinchTranslation.value)
prependTransform(t, committedTransform.value)
const [translateX, translateY, scale] = readTransform(t)
const dismissDistance = dismissSwipeTranslateY.value
const dismissProgress = Math.min(
Math.abs(dismissDistance) / (SCREEN.height / 2),
1,
)
return {
opacity: 1 - dismissProgress,
transform: [
{translateX},
{translateY: translateY + dismissDistance},
{scale},
],
transform: [{translateX}, {translateY: translateY}, {scale}],
}
})
@@ -119,12 +121,12 @@ const ImageItem = ({
candidateTransform: TransformMatrix,
) {
'worklet'
if (!imageDimensions) {
if (!imageAspect) {
return [0, 0]
}
const [nextTranslateX, nextTranslateY, nextScale] =
readTransform(candidateTransform)
const scaledDimensions = getScaledDimensions(imageDimensions, nextScale)
const scaledDimensions = getScaledDimensions(imageAspect, nextScale)
const clampedTranslateX = clampTranslation(
nextTranslateX,
scaledDimensions.width,
@@ -248,7 +250,7 @@ const ImageItem = ({
.numberOfTaps(2)
.onEnd(e => {
'worklet'
if (!imageDimensions) {
if (!imageDimensions || !imageAspect) {
return
}
const [, , committedScale] = readTransform(committedTransform.value)
@@ -260,7 +262,6 @@ const ImageItem = ({
}
// Try to zoom in so that we get rid of the black bars (whatever the orientation was).
const imageAspect = imageDimensions.width / imageDimensions.height
const screenAspect = SCREEN.width / SCREEN.height
const candidateScale = Math.max(
imageAspect / screenAspect,
@@ -288,59 +289,45 @@ const ImageItem = ({
committedTransform.value = withClampedSpring(finalTransform)
})
const dismissSwipePan = Gesture.Pan()
.enabled(!isScaled)
.activeOffsetY([-10, 10])
.failOffsetX([-10, 10])
.maxPointers(1)
.onUpdate(e => {
'worklet'
dismissSwipeTranslateY.value = e.translationY
})
.onEnd(e => {
'worklet'
if (Math.abs(e.velocityY) > 1000) {
dismissSwipeTranslateY.value = withDecay({velocity: e.velocityY})
runOnJS(onRequestClose)()
} else {
dismissSwipeTranslateY.value = withSpring(0, {
stiffness: 700,
damping: 50,
})
}
})
const composedGesture = isScrollViewBeingDragged
const composedGesture = isPagingAndroid
? // If the parent is not at rest, provide a no-op gesture.
Gesture.Manual()
: Gesture.Exclusive(
dismissSwipePan,
dismissSwipePan ?? Gesture.Manual(),
Gesture.Simultaneous(pinch, pan),
doubleTap,
singleTap,
)
return (
<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} />
<GestureDetector gesture={composedGesture}>
<Image
contentFit="contain"
source={{uri: imageSrc.uri}}
placeholderContentFit="contain"
placeholder={{uri: imageSrc.thumbUri}}
style={styles.image}
accessibilityLabel={imageSrc.alt}
accessibilityHint=""
accessibilityIgnoresInvertColors
cachePolicy="memory"
/>
</GestureDetector>
</Animated.View>
<GestureDetector gesture={composedGesture}>
<Animated.View
ref={containerRef}
style={[styles.container, animatedContainerStyle, {}]}>
<Animated.View style={animatedStyle}>
<ActivityIndicator size="small" color="#FFF" style={styles.loading} />
<Image
contentFit="cover"
source={{uri: imageSrc.uri}}
placeholderContentFit="cover"
placeholder={{uri: imageSrc.thumbUri}}
style={{
flex: 1,
borderRadius:
imageSrc.type === 'circle-avi'
? SCREEN.width / 2
: imageSrc.type === 'rect-avi'
? 20
: 0,
}}
accessibilityLabel={imageSrc.alt}
accessibilityHint=""
accessibilityIgnoresInvertColors
cachePolicy="memory"
/>
</Animated.View>
</Animated.View>
</GestureDetector>
)
}
@@ -349,9 +336,7 @@ const styles = StyleSheet.create({
width: SCREEN.width,
height: SCREEN.height,
overflow: 'hidden',
},
image: {
flex: 1,
justifyContent: 'center',
},
loading: {
position: 'absolute',
@@ -363,11 +348,10 @@ const styles = StyleSheet.create({
})
function getScaledDimensions(
imageDimensions: ImageDimensions,
imageAspect: number,
scale: number,
): ImageDimensions {
'worklet'
const imageAspect = imageDimensions.width / imageDimensions.height
const screenAspect = SCREEN.width / SCREEN.height
const isLandscape = imageAspect > screenAspect
if (isLandscape) {
@@ -7,23 +7,24 @@
*/
import React, {useState} from 'react'
import {ActivityIndicator, Dimensions, StyleSheet} from 'react-native'
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
import Animated, {
interpolate,
runOnJS,
useAnimatedRef,
useAnimatedStyle,
useSharedValue,
} from 'react-native-reanimated'
import {Image} from 'expo-image'
import {
ActivityIndicator,
Dimensions,
StyleProp,
StyleSheet,
} from 'react-native'
import {
Gesture,
GestureDetector,
PanGesture,
} from 'react-native-gesture-handler'
import Animated, {runOnJS, useAnimatedRef} from 'react-native-reanimated'
import {Image, ImageStyle} from 'expo-image'
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
import {Dimensions as ImageDimensions, ImageSource} from '../../@types'
import useImageDimensions from '../../hooks/useImageDimensions'
import {useImageDimensions} from '#/lib/media/image-sizes'
import {ImageSource} from '../../@types'
const SWIPE_CLOSE_OFFSET = 75
const SWIPE_CLOSE_VELOCITY = 1
const SCREEN = Dimensions.get('screen')
const MAX_ORIGINAL_IMAGE_ZOOM = 2
const MIN_DOUBLE_TAP_SCALE = 2
@@ -33,52 +34,43 @@ type Props = {
onRequestClose: () => void
onTap: () => void
onZoom: (scaled: boolean) => void
isScrollViewBeingDragged: boolean
isPagingAndroid: boolean // Unused
showControls: boolean
dismissSwipePan: PanGesture | null
animatedStyle: StyleProp<ImageStyle>
}
const ImageItem = ({
imageSrc,
onTap,
onZoom,
onRequestClose,
showControls,
dismissSwipePan,
animatedStyle,
}: Props) => {
const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
const translationY = useSharedValue(0)
const [scaled, setScaled] = useState(false)
const imageDimensions = useImageDimensions(imageSrc)
const [imageAspect, imageDimensions] = useImageDimensions({
src: imageSrc.uri,
knownDimensions: imageSrc.dimensions,
})
const maxZoomScale = imageDimensions
? (imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM
: 1
const animatedStyle = useAnimatedStyle(() => {
return {
opacity: interpolate(
translationY.value,
[-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET],
[0.5, 1, 0.5],
),
}
})
const scrollHandler = useAnimatedScrollHandler({
onScroll(e) {
const nextIsScaled = e.zoomScale > 1
translationY.value = nextIsScaled ? 0 : e.contentOffset.y
if (scaled !== nextIsScaled) {
runOnJS(handleZoom)(nextIsScaled)
}
},
onEndDrag(e) {
const velocityY = e.velocity?.y ?? 0
const nextIsScaled = e.zoomScale > 1
if (scaled !== nextIsScaled) {
runOnJS(handleZoom)(nextIsScaled)
}
if (!nextIsScaled && Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY) {
runOnJS(onRequestClose)()
}
},
})
@@ -99,7 +91,7 @@ const ImageItem = ({
const willZoom = !scaled
if (willZoom) {
nextZoomRect = getZoomRectAfterDoubleTap(
imageDimensions,
imageAspect,
absoluteX,
absoluteY,
)
@@ -125,31 +117,44 @@ const ImageItem = ({
runOnJS(handleDoubleTap)(absoluteX, absoluteY)
})
const composedGesture = Gesture.Exclusive(doubleTap, singleTap)
const composedGesture = Gesture.Exclusive(
dismissSwipePan ?? Gesture.Manual(),
doubleTap,
singleTap,
)
return (
<GestureDetector gesture={composedGesture}>
<Animated.ScrollView
// @ts-ignore Something's up with the types here
ref={scrollViewRef}
style={styles.listItem}
pinchGestureEnabled
showsHorizontalScrollIndicator={false}
showsVerticalScrollIndicator={false}
maximumZoomScale={maxZoomScale}
onScroll={scrollHandler}>
<Animated.View style={[styles.imageScrollContainer, animatedStyle]}>
onScroll={scrollHandler}
bounces={scaled}
centerContent>
<Animated.View style={animatedStyle}>
<ActivityIndicator size="small" color="#FFF" style={styles.loading} />
<Image
contentFit="contain"
contentFit="cover"
source={{uri: imageSrc.uri}}
placeholderContentFit="contain"
placeholderContentFit="cover"
placeholder={{uri: imageSrc.thumbUri}}
style={styles.image}
accessibilityLabel={imageSrc.alt}
accessibilityHint=""
enableLiveTextInteraction={showControls && !scaled}
accessibilityIgnoresInvertColors
style={{
flex: 1,
borderRadius:
imageSrc.type === 'circle-avi'
? SCREEN.width / 2
: imageSrc.type === 'rect-avi'
? 20
: 0,
}}
/>
</Animated.View>
</Animated.ScrollView>
@@ -158,17 +163,6 @@ const ImageItem = ({
}
const styles = StyleSheet.create({
imageScrollContainer: {
height: SCREEN.height,
},
listItem: {
width: SCREEN.width,
height: SCREEN.height,
},
image: {
width: SCREEN.width,
height: SCREEN.height,
},
loading: {
position: 'absolute',
top: 0,
@@ -179,7 +173,7 @@ const styles = StyleSheet.create({
})
const getZoomRectAfterDoubleTap = (
imageDimensions: ImageDimensions | null,
imageAspect: number | undefined,
touchX: number,
touchY: number,
): {
@@ -188,7 +182,7 @@ const getZoomRectAfterDoubleTap = (
width: number
height: number
} => {
if (!imageDimensions) {
if (!imageAspect) {
return {
x: 0,
y: 0,
@@ -199,7 +193,6 @@ const getZoomRectAfterDoubleTap = (
// First, let's figure out how much we want to zoom in.
// We want to try to zoom in at least close enough to get rid of black bars.
const imageAspect = imageDimensions.width / imageDimensions.height
const screenAspect = SCREEN.width / SCREEN.height
const zoom = Math.max(
imageAspect / screenAspect,
@@ -1,7 +1,9 @@
// default implementation fallback for web
import React from 'react'
import {View} from 'react-native'
import {StyleProp, View} from 'react-native'
import {PanGesture} from 'react-native-gesture-handler'
import {ImageStyle} from 'expo-image'
import {ImageSource} from '../../@types'
@@ -10,8 +12,10 @@ type Props = {
onRequestClose: () => void
onTap: () => void
onZoom: (scaled: boolean) => void
isScrollViewBeingDragged: boolean
isPagingAndroid: boolean
showControls: boolean
dismissSwipePan: PanGesture | null
animatedStyle: StyleProp<ImageStyle>
}
const ImageItem = (_props: Props) => {
@@ -1,93 +0,0 @@
/**
* Copyright (c) JOB TODAY S.A. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import {useEffect, useState} from 'react'
import {Image, ImageURISource} from 'react-native'
import {Dimensions, ImageSource} from '../@types'
const CACHE_SIZE = 50
type CacheStorageItem = {key: string; value: any}
const createCache = (cacheSize: number) => ({
_storage: [] as CacheStorageItem[],
get(key: string): any {
const {value} =
this._storage.find(({key: storageKey}) => storageKey === key) || {}
return value
},
set(key: string, value: any) {
if (this._storage.length >= cacheSize) {
this._storage.shift()
}
this._storage.push({key, value})
},
})
const imageDimensionsCache = createCache(CACHE_SIZE)
const useImageDimensions = (image: ImageSource): Dimensions | null => {
const [dimensions, setDimensions] = useState<Dimensions | null>(null)
const getImageDimensions = (
image: ImageSource,
): Promise<Dimensions | null> => {
return new Promise(resolve => {
if (image.uri) {
const source = image as ImageURISource
const cacheKey = source.uri as string
const imageDimensions = imageDimensionsCache.get(cacheKey)
if (imageDimensions) {
resolve(imageDimensions)
} else {
Image.getSizeWithHeaders(
// @ts-ignore
source.uri,
source.headers,
(width: number, height: number) => {
if (width > 0 && height > 0) {
imageDimensionsCache.set(cacheKey, {width, height})
resolve({width, height})
} else {
resolve(null)
}
},
() => {
resolve(null)
},
)
}
} else {
resolve(null)
}
})
}
let isImageUnmounted = false
useEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-shadow
getImageDimensions(image).then(dimensions => {
if (!isImageUnmounted) {
setDimensions(dimensions)
}
})
return () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
isImageUnmounted = true
}
}, [image])
return dimensions
}
export default useImageDimensions
+477 -79
View File
@@ -8,63 +8,115 @@
// Original code copied and simplified from the link below as the codebase is currently not maintained:
// https://github.com/jobtoday/react-native-image-viewing
import React, {ComponentType, useCallback, useMemo, useState} from 'react'
import {Platform, StyleSheet, View} from 'react-native'
import React, {useCallback, useMemo, useState} from 'react'
import {
Dimensions,
LayoutAnimation,
PixelRatio,
Platform,
StyleSheet,
View,
} from 'react-native'
import {Gesture} from 'react-native-gesture-handler'
import PagerView from 'react-native-pager-view'
import {MeasuredDimensions} from 'react-native-reanimated'
import Animated, {useAnimatedStyle, withSpring} from 'react-native-reanimated'
import {
cancelAnimation,
interpolate,
SharedValue,
useAnimatedReaction,
} from 'react-native-reanimated'
import Animated, {
runOnJS,
useAnimatedStyle,
useSharedValue,
withDecay,
withSpring,
} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {Edge, SafeAreaView} from 'react-native-safe-area-context'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Trans} from '@lingui/macro'
import {colors, s} from '#/lib/styles'
import {isAndroid, isIOS} from '#/platform/detection'
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 {PlatformInfo} from '../../../../../modules/expo-bluesky-swiss-army'
import {ImageSource} from './@types'
import ImageDefaultHeader from './components/ImageDefaultHeader'
import ImageItem from './components/ImageItem/ImageItem'
type Props = {
images: ImageSource[]
thumbDims: MeasuredDimensions | null
initialImageIndex: number
visible: boolean
onRequestClose: () => void
backgroundColor?: string
HeaderComponent?: ComponentType<{imageIndex: number}>
FooterComponent?: ComponentType<{imageIndex: number}>
}
const DEFAULT_BG_COLOR = '#000'
const SCREEN = Dimensions.get('screen')
const SCREEN_HEIGHT = Dimensions.get('window').height
const PIXEL_RATIO = PixelRatio.get()
function ImageViewing({
images,
thumbDims: _thumbDims, // TODO: Pass down and use for animation.
initialImageIndex,
visible,
lightbox,
openProgress,
onFlyAway,
onRequestClose,
backgroundColor = DEFAULT_BG_COLOR,
HeaderComponent,
FooterComponent,
}: Props) {
onPressSave,
onPressShare,
}: {
lightbox: Lightbox
openProgress: SharedValue<number>
onFlyAway: () => void
onRequestClose: () => void
onPressSave: (uri: string) => void
onPressShare: (uri: string) => void
}) {
const {images, index: initialImageIndex} = lightbox
const [isScaled, setIsScaled] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const [isPagingAndroid, setIsPagingAndroid] = useState(false)
const [imageIndex, setImageIndex] = useState(initialImageIndex)
const [showControls, setShowControls] = useState(true)
const dismissSwipeTranslateY = useSharedValue(0)
const isFlyingAway = useSharedValue(false)
const animatedHeaderStyle = useAnimatedStyle(() => ({
pointerEvents: showControls ? 'auto' : 'none',
opacity: withClampedSpring(showControls ? 1 : 0),
transform: [
{
translateY: withClampedSpring(showControls ? 0 : -30),
},
],
}))
const animatedFooterStyle = useAnimatedStyle(() => ({
pointerEvents: showControls ? 'auto' : 'none',
opacity: withClampedSpring(showControls ? 1 : 0),
transform: [
{
translateY: withClampedSpring(showControls ? 0 : 30),
},
],
}))
const animatedHeaderStyle = useAnimatedStyle(() => {
const show = showControls && dismissSwipeTranslateY.value === 0
return {
pointerEvents: show ? 'auto' : 'none',
opacity: withClampedSpring(show && openProgress.value === 1 ? 1 : 0),
transform: [
{
translateY: withClampedSpring(show ? 0 : -30),
},
],
}
})
const animatedFooterStyle = useAnimatedStyle(() => {
const show = showControls && dismissSwipeTranslateY.value === 0
return {
pointerEvents: show ? 'auto' : 'none',
opacity: withClampedSpring(show && openProgress.value === 1 ? 1 : 0),
transform: [
{
translateY: withClampedSpring(show ? 0 : 30),
},
],
}
})
const containerStyle = useAnimatedStyle(() => {
if (openProgress.value < 1 || isFlyingAway.value) {
return {pointerEvents: 'none'}
}
return {pointerEvents: 'auto'}
})
useAnimatedReaction(
() => Math.abs(dismissSwipeTranslateY.value) > SCREEN_HEIGHT,
(isOut, wasOut) => {
if (isOut && !wasOut) {
// Stop the animation from blocking the screen forever.
cancelAnimation(dismissSwipeTranslateY)
onFlyAway()
}
},
)
const onTap = useCallback(() => {
setShowControls(show => !show)
@@ -84,9 +136,20 @@ function ImageViewing({
return ['left', 'right'] satisfies Edge[] // iOS, so no top/bottom safe area
}, [])
if (!visible) {
return null
}
const backdropStyle = useAnimatedStyle(() => {
let opacity
if (openProgress.value < 1) {
opacity = Math.sqrt(openProgress.value)
} else {
opacity =
1 -
Math.min(
Math.abs(dismissSwipeTranslateY.value) / (SCREEN.height / 2),
1,
)
}
return {opacity}
})
return (
<SafeAreaView
@@ -94,15 +157,10 @@ function ImageViewing({
edges={edges}
aria-modal
accessibilityViewIsModal>
<View style={[styles.container, {backgroundColor}]}>
<Animated.View style={[styles.container, containerStyle]}>
<Animated.View style={[styles.backdrop, backdropStyle]} />
<Animated.View style={[styles.header, animatedHeaderStyle]}>
{typeof HeaderComponent !== 'undefined' ? (
React.createElement(HeaderComponent, {
imageIndex,
})
) : (
<ImageDefaultHeader onRequestClose={onRequestClose} />
)}
<ImageDefaultHeader onRequestClose={onRequestClose} />
</Animated.View>
<PagerView
scrollEnabled={!isScaled}
@@ -112,35 +170,289 @@ function ImageViewing({
setIsScaled(false)
}}
onPageScrollStateChanged={e => {
setIsDragging(e.nativeEvent.pageScrollState !== 'idle')
if (isAndroid) {
// Note this would be downright broken on iOS where this method
// can't actually reliably report idle state if you do an extra
// vertical drag while paginating (you had one job, pager view).
// But it's OK because we only need this state on Android anyway.
setIsPagingAndroid(e.nativeEvent.pageScrollState !== 'idle')
}
}}
overdrag={true}
style={styles.pager}>
{images.map(imageSrc => (
<View key={imageSrc.uri}>
<ImageItem
onTap={onTap}
onZoom={onZoom}
imageSrc={imageSrc}
onRequestClose={onRequestClose}
isScrollViewBeingDragged={isDragging}
showControls={showControls}
/>
</View>
{images.map((imageSrc, i) => (
<LightboxPage
key={imageSrc.uri}
image={imageSrc}
onRequestClose={onRequestClose}
onTap={onTap}
onZoom={onZoom}
isActive={i === imageIndex}
isPagingAndroid={isPagingAndroid}
isFlyingAway={isFlyingAway}
isScaled={isScaled}
showControls={showControls}
openProgress={openProgress}
dismissSwipeTranslateY={dismissSwipeTranslateY}
/>
))}
</PagerView>
{typeof FooterComponent !== 'undefined' && (
<Animated.View style={[styles.footer, animatedFooterStyle]}>
{React.createElement(FooterComponent, {
imageIndex,
})}
</Animated.View>
)}
</View>
<Animated.View style={[styles.footer, animatedFooterStyle]}>
<LightboxFooter
images={images}
index={imageIndex}
onPressSave={onPressSave}
onPressShare={onPressShare}
/>
</Animated.View>
</Animated.View>
</SafeAreaView>
)
}
function LightboxPage({
image,
onRequestClose,
onTap,
onZoom,
isActive,
isPagingAndroid,
isFlyingAway,
isScaled,
showControls,
openProgress,
dismissSwipeTranslateY,
}: {
image: ImageSource
onRequestClose: () => void
onTap: () => void
onZoom: (scaled: boolean) => void
isActive: boolean
isPagingAndroid: boolean
isFlyingAway: SharedValue<boolean>
isScaled: boolean
showControls: boolean
openProgress: SharedValue<number>
dismissSwipeTranslateY: SharedValue<number>
}) {
const dimensions = image.dimensions
const thumbRect = image.thumbRect
const imageAspect = dimensions ? dimensions.width / dimensions.height : null
const imageStyle = useAnimatedStyle(() => {
const width = SCREEN.width
const height = imageAspect ? SCREEN.width / imageAspect : undefined
if (isActive) {
if (openProgress.value === 1 || dismissSwipeTranslateY.value !== 0) {
return {
transform: [{translateY: dismissSwipeTranslateY.value}],
width,
height,
}
}
if (thumbRect && dimensions) {
return interpolateFromThumbnail(
openProgress.value,
thumbRect,
SCREEN,
dimensions,
)
}
}
return {
transform: [],
width,
height,
}
})
const dismissSwipePan = Gesture.Pan()
.enabled(!isScaled && isActive)
.activeOffsetY([-10, 10])
.failOffsetX([-10, 10])
.maxPointers(1)
.onUpdate(e => {
'worklet'
dismissSwipeTranslateY.value = e.translationY
})
.onEnd(e => {
'worklet'
if (Math.abs(e.velocityY) > 1000) {
isFlyingAway.value = true
dismissSwipeTranslateY.value = withDecay({
velocity: e.velocityY,
velocityFactor: Math.max(3000 / Math.abs(e.velocityY), 1), // Speed up if it's too slow.
deceleration: 1, // Danger! This relies on the reaction below stopping it.
})
} else {
dismissSwipeTranslateY.value = withSpring(0, {
stiffness: 700,
damping: 50,
})
}
})
return (
<ImageItem
imageSrc={image}
isPagingAndroid={isPagingAndroid}
onTap={onTap}
onZoom={onZoom}
onRequestClose={onRequestClose}
showControls={showControls}
animatedStyle={imageStyle}
dismissSwipePan={dismissSwipePan}
/>
)
}
function LightboxFooter({
images,
index,
onPressSave,
onPressShare,
}: {
images: ImageSource[]
index: number
onPressSave: (uri: string) => void
onPressShare: (uri: string) => void
}) {
const {alt: altText, uri} = images[index]
const [isAltExpanded, setAltExpanded] = React.useState(false)
const insets = useSafeAreaInsets()
const svMaxHeight = SCREEN_HEIGHT - insets.top - 50
const isMomentumScrolling = React.useRef(false)
return (
<ScrollView
style={[
{
backgroundColor: '#000d',
},
{maxHeight: svMaxHeight},
]}
scrollEnabled={isAltExpanded}
onMomentumScrollBegin={() => {
isMomentumScrolling.current = true
}}
onMomentumScrollEnd={() => {
isMomentumScrolling.current = false
}}
contentContainerStyle={{
paddingTop: 16,
paddingBottom: insets.bottom + 10,
paddingHorizontal: 24,
}}>
{altText ? (
<View accessibilityRole="button" style={styles.footerText}>
<Text
style={[s.gray3]}
numberOfLines={isAltExpanded ? undefined : 3}
selectable
onPress={() => {
if (isMomentumScrolling.current) {
return
}
LayoutAnimation.configureNext({
duration: 450,
update: {type: 'spring', springDamping: 1},
})
setAltExpanded(prev => !prev)
}}
onLongPress={() => {}}>
{altText}
</Text>
</View>
) : null}
<View style={styles.footerBtns}>
<Button
type="primary-outline"
style={styles.footerBtn}
onPress={() => onPressSave(uri)}>
<FontAwesomeIcon icon={['far', 'floppy-disk']} style={s.white} />
<Text type="xl" style={s.white}>
<Trans context="action">Save</Trans>
</Text>
</Button>
<Button
type="primary-outline"
style={styles.footerBtn}
onPress={() => onPressShare(uri)}>
<FontAwesomeIcon icon="arrow-up-from-bracket" style={s.white} />
<Text type="xl" style={s.white}>
<Trans context="action">Share</Trans>
</Text>
</Button>
</View>
</ScrollView>
)
}
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 interpolateFromThumbnail(
progress: number,
thumbnailDims: {
pageX: number
width: number
pageY: number
height: number
},
screenSize: {width: number; height: number},
imageDims: {width: number; height: number},
) {
'worklet'
const imageAspect = imageDims.width / imageDims.height
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 finalWidth = screenSize.width
const finalHeight = screenSize.width / imageAspect
const initialScale = Math.min(
uncroppedInitialWidth / finalWidth,
uncroppedInitialHeight / finalHeight,
)
const croppedFinalWidth = thumbnailDims.width / initialScale
const croppedFinalHeight = thumbnailDims.height / initialScale
const screenCenterX = screenSize.width / 2
const screenCenterY = screenSize.height / 2
const thumbnailCenterX = thumbnailDims.pageX + thumbnailDims.width / 2
const thumbnailCenterY = thumbnailDims.pageY + thumbnailDims.height / 2
const initialTranslateX =
thumbnailCenterX + (finalWidth - croppedFinalWidth) / 2 - 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 width = interpolatePx(progress, [0, 1], [croppedFinalWidth, finalWidth])
const height = interpolatePx(
progress,
[0, 1],
[croppedFinalHeight, finalHeight],
)
return {
transform: [{translateX}, {translateY}, {scale}],
width,
height,
}
}
const styles = StyleSheet.create({
screen: {
position: 'absolute',
@@ -151,7 +463,14 @@ const styles = StyleSheet.create({
},
container: {
flex: 1,
},
backdrop: {
backgroundColor: '#000',
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
right: 0,
},
pager: {
flex: 1,
@@ -169,15 +488,94 @@ const styles = StyleSheet.create({
zIndex: 1,
bottom: 0,
},
footerText: {
paddingBottom: isIOS ? 20 : 16,
},
footerBtns: {
flexDirection: 'row',
justifyContent: 'center',
gap: 8,
},
footerBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
backgroundColor: 'transparent',
borderColor: colors.white,
},
})
const EnhancedImageViewing = (props: Props) => (
<ImageViewing key={props.initialImageIndex} {...props} />
)
function ImageViewingRoot({
lightbox: nextLightbox,
onRequestClose,
onPressSave,
onPressShare,
}: {
lightbox: Lightbox | null
onRequestClose: () => void
onPressSave: (uri: string) => void
onPressShare: (uri: string) => void
}) {
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)
if (canAnimate) {
openProgress.value = withClampedSpring(1)
return () => {
openProgress.value = withClampedSpring(0)
}
} else {
openProgress.value = 1
return () => {
openProgress.value = 0
}
}
}, [nextLightbox, openProgress])
useAnimatedReaction(
() => openProgress.value === 0,
(isGone, wasGone) => {
if (isGone && !wasGone) {
runOnJS(setActiveLightbox)(null)
}
},
)
if (!activeLightbox) {
return null
}
return (
<ImageViewing
key={activeLightbox.id}
lightbox={activeLightbox}
openProgress={openProgress}
onRequestClose={onRequestClose}
onFlyAway={() => {
'worklet'
openProgress.value = 0
runOnJS(onRequestClose)()
}}
onPressSave={onPressSave}
onPressShare={onPressShare}
/>
)
}
function withClampedSpring(value: any) {
'worklet'
return withSpring(value, {overshootClamping: true, stiffness: 300})
return withSpring(value, {overshootClamping: true, stiffness: 120})
}
export default EnhancedImageViewing
export default ImageViewingRoot
+8 -147
View File
@@ -1,74 +1,25 @@
import React from 'react'
import {Dimensions, LayoutAnimation, StyleSheet, View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import * as MediaLibrary from 'expo-media-library'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {saveImageToMediaLibrary, shareImageModal} from '#/lib/media/manip'
import {colors, s} from '#/lib/styles'
import {isIOS} from '#/platform/detection'
import {useLightbox, useLightboxControls} from '#/state/lightbox'
import {ScrollView} from '#/view/com/util/Views'
import {Button} from '../util/forms/Button'
import {Text} from '../util/text/Text'
import * as Toast from '../util/Toast'
import ImageView from './ImageViewing'
const SCREEN_HEIGHT = Dimensions.get('window').height
export function Lightbox() {
const {activeLightbox} = useLightbox()
const {closeLightbox} = useLightboxControls()
const onClose = React.useCallback(() => {
closeLightbox()
}, [closeLightbox])
if (!activeLightbox) {
return null
} else if (activeLightbox.type === 'profile-image') {
const opts = activeLightbox
return (
<ImageView
images={[
{uri: opts.profile.avatar || '', thumbUri: opts.profile.avatar || ''},
]}
initialImageIndex={0}
thumbDims={opts.thumbDims}
visible
onRequestClose={onClose}
FooterComponent={LightboxFooter}
/>
)
} else if (activeLightbox.type === 'images') {
const opts = activeLightbox
return (
<ImageView
images={opts.images.map(img => ({...img}))}
initialImageIndex={opts.index}
thumbDims={opts.thumbDims}
visible
onRequestClose={onClose}
FooterComponent={LightboxFooter}
/>
)
} else {
return null
}
}
function LightboxFooter({imageIndex}: {imageIndex: number}) {
const {_} = useLingui()
const {activeLightbox} = useLightbox()
const [isAltExpanded, setAltExpanded] = React.useState(false)
const [permissionResponse, requestPermission] = MediaLibrary.usePermissions({
granularPermissions: ['photo'],
})
const insets = useSafeAreaInsets()
const svMaxHeight = SCREEN_HEIGHT - insets.top - 50
const isMomentumScrolling = React.useRef(false)
const saveImageToAlbumWithToasts = React.useCallback(
async (uri: string) => {
if (!permissionResponse || permissionResponse.granted === false) {
@@ -88,7 +39,6 @@ function LightboxFooter({imageIndex}: {imageIndex: number}) {
}
return
}
try {
await saveImageToMediaLibrary({uri})
Toast.show(_(msg`Saved to your camera roll`))
@@ -99,101 +49,12 @@ function LightboxFooter({imageIndex}: {imageIndex: number}) {
[permissionResponse, requestPermission, _],
)
const lightbox = activeLightbox
if (!lightbox) {
return null
}
let altText = ''
let uri = ''
if (lightbox.type === 'images') {
const opts = lightbox
uri = opts.images[imageIndex].uri
altText = opts.images[imageIndex].alt || ''
} else if (lightbox.type === 'profile-image') {
const opts = lightbox
uri = opts.profile.avatar || ''
}
return (
<ScrollView
style={[
{
backgroundColor: '#000d',
},
{maxHeight: svMaxHeight},
]}
scrollEnabled={isAltExpanded}
onMomentumScrollBegin={() => {
isMomentumScrolling.current = true
}}
onMomentumScrollEnd={() => {
isMomentumScrolling.current = false
}}
contentContainerStyle={{
paddingTop: 16,
paddingBottom: insets.bottom + 10,
paddingHorizontal: 24,
}}>
{altText ? (
<View accessibilityRole="button" style={styles.footerText}>
<Text
style={[s.gray3]}
numberOfLines={isAltExpanded ? undefined : 3}
selectable
onPress={() => {
if (isMomentumScrolling.current) {
return
}
LayoutAnimation.configureNext({
duration: 450,
update: {type: 'spring', springDamping: 1},
})
setAltExpanded(prev => !prev)
}}
onLongPress={() => {}}>
{altText}
</Text>
</View>
) : null}
<View style={styles.footerBtns}>
<Button
type="primary-outline"
style={styles.footerBtn}
onPress={() => saveImageToAlbumWithToasts(uri)}>
<FontAwesomeIcon icon={['far', 'floppy-disk']} style={s.white} />
<Text type="xl" style={s.white}>
<Trans context="action">Save</Trans>
</Text>
</Button>
<Button
type="primary-outline"
style={styles.footerBtn}
onPress={() => shareImageModal({uri})}>
<FontAwesomeIcon icon="arrow-up-from-bracket" style={s.white} />
<Text type="xl" style={s.white}>
<Trans context="action">Share</Trans>
</Text>
</Button>
</View>
</ScrollView>
<ImageView
lightbox={activeLightbox}
onRequestClose={onClose}
onPressSave={saveImageToAlbumWithToasts}
onPressShare={uri => shareImageModal({uri})}
/>
)
}
const styles = StyleSheet.create({
footerText: {
paddingBottom: isIOS ? 20 : 16,
},
footerBtns: {
flexDirection: 'row',
justifyContent: 'center',
gap: 8,
},
footerBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
backgroundColor: 'transparent',
borderColor: colors.white,
},
})
+89 -73
View File
@@ -21,13 +21,9 @@ import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {colors, s} from '#/lib/styles'
import {useLightbox, useLightboxControls} from '#/state/lightbox'
import {Text} from '../util/text/Text'
import {ImageSource} from './ImageViewing/@types'
import ImageDefaultHeader from './ImageViewing/components/ImageDefaultHeader'
interface Img {
uri: string
alt?: string
}
export function Lightbox() {
const {activeLightbox} = useLightbox()
const {closeLightbox} = useLightboxControls()
@@ -38,24 +34,8 @@ export function Lightbox() {
return null
}
const initialIndex =
activeLightbox.type === 'images' ? activeLightbox.index : 0
let imgs: Img[] | undefined
if (activeLightbox.type === 'profile-image') {
const opts = activeLightbox
if (opts.profile.avatar) {
imgs = [{uri: opts.profile.avatar}]
}
} else if (activeLightbox.type === 'images') {
const opts = activeLightbox
imgs = opts.images
}
if (!imgs) {
return null
}
const initialIndex = activeLightbox.index
const imgs = activeLightbox.images
return (
<LightboxInner
imgs={imgs}
@@ -70,7 +50,7 @@ function LightboxInner({
initialIndex = 0,
onClose,
}: {
imgs: Img[]
imgs: ImageSource[]
initialIndex: number
onClose: () => void
}) {
@@ -117,6 +97,8 @@ function LightboxInner({
return isTabletOrDesktop ? 32 : 24
}, [isTabletOrDesktop])
const img = imgs[index]
const isAvi = img.type === 'circle-avi' || img.type === 'rect-avi'
return (
<View style={styles.mask}>
<TouchableWithoutFeedback
@@ -125,55 +107,76 @@ function LightboxInner({
accessibilityLabel={_(msg`Close image viewer`)}
accessibilityHint={_(msg`Exits image view`)}
onAccessibilityEscape={onClose}>
<View style={styles.imageCenterer}>
<Image
accessibilityIgnoresInvertColors
source={imgs[index]}
style={styles.image as ImageStyle}
accessibilityLabel={imgs[index].alt}
accessibilityHint=""
/>
{canGoLeft && (
<TouchableOpacity
onPress={onPressLeft}
style={[
styles.btn,
btnStyle,
styles.leftBtn,
styles.blurredBackground,
]}
accessibilityRole="button"
accessibilityLabel={_(msg`Previous image`)}
accessibilityHint="">
<FontAwesomeIcon
icon="angle-left"
style={styles.icon as FontAwesomeIconStyle}
size={iconSize}
/>
</TouchableOpacity>
)}
{canGoRight && (
<TouchableOpacity
onPress={onPressRight}
style={[
styles.btn,
btnStyle,
styles.rightBtn,
styles.blurredBackground,
]}
accessibilityRole="button"
accessibilityLabel={_(msg`Next image`)}
accessibilityHint="">
<FontAwesomeIcon
icon="angle-right"
style={styles.icon as FontAwesomeIconStyle}
size={iconSize}
/>
</TouchableOpacity>
)}
</View>
{isAvi ? (
<View style={styles.aviCenterer}>
<img
src={img.uri}
// @ts-ignore web-only
style={
{
...styles.avi,
borderRadius:
img.type === 'circle-avi'
? '50%'
: img.type === 'rect-avi'
? '10%'
: 0,
} as ImageStyle
}
alt={img.alt}
/>
</View>
) : (
<View style={styles.imageCenterer}>
<Image
accessibilityIgnoresInvertColors
source={img}
style={styles.image as ImageStyle}
accessibilityLabel={img.alt}
accessibilityHint=""
/>
{canGoLeft && (
<TouchableOpacity
onPress={onPressLeft}
style={[
styles.btn,
btnStyle,
styles.leftBtn,
styles.blurredBackground,
]}
accessibilityRole="button"
accessibilityLabel={_(msg`Previous image`)}
accessibilityHint="">
<FontAwesomeIcon
icon="angle-left"
style={styles.icon as FontAwesomeIconStyle}
size={iconSize}
/>
</TouchableOpacity>
)}
{canGoRight && (
<TouchableOpacity
onPress={onPressRight}
style={[
styles.btn,
btnStyle,
styles.rightBtn,
styles.blurredBackground,
]}
accessibilityRole="button"
accessibilityLabel={_(msg`Next image`)}
accessibilityHint="">
<FontAwesomeIcon
icon="angle-right"
style={styles.icon as FontAwesomeIconStyle}
size={iconSize}
/>
</TouchableOpacity>
)}
</View>
)}
</TouchableWithoutFeedback>
{imgs[index].alt ? (
{img.alt ? (
<View style={styles.footer}>
<Pressable
accessibilityLabel={_(msg`Expand alt text`)}
@@ -187,7 +190,7 @@ function LightboxInner({
style={s.white}
numberOfLines={isAltExpanded ? 0 : 3}
ellipsizeMode="tail">
{imgs[index].alt}
{img.alt}
</Text>
</Pressable>
</View>
@@ -219,6 +222,19 @@ const styles = StyleSheet.create({
height: '100%',
resizeMode: 'contain',
},
aviCenterer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
avi: {
// @ts-ignore web-only
maxWidth: `calc(min(400px, 100vw))`,
// @ts-ignore web-only
maxHeight: `calc(min(400px, 100vh))`,
padding: 16,
boxSizing: 'border-box',
},
icon: {
color: colors.white,
},
+51 -20
View File
@@ -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,18 +74,39 @@ export function ProfileSubpageHeader({
setDrawerOpen(true)
}, [setDrawerOpen])
const _openLightbox = React.useCallback(
(uri: string, thumbRect: MeasuredDimensions | null) => {
openLightbox({
images: [
{
uri,
thumbUri: uri,
thumbRect,
dimensions: {
// It's fine if it's actually smaller but we know it's 1:1.
height: 1000,
width: 1000,
},
type: 'rect-avi',
},
],
index: 0,
})
},
[openLightbox],
)
const onPressAvi = React.useCallback(() => {
if (
avatar // TODO && !(view.moderation.avatar.blur && view.moderation.avatar.noOverride)
) {
openLightbox({
type: 'images',
images: [{uri: avatar, thumbUri: avatar}],
index: 0,
thumbDims: null,
})
runOnUI(() => {
'worklet'
const rect = measure(aviRef)
runOnJS(_openLightbox)(avatar, rect)
})()
}
}, [openLightbox, avatar])
}, [_openLightbox, avatar, aviRef])
return (
<CenteredView style={pal.view}>
@@ -125,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}>
<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
+18 -43
View File
@@ -5,7 +5,7 @@ import {AppBskyEmbedImages} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import * as imageSizes from '#/lib/media/image-sizes'
import {useImageDimensions} from '#/lib/media/image-sizes'
import {Dimensions} from '#/lib/media/types'
import {isNative} from '#/platform/detection'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
@@ -14,44 +14,24 @@ import {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/compone
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {Text} from '#/components/Typography'
export function useImageAspectRatio({
function useImageAspectRatio({
src,
dimensions,
knownDimensions,
}: {
src: string
dimensions: Dimensions | undefined
knownDimensions: Dimensions | null
}) {
const [raw, setAspectRatio] = React.useState<number>(
dimensions ? calc(dimensions) : 1,
)
// this basically controls the width of the image
const {isCropped, constrained, max} = React.useMemo(() => {
const [raw] = useImageDimensions({src, knownDimensions})
let constrained: number | undefined
let max: number | undefined
let isCropped: boolean | undefined
if (raw !== undefined) {
const ratio = 1 / 2 // max of 1:2 ratio in feeds
const constrained = Math.max(raw, ratio)
const max = Math.max(raw, 0.25) // max of 1:4 in thread
const isCropped = raw < constrained
return {
isCropped,
constrained,
max,
}
}, [raw])
React.useEffect(() => {
let aborted = false
if (dimensions) return
imageSizes.fetch(src).then(newDim => {
if (aborted) return
setAspectRatio(calc(newDim))
})
return () => {
aborted = true
}
}, [dimensions, setAspectRatio, src])
constrained = Math.max(raw, ratio)
max = Math.max(raw, 0.25) // max of 1:4 in thread
isCropped = raw < constrained
}
return {
dimensions,
raw,
constrained,
max,
isCropped,
@@ -125,7 +105,7 @@ export function AutoSizedImage({
isCropped: rawIsCropped,
} = useImageAspectRatio({
src: image.thumb,
dimensions: image.aspectRatio,
knownDimensions: image.aspectRatio ?? null,
})
const cropDisabled = crop === 'none'
const isCropped = rawIsCropped && !cropDisabled
@@ -222,14 +202,16 @@ export function AutoSizedImage({
a.rounded_md,
a.overflow_hidden,
t.atoms.bg_contrast_25,
{aspectRatio: max},
{aspectRatio: max ?? 1},
]}>
{contents}
</Pressable>
)
} else {
return (
<ConstrainedImage fullBleed={crop === 'square'} aspectRatio={constrained}>
<ConstrainedImage
fullBleed={crop === 'square'}
aspectRatio={constrained ?? 1}>
<Pressable
onPress={onPress}
onLongPress={onLongPress}
@@ -244,10 +226,3 @@ export function AutoSizedImage({
)
}
}
function calc(dim: Dimensions) {
if (dim.width === 0 || dim.height === 0) {
return 1
}
return dim.width / dim.height
}
+6 -5
View File
@@ -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,10 @@ 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]}>
<Pressable
onPress={onPress ? () => onPress(index, containerRef) : undefined}
onPress={onPress ? () => onPress(index, containerRefs) : undefined}
onPressIn={onPressIn ? () => onPressIn(index) : undefined}
onLongPress={onLongPress ? () => onLongPress(index) : undefined}
style={[
+31 -6
View File
@@ -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
@@ -42,7 +42,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
@@ -54,8 +54,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}]}>
@@ -63,6 +69,7 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
{...props}
index={0}
insetBorderStyle={noCorners(['topRight', 'bottomRight'])}
containerRefs={containerRefs}
/>
</View>
<View style={[a.flex_1, {aspectRatio: 1}]}>
@@ -70,12 +77,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]}>
@@ -83,6 +93,7 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
{...props}
index={0}
insetBorderStyle={noCorners(['topRight', 'bottomRight'])}
containerRefs={containerRefs}
/>
</View>
<View style={[a.flex_1, gap]}>
@@ -95,6 +106,7 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
'bottomLeft',
'bottomRight',
])}
containerRefs={containerRefs}
/>
</View>
<View style={[a.flex_1]}>
@@ -106,13 +118,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]}>
@@ -125,6 +145,7 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
'topRight',
'bottomRight',
])}
containerRefs={containerRefs}
/>
</View>
<View style={[a.flex_1, {aspectRatio: 1.5}]}>
@@ -136,6 +157,7 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
'bottomLeft',
'bottomRight',
])}
containerRefs={containerRefs}
/>
</View>
</View>
@@ -149,6 +171,7 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
'topRight',
'bottomRight',
])}
containerRefs={containerRefs}
/>
</View>
<View style={[a.flex_1, {aspectRatio: 1.5}]}>
@@ -160,11 +183,13 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
'bottomLeft',
'topRight',
])}
containerRefs={containerRefs}
/>
</View>
</View>
</>
)
}
default:
return null
+11 -9
View File
@@ -145,27 +145,29 @@ export function PostEmbeds({
uri: img.fullsize,
thumbUri: img.thumb,
alt: img.alt,
aspectRatio: img.aspectRatio,
dimensions: img.aspectRatio ?? null,
}))
const _openLightbox = (
index: number,
thumbDims: MeasuredDimensions | null,
thumbRects: (MeasuredDimensions | null)[],
) => {
openLightbox({
type: 'images',
images: items,
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) => {
@@ -189,7 +191,7 @@ export function PostEmbeds({
: 'constrained'
}
image={image}
onPress={() => onPress(0, containerRef)}
onPress={() => onPress(0, [containerRef])}
onPressIn={() => onPressIn(0)}
hideBadge={
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia