Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2203ac964 | |||
| 68578f3860 | |||
| ea8ae4e7f7 | |||
| 394aadd14e | |||
| 95615d47f6 | |||
| b3f4338d21 | |||
| 01b350175e | |||
| 852b8ae271 | |||
| d600cc3343 | |||
| e04f8341fd | |||
| 77e71cf353 | |||
| 092494f891 | |||
| ca945a9871 | |||
| b2019519d4 | |||
| 4949b7cdc5 | |||
| 24283427fd | |||
| b472d54b34 | |||
| e8ec306839 | |||
| e01f69132b | |||
| d8c1354e96 | |||
| 58e261676b | |||
| dcf72ae381 | |||
| 0032aa1620 | |||
| f08e542995 | |||
| 68ab910d5a | |||
| de1a6fe14d | |||
| aeda009b2f | |||
| e1d1735f56 | |||
| 03679c4e51 | |||
| 4fb92ab059 | |||
| d35fcca01e | |||
| 0be8c55780 | |||
| fe7d97b0bf | |||
| 9c28b3a491 | |||
| 6ee2b05ac0 | |||
| ab6877795f | |||
| 00ff654134 | |||
| fd27242ce8 | |||
| 431c81981c | |||
| 5e1a2fcf06 | |||
| c95f8e8659 | |||
| 79a61adef7 | |||
| 0ae5033147 | |||
| 6b478644e2 | |||
| fe33a21d3e | |||
| 412426bac3 | |||
| 56740a3d26 | |||
| af11271ff7 | |||
| 33f4ab2697 | |||
| 71c4add42e | |||
| fc0f7bbf9c | |||
| 9b076032cc | |||
| c4c01955cc |
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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={[
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user