Refactor lightbox prop drilling (#6073)

* Refactor lightbox footer to render prop

* Unify lightbox types

* Unindent

* Refactor LightboxFooter props

* Move LightboxFooter into the implementation file
This commit is contained in:
dan
2024-11-04 21:31:21 +00:00
committed by GitHub
parent 174988bc5a
commit 26c48373e2
7 changed files with 155 additions and 214 deletions
+12 -2
View File
@@ -55,8 +55,18 @@ let ProfileHeaderShell = ({
const modui = moderation.ui('avatar') const modui = moderation.ui('avatar')
if (profile.avatar && !(modui.blur && modui.noOverride)) { if (profile.avatar && !(modui.blur && modui.noOverride)) {
openLightbox({ openLightbox({
type: 'profile-image', images: [
profile: profile, {
uri: profile.avatar,
thumbUri: profile.avatar,
dimensions: {
// It's fine if it's actually smaller but we know it's 1:1.
height: 1000,
width: 1000,
},
},
],
index: 0,
thumbDims: null, thumbDims: null,
}) })
} }
+3 -20
View File
@@ -1,32 +1,15 @@
import React from 'react' import React from 'react'
import type {MeasuredDimensions} from 'react-native-reanimated' import type {MeasuredDimensions} from 'react-native-reanimated'
import {AppBskyActorDefs} from '@atproto/api'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {Dimensions} from '#/lib/media/types' import {ImageSource} from '#/view/com/lightbox/ImageViewing/@types'
type ProfileImageLightbox = { type Lightbox = {
type: 'profile-image' images: ImageSource[]
profile: AppBskyActorDefs.ProfileViewDetailed
thumbDims: null
}
type ImagesLightboxItem = {
uri: string
thumbUri: string
alt?: string
dimensions: Dimensions | null
}
type ImagesLightbox = {
type: 'images'
images: ImagesLightboxItem[]
thumbDims: MeasuredDimensions | null thumbDims: MeasuredDimensions | null
index: number index: number
} }
type Lightbox = ProfileImageLightbox | ImagesLightbox
const LightboxContext = React.createContext<{ const LightboxContext = React.createContext<{
activeLightbox: Lightbox | null activeLightbox: Lightbox | null
}>({ }>({
+126 -20
View File
@@ -8,13 +8,27 @@
// Original code copied and simplified from the link below as the codebase is currently not maintained: // Original code copied and simplified from the link below as the codebase is currently not maintained:
// https://github.com/jobtoday/react-native-image-viewing // https://github.com/jobtoday/react-native-image-viewing
import React, {ComponentType, useCallback, useMemo, useState} from 'react' import React, {useCallback, useMemo, useState} from 'react'
import {Platform, StyleSheet, View} from 'react-native' import {
Dimensions,
LayoutAnimation,
Platform,
StyleSheet,
View,
} from 'react-native'
import PagerView from 'react-native-pager-view' import PagerView from 'react-native-pager-view'
import {MeasuredDimensions} from 'react-native-reanimated' import {MeasuredDimensions} from 'react-native-reanimated'
import Animated, {useAnimatedStyle, withSpring} from 'react-native-reanimated' import Animated, {useAnimatedStyle, withSpring} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {Edge, SafeAreaView} from 'react-native-safe-area-context' import {Edge, SafeAreaView} from 'react-native-safe-area-context'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Trans} from '@lingui/macro'
import {colors, s} from '#/lib/styles'
import {isIOS} from '#/platform/detection'
import {Button} from '#/view/com/util/forms/Button'
import {Text} from '#/view/com/util/text/Text'
import {ScrollView} from '#/view/com/util/Views'
import {ImageSource} from './@types' import {ImageSource} from './@types'
import ImageDefaultHeader from './components/ImageDefaultHeader' import ImageDefaultHeader from './components/ImageDefaultHeader'
import ImageItem from './components/ImageItem/ImageItem' import ImageItem from './components/ImageItem/ImageItem'
@@ -26,10 +40,11 @@ type Props = {
visible: boolean visible: boolean
onRequestClose: () => void onRequestClose: () => void
backgroundColor?: string backgroundColor?: string
HeaderComponent?: ComponentType<{imageIndex: number}> onPressSave: (uri: string) => void
FooterComponent?: ComponentType<{imageIndex: number}> onPressShare: (uri: string) => void
} }
const SCREEN_HEIGHT = Dimensions.get('window').height
const DEFAULT_BG_COLOR = '#000' const DEFAULT_BG_COLOR = '#000'
function ImageViewing({ function ImageViewing({
@@ -39,8 +54,8 @@ function ImageViewing({
visible, visible,
onRequestClose, onRequestClose,
backgroundColor = DEFAULT_BG_COLOR, backgroundColor = DEFAULT_BG_COLOR,
HeaderComponent, onPressSave,
FooterComponent, onPressShare,
}: Props) { }: Props) {
const [isScaled, setIsScaled] = useState(false) const [isScaled, setIsScaled] = useState(false)
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
@@ -96,13 +111,7 @@ function ImageViewing({
accessibilityViewIsModal> accessibilityViewIsModal>
<View style={[styles.container, {backgroundColor}]}> <View style={[styles.container, {backgroundColor}]}>
<Animated.View style={[styles.header, animatedHeaderStyle]}> <Animated.View style={[styles.header, animatedHeaderStyle]}>
{typeof HeaderComponent !== 'undefined' ? ( <ImageDefaultHeader onRequestClose={onRequestClose} />
React.createElement(HeaderComponent, {
imageIndex,
})
) : (
<ImageDefaultHeader onRequestClose={onRequestClose} />
)}
</Animated.View> </Animated.View>
<PagerView <PagerView
scrollEnabled={!isScaled} scrollEnabled={!isScaled}
@@ -129,18 +138,100 @@ function ImageViewing({
</View> </View>
))} ))}
</PagerView> </PagerView>
{typeof FooterComponent !== 'undefined' && ( <Animated.View style={[styles.footer, animatedFooterStyle]}>
<Animated.View style={[styles.footer, animatedFooterStyle]}> <LightboxFooter
{React.createElement(FooterComponent, { images={images}
imageIndex, index={imageIndex}
})} onPressSave={onPressSave}
</Animated.View> onPressShare={onPressShare}
)} />
</Animated.View>
</View> </View>
</SafeAreaView> </SafeAreaView>
) )
} }
function LightboxFooter({
images,
index,
onPressSave,
onPressShare,
}: {
images: ImageSource[]
index: number
onPressSave: (uri: string) => void
onPressShare: (uri: string) => void
}) {
const {alt: altText, uri} = images[index]
const [isAltExpanded, setAltExpanded] = React.useState(false)
const insets = useSafeAreaInsets()
const svMaxHeight = SCREEN_HEIGHT - insets.top - 50
const isMomentumScrolling = React.useRef(false)
return (
<ScrollView
style={[
{
backgroundColor: '#000d',
},
{maxHeight: svMaxHeight},
]}
scrollEnabled={isAltExpanded}
onMomentumScrollBegin={() => {
isMomentumScrolling.current = true
}}
onMomentumScrollEnd={() => {
isMomentumScrolling.current = false
}}
contentContainerStyle={{
paddingTop: 16,
paddingBottom: insets.bottom + 10,
paddingHorizontal: 24,
}}>
{altText ? (
<View accessibilityRole="button" style={styles.footerText}>
<Text
style={[s.gray3]}
numberOfLines={isAltExpanded ? undefined : 3}
selectable
onPress={() => {
if (isMomentumScrolling.current) {
return
}
LayoutAnimation.configureNext({
duration: 450,
update: {type: 'spring', springDamping: 1},
})
setAltExpanded(prev => !prev)
}}
onLongPress={() => {}}>
{altText}
</Text>
</View>
) : null}
<View style={styles.footerBtns}>
<Button
type="primary-outline"
style={styles.footerBtn}
onPress={() => onPressSave(uri)}>
<FontAwesomeIcon icon={['far', 'floppy-disk']} style={s.white} />
<Text type="xl" style={s.white}>
<Trans context="action">Save</Trans>
</Text>
</Button>
<Button
type="primary-outline"
style={styles.footerBtn}
onPress={() => onPressShare(uri)}>
<FontAwesomeIcon icon="arrow-up-from-bracket" style={s.white} />
<Text type="xl" style={s.white}>
<Trans context="action">Share</Trans>
</Text>
</Button>
</View>
</ScrollView>
)
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
screen: { screen: {
position: 'absolute', position: 'absolute',
@@ -169,6 +260,21 @@ const styles = StyleSheet.create({
zIndex: 1, zIndex: 1,
bottom: 0, bottom: 0,
}, },
footerText: {
paddingBottom: isIOS ? 20 : 16,
},
footerBtns: {
flexDirection: 'row',
justifyContent: 'center',
gap: 8,
},
footerBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
backgroundColor: 'transparent',
borderColor: colors.white,
},
}) })
const EnhancedImageViewing = (props: Props) => ( const EnhancedImageViewing = (props: Props) => (
+12 -152
View File
@@ -1,82 +1,25 @@
import React from 'react' import React from 'react'
import {Dimensions, LayoutAnimation, StyleSheet, View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import * as MediaLibrary from 'expo-media-library' import * as MediaLibrary from 'expo-media-library'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {msg} from '@lingui/macro'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {saveImageToMediaLibrary, shareImageModal} from '#/lib/media/manip' import {saveImageToMediaLibrary, shareImageModal} from '#/lib/media/manip'
import {colors, s} from '#/lib/styles'
import {isIOS} from '#/platform/detection'
import {useLightbox, useLightboxControls} from '#/state/lightbox' import {useLightbox, useLightboxControls} from '#/state/lightbox'
import {ScrollView} from '#/view/com/util/Views'
import {Button} from '../util/forms/Button'
import {Text} from '../util/text/Text'
import * as Toast from '../util/Toast' import * as Toast from '../util/Toast'
import ImageView from './ImageViewing' import ImageView from './ImageViewing'
const SCREEN_HEIGHT = Dimensions.get('window').height
export function Lightbox() { export function Lightbox() {
const {activeLightbox} = useLightbox() const {activeLightbox} = useLightbox()
const {closeLightbox} = useLightboxControls() const {closeLightbox} = useLightboxControls()
const onClose = React.useCallback(() => { const onClose = React.useCallback(() => {
closeLightbox() closeLightbox()
}, [closeLightbox]) }, [closeLightbox])
if (!activeLightbox) {
return null
} else if (activeLightbox.type === 'profile-image') {
const opts = activeLightbox
return (
<ImageView
images={[
{
uri: opts.profile.avatar || '',
thumbUri: opts.profile.avatar || '',
dimensions: {
// It's fine if it's actually smaller but we know it's 1:1.
height: 1000,
width: 1000,
},
},
]}
initialImageIndex={0}
thumbDims={opts.thumbDims}
visible
onRequestClose={onClose}
FooterComponent={LightboxFooter}
/>
)
} else if (activeLightbox.type === 'images') {
const opts = activeLightbox
return (
<ImageView
images={opts.images.map(img => ({...img}))}
initialImageIndex={opts.index}
thumbDims={opts.thumbDims}
visible
onRequestClose={onClose}
FooterComponent={LightboxFooter}
/>
)
} else {
return null
}
}
function LightboxFooter({imageIndex}: {imageIndex: number}) {
const {_} = useLingui() const {_} = useLingui()
const {activeLightbox} = useLightbox()
const [isAltExpanded, setAltExpanded] = React.useState(false)
const [permissionResponse, requestPermission] = MediaLibrary.usePermissions({ const [permissionResponse, requestPermission] = MediaLibrary.usePermissions({
granularPermissions: ['photo'], granularPermissions: ['photo'],
}) })
const insets = useSafeAreaInsets()
const svMaxHeight = SCREEN_HEIGHT - insets.top - 50
const isMomentumScrolling = React.useRef(false)
const saveImageToAlbumWithToasts = React.useCallback( const saveImageToAlbumWithToasts = React.useCallback(
async (uri: string) => { async (uri: string) => {
if (!permissionResponse || permissionResponse.granted === false) { if (!permissionResponse || permissionResponse.granted === false) {
@@ -96,7 +39,6 @@ function LightboxFooter({imageIndex}: {imageIndex: number}) {
} }
return return
} }
try { try {
await saveImageToMediaLibrary({uri}) await saveImageToMediaLibrary({uri})
Toast.show(_(msg`Saved to your camera roll`)) Toast.show(_(msg`Saved to your camera roll`))
@@ -107,101 +49,19 @@ function LightboxFooter({imageIndex}: {imageIndex: number}) {
[permissionResponse, requestPermission, _], [permissionResponse, requestPermission, _],
) )
const lightbox = activeLightbox if (!activeLightbox) {
if (!lightbox) {
return null return null
} }
let altText = ''
let uri = ''
if (lightbox.type === 'images') {
const opts = lightbox
uri = opts.images[imageIndex].uri
altText = opts.images[imageIndex].alt || ''
} else if (lightbox.type === 'profile-image') {
const opts = lightbox
uri = opts.profile.avatar || ''
}
return ( return (
<ScrollView <ImageView
style={[ images={activeLightbox.images}
{ initialImageIndex={activeLightbox.index}
backgroundColor: '#000d', thumbDims={activeLightbox.thumbDims}
}, visible
{maxHeight: svMaxHeight}, onRequestClose={onClose}
]} onPressSave={saveImageToAlbumWithToasts}
scrollEnabled={isAltExpanded} onPressShare={uri => shareImageModal({uri})}
onMomentumScrollBegin={() => { />
isMomentumScrolling.current = true
}}
onMomentumScrollEnd={() => {
isMomentumScrolling.current = false
}}
contentContainerStyle={{
paddingTop: 16,
paddingBottom: insets.bottom + 10,
paddingHorizontal: 24,
}}>
{altText ? (
<View accessibilityRole="button" style={styles.footerText}>
<Text
style={[s.gray3]}
numberOfLines={isAltExpanded ? undefined : 3}
selectable
onPress={() => {
if (isMomentumScrolling.current) {
return
}
LayoutAnimation.configureNext({
duration: 450,
update: {type: 'spring', springDamping: 1},
})
setAltExpanded(prev => !prev)
}}
onLongPress={() => {}}>
{altText}
</Text>
</View>
) : null}
<View style={styles.footerBtns}>
<Button
type="primary-outline"
style={styles.footerBtn}
onPress={() => saveImageToAlbumWithToasts(uri)}>
<FontAwesomeIcon icon={['far', 'floppy-disk']} style={s.white} />
<Text type="xl" style={s.white}>
<Trans context="action">Save</Trans>
</Text>
</Button>
<Button
type="primary-outline"
style={styles.footerBtn}
onPress={() => shareImageModal({uri})}>
<FontAwesomeIcon icon="arrow-up-from-bracket" style={s.white} />
<Text type="xl" style={s.white}>
<Trans context="action">Share</Trans>
</Text>
</Button>
</View>
</ScrollView>
) )
} }
const styles = StyleSheet.create({
footerText: {
paddingBottom: isIOS ? 20 : 16,
},
footerBtns: {
flexDirection: 'row',
justifyContent: 'center',
gap: 8,
},
footerBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
backgroundColor: 'transparent',
borderColor: colors.white,
},
})
+2 -18
View File
@@ -38,24 +38,8 @@ export function Lightbox() {
return null return null
} }
const initialIndex = const initialIndex = activeLightbox.index
activeLightbox.type === 'images' ? activeLightbox.index : 0 const imgs = activeLightbox.images
let imgs: Img[] | undefined
if (activeLightbox.type === 'profile-image') {
const opts = activeLightbox
if (opts.profile.avatar) {
imgs = [{uri: opts.profile.avatar}]
}
} else if (activeLightbox.type === 'images') {
const opts = activeLightbox
imgs = opts.images
}
if (!imgs) {
return null
}
return ( return (
<LightboxInner <LightboxInner
imgs={imgs} imgs={imgs}
@@ -71,7 +71,6 @@ export function ProfileSubpageHeader({
avatar // TODO && !(view.moderation.avatar.blur && view.moderation.avatar.noOverride) avatar // TODO && !(view.moderation.avatar.blur && view.moderation.avatar.noOverride)
) { ) {
openLightbox({ openLightbox({
type: 'images',
images: [ images: [
{ {
uri: avatar, uri: avatar,
-1
View File
@@ -152,7 +152,6 @@ export function PostEmbeds({
thumbDims: MeasuredDimensions | null, thumbDims: MeasuredDimensions | null,
) => { ) => {
openLightbox({ openLightbox({
type: 'images',
images: items, images: items,
index, index,
thumbDims, thumbDims,