diff --git a/src/App.native.tsx b/src/App.native.tsx index ccc7de32..5955504e 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -22,6 +22,7 @@ import * as Toast from 'view/com/util/Toast' 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 MutedThreadsProvider} from 'state/muted-threads' import {Provider as InvitesStateProvider} from 'state/invites' import {Provider as PrefsStateProvider} from 'state/preferences' @@ -84,7 +85,9 @@ function App() { - + + + diff --git a/src/App.web.tsx b/src/App.web.tsx index 363161bf..9e5b99a9 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -17,6 +17,7 @@ import {ToastContainer} from 'view/com/util/Toast.web' import {ThemeProvider} from 'lib/ThemeContext' import {queryClient} from 'lib/react-query' import {Provider as ShellStateProvider} from 'state/shell' +import {Provider as ModalStateProvider} from 'state/modals' import {Provider as MutedThreadsProvider} from 'state/muted-threads' import {Provider as InvitesStateProvider} from 'state/invites' import {Provider as PrefsStateProvider} from 'state/preferences' @@ -74,7 +75,9 @@ function App() { - + + + diff --git a/src/lib/hooks/useAccountSwitcher.ts b/src/lib/hooks/useAccountSwitcher.ts index 1ddb181a..b165fddb 100644 --- a/src/lib/hooks/useAccountSwitcher.ts +++ b/src/lib/hooks/useAccountSwitcher.ts @@ -7,6 +7,7 @@ import {AccountData} from 'state/models/session' import {reset as resetNavigation} from '../../Navigation' import * as Toast from 'view/com/util/Toast' import {useSetDrawerOpen} from '#/state/shell/drawer-open' +import {useModalControls} from '#/state/modals' export function useAccountSwitcher(): [ boolean, @@ -16,6 +17,7 @@ export function useAccountSwitcher(): [ const {track} = useAnalytics() const store = useStores() const setDrawerOpen = useSetDrawerOpen() + const {closeModal} = useModalControls() const [isSwitching, setIsSwitching] = useState(false) const navigation = useNavigation() @@ -25,6 +27,7 @@ export function useAccountSwitcher(): [ setIsSwitching(true) const success = await store.session.resumeSession(acct) setDrawerOpen(false) + closeModal() store.shell.closeAllActiveElements() if (success) { resetNavigation() @@ -36,7 +39,7 @@ export function useAccountSwitcher(): [ store.session.clear() } }, - [track, setIsSwitching, navigation, store, setDrawerOpen], + [track, setIsSwitching, navigation, store, setDrawerOpen, closeModal], ) return [isSwitching, setIsSwitching, onPressSwitchAccount] diff --git a/src/lib/hooks/useOTAUpdate.ts b/src/lib/hooks/useOTAUpdate.ts index 0ce97a4c..a3584fc9 100644 --- a/src/lib/hooks/useOTAUpdate.ts +++ b/src/lib/hooks/useOTAUpdate.ts @@ -1,15 +1,15 @@ import * as Updates from 'expo-updates' import {useCallback, useEffect} from 'react' import {AppState} from 'react-native' -import {useStores} from 'state/index' import {logger} from '#/logger' +import {useModalControls} from '#/state/modals' export function useOTAUpdate() { - const store = useStores() + const {openModal} = useModalControls() // HELPER FUNCTIONS const showUpdatePopup = useCallback(() => { - store.shell.openModal({ + openModal({ name: 'confirm', title: 'Update Available', message: @@ -20,7 +20,7 @@ export function useOTAUpdate() { }) }, }) - }, [store.shell]) + }, [openModal]) const checkForUpdate = useCallback(async () => { logger.debug('useOTAUpdate: Checking for update...') try { diff --git a/src/lib/media/alt-text.ts b/src/lib/media/alt-text.ts deleted file mode 100644 index 4109f667..00000000 --- a/src/lib/media/alt-text.ts +++ /dev/null @@ -1,12 +0,0 @@ -import {RootStoreModel} from 'state/index' -import {ImageModel} from 'state/models/media/image' - -export async function openAltTextModal( - store: RootStoreModel, - image: ImageModel, -) { - store.shell.openModal({ - name: 'alt-text-image', - image, - }) -} diff --git a/src/lib/media/picker.web.tsx b/src/lib/media/picker.web.tsx index d12685b0..50b9c73e 100644 --- a/src/lib/media/picker.web.tsx +++ b/src/lib/media/picker.web.tsx @@ -4,6 +4,7 @@ import {CameraOpts, CropperOptions} from './types' import {RootStoreModel} from 'state/index' import {Image as RNImage} from 'react-native-image-crop-picker' export {openPicker} from './picker.shared' +import {unstable__openModal} from '#/state/modals' export async function openCamera( _store: RootStoreModel, @@ -14,12 +15,12 @@ export async function openCamera( } export async function openCropper( - store: RootStoreModel, + _store: RootStoreModel, opts: CropperOptions, ): Promise { // TODO handle more opts return new Promise((resolve, reject) => { - store.shell.openModal({ + unstable__openModal({ name: 'crop-image', uri: opts.path, onSelect: (img?: RNImage) => { diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx new file mode 100644 index 00000000..f9bd1e3c --- /dev/null +++ b/src/state/modals/index.tsx @@ -0,0 +1,284 @@ +import React from 'react' +import {AppBskyActorDefs, ModerationUI} from '@atproto/api' +import {StyleProp, ViewStyle, DeviceEventEmitter} from 'react-native' +import {Image as RNImage} from 'react-native-image-crop-picker' + +import {ProfileModel} from '#/state/models/content/profile' +import {ImageModel} from '#/state/models/media/image' +import {ListModel} from '#/state/models/content/list' +import {GalleryModel} from '#/state/models/media/gallery' + +export interface ConfirmModal { + name: 'confirm' + title: string + message: string | (() => JSX.Element) + onPressConfirm: () => void | Promise + onPressCancel?: () => void | Promise + confirmBtnText?: string + confirmBtnStyle?: StyleProp + cancelBtnText?: string +} + +export interface EditProfileModal { + name: 'edit-profile' + profileView: ProfileModel + onUpdate?: () => void +} + +export interface ProfilePreviewModal { + name: 'profile-preview' + did: string +} + +export interface ServerInputModal { + name: 'server-input' + initialService: string + onSelect: (url: string) => void +} + +export interface ModerationDetailsModal { + name: 'moderation-details' + context: 'account' | 'content' + moderation: ModerationUI +} + +export type ReportModal = { + name: 'report' +} & ( + | { + uri: string + cid: string + } + | {did: string} +) + +export interface CreateOrEditListModal { + name: 'create-or-edit-list' + purpose?: string + list?: ListModel + onSave?: (uri: string) => void +} + +export interface UserAddRemoveListsModal { + name: 'user-add-remove-lists' + subject: string + displayName: string + onAdd?: (listUri: string) => void + onRemove?: (listUri: string) => void +} + +export interface ListAddUserModal { + name: 'list-add-user' + list: ListModel + onAdd?: (profile: AppBskyActorDefs.ProfileViewBasic) => void +} + +export interface EditImageModal { + name: 'edit-image' + image: ImageModel + gallery: GalleryModel +} + +export interface CropImageModal { + name: 'crop-image' + uri: string + onSelect: (img?: RNImage) => void +} + +export interface AltTextImageModal { + name: 'alt-text-image' + image: ImageModel +} + +export interface DeleteAccountModal { + name: 'delete-account' +} + +export interface RepostModal { + name: 'repost' + onRepost: () => void + onQuote: () => void + isReposted: boolean +} + +export interface SelfLabelModal { + name: 'self-label' + labels: string[] + hasMedia: boolean + onChange: (labels: string[]) => void +} + +export interface ChangeHandleModal { + name: 'change-handle' + onChanged: () => void +} + +export interface WaitlistModal { + name: 'waitlist' +} + +export interface InviteCodesModal { + name: 'invite-codes' +} + +export interface AddAppPasswordModal { + name: 'add-app-password' +} + +export interface ContentFilteringSettingsModal { + name: 'content-filtering-settings' +} + +export interface ContentLanguagesSettingsModal { + name: 'content-languages-settings' +} + +export interface PostLanguagesSettingsModal { + name: 'post-languages-settings' +} + +export interface BirthDateSettingsModal { + name: 'birth-date-settings' +} + +export interface VerifyEmailModal { + name: 'verify-email' + showReminder?: boolean +} + +export interface ChangeEmailModal { + name: 'change-email' +} + +export interface SwitchAccountModal { + name: 'switch-account' +} + +export interface LinkWarningModal { + name: 'link-warning' + text: string + href: string +} + +export type Modal = + // Account + | AddAppPasswordModal + | ChangeHandleModal + | DeleteAccountModal + | EditProfileModal + | ProfilePreviewModal + | BirthDateSettingsModal + | VerifyEmailModal + | ChangeEmailModal + | SwitchAccountModal + + // Curation + | ContentFilteringSettingsModal + | ContentLanguagesSettingsModal + | PostLanguagesSettingsModal + + // Moderation + | ModerationDetailsModal + | ReportModal + + // Lists + | CreateOrEditListModal + | UserAddRemoveListsModal + | ListAddUserModal + + // Posts + | AltTextImageModal + | CropImageModal + | EditImageModal + | ServerInputModal + | RepostModal + | SelfLabelModal + + // Bluesky access + | WaitlistModal + | InviteCodesModal + + // Generic + | ConfirmModal + | LinkWarningModal + +const ModalContext = React.createContext<{ + isModalActive: boolean + activeModals: Modal[] +}>({ + isModalActive: false, + activeModals: [], +}) + +const ModalControlContext = React.createContext<{ + openModal: (modal: Modal) => void + closeModal: () => void +}>({ + openModal: () => {}, + closeModal: () => {}, +}) + +/** + * @deprecated DO NOT USE THIS unless you have no other choice. + */ +export let unstable__openModal: (modal: Modal) => void = () => { + throw new Error(`ModalContext is not initialized`) +} + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [isModalActive, setIsModalActive] = React.useState(false) + const [activeModals, setActiveModals] = React.useState([]) + + const openModal = React.useCallback( + (modal: Modal) => { + DeviceEventEmitter.emit('navigation') + setActiveModals(activeModals => [...activeModals, modal]) + setIsModalActive(true) + }, + [setIsModalActive, setActiveModals], + ) + + unstable__openModal = openModal + + const closeModal = React.useCallback(() => { + let totalActiveModals = 0 + setActiveModals(activeModals => { + activeModals.pop() + totalActiveModals = activeModals.length + return activeModals + }) + setIsModalActive(totalActiveModals > 0) + }, [setIsModalActive, setActiveModals]) + + const state = React.useMemo( + () => ({ + isModalActive, + activeModals, + }), + [isModalActive, activeModals], + ) + + const methods = React.useMemo( + () => ({ + openModal, + closeModal, + }), + [openModal, closeModal], + ) + + return ( + + + {children} + + + ) +} + +export function useModals() { + return React.useContext(ModalContext) +} + +export function useModalControls() { + return React.useContext(ModalControlContext) +} diff --git a/src/state/models/media/gallery.ts b/src/state/models/media/gallery.ts index 1b22fadb..f9c3efca 100644 --- a/src/state/models/media/gallery.ts +++ b/src/state/models/media/gallery.ts @@ -4,7 +4,6 @@ import {ImageModel} from './image' import {Image as RNImage} from 'react-native-image-crop-picker' import {openPicker} from 'lib/media/picker' import {getImageDim} from 'lib/media/manip' -import {isNative} from 'platform/detection' export class GalleryModel { images: ImageModel[] = [] @@ -42,18 +41,6 @@ export class GalleryModel { } } - async edit(image: ImageModel) { - if (isNative) { - this.crop(image) - } else { - this.rootStore.shell.openModal({ - name: 'edit-image', - image, - gallery: this, - }) - } - } - async paste(uri: string) { if (this.size >= 4) { return diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts index b5fa4e59..8ef322db 100644 --- a/src/state/models/ui/shell.ts +++ b/src/state/models/ui/shell.ts @@ -1,16 +1,12 @@ -import {AppBskyEmbedRecord, AppBskyActorDefs, ModerationUI} from '@atproto/api' +import {AppBskyEmbedRecord} from '@atproto/api' import {RootStoreModel} from '../root-store' import {makeAutoObservable, runInAction} from 'mobx' import {ProfileModel} from '../content/profile' -import {Image as RNImage} from 'react-native-image-crop-picker' -import {ImageModel} from '../media/image' -import {ListModel} from '../content/list' -import {GalleryModel} from '../media/gallery' -import {StyleProp, ViewStyle} from 'react-native' import { shouldRequestEmailConfirmation, setEmailConfirmationRequested, } from '#/state/shell/reminders' +import {unstable__openModal} from '#/state/modals' export type ColorMode = 'system' | 'light' | 'dark' @@ -18,200 +14,6 @@ export function isColorMode(v: unknown): v is ColorMode { return v === 'system' || v === 'light' || v === 'dark' } -export interface ConfirmModal { - name: 'confirm' - title: string - message: string | (() => JSX.Element) - onPressConfirm: () => void | Promise - onPressCancel?: () => void | Promise - confirmBtnText?: string - confirmBtnStyle?: StyleProp - cancelBtnText?: string -} - -export interface EditProfileModal { - name: 'edit-profile' - profileView: ProfileModel - onUpdate?: () => void -} - -export interface ProfilePreviewModal { - name: 'profile-preview' - did: string -} - -export interface ServerInputModal { - name: 'server-input' - initialService: string - onSelect: (url: string) => void -} - -export interface ModerationDetailsModal { - name: 'moderation-details' - context: 'account' | 'content' - moderation: ModerationUI -} - -export type ReportModal = { - name: 'report' -} & ( - | { - uri: string - cid: string - } - | {did: string} -) - -export interface CreateOrEditListModal { - name: 'create-or-edit-list' - purpose?: string - list?: ListModel - onSave?: (uri: string) => void -} - -export interface UserAddRemoveListsModal { - name: 'user-add-remove-lists' - subject: string - displayName: string - onAdd?: (listUri: string) => void - onRemove?: (listUri: string) => void -} - -export interface ListAddUserModal { - name: 'list-add-user' - list: ListModel - onAdd?: (profile: AppBskyActorDefs.ProfileViewBasic) => void -} - -export interface EditImageModal { - name: 'edit-image' - image: ImageModel - gallery: GalleryModel -} - -export interface CropImageModal { - name: 'crop-image' - uri: string - onSelect: (img?: RNImage) => void -} - -export interface AltTextImageModal { - name: 'alt-text-image' - image: ImageModel -} - -export interface DeleteAccountModal { - name: 'delete-account' -} - -export interface RepostModal { - name: 'repost' - onRepost: () => void - onQuote: () => void - isReposted: boolean -} - -export interface SelfLabelModal { - name: 'self-label' - labels: string[] - hasMedia: boolean - onChange: (labels: string[]) => void -} - -export interface ChangeHandleModal { - name: 'change-handle' - onChanged: () => void -} - -export interface WaitlistModal { - name: 'waitlist' -} - -export interface InviteCodesModal { - name: 'invite-codes' -} - -export interface AddAppPasswordModal { - name: 'add-app-password' -} - -export interface ContentFilteringSettingsModal { - name: 'content-filtering-settings' -} - -export interface ContentLanguagesSettingsModal { - name: 'content-languages-settings' -} - -export interface PostLanguagesSettingsModal { - name: 'post-languages-settings' -} - -export interface BirthDateSettingsModal { - name: 'birth-date-settings' -} - -export interface VerifyEmailModal { - name: 'verify-email' - showReminder?: boolean -} - -export interface ChangeEmailModal { - name: 'change-email' -} - -export interface SwitchAccountModal { - name: 'switch-account' -} - -export interface LinkWarningModal { - name: 'link-warning' - text: string - href: string -} - -export type Modal = - // Account - | AddAppPasswordModal - | ChangeHandleModal - | DeleteAccountModal - | EditProfileModal - | ProfilePreviewModal - | BirthDateSettingsModal - | VerifyEmailModal - | ChangeEmailModal - | SwitchAccountModal - - // Curation - | ContentFilteringSettingsModal - | ContentLanguagesSettingsModal - | PostLanguagesSettingsModal - - // Moderation - | ModerationDetailsModal - | ReportModal - - // Lists - | CreateOrEditListModal - | UserAddRemoveListsModal - | ListAddUserModal - - // Posts - | AltTextImageModal - | CropImageModal - | EditImageModal - | ServerInputModal - | RepostModal - | SelfLabelModal - - // Bluesky access - | WaitlistModal - | InviteCodesModal - - // Generic - | ConfirmModal - | LinkWarningModal - interface LightboxModel {} export class ProfileImageLightbox implements LightboxModel { @@ -267,8 +69,6 @@ export interface ComposerOpts { } export class ShellUiModel { - isModalActive = false - activeModals: Modal[] = [] isLightboxActive = false activeLightbox: ProfileImageLightbox | ImagesLightbox | null = null isComposerActive = false @@ -293,10 +93,6 @@ export class ShellUiModel { this.closeLightbox() return true } - if (this.isModalActive) { - this.closeModal() - return true - } if (this.isComposerActive) { this.closeComposer() return true @@ -311,25 +107,11 @@ export class ShellUiModel { if (this.isLightboxActive) { this.closeLightbox() } - while (this.isModalActive) { - this.closeModal() - } if (this.isComposerActive) { this.closeComposer() } } - openModal(modal: Modal) { - this.rootStore.emitNavigation() - this.isModalActive = true - this.activeModals.push(modal) - } - - closeModal() { - this.activeModals.pop() - this.isModalActive = this.activeModals.length > 0 - } - openLightbox(lightbox: ProfileImageLightbox | ImagesLightbox) { this.rootStore.emitNavigation() this.isLightboxActive = true @@ -363,7 +145,7 @@ export class ShellUiModel { setupLoginModals() { this.rootStore.onSessionReady(() => { if (shouldRequestEmailConfirmation(this.rootStore.session)) { - this.openModal({name: 'verify-email', showReminder: true}) + unstable__openModal({name: 'verify-email', showReminder: true}) setEmailConfirmationRequested() } }) diff --git a/src/view/com/auth/create/Step2.tsx b/src/view/com/auth/create/Step2.tsx index 60e19756..b2054150 100644 --- a/src/view/com/auth/create/Step2.tsx +++ b/src/view/com/auth/create/Step2.tsx @@ -10,8 +10,8 @@ import {usePalette} from 'lib/hooks/usePalette' import {TextInput} from '../util/TextInput' import {Policies} from './Policies' import {ErrorMessage} from 'view/com/util/error/ErrorMessage' -import {useStores} from 'state/index' import {isWeb} from 'platform/detection' +import {useModalControls} from '#/state/modals' /** STEP 2: Your account * @field Invite code or waitlist @@ -28,11 +28,11 @@ export const Step2 = observer(function Step2Impl({ model: CreateAccountModel }) { const pal = usePalette('default') - const store = useStores() + const {openModal} = useModalControls() const onPressWaitlist = React.useCallback(() => { - store.shell.openModal({name: 'waitlist'}) - }, [store]) + openModal({name: 'waitlist'}) + }, [openModal]) return ( diff --git a/src/view/com/auth/login/Login.tsx b/src/view/com/auth/login/Login.tsx index acc05b6c..24a657c6 100644 --- a/src/view/com/auth/login/Login.tsx +++ b/src/view/com/auth/login/Login.tsx @@ -31,6 +31,7 @@ import {useTheme} from 'lib/ThemeContext' import {cleanError} from 'lib/strings/errors' import {isWeb} from 'platform/detection' import {logger} from '#/logger' +import {useModalControls} from '#/state/modals' enum Forms { Login, @@ -303,9 +304,10 @@ const LoginForm = ({ const [identifier, setIdentifier] = useState(initialHandle) const [password, setPassword] = useState('') const passwordInputRef = useRef(null) + const {openModal} = useModalControls() const onPressSelectService = () => { - store.shell.openModal({ + openModal({ name: 'server-input', initialService: serviceUrl, onSelect: setServiceUrl, @@ -528,7 +530,6 @@ const LoginForm = ({ } const ForgotPasswordForm = ({ - store, error, serviceUrl, serviceDescription, @@ -551,13 +552,14 @@ const ForgotPasswordForm = ({ const [isProcessing, setIsProcessing] = useState(false) const [email, setEmail] = useState('') const {screen} = useAnalytics() + const {openModal} = useModalControls() useEffect(() => { screen('Signin:ForgotPassword') }, [screen]) const onPressSelectService = () => { - store.shell.openModal({ + openModal({ name: 'server-input', initialService: serviceUrl, onSelect: setServiceUrl, diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 632e72fd..68f70682 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -49,6 +49,7 @@ import {LabelsBtn} from './labels/LabelsBtn' import {SelectLangBtn} from './select-language/SelectLangBtn' import {EmojiPickerButton} from './text-input/web/EmojiPicker.web' import {insertMentionAt} from 'lib/strings/mention-manip' +import {useModals, useModalControls} from '#/state/modals' import {useRequireAltTextEnabled} from '#/state/preferences' import { useLanguagePrefs, @@ -64,6 +65,8 @@ export const ComposePost = observer(function ComposePost({ quote: initQuote, mention: initMention, }: Props) { + const {activeModals} = useModals() + const {openModal, closeModal} = useModalControls() const {track} = useAnalytics() const pal = usePalette('default') const {isDesktop, isMobile} = useWebMediaQueries() @@ -118,18 +121,18 @@ export const ComposePost = observer(function ComposePost({ const onPressCancel = useCallback(() => { if (graphemeLength > 0 || !gallery.isEmpty) { - if (store.shell.activeModals.some(modal => modal.name === 'confirm')) { - store.shell.closeModal() + if (activeModals.some(modal => modal.name === 'confirm')) { + closeModal() } if (Keyboard) { Keyboard.dismiss() } - store.shell.openModal({ + openModal({ name: 'confirm', title: 'Discard draft', onPressConfirm: onClose, onPressCancel: () => { - store.shell.closeModal() + closeModal() }, message: "Are you sure you'd like to discard this draft?", confirmBtnText: 'Discard', @@ -138,7 +141,7 @@ export const ComposePost = observer(function ComposePost({ } else { onClose() } - }, [store, onClose, graphemeLength, gallery]) + }, [openModal, closeModal, activeModals, onClose, graphemeLength, gallery]) // android back button useEffect(() => { if (!isAndroid) { diff --git a/src/view/com/composer/labels/LabelsBtn.tsx b/src/view/com/composer/labels/LabelsBtn.tsx index 96908d47..4b6ad81c 100644 --- a/src/view/com/composer/labels/LabelsBtn.tsx +++ b/src/view/com/composer/labels/LabelsBtn.tsx @@ -3,11 +3,11 @@ import {Keyboard, StyleSheet} from 'react-native' import {observer} from 'mobx-react-lite' import {Button} from 'view/com/util/forms/Button' import {usePalette} from 'lib/hooks/usePalette' -import {useStores} from 'state/index' import {ShieldExclamation} from 'lib/icons' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' import {isNative} from 'platform/detection' +import {useModalControls} from '#/state/modals' export const LabelsBtn = observer(function LabelsBtn({ labels, @@ -19,7 +19,7 @@ export const LabelsBtn = observer(function LabelsBtn({ onChange: (v: string[]) => void }) { const pal = usePalette('default') - const store = useStores() + const {openModal} = useModalControls() return (