diff options
23 files changed, 1567 insertions, 45 deletions
diff --git a/ios/Podfile.lock b/ios/Podfile.lock index c85b74313..7f0ac0368 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -238,7 +238,7 @@ PODS: - glog - react-native-blur (4.3.0): - React-Core - - react-native-cameraroll (5.2.0): + - react-native-cameraroll (5.2.2): - React-Core - react-native-image-resizer (3.0.4): - React-Core @@ -597,13 +597,13 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: - boost: a7c83b31436843459a1961bfd74b96033dc77234 + boost: 57d2868c099736d80fcd648bf211b4431e51a558 BVLinearGradient: 34a999fda29036898a09c6a6b728b0b4189e1a44 - DoubleConversion: 831926d9b8bf8166fd87886c4abab286c2422662 + DoubleConversion: 5189b271737e1565bdce30deb4a08d647e3f5f54 FBLazyVector: 61839cba7a48c570b7ac3e1cd8a4d0948382202f FBReactNativeSpec: 5a14398ccf5e27c1ca2d7109eb920594ce93c10d fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9 - glog: 476ee3e89abb49e07f822b48323c51c57124b572 + glog: 04b94705f318337d7ead9e6d17c019bd9b1f6b1b hermes-engine: f6e715aa6c8bd38de6c13bc85e07b0a337edaa89 libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913 RCT-Folly: 424b8c9a7a0b9ab2886ffe9c3b041ef628fd4fb1 @@ -621,7 +621,7 @@ SPEC CHECKSUMS: React-jsinspector: 5061fcbec93fd672183dfb39cc2f65e55a0835db React-logger: a6c0b3a807a8e81f6d7fea2e72660766f55daa50 react-native-blur: 50c9feabacbc5f49b61337ebc32192c6be7ec3c3 - react-native-cameraroll: 0ff04cc4e0ff5f19a94ff4313e5c8bc4503cd86d + react-native-cameraroll: 71d68167beb6fc7216aa564abb6d86f1d666a2c6 react-native-image-resizer: 794abf75ec13ed1f0dbb1f134e27504ea65e9e66 react-native-pager-view: 54bed894cecebe28cede54c01038d9d1e122de43 react-native-paste-input: 5182843692fd2ec72be50f241a38a49796e225d7 diff --git a/package.json b/package.json index cb30b5711..2838905ff 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "@mattermost/react-native-paste-input": "^0.6.0", "@notifee/react-native": "^7.4.0", "@react-native-async-storage/async-storage": "^1.17.6", - "@react-native-camera-roll/camera-roll": "^5.1.0", + "@react-native-camera-roll/camera-roll": "^5.2.2", "@react-native-clipboard/clipboard": "^1.10.0", "@react-native-community/blur": "^4.3.0", "@segment/analytics-react-native": "^2.10.1", @@ -51,7 +51,6 @@ "react-native-gesture-handler": "^2.5.0", "react-native-haptic-feedback": "^1.14.0", "react-native-image-crop-picker": "^0.38.1", - "react-native-image-viewing": "^0.2.2", "react-native-inappbrowser-reborn": "^3.6.3", "react-native-linear-gradient": "^2.6.2", "react-native-pager-view": "^6.0.2", diff --git a/src/lib/images.ts b/src/lib/images.ts index 9fc1cbc34..8d5eaded0 100644 --- a/src/lib/images.ts +++ b/src/lib/images.ts @@ -1,5 +1,9 @@ import RNFetchBlob from 'rn-fetch-blob' import ImageResizer from '@bam.tech/react-native-image-resizer' +import {Share} from 'react-native' +import RNFS from 'react-native-fs' + +import * as Toast from '../view/com/util/Toast' export interface DownloadAndResizeOpts { uri: string @@ -128,3 +132,21 @@ export function scaleDownDimensions(dim: Dim, max: Dim): Dim { } return {width: dim.width * hScale, height: dim.height * hScale} } + +export const saveImageModal = async ({uri}: {uri: string}) => { + const downloadResponse = await RNFetchBlob.config({ + fileCache: true, + }).fetch('GET', uri) + + const imagePath = downloadResponse.path() + const base64Data = await downloadResponse.readFile('base64') + const result = await Share.share({ + url: 'data:image/png;base64,' + base64Data, + }) + if (result.action === Share.sharedAction) { + Toast.show('Image saved to gallery') + } else if (result.action === Share.dismissedAction) { + // dismissed + } + RNFS.unlink(imagePath) +} diff --git a/src/view/com/lightbox/ImageViewing/@types/index.ts b/src/view/com/lightbox/ImageViewing/@types/index.ts new file mode 100644 index 000000000..4a08e2394 --- /dev/null +++ b/src/view/com/lightbox/ImageViewing/@types/index.ts @@ -0,0 +1,21 @@ +/** + * 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 {ImageURISource, ImageRequireSource} from 'react-native' + +export type Dimensions = { + width: number + height: number +} + +export type Position = { + x: number + y: number +} + +export type ImageSource = ImageURISource | ImageRequireSource diff --git a/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx b/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx new file mode 100644 index 000000000..6880008e4 --- /dev/null +++ b/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx @@ -0,0 +1,52 @@ +/** + * 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 React from 'react' +import {SafeAreaView, Text, TouchableOpacity, StyleSheet} from 'react-native' + +type Props = { + onRequestClose: () => void +} + +const HIT_SLOP = {top: 16, left: 16, bottom: 16, right: 16} + +const ImageDefaultHeader = ({onRequestClose}: Props) => ( + <SafeAreaView style={styles.root}> + <TouchableOpacity + style={styles.closeButton} + onPress={onRequestClose} + hitSlop={HIT_SLOP}> + <Text style={styles.closeText}>✕</Text> + </TouchableOpacity> + </SafeAreaView> +) + +const styles = StyleSheet.create({ + root: { + alignItems: 'flex-end', + }, + closeButton: { + marginRight: 8, + marginTop: 8, + width: 44, + height: 44, + alignItems: 'center', + justifyContent: 'center', + borderRadius: 22, + backgroundColor: '#00000077', + }, + closeText: { + lineHeight: 22, + fontSize: 19, + textAlign: 'center', + color: '#FFF', + includeFontPadding: false, + }, +}) + +export default ImageDefaultHeader diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx new file mode 100644 index 000000000..01a53ff6f --- /dev/null +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx @@ -0,0 +1,152 @@ +/** + * 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 React, {useCallback, useRef, useState} from 'react' + +import { + Animated, + ScrollView, + Dimensions, + StyleSheet, + NativeScrollEvent, + NativeSyntheticEvent, + NativeMethodsMixin, +} from 'react-native' + +import useImageDimensions from '../../hooks/useImageDimensions' +import usePanResponder from '../../hooks/usePanResponder' + +import {getImageStyles, getImageTransform} from '../../utils' +import {ImageSource} from '../../@types' +import {ImageLoading} from './ImageLoading' + +const SWIPE_CLOSE_OFFSET = 75 +const SWIPE_CLOSE_VELOCITY = 1.75 +const SCREEN = Dimensions.get('window') +const SCREEN_WIDTH = SCREEN.width +const SCREEN_HEIGHT = SCREEN.height + +type Props = { + imageSrc: ImageSource + onRequestClose: () => void + onZoom: (isZoomed: boolean) => void + onLongPress: (image: ImageSource) => void + delayLongPress: number + swipeToCloseEnabled?: boolean + doubleTapToZoomEnabled?: boolean +} + +const ImageItem = ({ + imageSrc, + onZoom, + onRequestClose, + onLongPress, + delayLongPress, + swipeToCloseEnabled = true, + doubleTapToZoomEnabled = true, +}: Props) => { + const imageContainer = useRef<ScrollView & NativeMethodsMixin>(null) + const imageDimensions = useImageDimensions(imageSrc) + const [translate, scale] = getImageTransform(imageDimensions, SCREEN) + const scrollValueY = new Animated.Value(0) + const [isLoaded, setLoadEnd] = useState(false) + + const onLoaded = useCallback(() => setLoadEnd(true), []) + const onZoomPerformed = useCallback( + (isZoomed: boolean) => { + onZoom(isZoomed) + if (imageContainer?.current) { + imageContainer.current.setNativeProps({ + scrollEnabled: !isZoomed, + }) + } + }, + [onZoom], + ) + + const onLongPressHandler = useCallback(() => { + onLongPress(imageSrc) + }, [imageSrc, onLongPress]) + + const [panHandlers, scaleValue, translateValue] = usePanResponder({ + initialScale: scale || 1, + initialTranslate: translate || {x: 0, y: 0}, + onZoom: onZoomPerformed, + doubleTapToZoomEnabled, + onLongPress: onLongPressHandler, + delayLongPress, + }) + + const imagesStyles = getImageStyles( + imageDimensions, + translateValue, + scaleValue, + ) + const imageOpacity = scrollValueY.interpolate({ + inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET], + outputRange: [0.7, 1, 0.7], + }) + const imageStylesWithOpacity = {...imagesStyles, opacity: imageOpacity} + + const onScrollEndDrag = ({ + nativeEvent, + }: NativeSyntheticEvent<NativeScrollEvent>) => { + const velocityY = nativeEvent?.velocity?.y ?? 0 + const offsetY = nativeEvent?.contentOffset?.y ?? 0 + + if ( + (Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY && + offsetY > SWIPE_CLOSE_OFFSET) || + offsetY > SCREEN_HEIGHT / 2 + ) { + onRequestClose() + } + } + + const onScroll = ({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => { + const offsetY = nativeEvent?.contentOffset?.y ?? 0 + + scrollValueY.setValue(offsetY) + } + + return ( + <ScrollView + ref={imageContainer} + style={styles.listItem} + pagingEnabled + nestedScrollEnabled + showsHorizontalScrollIndicator={false} + showsVerticalScrollIndicator={false} + contentContainerStyle={styles.imageScrollContainer} + scrollEnabled={swipeToCloseEnabled} + {...(swipeToCloseEnabled && { + onScroll, + onScrollEndDrag, + })}> + <Animated.Image + {...panHandlers} + source={imageSrc} + style={imageStylesWithOpacity} + onLoad={onLoaded} + /> + {(!isLoaded || !imageDimensions) && <ImageLoading />} + </ScrollView> + ) +} + +const styles = StyleSheet.create({ + listItem: { + width: SCREEN_WIDTH, + height: SCREEN_HEIGHT, + }, + imageScrollContainer: { + height: SCREEN_HEIGHT * 2, + }, +}) + +export default React.memo(ImageItem) diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx new file mode 100644 index 000000000..12d37e283 --- /dev/null +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx @@ -0,0 +1,152 @@ +/** + * 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 React, {useCallback, useRef, useState} from 'react' + +import { + Animated, + Dimensions, + ScrollView, + StyleSheet, + View, + NativeScrollEvent, + NativeSyntheticEvent, + TouchableWithoutFeedback, +} from 'react-native' + +import useDoubleTapToZoom from '../../hooks/useDoubleTapToZoom' +import useImageDimensions from '../../hooks/useImageDimensions' + +import {getImageStyles, getImageTransform} from '../../utils' +import {ImageSource} from '../../@types' +import {ImageLoading} from './ImageLoading' + +const SWIPE_CLOSE_OFFSET = 75 +const SWIPE_CLOSE_VELOCITY = 1 +const SCREEN = Dimensions.get('screen') +const SCREEN_WIDTH = SCREEN.width +const SCREEN_HEIGHT = SCREEN.height + +type Props = { + imageSrc: ImageSource + onRequestClose: () => void + onZoom: (scaled: boolean) => void + onLongPress: (image: ImageSource) => void + delayLongPress: number + swipeToCloseEnabled?: boolean + doubleTapToZoomEnabled?: boolean +} + +const ImageItem = ({ + imageSrc, + onZoom, + onRequestClose, + onLongPress, + delayLongPress, + swipeToCloseEnabled = true, + doubleTapToZoomEnabled = true, +}: Props) => { + const scrollViewRef = useRef<ScrollView>(null) + const [loaded, setLoaded] = useState(false) + const [scaled, setScaled] = useState(false) + const imageDimensions = useImageDimensions(imageSrc) + const handleDoubleTap = useDoubleTapToZoom(scrollViewRef, scaled, SCREEN) + + const [translate, scale] = getImageTransform(imageDimensions, SCREEN) + const scrollValueY = new Animated.Value(0) + const scaleValue = new Animated.Value(scale || 1) + const translateValue = new Animated.ValueXY(translate) + const maxScale = scale && scale > 0 ? Math.max(1 / scale, 1) : 1 + + const imageOpacity = scrollValueY.interpolate({ + inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET], + outputRange: [0.5, 1, 0.5], + }) + const imagesStyles = getImageStyles( + imageDimensions, + translateValue, + scaleValue, + ) + const imageStylesWithOpacity = {...imagesStyles, opacity: imageOpacity} + + const onScrollEndDrag = useCallback( + ({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => { + const velocityY = nativeEvent?.velocity?.y ?? 0 + const currentScaled = nativeEvent?.zoomScale > 1 + + onZoom(currentScaled) + setScaled(currentScaled) + + if ( + !currentScaled && + swipeToCloseEnabled && + Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY + ) { + onRequestClose() + } + }, + [onRequestClose, onZoom, swipeToCloseEnabled], + ) + + const onScroll = ({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => { + const offsetY = nativeEvent?.contentOffset?.y ?? 0 + + if (nativeEvent?.zoomScale > 1) { + return + } + + scrollValueY.setValue(offsetY) + } + + const onLongPressHandler = useCallback(() => { + onLongPress(imageSrc) + }, [imageSrc, onLongPress]) + + return ( + <View> + <ScrollView + ref={scrollViewRef} + style={styles.listItem} + pinchGestureEnabled + showsHorizontalScrollIndicator={false} + showsVerticalScrollIndicator={false} + maximumZoomScale={maxScale} + contentContainerStyle={styles.imageScrollContainer} + scrollEnabled={swipeToCloseEnabled} + onScrollEndDrag={onScrollEndDrag} + scrollEventThrottle={1} + {...(swipeToCloseEnabled && { + onScroll, + })}> + {(!loaded || !imageDimensions) && <ImageLoading />} + <TouchableWithoutFeedback + onPress={doubleTapToZoomEnabled ? handleDoubleTap : undefined} + onLongPress={onLongPressHandler} + delayLongPress={delayLongPress}> + <Animated.Image + source={imageSrc} + style={imageStylesWithOpacity} + onLoad={() => setLoaded(true)} + /> + </TouchableWithoutFeedback> + </ScrollView> + </View> + ) +} + +const styles = StyleSheet.create({ + listItem: { + width: SCREEN_WIDTH, + height: SCREEN_HEIGHT, + }, + imageScrollContainer: { + height: SCREEN_HEIGHT, + }, +}) + +export default React.memo(ImageItem) diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageLoading.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageLoading.tsx new file mode 100644 index 000000000..9667fcaa7 --- /dev/null +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageLoading.tsx @@ -0,0 +1,37 @@ +/** + * 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 React from 'react' + +import {ActivityIndicator, Dimensions, StyleSheet, View} from 'react-native' + +const SCREEN = Dimensions.get('screen') +const SCREEN_WIDTH = SCREEN.width +const SCREEN_HEIGHT = SCREEN.height + +export const ImageLoading = () => ( + <View style={styles.loading}> + <ActivityIndicator size="small" color="#FFF" /> + </View> +) + +const styles = StyleSheet.create({ + listItem: { + width: SCREEN_WIDTH, + height: SCREEN_HEIGHT, + }, + loading: { + width: SCREEN_WIDTH, + height: SCREEN_HEIGHT, + alignItems: 'center', + justifyContent: 'center', + }, + imageScrollContainer: { + height: SCREEN_HEIGHT, + }, +}) diff --git a/src/view/com/lightbox/ImageViewing/hooks/useAnimatedComponents.ts b/src/view/com/lightbox/ImageViewing/hooks/useAnimatedComponents.ts new file mode 100644 index 000000000..c21cd7f2c --- /dev/null +++ b/src/view/com/lightbox/ImageViewing/hooks/useAnimatedComponents.ts @@ -0,0 +1,47 @@ +/** + * 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 {Animated} from 'react-native' + +const INITIAL_POSITION = {x: 0, y: 0} +const ANIMATION_CONFIG = { + duration: 200, + useNativeDriver: true, +} + +const useAnimatedComponents = () => { + const headerTranslate = new Animated.ValueXY(INITIAL_POSITION) + const footerTranslate = new Animated.ValueXY(INITIAL_POSITION) + + const toggleVisible = (isVisible: boolean) => { + if (isVisible) { + Animated.parallel([ + Animated.timing(headerTranslate.y, {...ANIMATION_CONFIG, toValue: 0}), + Animated.timing(footerTranslate.y, {...ANIMATION_CONFIG, toValue: 0}), + ]).start() + } else { + Animated.parallel([ + Animated.timing(headerTranslate.y, { + ...ANIMATION_CONFIG, + toValue: -300, + }), + Animated.timing(footerTranslate.y, { + ...ANIMATION_CONFIG, + toValue: 300, + }), + ]).start() + } + } + + const headerTransform = headerTranslate.getTranslateTransform() + const footerTransform = footerTranslate.getTranslateTransform() + + return [headerTransform, footerTransform, toggleVisible] as const +} + +export default useAnimatedComponents diff --git a/src/view/com/lightbox/ImageViewing/hooks/useDoubleTapToZoom.ts b/src/view/com/lightbox/ImageViewing/hooks/useDoubleTapToZoom.ts new file mode 100644 index 000000000..92746e951 --- /dev/null +++ b/src/view/com/lightbox/ImageViewing/hooks/useDoubleTapToZoom.ts @@ -0,0 +1,65 @@ +/** + * 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 React, {useCallback} from 'react' +import {ScrollView, NativeTouchEvent, NativeSyntheticEvent} from 'react-native' + +import {Dimensions} from '../@types' + +const DOUBLE_TAP_DELAY = 300 +let lastTapTS: number | null = null + +/** + * This is iOS only. + * Same functionality for Android implemented inside usePanResponder hook. + */ +function useDoubleTapToZoom( + scrollViewRef: React.RefObject<ScrollView>, + scaled: boolean, + screen: Dimensions, +) { + const handleDoubleTap = useCallback( + (event: NativeSyntheticEvent<NativeTouchEvent>) => { + const nowTS = new Date().getTime() + const scrollResponderRef = scrollViewRef?.current?.getScrollResponder() + + if (lastTapTS && nowTS - lastTapTS < DOUBLE_TAP_DELAY) { + const {pageX, pageY} = event.nativeEvent + let targetX = 0 + let targetY = 0 + let targetWidth = screen.width + let targetHeight = screen.height + + // Zooming in + // TODO: Add more precise calculation of targetX, targetY based on touch + if (!scaled) { + targetX = pageX / 2 + targetY = pageY / 2 + targetWidth = screen.width / 2 + targetHeight = screen.height / 2 + } + + // @ts-ignore + scrollResponderRef?.scrollResponderZoomTo({ + x: targetX, + y: targetY, + width: targetWidth, + height: targetHeight, + animated: true, + }) + } else { + lastTapTS = nowTS + } + }, + [scaled, screen.height, screen.width, scrollViewRef], + ) + + return handleDoubleTap +} + +export default useDoubleTapToZoom diff --git a/src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts b/src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts new file mode 100644 index 000000000..bab136c50 --- /dev/null +++ b/src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts @@ -0,0 +1,88 @@ +/** + * 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 {createCache} from '../utils' +import {Dimensions, ImageSource} from '../@types' + +const CACHE_SIZE = 50 +const imageDimensionsCache = createCache(CACHE_SIZE) + +const useImageDimensions = (image: ImageSource): Dimensions | null => { + const [dimensions, setDimensions] = useState<Dimensions | null>(null) + + // eslint-disable-next-line @typescript-eslint/no-shadow + const getImageDimensions = (image: ImageSource): Promise<Dimensions> => { + return new Promise(resolve => { + if (typeof image === 'number') { + const cacheKey = `${image}` + let imageDimensions = imageDimensionsCache.get(cacheKey) + + if (!imageDimensions) { + const {width, height} = Image.resolveAssetSource(image) + imageDimensions = {width, height} + imageDimensionsCache.set(cacheKey, imageDimensions) + } + + resolve(imageDimensions) + + return + } + + // @ts-ignore + if (image.uri) { + const source = image as ImageURISource + + const cacheKey = source.uri as string + + const imageDimensions = imageDimensionsCache.get(cacheKey) + + if (imageDimensions) { + resolve(imageDimensions) + } else { + // @ts-ignore + Image.getSizeWithHeaders( + source.uri, + source.headers, + (width: number, height: number) => { + imageDimensionsCache.set(cacheKey, {width, height}) + resolve({width, height}) + }, + () => { + resolve({width: 0, height: 0}) + }, + ) + } + } else { + resolve({width: 0, height: 0}) + } + }) + } + + 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 diff --git a/src/view/com/lightbox/ImageViewing/hooks/useImageIndexChange.ts b/src/view/com/lightbox/ImageViewing/hooks/useImageIndexChange.ts new file mode 100644 index 000000000..16430f3aa --- /dev/null +++ b/src/view/com/lightbox/ImageViewing/hooks/useImageIndexChange.ts @@ -0,0 +1,32 @@ +/** + * 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 {useState} from 'react' +import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native' + +import {Dimensions} from '../@types' + +const useImageIndexChange = (imageIndex: number, screen: Dimensions) => { + const [currentImageIndex, setImageIndex] = useState(imageIndex) + const onScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => { + const { + nativeEvent: { + contentOffset: {x: scrollX}, + }, + } = event + + if (screen.width) { + const nextIndex = Math.round(scrollX / screen.width) + setImageIndex(nextIndex < 0 ? 0 : nextIndex) + } + } + + return [currentImageIndex, onScroll] as const +} + +export default useImageIndexChange diff --git a/src/view/com/lightbox/ImageViewing/hooks/useImagePrefetch.ts b/src/view/com/lightbox/ImageViewing/hooks/useImagePrefetch.ts new file mode 100644 index 000000000..3969945bb --- /dev/null +++ b/src/view/com/lightbox/ImageViewing/hooks/useImagePrefetch.ts @@ -0,0 +1,25 @@ +/** + * 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} from 'react' +import {Image} from 'react-native' +import {ImageSource} from '../@types' + +const useImagePrefetch = (images: ImageSource[]) => { + useEffect(() => { + images.forEach(image => { + //@ts-ignore + if (image.uri) { + //@ts-ignore + return Image.prefetch(image.uri) + } + }) + }, [images]) +} + +export default useImagePrefetch diff --git a/src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts b/src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts new file mode 100644 index 000000000..4600cf1a8 --- /dev/null +++ b/src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts @@ -0,0 +1,400 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +/** + * 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 {useMemo, useEffect} from 'react' +import { + Animated, + Dimensions, + GestureResponderEvent, + GestureResponderHandlers, + NativeTouchEvent, + PanResponderGestureState, +} from 'react-native' + +import {Position} from '../@types' +import { + createPanResponder, + getDistanceBetweenTouches, + getImageTranslate, + getImageDimensionsByTranslate, +} from '../utils' + +const SCREEN = Dimensions.get('window') +const SCREEN_WIDTH = SCREEN.width +const SCREEN_HEIGHT = SCREEN.height +const MIN_DIMENSION = Math.min(SCREEN_WIDTH, SCREEN_HEIGHT) + +const SCALE_MAX = 2 +const DOUBLE_TAP_DELAY = 300 +const OUT_BOUND_MULTIPLIER = 0.75 + +type Props = { + initialScale: number + initialTranslate: Position + onZoom: (isZoomed: boolean) => void + doubleTapToZoomEnabled: boolean + onLongPress: () => void + delayLongPress: number +} + +const usePanResponder = ({ + initialScale, + initialTranslate, + onZoom, + doubleTapToZoomEnabled, + onLongPress, + delayLongPress, +}: Props): Readonly< + [GestureResponderHandlers, Animated.Value, Animated.ValueXY] +> => { + let numberInitialTouches = 1 + let initialTouches: NativeTouchEvent[] = [] + let currentScale = initialScale + let currentTranslate = initialTranslate + let tmpScale = 0 + let tmpTranslate: Position | null = null + let isDoubleTapPerformed = false + let lastTapTS: number | null = null + let longPressHandlerRef: number | null = null + + const meaningfulShift = MIN_DIMENSION * 0.01 + const scaleValue = new Animated.Value(initialScale) + const translateValue = new Animated.ValueXY(initialTranslate) + + const imageDimensions = getImageDimensionsByTranslate( + initialTranslate, + SCREEN, + ) + + const getBounds = (scale: number) => { + const scaledImageDimensions = { + width: imageDimensions.width * scale, + height: imageDimensions.height * scale, + } + const translateDelta = getImageTranslate(scaledImageDimensions, SCREEN) + + const left = initialTranslate.x - translateDelta.x + const right = left - (scaledImageDimensions.width - SCREEN.width) + const top = initialTranslate.y - translateDelta.y + const bottom = top - (scaledImageDimensions.height - SCREEN.height) + + return [top, left, bottom, right] + } + + const getTranslateInBounds = (translate: Position, scale: number) => { + const inBoundTranslate = {x: translate.x, y: translate.y} + const [topBound, leftBound, bottomBound, rightBound] = getBounds(scale) + + if (translate.x > leftBound) { + inBoundTranslate.x = leftBound + } else if (translate.x < rightBound) { + inBoundTranslate.x = rightBound + } + + if (translate.y > topBound) { + inBoundTranslate.y = topBound + } else if (translate.y < bottomBound) { + inBoundTranslate.y = bottomBound + } + + return inBoundTranslate + } + + const fitsScreenByWidth = () => + imageDimensions.width * currentScale < SCREEN_WIDTH + const fitsScreenByHeight = () => + imageDimensions.height * currentScale < SCREEN_HEIGHT + + useEffect(() => { + scaleValue.addListener(({value}) => { + if (typeof onZoom === 'function') { + onZoom(value !== initialScale) + } + }) + + return () => scaleValue.removeAllListeners() + }) + + const cancelLongPressHandle = () => { + longPressHandlerRef && clearTimeout(longPressHandlerRef) + } + + const handlers = { + onGrant: ( + _: GestureResponderEvent, + gestureState: PanResponderGestureState, + ) => { + numberInitialTouches = gestureState.numberActiveTouches + + if (gestureState.numberActiveTouches > 1) { + return + } + + longPressHandlerRef = setTimeout(onLongPress, delayLongPress) + }, + onStart: ( + event: GestureResponderEvent, + gestureState: PanResponderGestureState, + ) => { + initialTouches = event.nativeEvent.touches + numberInitialTouches = gestureState.numberActiveTouches + + if (gestureState.numberActiveTouches > 1) { + return + } + + const tapTS = Date.now() + // Handle double tap event by calculating diff between first and second taps timestamps + + isDoubleTapPerformed = Boolean( + lastTapTS && tapTS - lastTapTS < DOUBLE_TAP_DELAY, + ) + + if (doubleTapToZoomEnabled && isDoubleTapPerformed) { + const isScaled = currentTranslate.x !== initialTranslate.x // currentScale !== initialScale; + const {pageX: touchX, pageY: touchY} = event.nativeEvent.touches[0] + const targetScale = SCALE_MAX + const nextScale = isScaled ? initialScale : targetScale + const nextTranslate = isScaled + ? initialTranslate + : getTranslateInBounds( + { + x: + initialTranslate.x + + (SCREEN_WIDTH / 2 - touchX) * (targetScale / currentScale), + y: + initialTranslate.y + + (SCREEN_HEIGHT / 2 - touchY) * (targetScale / currentScale), + }, + targetScale, + ) + + onZoom(!isScaled) + + Animated.parallel( + [ + Animated.timing(translateValue.x, { + toValue: nextTranslate.x, + duration: 300, + useNativeDriver: true, + }), + Animated.timing(translateValue.y, { + toValue: nextTranslate.y, + duration: 300, + useNativeDriver: true, + }), + Animated.timing(scaleValue, { + toValue: nextScale, + duration: 300, + useNativeDriver: true, + }), + ], + {stopTogether: false}, + ).start(() => { + currentScale = nextScale + currentTranslate = nextTranslate + }) + + lastTapTS = null + } else { + lastTapTS = Date.now() + } + }, + onMove: ( + event: GestureResponderEvent, + gestureState: PanResponderGestureState, + ) => { + const {dx, dy} = gestureState + + if (Math.abs(dx) >= meaningfulShift || Math.abs(dy) >= meaningfulShift) { + cancelLongPressHandle() + } + + // Don't need to handle move because double tap in progress (was handled in onStart) + if (doubleTapToZoomEnabled && isDoubleTapPerformed) { + cancelLongPressHandle() + return + } + + if ( + numberInitialTouches === 1 && + gestureState.numberActiveTouches === 2 + ) { + numberInitialTouches = 2 + initialTouches = event.nativeEvent.touches + } + + const isTapGesture = + numberInitialTouches === 1 && gestureState.numberActiveTouches === 1 + const isPinchGesture = + numberInitialTouches === 2 && gestureState.numberActiveTouches === 2 + + if (isPinchGesture) { + cancelLongPressHandle() + + const initialDistance = getDistanceBetweenTouches(initialTouches) + const currentDistance = getDistanceBetweenTouches( + event.nativeEvent.touches, + ) + + let nextScale = (currentDistance / initialDistance) * currentScale + + /** + * In case image is scaling smaller than initial size -> + * slow down this transition by applying OUT_BOUND_MULTIPLIER + */ + if (nextScale < initialScale) { + nextScale = + nextScale + (initialScale - nextScale) * OUT_BOUND_MULTIPLIER + } + + /** + * In case image is scaling down -> move it in direction of initial position + */ + if (currentScale > initialScale && currentScale > nextScale) { + const k = (currentScale - initialScale) / (currentScale - nextScale) + + const nextTranslateX = + nextScale < initialScale + ? initialTranslate.x + : currentTranslate.x - + (currentTranslate.x - initialTranslate.x) / k + + const nextTranslateY = + nextScale < initialScale + ? initialTranslate.y + : currentTranslate.y - + (currentTranslate.y - initialTranslate.y) / k + + translateValue.x.setValue(nextTranslateX) + translateValue.y.setValue(nextTranslateY) + + tmpTranslate = {x: nextTranslateX, y: nextTranslateY} + } + + scaleValue.setValue(nextScale) + tmpScale = nextScale + } + + if (isTapGesture && currentScale > initialScale) { + const {x, y} = currentTranslate + // eslint-disable-next-line @typescript-eslint/no-shadow + const {dx, dy} = gestureState + const [topBound, leftBound, bottomBound, rightBound] = + getBounds(currentScale) + + let nextTranslateX = x + dx + let nextTranslateY = y + dy + + if (nextTranslateX > leftBound) { + nextTranslateX = + nextTranslateX - (nextTranslateX - leftBound) * OUT_BOUND_MULTIPLIER + } + + if (nextTranslateX < rightBound) { + nextTranslateX = + nextTranslateX - + (nextTranslateX - rightBound) * OUT_BOUND_MULTIPLIER + } + + if (nextTranslateY > topBound) { + nextTranslateY = + nextTranslateY - (nextTranslateY - topBound) * OUT_BOUND_MULTIPLIER + } + + if (nextTranslateY < bottomBound) { + nextTranslateY = + nextTranslateY - + (nextTranslateY - bottomBound) * OUT_BOUND_MULTIPLIER + } + + if (fitsScreenByWidth()) { + nextTranslateX = x + } + + if (fitsScreenByHeight()) { + nextTranslateY = y + } + + translateValue.x.setValue(nextTranslateX) + translateValue.y.setValue(nextTranslateY) + + tmpTranslate = {x: nextTranslateX, y: nextTranslateY} + } + }, + onRelease: () => { + cancelLongPressHandle() + + if (isDoubleTapPerformed) { + isDoubleTapPerformed = false + } + + if (tmpScale > 0) { + if (tmpScale < initialScale || tmpScale > SCALE_MAX) { + tmpScale = tmpScale < initialScale ? initialScale : SCALE_MAX + Animated.timing(scaleValue, { + toValue: tmpScale, + duration: 100, + useNativeDriver: true, + }).start() + } + + currentScale = tmpScale + tmpScale = 0 + } + + if (tmpTranslate) { + const {x, y} = tmpTranslate + const [topBound, leftBound, bottomBound, rightBound] = + getBounds(currentScale) + + let nextTranslateX = x + let nextTranslateY = y + + if (!fitsScreenByWidth()) { + if (nextTranslateX > leftBound) { + nextTranslateX = leftBound + } else if (nextTranslateX < rightBound) { + nextTranslateX = rightBound + } + } + + if (!fitsScreenByHeight()) { + if (nextTranslateY > topBound) { + nextTranslateY = topBound + } else if (nextTranslateY < bottomBound) { + nextTranslateY = bottomBound + } + } + + Animated.parallel([ + Animated.timing(translateValue.x, { + toValue: nextTranslateX, + duration: 100, + useNativeDriver: true, + }), + Animated.timing(translateValue.y, { + toValue: nextTranslateY, + duration: 100, + useNativeDriver: true, + }), + ]).start() + + currentTranslate = {x: nextTranslateX, y: nextTranslateY} + tmpTranslate = null + } + }, + } + + const panResponder = useMemo(() => createPanResponder(handlers), [handlers]) + + return [panResponder.panHandlers, scaleValue, translateValue] +} + +export default usePanResponder diff --git a/src/view/com/lightbox/ImageViewing/hooks/useRequestClose.ts b/src/view/com/lightbox/ImageViewing/hooks/useRequestClose.ts new file mode 100644 index 000000000..4cd03fe71 --- /dev/null +++ b/src/view/com/lightbox/ImageViewing/hooks/useRequestClose.ts @@ -0,0 +1,24 @@ +/** + * 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 {useState} from 'react' + +const useRequestClose = (onRequestClose: () => void) => { + const [opacity, setOpacity] = useState(1) + + return [ + opacity, + () => { + setOpacity(0) + onRequestClose() + setTimeout(() => setOpacity(1), 0) + }, + ] as const +} + +export default useRequestClose diff --git a/src/view/com/lightbox/ImageViewing/index.tsx b/src/view/com/lightbox/ImageViewing/index.tsx new file mode 100644 index 000000000..fdaafe737 --- /dev/null +++ b/src/view/com/lightbox/ImageViewing/index.tsx @@ -0,0 +1,183 @@ +/** + * 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. + * + */ +// 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, useRef, useEffect} from 'react' +import { + Animated, + Dimensions, + StyleSheet, + View, + VirtualizedList, + ModalProps, +} from 'react-native' +import {Modal} from '../../modals/Modal' + +import ImageItem from './components/ImageItem/ImageItem' +import ImageDefaultHeader from './components/ImageDefaultHeader' + +import useAnimatedComponents from './hooks/useAnimatedComponents' +import useImageIndexChange from './hooks/useImageIndexChange' +import useRequestClose from './hooks/useRequestClose' +import {ImageSource} from './@types' + +type Props = { + images: ImageSource[] + keyExtractor?: (imageSrc: ImageSource, index: number) => string + imageIndex: number + visible: boolean + onRequestClose: () => void + onLongPress?: (image: ImageSource) => void + onImageIndexChange?: (imageIndex: number) => void + presentationStyle?: ModalProps['presentationStyle'] + animationType?: ModalProps['animationType'] + backgroundColor?: string + swipeToCloseEnabled?: boolean + doubleTapToZoomEnabled?: boolean + delayLongPress?: number + HeaderComponent?: ComponentType<{imageIndex: number}> + FooterComponent?: ComponentType<{imageIndex: number}> +} + +const DEFAULT_BG_COLOR = '#000' +const DEFAULT_DELAY_LONG_PRESS = 800 +const SCREEN = Dimensions.get('screen') +const SCREEN_WIDTH = SCREEN.width + +function ImageViewing({ + images, + keyExtractor, + imageIndex, + visible, + onRequestClose, + onLongPress = () => {}, + onImageIndexChange, + backgroundColor = DEFAULT_BG_COLOR, + swipeToCloseEnabled, + doubleTapToZoomEnabled, + delayLongPress = DEFAULT_DELAY_LONG_PRESS, + HeaderComponent, + FooterComponent, +}: Props) { + const imageList = useRef<VirtualizedList<ImageSource>>(null) + const [opacity, onRequestCloseEnhanced] = useRequestClose(onRequestClose) + const [currentImageIndex, onScroll] = useImageIndexChange(imageIndex, SCREEN) + const [headerTransform, footerTransform, toggleBarsVisible] = + useAnimatedComponents() + + useEffect(() => { + if (onImageIndexChange) { + onImageIndexChange(currentImageIndex) + } + }, [currentImageIndex, onImageIndexChange]) + + const onZoom = useCallback( + (isScaled: boolean) => { + // @ts-ignore + imageList?.current?.setNativeProps({scrollEnabled: !isScaled}) + toggleBarsVisible(!isScaled) + }, + [toggleBarsVisible], + ) + + if (!visible) { + return null + } + + return ( + <View style={styles.screen}> + <Modal /> + <View style={[styles.container, {opacity, backgroundColor}]}> + <Animated.View style={[styles.header, {transform: headerTransform}]}> + {typeof HeaderComponent !== 'undefined' ? ( + React.createElement(HeaderComponent, { + imageIndex: currentImageIndex, + }) + ) : ( + <ImageDefaultHeader onRequestClose={onRequestCloseEnhanced} /> + )} + </Animated.View> + <VirtualizedList + ref={imageList} + data={images} + horizontal + pagingEnabled + windowSize={2} + initialNumToRender={1} + maxToRenderPerBatch={1} + showsHorizontalScrollIndicator={false} + showsVerticalScrollIndicator={false} + initialScrollIndex={imageIndex} + getItem={(_, index) => images[index]} + getItemCount={() => images.length} + getItemLayout={(_, index) => ({ + length: SCREEN_WIDTH, + offset: SCREEN_WIDTH * index, + index, + })} + renderItem={({item: imageSrc}) => ( + <ImageItem + onZoom={onZoom} + imageSrc={imageSrc} + onRequestClose={onRequestCloseEnhanced} + onLongPress={onLongPress} + delayLongPress={delayLongPress} + swipeToCloseEnabled={swipeToCloseEnabled} + doubleTapToZoomEnabled={doubleTapToZoomEnabled} + /> + )} + onMomentumScrollEnd={onScroll} + //@ts-ignore + keyExtractor={(imageSrc, index) => + keyExtractor + ? keyExtractor(imageSrc, index) + : typeof imageSrc === 'number' + ? `${imageSrc}` + : imageSrc.uri + } + /> + {typeof FooterComponent !== 'undefined' && ( + <Animated.View style={[styles.footer, {transform: footerTransform}]}> + {React.createElement(FooterComponent, { + imageIndex: currentImageIndex, + })} + </Animated.View> + )} + </View> + </View> + ) +} + +const styles = StyleSheet.create({ + screen: { + position: 'absolute', + }, + container: { + flex: 1, + backgroundColor: '#000', + }, + header: { + position: 'absolute', + width: '100%', + zIndex: 1, + top: 0, + }, + footer: { + position: 'absolute', + width: '100%', + zIndex: 1, + bottom: 0, + }, +}) + +const EnhancedImageViewing = (props: Props) => ( + <ImageViewing key={props.imageIndex} {...props} /> +) + +export default EnhancedImageViewing diff --git a/src/view/com/lightbox/ImageViewing/utils.ts b/src/view/com/lightbox/ImageViewing/utils.ts new file mode 100644 index 000000000..7fcdc84cf --- /dev/null +++ b/src/view/com/lightbox/ImageViewing/utils.ts @@ -0,0 +1,179 @@ +/** + * 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 { + Animated, + GestureResponderEvent, + PanResponder, + PanResponderGestureState, + PanResponderInstance, + NativeTouchEvent, +} from 'react-native' +import {Dimensions, Position} from './@types' + +type CacheStorageItem = {key: string; value: any} + +export 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}) + }, +}) + +export const splitArrayIntoBatches = (arr: any[], batchSize: number): any[] => + arr.reduce((result, item) => { + const batch = result.pop() || [] + + if (batch.length < batchSize) { + batch.push(item) + result.push(batch) + } else { + result.push(batch, [item]) + } + + return result + }, []) + +export const getImageTransform = ( + image: Dimensions | null, + screen: Dimensions, +) => { + if (!image?.width || !image?.height) { + return [] as const + } + + const wScale = screen.width / image.width + const hScale = screen.height / image.height + const scale = Math.min(wScale, hScale) + const {x, y} = getImageTranslate(image, screen) + + return [{x, y}, scale] as const +} + +export const getImageStyles = ( + image: Dimensions | null, + translate: Animated.ValueXY, + scale?: Animated.Value, +) => { + if (!image?.width || !image?.height) { + return {width: 0, height: 0} + } + + const transform = translate.getTranslateTransform() + + if (scale) { + transform.push({scale}, {perspective: new Animated.Value(1000)}) + } + + return { + width: image.width, + height: image.height, + transform, + } +} + +export const getImageTranslate = ( + image: Dimensions, + screen: Dimensions, +): Position => { + const getTranslateForAxis = (axis: 'x' | 'y'): number => { + const imageSize = axis === 'x' ? image.width : image.height + const screenSize = axis === 'x' ? screen.width : screen.height + + return (screenSize - imageSize) / 2 + } + + return { + x: getTranslateForAxis('x'), + y: getTranslateForAxis('y'), + } +} + +export const getImageDimensionsByTranslate = ( + translate: Position, + screen: Dimensions, +): Dimensions => ({ + width: screen.width - translate.x * 2, + height: screen.height - translate.y * 2, +}) + +export const getImageTranslateForScale = ( + currentTranslate: Position, + targetScale: number, + screen: Dimensions, +): Position => { + const {width, height} = getImageDimensionsByTranslate( + currentTranslate, + screen, + ) + + const targetImageDimensions = { + width: width * targetScale, + height: height * targetScale, + } + + return getImageTranslate(targetImageDimensions, screen) +} + +type HandlerType = ( + event: GestureResponderEvent, + state: PanResponderGestureState, +) => void + +type PanResponderProps = { + onGrant: HandlerType + onStart?: HandlerType + onMove: HandlerType + onRelease?: HandlerType + onTerminate?: HandlerType +} + +export const createPanResponder = ({ + onGrant, + onStart, + onMove, + onRelease, + onTerminate, +}: PanResponderProps): PanResponderInstance => + PanResponder.create({ + onStartShouldSetPanResponder: () => true, + onStartShouldSetPanResponderCapture: () => true, + onMoveShouldSetPanResponder: () => true, + onMoveShouldSetPanResponderCapture: () => true, + onPanResponderGrant: onGrant, + onPanResponderStart: onStart, + onPanResponderMove: onMove, + onPanResponderRelease: onRelease, + onPanResponderTerminate: onTerminate, + onPanResponderTerminationRequest: () => false, + onShouldBlockNativeResponder: () => false, + }) + +export const getDistanceBetweenTouches = ( + touches: NativeTouchEvent[], +): number => { + const [a, b] = touches + + if (a == null || b == null) { + return 0 + } + + return Math.sqrt( + Math.pow(a.pageX - b.pageX, 2) + Math.pow(a.pageY - b.pageY, 2), + ) +} diff --git a/src/view/com/lightbox/Lightbox.tsx b/src/view/com/lightbox/Lightbox.tsx index 3369c2770..c777a5528 100644 --- a/src/view/com/lightbox/Lightbox.tsx +++ b/src/view/com/lightbox/Lightbox.tsx @@ -1,20 +1,22 @@ import React from 'react' import {View} from 'react-native' import {observer} from 'mobx-react-lite' -import ImageView from 'react-native-image-viewing' +import ImageView from './ImageViewing' import {useStores} from '../../../state' - import * as models from '../../../state/models/shell-ui' +import {saveImageModal} from '../../../lib/images' export const Lightbox = observer(function Lightbox() { const store = useStores() + if (!store.shell.isLightboxActive) { + return null + } + const onClose = () => { - console.log('hit') store.shell.closeLightbox() } - - if (!store.shell.isLightboxActive) { - return <View /> + const onLongPress = ({uri}: {uri: string}) => { + saveImageModal({uri}) } if (store.shell.activeLightbox?.name === 'profile-image') { @@ -35,6 +37,7 @@ export const Lightbox = observer(function Lightbox() { imageIndex={opts.index} visible onRequestClose={onClose} + onLongPress={onLongPress} /> ) } else { diff --git a/src/view/com/util/PostEmbeds.tsx b/src/view/com/util/PostEmbeds.tsx index e3fca2538..1d8df038b 100644 --- a/src/view/com/util/PostEmbeds.tsx +++ b/src/view/com/util/PostEmbeds.tsx @@ -10,6 +10,7 @@ import {ImagesLightbox} from '../../../state/models/shell-ui' import {useStores} from '../../../state' import {usePalette} from '../../lib/hooks/usePalette' import {gradients} from '../../lib/styles' +import {saveImageModal} from '../../../lib/images' type Embed = | AppBskyEmbedImages.Presented @@ -31,6 +32,10 @@ export function PostEmbeds({ const openLightbox = (index: number) => { store.shell.openLightbox(new ImagesLightbox(uris, index)) } + const onLongPress = (index: number) => { + saveImageModal({uri: uris[index]}) + } + if (embed.images.length === 4) { return ( <View style={[styles.imagesContainer, style]}> @@ -38,6 +43,7 @@ export function PostEmbeds({ type="four" uris={embed.images.map(img => img.thumb)} onPress={openLightbox} + onLongPress={onLongPress} /> </View> ) @@ -48,6 +54,7 @@ export function PostEmbeds({ type="three" uris={embed.images.map(img => img.thumb)} onPress={openLightbox} + onLongPress={onLongPress} /> </View> ) @@ -58,6 +65,7 @@ export function PostEmbeds({ type="two" uris={embed.images.map(img => img.thumb)} onPress={openLightbox} + onLongPress={onLongPress} /> </View> ) @@ -67,6 +75,7 @@ export function PostEmbeds({ <AutoSizedImage uri={embed.images[0].thumb} onPress={() => openLightbox(0)} + onLongPress={() => onLongPress(0)} containerStyle={styles.singleImage} /> </View> diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx index 648bb957f..cedd3bc90 100644 --- a/src/view/com/util/images/AutoSizedImage.tsx +++ b/src/view/com/util/images/AutoSizedImage.tsx @@ -5,13 +5,14 @@ import { LayoutChangeEvent, StyleProp, StyleSheet, - TouchableWithoutFeedback, + TouchableOpacity, View, ViewStyle, } from 'react-native' import {Text} from '../text/Text' import {useTheme} from '../../../lib/ThemeContext' import {usePalette} from '../../../lib/hooks/usePalette' +import {DELAY_PRESS_IN} from './constants' const MAX_HEIGHT = 300 @@ -23,6 +24,7 @@ interface Dim { export function AutoSizedImage({ uri, onPress, + onLongPress, style, containerStyle, }: { @@ -80,7 +82,10 @@ export function AutoSizedImage({ return ( <View style={style}> - <TouchableWithoutFeedback onPress={onPress}> + <TouchableOpacity + onPress={onPress} + onLongPress={onLongPress} + delayPressIn={DELAY_PRESS_IN}> {error ? ( <View style={[styles.errorContainer, errPal.view, containerStyle]}> <Text style={errPal.text}>{error}</Text> @@ -99,7 +104,7 @@ export function AutoSizedImage({ onLayout={onLayout} /> )} - </TouchableWithoutFeedback> + </TouchableOpacity> </View> ) } diff --git a/src/view/com/util/images/ImageLayoutGrid.tsx b/src/view/com/util/images/ImageLayoutGrid.tsx index 8acab7109..dd0ea3775 100644 --- a/src/view/com/util/images/ImageLayoutGrid.tsx +++ b/src/view/com/util/images/ImageLayoutGrid.tsx @@ -5,14 +5,15 @@ import { LayoutChangeEvent, StyleProp, StyleSheet, - TouchableWithoutFeedback, + TouchableOpacity, View, ViewStyle, } from 'react-native' +import {DELAY_PRESS_IN} from './constants' interface Dim { width: number - height: number + height: numberPressIn } export type ImageLayoutGridType = 'two' | 'three' | 'four' @@ -21,6 +22,7 @@ export function ImageLayoutGrid({ type, uris, onPress, + onLongPress, style, }: { type: ImageLayoutGridType @@ -44,6 +46,7 @@ export function ImageLayoutGrid({ type={type} uris={uris} onPress={onPress} + onLongPress={onLongPress} containerInfo={containerInfo} /> ) : undefined} @@ -55,6 +58,7 @@ function ImageLayoutGridInner({ type, uris, onPress, + onLongPress, containerInfo, }: { type: ImageLayoutGridType @@ -84,31 +88,46 @@ function ImageLayoutGridInner({ if (type === 'two') { return ( <View style={styles.flexRow}> - <TouchableWithoutFeedback onPress={() => onPress?.(0)}> + <TouchableOpacity + delayPressIn={DELAY_PRESS_IN} + onPress={() => onPress?.(0)} + onLongPress={() => onLongPress(0)}> <Image source={{uri: uris[0]}} style={size1} /> - </TouchableWithoutFeedback> + </TouchableOpacity> <View style={styles.wSpace} /> - <TouchableWithoutFeedback onPress={() => onPress?.(1)}> + <TouchableOpacity + delayPressIn={DELAY_PRESS_IN} + onPress={() => onPress?.(1)} + onLongPress={() => onLongPress(1)}> <Image source={{uri: uris[1]}} style={size1} /> - </TouchableWithoutFeedback> + </TouchableOpacity> </View> ) } if (type === 'three') { return ( <View style={styles.flexRow}> - <TouchableWithoutFeedback onPress={() => onPress?.(0)}> + <TouchableOpacity + delayPressIn={DELAY_PRESS_IN} + onPress={() => onPress?.(0)} + onLongPress={() => onLongPress(0)}> <Image source={{uri: uris[0]}} style={size2} /> - </TouchableWithoutFeedback> + </TouchableOpacity> <View style={styles.wSpace} /> <View> - <TouchableWithoutFeedback onPress={() => onPress?.(1)}> + <TouchableOpacity + delayPressIn={DELAY_PRESS_IN} + onPress={() => onPress?.(1)} + onLongPress={() => onLongPress(1)}> <Image source={{uri: uris[1]}} style={size1} /> - </TouchableWithoutFeedback> + </TouchableOpacity> <View style={styles.hSpace} /> - <TouchableWithoutFeedback onPress={() => onPress?.(2)}> + <TouchableOpacity + delayPressIn={DELAY_PRESS_IN} + onPress={() => onPress?.(2)} + onLongPress={() => onLongPress(2)}> <Image source={{uri: uris[2]}} style={size1} /> - </TouchableWithoutFeedback> + </TouchableOpacity> </View> </View> ) @@ -117,23 +136,35 @@ function ImageLayoutGridInner({ return ( <View style={styles.flexRow}> <View> - <TouchableWithoutFeedback onPress={() => onPress?.(0)}> + <TouchableOpacity + delayPressIn={DELAY_PRESS_IN} + onPress={() => onPress?.(0)} + onLongPress={() => onLongPress(0)}> <Image source={{uri: uris[0]}} style={size1} /> - </TouchableWithoutFeedback> + </TouchableOpacity> <View style={styles.hSpace} /> - <TouchableWithoutFeedback onPress={() => onPress?.(1)}> + <TouchableOpacity + delayPressIn={DELAY_PRESS_IN} + onPress={() => onPress?.(1)} + onLongPress={() => onLongPress(1)}> <Image source={{uri: uris[1]}} style={size1} /> - </TouchableWithoutFeedback> + </TouchableOpacity> </View> <View style={styles.wSpace} /> <View> - <TouchableWithoutFeedback onPress={() => onPress?.(2)}> + <TouchableOpacity + delayPressIn={DELAY_PRESS_IN} + onPress={() => onPress?.(2)} + onLongPress={() => onLongPress(2)}> <Image source={{uri: uris[2]}} style={size1} /> - </TouchableWithoutFeedback> + </TouchableOpacity> <View style={styles.hSpace} /> - <TouchableWithoutFeedback onPress={() => onPress?.(3)}> + <TouchableOpacity + delayPressIn={DELAY_PRESS_IN} + onPress={() => onPress?.(3)} + onLongPress={() => onLongPress(3)}> <Image source={{uri: uris[3]}} style={size1} /> - </TouchableWithoutFeedback> + </TouchableOpacity> </View> </View> ) diff --git a/src/view/com/util/images/constants.ts b/src/view/com/util/images/constants.ts new file mode 100644 index 000000000..cb2c26cea --- /dev/null +++ b/src/view/com/util/images/constants.ts @@ -0,0 +1 @@ +export const DELAY_PRESS_IN = 500 diff --git a/yarn.lock b/yarn.lock index 27b2f1f19..4df115ef0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2136,10 +2136,10 @@ dependencies: merge-options "^3.0.4" -"@react-native-camera-roll/camera-roll@^5.1.0": - version "5.2.0" - resolved "https://registry.yarnpkg.com/@react-native-camera-roll/camera-roll/-/camera-roll-5.2.0.tgz#a30dca7c486379650c03fb8cc6fe35b7de6eeb82" - integrity sha512-CIFkEqWeMtFo3fG/0nULrmLs8xikbOUuEty8wWxpyBWq7OM9Hi13pXJ1FWrIrxDcFuL7d0bxIqpqNrt59lAPrQ== +"@react-native-camera-roll/camera-roll@^5.2.2": + version "5.2.2" + resolved "https://registry.yarnpkg.com/@react-native-camera-roll/camera-roll/-/camera-roll-5.2.2.tgz#dbdfa4ffb126b4d7efa01f3c5fc030ce3bfcdf2d" + integrity sha512-LVzUX1KdKvOXJGiV/9tlkDyDSOEjvAzuiV8OkSUD13TXN/Tk5u2KVHTYRYJz5pmXanLN2dmEamctJcqKCeXYxg== "@react-native-clipboard/clipboard@^1.10.0": version "1.11.1" @@ -11225,11 +11225,6 @@ react-native-image-crop-picker@^0.38.1: resolved "https://registry.yarnpkg.com/react-native-image-crop-picker/-/react-native-image-crop-picker-0.38.1.tgz#5973b4a8b55835b987e6be2064de411e849ac005" integrity sha512-cF5UQnWplzHCeiCO+aiGS/0VomWaLmFf3nSsgTMPfY+8+99h8N/eHQvVdSF7RsGw50B8394wGeGyqHjjp8YRWw== -react-native-image-viewing@^0.2.2: - version "0.2.2" - resolved "https://registry.yarnpkg.com/react-native-image-viewing/-/react-native-image-viewing-0.2.2.tgz#fb26e57d7d3d9ce4559a3af3d244387c0367242b" - integrity sha512-osWieG+p/d2NPbAyonOMubttajtYEYiRGQaJA54slFxZ69j1V4/dCmcrVQry47ktVKy8/qpFwCpW1eT6MH5T2Q== - react-native-inappbrowser-reborn@^3.6.3: version "3.7.0" resolved "https://registry.yarnpkg.com/react-native-inappbrowser-reborn/-/react-native-inappbrowser-reborn-3.7.0.tgz#849a43c3c7da22b65147649fe596836bcb494083" |