1
0
mirror of https://github.com/tooot-app/app synced 2025-04-23 22:57:23 +02:00
This commit is contained in:
Zhiyuan Zheng 2021-02-14 00:27:21 +01:00
parent a9326c381a
commit 13efb56324
No known key found for this signature in database
GPG Key ID: 078A93AB607D85E0
7 changed files with 214 additions and 203 deletions

View File

@ -51,6 +51,8 @@ declare namespace Nav {
'Screen-ImagesViewer': { 'Screen-ImagesViewer': {
imageUrls: { imageUrls: {
url: Mastodon.AttachmentImage['url'] url: Mastodon.AttachmentImage['url']
width?: number
height?: number
preview_url: Mastodon.AttachmentImage['preview_url'] preview_url: Mastodon.AttachmentImage['preview_url']
remote_url?: Mastodon.AttachmentImage['remote_url'] remote_url?: Mastodon.AttachmentImage['remote_url']
imageIndex: number imageIndex: number

View File

@ -1,12 +1,17 @@
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import React, { useCallback, useMemo, useState } from 'react' import { useTheme } from '@utils/styles/ThemeManager'
import React, { useCallback, useMemo, useRef, useState } from 'react'
import { Pressable, StyleProp, StyleSheet, ViewStyle } from 'react-native' import { Pressable, StyleProp, StyleSheet, ViewStyle } from 'react-native'
import { Blurhash } from 'react-native-blurhash' import { Blurhash } from 'react-native-blurhash'
import FastImage, { ImageStyle } from 'react-native-fast-image' import FastImage, { ImageStyle } from 'react-native-fast-image'
import { useTheme } from '@utils/styles/ThemeManager'
// blurhas -> if blurhash, show before any loading succeed
// original -> load original
// original, remote -> if original failed, then remote
// preview, original -> first show preview, then original
// preview, original, remote -> first show preview, then original, if original failed, then remote
export interface Props { export interface Props {
sharedElement?: string
hidden?: boolean hidden?: boolean
uri: { preview?: string; original: string; remote?: string } uri: { preview?: string; original: string; remote?: string }
blurhash?: string blurhash?: string
@ -14,115 +19,113 @@ export interface Props {
onPress?: () => void onPress?: () => void
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
imageStyle?: StyleProp<ImageStyle> imageStyle?: StyleProp<ImageStyle>
// For image viewer when there is no image size available
setImageDimensions?: React.Dispatch<
React.SetStateAction<{
width: number
height: number
}>
>
} }
const GracefullyImage = React.memo( const GracefullyImage = React.memo(
({ ({
sharedElement,
hidden = false, hidden = false,
uri, uri,
blurhash, blurhash,
dimension, dimension,
onPress, onPress,
style, style,
imageStyle imageStyle,
setImageDimensions
}: Props) => { }: Props) => {
const { mode, theme } = useTheme() const { theme } = useTheme()
const [previewLoaded, setPreviewLoaded] = useState( const originalFailed = useRef(false)
uri.preview ? false : true const [imageLoaded, setImageLoaded] = useState(false)
const source = useMemo(() => {
if (originalFailed.current) {
return { uri: uri.remote || undefined }
} else {
return { uri: uri.original }
}
}, [originalFailed.current])
const onLoad = useCallback(
({ nativeEvent }) => {
setImageLoaded(true)
setImageDimensions &&
setImageDimensions({
width: nativeEvent.width,
height: nativeEvent.height
})
},
[source.uri]
) )
const [originalLoaded, setOriginalLoaded] = useState(false)
const [originalFailed, setOriginalFailed] = useState(false)
const [remoteLoaded, setRemoteLoaded] = useState(uri.remote ? false : true)
const sourceUri = useMemo(() => {
if (previewLoaded) {
if (originalFailed) {
return uri.remote
} else {
return uri.original
}
} else {
return uri.preview
}
}, [previewLoaded, originalLoaded, originalFailed, remoteLoaded])
const onLoad = useCallback(() => {
if (previewLoaded) {
if (originalFailed) {
return setRemoteLoaded(true)
} else {
return setOriginalLoaded(true)
}
} else {
return setPreviewLoaded(true)
}
}, [previewLoaded, originalLoaded, originalFailed, remoteLoaded])
const onError = useCallback(() => { const onError = useCallback(() => {
if (previewLoaded) { if (!originalFailed.current) {
if (originalFailed) { originalFailed.current = true
return
} else {
return setOriginalFailed(true)
}
} else {
return
} }
}, [previewLoaded, originalLoaded, originalFailed, remoteLoaded]) }, [originalFailed.current])
const children = useCallback(() => { const previewView = useMemo(
return ( () =>
<> uri.preview && !imageLoaded ? (
<FastImage <FastImage
source={{ uri: sourceUri }} source={{ uri: uri.preview }}
style={[styles.image, imageStyle]} style={[{ flex: 1 }, imageStyle]}
onLoad={onLoad}
onError={onError}
/> />
{blurhash && ) : null,
(hidden || !(previewLoaded || originalLoaded || remoteLoaded)) ? ( [imageLoaded]
<Blurhash )
decodeAsync const originalView = useMemo(
blurhash={blurhash} () => (
style={{ <FastImage
width: '100%', source={source}
height: '100%', style={[{ flex: imageLoaded ? 1 : undefined }, imageStyle]}
position: 'absolute', onLoad={onLoad}
top: StyleConstants.Spacing.XS / 2, onError={onError}
left: StyleConstants.Spacing.XS / 2 />
}} ),
/> [source, imageLoaded]
) : null} )
</> const blurhashView = useMemo(() => {
) return blurhash && (hidden || !imageLoaded) ? (
}, [hidden, previewLoaded, originalLoaded, remoteLoaded, mode, uri]) <Blurhash decodeAsync blurhash={blurhash} style={styles.blurhash} />
) : null
}, [hidden, imageLoaded])
return ( return (
<Pressable <Pressable
children={children} style={[style, dimension, { backgroundColor: theme.shimmerDefault }]}
style={[
style,
{ backgroundColor: theme.shimmerDefault },
dimension && { ...dimension }
]}
{...(onPress {...(onPress
? hidden ? hidden
? { disabled: true } ? { disabled: true }
: { onPress } : { onPress }
: { disabled: true })} : { disabled: true })}
/> >
{previewView}
{originalView}
{blurhashView}
</Pressable>
) )
}, },
(prev, next) => { (prev, next) => {
let skipUpdate = true let skipUpdate = true
skipUpdate = prev.hidden === next.hidden skipUpdate = prev.hidden === next.hidden
skipUpdate = prev.uri.preview === next.uri.preview
skipUpdate = prev.uri.original === next.uri.original skipUpdate = prev.uri.original === next.uri.original
skipUpdate = prev.uri.remote === next.uri.remote
return false return false
} }
) )
const styles = StyleSheet.create({ const styles = StyleSheet.create({
image: { blurhash: {
flex: 1 width: '100%',
height: '100%',
position: 'absolute',
top: StyleConstants.Spacing.XS / 2,
left: StyleConstants.Spacing.XS / 2
} }
}) })

View File

@ -32,11 +32,7 @@ const TimelineAttachment: React.FC<Props> = ({ status }) => {
haptics('Light') haptics('Light')
}, []) }, [])
let imageUrls: (App.IImageInfo & { let imageUrls: Nav.RootStackParamList['Screen-ImagesViewer']['imageUrls'] = []
preview_url: Mastodon.AttachmentImage['preview_url']
remote_url?: Mastodon.AttachmentImage['remote_url']
imageIndex: number
})[] = []
const navigation = useNavigation() const navigation = useNavigation()
const navigateToImagesViewer = (imageIndex: number) => const navigateToImagesViewer = (imageIndex: number) =>
navigation.navigate('Screen-ImagesViewer', { navigation.navigate('Screen-ImagesViewer', {
@ -52,6 +48,8 @@ const TimelineAttachment: React.FC<Props> = ({ status }) => {
url: attachment.url, url: attachment.url,
preview_url: attachment.preview_url, preview_url: attachment.preview_url,
remote_url: attachment.remote_url, remote_url: attachment.remote_url,
width: attachment.meta?.original?.width,
height: attachment.meta?.original?.height,
imageIndex: index imageIndex: index
}) })
return ( return (

View File

@ -28,12 +28,7 @@ const AttachmentImage: React.FC<Props> = ({
return ( return (
<GracefullyImage <GracefullyImage
hidden={sensitiveShown} hidden={sensitiveShown}
uri={{ uri={{ original: image.preview_url, remote: image.remote_url }}
preview: image.preview_url,
original: image.url,
remote: image.remote_url
}}
sharedElement={image.url}
blurhash={image.blurhash} blurhash={image.blurhash}
onPress={onPress} onPress={onPress}
style={[ style={[

View File

@ -1,10 +1,10 @@
import React, { useCallback } from 'react'
import { StyleConstants } from '@utils/styles/constants'
import { useNavigation } from '@react-navigation/native'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import GracefullyImage from '@components/GracefullyImage'
import { StackNavigationProp } from '@react-navigation/stack'
import analytics from '@components/analytics' import analytics from '@components/analytics'
import GracefullyImage from '@components/GracefullyImage'
import { useNavigation } from '@react-navigation/native'
import { StackNavigationProp } from '@react-navigation/stack'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { StyleConstants } from '@utils/styles/constants'
import React, { useCallback } from 'react'
export interface Props { export interface Props {
queryKey?: QueryKeyTimeline queryKey?: QueryKeyTimeline

@ -1 +1 @@
Subproject commit c93d6462d598a7b65086f58d40b6b5589be2eefe Subproject commit c8af057d3cc4d6757fd377f97d18f8df33fa1d61

View File

@ -1,6 +1,6 @@
import analytics from '@components/analytics' import analytics from '@components/analytics'
import haptics from '@components/haptics'
import { HeaderCenter, HeaderLeft, HeaderRight } from '@components/Header' import { HeaderCenter, HeaderLeft, HeaderRight } from '@components/Header'
import { toast } from '@components/toast'
import { useActionSheet } from '@expo/react-native-action-sheet' import { useActionSheet } from '@expo/react-native-action-sheet'
import CameraRoll from '@react-native-community/cameraroll' import CameraRoll from '@react-native-community/cameraroll'
import { StackScreenProps } from '@react-navigation/stack' import { StackScreenProps } from '@react-navigation/stack'
@ -13,10 +13,119 @@ import {
Platform, Platform,
Share, Share,
StatusBar, StatusBar,
StyleSheet,
View View
} from 'react-native' } from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context' import {
SafeAreaProvider,
useSafeAreaInsets
} from 'react-native-safe-area-context'
const HeaderComponent = React.memo(
({
navigation,
currentIndex,
imageUrls
}: {
navigation: ScreenImagesViewerProp['navigation']
currentIndex: number
imageUrls: {
url: string
width?: number | undefined
height?: number | undefined
preview_url: string
remote_url?: string | undefined
imageIndex: number
}[]
}) => {
const insets = useSafeAreaInsets()
const { t } = useTranslation('screenImageViewer')
const { showActionSheetWithOptions } = useActionSheet()
const hasAndroidPermission = async () => {
const permission = PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE
const hasPermission = await PermissionsAndroid.check(permission)
if (hasPermission) {
return true
}
const status = await PermissionsAndroid.request(permission)
return status === 'granted'
}
const saveImage = async () => {
if (Platform.OS === 'android' && !(await hasAndroidPermission())) {
return
}
CameraRoll.save(
imageUrls[currentIndex].url ||
imageUrls[currentIndex].remote_url ||
imageUrls[currentIndex].preview_url
)
.then(() => haptics('Success'))
.catch(() => haptics('Error'))
}
const onPress = useCallback(() => {
analytics('imageviewer_more_press')
showActionSheetWithOptions(
{
options: [
t('content.options.save'),
t('content.options.share'),
t('content.options.cancel')
],
cancelButtonIndex: 2
},
async buttonIndex => {
switch (buttonIndex) {
case 0:
analytics('imageviewer_more_save_press')
saveImage()
break
case 1:
analytics('imageviewer_more_share_press')
switch (Platform.OS) {
case 'ios':
return Share.share({ url: imageUrls[currentIndex].url })
case 'android':
return Share.share({ message: imageUrls[currentIndex].url })
}
break
}
}
)
}, [currentIndex])
return (
<View
style={{
flex: 1,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: insets.top
}}
>
<HeaderLeft
content='X'
native={false}
onPress={() => navigation.goBack()}
/>
<HeaderCenter
inverted
content={`${currentIndex + 1} / ${imageUrls.length}`}
/>
<HeaderRight
content='MoreHorizontal'
native={false}
onPress={onPress}
/>
</View>
)
},
(prev, next) => prev.currentIndex === next.currentIndex
)
export type ScreenImagesViewerProp = StackScreenProps< export type ScreenImagesViewerProp = StackScreenProps<
Nav.RootStackParamList, Nav.RootStackParamList,
@ -37,120 +146,24 @@ const ScreenImagesViewer = ({
findIndex(imageUrls, ['imageIndex', imageIndex]) findIndex(imageUrls, ['imageIndex', imageIndex])
) )
const hasAndroidPermission = async () => {
const permission = PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE
const hasPermission = await PermissionsAndroid.check(permission)
if (hasPermission) {
return true
}
const status = await PermissionsAndroid.request(permission)
return status === 'granted'
}
const saveImage = async () => {
if (Platform.OS === 'android' && !(await hasAndroidPermission())) {
return
}
CameraRoll.save(
imageUrls[imageIndex].url ||
imageUrls[imageIndex].remote_url ||
imageUrls[imageIndex].preview_url
)
.then(() =>
toast({ type: 'success', message: t('content.save.success') })
)
.catch(() =>
toast({
type: 'error',
message: t('common:toastMessage.error.message', {
function: t('content.save.function')
})
})
)
}
const { t } = useTranslation('screenImageViewer')
const { showActionSheetWithOptions } = useActionSheet()
const onPress = useCallback(() => {
analytics('imageviewer_more_press')
showActionSheetWithOptions(
{
options: [
t('content.options.save'),
t('content.options.share'),
t('content.options.cancel')
],
cancelButtonIndex: 2
},
async buttonIndex => {
switch (buttonIndex) {
case 0:
analytics('imageviewer_more_save_press')
saveImage()
break
case 1:
analytics('imageviewer_more_share_press')
switch (Platform.OS) {
case 'ios':
return Share.share({ url: imageUrls[currentIndex].url })
case 'android':
return Share.share({ message: imageUrls[currentIndex].url })
}
break
}
}
)
}, [currentIndex])
const HeaderComponent = useCallback(
() => (
<View
style={{
flex: 1,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center'
}}
>
<HeaderLeft
content='X'
native={false}
onPress={() => navigation.goBack()}
/>
<HeaderCenter
inverted
content={`${currentIndex + 1} / ${imageUrls.length}`}
/>
<HeaderRight
content='MoreHorizontal'
native={false}
onPress={onPress}
/>
</View>
),
[currentIndex]
)
return ( return (
<SafeAreaView style={styles.base} edges={['top']}> <SafeAreaProvider>
<StatusBar backgroundColor='rgb(0,0,0)' /> <StatusBar backgroundColor='rgb(0,0,0)' />
<ImageView <ImageView
images={imageUrls.map(urls => ({ uri: urls.url }))} images={imageUrls}
imageIndex={imageIndex} imageIndex={imageIndex}
onImageIndexChange={index => setCurrentIndex(index)} onImageIndexChange={index => setCurrentIndex(index)}
onRequestClose={() => navigation.goBack()} onRequestClose={() => navigation.goBack()}
HeaderComponent={HeaderComponent} HeaderComponent={() => (
<HeaderComponent
navigation={navigation}
currentIndex={currentIndex}
imageUrls={imageUrls}
/>
)}
/> />
</SafeAreaView> </SafeAreaProvider>
) )
} }
const styles = StyleSheet.create({
base: {
flex: 1,
backgroundColor: 'black'
}
})
export default ScreenImagesViewer export default ScreenImagesViewer