Factor lightbox out into hook/context (#1919)

zio/stable
Paul Frazee 2023-11-15 18:17:03 -08:00 committed by GitHub
parent 03b20c70e4
commit e749f2f3a5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 152 additions and 104 deletions

View File

@ -25,6 +25,7 @@ import {queryClient} from 'lib/react-query'
import {TestCtrls} from 'view/com/testing/TestCtrls' import {TestCtrls} from 'view/com/testing/TestCtrls'
import {Provider as ShellStateProvider} from 'state/shell' import {Provider as ShellStateProvider} from 'state/shell'
import {Provider as ModalStateProvider} from 'state/modals' import {Provider as ModalStateProvider} from 'state/modals'
import {Provider as LightboxStateProvider} from 'state/lightbox'
import {Provider as MutedThreadsProvider} from 'state/muted-threads' import {Provider as MutedThreadsProvider} from 'state/muted-threads'
import {Provider as InvitesStateProvider} from 'state/invites' import {Provider as InvitesStateProvider} from 'state/invites'
import {Provider as PrefsStateProvider} from 'state/preferences' import {Provider as PrefsStateProvider} from 'state/preferences'
@ -124,7 +125,9 @@ function App() {
<MutedThreadsProvider> <MutedThreadsProvider>
<InvitesStateProvider> <InvitesStateProvider>
<ModalStateProvider> <ModalStateProvider>
<InnerApp /> <LightboxStateProvider>
<InnerApp />
</LightboxStateProvider>
</ModalStateProvider> </ModalStateProvider>
</InvitesStateProvider> </InvitesStateProvider>
</MutedThreadsProvider> </MutedThreadsProvider>

View File

@ -22,6 +22,7 @@ import {I18nProvider} from '@lingui/react'
import {defaultLocale, dynamicActivate} from './locale/i18n' import {defaultLocale, dynamicActivate} from './locale/i18n'
import {Provider as ShellStateProvider} from 'state/shell' import {Provider as ShellStateProvider} from 'state/shell'
import {Provider as ModalStateProvider} from 'state/modals' import {Provider as ModalStateProvider} from 'state/modals'
import {Provider as LightboxStateProvider} from 'state/lightbox'
import {Provider as MutedThreadsProvider} from 'state/muted-threads' import {Provider as MutedThreadsProvider} from 'state/muted-threads'
import {Provider as InvitesStateProvider} from 'state/invites' import {Provider as InvitesStateProvider} from 'state/invites'
import {Provider as PrefsStateProvider} from 'state/preferences' import {Provider as PrefsStateProvider} from 'state/preferences'
@ -111,7 +112,9 @@ function App() {
<MutedThreadsProvider> <MutedThreadsProvider>
<InvitesStateProvider> <InvitesStateProvider>
<ModalStateProvider> <ModalStateProvider>
<InnerApp /> <LightboxStateProvider>
<InnerApp />
</LightboxStateProvider>
</ModalStateProvider> </ModalStateProvider>
</InvitesStateProvider> </InvitesStateProvider>
</MutedThreadsProvider> </MutedThreadsProvider>

View File

@ -0,0 +1,86 @@
import React from 'react'
import {AppBskyActorDefs} from '@atproto/api'
interface Lightbox {
name: string
}
export class ProfileImageLightbox implements Lightbox {
name = 'profile-image'
constructor(public profile: AppBskyActorDefs.ProfileViewDetailed) {}
}
interface ImagesLightboxItem {
uri: string
alt?: string
}
export class ImagesLightbox implements Lightbox {
name = 'images'
constructor(public images: ImagesLightboxItem[], public index: number) {}
setIndex(index: number) {
this.index = index
}
}
const LightboxContext = React.createContext<{
activeLightbox: Lightbox | null
}>({
activeLightbox: null,
})
const LightboxControlContext = React.createContext<{
openLightbox: (lightbox: Lightbox) => void
closeLightbox: () => void
}>({
openLightbox: () => {},
closeLightbox: () => {},
})
export function Provider({children}: React.PropsWithChildren<{}>) {
const [activeLightbox, setActiveLightbox] = React.useState<Lightbox | null>(
null,
)
const openLightbox = React.useCallback(
(lightbox: Lightbox) => {
setActiveLightbox(lightbox)
},
[setActiveLightbox],
)
const closeLightbox = React.useCallback(() => {
setActiveLightbox(null)
}, [setActiveLightbox])
const state = React.useMemo(
() => ({
activeLightbox,
}),
[activeLightbox],
)
const methods = React.useMemo(
() => ({
openLightbox,
closeLightbox,
}),
[openLightbox, closeLightbox],
)
return (
<LightboxContext.Provider value={state}>
<LightboxControlContext.Provider value={methods}>
{children}
</LightboxControlContext.Provider>
</LightboxContext.Provider>
)
}
export function useLightbox() {
return React.useContext(LightboxContext)
}
export function useLightboxControls() {
return React.useContext(LightboxControlContext)
}

View File

@ -1,6 +1,6 @@
import React from 'react' import React from 'react'
import {AppBskyActorDefs, AppBskyGraphDefs, ModerationUI} from '@atproto/api' import {AppBskyActorDefs, AppBskyGraphDefs, ModerationUI} from '@atproto/api'
import {StyleProp, ViewStyle, DeviceEventEmitter} from 'react-native' import {StyleProp, ViewStyle} from 'react-native'
import {Image as RNImage} from 'react-native-image-crop-picker' import {Image as RNImage} from 'react-native-image-crop-picker'
import {ImageModel} from '#/state/models/media/image' import {ImageModel} from '#/state/models/media/image'
@ -232,7 +232,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const openModal = React.useCallback( const openModal = React.useCallback(
(modal: Modal) => { (modal: Modal) => {
DeviceEventEmitter.emit('navigation')
setActiveModals(activeModals => [...activeModals, modal]) setActiveModals(activeModals => [...activeModals, modal])
setIsModalActive(true) setIsModalActive(true)
}, },

View File

@ -1,4 +1,3 @@
import {AppBskyActorDefs} from '@atproto/api'
import {RootStoreModel} from '../root-store' import {RootStoreModel} from '../root-store'
import {makeAutoObservable} from 'mobx' import {makeAutoObservable} from 'mobx'
import { import {
@ -13,34 +12,7 @@ export function isColorMode(v: unknown): v is ColorMode {
return v === 'system' || v === 'light' || v === 'dark' return v === 'system' || v === 'light' || v === 'dark'
} }
interface LightboxModel {}
export class ProfileImageLightbox implements LightboxModel {
name = 'profile-image'
constructor(public profile: AppBskyActorDefs.ProfileViewDetailed) {
makeAutoObservable(this)
}
}
interface ImagesLightboxItem {
uri: string
alt?: string
}
export class ImagesLightbox implements LightboxModel {
name = 'images'
constructor(public images: ImagesLightboxItem[], public index: number) {
makeAutoObservable(this)
}
setIndex(index: number) {
this.index = index
}
}
export class ShellUiModel { export class ShellUiModel {
isLightboxActive = false
activeLightbox: ProfileImageLightbox | ImagesLightbox | null = null
constructor(public rootStore: RootStoreModel) { constructor(public rootStore: RootStoreModel) {
makeAutoObservable(this, { makeAutoObservable(this, {
rootStore: false, rootStore: false,
@ -54,32 +26,13 @@ export class ShellUiModel {
* (used by the android hardware back btn) * (used by the android hardware back btn)
*/ */
closeAnyActiveElement(): boolean { closeAnyActiveElement(): boolean {
if (this.isLightboxActive) {
this.closeLightbox()
return true
}
return false return false
} }
/** /**
* used to clear out any modals, eg for a navigation * used to clear out any modals, eg for a navigation
*/ */
closeAllActiveElements() { closeAllActiveElements() {}
if (this.isLightboxActive) {
this.closeLightbox()
}
}
openLightbox(lightbox: ProfileImageLightbox | ImagesLightbox) {
this.rootStore.emitNavigation()
this.isLightboxActive = true
this.activeLightbox = lightbox
}
closeLightbox() {
this.isLightboxActive = false
this.activeLightbox = null
}
setupLoginModals() { setupLoginModals() {
this.rootStore.onSessionReady(() => { this.rootStore.onSessionReady(() => {

View File

@ -1,10 +1,7 @@
import React from 'react' import React from 'react'
import {Pressable, StyleSheet, View} from 'react-native' import {Pressable, StyleSheet, View} from 'react-native'
import {observer} from 'mobx-react-lite'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import ImageView from './ImageViewing' import ImageView from './ImageViewing'
import {useStores} from 'state/index'
import * as models from 'state/models/ui/shell'
import {shareImageModal, saveImageToMediaLibrary} from 'lib/media/manip' import {shareImageModal, saveImageToMediaLibrary} from 'lib/media/manip'
import * as Toast from '../util/Toast' import * as Toast from '../util/Toast'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
@ -12,17 +9,24 @@ import {s, colors} from 'lib/styles'
import {Button} from '../util/forms/Button' import {Button} from '../util/forms/Button'
import {isIOS} from 'platform/detection' import {isIOS} from 'platform/detection'
import * as MediaLibrary from 'expo-media-library' import * as MediaLibrary from 'expo-media-library'
import {
useLightbox,
useLightboxControls,
ProfileImageLightbox,
ImagesLightbox,
} from '#/state/lightbox'
export const Lightbox = observer(function Lightbox() { export function Lightbox() {
const store = useStores() const {activeLightbox} = useLightbox()
const {closeLightbox} = useLightboxControls()
const onClose = React.useCallback(() => { const onClose = React.useCallback(() => {
store.shell.closeLightbox() closeLightbox()
}, [store]) }, [closeLightbox])
if (!store.shell.activeLightbox) { if (!activeLightbox) {
return null return null
} else if (store.shell.activeLightbox.name === 'profile-image') { } else if (activeLightbox.name === 'profile-image') {
const opts = store.shell.activeLightbox as models.ProfileImageLightbox const opts = activeLightbox as ProfileImageLightbox
return ( return (
<ImageView <ImageView
images={[{uri: opts.profile.avatar || ''}]} images={[{uri: opts.profile.avatar || ''}]}
@ -32,8 +36,8 @@ export const Lightbox = observer(function Lightbox() {
FooterComponent={LightboxFooter} FooterComponent={LightboxFooter}
/> />
) )
} else if (store.shell.activeLightbox.name === 'images') { } else if (activeLightbox.name === 'images') {
const opts = store.shell.activeLightbox as models.ImagesLightbox const opts = activeLightbox as ImagesLightbox
return ( return (
<ImageView <ImageView
images={opts.images.map(img => ({...img}))} images={opts.images.map(img => ({...img}))}
@ -46,14 +50,10 @@ export const Lightbox = observer(function Lightbox() {
} else { } else {
return null return null
} }
}) }
const LightboxFooter = observer(function LightboxFooter({ function LightboxFooter({imageIndex}: {imageIndex: number}) {
imageIndex, const {activeLightbox} = useLightbox()
}: {
imageIndex: number
}) {
const store = useStores()
const [isAltExpanded, setAltExpanded] = React.useState(false) const [isAltExpanded, setAltExpanded] = React.useState(false)
const [permissionResponse, requestPermission] = MediaLibrary.usePermissions() const [permissionResponse, requestPermission] = MediaLibrary.usePermissions()
@ -81,7 +81,7 @@ const LightboxFooter = observer(function LightboxFooter({
[permissionResponse, requestPermission], [permissionResponse, requestPermission],
) )
const lightbox = store.shell.activeLightbox const lightbox = activeLightbox
if (!lightbox) { if (!lightbox) {
return null return null
} }
@ -89,11 +89,11 @@ const LightboxFooter = observer(function LightboxFooter({
let altText = '' let altText = ''
let uri = '' let uri = ''
if (lightbox.name === 'images') { if (lightbox.name === 'images') {
const opts = lightbox as models.ImagesLightbox const opts = lightbox as ImagesLightbox
uri = opts.images[imageIndex].uri uri = opts.images[imageIndex].uri
altText = opts.images[imageIndex].alt || '' altText = opts.images[imageIndex].alt || ''
} else if (lightbox.name === 'profile-image') { } else if (lightbox.name === 'profile-image') {
const opts = lightbox as models.ProfileImageLightbox const opts = lightbox as ProfileImageLightbox
uri = opts.profile.avatar || '' uri = opts.profile.avatar || ''
} }
@ -132,7 +132,7 @@ const LightboxFooter = observer(function LightboxFooter({
</View> </View>
</View> </View>
) )
}) }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
footer: { footer: {

View File

@ -7,41 +7,42 @@ import {
View, View,
Pressable, Pressable,
} from 'react-native' } from 'react-native'
import {observer} from 'mobx-react-lite'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {useStores} from 'state/index'
import * as models from 'state/models/ui/shell'
import {colors, s} from 'lib/styles' import {colors, s} from 'lib/styles'
import ImageDefaultHeader from './ImageViewing/components/ImageDefaultHeader' import ImageDefaultHeader from './ImageViewing/components/ImageDefaultHeader'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {
useLightbox,
useLightboxControls,
ImagesLightbox,
ProfileImageLightbox,
} from '#/state/lightbox'
interface Img { interface Img {
uri: string uri: string
alt?: string alt?: string
} }
export const Lightbox = observer(function Lightbox() { export function Lightbox() {
const store = useStores() const {activeLightbox} = useLightbox()
const {closeLightbox} = useLightboxControls()
const onClose = useCallback(() => store.shell.closeLightbox(), [store.shell]) if (!activeLightbox) {
if (!store.shell.isLightboxActive) {
return null return null
} }
const activeLightbox = store.shell.activeLightbox
const initialIndex = const initialIndex =
activeLightbox instanceof models.ImagesLightbox ? activeLightbox.index : 0 activeLightbox instanceof ImagesLightbox ? activeLightbox.index : 0
let imgs: Img[] | undefined let imgs: Img[] | undefined
if (activeLightbox instanceof models.ProfileImageLightbox) { if (activeLightbox instanceof ProfileImageLightbox) {
const opts = activeLightbox const opts = activeLightbox
if (opts.profile.avatar) { if (opts.profile.avatar) {
imgs = [{uri: opts.profile.avatar}] imgs = [{uri: opts.profile.avatar}]
} }
} else if (activeLightbox instanceof models.ImagesLightbox) { } else if (activeLightbox instanceof ImagesLightbox) {
const opts = activeLightbox const opts = activeLightbox
imgs = opts.images imgs = opts.images
} }
@ -51,9 +52,13 @@ export const Lightbox = observer(function Lightbox() {
} }
return ( return (
<LightboxInner imgs={imgs} initialIndex={initialIndex} onClose={onClose} /> <LightboxInner
imgs={imgs}
initialIndex={initialIndex}
onClose={closeLightbox}
/>
) )
}) }
function LightboxInner({ function LightboxInner({
imgs, imgs,

View File

@ -17,7 +17,6 @@ import {useLingui} from '@lingui/react'
import {NavigationProp} from 'lib/routes/types' import {NavigationProp} from 'lib/routes/types'
import {isNative} from 'platform/detection' import {isNative} from 'platform/detection'
import {BlurView} from '../util/BlurView' import {BlurView} from '../util/BlurView'
import {ProfileImageLightbox} from 'state/models/ui/shell'
import * as Toast from '../util/Toast' import * as Toast from '../util/Toast'
import {LoadingPlaceholder} from '../util/LoadingPlaceholder' import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
@ -30,8 +29,8 @@ import {formatCount} from '../util/numeric/format'
import {NativeDropdown, DropdownItem} from '../util/forms/NativeDropdown' import {NativeDropdown, DropdownItem} from '../util/forms/NativeDropdown'
import {Link} from '../util/Link' import {Link} from '../util/Link'
import {ProfileHeaderSuggestedFollows} from './ProfileHeaderSuggestedFollows' import {ProfileHeaderSuggestedFollows} from './ProfileHeaderSuggestedFollows'
import {useStores} from 'state/index'
import {useModalControls} from '#/state/modals' import {useModalControls} from '#/state/modals'
import {useLightboxControls, ProfileImageLightbox} from '#/state/lightbox'
import { import {
useProfileFollowMutation, useProfileFollowMutation,
useProfileUnfollowMutation, useProfileUnfollowMutation,
@ -115,10 +114,10 @@ function ProfileHeaderLoaded({
}: Props) { }: Props) {
const pal = usePalette('default') const pal = usePalette('default')
const palInverted = usePalette('inverted') const palInverted = usePalette('inverted')
const store = useStores()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const {_} = useLingui() const {_} = useLingui()
const {openModal} = useModalControls() const {openModal} = useModalControls()
const {openLightbox} = useLightboxControls()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const {track} = useAnalytics() const {track} = useAnalytics()
const invalidHandle = isInvalidHandle(profile.handle) const invalidHandle = isInvalidHandle(profile.handle)
@ -151,9 +150,9 @@ function ProfileHeaderLoaded({
profile.avatar && profile.avatar &&
!(moderation.avatar.blur && moderation.avatar.noOverride) !(moderation.avatar.blur && moderation.avatar.noOverride)
) { ) {
store.shell.openLightbox(new ProfileImageLightbox(profile)) openLightbox(new ProfileImageLightbox(profile))
} }
}, [store, profile, moderation]) }, [openLightbox, profile, moderation])
const onPressFollow = React.useCallback(async () => { const onPressFollow = React.useCallback(async () => {
if (profile.viewer?.following) { if (profile.viewer?.following) {

View File

@ -16,7 +16,7 @@ import {useStores} from 'state/index'
import {NavigationProp} from 'lib/routes/types' import {NavigationProp} from 'lib/routes/types'
import {BACK_HITSLOP} from 'lib/constants' import {BACK_HITSLOP} from 'lib/constants'
import {isNative} from 'platform/detection' import {isNative} from 'platform/detection'
import {ImagesLightbox} from 'state/models/ui/shell' import {useLightboxControls, ImagesLightbox} from '#/state/lightbox'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useSetDrawerOpen} from '#/state/shell' import {useSetDrawerOpen} from '#/state/shell'
@ -50,6 +50,7 @@ export const ProfileSubpageHeader = observer(function HeaderImpl({
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const {_} = useLingui() const {_} = useLingui()
const {isMobile} = useWebMediaQueries() const {isMobile} = useWebMediaQueries()
const {openLightbox} = useLightboxControls()
const pal = usePalette('default') const pal = usePalette('default')
const canGoBack = navigation.canGoBack() const canGoBack = navigation.canGoBack()
@ -69,9 +70,9 @@ export const ProfileSubpageHeader = observer(function HeaderImpl({
if ( if (
avatar // TODO && !(view.moderation.avatar.blur && view.moderation.avatar.noOverride) avatar // TODO && !(view.moderation.avatar.blur && view.moderation.avatar.noOverride)
) { ) {
store.shell.openLightbox(new ImagesLightbox([{uri: avatar}], 0)) openLightbox(new ImagesLightbox([{uri: avatar}], 0))
} }
}, [store, avatar]) }, [openLightbox, avatar])
return ( return (
<CenteredView style={pal.view}> <CenteredView style={pal.view}>

View File

@ -19,8 +19,7 @@ import {
} from '@atproto/api' } from '@atproto/api'
import {Link} from '../Link' import {Link} from '../Link'
import {ImageLayoutGrid} from '../images/ImageLayoutGrid' import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
import {ImagesLightbox} from 'state/models/ui/shell' import {useLightboxControls, ImagesLightbox} from '#/state/lightbox'
import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {YoutubeEmbed} from './YoutubeEmbed' import {YoutubeEmbed} from './YoutubeEmbed'
@ -49,7 +48,7 @@ export function PostEmbeds({
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const {openLightbox} = useLightboxControls()
const {isMobile} = useWebMediaQueries() const {isMobile} = useWebMediaQueries()
// quote post with media // quote post with media
@ -104,8 +103,8 @@ export function PostEmbeds({
alt: img.alt, alt: img.alt,
aspectRatio: img.aspectRatio, aspectRatio: img.aspectRatio,
})) }))
const openLightbox = (index: number) => { const _openLightbox = (index: number) => {
store.shell.openLightbox(new ImagesLightbox(items, index)) openLightbox(new ImagesLightbox(items, index))
} }
const onPressIn = (_: number) => { const onPressIn = (_: number) => {
InteractionManager.runAfterInteractions(() => { InteractionManager.runAfterInteractions(() => {
@ -121,7 +120,7 @@ export function PostEmbeds({
alt={alt} alt={alt}
uri={thumb} uri={thumb}
dimensionsHint={aspectRatio} dimensionsHint={aspectRatio}
onPress={() => openLightbox(0)} onPress={() => _openLightbox(0)}
onPressIn={() => onPressIn(0)} onPressIn={() => onPressIn(0)}
style={[ style={[
styles.singleImage, styles.singleImage,
@ -143,7 +142,7 @@ export function PostEmbeds({
<View style={[styles.imagesContainer, style]}> <View style={[styles.imagesContainer, style]}>
<ImageLayoutGrid <ImageLayoutGrid
images={embed.images} images={embed.images}
onPress={openLightbox} onPress={_openLightbox}
onPressIn={onPressIn} onPressIn={onPressIn}
style={ style={
embed.images.length === 1 embed.images.length === 1