Skip to content

Commit

Permalink
Saves image on long press (bluesky-social#83)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
arrygoo authored Jan 25, 2023
1 parent adf328b commit eb33c3f
Show file tree
Hide file tree
Showing 23 changed files with 1,567 additions and 45 deletions.
10 changes: 5 additions & 5 deletions ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
22 changes: 22 additions & 0 deletions src/lib/images.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
}
21 changes: 21 additions & 0 deletions src/view/com/lightbox/ImageViewing/@types/index.ts
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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)
Loading

0 comments on commit eb33c3f

Please sign in to comment.