diff --git a/__mocks__/sentry-expo.js b/__mocks__/sentry-expo.js index e735c48c55..5a0e644e8a 100644 --- a/__mocks__/sentry-expo.js +++ b/__mocks__/sentry-expo.js @@ -1,10 +1,3 @@ jest.mock('sentry-expo', () => ({ init: () => jest.fn(), - Native: { - ReactNativeTracing: jest.fn().mockImplementation(() => ({ - start: jest.fn(), - stop: jest.fn(), - })), - ReactNavigationInstrumentation: jest.fn(), - }, })) diff --git a/bskyweb/templates/base.html b/bskyweb/templates/base.html index 7eeb7e4cc1..b03fbbee4e 100644 --- a/bskyweb/templates/base.html +++ b/bskyweb/templates/base.html @@ -176,8 +176,8 @@ {% block html_head_extra -%}{%- endblock %} - - + + {%- block body_all %} diff --git a/src/App.native.tsx b/src/App.native.tsx index f0b1a1bc51..f5d35cf741 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -10,7 +10,8 @@ import {QueryClientProvider} from '@tanstack/react-query' import 'view/icons' -import {withSentry} from 'lib/sentry' +import {init as initPersistedState} from '#/state/persisted' +import {useColorMode} from 'state/shell' import {ThemeProvider} from 'lib/ThemeContext' import {s} from 'lib/styles' import {RootStoreModel, setupState, RootStoreProvider} from './state' @@ -20,10 +21,12 @@ import * as analytics from 'lib/analytics/analytics' 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' SplashScreen.preventAutoHideAsync() -const App = observer(function AppImpl() { +const InnerApp = observer(function AppImpl() { + const colorMode = useColorMode() const [rootStore, setRootStore] = useState( undefined, ) @@ -46,7 +49,7 @@ const App = observer(function AppImpl() { } return ( - + @@ -62,4 +65,22 @@ const App = observer(function AppImpl() { ) }) -export default withSentry(App) +function App() { + const [isReady, setReady] = useState(false) + + React.useEffect(() => { + initPersistedState().then(() => setReady(true)) + }, []) + + if (!isReady) { + return null + } + + return ( + + + + ) +} + +export default App diff --git a/src/App.web.tsx b/src/App.web.tsx index b420f974cc..b2a0eb8bc7 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -8,6 +8,8 @@ import {RootSiblingParent} from 'react-native-root-siblings' import 'view/icons' +import {init as initPersistedState} from '#/state/persisted' +import {useColorMode} from 'state/shell' import * as analytics from 'lib/analytics/analytics' import {RootStoreModel, setupState, RootStoreProvider} from './state' import {Shell} from 'view/shell/index' @@ -17,8 +19,10 @@ import {queryClient} from 'lib/react-query' import {i18n} from '@lingui/core' import {I18nProvider} from '@lingui/react' import {defaultLocale, dynamicActivate} from './locale/i18n' +import {Provider as ShellStateProvider} from 'state/shell' -const App = observer(function AppImpl() { +const InnerApp = observer(function AppImpl() { + const colorMode = useColorMode() const [rootStore, setRootStore] = useState( undefined, ) @@ -39,7 +43,7 @@ const App = observer(function AppImpl() { return ( - + @@ -57,4 +61,22 @@ const App = observer(function AppImpl() { ) }) +function App() { + const [isReady, setReady] = useState(false) + + React.useEffect(() => { + initPersistedState().then(() => setReady(true)) + }, []) + + if (!isReady) { + return null + } + + return ( + + + + ) +} + export default App diff --git a/src/Navigation.tsx b/src/Navigation.tsx index a6949de230..381f33cf94 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -34,7 +34,6 @@ import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' import {router} from './routes' import {usePalette} from 'lib/hooks/usePalette' import {useStores} from './state' -import {getRoutingInstrumentation} from 'lib/sentry' import {bskyTitle} from 'lib/strings/headings' import {JSX} from 'react/jsx-runtime' import {timeout} from 'lib/async/timeout' @@ -478,12 +477,6 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) { ) console.log(`Time to first paint: ${initMs} ms`) logModuleInitTrace() - - // Register the navigation container with the Sentry instrumentation (only works on native) - if (isNative) { - const routingInstrumentation = getRoutingInstrumentation() - routingInstrumentation.registerNavigationContainer(navigationRef) - } }}> {children} diff --git a/src/lib/hooks/useAccountSwitcher.ts b/src/lib/hooks/useAccountSwitcher.ts index 85bd5d0d45..1ddb181a85 100644 --- a/src/lib/hooks/useAccountSwitcher.ts +++ b/src/lib/hooks/useAccountSwitcher.ts @@ -6,6 +6,7 @@ import {NavigationProp} from 'lib/routes/types' 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' export function useAccountSwitcher(): [ boolean, @@ -13,8 +14,8 @@ export function useAccountSwitcher(): [ (acct: AccountData) => Promise, ] { const {track} = useAnalytics() - const store = useStores() + const setDrawerOpen = useSetDrawerOpen() const [isSwitching, setIsSwitching] = useState(false) const navigation = useNavigation() @@ -23,6 +24,7 @@ export function useAccountSwitcher(): [ track('Settings:SwitchAccountButtonClicked') setIsSwitching(true) const success = await store.session.resumeSession(acct) + setDrawerOpen(false) store.shell.closeAllActiveElements() if (success) { resetNavigation() @@ -34,7 +36,7 @@ export function useAccountSwitcher(): [ store.session.clear() } }, - [track, setIsSwitching, navigation, store], + [track, setIsSwitching, navigation, store, setDrawerOpen], ) return [isSwitching, setIsSwitching, onPressSwitchAccount] diff --git a/src/lib/hooks/useMinimalShellMode.tsx b/src/lib/hooks/useMinimalShellMode.tsx index 475d165d3f..ada934a263 100644 --- a/src/lib/hooks/useMinimalShellMode.tsx +++ b/src/lib/hooks/useMinimalShellMode.tsx @@ -1,6 +1,5 @@ import React from 'react' import {autorun} from 'mobx' -import {useStores} from 'state/index' import { Easing, interpolate, @@ -9,8 +8,10 @@ import { withTiming, } from 'react-native-reanimated' +import {useMinimalShellMode as useMinimalShellModeState} from '#/state/shell/minimal-mode' + export function useMinimalShellMode() { - const store = useStores() + const minimalShellMode = useMinimalShellModeState() const minimalShellInterp = useSharedValue(0) const footerMinimalShellTransform = useAnimatedStyle(() => { return { @@ -38,7 +39,7 @@ export function useMinimalShellMode() { React.useEffect(() => { return autorun(() => { - if (store.shell.minimalShellMode) { + if (minimalShellMode) { minimalShellInterp.value = withTiming(1, { duration: 125, easing: Easing.bezier(0.25, 0.1, 0.25, 1), @@ -50,9 +51,10 @@ export function useMinimalShellMode() { }) } }) - }, [minimalShellInterp, store.shell.minimalShellMode]) + }, [minimalShellInterp, minimalShellMode]) return { + minimalShellMode, footerMinimalShellTransform, headerMinimalShellTransform, fabMinimalShellTransform, diff --git a/src/lib/hooks/useOnMainScroll.ts b/src/lib/hooks/useOnMainScroll.ts index 250ef3a364..2eab4b250f 100644 --- a/src/lib/hooks/useOnMainScroll.ts +++ b/src/lib/hooks/useOnMainScroll.ts @@ -1,8 +1,8 @@ import {useState, useCallback, useRef} from 'react' import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native' -import {RootStoreModel} from 'state/index' import {s} from 'lib/styles' import {useWebMediaQueries} from './useWebMediaQueries' +import {useSetMinimalShellMode, useMinimalShellMode} from '#/state/shell' const Y_LIMIT = 10 @@ -19,12 +19,12 @@ export type OnScrollCb = ( ) => void export type ResetCb = () => void -export function useOnMainScroll( - store: RootStoreModel, -): [OnScrollCb, boolean, ResetCb] { +export function useOnMainScroll(): [OnScrollCb, boolean, ResetCb] { let lastY = useRef(0) let [isScrolledDown, setIsScrolledDown] = useState(false) const {dyLimitUp, dyLimitDown} = useDeviceLimits() + const minimalShellMode = useMinimalShellMode() + const setMinimalShellMode = useSetMinimalShellMode() return [ useCallback( @@ -33,13 +33,10 @@ export function useOnMainScroll( const dy = y - (lastY.current || 0) lastY.current = y - if (!store.shell.minimalShellMode && dy > dyLimitDown && y > Y_LIMIT) { - store.shell.setMinimalShellMode(true) - } else if ( - store.shell.minimalShellMode && - (dy < dyLimitUp * -1 || y <= Y_LIMIT) - ) { - store.shell.setMinimalShellMode(false) + if (!minimalShellMode && dy > dyLimitDown && y > Y_LIMIT) { + setMinimalShellMode(true) + } else if (minimalShellMode && (dy < dyLimitUp * -1 || y <= Y_LIMIT)) { + setMinimalShellMode(false) } if ( @@ -54,13 +51,19 @@ export function useOnMainScroll( setIsScrolledDown(false) } }, - [store.shell, dyLimitDown, dyLimitUp, isScrolledDown], + [ + dyLimitDown, + dyLimitUp, + isScrolledDown, + minimalShellMode, + setMinimalShellMode, + ], ), isScrolledDown, useCallback(() => { setIsScrolledDown(false) - store.shell.setMinimalShellMode(false) + setMinimalShellMode(false) lastY.current = 1e8 // NOTE we set this very high so that the onScroll logic works right -prf - }, [store, setIsScrolledDown]), + }, [setIsScrolledDown, setMinimalShellMode]), ] } diff --git a/src/lib/routes/back-handler.ts b/src/lib/routes/back-handler.ts deleted file mode 100644 index aae2f2c24e..0000000000 --- a/src/lib/routes/back-handler.ts +++ /dev/null @@ -1,19 +0,0 @@ -import {isAndroid} from 'platform/detection' -import {BackHandler} from 'react-native' -import {RootStoreModel} from 'state/index' - -export function init(store: RootStoreModel) { - // only register back handler on android, otherwise it throws an error - if (isAndroid) { - const backHandler = BackHandler.addEventListener( - 'hardwareBackPress', - () => { - return store.shell.closeAnyActiveElement() - }, - ) - return () => { - backHandler.remove() - } - } - return () => {} -} diff --git a/src/lib/sentry.ts b/src/lib/sentry.ts index b7f04854da..b080bcc5cc 100644 --- a/src/lib/sentry.ts +++ b/src/lib/sentry.ts @@ -1,52 +1,8 @@ -import {isNative, isWeb} from 'platform/detection' -import {FC} from 'react' -import * as Sentry from 'sentry-expo' +import {init} from 'sentry-expo' -// Sentry Initialization - -export const getRoutingInstrumentation = () => { - return new Sentry.Native.ReactNavigationInstrumentation() // initialize this in `onReady` prop of NavigationContainer -} - -Sentry.init({ +init({ dsn: 'https://05bc3789bf994b81bd7ce20c86ccd3ae@o4505071687041024.ingest.sentry.io/4505071690514432', enableInExpoDevelopment: false, // if true, Sentry will try to send events/errors in development mode. debug: false, // If `true`, Sentry will try to print out useful debugging information if something goes wrong with sending the event. Set it to `false` in production environment: __DEV__ ? 'development' : 'production', // Set the environment - // @ts-ignore exists but not in types, see https://docs.sentry.io/platforms/react-native/configuration/options/#enableAutoPerformanceTracking - enableAutoPerformanceTracking: true, // Enable auto performance tracking - tracesSampleRate: 0.5, // Set tracesSampleRate to 1.0 to capture 100% of transactions for performance monitoring. // TODO: this might be too much in production - _experiments: { - // The sampling rate for profiling is relative to TracesSampleRate. - // In this case, we'll capture profiles for 50% of transactions. - profilesSampleRate: 0.5, - }, - integrations: isNative - ? [ - new Sentry.Native.ReactNativeTracing({ - shouldCreateSpanForRequest: url => { - // Do not create spans for outgoing requests to a `/logs` endpoint as it is too noisy due to expo - return !url.match(/\/logs$/) - }, - routingInstrumentation: getRoutingInstrumentation(), - }), - ] - : [], // no integrations for web, yet }) - -// if web, use Browser client, otherwise use Native client -export function getSentryClient() { - if (isWeb) { - return Sentry.Browser - } - return Sentry.Native -} - -// wrap root App component with Sentry for automatic touch event tracking and performance monitoring -export function withSentry(Component: FC) { - if (isWeb) { - return Component // .wrap is not required or available for web - } - const sentryClient = getSentryClient() - return sentryClient.wrap(Component) -} diff --git a/src/lib/styles.ts b/src/lib/styles.ts index 8ee6e596b4..152e60eb0f 100644 --- a/src/lib/styles.ts +++ b/src/lib/styles.ts @@ -164,6 +164,7 @@ export const s = StyleSheet.create({ flexRow: {flexDirection: 'row'}, flexCol: {flexDirection: 'column'}, flex1: {flex: 1}, + flexGrow1: {flexGrow: 1}, alignCenter: {alignItems: 'center'}, alignBaseline: {alignItems: 'baseline'}, diff --git a/src/state/models/content/feed-source.ts b/src/state/models/content/feed-source.ts index 79747d6fbf..156e3be3b9 100644 --- a/src/state/models/content/feed-source.ts +++ b/src/state/models/content/feed-source.ts @@ -142,7 +142,8 @@ export class FeedSourceModel { } async unsave() { - if (this.type !== 'feed-generator') { + // TODO TEMPORARY — see PRF's comment in content/list.ts togglePin + if (this.type !== 'feed-generator' && this.type !== 'list') { return } try { @@ -179,7 +180,13 @@ export class FeedSourceModel { name: this.displayName, uri: this.uri, }) - return this.rootStore.preferences.removePinnedFeed(this.uri) + + if (this.type === 'list') { + // TODO TEMPORARY — see PRF's comment in content/list.ts togglePin + return this.unsave() + } else { + return this.rootStore.preferences.removePinnedFeed(this.uri) + } } } diff --git a/src/state/models/content/list.ts b/src/state/models/content/list.ts index 115426e5c8..fc09eeb9f5 100644 --- a/src/state/models/content/list.ts +++ b/src/state/models/content/list.ts @@ -361,7 +361,7 @@ export class ListModel { name: this.data?.name || '', uri: this.uri, }) - // TEMPORARY + // TODO TEMPORARY // lists are temporarily piggybacking on the saved/pinned feeds preferences // we'll eventually replace saved feeds with the bookmarks API // until then, we need to unsave lists instead of just unpin them diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index cf7307ca31..1943f6dbc1 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -74,7 +74,6 @@ export class RootStoreModel { session: this.session.serialize(), me: this.me.serialize(), onboarding: this.onboarding.serialize(), - shell: this.shell.serialize(), preferences: this.preferences.serialize(), invitedUsers: this.invitedUsers.serialize(), mutedThreads: this.mutedThreads.serialize(), @@ -99,9 +98,6 @@ export class RootStoreModel { if (hasProp(v, 'session')) { this.session.hydrate(v.session) } - if (hasProp(v, 'shell')) { - this.shell.hydrate(v.shell) - } if (hasProp(v, 'preferences')) { this.preferences.hydrate(v.preferences) } diff --git a/src/state/models/ui/saved-feeds.ts b/src/state/models/ui/saved-feeds.ts index fd84edc02b..624da4f5f2 100644 --- a/src/state/models/ui/saved-feeds.ts +++ b/src/state/models/ui/saved-feeds.ts @@ -38,12 +38,18 @@ export class SavedFeedsModel { return this.hasLoaded && !this.hasContent } - get pinned() { - return this.all.filter(feed => feed.isPinned) + get pinned(): FeedSourceModel[] { + return this.rootStore.preferences.savedFeeds + .filter(feed => this.rootStore.preferences.isPinnedFeed(feed)) + .map(uri => this.all.find(f => f.uri === uri)) + .filter(Boolean) as FeedSourceModel[] } - get unpinned() { - return this.all.filter(feed => !feed.isPinned) + get unpinned(): FeedSourceModel[] { + return this.rootStore.preferences.savedFeeds + .filter(feed => !this.rootStore.preferences.isPinnedFeed(feed)) + .map(uri => this.all.find(f => f.uri === uri)) + .filter(Boolean) as FeedSourceModel[] } get pinnedFeedNames() { diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts index 9c0cc6e307..d39131629e 100644 --- a/src/state/models/ui/shell.ts +++ b/src/state/models/ui/shell.ts @@ -2,13 +2,11 @@ import {AppBskyEmbedRecord, AppBskyActorDefs, ModerationUI} from '@atproto/api' import {RootStoreModel} from '../root-store' import {makeAutoObservable, runInAction} from 'mobx' import {ProfileModel} from '../content/profile' -import {isObj, hasProp} from 'lib/type-guards' 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 {isWeb} from 'platform/detection' export type ColorMode = 'system' | 'light' | 'dark' @@ -265,10 +263,6 @@ export interface ComposerOpts { } export class ShellUiModel { - colorMode: ColorMode = 'system' - minimalShellMode = false - isDrawerOpen = false - isDrawerSwipeDisabled = false isModalActive = false activeModals: Modal[] = [] isLightboxActive = false @@ -279,44 +273,13 @@ export class ShellUiModel { constructor(public rootStore: RootStoreModel) { makeAutoObservable(this, { - serialize: false, rootStore: false, - hydrate: false, }) this.setupClock() this.setupLoginModals() } - serialize(): unknown { - return { - colorMode: this.colorMode, - } - } - - hydrate(v: unknown) { - if (isObj(v)) { - if (hasProp(v, 'colorMode') && isColorMode(v.colorMode)) { - this.setColorMode(v.colorMode) - } - } - } - - setColorMode(mode: ColorMode) { - this.colorMode = mode - - if (isWeb && typeof window !== 'undefined') { - const html = window.document.documentElement - // remove any other color mode classes - html.className = html.className.replace(/colorMode--\w+/g, '') - html.classList.add(`colorMode--${mode}`) - } - } - - setMinimalShellMode(v: boolean) { - this.minimalShellMode = v - } - /** * returns true if something was closed * (used by the android hardware back btn) @@ -334,10 +297,6 @@ export class ShellUiModel { this.closeComposer() return true } - if (this.isDrawerOpen) { - this.closeDrawer() - return true - } return false } @@ -354,21 +313,6 @@ export class ShellUiModel { if (this.isComposerActive) { this.closeComposer() } - if (this.isDrawerOpen) { - this.closeDrawer() - } - } - - openDrawer() { - this.isDrawerOpen = true - } - - closeDrawer() { - this.isDrawerOpen = false - } - - setIsDrawerSwipeDisabled(v: boolean) { - this.isDrawerSwipeDisabled = v } openModal(modal: Modal) { diff --git a/src/state/persisted/broadcast/index.ts b/src/state/persisted/broadcast/index.ts new file mode 100644 index 0000000000..e0e7f724b7 --- /dev/null +++ b/src/state/persisted/broadcast/index.ts @@ -0,0 +1,6 @@ +export default class BroadcastChannel { + constructor(public name: string) {} + postMessage(_data: any) {} + close() {} + onmessage: (event: MessageEvent) => void = () => {} +} diff --git a/src/state/persisted/broadcast/index.web.ts b/src/state/persisted/broadcast/index.web.ts new file mode 100644 index 0000000000..33b3548ad3 --- /dev/null +++ b/src/state/persisted/broadcast/index.web.ts @@ -0,0 +1 @@ +export default BroadcastChannel diff --git a/src/state/persisted/index.ts b/src/state/persisted/index.ts new file mode 100644 index 0000000000..67fac6b65b --- /dev/null +++ b/src/state/persisted/index.ts @@ -0,0 +1,91 @@ +import EventEmitter from 'eventemitter3' +import {logger} from '#/logger' +import {defaults, Schema} from '#/state/persisted/schema' +import {migrate} from '#/state/persisted/legacy' +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' + +const broadcast = new BroadcastChannel('BSKY_BROADCAST_CHANNEL') +const UPDATE_EVENT = 'BSKY_UPDATE' + +let _state: Schema = defaults +const _emitter = new EventEmitter() + +/** + * Initializes and returns persisted data state, so that it can be passed to + * the Provider. + */ +export async function init() { + logger.debug('persisted state: initializing') + + broadcast.onmessage = onBroadcastMessage + + try { + await migrate() // migrate old store + const stored = await store.read() // check for new store + if (!stored) await store.write(defaults) // opt: init new store + _state = stored || defaults // return new store + } catch (e) { + logger.error('persisted state: failed to load root state from storage', { + error: e, + }) + // AsyncStorage failured, but we can still continue in memory + return defaults + } +} + +export function get(key: K): Schema[K] { + return _state[key] +} + +export async function write( + key: K, + value: Schema[K], +): Promise { + try { + _state[key] = value + await store.write(_state) + // must happen on next tick, otherwise the tab will read stale storage data + setTimeout(() => broadcast.postMessage({event: UPDATE_EVENT}), 0) + logger.debug(`persisted state: wrote root state to storage`) + } catch (e) { + logger.error(`persisted state: failed writing root state to storage`, { + error: e, + }) + } +} + +export function onUpdate(cb: () => void): () => void { + _emitter.addListener('update', cb) + return () => _emitter.removeListener('update', cb) +} + +async function onBroadcastMessage({data}: MessageEvent) { + // validate event + if (typeof data === 'object' && data.event === UPDATE_EVENT) { + try { + // read next state, possibly updated by another tab + const next = await store.read() + + if (next) { + logger.debug(`persisted state: handling update from broadcast channel`) + _state = next + _emitter.emit('update') + } else { + logger.error( + `persisted state: handled update update from broadcast channel, but found no data`, + ) + } + } catch (e) { + logger.error( + `persisted state: failed handling update from broadcast channel`, + { + error: e, + }, + ) + } + } +} diff --git a/src/state/persisted/legacy.ts b/src/state/persisted/legacy.ts new file mode 100644 index 0000000000..6d0a2bccc1 --- /dev/null +++ b/src/state/persisted/legacy.ts @@ -0,0 +1,137 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' + +import {logger} from '#/logger' +import {defaults, Schema} from '#/state/persisted/schema' +import {write, read} from '#/state/persisted/store' + +/** + * The shape of the serialized data from our legacy Mobx store. + */ +type LegacySchema = { + shell: { + colorMode: 'system' | 'light' | 'dark' + } + session: { + data: { + service: string + did: `did:plc:${string}` + } + accounts: { + service: string + did: `did:plc:${string}` + refreshJwt: string + accessJwt: string + handle: string + email: string + displayName: string + aviUrl: string + emailConfirmed: boolean + }[] + } + me: { + did: `did:plc:${string}` + handle: string + displayName: string + description: string + avatar: string + } + onboarding: { + step: string + } + preferences: { + primaryLanguage: string + contentLanguages: string[] + postLanguage: string + postLanguageHistory: string[] + contentLabels: { + nsfw: string + nudity: string + suggestive: string + gore: string + hate: string + spam: string + impersonation: string + } + savedFeeds: string[] + pinnedFeeds: string[] + requireAltTextEnabled: boolean + } + invitedUsers: { + seenDids: string[] + copiedInvites: string[] + } + mutedThreads: {uris: string[]} + reminders: {lastEmailConfirm: string} +} + +const DEPRECATED_ROOT_STATE_STORAGE_KEY = 'root' + +export function transform(legacy: LegacySchema): Schema { + return { + colorMode: legacy.shell?.colorMode || defaults.colorMode, + session: { + accounts: legacy.session.accounts || defaults.session.accounts, + currentAccount: + legacy.session.accounts.find(a => a.did === legacy.session.data.did) || + defaults.session.currentAccount, + }, + reminders: { + lastEmailConfirmReminder: + legacy.reminders.lastEmailConfirm || + defaults.reminders.lastEmailConfirmReminder, + }, + languagePrefs: { + primaryLanguage: + legacy.preferences.primaryLanguage || + defaults.languagePrefs.primaryLanguage, + contentLanguages: + legacy.preferences.contentLanguages || + defaults.languagePrefs.contentLanguages, + postLanguage: + legacy.preferences.postLanguage || defaults.languagePrefs.postLanguage, + postLanguageHistory: + legacy.preferences.postLanguageHistory || + defaults.languagePrefs.postLanguageHistory, + }, + requireAltTextEnabled: + legacy.preferences.requireAltTextEnabled || + defaults.requireAltTextEnabled, + mutedThreads: legacy.mutedThreads.uris || defaults.mutedThreads, + invitedUsers: { + seenDids: legacy.invitedUsers.seenDids || defaults.invitedUsers.seenDids, + copiedInvites: + legacy.invitedUsers.copiedInvites || + defaults.invitedUsers.copiedInvites, + }, + onboarding: { + step: legacy.onboarding.step || defaults.onboarding.step, + }, + } +} + +/** + * Migrates legacy persisted state to new store if new store doesn't exist in + * local storage AND old storage exists. + */ +export async function migrate() { + logger.debug('persisted state: migrate') + + try { + const rawLegacyData = await AsyncStorage.getItem( + DEPRECATED_ROOT_STATE_STORAGE_KEY, + ) + const alreadyMigrated = Boolean(await read()) + + if (!alreadyMigrated && rawLegacyData) { + logger.debug('persisted state: migrating legacy storage') + const legacyData = JSON.parse(rawLegacyData) + const newData = transform(legacyData) + await write(newData) + logger.debug('persisted state: migrated legacy storage') + } + } catch (e) { + logger.error('persisted state: error migrating legacy storage', { + error: String(e), + }) + } +} diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts new file mode 100644 index 0000000000..1c5d317cce --- /dev/null +++ b/src/state/persisted/schema.ts @@ -0,0 +1,68 @@ +import {z} from 'zod' +import {deviceLocales} from '#/platform/detection' + +// only data needed for rendering account page +const accountSchema = z.object({ + service: z.string(), + did: z.string(), + refreshJwt: z.string().optional(), + accessJwt: z.string().optional(), + handle: z.string(), + displayName: z.string(), + aviUrl: z.string(), +}) + +export const schema = z.object({ + colorMode: z.enum(['system', 'light', 'dark']), + session: z.object({ + accounts: z.array(accountSchema), + currentAccount: accountSchema.optional(), + }), + reminders: z.object({ + lastEmailConfirmReminder: z.string().optional(), + }), + languagePrefs: z.object({ + primaryLanguage: z.string(), // should move to server + contentLanguages: z.array(z.string()), // should move to server + postLanguage: z.string(), // should move to server + postLanguageHistory: z.array(z.string()), + }), + requireAltTextEnabled: z.boolean(), // should move to server + mutedThreads: z.array(z.string()), // should move to server + invitedUsers: z.object({ + seenDids: z.array(z.string()), + copiedInvites: z.array(z.string()), + }), + onboarding: z.object({ + step: z.string(), + }), +}) +export type Schema = z.infer + +export const defaults: Schema = { + colorMode: 'system', + session: { + accounts: [], + currentAccount: undefined, + }, + reminders: { + lastEmailConfirmReminder: undefined, + }, + languagePrefs: { + primaryLanguage: deviceLocales[0] || 'en', + contentLanguages: deviceLocales || [], + postLanguage: deviceLocales[0] || 'en', + postLanguageHistory: (deviceLocales || []) + .concat(['en', 'ja', 'pt', 'de']) + .slice(0, 6), + }, + requireAltTextEnabled: false, + mutedThreads: [], + invitedUsers: { + seenDids: [], + copiedInvites: [], + }, + onboarding: { + step: 'Home', + }, +} diff --git a/src/state/persisted/store.ts b/src/state/persisted/store.ts new file mode 100644 index 0000000000..2b03bec20c --- /dev/null +++ b/src/state/persisted/store.ts @@ -0,0 +1,18 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' + +import {Schema, schema} from '#/state/persisted/schema' + +const BSKY_STORAGE = 'BSKY_STORAGE' + +export async function write(value: Schema) { + schema.parse(value) + await AsyncStorage.setItem(BSKY_STORAGE, JSON.stringify(value)) +} + +export async function read(): Promise { + const rawData = await AsyncStorage.getItem(BSKY_STORAGE) + const objData = rawData ? JSON.parse(rawData) : undefined + if (schema.safeParse(objData).success) { + return objData + } +} diff --git a/src/state/shell/color-mode.tsx b/src/state/shell/color-mode.tsx new file mode 100644 index 0000000000..74379da371 --- /dev/null +++ b/src/state/shell/color-mode.tsx @@ -0,0 +1,56 @@ +import React from 'react' +import {isWeb} from '#/platform/detection' +import * as persisted from '#/state/persisted' + +type StateContext = persisted.Schema['colorMode'] +type SetContext = (v: persisted.Schema['colorMode']) => void + +const stateContext = React.createContext('system') +const setContext = React.createContext( + (_: persisted.Schema['colorMode']) => {}, +) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [state, setState] = React.useState(persisted.get('colorMode')) + + const setStateWrapped = React.useCallback( + (colorMode: persisted.Schema['colorMode']) => { + setState(colorMode) + persisted.write('colorMode', colorMode) + updateDocument(colorMode) + }, + [setState], + ) + + React.useEffect(() => { + return persisted.onUpdate(() => { + setState(persisted.get('colorMode')) + updateDocument(persisted.get('colorMode')) + }) + }, [setStateWrapped]) + + return ( + + + {children} + + + ) +} + +export function useColorMode() { + return React.useContext(stateContext) +} + +export function useSetColorMode() { + return React.useContext(setContext) +} + +function updateDocument(colorMode: string) { + if (isWeb && typeof window !== 'undefined') { + const html = window.document.documentElement + // remove any other color mode classes + html.className = html.className.replace(/colorMode--\w+/g, '') + html.classList.add(`colorMode--${colorMode}`) + } +} diff --git a/src/state/shell/drawer-open.tsx b/src/state/shell/drawer-open.tsx new file mode 100644 index 0000000000..a2322f6806 --- /dev/null +++ b/src/state/shell/drawer-open.tsx @@ -0,0 +1,24 @@ +import React from 'react' + +type StateContext = boolean +type SetContext = (v: boolean) => void + +const stateContext = React.createContext(false) +const setContext = React.createContext((_: boolean) => {}) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [state, setState] = React.useState(false) + return ( + + {children} + + ) +} + +export function useIsDrawerOpen() { + return React.useContext(stateContext) +} + +export function useSetDrawerOpen() { + return React.useContext(setContext) +} diff --git a/src/state/shell/drawer-swipe-disabled.tsx b/src/state/shell/drawer-swipe-disabled.tsx new file mode 100644 index 0000000000..d3f09f2a8b --- /dev/null +++ b/src/state/shell/drawer-swipe-disabled.tsx @@ -0,0 +1,24 @@ +import React from 'react' + +type StateContext = boolean +type SetContext = (v: boolean) => void + +const stateContext = React.createContext(false) +const setContext = React.createContext((_: boolean) => {}) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [state, setState] = React.useState(false) + return ( + + {children} + + ) +} + +export function useIsDrawerSwipeDisabled() { + return React.useContext(stateContext) +} + +export function useSetDrawerSwipeDisabled() { + return React.useContext(setContext) +} diff --git a/src/state/shell/index.tsx b/src/state/shell/index.tsx new file mode 100644 index 0000000000..1e01a4e7d5 --- /dev/null +++ b/src/state/shell/index.tsx @@ -0,0 +1,25 @@ +import React from 'react' +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' + +export {useIsDrawerOpen, useSetDrawerOpen} from './drawer-open' +export { + useIsDrawerSwipeDisabled, + useSetDrawerSwipeDisabled, +} from './drawer-swipe-disabled' +export {useMinimalShellMode, useSetMinimalShellMode} from './minimal-mode' +export {useColorMode, useSetColorMode} from './color-mode' + +export function Provider({children}: React.PropsWithChildren<{}>) { + return ( + + + + {children} + + + + ) +} diff --git a/src/state/shell/minimal-mode.tsx b/src/state/shell/minimal-mode.tsx new file mode 100644 index 0000000000..4909a9a651 --- /dev/null +++ b/src/state/shell/minimal-mode.tsx @@ -0,0 +1,24 @@ +import React from 'react' + +type StateContext = boolean +type SetContext = (v: boolean) => void + +const stateContext = React.createContext(false) +const setContext = React.createContext((_: boolean) => {}) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [state, setState] = React.useState(false) + return ( + + {children} + + ) +} + +export function useMinimalShellMode() { + return React.useContext(stateContext) +} + +export function useSetMinimalShellMode() { + return React.useContext(setContext) +} diff --git a/src/view/com/auth/LoggedOut.tsx b/src/view/com/auth/LoggedOut.tsx index c74c2aa33e..3e2c9c1bff 100644 --- a/src/view/com/auth/LoggedOut.tsx +++ b/src/view/com/auth/LoggedOut.tsx @@ -9,6 +9,7 @@ import {usePalette} from 'lib/hooks/usePalette' import {useStores} from 'state/index' import {useAnalytics} from 'lib/analytics/analytics' import {SplashScreen} from './SplashScreen' +import {useSetMinimalShellMode} from '#/state/shell/minimal-mode' enum ScreenState { S_LoginOrCreateAccount, @@ -19,6 +20,7 @@ enum ScreenState { export const LoggedOut = observer(function LoggedOutImpl() { const pal = usePalette('default') const store = useStores() + const setMinimalShellMode = useSetMinimalShellMode() const {screen} = useAnalytics() const [screenState, setScreenState] = React.useState( ScreenState.S_LoginOrCreateAccount, @@ -26,8 +28,8 @@ export const LoggedOut = observer(function LoggedOutImpl() { React.useEffect(() => { screen('Login') - store.shell.setMinimalShellMode(true) - }, [store, screen]) + setMinimalShellMode(true) + }, [screen, setMinimalShellMode]) if ( store.session.isResumingSession || diff --git a/src/view/com/auth/Onboarding.tsx b/src/view/com/auth/Onboarding.tsx index a36544a037..bec1dc236a 100644 --- a/src/view/com/auth/Onboarding.tsx +++ b/src/view/com/auth/Onboarding.tsx @@ -8,14 +8,16 @@ 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' export const Onboarding = observer(function OnboardingImpl() { const pal = usePalette('default') const store = useStores() + const setMinimalShellMode = useSetMinimalShellMode() React.useEffect(() => { - store.shell.setMinimalShellMode(true) - }, [store]) + setMinimalShellMode(true) + }, [setMinimalShellMode]) const next = () => store.onboarding.next() const skip = () => store.onboarding.skip() diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx index 688309624d..502e4b4d2d 100644 --- a/src/view/com/composer/ExternalEmbed.tsx +++ b/src/view/com/composer/ExternalEmbed.tsx @@ -54,14 +54,14 @@ export const ExternalEmbed = ({ {link.meta.description} )} - {!!link.meta?.error && ( + {link.meta?.error ? ( {link.meta.error} - )} + ) : null} (null) diff --git a/src/view/com/lists/ListsList.tsx b/src/view/com/lists/ListsList.tsx index 2992e1a749..2883a31d52 100644 --- a/src/view/com/lists/ListsList.tsx +++ b/src/view/com/lists/ListsList.tsx @@ -17,7 +17,7 @@ import {Text} from '../util/text/Text' import {ListsListModel} from 'state/models/lists/lists-list' import {useAnalytics} from 'lib/analytics/analytics' import {usePalette} from 'lib/hooks/usePalette' -import {FlatList} from '../util/Views.web' +import {FlatList} from '../util/Views' import {s} from 'lib/styles' import {logger} from '#/logger' import {Trans} from '@lingui/macro' diff --git a/src/view/com/modals/ChangeHandle.tsx b/src/view/com/modals/ChangeHandle.tsx index 825b421699..fb08cbccc8 100644 --- a/src/view/com/modals/ChangeHandle.tsx +++ b/src/view/com/modals/ChangeHandle.tsx @@ -489,13 +489,13 @@ function CustomHandleForm({ )} - {error && ( + {error ? ( {error} - )} + ) : null} - + ) }), diff --git a/src/view/screens/Log.tsx b/src/view/screens/Log.tsx index fc8f7868b4..69c07edae0 100644 --- a/src/view/screens/Log.tsx +++ b/src/view/screens/Log.tsx @@ -5,7 +5,6 @@ import {observer} from 'mobx-react-lite' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {ScrollView} from '../com/util/Views' -import {useStores} from 'state/index' import {s} from 'lib/styles' import {ViewHeader} from '../com/util/ViewHeader' import {Text} from '../com/util/text/Text' @@ -14,20 +13,21 @@ import {getEntries} from '#/logger/logDump' import {ago} from 'lib/strings/time' import {useLingui} from '@lingui/react' import {msg} from '@lingui/macro' +import {useSetMinimalShellMode} from '#/state/shell' export const LogScreen = observer(function Log({}: NativeStackScreenProps< CommonNavigatorParams, 'Log' >) { const pal = usePalette('default') - const store = useStores() const {_} = useLingui() + const setMinimalShellMode = useSetMinimalShellMode() const [expanded, setExpanded] = React.useState([]) useFocusEffect( React.useCallback(() => { - store.shell.setMinimalShellMode(false) - }, [store]), + setMinimalShellMode(false) + }, [setMinimalShellMode]), ) const toggler = (id: string) => () => { diff --git a/src/view/screens/Moderation.tsx b/src/view/screens/Moderation.tsx index d24bc145a5..142f3bce8d 100644 --- a/src/view/screens/Moderation.tsx +++ b/src/view/screens/Moderation.tsx @@ -17,20 +17,22 @@ import {Text} from '../com/util/text/Text' import {usePalette} from 'lib/hooks/usePalette' import {useAnalytics} from 'lib/analytics/analytics' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {useSetMinimalShellMode} from '#/state/shell' type Props = NativeStackScreenProps export const ModerationScreen = withAuthRequired( observer(function Moderation({}: Props) { const pal = usePalette('default') const store = useStores() + const setMinimalShellMode = useSetMinimalShellMode() const {screen, track} = useAnalytics() const {isTabletOrDesktop} = useWebMediaQueries() useFocusEffect( React.useCallback(() => { screen('Moderation') - store.shell.setMinimalShellMode(false) - }, [screen, store]), + setMinimalShellMode(false) + }, [screen, setMinimalShellMode]), ) const onPressContentFiltering = React.useCallback(() => { diff --git a/src/view/screens/ModerationBlockedAccounts.tsx b/src/view/screens/ModerationBlockedAccounts.tsx index f302c96b5b..0dc3b706bd 100644 --- a/src/view/screens/ModerationBlockedAccounts.tsx +++ b/src/view/screens/ModerationBlockedAccounts.tsx @@ -22,6 +22,7 @@ import {ViewHeader} from '../com/util/ViewHeader' import {CenteredView} from 'view/com/util/Views' import {ProfileCard} from 'view/com/profile/ProfileCard' import {logger} from '#/logger' +import {useSetMinimalShellMode} from '#/state/shell' type Props = NativeStackScreenProps< CommonNavigatorParams, @@ -31,6 +32,7 @@ export const ModerationBlockedAccounts = withAuthRequired( observer(function ModerationBlockedAccountsImpl({}: Props) { const pal = usePalette('default') const store = useStores() + const setMinimalShellMode = useSetMinimalShellMode() const {isTabletOrDesktop} = useWebMediaQueries() const {screen} = useAnalytics() const blockedAccounts = useMemo( @@ -41,9 +43,9 @@ export const ModerationBlockedAccounts = withAuthRequired( useFocusEffect( React.useCallback(() => { screen('BlockedAccounts') - store.shell.setMinimalShellMode(false) + setMinimalShellMode(false) blockedAccounts.refresh() - }, [screen, store, blockedAccounts]), + }, [screen, setMinimalShellMode, blockedAccounts]), ) const onRefresh = React.useCallback(() => { diff --git a/src/view/screens/ModerationModlists.tsx b/src/view/screens/ModerationModlists.tsx index fc148ed8ee..8794c6d176 100644 --- a/src/view/screens/ModerationModlists.tsx +++ b/src/view/screens/ModerationModlists.tsx @@ -16,12 +16,14 @@ import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader' import {s} from 'lib/styles' +import {useSetMinimalShellMode} from '#/state/shell' type Props = NativeStackScreenProps export const ModerationModlistsScreen = withAuthRequired( observer(function ModerationModlistsScreenImpl({}: Props) { const pal = usePalette('default') const store = useStores() + const setMinimalShellMode = useSetMinimalShellMode() const {isMobile} = useWebMediaQueries() const navigation = useNavigation() @@ -32,9 +34,9 @@ export const ModerationModlistsScreen = withAuthRequired( useFocusEffect( React.useCallback(() => { - store.shell.setMinimalShellMode(false) + setMinimalShellMode(false) mutelists.refresh() - }, [store, mutelists]), + }, [mutelists, setMinimalShellMode]), ) const onPressNewList = React.useCallback(() => { @@ -85,7 +87,7 @@ export const ModerationModlistsScreen = withAuthRequired( - + ) }), diff --git a/src/view/screens/ModerationMutedAccounts.tsx b/src/view/screens/ModerationMutedAccounts.tsx index 20bd21f37a..2fa27ee54f 100644 --- a/src/view/screens/ModerationMutedAccounts.tsx +++ b/src/view/screens/ModerationMutedAccounts.tsx @@ -22,6 +22,7 @@ import {ViewHeader} from '../com/util/ViewHeader' import {CenteredView} from 'view/com/util/Views' import {ProfileCard} from 'view/com/profile/ProfileCard' import {logger} from '#/logger' +import {useSetMinimalShellMode} from '#/state/shell' type Props = NativeStackScreenProps< CommonNavigatorParams, @@ -31,6 +32,7 @@ export const ModerationMutedAccounts = withAuthRequired( observer(function ModerationMutedAccountsImpl({}: Props) { const pal = usePalette('default') const store = useStores() + const setMinimalShellMode = useSetMinimalShellMode() const {isTabletOrDesktop} = useWebMediaQueries() const {screen} = useAnalytics() const mutedAccounts = useMemo(() => new MutedAccountsModel(store), [store]) @@ -38,9 +40,9 @@ export const ModerationMutedAccounts = withAuthRequired( useFocusEffect( React.useCallback(() => { screen('MutedAccounts') - store.shell.setMinimalShellMode(false) + setMinimalShellMode(false) mutedAccounts.refresh() - }, [screen, store, mutedAccounts]), + }, [screen, setMinimalShellMode, mutedAccounts]), ) const onRefresh = React.useCallback(() => { diff --git a/src/view/screens/NotFound.tsx b/src/view/screens/NotFound.tsx index cb52da58b3..c2125756cd 100644 --- a/src/view/screens/NotFound.tsx +++ b/src/view/screens/NotFound.tsx @@ -10,18 +10,18 @@ import {Text} from '../com/util/text/Text' import {Button} from 'view/com/util/forms/Button' import {NavigationProp} from 'lib/routes/types' import {usePalette} from 'lib/hooks/usePalette' -import {useStores} from 'state/index' import {s} from 'lib/styles' +import {useSetMinimalShellMode} from '#/state/shell' export const NotFoundScreen = () => { const pal = usePalette('default') const navigation = useNavigation() - const store = useStores() + const setMinimalShellMode = useSetMinimalShellMode() useFocusEffect( React.useCallback(() => { - store.shell.setMinimalShellMode(false) - }, [store]), + setMinimalShellMode(false) + }, [setMinimalShellMode]), ) const canGoBack = navigation.canGoBack() diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx index e1137ae9d5..cd482bd1c4 100644 --- a/src/view/screens/Notifications.tsx +++ b/src/view/screens/Notifications.tsx @@ -21,6 +21,7 @@ import {s, colors} from 'lib/styles' import {useAnalytics} from 'lib/analytics/analytics' import {isWeb} from 'platform/detection' import {logger} from '#/logger' +import {useSetMinimalShellMode} from '#/state/shell' type Props = NativeStackScreenProps< NotificationsTabNavigatorParams, @@ -29,8 +30,8 @@ type Props = NativeStackScreenProps< export const NotificationsScreen = withAuthRequired( observer(function NotificationsScreenImpl({}: Props) { const store = useStores() - const [onMainScroll, isScrolledDown, resetMainScroll] = - useOnMainScroll(store) + const setMinimalShellMode = useSetMinimalShellMode() + const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll() const scrollElRef = React.useRef(null) const {screen} = useAnalytics() const pal = usePalette('default') @@ -60,7 +61,7 @@ export const NotificationsScreen = withAuthRequired( // = useFocusEffect( React.useCallback(() => { - store.shell.setMinimalShellMode(false) + setMinimalShellMode(false) logger.debug('NotificationsScreen: Updating feed') const softResetSub = store.onScreenSoftReset(onPressLoadLatest) store.me.notifications.update() @@ -70,7 +71,7 @@ export const NotificationsScreen = withAuthRequired( softResetSub.remove() store.me.notifications.markAllRead() } - }, [store, screen, onPressLoadLatest]), + }, [store, screen, onPressLoadLatest, setMinimalShellMode]), ) useTabFocusEffect( diff --git a/src/view/screens/PostLikedBy.tsx b/src/view/screens/PostLikedBy.tsx index fb44f1f9be..2f45908b3d 100644 --- a/src/view/screens/PostLikedBy.tsx +++ b/src/view/screens/PostLikedBy.tsx @@ -5,19 +5,19 @@ import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {ViewHeader} from '../com/util/ViewHeader' import {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedBy' -import {useStores} from 'state/index' import {makeRecordUri} from 'lib/strings/url-helpers' +import {useSetMinimalShellMode} from '#/state/shell' type Props = NativeStackScreenProps export const PostLikedByScreen = withAuthRequired(({route}: Props) => { - const store = useStores() + const setMinimalShellMode = useSetMinimalShellMode() const {name, rkey} = route.params const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) useFocusEffect( React.useCallback(() => { - store.shell.setMinimalShellMode(false) - }, [store]), + setMinimalShellMode(false) + }, [setMinimalShellMode]), ) return ( diff --git a/src/view/screens/PostRepostedBy.tsx b/src/view/screens/PostRepostedBy.tsx index 19f0af18b5..abe03467a7 100644 --- a/src/view/screens/PostRepostedBy.tsx +++ b/src/view/screens/PostRepostedBy.tsx @@ -5,19 +5,19 @@ import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {ViewHeader} from '../com/util/ViewHeader' import {PostRepostedBy as PostRepostedByComponent} from '../com/post-thread/PostRepostedBy' -import {useStores} from 'state/index' import {makeRecordUri} from 'lib/strings/url-helpers' +import {useSetMinimalShellMode} from '#/state/shell' type Props = NativeStackScreenProps export const PostRepostedByScreen = withAuthRequired(({route}: Props) => { - const store = useStores() const {name, rkey} = route.params const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) + const setMinimalShellMode = useSetMinimalShellMode() useFocusEffect( React.useCallback(() => { - store.shell.setMinimalShellMode(false) - }, [store]), + setMinimalShellMode(false) + }, [setMinimalShellMode]), ) return ( diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx index 8bb279be84..0bdd062698 100644 --- a/src/view/screens/PostThread.tsx +++ b/src/view/screens/PostThread.tsx @@ -15,6 +15,7 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context' import {clamp} from 'lodash' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {logger} from '#/logger' +import {useMinimalShellMode, useSetMinimalShellMode} from '#/state/shell' const SHELL_FOOTER_HEIGHT = 44 @@ -22,6 +23,8 @@ type Props = NativeStackScreenProps export const PostThreadScreen = withAuthRequired( observer(function PostThreadScreenImpl({route}: Props) { const store = useStores() + const minimalShellMode = useMinimalShellMode() + const setMinimalShellMode = useSetMinimalShellMode() const safeAreaInsets = useSafeAreaInsets() const {name, rkey} = route.params const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) @@ -33,7 +36,7 @@ export const PostThreadScreen = withAuthRequired( useFocusEffect( React.useCallback(() => { - store.shell.setMinimalShellMode(false) + setMinimalShellMode(false) const threadCleanup = view.registerListeners() InteractionManager.runAfterInteractions(() => { @@ -47,7 +50,7 @@ export const PostThreadScreen = withAuthRequired( return () => { threadCleanup() } - }, [store, view]), + }, [view, setMinimalShellMode]), ) const onPressReply = React.useCallback(() => { @@ -80,7 +83,7 @@ export const PostThreadScreen = withAuthRequired( treeView={!!store.preferences.thread.lab_treeViewEnabled} /> - {isMobile && !store.shell.minimalShellMode && ( + {isMobile && !minimalShellMode && ( export const PrivacyPolicyScreen = (_props: Props) => { const pal = usePalette('default') - const store = useStores() + const setMinimalShellMode = useSetMinimalShellMode() useFocusEffect( React.useCallback(() => { - store.shell.setMinimalShellMode(false) - }, [store]), + setMinimalShellMode(false) + }, [setMinimalShellMode]), ) return ( diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 1d738bfd32..f2aa7f05d2 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -32,11 +32,13 @@ import {combinedDisplayName} from 'lib/strings/display-names' import {logger} from '#/logger' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useSetMinimalShellMode} from '#/state/shell' type Props = NativeStackScreenProps export const ProfileScreen = withAuthRequired( observer(function ProfileScreenImpl({route}: Props) { const store = useStores() + const setMinimalShellMode = useSetMinimalShellMode() const {screen, track} = useAnalytics() const {_} = useLingui() const viewSelectorRef = React.useRef(null) @@ -72,7 +74,7 @@ export const ProfileScreen = withAuthRequired( React.useCallback(() => { const softResetSub = store.onScreenSoftReset(onSoftReset) let aborted = false - store.shell.setMinimalShellMode(false) + setMinimalShellMode(false) const feedCleanup = uiState.feed.registerListeners() if (!hasSetup) { uiState.setup().then(() => { @@ -87,7 +89,7 @@ export const ProfileScreen = withAuthRequired( feedCleanup() softResetSub.remove() } - }, [store, onSoftReset, uiState, hasSetup]), + }, [store, onSoftReset, uiState, hasSetup, setMinimalShellMode]), ) // events diff --git a/src/view/screens/ProfileFeed.tsx b/src/view/screens/ProfileFeed.tsx index 051c0ab9fe..a4d3a4fe0b 100644 --- a/src/view/screens/ProfileFeed.tsx +++ b/src/view/screens/ProfileFeed.tsx @@ -1,7 +1,14 @@ import React, {useMemo, useCallback} from 'react' -import {FlatList, StyleSheet, View, ActivityIndicator} from 'react-native' +import { + FlatList, + NativeScrollEvent, + StyleSheet, + View, + ActivityIndicator, +} from 'react-native' import {NativeStackScreenProps} from '@react-navigation/native-stack' import {useNavigation} from '@react-navigation/native' +import {useAnimatedScrollHandler} from 'react-native-reanimated' import {usePalette} from 'lib/hooks/usePalette' import {HeartIcon, HeartIconSolid} from 'lib/icons' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' @@ -26,7 +33,6 @@ import {EmptyState} from 'view/com/util/EmptyState' import * as Toast from 'view/com/util/Toast' import {useSetTitle} from 'lib/hooks/useSetTitle' import {useCustomFeed} from 'lib/hooks/useCustomFeed' -import {OnScrollCb} from 'lib/hooks/useOnMainScroll' import {shareUrl} from 'lib/sharing' import {toShareUrl} from 'lib/strings/url-helpers' import {Haptics} from 'lib/haptics' @@ -337,11 +343,11 @@ export const ProfileFeedScreenInner = observer( {({onScroll, headerHeight, isScrolledDown}) => ( )} {({onScroll, headerHeight}) => ( - - - + /> )} void headerHeight: number isScrolledDown: boolean } @@ -410,12 +412,13 @@ const FeedSection = React.forwardRef( return }, []) + const scrollHandler = useAnimatedScrollHandler({onScroll}) return ( void + onScroll: (e: NativeScrollEvent) => void }) { const pal = usePalette('default') const {_} = useLingui() + const scrollHandler = useAnimatedScrollHandler({onScroll}) if (!feedInfo) { return } + return ( - - {feedInfo.descriptionRT ? ( - - ) : ( - - No description - - )} - - - {typeof feedInfo.likeCount === 'number' && ( - + + {feedInfo.descriptionRT ? ( + - )} - - - Created by{' '} - {feedInfo.isOwner ? ( - 'you' ) : ( - + + No description + )} - - + + + {typeof feedInfo.likeCount === 'number' && ( + + )} + + + Created by{' '} + {feedInfo.isOwner ? ( + 'you' + ) : ( + + )} + + + ) }) diff --git a/src/view/screens/ProfileFeedLikedBy.tsx b/src/view/screens/ProfileFeedLikedBy.tsx index 2e9d12aae3..4972116f35 100644 --- a/src/view/screens/ProfileFeedLikedBy.tsx +++ b/src/view/screens/ProfileFeedLikedBy.tsx @@ -5,19 +5,19 @@ import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {ViewHeader} from '../com/util/ViewHeader' import {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedBy' -import {useStores} from 'state/index' import {makeRecordUri} from 'lib/strings/url-helpers' +import {useSetMinimalShellMode} from '#/state/shell' type Props = NativeStackScreenProps export const ProfileFeedLikedByScreen = withAuthRequired(({route}: Props) => { - const store = useStores() + const setMinimalShellMode = useSetMinimalShellMode() const {name, rkey} = route.params const uri = makeRecordUri(name, 'app.bsky.feed.generator', rkey) useFocusEffect( React.useCallback(() => { - store.shell.setMinimalShellMode(false) - }, [store]), + setMinimalShellMode(false) + }, [setMinimalShellMode]), ) return ( diff --git a/src/view/screens/ProfileFollowers.tsx b/src/view/screens/ProfileFollowers.tsx index e2f95fbe4f..49f55bf46b 100644 --- a/src/view/screens/ProfileFollowers.tsx +++ b/src/view/screens/ProfileFollowers.tsx @@ -5,17 +5,17 @@ import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {ViewHeader} from '../com/util/ViewHeader' import {ProfileFollowers as ProfileFollowersComponent} from '../com/profile/ProfileFollowers' -import {useStores} from 'state/index' +import {useSetMinimalShellMode} from '#/state/shell' type Props = NativeStackScreenProps export const ProfileFollowersScreen = withAuthRequired(({route}: Props) => { - const store = useStores() const {name} = route.params + const setMinimalShellMode = useSetMinimalShellMode() useFocusEffect( React.useCallback(() => { - store.shell.setMinimalShellMode(false) - }, [store]), + setMinimalShellMode(false) + }, [setMinimalShellMode]), ) return ( diff --git a/src/view/screens/ProfileFollows.tsx b/src/view/screens/ProfileFollows.tsx index f70944f557..4f0ff7d67f 100644 --- a/src/view/screens/ProfileFollows.tsx +++ b/src/view/screens/ProfileFollows.tsx @@ -5,17 +5,17 @@ import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {ViewHeader} from '../com/util/ViewHeader' import {ProfileFollows as ProfileFollowsComponent} from '../com/profile/ProfileFollows' -import {useStores} from 'state/index' +import {useSetMinimalShellMode} from '#/state/shell' type Props = NativeStackScreenProps export const ProfileFollowsScreen = withAuthRequired(({route}: Props) => { - const store = useStores() const {name} = route.params + const setMinimalShellMode = useSetMinimalShellMode() useFocusEffect( React.useCallback(() => { - store.shell.setMinimalShellMode(false) - }, [store]), + setMinimalShellMode(false) + }, [setMinimalShellMode]), ) return ( diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx index 5b4cb9e8f1..7aef64acd0 100644 --- a/src/view/screens/ProfileList.tsx +++ b/src/view/screens/ProfileList.tsx @@ -2,6 +2,7 @@ import React, {useCallback, useMemo} from 'react' import { ActivityIndicator, FlatList, + NativeScrollEvent, Pressable, StyleSheet, View, @@ -10,6 +11,7 @@ import {useFocusEffect} from '@react-navigation/native' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {useNavigation} from '@react-navigation/native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {useAnimatedScrollHandler} from 'react-native-reanimated' import {observer} from 'mobx-react-lite' import {RichText as RichTextAPI} from '@atproto/api' import {withAuthRequired} from 'view/com/auth/withAuthRequired' @@ -33,7 +35,6 @@ import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {useSetTitle} from 'lib/hooks/useSetTitle' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {OnScrollCb} from 'lib/hooks/useOnMainScroll' import {NavigationProp} from 'lib/routes/types' import {toShareUrl} from 'lib/strings/url-helpers' import {shareUrl} from 'lib/sharing' @@ -46,6 +47,7 @@ import {ListItems} from 'view/com/lists/ListItems' import {logger} from '#/logger' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useSetMinimalShellMode} from '#/state/shell' const SECTION_TITLES_CURATE = ['Posts', 'About'] const SECTION_TITLES_MOD = ['About'] @@ -107,6 +109,7 @@ export const ProfileListScreenInner = observer( }: Props & {listOwnerDid: string}) { const store = useStores() const {_} = useLingui() + const setMinimalShellMode = useSetMinimalShellMode() const {rkey} = route.params const feedSectionRef = React.useRef(null) const aboutSectionRef = React.useRef(null) @@ -126,13 +129,13 @@ export const ProfileListScreenInner = observer( useFocusEffect( useCallback(() => { - store.shell.setMinimalShellMode(false) + setMinimalShellMode(false) list.loadMore(true).then(() => { if (list.isCuratelist) { feed.setup() } }) - }, [store, list, feed]), + }, [setMinimalShellMode, list, feed]), ) const onPressAddUser = useCallback(() => { @@ -168,11 +171,11 @@ export const ProfileListScreenInner = observer( {({onScroll, headerHeight, isScrolledDown}) => ( ( {({onScroll, headerHeight, isScrolledDown}) => (
- {list.error && } + {list.error ? : null} ) }, @@ -551,7 +553,7 @@ const Header = observer(function HeaderImpl({ interface FeedSectionProps { feed: PostsFeedModel - onScroll: OnScrollCb + onScroll: (e: NativeScrollEvent) => void headerHeight: number isScrolledDown: boolean } @@ -575,13 +577,14 @@ const FeedSection = React.forwardRef( return }, []) + const scrollHandler = useAnimatedScrollHandler({onScroll}) return ( void - onScroll: OnScrollCb + onScroll: (e: NativeScrollEvent) => void headerHeight: number isScrolledDown: boolean } @@ -736,6 +739,7 @@ const AboutSection = React.forwardRef( ) }, []) + const scrollHandler = useAnimatedScrollHandler({onScroll}) return ( ( renderEmptyState={renderEmptyState} list={list} headerOffset={headerHeight} - onScroll={onScroll} + onScroll={scrollHandler} scrollEventThrottle={1} /> {isScrolledDown && ( diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx index 18bbf06c6e..487f566434 100644 --- a/src/view/screens/SavedFeeds.tsx +++ b/src/view/screens/SavedFeeds.tsx @@ -27,6 +27,7 @@ import * as Toast from 'view/com/util/Toast' import {Haptics} from 'lib/haptics' import {TextLink} from 'view/com/util/Link' import {logger} from '#/logger' +import {useSetMinimalShellMode} from '#/state/shell' const HITSLOP_TOP = { top: 20, @@ -48,6 +49,7 @@ export const SavedFeeds = withAuthRequired( const store = useStores() const {isMobile, isTabletOrDesktop} = useWebMediaQueries() const {screen} = useAnalytics() + const setMinimalShellMode = useSetMinimalShellMode() const savedFeeds = useMemo(() => { const model = new SavedFeedsModel(store) @@ -57,9 +59,9 @@ export const SavedFeeds = withAuthRequired( useFocusEffect( useCallback(() => { screen('SavedFeeds') - store.shell.setMinimalShellMode(false) + setMinimalShellMode(false) savedFeeds.refresh() - }, [screen, store, savedFeeds]), + }, [screen, setMinimalShellMode, savedFeeds]), ) return ( diff --git a/src/view/screens/SearchMobile.tsx b/src/view/screens/SearchMobile.tsx index b80c1667ff..c1df58ffdb 100644 --- a/src/view/screens/SearchMobile.tsx +++ b/src/view/screens/SearchMobile.tsx @@ -27,15 +27,18 @@ import {ProfileCard} from 'view/com/profile/ProfileCard' import {usePalette} from 'lib/hooks/usePalette' import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' import {isAndroid, isIOS} from 'platform/detection' +import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell' type Props = NativeStackScreenProps export const SearchScreen = withAuthRequired( observer(function SearchScreenImpl({}: Props) { const pal = usePalette('default') const store = useStores() + const setMinimalShellMode = useSetMinimalShellMode() + const setIsDrawerSwipeDisabled = useSetDrawerSwipeDisabled() const scrollViewRef = React.useRef(null) const flatListRef = React.useRef(null) - const [onMainScroll] = useOnMainScroll(store) + const [onMainScroll] = useOnMainScroll() const [isInputFocused, setIsInputFocused] = React.useState(false) const [query, setQuery] = React.useState('') const autocompleteView = React.useMemo( @@ -75,8 +78,8 @@ export const SearchScreen = withAuthRequired( setQuery('') autocompleteView.setActive(false) setSearchUIModel(undefined) - store.shell.setIsDrawerSwipeDisabled(false) - }, [setQuery, autocompleteView, store]) + setIsDrawerSwipeDisabled(false) + }, [setQuery, autocompleteView, setIsDrawerSwipeDisabled]) const onSubmitQuery = React.useCallback(() => { if (query.length === 0) { @@ -86,8 +89,8 @@ export const SearchScreen = withAuthRequired( const model = new SearchUIModel(store) model.fetch(query) setSearchUIModel(model) - store.shell.setIsDrawerSwipeDisabled(true) - }, [query, setSearchUIModel, store]) + setIsDrawerSwipeDisabled(true) + }, [query, setSearchUIModel, store, setIsDrawerSwipeDisabled]) const onSoftReset = React.useCallback(() => { scrollViewRef.current?.scrollTo({x: 0, y: 0}) @@ -102,7 +105,7 @@ export const SearchScreen = withAuthRequired( softResetSub.remove() } - store.shell.setMinimalShellMode(false) + setMinimalShellMode(false) autocompleteView.setup() if (!foafs.hasData) { foafs.fetch() @@ -112,7 +115,14 @@ export const SearchScreen = withAuthRequired( } return cleanup - }, [store, autocompleteView, foafs, suggestedActors, onSoftReset]), + }, [ + store, + autocompleteView, + foafs, + suggestedActors, + onSoftReset, + setMinimalShellMode, + ]), ) const onPress = useCallback(() => { diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index 2721530b3c..accf577cbc 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -46,6 +46,11 @@ import Clipboard from '@react-native-clipboard/clipboard' import {makeProfileLink} from 'lib/routes/links' import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn' import {logger} from '#/logger' +import { + useSetMinimalShellMode, + useColorMode, + useSetColorMode, +} from '#/state/shell' // TEMPORARY (APP-700) // remove after backend testing finishes @@ -58,9 +63,12 @@ import {useLingui} from '@lingui/react' type Props = NativeStackScreenProps export const SettingsScreen = withAuthRequired( observer(function Settings({}: Props) { + const colorMode = useColorMode() + const setColorMode = useSetColorMode() const pal = usePalette('default') const store = useStores() const {_} = useLingui() + const setMinimalShellMode = useSetMinimalShellMode() const navigation = useNavigation() const {isMobile} = useWebMediaQueries() const {screen, track} = useAnalytics() @@ -91,8 +99,8 @@ export const SettingsScreen = withAuthRequired( useFocusEffect( React.useCallback(() => { screen('Settings') - store.shell.setMinimalShellMode(false) - }, [screen, store]), + setMinimalShellMode(false) + }, [screen, setMinimalShellMode]), ) const onPressAddAccount = React.useCallback(() => { @@ -380,23 +388,23 @@ export const SettingsScreen = withAuthRequired( store.shell.setColorMode('system')} + onSelect={() => setColorMode('system')} accessibilityHint="Set color theme to system setting" /> store.shell.setColorMode('light')} + onSelect={() => setColorMode('light')} accessibilityHint="Set color theme to light" /> store.shell.setColorMode('dark')} + onSelect={() => setColorMode('dark')} accessibilityHint="Set color theme to dark" /> diff --git a/src/view/screens/Support.tsx b/src/view/screens/Support.tsx index dc00d473d5..7106b4136a 100644 --- a/src/view/screens/Support.tsx +++ b/src/view/screens/Support.tsx @@ -3,23 +3,23 @@ import {View} from 'react-native' import {useFocusEffect} from '@react-navigation/native' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {ViewHeader} from '../com/util/ViewHeader' -import {useStores} from 'state/index' import {Text} from 'view/com/util/text/Text' import {TextLink} from 'view/com/util/Link' import {CenteredView} from 'view/com/util/Views' import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' import {HELP_DESK_URL} from 'lib/constants' +import {useSetMinimalShellMode} from '#/state/shell' type Props = NativeStackScreenProps export const SupportScreen = (_props: Props) => { - const store = useStores() const pal = usePalette('default') + const setMinimalShellMode = useSetMinimalShellMode() useFocusEffect( React.useCallback(() => { - store.shell.setMinimalShellMode(false) - }, [store]), + setMinimalShellMode(false) + }, [setMinimalShellMode]), ) return ( diff --git a/src/view/screens/TermsOfService.tsx b/src/view/screens/TermsOfService.tsx index 09b2a7f22b..b7a388b65d 100644 --- a/src/view/screens/TermsOfService.tsx +++ b/src/view/screens/TermsOfService.tsx @@ -5,20 +5,20 @@ import {Text} from 'view/com/util/text/Text' import {TextLink} from 'view/com/util/Link' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {ViewHeader} from '../com/util/ViewHeader' -import {useStores} from 'state/index' import {ScrollView} from 'view/com/util/Views' import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' +import {useSetMinimalShellMode} from '#/state/shell' type Props = NativeStackScreenProps export const TermsOfServiceScreen = (_props: Props) => { const pal = usePalette('default') - const store = useStores() + const setMinimalShellMode = useSetMinimalShellMode() useFocusEffect( React.useCallback(() => { - store.shell.setMinimalShellMode(false) - }, [store]), + setMinimalShellMode(false) + }, [setMinimalShellMode]), ) return ( diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx index ee3c2a9379..a8e2c5d1af 100644 --- a/src/view/shell/Drawer.tsx +++ b/src/view/shell/Drawer.tsx @@ -45,12 +45,14 @@ import {isWeb} from 'platform/detection' import {formatCount, formatCountShortOnly} from 'view/com/util/numeric/format' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useSetDrawerOpen} from '#/state/shell' export const DrawerContent = observer(function DrawerContentImpl() { const theme = useTheme() const pal = usePalette('default') const store = useStores() const {_} = useLingui() + const setDrawerOpen = useSetDrawerOpen() const navigation = useNavigation() const {track} = useAnalytics() const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} = @@ -65,7 +67,7 @@ export const DrawerContent = observer(function DrawerContentImpl() { (tab: string) => { track('Menu:ItemClicked', {url: tab}) const state = navigation.getState() - store.shell.closeDrawer() + setDrawerOpen(false) if (isWeb) { // hack because we have flat navigator for web and MyProfile does not exist on the web navigator -ansh if (tab === 'MyProfile') { @@ -86,7 +88,7 @@ export const DrawerContent = observer(function DrawerContentImpl() { } } }, - [store, track, navigation], + [store, track, navigation, setDrawerOpen], ) const onPressHome = React.useCallback(() => onPressTab('Home'), [onPressTab]) @@ -113,20 +115,20 @@ export const DrawerContent = observer(function DrawerContentImpl() { const onPressLists = React.useCallback(() => { track('Menu:ItemClicked', {url: 'Lists'}) navigation.navigate('Lists') - store.shell.closeDrawer() - }, [navigation, track, store.shell]) + setDrawerOpen(false) + }, [navigation, track, setDrawerOpen]) const onPressModeration = React.useCallback(() => { track('Menu:ItemClicked', {url: 'Moderation'}) navigation.navigate('Moderation') - store.shell.closeDrawer() - }, [navigation, track, store.shell]) + setDrawerOpen(false) + }, [navigation, track, setDrawerOpen]) const onPressSettings = React.useCallback(() => { track('Menu:ItemClicked', {url: 'Settings'}) navigation.navigate('Settings') - store.shell.closeDrawer() - }, [navigation, track, store.shell]) + setDrawerOpen(false) + }, [navigation, track, setDrawerOpen]) const onPressFeedback = React.useCallback(() => { track('Menu:FeedbackClicked') @@ -440,13 +442,14 @@ const InviteCodes = observer(function InviteCodesImpl({ }) { const {track} = useAnalytics() const store = useStores() + const setDrawerOpen = useSetDrawerOpen() const pal = usePalette('default') const {invitesAvailable} = store.me const onPress = React.useCallback(() => { track('Menu:ItemClicked', {url: '#invite-codes'}) - store.shell.closeDrawer() + setDrawerOpen(false) store.shell.openModal({name: 'invite-codes'}) - }, [store, track]) + }, [store, track, setDrawerOpen]) return ( , []) const onOpenDrawer = React.useCallback( - () => store.shell.openDrawer(), - [store], + () => setIsDrawerOpen(true), + [setIsDrawerOpen], ) const onCloseDrawer = React.useCallback( - () => store.shell.closeDrawer(), - [store], + () => setIsDrawerOpen(false), + [setIsDrawerOpen], ) const canGoBack = useNavigationState(state => !isStateAtTabRoot(state)) React.useEffect(() => { - const listener = backHandler.init(store) - return () => { - listener() + let listener = {remove() {}} + if (isAndroid) { + listener = BackHandler.addEventListener('hardwareBackPress', () => { + setIsDrawerOpen(false) + return store.shell.closeAnyActiveElement() + }) } - }, [store]) + return () => { + listener.remove() + } + }, [store, setIsDrawerOpen]) return ( <> @@ -59,14 +74,12 @@ const ShellInner = observer(function ShellInnerImpl() { diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx index 49e513532d..3a799cd417 100644 --- a/src/view/shell/index.web.tsx +++ b/src/view/shell/index.web.tsx @@ -18,18 +18,22 @@ import {useNavigation} from '@react-navigation/native' import {NavigationProp} from 'lib/routes/types' import {useAuxClick} from 'lib/hooks/useAuxClick' import {t} from '@lingui/macro' +import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell' const ShellInner = observer(function ShellInnerImpl() { const store = useStores() + const isDrawerOpen = useIsDrawerOpen() + const setDrawerOpen = useSetDrawerOpen() const {isDesktop, isMobile} = useWebMediaQueries() const navigator = useNavigation() useAuxClick() useEffect(() => { navigator.addListener('state', () => { + setDrawerOpen(false) store.shell.closeAnyActiveElement() }) - }, [navigator, store.shell]) + }, [navigator, store.shell, setDrawerOpen]) const showBottomBar = isMobile && !store.onboarding.isActive const showSideNavs = @@ -58,9 +62,9 @@ const ShellInner = observer(function ShellInnerImpl() { {showBottomBar && } - {!isDesktop && store.shell.isDrawerOpen && ( + {!isDesktop && isDrawerOpen && ( store.shell.closeDrawer()} + onPress={() => setDrawerOpen(false)} style={styles.drawerMask} accessibilityLabel={t`Close navigation footer`} accessibilityHint="Closes bottom navigation bar">