An ATproto social media client -- with an independent Appview.
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Remove unused lightbox options (#1616)

* Inline lightbox helpers

* Delete unused useImagePrefetch

* Delete unused long press gesture

* Always enable double tap

* Always enable swipe to close

* Remove unused onImageIndexChange

* Inline custom Hooks into ImageViewing

* Declare LightboxFooter outside Lightbox

* Add more TODO comments

* Inline useDoubleTapToZoom

* Remove dead utils, move utils used only once

authored by

dan and committed by
GitHub
260b03a0 eb7306b1

+333 -574
+4 -25
src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx
··· 36 36 imageSrc: ImageSource 37 37 onRequestClose: () => void 38 38 onZoom: (isZoomed: boolean) => void 39 - onLongPress: (image: ImageSource) => void 40 - delayLongPress: number 41 - swipeToCloseEnabled?: boolean 42 - doubleTapToZoomEnabled?: boolean 43 39 } 44 40 45 41 const AnimatedImage = Animated.createAnimatedComponent(Image) 46 42 47 - const ImageItem = ({ 48 - imageSrc, 49 - onZoom, 50 - onRequestClose, 51 - onLongPress, 52 - delayLongPress, 53 - swipeToCloseEnabled = true, 54 - doubleTapToZoomEnabled = true, 55 - }: Props) => { 43 + const ImageItem = ({imageSrc, onZoom, onRequestClose}: Props) => { 56 44 const imageContainer = useRef<ScrollView & NativeMethodsMixin>(null) 57 45 const imageDimensions = useImageDimensions(imageSrc) 58 46 const [translate, scale] = getImageTransform(imageDimensions, SCREEN) ··· 72 60 [onZoom], 73 61 ) 74 62 75 - const onLongPressHandler = useCallback(() => { 76 - onLongPress(imageSrc) 77 - }, [imageSrc, onLongPress]) 78 - 79 63 const [panHandlers, scaleValue, translateValue] = usePanResponder({ 80 64 initialScale: scale || 1, 81 65 initialTranslate: translate || {x: 0, y: 0}, 82 66 onZoom: onZoomPerformed, 83 - doubleTapToZoomEnabled, 84 - onLongPress: onLongPressHandler, 85 - delayLongPress, 86 67 }) 87 68 88 69 const imagesStyles = getImageStyles( ··· 126 107 showsHorizontalScrollIndicator={false} 127 108 showsVerticalScrollIndicator={false} 128 109 contentContainerStyle={styles.imageScrollContainer} 129 - scrollEnabled={swipeToCloseEnabled} 130 - {...(swipeToCloseEnabled && { 131 - onScroll, 132 - onScrollEndDrag, 133 - })}> 110 + scrollEnabled={true} 111 + onScroll={onScroll} 112 + onScrollEndDrag={onScrollEndDrag}> 134 113 <AnimatedImage 135 114 {...panHandlers} 136 115 source={imageSrc}
+137 -38
src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx
··· 16 16 View, 17 17 NativeScrollEvent, 18 18 NativeSyntheticEvent, 19 + NativeTouchEvent, 19 20 TouchableWithoutFeedback, 20 21 } from 'react-native' 21 22 import {Image} from 'expo-image' 22 23 23 - import useDoubleTapToZoom from '../../hooks/useDoubleTapToZoom' 24 24 import useImageDimensions from '../../hooks/useImageDimensions' 25 25 26 26 import {getImageStyles, getImageTransform} from '../../utils' 27 27 import {ImageSource} from '../../@types' 28 28 import {ImageLoading} from './ImageLoading' 29 29 30 + const DOUBLE_TAP_DELAY = 300 30 31 const SWIPE_CLOSE_OFFSET = 75 31 32 const SWIPE_CLOSE_VELOCITY = 1 32 33 const SCREEN = Dimensions.get('screen') 33 34 const SCREEN_WIDTH = SCREEN.width 34 35 const SCREEN_HEIGHT = SCREEN.height 36 + const MIN_ZOOM = 2 35 37 const MAX_SCALE = 2 36 38 37 39 type Props = { 38 40 imageSrc: ImageSource 39 41 onRequestClose: () => void 40 42 onZoom: (scaled: boolean) => void 41 - onLongPress: (image: ImageSource) => void 42 - delayLongPress: number 43 - swipeToCloseEnabled?: boolean 44 - doubleTapToZoomEnabled?: boolean 45 43 } 46 44 47 45 const AnimatedImage = Animated.createAnimatedComponent(Image) 48 46 49 - const ImageItem = ({ 50 - imageSrc, 51 - onZoom, 52 - onRequestClose, 53 - onLongPress, 54 - delayLongPress, 55 - swipeToCloseEnabled = true, 56 - doubleTapToZoomEnabled = true, 57 - }: Props) => { 47 + let lastTapTS: number | null = null 48 + 49 + const ImageItem = ({imageSrc, onZoom, onRequestClose}: Props) => { 58 50 const scrollViewRef = useRef<ScrollView>(null) 59 51 const [loaded, setLoaded] = useState(false) 60 52 const [scaled, setScaled] = useState(false) 61 53 const imageDimensions = useImageDimensions(imageSrc) 62 - const handleDoubleTap = useDoubleTapToZoom( 63 - scrollViewRef, 64 - scaled, 65 - SCREEN, 66 - imageDimensions, 67 - ) 68 - 69 54 const [translate, scale] = getImageTransform(imageDimensions, SCREEN) 55 + 56 + // TODO: It's not valid to reinitialize Animated values during render. 57 + // This is a bug. 70 58 const scrollValueY = new Animated.Value(0) 71 59 const scaleValue = new Animated.Value(scale || 1) 72 60 const translateValue = new Animated.ValueXY(translate) ··· 91 79 onZoom(currentScaled) 92 80 setScaled(currentScaled) 93 81 94 - if ( 95 - !currentScaled && 96 - swipeToCloseEnabled && 97 - Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY 98 - ) { 82 + if (!currentScaled && Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY) { 99 83 onRequestClose() 100 84 } 101 85 }, 102 - [onRequestClose, onZoom, swipeToCloseEnabled], 86 + [onRequestClose, onZoom], 103 87 ) 104 88 105 89 const onScroll = ({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => { ··· 112 96 scrollValueY.setValue(offsetY) 113 97 } 114 98 115 - const onLongPressHandler = useCallback(() => { 116 - onLongPress(imageSrc) 117 - }, [imageSrc, onLongPress]) 99 + const handleDoubleTap = useCallback( 100 + (event: NativeSyntheticEvent<NativeTouchEvent>) => { 101 + const nowTS = new Date().getTime() 102 + const scrollResponderRef = scrollViewRef?.current?.getScrollResponder() 103 + 104 + if (lastTapTS && nowTS - lastTapTS < DOUBLE_TAP_DELAY) { 105 + let nextZoomRect = { 106 + x: 0, 107 + y: 0, 108 + width: SCREEN.width, 109 + height: SCREEN.height, 110 + } 111 + 112 + const willZoom = !scaled 113 + if (willZoom) { 114 + const {pageX, pageY} = event.nativeEvent 115 + nextZoomRect = getZoomRectAfterDoubleTap( 116 + imageDimensions, 117 + pageX, 118 + pageY, 119 + ) 120 + } 121 + 122 + // @ts-ignore 123 + scrollResponderRef?.scrollResponderZoomTo({ 124 + ...nextZoomRect, // This rect is in screen coordinates 125 + animated: true, 126 + }) 127 + } else { 128 + lastTapTS = nowTS 129 + } 130 + }, 131 + [imageDimensions, scaled], 132 + ) 118 133 119 134 return ( 120 135 <View> ··· 126 141 showsVerticalScrollIndicator={false} 127 142 maximumZoomScale={maxScrollViewZoom} 128 143 contentContainerStyle={styles.imageScrollContainer} 129 - scrollEnabled={swipeToCloseEnabled} 144 + scrollEnabled={true} 145 + onScroll={onScroll} 130 146 onScrollEndDrag={onScrollEndDrag} 131 - scrollEventThrottle={1} 132 - {...(swipeToCloseEnabled && { 133 - onScroll, 134 - })}> 147 + scrollEventThrottle={1}> 135 148 {(!loaded || !imageDimensions) && <ImageLoading />} 136 149 <TouchableWithoutFeedback 137 - onPress={doubleTapToZoomEnabled ? handleDoubleTap : undefined} 138 - onLongPress={onLongPressHandler} 139 - delayLongPress={delayLongPress} 150 + onPress={handleDoubleTap} 140 151 accessibilityRole="image" 141 152 accessibilityLabel={imageSrc.alt} 142 153 accessibilityHint=""> ··· 160 171 height: SCREEN_HEIGHT, 161 172 }, 162 173 }) 174 + 175 + const getZoomRectAfterDoubleTap = ( 176 + imageDimensions: {width: number; height: number} | null, 177 + touchX: number, 178 + touchY: number, 179 + ): { 180 + x: number 181 + y: number 182 + width: number 183 + height: number 184 + } => { 185 + if (!imageDimensions) { 186 + return { 187 + x: 0, 188 + y: 0, 189 + width: SCREEN.width, 190 + height: SCREEN.height, 191 + } 192 + } 193 + 194 + // First, let's figure out how much we want to zoom in. 195 + // We want to try to zoom in at least close enough to get rid of black bars. 196 + const imageAspect = imageDimensions.width / imageDimensions.height 197 + const screenAspect = SCREEN.width / SCREEN.height 198 + const zoom = Math.max( 199 + imageAspect / screenAspect, 200 + screenAspect / imageAspect, 201 + MIN_ZOOM, 202 + ) 203 + // Unlike in the Android version, we don't constrain the *max* zoom level here. 204 + // Instead, this is done in the ScrollView props so that it constraints pinch too. 205 + 206 + // Next, we'll be calculating the rectangle to "zoom into" in screen coordinates. 207 + // We already know the zoom level, so this gives us the rectangle size. 208 + let rectWidth = SCREEN.width / zoom 209 + let rectHeight = SCREEN.height / zoom 210 + 211 + // Before we settle on the zoomed rect, figure out the safe area it has to be inside. 212 + // We don't want to introduce new black bars or make existing black bars unbalanced. 213 + let minX = 0 214 + let minY = 0 215 + let maxX = SCREEN.width - rectWidth 216 + let maxY = SCREEN.height - rectHeight 217 + if (imageAspect >= screenAspect) { 218 + // The image has horizontal black bars. Exclude them from the safe area. 219 + const renderedHeight = SCREEN.width / imageAspect 220 + const horizontalBarHeight = (SCREEN.height - renderedHeight) / 2 221 + minY += horizontalBarHeight 222 + maxY -= horizontalBarHeight 223 + } else { 224 + // The image has vertical black bars. Exclude them from the safe area. 225 + const renderedWidth = SCREEN.height * imageAspect 226 + const verticalBarWidth = (SCREEN.width - renderedWidth) / 2 227 + minX += verticalBarWidth 228 + maxX -= verticalBarWidth 229 + } 230 + 231 + // Finally, we can position the rect according to its size and the safe area. 232 + let rectX 233 + if (maxX >= minX) { 234 + // Content fills the screen horizontally so we have horizontal wiggle room. 235 + // Try to keep the tapped point under the finger after zoom. 236 + rectX = touchX - touchX / zoom 237 + rectX = Math.min(rectX, maxX) 238 + rectX = Math.max(rectX, minX) 239 + } else { 240 + // Keep the rect centered on the screen so that black bars are balanced. 241 + rectX = SCREEN.width / 2 - rectWidth / 2 242 + } 243 + let rectY 244 + if (maxY >= minY) { 245 + // Content fills the screen vertically so we have vertical wiggle room. 246 + // Try to keep the tapped point under the finger after zoom. 247 + rectY = touchY - touchY / zoom 248 + rectY = Math.min(rectY, maxY) 249 + rectY = Math.max(rectY, minY) 250 + } else { 251 + // Keep the rect centered on the screen so that black bars are balanced. 252 + rectY = SCREEN.height / 2 - rectHeight / 2 253 + } 254 + 255 + return { 256 + x: rectX, 257 + y: rectY, 258 + height: rectHeight, 259 + width: rectWidth, 260 + } 261 + } 163 262 164 263 export default React.memo(ImageItem)
-4
src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx
··· 8 8 imageSrc: ImageSource 9 9 onRequestClose: () => void 10 10 onZoom: (scaled: boolean) => void 11 - onLongPress: (image: ImageSource) => void 12 - delayLongPress: number 13 - swipeToCloseEnabled?: boolean 14 - doubleTapToZoomEnabled?: boolean 15 11 } 16 12 17 13 const ImageItem = (_props: Props) => {
-47
src/view/com/lightbox/ImageViewing/hooks/useAnimatedComponents.ts
··· 1 - /** 2 - * Copyright (c) JOB TODAY S.A. and its affiliates. 3 - * 4 - * This source code is licensed under the MIT license found in the 5 - * LICENSE file in the root directory of this source tree. 6 - * 7 - */ 8 - 9 - import {Animated} from 'react-native' 10 - 11 - const INITIAL_POSITION = {x: 0, y: 0} 12 - const ANIMATION_CONFIG = { 13 - duration: 200, 14 - useNativeDriver: true, 15 - } 16 - 17 - const useAnimatedComponents = () => { 18 - const headerTranslate = new Animated.ValueXY(INITIAL_POSITION) 19 - const footerTranslate = new Animated.ValueXY(INITIAL_POSITION) 20 - 21 - const toggleVisible = (isVisible: boolean) => { 22 - if (isVisible) { 23 - Animated.parallel([ 24 - Animated.timing(headerTranslate.y, {...ANIMATION_CONFIG, toValue: 0}), 25 - Animated.timing(footerTranslate.y, {...ANIMATION_CONFIG, toValue: 0}), 26 - ]).start() 27 - } else { 28 - Animated.parallel([ 29 - Animated.timing(headerTranslate.y, { 30 - ...ANIMATION_CONFIG, 31 - toValue: -300, 32 - }), 33 - Animated.timing(footerTranslate.y, { 34 - ...ANIMATION_CONFIG, 35 - toValue: 300, 36 - }), 37 - ]).start() 38 - } 39 - } 40 - 41 - const headerTransform = headerTranslate.getTranslateTransform() 42 - const footerTransform = footerTranslate.getTranslateTransform() 43 - 44 - return [headerTransform, footerTransform, toggleVisible] as const 45 - } 46 - 47 - export default useAnimatedComponents
-150
src/view/com/lightbox/ImageViewing/hooks/useDoubleTapToZoom.ts
··· 1 - /** 2 - * Copyright (c) JOB TODAY S.A. and its affiliates. 3 - * 4 - * This source code is licensed under the MIT license found in the 5 - * LICENSE file in the root directory of this source tree. 6 - * 7 - */ 8 - 9 - import React, {useCallback} from 'react' 10 - import {ScrollView, NativeTouchEvent, NativeSyntheticEvent} from 'react-native' 11 - 12 - import {Dimensions} from '../@types' 13 - 14 - const DOUBLE_TAP_DELAY = 300 15 - const MIN_ZOOM = 2 16 - 17 - let lastTapTS: number | null = null 18 - 19 - /** 20 - * This is iOS only. 21 - * Same functionality for Android implemented inside usePanResponder hook. 22 - */ 23 - function useDoubleTapToZoom( 24 - scrollViewRef: React.RefObject<ScrollView>, 25 - scaled: boolean, 26 - screen: Dimensions, 27 - imageDimensions: Dimensions | null, 28 - ) { 29 - const handleDoubleTap = useCallback( 30 - (event: NativeSyntheticEvent<NativeTouchEvent>) => { 31 - const nowTS = new Date().getTime() 32 - const scrollResponderRef = scrollViewRef?.current?.getScrollResponder() 33 - 34 - const getZoomRectAfterDoubleTap = ( 35 - touchX: number, 36 - touchY: number, 37 - ): { 38 - x: number 39 - y: number 40 - width: number 41 - height: number 42 - } => { 43 - if (!imageDimensions) { 44 - return { 45 - x: 0, 46 - y: 0, 47 - width: screen.width, 48 - height: screen.height, 49 - } 50 - } 51 - 52 - // First, let's figure out how much we want to zoom in. 53 - // We want to try to zoom in at least close enough to get rid of black bars. 54 - const imageAspect = imageDimensions.width / imageDimensions.height 55 - const screenAspect = screen.width / screen.height 56 - const zoom = Math.max( 57 - imageAspect / screenAspect, 58 - screenAspect / imageAspect, 59 - MIN_ZOOM, 60 - ) 61 - // Unlike in the Android version, we don't constrain the *max* zoom level here. 62 - // Instead, this is done in the ScrollView props so that it constraints pinch too. 63 - 64 - // Next, we'll be calculating the rectangle to "zoom into" in screen coordinates. 65 - // We already know the zoom level, so this gives us the rectangle size. 66 - let rectWidth = screen.width / zoom 67 - let rectHeight = screen.height / zoom 68 - 69 - // Before we settle on the zoomed rect, figure out the safe area it has to be inside. 70 - // We don't want to introduce new black bars or make existing black bars unbalanced. 71 - let minX = 0 72 - let minY = 0 73 - let maxX = screen.width - rectWidth 74 - let maxY = screen.height - rectHeight 75 - if (imageAspect >= screenAspect) { 76 - // The image has horizontal black bars. Exclude them from the safe area. 77 - const renderedHeight = screen.width / imageAspect 78 - const horizontalBarHeight = (screen.height - renderedHeight) / 2 79 - minY += horizontalBarHeight 80 - maxY -= horizontalBarHeight 81 - } else { 82 - // The image has vertical black bars. Exclude them from the safe area. 83 - const renderedWidth = screen.height * imageAspect 84 - const verticalBarWidth = (screen.width - renderedWidth) / 2 85 - minX += verticalBarWidth 86 - maxX -= verticalBarWidth 87 - } 88 - 89 - // Finally, we can position the rect according to its size and the safe area. 90 - let rectX 91 - if (maxX >= minX) { 92 - // Content fills the screen horizontally so we have horizontal wiggle room. 93 - // Try to keep the tapped point under the finger after zoom. 94 - rectX = touchX - touchX / zoom 95 - rectX = Math.min(rectX, maxX) 96 - rectX = Math.max(rectX, minX) 97 - } else { 98 - // Keep the rect centered on the screen so that black bars are balanced. 99 - rectX = screen.width / 2 - rectWidth / 2 100 - } 101 - let rectY 102 - if (maxY >= minY) { 103 - // Content fills the screen vertically so we have vertical wiggle room. 104 - // Try to keep the tapped point under the finger after zoom. 105 - rectY = touchY - touchY / zoom 106 - rectY = Math.min(rectY, maxY) 107 - rectY = Math.max(rectY, minY) 108 - } else { 109 - // Keep the rect centered on the screen so that black bars are balanced. 110 - rectY = screen.height / 2 - rectHeight / 2 111 - } 112 - 113 - return { 114 - x: rectX, 115 - y: rectY, 116 - height: rectHeight, 117 - width: rectWidth, 118 - } 119 - } 120 - 121 - if (lastTapTS && nowTS - lastTapTS < DOUBLE_TAP_DELAY) { 122 - let nextZoomRect = { 123 - x: 0, 124 - y: 0, 125 - width: screen.width, 126 - height: screen.height, 127 - } 128 - 129 - const willZoom = !scaled 130 - if (willZoom) { 131 - const {pageX, pageY} = event.nativeEvent 132 - nextZoomRect = getZoomRectAfterDoubleTap(pageX, pageY) 133 - } 134 - 135 - // @ts-ignore 136 - scrollResponderRef?.scrollResponderZoomTo({ 137 - ...nextZoomRect, // This rect is in screen coordinates 138 - animated: true, 139 - }) 140 - } else { 141 - lastTapTS = nowTS 142 - } 143 - }, 144 - [imageDimensions, scaled, screen.height, screen.width, scrollViewRef], 145 - ) 146 - 147 - return handleDoubleTap 148 - } 149 - 150 - export default useDoubleTapToZoom
+20 -2
src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts
··· 8 8 9 9 import {useEffect, useState} from 'react' 10 10 import {Image, ImageURISource} from 'react-native' 11 - 12 - import {createCache} from '../utils' 13 11 import {Dimensions, ImageSource} from '../@types' 14 12 15 13 const CACHE_SIZE = 50 14 + 15 + type CacheStorageItem = {key: string; value: any} 16 + 17 + const createCache = (cacheSize: number) => ({ 18 + _storage: [] as CacheStorageItem[], 19 + get(key: string): any { 20 + const {value} = 21 + this._storage.find(({key: storageKey}) => storageKey === key) || {} 22 + 23 + return value 24 + }, 25 + set(key: string, value: any) { 26 + if (this._storage.length >= cacheSize) { 27 + this._storage.shift() 28 + } 29 + 30 + this._storage.push({key, value}) 31 + }, 32 + }) 33 + 16 34 const imageDimensionsCache = createCache(CACHE_SIZE) 17 35 18 36 const useImageDimensions = (image: ImageSource): Dimensions | null => {
-32
src/view/com/lightbox/ImageViewing/hooks/useImageIndexChange.ts
··· 1 - /** 2 - * Copyright (c) JOB TODAY S.A. and its affiliates. 3 - * 4 - * This source code is licensed under the MIT license found in the 5 - * LICENSE file in the root directory of this source tree. 6 - * 7 - */ 8 - 9 - import {useState} from 'react' 10 - import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native' 11 - 12 - import {Dimensions} from '../@types' 13 - 14 - const useImageIndexChange = (imageIndex: number, screen: Dimensions) => { 15 - const [currentImageIndex, setImageIndex] = useState(imageIndex) 16 - const onScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => { 17 - const { 18 - nativeEvent: { 19 - contentOffset: {x: scrollX}, 20 - }, 21 - } = event 22 - 23 - if (screen.width) { 24 - const nextIndex = Math.round(scrollX / screen.width) 25 - setImageIndex(nextIndex < 0 ? 0 : nextIndex) 26 - } 27 - } 28 - 29 - return [currentImageIndex, onScroll] as const 30 - } 31 - 32 - export default useImageIndexChange
-25
src/view/com/lightbox/ImageViewing/hooks/useImagePrefetch.ts
··· 1 - /** 2 - * Copyright (c) JOB TODAY S.A. and its affiliates. 3 - * 4 - * This source code is licensed under the MIT license found in the 5 - * LICENSE file in the root directory of this source tree. 6 - * 7 - */ 8 - 9 - import {useEffect} from 'react' 10 - import {Image} from 'react-native' 11 - import {ImageSource} from '../@types' 12 - 13 - const useImagePrefetch = (images: ImageSource[]) => { 14 - useEffect(() => { 15 - images.forEach(image => { 16 - //@ts-ignore 17 - if (image.uri) { 18 - //@ts-ignore 19 - return Image.prefetch(image.uri) 20 - } 21 - }) 22 - }, [images]) 23 - } 24 - 25 - export default useImagePrefetch
+26 -34
src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts
··· 18 18 } from 'react-native' 19 19 20 20 import {Position} from '../@types' 21 - import { 22 - getDistanceBetweenTouches, 23 - getImageTranslate, 24 - getImageDimensionsByTranslate, 25 - } from '../utils' 21 + import {getImageTranslate} from '../utils' 26 22 27 23 const SCREEN = Dimensions.get('window') 28 24 const SCREEN_WIDTH = SCREEN.width 29 25 const SCREEN_HEIGHT = SCREEN.height 30 - const MIN_DIMENSION = Math.min(SCREEN_WIDTH, SCREEN_HEIGHT) 31 26 const ANDROID_BAR_HEIGHT = 24 32 27 33 28 const MIN_ZOOM = 2 ··· 39 34 initialScale: number 40 35 initialTranslate: Position 41 36 onZoom: (isZoomed: boolean) => void 42 - doubleTapToZoomEnabled: boolean 43 - onLongPress: () => void 44 - delayLongPress: number 45 37 } 46 38 47 39 const usePanResponder = ({ 48 40 initialScale, 49 41 initialTranslate, 50 42 onZoom, 51 - doubleTapToZoomEnabled, 52 - onLongPress, 53 - delayLongPress, 54 43 }: Props): Readonly< 55 44 [GestureResponderHandlers, Animated.Value, Animated.ValueXY] 56 45 > => { ··· 62 51 let tmpTranslate: Position | null = null 63 52 let isDoubleTapPerformed = false 64 53 let lastTapTS: number | null = null 65 - let longPressHandlerRef: NodeJS.Timeout | null = null 66 54 67 - const meaningfulShift = MIN_DIMENSION * 0.01 55 + // TODO: It's not valid to reinitialize Animated values during render. 56 + // This is a bug. 68 57 const scaleValue = new Animated.Value(initialScale) 69 58 const translateValue = new Animated.ValueXY(initialTranslate) 70 59 ··· 155 144 return () => scaleValue.removeAllListeners() 156 145 }) 157 146 158 - const cancelLongPressHandle = () => { 159 - longPressHandlerRef && clearTimeout(longPressHandlerRef) 160 - } 161 - 162 147 const panResponder = PanResponder.create({ 163 148 onStartShouldSetPanResponder: () => true, 164 149 onStartShouldSetPanResponderCapture: () => true, ··· 173 158 if (gestureState.numberActiveTouches > 1) { 174 159 return 175 160 } 176 - 177 - longPressHandlerRef = setTimeout(onLongPress, delayLongPress) 178 161 }, 179 162 onPanResponderStart: ( 180 163 event: GestureResponderEvent, ··· 194 177 lastTapTS && tapTS - lastTapTS < DOUBLE_TAP_DELAY, 195 178 ) 196 179 197 - if (doubleTapToZoomEnabled && isDoubleTapPerformed) { 180 + if (isDoubleTapPerformed) { 198 181 let nextScale = initialScale 199 182 let nextTranslate = initialTranslate 200 183 ··· 241 224 event: GestureResponderEvent, 242 225 gestureState: PanResponderGestureState, 243 226 ) => { 244 - const {dx, dy} = gestureState 245 - 246 - if (Math.abs(dx) >= meaningfulShift || Math.abs(dy) >= meaningfulShift) { 247 - cancelLongPressHandle() 248 - } 249 - 250 227 // Don't need to handle move because double tap in progress (was handled in onStart) 251 - if (doubleTapToZoomEnabled && isDoubleTapPerformed) { 252 - cancelLongPressHandle() 228 + if (isDoubleTapPerformed) { 253 229 return 254 230 } 255 231 ··· 267 243 numberInitialTouches === 2 && gestureState.numberActiveTouches === 2 268 244 269 245 if (isPinchGesture) { 270 - cancelLongPressHandle() 271 - 272 246 const initialDistance = getDistanceBetweenTouches(initialTouches) 273 247 const currentDistance = getDistanceBetweenTouches( 274 248 event.nativeEvent.touches, ··· 315 289 316 290 if (isTapGesture && currentScale > initialScale) { 317 291 const {x, y} = currentTranslate 318 - // eslint-disable-next-line @typescript-eslint/no-shadow 292 + 319 293 const {dx, dy} = gestureState 320 294 const [topBound, leftBound, bottomBound, rightBound] = 321 295 getBounds(currentScale) ··· 360 334 } 361 335 }, 362 336 onPanResponderRelease: () => { 363 - cancelLongPressHandle() 364 - 365 337 if (isDoubleTapPerformed) { 366 338 isDoubleTapPerformed = false 367 339 } ··· 426 398 }) 427 399 428 400 return [panResponder.panHandlers, scaleValue, translateValue] 401 + } 402 + 403 + const getImageDimensionsByTranslate = ( 404 + translate: Position, 405 + screen: {width: number; height: number}, 406 + ): {width: number; height: number} => ({ 407 + width: screen.width - translate.x * 2, 408 + height: screen.height - translate.y * 2, 409 + }) 410 + 411 + const getDistanceBetweenTouches = (touches: NativeTouchEvent[]): number => { 412 + const [a, b] = touches 413 + 414 + if (a == null || b == null) { 415 + return 0 416 + } 417 + 418 + return Math.sqrt( 419 + Math.pow(a.pageX - b.pageX, 2) + Math.pow(a.pageY - b.pageY, 2), 420 + ) 429 421 } 430 422 431 423 export default usePanResponder
-24
src/view/com/lightbox/ImageViewing/hooks/useRequestClose.ts
··· 1 - /** 2 - * Copyright (c) JOB TODAY S.A. and its affiliates. 3 - * 4 - * This source code is licensed under the MIT license found in the 5 - * LICENSE file in the root directory of this source tree. 6 - * 7 - */ 8 - 9 - import {useState} from 'react' 10 - 11 - const useRequestClose = (onRequestClose: () => void) => { 12 - const [opacity, setOpacity] = useState(1) 13 - 14 - return [ 15 - opacity, 16 - () => { 17 - setOpacity(0) 18 - onRequestClose() 19 - setTimeout(() => setOpacity(1), 0) 20 - }, 21 - ] as const 22 - } 23 - 24 - export default useRequestClose
+59 -35
src/view/com/lightbox/ImageViewing/index.tsx
··· 12 12 ComponentType, 13 13 useCallback, 14 14 useRef, 15 - useEffect, 16 15 useMemo, 16 + useState, 17 17 } from 'react' 18 18 import { 19 19 Animated, 20 20 Dimensions, 21 + NativeSyntheticEvent, 22 + NativeScrollEvent, 21 23 StyleSheet, 22 24 View, 23 25 VirtualizedList, ··· 29 31 import ImageItem from './components/ImageItem/ImageItem' 30 32 import ImageDefaultHeader from './components/ImageDefaultHeader' 31 33 32 - import useAnimatedComponents from './hooks/useAnimatedComponents' 33 - import useImageIndexChange from './hooks/useImageIndexChange' 34 - import useRequestClose from './hooks/useRequestClose' 35 34 import {ImageSource} from './@types' 36 35 import {Edge, SafeAreaView} from 'react-native-safe-area-context' 37 36 ··· 41 40 imageIndex: number 42 41 visible: boolean 43 42 onRequestClose: () => void 44 - onLongPress?: (image: ImageSource) => void 45 - onImageIndexChange?: (imageIndex: number) => void 46 43 presentationStyle?: ModalProps['presentationStyle'] 47 44 animationType?: ModalProps['animationType'] 48 45 backgroundColor?: string 49 - swipeToCloseEnabled?: boolean 50 - doubleTapToZoomEnabled?: boolean 51 - delayLongPress?: number 52 46 HeaderComponent?: ComponentType<{imageIndex: number}> 53 47 FooterComponent?: ComponentType<{imageIndex: number}> 54 48 } 55 49 56 50 const DEFAULT_BG_COLOR = '#000' 57 - const DEFAULT_DELAY_LONG_PRESS = 800 58 51 const SCREEN = Dimensions.get('screen') 59 52 const SCREEN_WIDTH = SCREEN.width 53 + const INITIAL_POSITION = {x: 0, y: 0} 54 + const ANIMATION_CONFIG = { 55 + duration: 200, 56 + useNativeDriver: true, 57 + } 60 58 61 59 function ImageViewing({ 62 60 images, ··· 64 62 imageIndex, 65 63 visible, 66 64 onRequestClose, 67 - onLongPress = () => {}, 68 - onImageIndexChange, 69 65 backgroundColor = DEFAULT_BG_COLOR, 70 - swipeToCloseEnabled, 71 - doubleTapToZoomEnabled, 72 - delayLongPress = DEFAULT_DELAY_LONG_PRESS, 73 66 HeaderComponent, 74 67 FooterComponent, 75 68 }: Props) { 76 69 const imageList = useRef<VirtualizedList<ImageSource>>(null) 77 - const [opacity, onRequestCloseEnhanced] = useRequestClose(onRequestClose) 78 - const [currentImageIndex, onScroll] = useImageIndexChange(imageIndex, SCREEN) 79 - const [headerTransform, footerTransform, toggleBarsVisible] = 80 - useAnimatedComponents() 70 + const [opacity, setOpacity] = useState(1) 71 + const [currentImageIndex, setImageIndex] = useState(imageIndex) 81 72 82 - useEffect(() => { 83 - if (onImageIndexChange) { 84 - onImageIndexChange(currentImageIndex) 73 + // TODO: It's not valid to reinitialize Animated values during render. 74 + // This is a bug. 75 + const headerTranslate = new Animated.ValueXY(INITIAL_POSITION) 76 + const footerTranslate = new Animated.ValueXY(INITIAL_POSITION) 77 + 78 + const toggleBarsVisible = (isVisible: boolean) => { 79 + if (isVisible) { 80 + Animated.parallel([ 81 + Animated.timing(headerTranslate.y, {...ANIMATION_CONFIG, toValue: 0}), 82 + Animated.timing(footerTranslate.y, {...ANIMATION_CONFIG, toValue: 0}), 83 + ]).start() 84 + } else { 85 + Animated.parallel([ 86 + Animated.timing(headerTranslate.y, { 87 + ...ANIMATION_CONFIG, 88 + toValue: -300, 89 + }), 90 + Animated.timing(footerTranslate.y, { 91 + ...ANIMATION_CONFIG, 92 + toValue: 300, 93 + }), 94 + ]).start() 85 95 } 86 - }, [currentImageIndex, onImageIndexChange]) 96 + } 97 + 98 + const onRequestCloseEnhanced = () => { 99 + setOpacity(0) 100 + onRequestClose() 101 + setTimeout(() => setOpacity(1), 0) 102 + } 103 + 104 + const onScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => { 105 + const { 106 + nativeEvent: { 107 + contentOffset: {x: scrollX}, 108 + }, 109 + } = event 110 + 111 + if (SCREEN.width) { 112 + const nextIndex = Math.round(scrollX / SCREEN.width) 113 + setImageIndex(nextIndex < 0 ? 0 : nextIndex) 114 + } 115 + } 87 116 88 - const onZoom = useCallback( 89 - (isScaled: boolean) => { 90 - // @ts-ignore 91 - imageList?.current?.setNativeProps({scrollEnabled: !isScaled}) 92 - toggleBarsVisible(!isScaled) 93 - }, 94 - [toggleBarsVisible], 95 - ) 117 + const onZoom = (isScaled: boolean) => { 118 + // @ts-ignore 119 + imageList?.current?.setNativeProps({scrollEnabled: !isScaled}) 120 + toggleBarsVisible(!isScaled) 121 + } 96 122 97 123 const edges = useMemo(() => { 98 124 if (Platform.OS === 'android') { ··· 111 137 return null 112 138 } 113 139 140 + const headerTransform = headerTranslate.getTranslateTransform() 141 + const footerTransform = footerTranslate.getTranslateTransform() 114 142 return ( 115 143 <SafeAreaView 116 144 style={styles.screen} ··· 148 176 onZoom={onZoom} 149 177 imageSrc={imageSrc} 150 178 onRequestClose={onRequestCloseEnhanced} 151 - onLongPress={onLongPress} 152 - delayLongPress={delayLongPress} 153 - swipeToCloseEnabled={swipeToCloseEnabled} 154 - doubleTapToZoomEnabled={doubleTapToZoomEnabled} 155 179 /> 156 180 )} 157 181 onMomentumScrollEnd={onScroll}
+1 -74
src/view/com/lightbox/ImageViewing/utils.ts
··· 6 6 * 7 7 */ 8 8 9 - import {Animated, NativeTouchEvent} from 'react-native' 9 + import {Animated} from 'react-native' 10 10 import {Dimensions, Position} from './@types' 11 - 12 - type CacheStorageItem = {key: string; value: any} 13 - 14 - export const createCache = (cacheSize: number) => ({ 15 - _storage: [] as CacheStorageItem[], 16 - get(key: string): any { 17 - const {value} = 18 - this._storage.find(({key: storageKey}) => storageKey === key) || {} 19 - 20 - return value 21 - }, 22 - set(key: string, value: any) { 23 - if (this._storage.length >= cacheSize) { 24 - this._storage.shift() 25 - } 26 - 27 - this._storage.push({key, value}) 28 - }, 29 - }) 30 - 31 - export const splitArrayIntoBatches = (arr: any[], batchSize: number): any[] => 32 - arr.reduce((result, item) => { 33 - const batch = result.pop() || [] 34 - 35 - if (batch.length < batchSize) { 36 - batch.push(item) 37 - result.push(batch) 38 - } else { 39 - result.push(batch, [item]) 40 - } 41 - 42 - return result 43 - }, []) 44 11 45 12 export const getImageTransform = ( 46 13 image: Dimensions | null, ··· 97 64 y: getTranslateForAxis('y'), 98 65 } 99 66 } 100 - 101 - export const getImageDimensionsByTranslate = ( 102 - translate: Position, 103 - screen: Dimensions, 104 - ): Dimensions => ({ 105 - width: screen.width - translate.x * 2, 106 - height: screen.height - translate.y * 2, 107 - }) 108 - 109 - export const getImageTranslateForScale = ( 110 - currentTranslate: Position, 111 - targetScale: number, 112 - screen: Dimensions, 113 - ): Position => { 114 - const {width, height} = getImageDimensionsByTranslate( 115 - currentTranslate, 116 - screen, 117 - ) 118 - 119 - const targetImageDimensions = { 120 - width: width * targetScale, 121 - height: height * targetScale, 122 - } 123 - 124 - return getImageTranslate(targetImageDimensions, screen) 125 - } 126 - 127 - export const getDistanceBetweenTouches = ( 128 - touches: NativeTouchEvent[], 129 - ): number => { 130 - const [a, b] = touches 131 - 132 - if (a == null || b == null) { 133 - return 0 134 - } 135 - 136 - return Math.sqrt( 137 - Math.pow(a.pageX - b.pageX, 2) + Math.pow(a.pageY - b.pageY, 2), 138 - ) 139 - }
+86 -84
src/view/com/lightbox/Lightbox.tsx
··· 15 15 16 16 export const Lightbox = observer(function Lightbox() { 17 17 const store = useStores() 18 - const [isAltExpanded, setAltExpanded] = React.useState(false) 19 - const [permissionResponse, requestPermission] = MediaLibrary.usePermissions() 20 - 21 18 const onClose = React.useCallback(() => { 22 19 store.shell.closeLightbox() 23 20 }, [store]) 24 21 25 - const saveImageToAlbumWithToasts = React.useCallback( 26 - async (uri: string) => { 27 - if (!permissionResponse || permissionResponse.granted === false) { 28 - Toast.show('Permission to access camera roll is required.') 29 - if (permissionResponse?.canAskAgain) { 30 - requestPermission() 31 - } else { 32 - Toast.show( 33 - 'Permission to access camera roll was denied. Please enable it in your system settings.', 34 - ) 35 - } 36 - return 37 - } 38 - 39 - try { 40 - await saveImageToMediaLibrary({uri}) 41 - Toast.show('Saved to your camera roll.') 42 - } catch (e: any) { 43 - Toast.show(`Failed to save image: ${String(e)}`) 44 - } 45 - }, 46 - [permissionResponse, requestPermission], 47 - ) 48 - 49 - const LightboxFooter = React.useCallback( 50 - ({imageIndex}: {imageIndex: number}) => { 51 - const lightbox = store.shell.activeLightbox 52 - if (!lightbox) { 53 - return null 54 - } 55 - 56 - let altText = '' 57 - let uri = '' 58 - if (lightbox.name === 'images') { 59 - const opts = lightbox as models.ImagesLightbox 60 - uri = opts.images[imageIndex].uri 61 - altText = opts.images[imageIndex].alt || '' 62 - } else if (lightbox.name === 'profile-image') { 63 - const opts = lightbox as models.ProfileImageLightbox 64 - uri = opts.profileView.avatar || '' 65 - } 66 - 67 - return ( 68 - <View style={[styles.footer]}> 69 - {altText ? ( 70 - <Pressable 71 - onPress={() => setAltExpanded(!isAltExpanded)} 72 - accessibilityRole="button"> 73 - <Text 74 - style={[s.gray3, styles.footerText]} 75 - numberOfLines={isAltExpanded ? undefined : 3}> 76 - {altText} 77 - </Text> 78 - </Pressable> 79 - ) : null} 80 - <View style={styles.footerBtns}> 81 - <Button 82 - type="primary-outline" 83 - style={styles.footerBtn} 84 - onPress={() => saveImageToAlbumWithToasts(uri)}> 85 - <FontAwesomeIcon icon={['far', 'floppy-disk']} style={s.white} /> 86 - <Text type="xl" style={s.white}> 87 - Save 88 - </Text> 89 - </Button> 90 - <Button 91 - type="primary-outline" 92 - style={styles.footerBtn} 93 - onPress={() => shareImageModal({uri})}> 94 - <FontAwesomeIcon icon="arrow-up-from-bracket" style={s.white} /> 95 - <Text type="xl" style={s.white}> 96 - Share 97 - </Text> 98 - </Button> 99 - </View> 100 - </View> 101 - ) 102 - }, 103 - [store.shell.activeLightbox, isAltExpanded, saveImageToAlbumWithToasts], 104 - ) 105 - 106 22 if (!store.shell.activeLightbox) { 107 23 return null 108 24 } else if (store.shell.activeLightbox.name === 'profile-image') { ··· 130 46 } else { 131 47 return null 132 48 } 49 + }) 50 + 51 + const LightboxFooter = observer(function LightboxFooter({ 52 + imageIndex, 53 + }: { 54 + imageIndex: number 55 + }) { 56 + const store = useStores() 57 + const [isAltExpanded, setAltExpanded] = React.useState(false) 58 + const [permissionResponse, requestPermission] = MediaLibrary.usePermissions() 59 + 60 + const saveImageToAlbumWithToasts = React.useCallback( 61 + async (uri: string) => { 62 + if (!permissionResponse || permissionResponse.granted === false) { 63 + Toast.show('Permission to access camera roll is required.') 64 + if (permissionResponse?.canAskAgain) { 65 + requestPermission() 66 + } else { 67 + Toast.show( 68 + 'Permission to access camera roll was denied. Please enable it in your system settings.', 69 + ) 70 + } 71 + return 72 + } 73 + 74 + try { 75 + await saveImageToMediaLibrary({uri}) 76 + Toast.show('Saved to your camera roll.') 77 + } catch (e: any) { 78 + Toast.show(`Failed to save image: ${String(e)}`) 79 + } 80 + }, 81 + [permissionResponse, requestPermission], 82 + ) 83 + 84 + const lightbox = store.shell.activeLightbox 85 + if (!lightbox) { 86 + return null 87 + } 88 + 89 + let altText = '' 90 + let uri = '' 91 + if (lightbox.name === 'images') { 92 + const opts = lightbox as models.ImagesLightbox 93 + uri = opts.images[imageIndex].uri 94 + altText = opts.images[imageIndex].alt || '' 95 + } else if (lightbox.name === 'profile-image') { 96 + const opts = lightbox as models.ProfileImageLightbox 97 + uri = opts.profileView.avatar || '' 98 + } 99 + 100 + return ( 101 + <View style={[styles.footer]}> 102 + {altText ? ( 103 + <Pressable 104 + onPress={() => setAltExpanded(!isAltExpanded)} 105 + accessibilityRole="button"> 106 + <Text 107 + style={[s.gray3, styles.footerText]} 108 + numberOfLines={isAltExpanded ? undefined : 3}> 109 + {altText} 110 + </Text> 111 + </Pressable> 112 + ) : null} 113 + <View style={styles.footerBtns}> 114 + <Button 115 + type="primary-outline" 116 + style={styles.footerBtn} 117 + onPress={() => saveImageToAlbumWithToasts(uri)}> 118 + <FontAwesomeIcon icon={['far', 'floppy-disk']} style={s.white} /> 119 + <Text type="xl" style={s.white}> 120 + Save 121 + </Text> 122 + </Button> 123 + <Button 124 + type="primary-outline" 125 + style={styles.footerBtn} 126 + onPress={() => shareImageModal({uri})}> 127 + <FontAwesomeIcon icon="arrow-up-from-bracket" style={s.white} /> 128 + <Text type="xl" style={s.white}> 129 + Share 130 + </Text> 131 + </Button> 132 + </View> 133 + </View> 134 + ) 133 135 }) 134 136 135 137 const styles = StyleSheet.create({