diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index c85b743136..7f0ac03688 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 cb30b57114..2838905ff1 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 9fc1cbc341..8d5eaded0f 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 0000000000..4a08e23940
--- /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 0000000000..6880008e4a
--- /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) => (
+
+
+ ✕
+
+
+)
+
+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 0000000000..01a53ff6f3
--- /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(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) => {
+ 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) => {
+ const offsetY = nativeEvent?.contentOffset?.y ?? 0
+
+ scrollValueY.setValue(offsetY)
+ }
+
+ return (
+
+
+ {(!isLoaded || !imageDimensions) && }
+
+ )
+}
+
+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 0000000000..12d37e283a
--- /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(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) => {
+ 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) => {
+ const offsetY = nativeEvent?.contentOffset?.y ?? 0
+
+ if (nativeEvent?.zoomScale > 1) {
+ return
+ }
+
+ scrollValueY.setValue(offsetY)
+ }
+
+ const onLongPressHandler = useCallback(() => {
+ onLongPress(imageSrc)
+ }, [imageSrc, onLongPress])
+
+ return (
+
+
+ {(!loaded || !imageDimensions) && }
+
+ setLoaded(true)}
+ />
+
+
+
+ )
+}
+
+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 0000000000..9667fcaa7b
--- /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 = () => (
+
+
+
+)
+
+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 0000000000..c21cd7f2c1
--- /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 0000000000..92746e951a
--- /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,
+ scaled: boolean,
+ screen: Dimensions,
+) {
+ const handleDoubleTap = useCallback(
+ (event: NativeSyntheticEvent) => {
+ 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 0000000000..bab136c506
--- /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(null)
+
+ // eslint-disable-next-line @typescript-eslint/no-shadow
+ const getImageDimensions = (image: ImageSource): Promise => {
+ 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 0000000000..16430f3aaa
--- /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) => {
+ 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 0000000000..3969945bb5
--- /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 0000000000..4600cf1a8d
--- /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 0000000000..4cd03fe71b
--- /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 0000000000..fdaafe737b
--- /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>(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 (
+
+
+
+
+ {typeof HeaderComponent !== 'undefined' ? (
+ React.createElement(HeaderComponent, {
+ imageIndex: currentImageIndex,
+ })
+ ) : (
+
+ )}
+
+ images[index]}
+ getItemCount={() => images.length}
+ getItemLayout={(_, index) => ({
+ length: SCREEN_WIDTH,
+ offset: SCREEN_WIDTH * index,
+ index,
+ })}
+ renderItem={({item: imageSrc}) => (
+
+ )}
+ onMomentumScrollEnd={onScroll}
+ //@ts-ignore
+ keyExtractor={(imageSrc, index) =>
+ keyExtractor
+ ? keyExtractor(imageSrc, index)
+ : typeof imageSrc === 'number'
+ ? `${imageSrc}`
+ : imageSrc.uri
+ }
+ />
+ {typeof FooterComponent !== 'undefined' && (
+
+ {React.createElement(FooterComponent, {
+ imageIndex: currentImageIndex,
+ })}
+
+ )}
+
+
+ )
+}
+
+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) => (
+
+)
+
+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 0000000000..7fcdc84cf0
--- /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 3369c27708..c777a55283 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()
- const onClose = () => {
- console.log('hit')
- store.shell.closeLightbox()
+ if (!store.shell.isLightboxActive) {
+ return null
}
- if (!store.shell.isLightboxActive) {
- return
+ const onClose = () => {
+ store.shell.closeLightbox()
+ }
+ 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 e3fca2538c..1d8df038be 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 (
@@ -38,6 +43,7 @@ export function PostEmbeds({
type="four"
uris={embed.images.map(img => img.thumb)}
onPress={openLightbox}
+ onLongPress={onLongPress}
/>
)
@@ -48,6 +54,7 @@ export function PostEmbeds({
type="three"
uris={embed.images.map(img => img.thumb)}
onPress={openLightbox}
+ onLongPress={onLongPress}
/>
)
@@ -58,6 +65,7 @@ export function PostEmbeds({
type="two"
uris={embed.images.map(img => img.thumb)}
onPress={openLightbox}
+ onLongPress={onLongPress}
/>
)
@@ -67,6 +75,7 @@ export function PostEmbeds({
openLightbox(0)}
+ onLongPress={() => onLongPress(0)}
containerStyle={styles.singleImage}
/>
diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx
index 648bb957fe..cedd3bc902 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 (
-
+
{error ? (
{error}
@@ -99,7 +104,7 @@ export function AutoSizedImage({
onLayout={onLayout}
/>
)}
-
+
)
}
diff --git a/src/view/com/util/images/ImageLayoutGrid.tsx b/src/view/com/util/images/ImageLayoutGrid.tsx
index 8acab71098..dd0ea37754 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 (
- onPress?.(0)}>
+ onPress?.(0)}
+ onLongPress={() => onLongPress(0)}>
-
+
- onPress?.(1)}>
+ onPress?.(1)}
+ onLongPress={() => onLongPress(1)}>
-
+
)
}
if (type === 'three') {
return (
- onPress?.(0)}>
+ onPress?.(0)}
+ onLongPress={() => onLongPress(0)}>
-
+
- onPress?.(1)}>
+ onPress?.(1)}
+ onLongPress={() => onLongPress(1)}>
-
+
- onPress?.(2)}>
+ onPress?.(2)}
+ onLongPress={() => onLongPress(2)}>
-
+
)
@@ -117,23 +136,35 @@ function ImageLayoutGridInner({
return (
- onPress?.(0)}>
+ onPress?.(0)}
+ onLongPress={() => onLongPress(0)}>
-
+
- onPress?.(1)}>
+ onPress?.(1)}
+ onLongPress={() => onLongPress(1)}>
-
+
- onPress?.(2)}>
+ onPress?.(2)}
+ onLongPress={() => onLongPress(2)}>
-
+
- onPress?.(3)}>
+ onPress?.(3)}
+ onLongPress={() => onLongPress(3)}>
-
+
)
diff --git a/src/view/com/util/images/constants.ts b/src/view/com/util/images/constants.ts
new file mode 100644
index 0000000000..cb2c26cea3
--- /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 27b2f1f190..4df115ef0f 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"