my fork of the bluesky client

Shell behaviors update (react-query refactor) (#1915)

* Move tick-every-minute into a hook/context

* Move soft-reset event out of the shell model

* Update soft-reset listener on new search page

* Implement session-loaded and session-dropped events

* Update analytics and push-notifications to use new session system

authored by

Paul Frazee and committed by
GitHub
6616b2bf f23e9978

+185 -135
+9 -5
src/App.native.tsx
··· 11 import 'view/icons' 12 13 import {init as initPersistedState} from '#/state/persisted' 14 import {useColorMode} from 'state/shell' 15 import {ThemeProvider} from 'lib/ThemeContext' 16 import {s} from 'lib/styles' ··· 53 useEffect(() => { 54 setupState().then(store => { 55 setRootStore(store) 56 - analytics.init(store) 57 - notifications.init(store, queryClient) 58 - store.onSessionDropped(() => { 59 - Toast.show('Sorry! Your session expired. Please log in again.') 60 - }) 61 }) 62 }, []) 63 64 useEffect(() => { 65 const account = persisted.get('session').currentAccount 66 resumeSession(account) 67 }, [resumeSession])
··· 11 import 'view/icons' 12 13 import {init as initPersistedState} from '#/state/persisted' 14 + import {init as initReminders} from '#/state/shell/reminders' 15 + import {listenSessionDropped} from './state/events' 16 import {useColorMode} from 'state/shell' 17 import {ThemeProvider} from 'lib/ThemeContext' 18 import {s} from 'lib/styles' ··· 55 useEffect(() => { 56 setupState().then(store => { 57 setRootStore(store) 58 }) 59 }, []) 60 61 useEffect(() => { 62 + initReminders() 63 + analytics.init() 64 + notifications.init(queryClient) 65 + listenSessionDropped(() => { 66 + Toast.show('Sorry! Your session expired. Please log in again.') 67 + }) 68 + 69 const account = persisted.get('session').currentAccount 70 resumeSession(account) 71 }, [resumeSession])
+5 -2
src/App.web.tsx
··· 9 import 'view/icons' 10 11 import {init as initPersistedState} from '#/state/persisted' 12 import {useColorMode} from 'state/shell' 13 import * as analytics from 'lib/analytics/analytics' 14 import {RootStoreModel, setupState, RootStoreProvider} from './state' ··· 44 useEffect(() => { 45 setupState().then(store => { 46 setRootStore(store) 47 - analytics.init(store) 48 }) 49 - dynamicActivate(defaultLocale) // async import of locale data 50 }, []) 51 52 useEffect(() => { 53 const account = persisted.get('session').currentAccount 54 resumeSession(account) 55 }, [resumeSession])
··· 9 import 'view/icons' 10 11 import {init as initPersistedState} from '#/state/persisted' 12 + import {init as initReminders} from '#/state/shell/reminders' 13 import {useColorMode} from 'state/shell' 14 import * as analytics from 'lib/analytics/analytics' 15 import {RootStoreModel, setupState, RootStoreProvider} from './state' ··· 45 useEffect(() => { 46 setupState().then(store => { 47 setRootStore(store) 48 }) 49 }, []) 50 51 useEffect(() => { 52 + initReminders() 53 + analytics.init() 54 + dynamicActivate(defaultLocale) // async import of locale data 55 + 56 const account = persisted.get('session').currentAccount 57 resumeSession(account) 58 }, [resumeSession])
+40 -38
src/lib/analytics/analytics.tsx
··· 1 import React from 'react' 2 import {AppState, AppStateStatus} from 'react-native' 3 import { 4 createClient, 5 AnalyticsProvider, 6 useAnalytics as useAnalyticsOrig, 7 ClientMethods, 8 } from '@segment/analytics-react-native' 9 - import {RootStoreModel, AppInfo} from 'state/models/root-store' 10 - import {useStores} from 'state/models/root-store' 11 import {sha256} from 'js-sha256' 12 import {ScreenEvent, TrackEvent} from './types' 13 import {logger} from '#/logger' 14 15 const segmentClient = createClient({ 16 writeKey: '8I6DsgfiSLuoONyaunGoiQM7A6y2ybdI', ··· 21 export const track = segmentClient?.track?.bind?.(segmentClient) as TrackEvent 22 23 export function useAnalytics() { 24 - const store = useStores() 25 const methods: ClientMethods = useAnalyticsOrig() 26 return React.useMemo(() => { 27 - if (store.session.hasSession) { 28 return { 29 screen: methods.screen as ScreenEvent, // ScreenEvents defines all the possible screen names 30 track: methods.track as TrackEvent, // TrackEvents defines all the possible track events and their properties ··· 45 alias: () => Promise<void>, 46 reset: () => Promise<void>, 47 } 48 - }, [store, methods]) 49 } 50 51 - export function init(store: RootStoreModel) { 52 - store.onSessionLoaded(() => { 53 - const sess = store.session.currentSession 54 - if (sess) { 55 - if (sess.did) { 56 - const did_hashed = sha256(sess.did) 57 - segmentClient.identify(did_hashed, {did_hashed}) 58 - logger.debug('Ping w/hash') 59 - } else { 60 - logger.debug('Ping w/o hash') 61 - segmentClient.identify() 62 - } 63 } 64 }) 65 ··· 67 // this is a copy of segment's own lifecycle event tracking 68 // we handle it manually to ensure that it never fires while the app is backgrounded 69 // -prf 70 - segmentClient.isReady.onChange(() => { 71 if (AppState.currentState !== 'active') { 72 logger.debug('Prevented a metrics ping while the app was backgrounded') 73 return ··· 78 return 79 } 80 81 - const oldAppInfo = store.appInfo 82 const newAppInfo = context.app as AppInfo 83 - store.setAppInfo(newAppInfo) 84 logger.debug('Recording app info', {new: newAppInfo, old: oldAppInfo}) 85 86 if (typeof oldAppInfo === 'undefined') { 87 - if (store.session.hasSession) { 88 - segmentClient.track('Application Installed', { 89 - version: newAppInfo.version, 90 - build: newAppInfo.build, 91 - }) 92 - } 93 } else if (newAppInfo.version !== oldAppInfo.version) { 94 - if (store.session.hasSession) { 95 - segmentClient.track('Application Updated', { 96 - version: newAppInfo.version, 97 - build: newAppInfo.build, 98 - previous_version: oldAppInfo.version, 99 - previous_build: oldAppInfo.build, 100 - }) 101 - } 102 - } 103 - if (store.session.hasSession) { 104 - segmentClient.track('Application Opened', { 105 - from_background: false, 106 version: newAppInfo.version, 107 build: newAppInfo.build, 108 }) 109 } 110 }) 111 112 let lastState: AppStateStatus = AppState.currentState ··· 130 <AnalyticsProvider client={segmentClient}>{children}</AnalyticsProvider> 131 ) 132 }
··· 1 import React from 'react' 2 import {AppState, AppStateStatus} from 'react-native' 3 + import AsyncStorage from '@react-native-async-storage/async-storage' 4 import { 5 createClient, 6 AnalyticsProvider, 7 useAnalytics as useAnalyticsOrig, 8 ClientMethods, 9 } from '@segment/analytics-react-native' 10 + import {AppInfo} from 'state/models/root-store' 11 + import {useSession} from '#/state/session' 12 import {sha256} from 'js-sha256' 13 import {ScreenEvent, TrackEvent} from './types' 14 import {logger} from '#/logger' 15 + import {listenSessionLoaded} from '#/state/events' 16 17 const segmentClient = createClient({ 18 writeKey: '8I6DsgfiSLuoONyaunGoiQM7A6y2ybdI', ··· 23 export const track = segmentClient?.track?.bind?.(segmentClient) as TrackEvent 24 25 export function useAnalytics() { 26 + const {hasSession} = useSession() 27 const methods: ClientMethods = useAnalyticsOrig() 28 return React.useMemo(() => { 29 + if (hasSession) { 30 return { 31 screen: methods.screen as ScreenEvent, // ScreenEvents defines all the possible screen names 32 track: methods.track as TrackEvent, // TrackEvents defines all the possible track events and their properties ··· 47 alias: () => Promise<void>, 48 reset: () => Promise<void>, 49 } 50 + }, [hasSession, methods]) 51 } 52 53 + export function init() { 54 + listenSessionLoaded(account => { 55 + if (account.did) { 56 + const did_hashed = sha256(account.did) 57 + segmentClient.identify(did_hashed, {did_hashed}) 58 + logger.debug('Ping w/hash') 59 + } else { 60 + logger.debug('Ping w/o hash') 61 + segmentClient.identify() 62 } 63 }) 64 ··· 66 // this is a copy of segment's own lifecycle event tracking 67 // we handle it manually to ensure that it never fires while the app is backgrounded 68 // -prf 69 + segmentClient.isReady.onChange(async () => { 70 if (AppState.currentState !== 'active') { 71 logger.debug('Prevented a metrics ping while the app was backgrounded') 72 return ··· 77 return 78 } 79 80 + const oldAppInfo = await readAppInfo() 81 const newAppInfo = context.app as AppInfo 82 + writeAppInfo(newAppInfo) 83 logger.debug('Recording app info', {new: newAppInfo, old: oldAppInfo}) 84 85 if (typeof oldAppInfo === 'undefined') { 86 + segmentClient.track('Application Installed', { 87 + version: newAppInfo.version, 88 + build: newAppInfo.build, 89 + }) 90 } else if (newAppInfo.version !== oldAppInfo.version) { 91 + segmentClient.track('Application Updated', { 92 version: newAppInfo.version, 93 build: newAppInfo.build, 94 + previous_version: oldAppInfo.version, 95 + previous_build: oldAppInfo.build, 96 }) 97 } 98 + segmentClient.track('Application Opened', { 99 + from_background: false, 100 + version: newAppInfo.version, 101 + build: newAppInfo.build, 102 + }) 103 }) 104 105 let lastState: AppStateStatus = AppState.currentState ··· 123 <AnalyticsProvider client={segmentClient}>{children}</AnalyticsProvider> 124 ) 125 } 126 + 127 + async function writeAppInfo(value: AppInfo) { 128 + await AsyncStorage.setItem('BSKY_APP_INFO', JSON.stringify(value)) 129 + } 130 + 131 + async function readAppInfo(): Promise<Partial<AppInfo> | undefined> { 132 + const rawData = await AsyncStorage.getItem('BSKY_APP_INFO') 133 + return rawData ? JSON.parse(rawData) : undefined 134 + }
+7 -7
src/lib/notifications/notifications.ts
··· 1 import * as Notifications from 'expo-notifications' 2 import {QueryClient} from '@tanstack/react-query' 3 - import {RootStoreModel} from '../../state' 4 import {resetToTab} from '../../Navigation' 5 import {devicePlatform, isIOS} from 'platform/detection' 6 import {track} from 'lib/analytics/analytics' 7 import {logger} from '#/logger' 8 import {RQKEY as RQKEY_NOTIFS} from '#/state/queries/notifications/feed' 9 10 const SERVICE_DID = (serviceUrl?: string) => 11 serviceUrl?.includes('staging') 12 ? 'did:web:api.staging.bsky.dev' 13 : 'did:web:api.bsky.app' 14 15 - export function init(store: RootStoreModel, queryClient: QueryClient) { 16 - store.onSessionLoaded(async () => { 17 // request notifications permission once the user has logged in 18 const perms = await Notifications.getPermissionsAsync() 19 if (!perms.granted) { ··· 24 const token = await getPushToken() 25 if (token) { 26 try { 27 - await store.agent.api.app.bsky.notification.registerPush({ 28 - serviceDid: SERVICE_DID(store.session.data?.service), 29 platform: devicePlatform, 30 token: token.data, 31 appId: 'xyz.blueskyweb.app', ··· 53 ) 54 if (t) { 55 try { 56 - await store.agent.api.app.bsky.notification.registerPush({ 57 - serviceDid: SERVICE_DID(store.session.data?.service), 58 platform: devicePlatform, 59 token: t, 60 appId: 'xyz.blueskyweb.app',
··· 1 import * as Notifications from 'expo-notifications' 2 import {QueryClient} from '@tanstack/react-query' 3 import {resetToTab} from '../../Navigation' 4 import {devicePlatform, isIOS} from 'platform/detection' 5 import {track} from 'lib/analytics/analytics' 6 import {logger} from '#/logger' 7 import {RQKEY as RQKEY_NOTIFS} from '#/state/queries/notifications/feed' 8 + import {listenSessionLoaded} from '#/state/events' 9 10 const SERVICE_DID = (serviceUrl?: string) => 11 serviceUrl?.includes('staging') 12 ? 'did:web:api.staging.bsky.dev' 13 : 'did:web:api.bsky.app' 14 15 + export function init(queryClient: QueryClient) { 16 + listenSessionLoaded(async (account, agent) => { 17 // request notifications permission once the user has logged in 18 const perms = await Notifications.getPermissionsAsync() 19 if (!perms.granted) { ··· 24 const token = await getPushToken() 25 if (token) { 26 try { 27 + await agent.api.app.bsky.notification.registerPush({ 28 + serviceDid: SERVICE_DID(account.service), 29 platform: devicePlatform, 30 token: token.data, 31 appId: 'xyz.blueskyweb.app', ··· 53 ) 54 if (t) { 55 try { 56 + await agent.api.app.bsky.notification.registerPush({ 57 + serviceDid: SERVICE_DID(account.service), 58 platform: devicePlatform, 59 token: t, 60 appId: 'xyz.blueskyweb.app',
+38
src/state/events.ts
···
··· 1 + import EventEmitter from 'eventemitter3' 2 + import {BskyAgent} from '@atproto/api' 3 + import {SessionAccount} from './session' 4 + 5 + type UnlistenFn = () => void 6 + 7 + const emitter = new EventEmitter() 8 + 9 + // a "soft reset" typically means scrolling to top and loading latest 10 + // but it can depend on the screen 11 + export function emitSoftReset() { 12 + emitter.emit('soft-reset') 13 + } 14 + export function listenSoftReset(fn: () => void): UnlistenFn { 15 + emitter.on('soft-reset', fn) 16 + return () => emitter.off('soft-reset', fn) 17 + } 18 + 19 + export function emitSessionLoaded( 20 + sessionAccount: SessionAccount, 21 + agent: BskyAgent, 22 + ) { 23 + emitter.emit('session-loaded', sessionAccount, agent) 24 + } 25 + export function listenSessionLoaded( 26 + fn: (sessionAccount: SessionAccount, agent: BskyAgent) => void, 27 + ): UnlistenFn { 28 + emitter.on('session-loaded', fn) 29 + return () => emitter.off('session-loaded', fn) 30 + } 31 + 32 + export function emitSessionDropped() { 33 + emitter.emit('session-dropped') 34 + } 35 + export function listenSessionDropped(fn: () => void): UnlistenFn { 36 + emitter.on('session-dropped', fn) 37 + return () => emitter.off('session-dropped', fn) 38 + }
+1 -11
src/state/models/ui/shell.ts
··· 1 import {AppBskyActorDefs} from '@atproto/api' 2 import {RootStoreModel} from '../root-store' 3 - import {makeAutoObservable, runInAction} from 'mobx' 4 import { 5 shouldRequestEmailConfirmation, 6 setEmailConfirmationRequested, ··· 40 export class ShellUiModel { 41 isLightboxActive = false 42 activeLightbox: ProfileImageLightbox | ImagesLightbox | null = null 43 - tickEveryMinute = Date.now() 44 45 constructor(public rootStore: RootStoreModel) { 46 makeAutoObservable(this, { 47 rootStore: false, 48 }) 49 50 - this.setupClock() 51 this.setupLoginModals() 52 } 53 ··· 81 closeLightbox() { 82 this.isLightboxActive = false 83 this.activeLightbox = null 84 - } 85 - 86 - setupClock() { 87 - setInterval(() => { 88 - runInAction(() => { 89 - this.tickEveryMinute = Date.now() 90 - }) 91 - }, 60_000) 92 } 93 94 setupLoginModals() {
··· 1 import {AppBskyActorDefs} from '@atproto/api' 2 import {RootStoreModel} from '../root-store' 3 + import {makeAutoObservable} from 'mobx' 4 import { 5 shouldRequestEmailConfirmation, 6 setEmailConfirmationRequested, ··· 40 export class ShellUiModel { 41 isLightboxActive = false 42 activeLightbox: ProfileImageLightbox | ImagesLightbox | null = null 43 44 constructor(public rootStore: RootStoreModel) { 45 makeAutoObservable(this, { 46 rootStore: false, 47 }) 48 49 this.setupLoginModals() 50 } 51 ··· 79 closeLightbox() { 80 this.isLightboxActive = false 81 this.activeLightbox = null 82 } 83 84 setupLoginModals() {
+7 -2
src/state/session/index.tsx
··· 1 import React from 'react' 2 - import {DeviceEventEmitter} from 'react-native' 3 import {BskyAgent, AtpPersistSessionHandler} from '@atproto/api' 4 5 import {networkRetry} from '#/lib/async/retry' ··· 7 import * as persisted from '#/state/persisted' 8 import {PUBLIC_BSKY_AGENT} from '#/state/queries' 9 import {IS_PROD} from '#/lib/constants' 10 11 export type SessionAccount = persisted.PersistedAccount 12 ··· 98 logger.DebugContext.session, 99 ) 100 101 - if (expired) DeviceEventEmitter.emit('session-dropped') 102 103 persistSessionCallback({ 104 expired, ··· 180 181 setState(s => ({...s, agent})) 182 upsertAccount(account) 183 184 logger.debug( 185 `session: created account`, ··· 230 231 setState(s => ({...s, agent})) 232 upsertAccount(account) 233 234 logger.debug( 235 `session: logged in`, ··· 291 292 setState(s => ({...s, agent})) 293 upsertAccount(account) 294 }, 295 [upsertAccount], 296 )
··· 1 import React from 'react' 2 import {BskyAgent, AtpPersistSessionHandler} from '@atproto/api' 3 4 import {networkRetry} from '#/lib/async/retry' ··· 6 import * as persisted from '#/state/persisted' 7 import {PUBLIC_BSKY_AGENT} from '#/state/queries' 8 import {IS_PROD} from '#/lib/constants' 9 + import {emitSessionLoaded, emitSessionDropped} from '../events' 10 11 export type SessionAccount = persisted.PersistedAccount 12 ··· 98 logger.DebugContext.session, 99 ) 100 101 + if (expired) { 102 + emitSessionDropped() 103 + } 104 105 persistSessionCallback({ 106 expired, ··· 182 183 setState(s => ({...s, agent})) 184 upsertAccount(account) 185 + emitSessionLoaded(account, agent) 186 187 logger.debug( 188 `session: created account`, ··· 233 234 setState(s => ({...s, agent})) 235 upsertAccount(account) 236 + emitSessionLoaded(account, agent) 237 238 logger.debug( 239 `session: logged in`, ··· 295 296 setState(s => ({...s, agent})) 297 upsertAccount(account) 298 + emitSessionLoaded(account, agent) 299 }, 300 [upsertAccount], 301 )
+6 -1
src/state/shell/index.tsx
··· 6 import {Provider as ColorModeProvider} from './color-mode' 7 import {Provider as OnboardingProvider} from './onboarding' 8 import {Provider as ComposerProvider} from './composer' 9 10 export {useIsDrawerOpen, useSetDrawerOpen} from './drawer-open' 11 export { ··· 15 export {useMinimalShellMode, useSetMinimalShellMode} from './minimal-mode' 16 export {useColorMode, useSetColorMode} from './color-mode' 17 export {useOnboardingState, useOnboardingDispatch} from './onboarding' 18 19 export function Provider({children}: React.PropsWithChildren<{}>) { 20 return ( ··· 24 <MinimalModeProvider> 25 <ColorModeProvider> 26 <OnboardingProvider> 27 - <ComposerProvider>{children}</ComposerProvider> 28 </OnboardingProvider> 29 </ColorModeProvider> 30 </MinimalModeProvider>
··· 6 import {Provider as ColorModeProvider} from './color-mode' 7 import {Provider as OnboardingProvider} from './onboarding' 8 import {Provider as ComposerProvider} from './composer' 9 + import {Provider as TickEveryMinuteProvider} from './tick-every-minute' 10 11 export {useIsDrawerOpen, useSetDrawerOpen} from './drawer-open' 12 export { ··· 16 export {useMinimalShellMode, useSetMinimalShellMode} from './minimal-mode' 17 export {useColorMode, useSetColorMode} from './color-mode' 18 export {useOnboardingState, useOnboardingDispatch} from './onboarding' 19 + export {useComposerState, useComposerControls} from './composer' 20 + export {useTickEveryMinute} from './tick-every-minute' 21 22 export function Provider({children}: React.PropsWithChildren<{}>) { 23 return ( ··· 27 <MinimalModeProvider> 28 <ColorModeProvider> 29 <OnboardingProvider> 30 + <ComposerProvider> 31 + <TickEveryMinuteProvider>{children}</TickEveryMinuteProvider> 32 + </ComposerProvider> 33 </OnboardingProvider> 34 </ColorModeProvider> 35 </MinimalModeProvider>
+16 -6
src/state/shell/reminders.ts
··· 1 import * as persisted from '#/state/persisted' 2 - import {SessionModel} from '../models/session' 3 import {toHashCode} from 'lib/strings/helpers' 4 import {isOnboardingActive} from './onboarding' 5 6 - export function shouldRequestEmailConfirmation(session: SessionModel) { 7 - const sess = session.currentSession 8 - if (!sess) { 9 return false 10 } 11 - if (sess.emailConfirmed) { 12 return false 13 } 14 if (isOnboardingActive()) { ··· 22 // shard the users into 2 day of the week buckets 23 // (this is to avoid a sudden influx of email updates when 24 // this feature rolls out) 25 - const code = toHashCode(sess.did) % 7 26 if (code !== today.getDay() && code !== (today.getDay() + 1) % 7) { 27 return false 28 }
··· 1 import * as persisted from '#/state/persisted' 2 import {toHashCode} from 'lib/strings/helpers' 3 import {isOnboardingActive} from './onboarding' 4 + import {SessionAccount} from '../session' 5 + import {listenSessionLoaded} from '../events' 6 + import {unstable__openModal} from '../modals' 7 8 + export function init() { 9 + listenSessionLoaded(account => { 10 + if (shouldRequestEmailConfirmation(account)) { 11 + unstable__openModal({name: 'verify-email', showReminder: true}) 12 + setEmailConfirmationRequested() 13 + } 14 + }) 15 + } 16 + 17 + export function shouldRequestEmailConfirmation(account: SessionAccount) { 18 + if (!account) { 19 return false 20 } 21 + if (account.emailConfirmed) { 22 return false 23 } 24 if (isOnboardingActive()) { ··· 32 // shard the users into 2 day of the week buckets 33 // (this is to avoid a sudden influx of email updates when 34 // this feature rolls out) 35 + const code = toHashCode(account.did) % 7 36 if (code !== today.getDay() && code !== (today.getDay() + 1) % 7) { 37 return false 38 }
+20
src/state/shell/tick-every-minute.tsx
···
··· 1 + import React from 'react' 2 + 3 + type StateContext = number 4 + 5 + const stateContext = React.createContext<StateContext>(0) 6 + 7 + export function Provider({children}: React.PropsWithChildren<{}>) { 8 + const [tick, setTick] = React.useState(Date.now()) 9 + React.useEffect(() => { 10 + const i = setInterval(() => { 11 + setTick(Date.now()) 12 + }, 60_000) 13 + return () => clearInterval(i) 14 + }, []) 15 + return <stateContext.Provider value={tick}>{children}</stateContext.Provider> 16 + } 17 + 18 + export function useTickEveryMinute() { 19 + return React.useContext(stateContext) 20 + }
+5 -18
src/view/com/feeds/FeedPage.tsx
··· 14 import {colors, s} from 'lib/styles' 15 import React from 'react' 16 import {FlatList, View, useWindowDimensions} from 'react-native' 17 - import {useStores} from 'state/index' 18 import {Feed} from '../posts/Feed' 19 import {TextLink} from '../util/Link' 20 import {FAB} from '../util/fab/FAB' ··· 23 import {useLingui} from '@lingui/react' 24 import {useSession} from '#/state/session' 25 import {useComposerControls} from '#/state/shell/composer' 26 27 const POLL_FREQ = 30e3 // 30sec 28 ··· 41 renderEmptyState: () => JSX.Element 42 renderEndOfFeed?: () => JSX.Element 43 }) { 44 - const store = useStores() 45 const {isSandbox} = useSession() 46 const pal = usePalette('default') 47 const {_} = useLingui() ··· 73 if (!isPageFocused || !isScreenFocused) { 74 return 75 } 76 - const softResetSub = store.onScreenSoftReset(onSoftReset) 77 screen('Feed') 78 - return () => { 79 - softResetSub.remove() 80 - } 81 - }, [store, onSoftReset, screen, feed, isPageFocused, isScreenFocused]) 82 83 const onPressCompose = React.useCallback(() => { 84 track('HomeScreen:PressCompose') ··· 125 )} 126 </> 127 } 128 - onPress={() => store.emitScreenSoftReset()} 129 /> 130 <TextLink 131 type="title-lg" ··· 144 ) 145 } 146 return <></> 147 - }, [ 148 - isDesktop, 149 - pal.view, 150 - pal.text, 151 - pal.textLight, 152 - store, 153 - hasNew, 154 - _, 155 - isSandbox, 156 - ]) 157 158 return ( 159 <View testID={testID} style={s.h100pct}>
··· 14 import {colors, s} from 'lib/styles' 15 import React from 'react' 16 import {FlatList, View, useWindowDimensions} from 'react-native' 17 import {Feed} from '../posts/Feed' 18 import {TextLink} from '../util/Link' 19 import {FAB} from '../util/fab/FAB' ··· 22 import {useLingui} from '@lingui/react' 23 import {useSession} from '#/state/session' 24 import {useComposerControls} from '#/state/shell/composer' 25 + import {listenSoftReset, emitSoftReset} from '#/state/events' 26 27 const POLL_FREQ = 30e3 // 30sec 28 ··· 41 renderEmptyState: () => JSX.Element 42 renderEndOfFeed?: () => JSX.Element 43 }) { 44 const {isSandbox} = useSession() 45 const pal = usePalette('default') 46 const {_} = useLingui() ··· 72 if (!isPageFocused || !isScreenFocused) { 73 return 74 } 75 screen('Feed') 76 + return listenSoftReset(onSoftReset) 77 + }, [onSoftReset, screen, isPageFocused, isScreenFocused]) 78 79 const onPressCompose = React.useCallback(() => { 80 track('HomeScreen:PressCompose') ··· 121 )} 122 </> 123 } 124 + onPress={emitSoftReset} 125 /> 126 <TextLink 127 type="title-lg" ··· 140 ) 141 } 142 return <></> 143 + }, [isDesktop, pal.view, pal.text, pal.textLight, hasNew, _, isSandbox]) 144 145 return ( 146 <View testID={testID} style={s.h100pct}>
+2 -1
src/view/com/profile/ProfileSubpageHeader.tsx
··· 20 import {useLingui} from '@lingui/react' 21 import {msg} from '@lingui/macro' 22 import {useSetDrawerOpen} from '#/state/shell' 23 24 export const ProfileSubpageHeader = observer(function HeaderImpl({ 25 isLoading, ··· 145 href={href} 146 style={[pal.text, {fontWeight: 'bold'}]} 147 text={title || ''} 148 - onPress={() => store.emitScreenSoftReset()} 149 numberOfLines={4} 150 /> 151 )}
··· 20 import {useLingui} from '@lingui/react' 21 import {msg} from '@lingui/macro' 22 import {useSetDrawerOpen} from '#/state/shell' 23 + import {emitSoftReset} from '#/state/events' 24 25 export const ProfileSubpageHeader = observer(function HeaderImpl({ 26 isLoading, ··· 146 href={href} 147 style={[pal.text, {fontWeight: 'bold'}]} 148 text={title || ''} 149 + onPress={emitSoftReset} 150 numberOfLines={4} 151 /> 152 )}
+5 -6
src/view/com/util/TimeElapsed.tsx
··· 1 import React from 'react' 2 - import {observer} from 'mobx-react-lite' 3 import {ago} from 'lib/strings/time' 4 - import {useStores} from 'state/index' 5 6 // FIXME(dan): Figure out why the false positives 7 /* eslint-disable react/prop-types */ 8 9 - export const TimeElapsed = observer(function TimeElapsed({ 10 timestamp, 11 children, 12 }: { 13 timestamp: string 14 children: ({timeElapsed}: {timeElapsed: string}) => JSX.Element 15 }) { 16 - const stores = useStores() 17 const [timeElapsed, setTimeAgo] = React.useState(ago(timestamp)) 18 19 React.useEffect(() => { 20 setTimeAgo(ago(timestamp)) 21 - }, [timestamp, setTimeAgo, stores.shell.tickEveryMinute]) 22 23 return children({timeElapsed}) 24 - })
··· 1 import React from 'react' 2 import {ago} from 'lib/strings/time' 3 + import {useTickEveryMinute} from '#/state/shell' 4 5 // FIXME(dan): Figure out why the false positives 6 /* eslint-disable react/prop-types */ 7 8 + export function TimeElapsed({ 9 timestamp, 10 children, 11 }: { 12 timestamp: string 13 children: ({timeElapsed}: {timeElapsed: string}) => JSX.Element 14 }) { 15 + const tick = useTickEveryMinute() 16 const [timeElapsed, setTimeAgo] = React.useState(ago(timestamp)) 17 18 React.useEffect(() => { 19 setTimeAgo(ago(timestamp)) 20 + }, [timestamp, setTimeAgo, tick]) 21 22 return children({timeElapsed}) 23 + }
+3 -4
src/view/screens/Home.tsx
··· 9 import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState' 10 import {FeedsTabBar} from '../com/pager/FeedsTabBar' 11 import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager' 12 - import {useStores} from 'state/index' 13 import {FeedPage} from 'view/com/feeds/FeedPage' 14 import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell' 15 import {usePreferencesQuery} from '#/state/queries/preferences' 16 17 type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'> 18 export const HomeScreen = withAuthRequired( 19 observer(function HomeScreenImpl({}: Props) { 20 - const store = useStores() 21 const setMinimalShellMode = useSetMinimalShellMode() 22 const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() 23 const pagerRef = React.useRef<PagerRef>(null) ··· 74 ) 75 76 const onPressSelected = React.useCallback(() => { 77 - store.emitScreenSoftReset() 78 - }, [store]) 79 80 const onPageScrollStateChanged = React.useCallback( 81 (state: 'idle' | 'dragging' | 'settling') => {
··· 9 import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState' 10 import {FeedsTabBar} from '../com/pager/FeedsTabBar' 11 import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager' 12 import {FeedPage} from 'view/com/feeds/FeedPage' 13 import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell' 14 import {usePreferencesQuery} from '#/state/queries/preferences' 15 + import {emitSoftReset} from '#/state/events' 16 17 type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'> 18 export const HomeScreen = withAuthRequired( 19 observer(function HomeScreenImpl({}: Props) { 20 const setMinimalShellMode = useSetMinimalShellMode() 21 const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() 22 const pagerRef = React.useRef<PagerRef>(null) ··· 73 ) 74 75 const onPressSelected = React.useCallback(() => { 76 + emitSoftReset() 77 + }, []) 78 79 const onPageScrollStateChanged = React.useCallback( 80 (state: 'idle' | 'dragging' | 'settling') => {
+5 -10
src/view/screens/Notifications.tsx
··· 11 import {Feed} from '../com/notifications/Feed' 12 import {TextLink} from 'view/com/util/Link' 13 import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' 14 - import {useStores} from 'state/index' 15 import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' 16 import {usePalette} from 'lib/hooks/usePalette' 17 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' ··· 21 import {useSetMinimalShellMode} from '#/state/shell' 22 import {useUnreadNotifications} from '#/state/queries/notifications/unread' 23 import {RQKEY as NOTIFS_RQKEY} from '#/state/queries/notifications/feed' 24 25 type Props = NativeStackScreenProps< 26 NotificationsTabNavigatorParams, ··· 28 > 29 export const NotificationsScreen = withAuthRequired( 30 function NotificationsScreenImpl({}: Props) { 31 - const store = useStores() 32 const setMinimalShellMode = useSetMinimalShellMode() 33 const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll() 34 const scrollElRef = React.useRef<FlatList>(null) ··· 57 React.useCallback(() => { 58 setMinimalShellMode(false) 59 logger.debug('NotificationsScreen: Updating feed') 60 - const softResetSub = store.onScreenSoftReset(onPressLoadLatest) 61 screen('Notifications') 62 - 63 - return () => { 64 - softResetSub.remove() 65 - } 66 - }, [store, screen, onPressLoadLatest, setMinimalShellMode]), 67 ) 68 69 const ListHeaderComponent = React.useCallback(() => { ··· 100 )} 101 </> 102 } 103 - onPress={() => store.emitScreenSoftReset()} 104 /> 105 </View> 106 ) 107 } 108 return <></> 109 - }, [isDesktop, pal, store, hasNew]) 110 111 return ( 112 <View testID="notificationsScreen" style={s.hContentRegion}>
··· 11 import {Feed} from '../com/notifications/Feed' 12 import {TextLink} from 'view/com/util/Link' 13 import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' 14 import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' 15 import {usePalette} from 'lib/hooks/usePalette' 16 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' ··· 20 import {useSetMinimalShellMode} from '#/state/shell' 21 import {useUnreadNotifications} from '#/state/queries/notifications/unread' 22 import {RQKEY as NOTIFS_RQKEY} from '#/state/queries/notifications/feed' 23 + import {listenSoftReset, emitSoftReset} from '#/state/events' 24 25 type Props = NativeStackScreenProps< 26 NotificationsTabNavigatorParams, ··· 28 > 29 export const NotificationsScreen = withAuthRequired( 30 function NotificationsScreenImpl({}: Props) { 31 const setMinimalShellMode = useSetMinimalShellMode() 32 const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll() 33 const scrollElRef = React.useRef<FlatList>(null) ··· 56 React.useCallback(() => { 57 setMinimalShellMode(false) 58 logger.debug('NotificationsScreen: Updating feed') 59 screen('Notifications') 60 + return listenSoftReset(onPressLoadLatest) 61 + }, [screen, onPressLoadLatest, setMinimalShellMode]), 62 ) 63 64 const ListHeaderComponent = React.useCallback(() => { ··· 95 )} 96 </> 97 } 98 + onPress={emitSoftReset} 99 /> 100 </View> 101 ) 102 } 103 return <></> 104 + }, [isDesktop, pal, hasNew]) 105 106 return ( 107 <View testID="notificationsScreen" style={s.hContentRegion}>
+3 -5
src/view/screens/Profile.tsx
··· 12 import {Feed} from 'view/com/posts/Feed' 13 import {ProfileLists} from '../com/lists/ProfileLists' 14 import {ProfileFeedgens} from '../com/feeds/ProfileFeedgens' 15 - import {useStores} from 'state/index' 16 import {ProfileHeader} from '../com/profile/ProfileHeader' 17 import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' 18 import {ErrorScreen} from '../com/util/error/ErrorScreen' ··· 37 import {LoadLatestBtn} from '../com/util/load-latest/LoadLatestBtn' 38 import {useQueryClient} from '@tanstack/react-query' 39 import {useComposerControls} from '#/state/shell/composer' 40 41 type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'> 42 export const ProfileScreen = withAuthRequired(function ProfileScreenImpl({ ··· 126 hideBackButton: boolean 127 }) { 128 const profile = useProfileShadow(profileUnshadowed, dataUpdatedAt) 129 - const store = useStores() 130 const {currentAccount} = useSession() 131 const setMinimalShellMode = useSetMinimalShellMode() 132 const {openComposer} = useComposerControls() ··· 169 React.useCallback(() => { 170 setMinimalShellMode(false) 171 screen('Profile') 172 - const softResetSub = store.onScreenSoftReset(() => { 173 viewSelectorRef.current?.scrollToTop() 174 }) 175 - return () => softResetSub.remove() 176 - }, [store, viewSelectorRef, setMinimalShellMode, screen]), 177 ) 178 179 useFocusEffect(
··· 12 import {Feed} from 'view/com/posts/Feed' 13 import {ProfileLists} from '../com/lists/ProfileLists' 14 import {ProfileFeedgens} from '../com/feeds/ProfileFeedgens' 15 import {ProfileHeader} from '../com/profile/ProfileHeader' 16 import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' 17 import {ErrorScreen} from '../com/util/error/ErrorScreen' ··· 36 import {LoadLatestBtn} from '../com/util/load-latest/LoadLatestBtn' 37 import {useQueryClient} from '@tanstack/react-query' 38 import {useComposerControls} from '#/state/shell/composer' 39 + import {listenSoftReset} from '#/state/events' 40 41 type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'> 42 export const ProfileScreen = withAuthRequired(function ProfileScreenImpl({ ··· 126 hideBackButton: boolean 127 }) { 128 const profile = useProfileShadow(profileUnshadowed, dataUpdatedAt) 129 const {currentAccount} = useSession() 130 const setMinimalShellMode = useSetMinimalShellMode() 131 const {openComposer} = useComposerControls() ··· 168 React.useCallback(() => { 169 setMinimalShellMode(false) 170 screen('Profile') 171 + return listenSoftReset(() => { 172 viewSelectorRef.current?.scrollToTop() 173 }) 174 + }, [viewSelectorRef, setMinimalShellMode, screen]), 175 ) 176 177 useFocusEffect(
+3 -9
src/view/screens/Search/Search.tsx
··· 42 import {useModerationOpts} from '#/state/queries/preferences' 43 import {SearchResultCard} from '#/view/shell/desktop/Search' 44 import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell' 45 - import {useStores} from '#/state' 46 import {isWeb} from '#/platform/detection' 47 48 function Loader() { 49 const pal = usePalette('default') ··· 421 const moderationOpts = useModerationOpts() 422 const search = useActorAutocompleteFn() 423 const setMinimalShellMode = useSetMinimalShellMode() 424 - const store = useStores() 425 const {isTablet} = useWebMediaQueries() 426 427 const searchDebounceTimeout = React.useRef<NodeJS.Timeout | undefined>( ··· 490 491 useFocusEffect( 492 React.useCallback(() => { 493 - const softResetSub = store.onScreenSoftReset(onSoftReset) 494 - 495 setMinimalShellMode(false) 496 - 497 - return () => { 498 - softResetSub.remove() 499 - } 500 - }, [store, onSoftReset, setMinimalShellMode]), 501 ) 502 503 return (
··· 42 import {useModerationOpts} from '#/state/queries/preferences' 43 import {SearchResultCard} from '#/view/shell/desktop/Search' 44 import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell' 45 import {isWeb} from '#/platform/detection' 46 + import {listenSoftReset} from '#/state/events' 47 48 function Loader() { 49 const pal = usePalette('default') ··· 421 const moderationOpts = useModerationOpts() 422 const search = useActorAutocompleteFn() 423 const setMinimalShellMode = useSetMinimalShellMode() 424 const {isTablet} = useWebMediaQueries() 425 426 const searchDebounceTimeout = React.useRef<NodeJS.Timeout | undefined>( ··· 489 490 useFocusEffect( 491 React.useCallback(() => { 492 setMinimalShellMode(false) 493 + return listenSoftReset(onSoftReset) 494 + }, [onSoftReset, setMinimalShellMode]), 495 ) 496 497 return (
+4 -4
src/view/shell/Drawer.tsx
··· 50 import {useSession, SessionAccount} from '#/state/session' 51 import {useProfileQuery} from '#/state/queries/profile' 52 import {useUnreadNotifications} from '#/state/queries/notifications/unread' 53 54 export function DrawerProfileCard({ 55 account, ··· 103 export const DrawerContent = observer(function DrawerContentImpl() { 104 const theme = useTheme() 105 const pal = usePalette('default') 106 - const store = useStores() 107 const {_} = useLingui() 108 const setDrawerOpen = useSetDrawerOpen() 109 const navigation = useNavigation<NavigationProp>() ··· 124 if (isWeb) { 125 // hack because we have flat navigator for web and MyProfile does not exist on the web navigator -ansh 126 if (tab === 'MyProfile') { 127 - navigation.navigate('Profile', {name: store.me.handle}) 128 } else { 129 // @ts-ignore must be Home, Search, Notifications, or MyProfile 130 navigation.navigate(tab) ··· 132 } else { 133 const tabState = getTabState(state, tab) 134 if (tabState === TabState.InsideAtRoot) { 135 - store.emitScreenSoftReset() 136 } else if (tabState === TabState.Inside) { 137 navigation.dispatch(StackActions.popToTop()) 138 } else { ··· 141 } 142 } 143 }, 144 - [store, track, navigation, setDrawerOpen], 145 ) 146 147 const onPressHome = React.useCallback(() => onPressTab('Home'), [onPressTab])
··· 50 import {useSession, SessionAccount} from '#/state/session' 51 import {useProfileQuery} from '#/state/queries/profile' 52 import {useUnreadNotifications} from '#/state/queries/notifications/unread' 53 + import {emitSoftReset} from '#/state/events' 54 55 export function DrawerProfileCard({ 56 account, ··· 104 export const DrawerContent = observer(function DrawerContentImpl() { 105 const theme = useTheme() 106 const pal = usePalette('default') 107 const {_} = useLingui() 108 const setDrawerOpen = useSetDrawerOpen() 109 const navigation = useNavigation<NavigationProp>() ··· 124 if (isWeb) { 125 // hack because we have flat navigator for web and MyProfile does not exist on the web navigator -ansh 126 if (tab === 'MyProfile') { 127 + navigation.navigate('Profile', {name: currentAccount!.handle}) 128 } else { 129 // @ts-ignore must be Home, Search, Notifications, or MyProfile 130 navigation.navigate(tab) ··· 132 } else { 133 const tabState = getTabState(state, tab) 134 if (tabState === TabState.InsideAtRoot) { 135 + emitSoftReset() 136 } else if (tabState === TabState.Inside) { 137 navigation.dispatch(StackActions.popToTop()) 138 } else { ··· 141 } 142 } 143 }, 144 + [track, navigation, setDrawerOpen, currentAccount], 145 ) 146 147 const onPressHome = React.useCallback(() => onPressTab('Home'), [onPressTab])
+3 -2
src/view/shell/bottom-bar/BottomBar.tsx
··· 29 import {useModalControls} from '#/state/modals' 30 import {useShellLayout} from '#/state/shell/shell-layout' 31 import {useUnreadNotifications} from '#/state/queries/notifications/unread' 32 33 type TabOptions = 'Home' | 'Search' | 'Notifications' | 'MyProfile' | 'Feeds' 34 ··· 53 const state = navigation.getState() 54 const tabState = getTabState(state, tab) 55 if (tabState === TabState.InsideAtRoot) { 56 - store.emitScreenSoftReset() 57 } else if (tabState === TabState.Inside) { 58 navigation.dispatch(StackActions.popToTop()) 59 } else { 60 navigation.navigate(`${tab}Tab`) 61 } 62 }, 63 - [store, track, navigation], 64 ) 65 const onPressHome = React.useCallback(() => onPressTab('Home'), [onPressTab]) 66 const onPressSearch = React.useCallback(
··· 29 import {useModalControls} from '#/state/modals' 30 import {useShellLayout} from '#/state/shell/shell-layout' 31 import {useUnreadNotifications} from '#/state/queries/notifications/unread' 32 + import {emitSoftReset} from '#/state/events' 33 34 type TabOptions = 'Home' | 'Search' | 'Notifications' | 'MyProfile' | 'Feeds' 35 ··· 54 const state = navigation.getState() 55 const tabState = getTabState(state, tab) 56 if (tabState === TabState.InsideAtRoot) { 57 + emitSoftReset() 58 } else if (tabState === TabState.Inside) { 59 navigation.dispatch(StackActions.popToTop()) 60 } else { 61 navigation.navigate(`${tab}Tab`) 62 } 63 }, 64 + [track, navigation], 65 ) 66 const onPressHome = React.useCallback(() => onPressTab('Home'), [onPressTab]) 67 const onPressSearch = React.useCallback(
+3 -4
src/view/shell/desktop/LeftNav.tsx
··· 16 import {Link} from 'view/com/util/Link' 17 import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' 18 import {usePalette} from 'lib/hooks/usePalette' 19 - import {useStores} from 'state/index' 20 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 21 import {s, colors} from 'lib/styles' 22 import { ··· 46 import {useUnreadNotifications} from '#/state/queries/notifications/unread' 47 import {useComposerControls} from '#/state/shell/composer' 48 import {useFetchHandle} from '#/state/queries/handle' 49 50 const ProfileCard = observer(function ProfileCardImpl() { 51 const {currentAccount} = useSession() ··· 126 }: NavItemProps) { 127 const pal = usePalette('default') 128 const {currentAccount} = useSession() 129 - const store = useStores() 130 const {isDesktop, isTablet} = useWebMediaQueries() 131 const [pathName] = React.useMemo(() => router.matchPath(href), [href]) 132 const currentRouteInfo = useNavigationState(state => { ··· 149 } 150 e.preventDefault() 151 if (isCurrent) { 152 - store.emitScreenSoftReset() 153 } else { 154 onPress() 155 } 156 }, 157 - [onPress, isCurrent, store], 158 ) 159 160 return (
··· 16 import {Link} from 'view/com/util/Link' 17 import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' 18 import {usePalette} from 'lib/hooks/usePalette' 19 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 20 import {s, colors} from 'lib/styles' 21 import { ··· 45 import {useUnreadNotifications} from '#/state/queries/notifications/unread' 46 import {useComposerControls} from '#/state/shell/composer' 47 import {useFetchHandle} from '#/state/queries/handle' 48 + import {emitSoftReset} from '#/state/events' 49 50 const ProfileCard = observer(function ProfileCardImpl() { 51 const {currentAccount} = useSession() ··· 126 }: NavItemProps) { 127 const pal = usePalette('default') 128 const {currentAccount} = useSession() 129 const {isDesktop, isTablet} = useWebMediaQueries() 130 const [pathName] = React.useMemo(() => router.matchPath(href), [href]) 131 const currentRouteInfo = useNavigationState(state => { ··· 148 } 149 e.preventDefault() 150 if (isCurrent) { 151 + emitSoftReset() 152 } else { 153 onPress() 154 } 155 }, 156 + [onPress, isCurrent], 157 ) 158 159 return (