From e749f2f3a52f5c1e137ce8262701b9c9df96324f Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Wed, 15 Nov 2023 18:17:03 -0800 Subject: [PATCH] Factor lightbox out into hook/context (#1919) --- src/App.native.tsx | 5 +- src/App.web.tsx | 5 +- src/state/lightbox.tsx | 86 +++++++++++++++++++ src/state/modals/index.tsx | 3 +- src/state/models/ui/shell.ts | 49 +---------- src/view/com/lightbox/Lightbox.tsx | 46 +++++----- src/view/com/lightbox/Lightbox.web.tsx | 33 ++++--- src/view/com/profile/ProfileHeader.tsx | 9 +- src/view/com/profile/ProfileSubpageHeader.tsx | 7 +- src/view/com/util/post-embeds/index.tsx | 13 ++- 10 files changed, 152 insertions(+), 104 deletions(-) create mode 100644 src/state/lightbox.tsx diff --git a/src/App.native.tsx b/src/App.native.tsx index ffa8b338..3f49eb11 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -25,6 +25,7 @@ import {queryClient} from 'lib/react-query' import {TestCtrls} from 'view/com/testing/TestCtrls' import {Provider as ShellStateProvider} from 'state/shell' 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 InvitesStateProvider} from 'state/invites' import {Provider as PrefsStateProvider} from 'state/preferences' @@ -124,7 +125,9 @@ function App() { - + + + diff --git a/src/App.web.tsx b/src/App.web.tsx index 8e22f648..e1f4e803 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -22,6 +22,7 @@ import {I18nProvider} from '@lingui/react' import {defaultLocale, dynamicActivate} from './locale/i18n' import {Provider as ShellStateProvider} from 'state/shell' 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 InvitesStateProvider} from 'state/invites' import {Provider as PrefsStateProvider} from 'state/preferences' @@ -111,7 +112,9 @@ function App() { - + + + diff --git a/src/state/lightbox.tsx b/src/state/lightbox.tsx new file mode 100644 index 00000000..613cd638 --- /dev/null +++ b/src/state/lightbox.tsx @@ -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( + 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 ( + + + {children} + + + ) +} + +export function useLightbox() { + return React.useContext(LightboxContext) +} + +export function useLightboxControls() { + return React.useContext(LightboxControlContext) +} diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx index 57f48663..9dd3e419 100644 --- a/src/state/modals/index.tsx +++ b/src/state/modals/index.tsx @@ -1,6 +1,6 @@ import React from 'react' 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 {ImageModel} from '#/state/models/media/image' @@ -232,7 +232,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const openModal = React.useCallback( (modal: Modal) => { - DeviceEventEmitter.emit('navigation') setActiveModals(activeModals => [...activeModals, modal]) setIsModalActive(true) }, diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts index 223c2062..1631b8f9 100644 --- a/src/state/models/ui/shell.ts +++ b/src/state/models/ui/shell.ts @@ -1,4 +1,3 @@ -import {AppBskyActorDefs} from '@atproto/api' import {RootStoreModel} from '../root-store' import {makeAutoObservable} from 'mobx' import { @@ -13,34 +12,7 @@ export function isColorMode(v: unknown): v is ColorMode { 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 { - isLightboxActive = false - activeLightbox: ProfileImageLightbox | ImagesLightbox | null = null - constructor(public rootStore: RootStoreModel) { makeAutoObservable(this, { rootStore: false, @@ -54,32 +26,13 @@ export class ShellUiModel { * (used by the android hardware back btn) */ closeAnyActiveElement(): boolean { - if (this.isLightboxActive) { - this.closeLightbox() - return true - } return false } /** * used to clear out any modals, eg for a navigation */ - 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 - } + closeAllActiveElements() {} setupLoginModals() { this.rootStore.onSessionReady(() => { diff --git a/src/view/com/lightbox/Lightbox.tsx b/src/view/com/lightbox/Lightbox.tsx index 1cadbf9a..8a18df33 100644 --- a/src/view/com/lightbox/Lightbox.tsx +++ b/src/view/com/lightbox/Lightbox.tsx @@ -1,10 +1,7 @@ import React from 'react' import {Pressable, StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 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 * as Toast from '../util/Toast' import {Text} from '../util/text/Text' @@ -12,17 +9,24 @@ import {s, colors} from 'lib/styles' import {Button} from '../util/forms/Button' import {isIOS} from 'platform/detection' import * as MediaLibrary from 'expo-media-library' +import { + useLightbox, + useLightboxControls, + ProfileImageLightbox, + ImagesLightbox, +} from '#/state/lightbox' -export const Lightbox = observer(function Lightbox() { - const store = useStores() +export function Lightbox() { + const {activeLightbox} = useLightbox() + const {closeLightbox} = useLightboxControls() const onClose = React.useCallback(() => { - store.shell.closeLightbox() - }, [store]) + closeLightbox() + }, [closeLightbox]) - if (!store.shell.activeLightbox) { + if (!activeLightbox) { return null - } else if (store.shell.activeLightbox.name === 'profile-image') { - const opts = store.shell.activeLightbox as models.ProfileImageLightbox + } else if (activeLightbox.name === 'profile-image') { + const opts = activeLightbox as ProfileImageLightbox return ( ) - } else if (store.shell.activeLightbox.name === 'images') { - const opts = store.shell.activeLightbox as models.ImagesLightbox + } else if (activeLightbox.name === 'images') { + const opts = activeLightbox as ImagesLightbox return ( ({...img}))} @@ -46,14 +50,10 @@ export const Lightbox = observer(function Lightbox() { } else { return null } -}) +} -const LightboxFooter = observer(function LightboxFooter({ - imageIndex, -}: { - imageIndex: number -}) { - const store = useStores() +function LightboxFooter({imageIndex}: {imageIndex: number}) { + const {activeLightbox} = useLightbox() const [isAltExpanded, setAltExpanded] = React.useState(false) const [permissionResponse, requestPermission] = MediaLibrary.usePermissions() @@ -81,7 +81,7 @@ const LightboxFooter = observer(function LightboxFooter({ [permissionResponse, requestPermission], ) - const lightbox = store.shell.activeLightbox + const lightbox = activeLightbox if (!lightbox) { return null } @@ -89,11 +89,11 @@ const LightboxFooter = observer(function LightboxFooter({ let altText = '' let uri = '' if (lightbox.name === 'images') { - const opts = lightbox as models.ImagesLightbox + const opts = lightbox as ImagesLightbox uri = opts.images[imageIndex].uri altText = opts.images[imageIndex].alt || '' } else if (lightbox.name === 'profile-image') { - const opts = lightbox as models.ProfileImageLightbox + const opts = lightbox as ProfileImageLightbox uri = opts.profile.avatar || '' } @@ -132,7 +132,7 @@ const LightboxFooter = observer(function LightboxFooter({ ) -}) +} const styles = StyleSheet.create({ footer: { diff --git a/src/view/com/lightbox/Lightbox.web.tsx b/src/view/com/lightbox/Lightbox.web.tsx index 4b6ad59f..45e1fa5a 100644 --- a/src/view/com/lightbox/Lightbox.web.tsx +++ b/src/view/com/lightbox/Lightbox.web.tsx @@ -7,41 +7,42 @@ import { View, Pressable, } from 'react-native' -import {observer} from 'mobx-react-lite' 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 ImageDefaultHeader from './ImageViewing/components/ImageDefaultHeader' import {Text} from '../util/text/Text' import {useLingui} from '@lingui/react' import {msg} from '@lingui/macro' +import { + useLightbox, + useLightboxControls, + ImagesLightbox, + ProfileImageLightbox, +} from '#/state/lightbox' interface Img { uri: string alt?: string } -export const Lightbox = observer(function Lightbox() { - const store = useStores() +export function Lightbox() { + const {activeLightbox} = useLightbox() + const {closeLightbox} = useLightboxControls() - const onClose = useCallback(() => store.shell.closeLightbox(), [store.shell]) - - if (!store.shell.isLightboxActive) { + if (!activeLightbox) { return null } - const activeLightbox = store.shell.activeLightbox const initialIndex = - activeLightbox instanceof models.ImagesLightbox ? activeLightbox.index : 0 + activeLightbox instanceof ImagesLightbox ? activeLightbox.index : 0 let imgs: Img[] | undefined - if (activeLightbox instanceof models.ProfileImageLightbox) { + if (activeLightbox instanceof ProfileImageLightbox) { const opts = activeLightbox if (opts.profile.avatar) { imgs = [{uri: opts.profile.avatar}] } - } else if (activeLightbox instanceof models.ImagesLightbox) { + } else if (activeLightbox instanceof ImagesLightbox) { const opts = activeLightbox imgs = opts.images } @@ -51,9 +52,13 @@ export const Lightbox = observer(function Lightbox() { } return ( - + ) -}) +} function LightboxInner({ imgs, diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index 92d51ac7..a6c978fd 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -17,7 +17,6 @@ import {useLingui} from '@lingui/react' import {NavigationProp} from 'lib/routes/types' import {isNative} from 'platform/detection' import {BlurView} from '../util/BlurView' -import {ProfileImageLightbox} from 'state/models/ui/shell' import * as Toast from '../util/Toast' import {LoadingPlaceholder} from '../util/LoadingPlaceholder' import {Text} from '../util/text/Text' @@ -30,8 +29,8 @@ import {formatCount} from '../util/numeric/format' import {NativeDropdown, DropdownItem} from '../util/forms/NativeDropdown' import {Link} from '../util/Link' import {ProfileHeaderSuggestedFollows} from './ProfileHeaderSuggestedFollows' -import {useStores} from 'state/index' import {useModalControls} from '#/state/modals' +import {useLightboxControls, ProfileImageLightbox} from '#/state/lightbox' import { useProfileFollowMutation, useProfileUnfollowMutation, @@ -115,10 +114,10 @@ function ProfileHeaderLoaded({ }: Props) { const pal = usePalette('default') const palInverted = usePalette('inverted') - const store = useStores() const {currentAccount} = useSession() const {_} = useLingui() const {openModal} = useModalControls() + const {openLightbox} = useLightboxControls() const navigation = useNavigation() const {track} = useAnalytics() const invalidHandle = isInvalidHandle(profile.handle) @@ -151,9 +150,9 @@ function ProfileHeaderLoaded({ profile.avatar && !(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 () => { if (profile.viewer?.following) { diff --git a/src/view/com/profile/ProfileSubpageHeader.tsx b/src/view/com/profile/ProfileSubpageHeader.tsx index e1b587be..ef128e87 100644 --- a/src/view/com/profile/ProfileSubpageHeader.tsx +++ b/src/view/com/profile/ProfileSubpageHeader.tsx @@ -16,7 +16,7 @@ import {useStores} from 'state/index' import {NavigationProp} from 'lib/routes/types' import {BACK_HITSLOP} from 'lib/constants' import {isNative} from 'platform/detection' -import {ImagesLightbox} from 'state/models/ui/shell' +import {useLightboxControls, ImagesLightbox} from '#/state/lightbox' import {useLingui} from '@lingui/react' import {msg} from '@lingui/macro' import {useSetDrawerOpen} from '#/state/shell' @@ -50,6 +50,7 @@ export const ProfileSubpageHeader = observer(function HeaderImpl({ const navigation = useNavigation() const {_} = useLingui() const {isMobile} = useWebMediaQueries() + const {openLightbox} = useLightboxControls() const pal = usePalette('default') const canGoBack = navigation.canGoBack() @@ -69,9 +70,9 @@ export const ProfileSubpageHeader = observer(function HeaderImpl({ if ( 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 ( diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index b4c7c45a..ca3bf110 100644 --- a/src/view/com/util/post-embeds/index.tsx +++ b/src/view/com/util/post-embeds/index.tsx @@ -19,8 +19,7 @@ import { } from '@atproto/api' import {Link} from '../Link' import {ImageLayoutGrid} from '../images/ImageLayoutGrid' -import {ImagesLightbox} from 'state/models/ui/shell' -import {useStores} from 'state/index' +import {useLightboxControls, ImagesLightbox} from '#/state/lightbox' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {YoutubeEmbed} from './YoutubeEmbed' @@ -49,7 +48,7 @@ export function PostEmbeds({ style?: StyleProp }) { const pal = usePalette('default') - const store = useStores() + const {openLightbox} = useLightboxControls() const {isMobile} = useWebMediaQueries() // quote post with media @@ -104,8 +103,8 @@ export function PostEmbeds({ alt: img.alt, aspectRatio: img.aspectRatio, })) - const openLightbox = (index: number) => { - store.shell.openLightbox(new ImagesLightbox(items, index)) + const _openLightbox = (index: number) => { + openLightbox(new ImagesLightbox(items, index)) } const onPressIn = (_: number) => { InteractionManager.runAfterInteractions(() => { @@ -121,7 +120,7 @@ export function PostEmbeds({ alt={alt} uri={thumb} dimensionsHint={aspectRatio} - onPress={() => openLightbox(0)} + onPress={() => _openLightbox(0)} onPressIn={() => onPressIn(0)} style={[ styles.singleImage, @@ -143,7 +142,7 @@ export function PostEmbeds({