diff --git a/src/App.native.tsx b/src/App.native.tsx index f5d35cf741..5955504e3e 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -22,6 +22,10 @@ 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' SplashScreen.preventAutoHideAsync() @@ -78,7 +82,15 @@ function App() { return ( - + + + + + + + + + ) } diff --git a/src/App.web.tsx b/src/App.web.tsx index b2a0eb8bc7..ef275b3926 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -20,6 +20,10 @@ import {i18n} from '@lingui/core' 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 MutedThreadsProvider} from 'state/muted-threads' +import {Provider as InvitesStateProvider} from 'state/invites' +import {Provider as PrefsStateProvider} from 'state/preferences' const InnerApp = observer(function AppImpl() { const colorMode = useColorMode() @@ -74,7 +78,15 @@ function App() { return ( - + + + + + + + + + ) } diff --git a/src/lib/hooks/useAccountSwitcher.ts b/src/lib/hooks/useAccountSwitcher.ts index 1ddb181a85..b165fddb59 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 0ce97a4c8b..a3584fc9db 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 4109f667a0..0000000000 --- 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 d12685b0c5..50b9c73e94 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/invites.tsx b/src/state/invites.tsx new file mode 100644 index 0000000000..6a0d1b5900 --- /dev/null +++ b/src/state/invites.tsx @@ -0,0 +1,56 @@ +import React from 'react' +import * as persisted from '#/state/persisted' + +type StateContext = persisted.Schema['invites'] +type ApiContext = { + setInviteCopied: (code: string) => void +} + +const stateContext = React.createContext( + persisted.defaults.invites, +) +const apiContext = React.createContext({ + setInviteCopied(_: string) {}, +}) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [state, setState] = React.useState(persisted.get('invites')) + + const api = React.useMemo( + () => ({ + setInviteCopied(code: string) { + setState(state => { + state = { + ...state, + copiedInvites: state.copiedInvites.includes(code) + ? state.copiedInvites + : state.copiedInvites.concat([code]), + } + persisted.write('invites', state) + return state + }) + }, + }), + [setState], + ) + + React.useEffect(() => { + return persisted.onUpdate(() => { + setState(persisted.get('invites')) + }) + }, [setState]) + + return ( + + {children} + + ) +} + +export function useInvitesState() { + return React.useContext(stateContext) +} + +export function useInvitesAPI() { + return React.useContext(apiContext) +} diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx new file mode 100644 index 0000000000..f9bd1e3c9a --- /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/content/post-thread-item.ts b/src/state/models/content/post-thread-item.ts index 942f3acc81..855b038c46 100644 --- a/src/state/models/content/post-thread-item.ts +++ b/src/state/models/content/post-thread-item.ts @@ -63,10 +63,6 @@ export class PostThreadItemModel { return this.post.uri } - get isThreadMuted() { - return this.data.isThreadMuted - } - get moderation(): PostModeration { return this.data.moderation } @@ -129,10 +125,6 @@ export class PostThreadItemModel { this.data.toggleRepost() } - async toggleThreadMute() { - this.data.toggleThreadMute() - } - async delete() { this.data.delete() } diff --git a/src/state/models/content/post-thread.ts b/src/state/models/content/post-thread.ts index fd194056ae..65e74f7ce9 100644 --- a/src/state/models/content/post-thread.ts +++ b/src/state/models/content/post-thread.ts @@ -74,10 +74,6 @@ export class PostThreadModel { return this.resolvedUri } - get isThreadMuted() { - return this.rootStore.mutedThreads.uris.has(this.rootUri) - } - get isCachedPostAReply() { if (AppBskyFeedPost.isRecord(this.thread?.post.record)) { return !!this.thread?.post.record.reply @@ -140,14 +136,6 @@ export class PostThreadModel { this.refresh() } - async toggleThreadMute() { - if (this.isThreadMuted) { - this.rootStore.mutedThreads.uris.delete(this.rootUri) - } else { - this.rootStore.mutedThreads.uris.add(this.rootUri) - } - } - // state transitions // = diff --git a/src/state/models/discovery/onboarding.ts b/src/state/models/discovery/onboarding.ts deleted file mode 100644 index 3638e7f0d2..0000000000 --- a/src/state/models/discovery/onboarding.ts +++ /dev/null @@ -1,106 +0,0 @@ -import {makeAutoObservable} from 'mobx' -import {RootStoreModel} from '../root-store' -import {hasProp} from 'lib/type-guards' -import {track} from 'lib/analytics/analytics' -import {SuggestedActorsModel} from './suggested-actors' - -export const OnboardingScreenSteps = { - Welcome: 'Welcome', - RecommendedFeeds: 'RecommendedFeeds', - RecommendedFollows: 'RecommendedFollows', - Home: 'Home', -} as const - -type OnboardingStep = - (typeof OnboardingScreenSteps)[keyof typeof OnboardingScreenSteps] -const OnboardingStepsArray = Object.values(OnboardingScreenSteps) -export class OnboardingModel { - // state - step: OnboardingStep = 'Home' // default state to skip onboarding, only enabled for new users by calling start() - - // data - suggestedActors: SuggestedActorsModel - - constructor(public rootStore: RootStoreModel) { - this.suggestedActors = new SuggestedActorsModel(this.rootStore) - makeAutoObservable(this, { - rootStore: false, - hydrate: false, - serialize: false, - }) - } - - serialize(): unknown { - return { - step: this.step, - } - } - - hydrate(v: unknown) { - if (typeof v === 'object' && v !== null) { - if ( - hasProp(v, 'step') && - typeof v.step === 'string' && - OnboardingStepsArray.includes(v.step as OnboardingStep) - ) { - this.step = v.step as OnboardingStep - } - } else { - // if there is no valid state, we'll just reset - this.reset() - } - } - - /** - * Returns the name of the next screen in the onboarding process based on the current step or screen name provided. - * @param {OnboardingStep} [currentScreenName] - * @returns name of next screen in the onboarding process - */ - next(currentScreenName?: OnboardingStep) { - currentScreenName = currentScreenName || this.step - if (currentScreenName === 'Welcome') { - this.step = 'RecommendedFeeds' - return this.step - } else if (this.step === 'RecommendedFeeds') { - this.step = 'RecommendedFollows' - // prefetch recommended follows - this.suggestedActors.loadMore(true) - return this.step - } else if (this.step === 'RecommendedFollows') { - this.finish() - return this.step - } else { - // if we get here, we're in an invalid state, let's just go Home - return 'Home' - } - } - - start() { - this.step = 'Welcome' - track('Onboarding:Begin') - } - - finish() { - this.rootStore.me.mainFeed.refresh() // load the selected content - this.step = 'Home' - track('Onboarding:Complete') - } - - reset() { - this.step = 'Welcome' - track('Onboarding:Reset') - } - - skip() { - this.step = 'Home' - track('Onboarding:Skipped') - } - - get isComplete() { - return this.step === 'Home' - } - - get isActive() { - return !this.isComplete - } -} diff --git a/src/state/models/feeds/notifications.ts b/src/state/models/feeds/notifications.ts index 607e3038bb..5f34feb662 100644 --- a/src/state/models/feeds/notifications.ts +++ b/src/state/models/feeds/notifications.ts @@ -18,6 +18,7 @@ import {RootStoreModel} from '../root-store' import {PostThreadModel} from '../content/post-thread' import {cleanError} from 'lib/strings/errors' import {logger} from '#/logger' +import {isThreadMuted} from '#/state/muted-threads' const GROUPABLE_REASONS = ['like', 'repost', 'follow'] const PAGE_SIZE = 30 @@ -303,7 +304,7 @@ export class NotificationsFeedModel { } get unreadCountLabel(): string { - const count = this.unreadCount + this.rootStore.invitedUsers.numNotifs + const count = this.unreadCount if (count >= MAX_VISIBLE_NOTIFS) { return `${MAX_VISIBLE_NOTIFS}+` } @@ -550,8 +551,7 @@ export class NotificationsFeedModel { .filter(item => { const hideByLabel = item.shouldFilter let mutedThread = !!( - item.reasonSubjectRootUri && - this.rootStore.mutedThreads.uris.has(item.reasonSubjectRootUri) + item.reasonSubjectRootUri && isThreadMuted(item.reasonSubjectRootUri) ) return !hideByLabel && !mutedThread }) diff --git a/src/state/models/feeds/post.ts b/src/state/models/feeds/post.ts index d064edc21b..4fa1213b57 100644 --- a/src/state/models/feeds/post.ts +++ b/src/state/models/feeds/post.ts @@ -75,10 +75,6 @@ export class PostsFeedItemModel { return this.post.uri } - get isThreadMuted() { - return this.rootStore.mutedThreads.uris.has(this.rootUri) - } - get moderation(): PostModeration { return moderatePost(this.post, this.rootStore.preferences.moderationOpts) } @@ -172,20 +168,6 @@ export class PostsFeedItemModel { } } - async toggleThreadMute() { - try { - if (this.isThreadMuted) { - this.rootStore.mutedThreads.uris.delete(this.rootUri) - track('Post:ThreadUnmute') - } else { - this.rootStore.mutedThreads.uris.add(this.rootUri) - track('Post:ThreadMute') - } - } catch (error) { - logger.error('Failed to toggle thread mute', {error}) - } - } - async delete() { try { await this.rootStore.agent.deletePost(this.post.uri) diff --git a/src/state/models/invited-users.ts b/src/state/models/invited-users.ts deleted file mode 100644 index 9ba65e19ec..0000000000 --- a/src/state/models/invited-users.ts +++ /dev/null @@ -1,88 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import {ComAtprotoServerDefs, AppBskyActorDefs} from '@atproto/api' -import {RootStoreModel} from './root-store' -import {isObj, hasProp, isStrArray} from 'lib/type-guards' -import {logger} from '#/logger' - -export class InvitedUsers { - copiedInvites: string[] = [] - seenDids: string[] = [] - profiles: AppBskyActorDefs.ProfileViewDetailed[] = [] - - get numNotifs() { - return this.profiles.length - } - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable( - this, - {rootStore: false, serialize: false, hydrate: false}, - {autoBind: true}, - ) - } - - serialize() { - return {seenDids: this.seenDids, copiedInvites: this.copiedInvites} - } - - hydrate(v: unknown) { - if (isObj(v) && hasProp(v, 'seenDids') && isStrArray(v.seenDids)) { - this.seenDids = v.seenDids - } - if ( - isObj(v) && - hasProp(v, 'copiedInvites') && - isStrArray(v.copiedInvites) - ) { - this.copiedInvites = v.copiedInvites - } - } - - async fetch(invites: ComAtprotoServerDefs.InviteCode[]) { - // pull the dids of invited users not marked seen - const dids = [] - for (const invite of invites) { - for (const use of invite.uses) { - if (!this.seenDids.includes(use.usedBy)) { - dids.push(use.usedBy) - } - } - } - - // fetch their profiles - this.profiles = [] - if (dids.length) { - try { - const res = await this.rootStore.agent.app.bsky.actor.getProfiles({ - actors: dids, - }) - runInAction(() => { - // save the ones following -- these are the ones we want to notify the user about - this.profiles = res.data.profiles.filter( - profile => !profile.viewer?.following, - ) - }) - this.rootStore.me.follows.hydrateMany(this.profiles) - } catch (e) { - logger.error('Failed to fetch profiles for invited users', { - error: e, - }) - } - } - } - - isInviteCopied(invite: string) { - return this.copiedInvites.includes(invite) - } - - setInviteCopied(invite: string) { - if (!this.isInviteCopied(invite)) { - this.copiedInvites.push(invite) - } - } - - markSeen(did: string) { - this.seenDids.push(did) - this.profiles = this.profiles.filter(profile => profile.did !== did) - } -} diff --git a/src/state/models/me.ts b/src/state/models/me.ts index d12cb68c41..d3061f166b 100644 --- a/src/state/models/me.ts +++ b/src/state/models/me.ts @@ -193,7 +193,6 @@ export class MeModel { error: e, }) } - await this.rootStore.invitedUsers.fetch(this.invites) } } diff --git a/src/state/models/media/gallery.ts b/src/state/models/media/gallery.ts index 1b22fadbdb..f9c3efcad8 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/muted-threads.ts b/src/state/models/muted-threads.ts deleted file mode 100644 index e6f2027452..0000000000 --- a/src/state/models/muted-threads.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * This is a temporary client-side system for storing muted threads - * When the system lands on prod we should switch to that - */ - -import {makeAutoObservable} from 'mobx' -import {isObj, hasProp, isStrArray} from 'lib/type-guards' - -export class MutedThreads { - uris: Set = new Set() - - constructor() { - makeAutoObservable( - this, - {serialize: false, hydrate: false}, - {autoBind: true}, - ) - } - - serialize() { - return {uris: Array.from(this.uris)} - } - - hydrate(v: unknown) { - if (isObj(v) && hasProp(v, 'uris') && isStrArray(v.uris)) { - this.uris = new Set(v.uris) - } - } -} diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index 1943f6dbc1..d11e9a1480 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -15,12 +15,9 @@ import {ProfilesCache} from './cache/profiles-view' import {PostsCache} from './cache/posts' import {LinkMetasCache} from './cache/link-metas' import {MeModel} from './me' -import {InvitedUsers} from './invited-users' import {PreferencesModel} from './ui/preferences' import {resetToTab} from '../../Navigation' import {ImageSizesCache} from './cache/image-sizes' -import {MutedThreads} from './muted-threads' -import {Reminders} from './ui/reminders' import {reset as resetNavigation} from '../../Navigation' import {logger} from '#/logger' @@ -28,7 +25,6 @@ import {logger} from '#/logger' // remove after backend testing finishes // -prf import {applyDebugHeader} from 'lib/api/debug-appview-proxy-header' -import {OnboardingModel} from './discovery/onboarding' export const appInfo = z.object({ build: z.string(), @@ -45,15 +41,11 @@ export class RootStoreModel { shell = new ShellUiModel(this) preferences = new PreferencesModel(this) me = new MeModel(this) - onboarding = new OnboardingModel(this) - invitedUsers = new InvitedUsers(this) handleResolutions = new HandleResolutionsCache() profiles = new ProfilesCache(this) posts = new PostsCache(this) linkMetas = new LinkMetasCache(this) imageSizes = new ImageSizesCache() - mutedThreads = new MutedThreads() - reminders = new Reminders(this) constructor(agent: BskyAgent) { this.agent = agent @@ -73,11 +65,7 @@ export class RootStoreModel { appInfo: this.appInfo, session: this.session.serialize(), me: this.me.serialize(), - onboarding: this.onboarding.serialize(), preferences: this.preferences.serialize(), - invitedUsers: this.invitedUsers.serialize(), - mutedThreads: this.mutedThreads.serialize(), - reminders: this.reminders.serialize(), } } @@ -92,24 +80,12 @@ export class RootStoreModel { if (hasProp(v, 'me')) { this.me.hydrate(v.me) } - if (hasProp(v, 'onboarding')) { - this.onboarding.hydrate(v.onboarding) - } if (hasProp(v, 'session')) { this.session.hydrate(v.session) } if (hasProp(v, 'preferences')) { this.preferences.hydrate(v.preferences) } - if (hasProp(v, 'invitedUsers')) { - this.invitedUsers.hydrate(v.invitedUsers) - } - if (hasProp(v, 'mutedThreads')) { - this.mutedThreads.hydrate(v.mutedThreads) - } - if (hasProp(v, 'reminders')) { - this.reminders.hydrate(v.reminders) - } } } diff --git a/src/state/models/ui/create-account.ts b/src/state/models/ui/create-account.ts index 1711b530fb..39c881db63 100644 --- a/src/state/models/ui/create-account.ts +++ b/src/state/models/ui/create-account.ts @@ -9,6 +9,7 @@ import {cleanError} from 'lib/strings/errors' import {getAge} from 'lib/strings/time' import {track} from 'lib/analytics/analytics' import {logger} from '#/logger' +import {DispatchContext as OnboardingDispatchContext} from '#/state/shell/onboarding' const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago @@ -90,7 +91,7 @@ export class CreateAccountModel { } } - async submit() { + async submit(onboardingDispatch: OnboardingDispatchContext) { if (!this.email) { this.setStep(2) return this.setError('Please enter your email.') @@ -111,7 +112,7 @@ export class CreateAccountModel { this.setIsProcessing(true) try { - this.rootStore.onboarding.start() // start now to avoid flashing the wrong view + onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view await this.rootStore.session.createAccount({ service: this.serviceUrl, email: this.email, @@ -122,7 +123,7 @@ export class CreateAccountModel { /* dont await */ this.rootStore.preferences.setBirthDate(this.birthDate) track('Create Account') } catch (e: any) { - this.rootStore.onboarding.skip() // undo starting the onboard + onboardingDispatch({type: 'skip'}) // undo starting the onboard let errMsg = e.toString() if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) { errMsg = diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts index 6e43198a31..9514865920 100644 --- a/src/state/models/ui/preferences.ts +++ b/src/state/models/ui/preferences.ts @@ -10,11 +10,10 @@ import {isObj, hasProp} from 'lib/type-guards' import {RootStoreModel} from '../root-store' import {ModerationOpts} from '@atproto/api' import {DEFAULT_FEEDS} from 'lib/constants' -import {deviceLocales} from 'platform/detection' import {getAge} from 'lib/strings/time' import {FeedTuner} from 'lib/api/feed-manip' -import {LANGUAGES} from '../../../locale/languages' import {logger} from '#/logger' +import {getContentLanguages} from '#/state/preferences/languages' // TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf export type LabelPreference = APILabelPreference | 'show' @@ -34,9 +33,6 @@ const LABEL_GROUPS = [ 'impersonation', ] const VISIBILITY_VALUES = ['ignore', 'warn', 'hide'] -const DEFAULT_LANG_CODES = (deviceLocales || []) - .concat(['en', 'ja', 'pt', 'de']) - .slice(0, 6) const THREAD_SORT_VALUES = ['oldest', 'newest', 'most-likes', 'random'] interface LegacyPreferences { @@ -62,10 +58,6 @@ export class LabelPreferencesModel { export class PreferencesModel { adultContentEnabled = false - primaryLanguage: string = deviceLocales[0] || 'en' - contentLanguages: string[] = deviceLocales || [] - postLanguage: string = deviceLocales[0] || 'en' - postLanguageHistory: string[] = DEFAULT_LANG_CODES contentLabels = new LabelPreferencesModel() savedFeeds: string[] = [] pinnedFeeds: string[] = [] @@ -83,7 +75,6 @@ export class PreferencesModel { prioritizeFollowedUsers: true, lab_treeViewEnabled: false, // experimental } - requireAltTextEnabled: boolean = false // used to help with transitions from device-stored to server-stored preferences legacyPreferences: LegacyPreferences | undefined @@ -104,14 +95,9 @@ export class PreferencesModel { serialize() { return { - primaryLanguage: this.primaryLanguage, - contentLanguages: this.contentLanguages, - postLanguage: this.postLanguage, - postLanguageHistory: this.postLanguageHistory, contentLabels: this.contentLabels, savedFeeds: this.savedFeeds, pinnedFeeds: this.pinnedFeeds, - requireAltTextEnabled: this.requireAltTextEnabled, } } @@ -122,44 +108,6 @@ export class PreferencesModel { */ hydrate(v: unknown) { if (isObj(v)) { - if ( - hasProp(v, 'primaryLanguage') && - typeof v.primaryLanguage === 'string' - ) { - this.primaryLanguage = v.primaryLanguage - } else { - // default to the device languages - this.primaryLanguage = deviceLocales[0] || 'en' - } - // check if content languages in preferences exist, otherwise default to device languages - if ( - hasProp(v, 'contentLanguages') && - Array.isArray(v.contentLanguages) && - typeof v.contentLanguages.every(item => typeof item === 'string') - ) { - this.contentLanguages = v.contentLanguages - } else { - // default to the device languages - this.contentLanguages = deviceLocales - } - if (hasProp(v, 'postLanguage') && typeof v.postLanguage === 'string') { - this.postLanguage = v.postLanguage - } else { - // default to the device languages - this.postLanguage = deviceLocales[0] || 'en' - } - if ( - hasProp(v, 'postLanguageHistory') && - Array.isArray(v.postLanguageHistory) && - typeof v.postLanguageHistory.every(item => typeof item === 'string') - ) { - this.postLanguageHistory = v.postLanguageHistory - .concat(DEFAULT_LANG_CODES) - .slice(0, 6) - } else { - // default to a starter set - this.postLanguageHistory = DEFAULT_LANG_CODES - } // check if content labels in preferences exist, then hydrate if (hasProp(v, 'contentLabels') && typeof v.contentLabels === 'object') { Object.assign(this.contentLabels, v.contentLabels) @@ -180,13 +128,6 @@ export class PreferencesModel { ) { this.pinnedFeeds = v.pinnedFeeds } - // check if requiring alt text is enabled in preferences, then hydrate - if ( - hasProp(v, 'requireAltTextEnabled') && - typeof v.requireAltTextEnabled === 'boolean' - ) { - this.requireAltTextEnabled = v.requireAltTextEnabled - } // grab legacy values this.legacyPreferences = getLegacyPreferences(v) } @@ -271,9 +212,6 @@ export class PreferencesModel { try { runInAction(() => { this.contentLabels = new LabelPreferencesModel() - this.contentLanguages = deviceLocales - this.postLanguage = deviceLocales ? deviceLocales.join(',') : 'en' - this.postLanguageHistory = DEFAULT_LANG_CODES this.savedFeeds = [] this.pinnedFeeds = [] }) @@ -285,81 +223,6 @@ export class PreferencesModel { } } - // languages - // = - - hasContentLanguage(code2: string) { - return this.contentLanguages.includes(code2) - } - - toggleContentLanguage(code2: string) { - if (this.hasContentLanguage(code2)) { - this.contentLanguages = this.contentLanguages.filter( - lang => lang !== code2, - ) - } else { - this.contentLanguages = this.contentLanguages.concat([code2]) - } - } - - /** - * A getter that splits `this.postLanguage` into an array of strings. - * - * This was previously the main field on this model, but now we're - * concatenating lang codes to make multi-selection a little better. - */ - get postLanguages() { - // filter out empty strings if exist - return this.postLanguage.split(',').filter(Boolean) - } - - hasPostLanguage(code2: string) { - return this.postLanguages.includes(code2) - } - - togglePostLanguage(code2: string) { - if (this.hasPostLanguage(code2)) { - this.postLanguage = this.postLanguages - .filter(lang => lang !== code2) - .join(',') - } else { - // sort alphabetically for deterministic comparison in context menu - this.postLanguage = this.postLanguages - .concat([code2]) - .sort((a, b) => a.localeCompare(b)) - .join(',') - } - } - - setPostLanguage(commaSeparatedLangCodes: string) { - this.postLanguage = commaSeparatedLangCodes - } - - /** - * Saves whatever language codes are currently selected into a history array, - * which is then used to populate the language selector menu. - */ - savePostLanguageToHistory() { - // filter out duplicate `this.postLanguage` if exists, and prepend - // value to start of array - this.postLanguageHistory = [this.postLanguage] - .concat( - this.postLanguageHistory.filter( - commaSeparatedLangCodes => - commaSeparatedLangCodes !== this.postLanguage, - ), - ) - .slice(0, 6) - } - - getReadablePostLanguages() { - const all = this.postLanguages.map(code2 => { - const lang = LANGUAGES.find(l => l.code2 === code2) - return lang ? lang.name : code2 - }) - return all.join(', ') - } - // moderation // = @@ -608,21 +471,13 @@ export class PreferencesModel { } } - toggleRequireAltTextEnabled() { - this.requireAltTextEnabled = !this.requireAltTextEnabled - } - - setPrimaryLanguage(lang: string) { - this.primaryLanguage = lang - } - getFeedTuners( feedType: 'home' | 'following' | 'author' | 'custom' | 'list' | 'likes', ) { if (feedType === 'custom') { return [ FeedTuner.dedupReposts, - FeedTuner.preferredLangOnly(this.contentLanguages), + FeedTuner.preferredLangOnly(getContentLanguages()), ] } if (feedType === 'list') { diff --git a/src/state/models/ui/reminders.e2e.ts b/src/state/models/ui/reminders.e2e.ts deleted file mode 100644 index ec0eca40d2..0000000000 --- a/src/state/models/ui/reminders.e2e.ts +++ /dev/null @@ -1,24 +0,0 @@ -import {makeAutoObservable} from 'mobx' -import {RootStoreModel} from '../root-store' - -export class Reminders { - constructor(public rootStore: RootStoreModel) { - makeAutoObservable( - this, - {serialize: false, hydrate: false}, - {autoBind: true}, - ) - } - - serialize() { - return {} - } - - hydrate(_v: unknown) {} - - get shouldRequestEmailConfirmation() { - return false - } - - setEmailConfirmationRequested() {} -} diff --git a/src/state/models/ui/reminders.ts b/src/state/models/ui/reminders.ts deleted file mode 100644 index c650de0040..0000000000 --- a/src/state/models/ui/reminders.ts +++ /dev/null @@ -1,64 +0,0 @@ -import {makeAutoObservable} from 'mobx' -import {isObj, hasProp} from 'lib/type-guards' -import {RootStoreModel} from '../root-store' -import {toHashCode} from 'lib/strings/helpers' - -export class Reminders { - lastEmailConfirm: Date | null = null - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable( - this, - {serialize: false, hydrate: false}, - {autoBind: true}, - ) - } - - serialize() { - return { - lastEmailConfirm: this.lastEmailConfirm - ? this.lastEmailConfirm.toISOString() - : undefined, - } - } - - hydrate(v: unknown) { - if ( - isObj(v) && - hasProp(v, 'lastEmailConfirm') && - typeof v.lastEmailConfirm === 'string' - ) { - this.lastEmailConfirm = new Date(v.lastEmailConfirm) - } - } - - get shouldRequestEmailConfirmation() { - const sess = this.rootStore.session.currentSession - if (!sess) { - return false - } - if (sess.emailConfirmed) { - return false - } - if (this.rootStore.onboarding.isActive) { - return false - } - // only prompt once - if (this.lastEmailConfirm) { - return false - } - const today = new Date() - // shard the users into 2 day of the week buckets - // (this is to avoid a sudden influx of email updates when - // this feature rolls out) - const code = toHashCode(sess.did) % 7 - if (code !== today.getDay() && code !== (today.getDay() + 1) % 7) { - return false - } - return true - } - - setEmailConfirmationRequested() { - this.lastEmailConfirm = new Date() - } -} diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts index d39131629e..8ef322db53 100644 --- a/src/state/models/ui/shell.ts +++ b/src/state/models/ui/shell.ts @@ -1,12 +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' @@ -14,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 { @@ -263,8 +69,6 @@ export interface ComposerOpts { } export class ShellUiModel { - isModalActive = false - activeModals: Modal[] = [] isLightboxActive = false activeLightbox: ProfileImageLightbox | ImagesLightbox | null = null isComposerActive = false @@ -289,10 +93,6 @@ export class ShellUiModel { this.closeLightbox() return true } - if (this.isModalActive) { - this.closeModal() - return true - } if (this.isComposerActive) { this.closeComposer() return true @@ -307,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 @@ -358,9 +144,9 @@ export class ShellUiModel { setupLoginModals() { this.rootStore.onSessionReady(() => { - if (this.rootStore.reminders.shouldRequestEmailConfirmation) { - this.openModal({name: 'verify-email', showReminder: true}) - this.rootStore.reminders.setEmailConfirmationRequested() + if (shouldRequestEmailConfirmation(this.rootStore.session)) { + unstable__openModal({name: 'verify-email', showReminder: true}) + setEmailConfirmationRequested() } }) } diff --git a/src/state/muted-threads.tsx b/src/state/muted-threads.tsx new file mode 100644 index 0000000000..2b3a7de6ab --- /dev/null +++ b/src/state/muted-threads.tsx @@ -0,0 +1,59 @@ +import React from 'react' +import * as persisted from '#/state/persisted' + +type StateContext = persisted.Schema['mutedThreads'] +type ToggleContext = (uri: string) => boolean + +const stateContext = React.createContext( + persisted.defaults.mutedThreads, +) +const toggleContext = React.createContext((_: string) => false) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [state, setState] = React.useState(persisted.get('mutedThreads')) + + const toggleThreadMute = React.useCallback( + (uri: string) => { + let muted = false + setState((arr: string[]) => { + if (arr.includes(uri)) { + arr = arr.filter(v => v !== uri) + muted = false + } else { + arr = arr.concat([uri]) + muted = true + } + persisted.write('mutedThreads', arr) + return arr + }) + return muted + }, + [setState], + ) + + React.useEffect(() => { + return persisted.onUpdate(() => { + setState(persisted.get('mutedThreads')) + }) + }, [setState]) + + return ( + + + {children} + + + ) +} + +export function useMutedThreads() { + return React.useContext(stateContext) +} + +export function useToggleThreadMute() { + return React.useContext(toggleContext) +} + +export function isThreadMuted(uri: string) { + return persisted.get('mutedThreads').includes(uri) +} diff --git a/src/state/persisted/index.ts b/src/state/persisted/index.ts index 67fac6b65b..f43cc9527c 100644 --- a/src/state/persisted/index.ts +++ b/src/state/persisted/index.ts @@ -6,7 +6,7 @@ import * as store from '#/state/persisted/store' import BroadcastChannel from '#/state/persisted/broadcast' export type {Schema} from '#/state/persisted/schema' -export {defaults as schema} from '#/state/persisted/schema' +export {defaults} from '#/state/persisted/schema' const broadcast = new BroadcastChannel('BSKY_BROADCAST_CHANNEL') const UPDATE_EVENT = 'BSKY_UPDATE' diff --git a/src/state/persisted/legacy.ts b/src/state/persisted/legacy.ts index 6d0a2bccc1..3da509304c 100644 --- a/src/state/persisted/legacy.ts +++ b/src/state/persisted/legacy.ts @@ -76,9 +76,9 @@ export function transform(legacy: LegacySchema): Schema { defaults.session.currentAccount, }, reminders: { - lastEmailConfirmReminder: + lastEmailConfirm: legacy.reminders.lastEmailConfirm || - defaults.reminders.lastEmailConfirmReminder, + defaults.reminders.lastEmailConfirm, }, languagePrefs: { primaryLanguage: @@ -97,11 +97,9 @@ export function transform(legacy: LegacySchema): Schema { legacy.preferences.requireAltTextEnabled || defaults.requireAltTextEnabled, mutedThreads: legacy.mutedThreads.uris || defaults.mutedThreads, - invitedUsers: { - seenDids: legacy.invitedUsers.seenDids || defaults.invitedUsers.seenDids, + invites: { copiedInvites: - legacy.invitedUsers.copiedInvites || - defaults.invitedUsers.copiedInvites, + legacy.invitedUsers.copiedInvites || defaults.invites.copiedInvites, }, onboarding: { step: legacy.onboarding.step || defaults.onboarding.step, diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index 1c5d317cce..9c52661e44 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -7,9 +7,9 @@ const accountSchema = z.object({ did: z.string(), refreshJwt: z.string().optional(), accessJwt: z.string().optional(), - handle: z.string(), - displayName: z.string(), - aviUrl: z.string(), + handle: z.string().optional(), + displayName: z.string().optional(), + aviUrl: z.string().optional(), }) export const schema = z.object({ @@ -19,7 +19,7 @@ export const schema = z.object({ currentAccount: accountSchema.optional(), }), reminders: z.object({ - lastEmailConfirmReminder: z.string().optional(), + lastEmailConfirm: z.string().optional(), }), languagePrefs: z.object({ primaryLanguage: z.string(), // should move to server @@ -29,8 +29,7 @@ export const schema = z.object({ }), requireAltTextEnabled: z.boolean(), // should move to server mutedThreads: z.array(z.string()), // should move to server - invitedUsers: z.object({ - seenDids: z.array(z.string()), + invites: z.object({ copiedInvites: z.array(z.string()), }), onboarding: z.object({ @@ -46,7 +45,7 @@ export const defaults: Schema = { currentAccount: undefined, }, reminders: { - lastEmailConfirmReminder: undefined, + lastEmailConfirm: undefined, }, languagePrefs: { primaryLanguage: deviceLocales[0] || 'en', @@ -58,8 +57,7 @@ export const defaults: Schema = { }, requireAltTextEnabled: false, mutedThreads: [], - invitedUsers: { - seenDids: [], + invites: { copiedInvites: [], }, onboarding: { diff --git a/src/state/preferences/alt-text-required.tsx b/src/state/preferences/alt-text-required.tsx new file mode 100644 index 0000000000..81de9e0060 --- /dev/null +++ b/src/state/preferences/alt-text-required.tsx @@ -0,0 +1,48 @@ +import React from 'react' +import * as persisted from '#/state/persisted' + +type StateContext = persisted.Schema['requireAltTextEnabled'] +type SetContext = (v: persisted.Schema['requireAltTextEnabled']) => void + +const stateContext = React.createContext( + persisted.defaults.requireAltTextEnabled, +) +const setContext = React.createContext( + (_: persisted.Schema['requireAltTextEnabled']) => {}, +) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [state, setState] = React.useState( + persisted.get('requireAltTextEnabled'), + ) + + const setStateWrapped = React.useCallback( + (requireAltTextEnabled: persisted.Schema['requireAltTextEnabled']) => { + setState(requireAltTextEnabled) + persisted.write('requireAltTextEnabled', requireAltTextEnabled) + }, + [setState], + ) + + React.useEffect(() => { + return persisted.onUpdate(() => { + setState(persisted.get('requireAltTextEnabled')) + }) + }, [setStateWrapped]) + + return ( + + + {children} + + + ) +} + +export function useRequireAltTextEnabled() { + return React.useContext(stateContext) +} + +export function useSetRequireAltTextEnabled() { + return React.useContext(setContext) +} diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx new file mode 100644 index 0000000000..1f4348cfc2 --- /dev/null +++ b/src/state/preferences/index.tsx @@ -0,0 +1,17 @@ +import React from 'react' +import {Provider as LanguagesProvider} from './languages' +import {Provider as AltTextRequiredProvider} from '../preferences/alt-text-required' + +export {useLanguagePrefs, useLanguagePrefsApi} from './languages' +export { + useRequireAltTextEnabled, + useSetRequireAltTextEnabled, +} from './alt-text-required' + +export function Provider({children}: React.PropsWithChildren<{}>) { + return ( + + {children} + + ) +} diff --git a/src/state/preferences/languages.tsx b/src/state/preferences/languages.tsx new file mode 100644 index 0000000000..95cf67417d --- /dev/null +++ b/src/state/preferences/languages.tsx @@ -0,0 +1,137 @@ +import React from 'react' +import * as persisted from '#/state/persisted' + +type SetStateCb = ( + s: persisted.Schema['languagePrefs'], +) => persisted.Schema['languagePrefs'] +type StateContext = persisted.Schema['languagePrefs'] +type ApiContext = { + setPrimaryLanguage: (code2: string) => void + setPostLanguage: (commaSeparatedLangCodes: string) => void + toggleContentLanguage: (code2: string) => void + togglePostLanguage: (code2: string) => void + savePostLanguageToHistory: () => void +} + +const stateContext = React.createContext( + persisted.defaults.languagePrefs, +) +const apiContext = React.createContext({ + setPrimaryLanguage: (_: string) => {}, + setPostLanguage: (_: string) => {}, + toggleContentLanguage: (_: string) => {}, + togglePostLanguage: (_: string) => {}, + savePostLanguageToHistory: () => {}, +}) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [state, setState] = React.useState(persisted.get('languagePrefs')) + + const setStateWrapped = React.useCallback( + (fn: SetStateCb) => { + const s = fn(persisted.get('languagePrefs')) + setState(s) + persisted.write('languagePrefs', s) + }, + [setState], + ) + + React.useEffect(() => { + return persisted.onUpdate(() => { + setState(persisted.get('languagePrefs')) + }) + }, [setStateWrapped]) + + const api = React.useMemo( + () => ({ + setPrimaryLanguage(code2: string) { + setStateWrapped(s => ({...s, primaryLanguage: code2})) + }, + setPostLanguage(commaSeparatedLangCodes: string) { + setStateWrapped(s => ({...s, postLanguage: commaSeparatedLangCodes})) + }, + toggleContentLanguage(code2: string) { + setStateWrapped(s => { + const exists = s.contentLanguages.includes(code2) + const next = exists + ? s.contentLanguages.filter(lang => lang !== code2) + : s.contentLanguages.concat(code2) + return { + ...s, + contentLanguages: next, + } + }) + }, + togglePostLanguage(code2: string) { + setStateWrapped(s => { + const exists = hasPostLanguage(state.postLanguage, code2) + let next = s.postLanguage + + if (exists) { + next = toPostLanguages(s.postLanguage) + .filter(lang => lang !== code2) + .join(',') + } else { + // sort alphabetically for deterministic comparison in context menu + next = toPostLanguages(s.postLanguage) + .concat([code2]) + .sort((a, b) => a.localeCompare(b)) + .join(',') + } + + return { + ...s, + postLanguage: next, + } + }) + }, + /** + * Saves whatever language codes are currently selected into a history array, + * which is then used to populate the language selector menu. + */ + savePostLanguageToHistory() { + // filter out duplicate `this.postLanguage` if exists, and prepend + // value to start of array + setStateWrapped(s => ({ + ...s, + postLanguageHistory: [s.postLanguage] + .concat( + s.postLanguageHistory.filter( + commaSeparatedLangCodes => + commaSeparatedLangCodes !== s.postLanguage, + ), + ) + .slice(0, 6), + })) + }, + }), + [state, setStateWrapped], + ) + + return ( + + {children} + + ) +} + +export function useLanguagePrefs() { + return React.useContext(stateContext) +} + +export function useLanguagePrefsApi() { + return React.useContext(apiContext) +} + +export function getContentLanguages() { + return persisted.get('languagePrefs').contentLanguages +} + +export function toPostLanguages(postLanguage: string): string[] { + // filter out empty strings if exist + return postLanguage.split(',').filter(Boolean) +} + +export function hasPostLanguage(postLanguage: string, code2: string): boolean { + return toPostLanguages(postLanguage).includes(code2) +} diff --git a/src/state/shell/color-mode.tsx b/src/state/shell/color-mode.tsx index 74379da371..c6a4b8a186 100644 --- a/src/state/shell/color-mode.tsx +++ b/src/state/shell/color-mode.tsx @@ -27,7 +27,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { setState(persisted.get('colorMode')) updateDocument(persisted.get('colorMode')) }) - }, [setStateWrapped]) + }, [setState]) return ( diff --git a/src/state/shell/index.tsx b/src/state/shell/index.tsx index 1e01a4e7d5..6291d32245 100644 --- a/src/state/shell/index.tsx +++ b/src/state/shell/index.tsx @@ -3,6 +3,7 @@ import {Provider as DrawerOpenProvider} from './drawer-open' import {Provider as DrawerSwipableProvider} from './drawer-swipe-disabled' import {Provider as MinimalModeProvider} from './minimal-mode' import {Provider as ColorModeProvider} from './color-mode' +import {Provider as OnboardingProvider} from './onboarding' export {useIsDrawerOpen, useSetDrawerOpen} from './drawer-open' export { @@ -11,13 +12,16 @@ export { } from './drawer-swipe-disabled' export {useMinimalShellMode, useSetMinimalShellMode} from './minimal-mode' export {useColorMode, useSetColorMode} from './color-mode' +export {useOnboardingState, useOnboardingDispatch} from './onboarding' export function Provider({children}: React.PropsWithChildren<{}>) { return ( - {children} + + {children} + diff --git a/src/state/shell/onboarding.tsx b/src/state/shell/onboarding.tsx new file mode 100644 index 0000000000..5963cc50ec --- /dev/null +++ b/src/state/shell/onboarding.tsx @@ -0,0 +1,119 @@ +import React from 'react' +import * as persisted from '#/state/persisted' +import {track} from '#/lib/analytics/analytics' + +export const OnboardingScreenSteps = { + Welcome: 'Welcome', + RecommendedFeeds: 'RecommendedFeeds', + RecommendedFollows: 'RecommendedFollows', + Home: 'Home', +} as const + +type OnboardingStep = + (typeof OnboardingScreenSteps)[keyof typeof OnboardingScreenSteps] +const OnboardingStepsArray = Object.values(OnboardingScreenSteps) + +type Action = + | {type: 'set'; step: OnboardingStep} + | {type: 'next'; currentStep?: OnboardingStep} + | {type: 'start'} + | {type: 'finish'} + | {type: 'skip'} + +export type StateContext = persisted.Schema['onboarding'] & { + isComplete: boolean + isActive: boolean +} +export type DispatchContext = (action: Action) => void + +const stateContext = React.createContext( + compute(persisted.defaults.onboarding), +) +const dispatchContext = React.createContext((_: Action) => {}) + +function reducer(state: StateContext, action: Action): StateContext { + switch (action.type) { + case 'set': { + if (OnboardingStepsArray.includes(action.step)) { + persisted.write('onboarding', {step: action.step}) + return compute({...state, step: action.step}) + } + return state + } + case 'next': { + const currentStep = action.currentStep || state.step + let nextStep = 'Home' + if (currentStep === 'Welcome') { + nextStep = 'RecommendedFeeds' + } else if (currentStep === 'RecommendedFeeds') { + nextStep = 'RecommendedFollows' + } else if (currentStep === 'RecommendedFollows') { + nextStep = 'Home' + } + persisted.write('onboarding', {step: nextStep}) + return compute({...state, step: nextStep}) + } + case 'start': { + track('Onboarding:Begin') + persisted.write('onboarding', {step: 'Welcome'}) + return compute({...state, step: 'Welcome'}) + } + case 'finish': { + track('Onboarding:Complete') + persisted.write('onboarding', {step: 'Home'}) + return compute({...state, step: 'Home'}) + } + case 'skip': { + track('Onboarding:Skipped') + persisted.write('onboarding', {step: 'Home'}) + return compute({...state, step: 'Home'}) + } + default: { + throw new Error('Invalid action') + } + } +} + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [state, dispatch] = React.useReducer( + reducer, + compute(persisted.get('onboarding')), + ) + + React.useEffect(() => { + return persisted.onUpdate(() => { + dispatch({ + type: 'set', + step: persisted.get('onboarding').step as OnboardingStep, + }) + }) + }, [dispatch]) + + return ( + + + {children} + + + ) +} + +export function useOnboardingState() { + return React.useContext(stateContext) +} + +export function useOnboardingDispatch() { + return React.useContext(dispatchContext) +} + +export function isOnboardingActive() { + return compute(persisted.get('onboarding')).isActive +} + +function compute(state: persisted.Schema['onboarding']): StateContext { + return { + ...state, + isActive: state.step !== 'Home', + isComplete: state.step === 'Home', + } +} diff --git a/src/state/shell/reminders.e2e.ts b/src/state/shell/reminders.e2e.ts new file mode 100644 index 0000000000..d7703e9e0c --- /dev/null +++ b/src/state/shell/reminders.e2e.ts @@ -0,0 +1,7 @@ +import {SessionModel} from '../models/session' + +export function shouldRequestEmailConfirmation(_session: SessionModel) { + return false +} + +export function setEmailConfirmationRequested() {} diff --git a/src/state/shell/reminders.ts b/src/state/shell/reminders.ts new file mode 100644 index 0000000000..e7ee7a5fe2 --- /dev/null +++ b/src/state/shell/reminders.ts @@ -0,0 +1,37 @@ +import * as persisted from '#/state/persisted' +import {SessionModel} from '../models/session' +import {toHashCode} from 'lib/strings/helpers' +import {isOnboardingActive} from './onboarding' + +export function shouldRequestEmailConfirmation(session: SessionModel) { + const sess = session.currentSession + if (!sess) { + return false + } + if (sess.emailConfirmed) { + return false + } + if (isOnboardingActive()) { + return false + } + // only prompt once + if (persisted.get('reminders').lastEmailConfirm) { + return false + } + const today = new Date() + // shard the users into 2 day of the week buckets + // (this is to avoid a sudden influx of email updates when + // this feature rolls out) + const code = toHashCode(sess.did) % 7 + if (code !== today.getDay() && code !== (today.getDay() + 1) % 7) { + return false + } + return true +} + +export function setEmailConfirmationRequested() { + persisted.write('reminders', { + ...persisted.get('reminders'), + lastEmailConfirm: new Date().toISOString(), + }) +} diff --git a/src/view/com/auth/Onboarding.tsx b/src/view/com/auth/Onboarding.tsx index bec1dc236a..994f4c1492 100644 --- a/src/view/com/auth/Onboarding.tsx +++ b/src/view/com/auth/Onboarding.tsx @@ -4,34 +4,35 @@ import {observer} from 'mobx-react-lite' import {ErrorBoundary} from 'view/com/util/ErrorBoundary' import {s} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' -import {useStores} from 'state/index' import {Welcome} from './onboarding/Welcome' import {RecommendedFeeds} from './onboarding/RecommendedFeeds' import {RecommendedFollows} from './onboarding/RecommendedFollows' import {useSetMinimalShellMode} from '#/state/shell/minimal-mode' +import {useOnboardingState, useOnboardingDispatch} from '#/state/shell' export const Onboarding = observer(function OnboardingImpl() { const pal = usePalette('default') - const store = useStores() const setMinimalShellMode = useSetMinimalShellMode() + const onboardingState = useOnboardingState() + const onboardingDispatch = useOnboardingDispatch() React.useEffect(() => { setMinimalShellMode(true) }, [setMinimalShellMode]) - const next = () => store.onboarding.next() - const skip = () => store.onboarding.skip() + const next = () => onboardingDispatch({type: 'next'}) + const skip = () => onboardingDispatch({type: 'skip'}) return ( - {store.onboarding.step === 'Welcome' && ( + {onboardingState.step === 'Welcome' && ( )} - {store.onboarding.step === 'RecommendedFeeds' && ( + {onboardingState.step === 'RecommendedFeeds' && ( )} - {store.onboarding.step === 'RecommendedFollows' && ( + {onboardingState.step === 'RecommendedFollows' && ( )} diff --git a/src/view/com/auth/create/CreateAccount.tsx b/src/view/com/auth/create/CreateAccount.tsx index 712871a5f1..8e2bbed856 100644 --- a/src/view/com/auth/create/CreateAccount.tsx +++ b/src/view/com/auth/create/CreateAccount.tsx @@ -17,6 +17,7 @@ import {CreateAccountModel} from 'state/models/ui/create-account' import {usePalette} from 'lib/hooks/usePalette' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useOnboardingDispatch} from '#/state/shell' import {Step1} from './Step1' import {Step2} from './Step2' @@ -32,6 +33,7 @@ export const CreateAccount = observer(function CreateAccountImpl({ const store = useStores() const model = React.useMemo(() => new CreateAccountModel(store), [store]) const {_} = useLingui() + const onboardingDispatch = useOnboardingDispatch() React.useEffect(() => { screen('CreateAccount') @@ -62,14 +64,14 @@ export const CreateAccount = observer(function CreateAccountImpl({ model.next() } else { try { - await model.submit() + await model.submit(onboardingDispatch) } catch { // dont need to handle here } finally { track('Try Create Account') } } - }, [model, track]) + }, [model, track, onboardingDispatch]) return ( { - store.shell.openModal({name: 'waitlist'}) - }, [store]) + openModal({name: 'waitlist'}) + }, [openModal]) return ( diff --git a/src/view/com/auth/login/ForgotPasswordForm.tsx b/src/view/com/auth/login/ForgotPasswordForm.tsx index d45dceb140..9bfab18b55 100644 --- a/src/view/com/auth/login/ForgotPasswordForm.tsx +++ b/src/view/com/auth/login/ForgotPasswordForm.tsx @@ -22,12 +22,12 @@ import {usePalette} from 'lib/hooks/usePalette' import {useTheme} from 'lib/ThemeContext' import {cleanError} from 'lib/strings/errors' import {logger} from '#/logger' -import {msg} from '@lingui/macro' +import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {styles} from './styles' +import {useModalControls} from '#/state/modals' export const ForgotPasswordForm = ({ - store, error, serviceUrl, serviceDescription, @@ -51,13 +51,14 @@ export const ForgotPasswordForm = ({ const [email, setEmail] = useState('') const {screen} = useAnalytics() const {_} = useLingui() + const {openModal} = useModalControls() useEffect(() => { screen('Signin:ForgotPassword') }, [screen]) const onPressSelectService = () => { - store.shell.openModal({ + openModal({ name: 'server-input', initialService: serviceUrl, onSelect: setServiceUrl, @@ -94,11 +95,13 @@ export const ForgotPasswordForm = ({ <> - Reset password + Reset password - Enter the email you used to create your account. We'll send you a - "reset code" so you can set a new password. + + Enter the email you used to create your account. We'll send you a + "reset code" so you can set a new password. + - Back + Back @@ -168,7 +171,7 @@ export const ForgotPasswordForm = ({ ) : !email ? ( - Next + Next ) : ( - Next + Next )} {!serviceDescription || isProcessing ? ( - Processing... + Processing... ) : undefined} diff --git a/src/view/com/auth/login/LoginForm.tsx b/src/view/com/auth/login/LoginForm.tsx index 681ab015c7..9779b939ab 100644 --- a/src/view/com/auth/login/LoginForm.tsx +++ b/src/view/com/auth/login/LoginForm.tsx @@ -25,6 +25,7 @@ import {logger} from '#/logger' import {Trans, msg} from '@lingui/macro' import {styles} from './styles' import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' export const LoginForm = ({ store, @@ -57,9 +58,10 @@ export const LoginForm = ({ const [password, setPassword] = useState('') const passwordInputRef = useRef(null) const {_} = useLingui() + const {openModal} = useModalControls() const onPressSelectService = () => { - store.shell.openModal({ + openModal({ name: 'server-input', initialService: serviceUrl, onSelect: setServiceUrl, diff --git a/src/view/com/auth/onboarding/RecommendedFollows.tsx b/src/view/com/auth/onboarding/RecommendedFollows.tsx index f2710d2ac6..9eef14e0b6 100644 --- a/src/view/com/auth/onboarding/RecommendedFollows.tsx +++ b/src/view/com/auth/onboarding/RecommendedFollows.tsx @@ -11,6 +11,7 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {usePalette} from 'lib/hooks/usePalette' import {useStores} from 'state/index' import {RecommendedFollowsItem} from './RecommendedFollowsItem' +import {SuggestedActorsModel} from '#/state/models/discovery/suggested-actors' type Props = { next: () => void @@ -21,16 +22,10 @@ export const RecommendedFollows = observer(function RecommendedFollowsImpl({ const store = useStores() const pal = usePalette('default') const {isTabletOrMobile} = useWebMediaQueries() - - React.useEffect(() => { - // Load suggested actors if not already loaded - // prefetch should happen in the onboarding model - if ( - !store.onboarding.suggestedActors.hasLoaded || - store.onboarding.suggestedActors.isEmpty - ) { - store.onboarding.suggestedActors.loadMore(true) - } + const suggestedActors = React.useMemo(() => { + const model = new SuggestedActorsModel(store) + model.refresh() + return model }, [store]) const title = ( @@ -98,13 +93,19 @@ export const RecommendedFollows = observer(function RecommendedFollowsImpl({ horizontal titleStyle={isTabletOrMobile ? undefined : {minWidth: 470}} contentStyle={{paddingHorizontal: 0}}> - {store.onboarding.suggestedActors.isLoading ? ( + {suggestedActors.isLoading ? ( ) : ( ( - + )} keyExtractor={(item, index) => item.did + index.toString()} style={{flex: 1}} @@ -126,13 +127,19 @@ export const RecommendedFollows = observer(function RecommendedFollowsImpl({ users. - {store.onboarding.suggestedActors.isLoading ? ( + {suggestedActors.isLoading ? ( ) : ( ( - + )} keyExtractor={(item, index) => item.did + index.toString()} style={{flex: 1}} diff --git a/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx index 7d3378f425..7ec78bd7fe 100644 --- a/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx +++ b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx @@ -1,4 +1,4 @@ -import React, {useMemo} from 'react' +import React from 'react' import {View, StyleSheet, ActivityIndicator} from 'react-native' import {AppBskyActorDefs, moderateProfile} from '@atproto/api' import {observer} from 'mobx-react-lite' @@ -19,22 +19,19 @@ import {Trans} from '@lingui/macro' type Props = { item: SuggestedActor index: number + insertSuggestionsByActor: (did: string, index: number) => Promise } -export const RecommendedFollowsItem: React.FC = ({item, index}) => { +export const RecommendedFollowsItem: React.FC = ({ + item, + index, + insertSuggestionsByActor, +}) => { const pal = usePalette('default') - const store = useStores() const {isMobile} = useWebMediaQueries() - const delay = useMemo(() => { - return ( - 50 * - (Math.abs(store.onboarding.suggestedActors.lastInsertedAtIndex - index) % - 5) - ) - }, [index, store.onboarding.suggestedActors.lastInsertedAtIndex]) return ( = ({item, index}) => { borderRightWidth: isMobile ? undefined : 1, }, ]}> - + ) } @@ -52,9 +54,11 @@ export const RecommendedFollowsItem: React.FC = ({item, index}) => { export const ProfileCard = observer(function ProfileCardImpl({ profile, index, + insertSuggestionsByActor, }: { profile: AppBskyActorDefs.ProfileViewBasic index: number + insertSuggestionsByActor: (did: string, index: number) => Promise }) { const {track} = useAnalytics() const store = useStores() @@ -95,10 +99,7 @@ export const ProfileCard = observer(function ProfileCardImpl({ onToggleFollow={async isFollow => { if (isFollow) { setAddingMoreSuggestions(true) - await store.onboarding.suggestedActors.insertSuggestionsByActor( - profile.did, - index, - ) + await insertSuggestionsByActor(profile.did, index) setAddingMoreSuggestions(false) track('Onboarding:SuggestedFollowFollowed') } diff --git a/src/view/com/auth/withAuthRequired.tsx b/src/view/com/auth/withAuthRequired.tsx index 25d12165fc..898f81051f 100644 --- a/src/view/com/auth/withAuthRequired.tsx +++ b/src/view/com/auth/withAuthRequired.tsx @@ -13,19 +13,21 @@ import {Onboarding} from './Onboarding' import {Text} from '../util/text/Text' import {usePalette} from 'lib/hooks/usePalette' import {STATUS_PAGE_URL} from 'lib/constants' +import {useOnboardingState} from '#/state/shell' export const withAuthRequired =

( Component: React.ComponentType

, ): React.FC

=> observer(function AuthRequired(props: P) { const store = useStores() + const onboardingState = useOnboardingState() if (store.session.isResumingSession) { return } if (!store.session.hasSession) { return } - if (store.onboarding.isActive) { + if (onboardingState.isActive) { return } return diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 234fd5e536..8f8c2eea36 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -51,6 +51,13 @@ import {EmojiPickerButton} from './text-input/web/EmojiPicker.web' import {insertMentionAt} from 'lib/strings/mention-manip' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useModals, useModalControls} from '#/state/modals' +import {useRequireAltTextEnabled} from '#/state/preferences' +import { + useLanguagePrefs, + useLanguagePrefsApi, + toPostLanguages, +} from '#/state/preferences/languages' type Props = ComposerOpts export const ComposePost = observer(function ComposePost({ @@ -59,11 +66,16 @@ 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() const store = useStores() const {_} = useLingui() + const requireAltTextEnabled = useRequireAltTextEnabled() + const langPrefs = useLanguagePrefs() + const setLangPrefs = useLanguagePrefsApi() const textInput = useRef(null) const [isKeyboardVisible] = useIsKeyboardVisible({iosUseWillEvents: true}) const [isProcessing, setIsProcessing] = useState(false) @@ -111,18 +123,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', @@ -131,7 +143,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) { @@ -190,7 +202,7 @@ export const ComposePost = observer(function ComposePost({ if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) { return } - if (store.preferences.requireAltTextEnabled && gallery.needsAltText) { + if (requireAltTextEnabled && gallery.needsAltText) { return } @@ -213,7 +225,7 @@ export const ComposePost = observer(function ComposePost({ labels, onStateChange: setProcessingState, knownHandles: autocompleteView.knownHandles, - langs: store.preferences.postLanguages, + langs: toPostLanguages(langPrefs.postLanguage), }) } catch (e: any) { if (extLink) { @@ -235,7 +247,7 @@ export const ComposePost = observer(function ComposePost({ if (!replyTo) { store.me.mainFeed.onPostCreated() } - store.preferences.savePostLanguageToHistory() + setLangPrefs.savePostLanguageToHistory() onPost?.() onClose() Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`) @@ -244,12 +256,8 @@ export const ComposePost = observer(function ComposePost({ const canPost = useMemo( () => graphemeLength <= MAX_GRAPHEME_LENGTH && - (!store.preferences.requireAltTextEnabled || !gallery.needsAltText), - [ - graphemeLength, - store.preferences.requireAltTextEnabled, - gallery.needsAltText, - ], + (!requireAltTextEnabled || !gallery.needsAltText), + [graphemeLength, requireAltTextEnabled, gallery.needsAltText], ) const selectTextInputPlaceholder = replyTo ? 'Write your reply' : `What's up?` @@ -321,7 +329,7 @@ export const ComposePost = observer(function ComposePost({ )} - {store.preferences.requireAltTextEnabled && gallery.needsAltText && ( + {requireAltTextEnabled && gallery.needsAltText && ( {labels.length > 0 ? ( diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx index fdbff20c02..69c8debb02 100644 --- a/src/view/com/composer/photos/Gallery.tsx +++ b/src/view/com/composer/photos/Gallery.tsx @@ -7,13 +7,13 @@ import {s, colors} from 'lib/styles' import {StyleSheet, TouchableOpacity, View} from 'react-native' import {Image} from 'expo-image' import {Text} from 'view/com/util/text/Text' -import {openAltTextModal} from 'lib/media/alt-text' import {Dimensions} from 'lib/media/types' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' +import {isNative} from 'platform/detection' const IMAGE_GAP = 8 @@ -49,10 +49,10 @@ const GalleryInner = observer(function GalleryImpl({ gallery, containerInfo, }: GalleryInnerProps) { - const store = useStores() const pal = usePalette('default') const {_} = useLingui() const {isMobile} = useWebMediaQueries() + const {openModal} = useModalControls() let side: number @@ -120,7 +120,10 @@ const GalleryInner = observer(function GalleryImpl({ accessibilityHint="" onPress={() => { Keyboard.dismiss() - openAltTextModal(store, image) + openModal({ + name: 'alt-text-image', + image, + }) }} style={[styles.altTextControl, altTextControlStyle]}> @@ -140,7 +143,17 @@ const GalleryInner = observer(function GalleryImpl({ accessibilityRole="button" accessibilityLabel={_(msg`Edit image`)} accessibilityHint="" - onPress={() => gallery.edit(image)} + onPress={() => { + if (isNative) { + gallery.crop(image) + } else { + openModal({ + name: 'edit-image', + image, + gallery, + }) + } + }} style={styles.imageControl}> { Keyboard.dismiss() - openAltTextModal(store, image) + openModal({ + name: 'alt-text-image', + image, + }) }} style={styles.altTextHiddenRegion} /> diff --git a/src/view/com/composer/select-language/SelectLangBtn.tsx b/src/view/com/composer/select-language/SelectLangBtn.tsx index 9688d5b098..7ea63ed625 100644 --- a/src/view/com/composer/select-language/SelectLangBtn.tsx +++ b/src/view/com/composer/select-language/SelectLangBtn.tsx @@ -12,16 +12,24 @@ import { DropdownItemButton, } from 'view/com/util/forms/DropdownButton' import {usePalette} from 'lib/hooks/usePalette' -import {useStores} from 'state/index' import {isNative} from 'platform/detection' import {codeToLanguageName} from '../../../../locale/helpers' +import {useModalControls} from '#/state/modals' +import { + useLanguagePrefs, + useLanguagePrefsApi, + toPostLanguages, + hasPostLanguage, +} from '#/state/preferences/languages' import {t, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' export const SelectLangBtn = observer(function SelectLangBtn() { const pal = usePalette('default') - const store = useStores() const {_} = useLingui() + const {openModal} = useModalControls() + const langPrefs = useLanguagePrefs() + const setLangPrefs = useLanguagePrefsApi() const onPressMore = useCallback(async () => { if (isNative) { @@ -29,11 +37,10 @@ export const SelectLangBtn = observer(function SelectLangBtn() { Keyboard.dismiss() } } - store.shell.openModal({name: 'post-languages-settings'}) - }, [store]) + openModal({name: 'post-languages-settings'}) + }, [openModal]) - const postLanguagesPref = store.preferences.postLanguages - const postLanguagePref = store.preferences.postLanguage + const postLanguagesPref = toPostLanguages(langPrefs.postLanguage) const items: DropdownItem[] = useMemo(() => { let arr: DropdownItemButton[] = [] @@ -52,13 +59,14 @@ export const SelectLangBtn = observer(function SelectLangBtn() { arr.push({ icon: - langCodes.every(code => store.preferences.hasPostLanguage(code)) && - langCodes.length === postLanguagesPref.length + langCodes.every(code => + hasPostLanguage(langPrefs.postLanguage, code), + ) && langCodes.length === postLanguagesPref.length ? ['fas', 'circle-dot'] : ['far', 'circle'], label: langName, onPress() { - store.preferences.setPostLanguage(commaSeparatedLangCodes) + setLangPrefs.setPostLanguage(commaSeparatedLangCodes) }, }) } @@ -68,11 +76,11 @@ export const SelectLangBtn = observer(function SelectLangBtn() { * Re-join here after sanitization bc postLanguageHistory is an array of * comma-separated strings too */ - add(postLanguagePref) + add(langPrefs.postLanguage) } // comma-separted strings of lang codes that have been used in the past - for (const lang of store.preferences.postLanguageHistory) { + for (const lang of langPrefs.postLanguageHistory) { add(lang) } @@ -85,7 +93,7 @@ export const SelectLangBtn = observer(function SelectLangBtn() { onPress: onPressMore, }, ] - }, [store.preferences, onPressMore, postLanguagePref, postLanguagesPref]) + }, [onPressMore, langPrefs, setLangPrefs, postLanguagesPref]) return ( () + const {openModal} = useModalControls() const onToggleSaved = React.useCallback(async () => { if (item.isSaved) { - store.shell.openModal({ + openModal({ name: 'confirm', title: 'Remove from my feeds', message: `Remove ${item.displayName} from my feeds?`, @@ -59,7 +59,7 @@ export const FeedSourceCard = observer(function FeedSourceCardImpl({ logger.error('Failed to save feed', {error: e}) } } - }, [store, item]) + }, [openModal, item]) return ( { let items: any[] = [] @@ -115,7 +115,7 @@ export const ListItems = observer(function ListItemsImpl({ const onPressEditMembership = React.useCallback( (profile: AppBskyActorDefs.ProfileViewBasic) => { - store.shell.openModal({ + openModal({ name: 'user-add-remove-lists', subject: profile.did, displayName: profile.displayName || profile.handle, @@ -131,7 +131,7 @@ export const ListItems = observer(function ListItemsImpl({ }, }) }, - [store, list], + [openModal, list], ) // rendering diff --git a/src/view/com/modals/AddAppPasswords.tsx b/src/view/com/modals/AddAppPasswords.tsx index fc63451497..095ad48bd2 100644 --- a/src/view/com/modals/AddAppPasswords.tsx +++ b/src/view/com/modals/AddAppPasswords.tsx @@ -15,6 +15,7 @@ import * as Toast from '../util/Toast' import {logger} from '#/logger' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' export const snapPoints = ['70%'] @@ -57,6 +58,7 @@ export function Component({}: {}) { const pal = usePalette('default') const store = useStores() const {_} = useLingui() + const {closeModal} = useModalControls() const [name, setName] = useState( shadesOfBlue[Math.floor(Math.random() * shadesOfBlue.length)], ) @@ -72,8 +74,8 @@ export function Component({}: {}) { }, [appPassword]) const onDone = React.useCallback(() => { - store.shell.closeModal() - }, [store]) + closeModal() + }, [closeModal]) const createAppPassword = async () => { // if name is all whitespace, we don't allow it diff --git a/src/view/com/modals/AltImage.tsx b/src/view/com/modals/AltImage.tsx index cc4dcb9297..80130f43ad 100644 --- a/src/view/com/modals/AltImage.tsx +++ b/src/view/com/modals/AltImage.tsx @@ -17,11 +17,11 @@ import {MAX_ALT_TEXT} from 'lib/constants' import {useTheme} from 'lib/ThemeContext' import {Text} from '../util/text/Text' import LinearGradient from 'react-native-linear-gradient' -import {useStores} from 'state/index' import {isAndroid, isWeb} from 'platform/detection' import {ImageModel} from 'state/models/media/image' import {useLingui} from '@lingui/react' import {Trans, msg} from '@lingui/macro' +import {useModalControls} from '#/state/modals' export const snapPoints = ['fullscreen'] @@ -31,11 +31,11 @@ interface Props { export function Component({image}: Props) { const pal = usePalette('default') - const store = useStores() const theme = useTheme() const {_} = useLingui() const [altText, setAltText] = useState(image.altText) const windim = useWindowDimensions() + const {closeModal} = useModalControls() const imageStyles = useMemo(() => { const maxWidth = isWeb ? 450 : windim.width @@ -56,11 +56,11 @@ export function Component({image}: Props) { const onPressSave = useCallback(() => { image.setAltText(altText) - store.shell.closeModal() - }, [store, image, altText]) + closeModal() + }, [closeModal, image, altText]) const onPressCancel = () => { - store.shell.closeModal() + closeModal() } return ( diff --git a/src/view/com/modals/BirthDateSettings.tsx b/src/view/com/modals/BirthDateSettings.tsx index 8a11a0635b..6655b7a6b1 100644 --- a/src/view/com/modals/BirthDateSettings.tsx +++ b/src/view/com/modals/BirthDateSettings.tsx @@ -17,6 +17,7 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {cleanError} from 'lib/strings/errors' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' export const snapPoints = ['50%'] @@ -24,6 +25,7 @@ export const Component = observer(function Component({}: {}) { const pal = usePalette('default') const store = useStores() const {_} = useLingui() + const {closeModal} = useModalControls() const [date, setDate] = useState( store.preferences.birthDate || new Date(), ) @@ -36,7 +38,7 @@ export const Component = observer(function Component({}: {}) { setIsProcessing(true) try { await store.preferences.setBirthDate(date) - store.shell.closeModal() + closeModal() } catch (e) { setError(cleanError(String(e))) } finally { diff --git a/src/view/com/modals/ChangeEmail.tsx b/src/view/com/modals/ChangeEmail.tsx index 1e929e3498..710c0588ed 100644 --- a/src/view/com/modals/ChangeEmail.tsx +++ b/src/view/com/modals/ChangeEmail.tsx @@ -14,6 +14,7 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {cleanError} from 'lib/strings/errors' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' enum Stages { InputEmail, @@ -35,6 +36,7 @@ export const Component = observer(function Component({}: {}) { const [isProcessing, setIsProcessing] = useState(false) const [error, setError] = useState('') const {isMobile} = useWebMediaQueries() + const {openModal, closeModal} = useModalControls() const onRequestChange = async () => { if (email === store.session.currentSession?.email) { @@ -95,8 +97,8 @@ export const Component = observer(function Component({}: {}) { } const onVerify = async () => { - store.shell.closeModal() - store.shell.openModal({name: 'verify-email'}) + closeModal() + openModal({name: 'verify-email'}) } return ( @@ -212,7 +214,7 @@ export const Component = observer(function Component({}: {}) {