about summary refs log tree commit diff
diff options
context:
space:
mode:
authorAryan Goharzad <arrygoo@gmail.com>2023-01-25 18:25:34 -0500
committerGitHub <noreply@github.com>2023-01-25 17:25:34 -0600
commiteb33c3fa812cc087db14a6b6ba743e982b26c462 (patch)
treed098f7a804c67755f39e95bbbfd56887bacf476c
parentadf328b50ce98c5ebd3282fe897ddfdcd0de8011 (diff)
downloadvoidsky-eb33c3fa812cc087db14a6b6ba743e982b26c462.tar.zst
Saves image on long press (#83)
* Saves image on long press

* Adds save on long press

* Forking lightbox

* move to wrapper only to the bottom sheet to reduce impact of this change

* lint

* lint

* lint

* Use official `share` API

* Clean up cache after download

* comment

* comment

* Reduce swipe close velocity

* Updates per feedback

* lint

* bugfix

* Adds delayed press-in for TouchableOpacity
-rw-r--r--ios/Podfile.lock10
-rw-r--r--package.json3
-rw-r--r--src/lib/images.ts22
-rw-r--r--src/view/com/lightbox/ImageViewing/@types/index.ts21
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx52
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx152
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx152
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageItem/ImageLoading.tsx37
-rw-r--r--src/view/com/lightbox/ImageViewing/hooks/useAnimatedComponents.ts47
-rw-r--r--src/view/com/lightbox/ImageViewing/hooks/useDoubleTapToZoom.ts65
-rw-r--r--src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts88
-rw-r--r--src/view/com/lightbox/ImageViewing/hooks/useImageIndexChange.ts32
-rw-r--r--src/view/com/lightbox/ImageViewing/hooks/useImagePrefetch.ts25
-rw-r--r--src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts400
-rw-r--r--src/view/com/lightbox/ImageViewing/hooks/useRequestClose.ts24
-rw-r--r--src/view/com/lightbox/ImageViewing/index.tsx183
-rw-r--r--src/view/com/lightbox/ImageViewing/utils.ts179
-rw-r--r--src/view/com/lightbox/Lightbox.tsx15
-rw-r--r--src/view/com/util/PostEmbeds.tsx9
-rw-r--r--src/view/com/util/images/AutoSizedImage.tsx11
-rw-r--r--src/view/com/util/images/ImageLayoutGrid.tsx71
-rw-r--r--src/view/com/util/images/constants.ts1
-rw-r--r--yarn.lock13
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"