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')
if (profile.avatar && !(modui.blur && modui.noOverride)) {
openLightbox({
type: 'profile-image',
profile: profile,
images: [
{
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,
})
}
+3 -20
View File
@@ -1,32 +1,15 @@
import React from 'react'
import type {MeasuredDimensions} from 'react-native-reanimated'
import {AppBskyActorDefs} from '@atproto/api'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {Dimensions} from '#/lib/media/types'
import {ImageSource} from '#/view/com/lightbox/ImageViewing/@types'
type ProfileImageLightbox = {
type: 'profile-image'
profile: AppBskyActorDefs.ProfileViewDetailed
thumbDims: null
}
type ImagesLightboxItem = {
uri: string
thumbUri: string
alt?: string
dimensions: Dimensions | null
}
type ImagesLightbox = {
type: 'images'
images: ImagesLightboxItem[]
type Lightbox = {
images: ImageSource[]
thumbDims: MeasuredDimensions | null
index: number
}
type Lightbox = ProfileImageLightbox | ImagesLightbox
const LightboxContext = React.createContext<{
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:
// https://github.com/jobtoday/react-native-image-viewing
import React, {ComponentType, useCallback, useMemo, useState} from 'react'
import {Platform, StyleSheet, View} from 'react-native'
import React, {useCallback, useMemo, useState} from 'react'
import {
Dimensions,
LayoutAnimation,
Platform,
StyleSheet,
View,
} from 'react-native'
import PagerView from 'react-native-pager-view'
import {MeasuredDimensions} 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 {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 ImageDefaultHeader from './components/ImageDefaultHeader'
import ImageItem from './components/ImageItem/ImageItem'
@@ -26,10 +40,11 @@ type Props = {
visible: boolean
onRequestClose: () => void
backgroundColor?: string
HeaderComponent?: ComponentType<{imageIndex: number}>
FooterComponent?: ComponentType<{imageIndex: number}>
onPressSave: (uri: string) => void
onPressShare: (uri: string) => void
}
const SCREEN_HEIGHT = Dimensions.get('window').height
const DEFAULT_BG_COLOR = '#000'
function ImageViewing({
@@ -39,8 +54,8 @@ function ImageViewing({
visible,
onRequestClose,
backgroundColor = DEFAULT_BG_COLOR,
HeaderComponent,
FooterComponent,
onPressSave,
onPressShare,
}: Props) {
const [isScaled, setIsScaled] = useState(false)
const [isDragging, setIsDragging] = useState(false)
@@ -96,13 +111,7 @@ function ImageViewing({
accessibilityViewIsModal>
<View style={[styles.container, {backgroundColor}]}>
<Animated.View style={[styles.header, animatedHeaderStyle]}>
{typeof HeaderComponent !== 'undefined' ? (
React.createElement(HeaderComponent, {
imageIndex,
})
) : (
<ImageDefaultHeader onRequestClose={onRequestClose} />
)}
<ImageDefaultHeader onRequestClose={onRequestClose} />
</Animated.View>
<PagerView
scrollEnabled={!isScaled}
@@ -129,18 +138,100 @@ function ImageViewing({
</View>
))}
</PagerView>
{typeof FooterComponent !== 'undefined' && (
<Animated.View style={[styles.footer, animatedFooterStyle]}>
{React.createElement(FooterComponent, {
imageIndex,
})}
</Animated.View>
)}
<Animated.View style={[styles.footer, animatedFooterStyle]}>
<LightboxFooter
images={images}
index={imageIndex}
onPressSave={onPressSave}
onPressShare={onPressShare}
/>
</Animated.View>
</View>
</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({
screen: {
position: 'absolute',
@@ -169,6 +260,21 @@ const styles = StyleSheet.create({
zIndex: 1,
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) => (
+12 -152
View File
@@ -1,82 +1,25 @@
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 {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
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 {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 ImageView from './ImageViewing'
const SCREEN_HEIGHT = Dimensions.get('window').height
export function Lightbox() {
const {activeLightbox} = useLightbox()
const {closeLightbox} = useLightboxControls()
const onClose = React.useCallback(() => {
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 {activeLightbox} = useLightbox()
const [isAltExpanded, setAltExpanded] = React.useState(false)
const [permissionResponse, requestPermission] = MediaLibrary.usePermissions({
granularPermissions: ['photo'],
})
const insets = useSafeAreaInsets()
const svMaxHeight = SCREEN_HEIGHT - insets.top - 50
const isMomentumScrolling = React.useRef(false)
const saveImageToAlbumWithToasts = React.useCallback(
async (uri: string) => {
if (!permissionResponse || permissionResponse.granted === false) {
@@ -96,7 +39,6 @@ function LightboxFooter({imageIndex}: {imageIndex: number}) {
}
return
}
try {
await saveImageToMediaLibrary({uri})
Toast.show(_(msg`Saved to your camera roll`))
@@ -107,101 +49,19 @@ function LightboxFooter({imageIndex}: {imageIndex: number}) {
[permissionResponse, requestPermission, _],
)
const lightbox = activeLightbox
if (!lightbox) {
if (!activeLightbox) {
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 (
<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={() => 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>
<ImageView
images={activeLightbox.images}
initialImageIndex={activeLightbox.index}
thumbDims={activeLightbox.thumbDims}
visible
onRequestClose={onClose}
onPressSave={saveImageToAlbumWithToasts}
onPressShare={uri => shareImageModal({uri})}
/>
)
}
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
}
const initialIndex =
activeLightbox.type === 'images' ? activeLightbox.index : 0
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
}
const initialIndex = activeLightbox.index
const imgs = activeLightbox.images
return (
<LightboxInner
imgs={imgs}
@@ -71,7 +71,6 @@ export function ProfileSubpageHeader({
avatar // TODO && !(view.moderation.avatar.blur && view.moderation.avatar.noOverride)
) {
openLightbox({
type: 'images',
images: [
{
uri: avatar,
-1
View File
@@ -152,7 +152,6 @@ export function PostEmbeds({
thumbDims: MeasuredDimensions | null,
) => {
openLightbox({
type: 'images',
images: items,
index,
thumbDims,