diff options
Diffstat (limited to 'src/state/shell')
-rw-r--r-- | src/state/shell/color-mode.tsx | 2 | ||||
-rw-r--r-- | src/state/shell/composer.tsx | 85 | ||||
-rw-r--r-- | src/state/shell/drawer-open.tsx | 1 | ||||
-rw-r--r-- | src/state/shell/index.tsx | 29 | ||||
-rw-r--r-- | src/state/shell/logged-out.tsx | 37 | ||||
-rw-r--r-- | src/state/shell/minimal-mode.tsx | 31 | ||||
-rw-r--r-- | src/state/shell/onboarding.tsx | 123 | ||||
-rw-r--r-- | src/state/shell/reminders.e2e.ts | 8 | ||||
-rw-r--r-- | src/state/shell/reminders.ts | 29 | ||||
-rw-r--r-- | src/state/shell/shell-layout.tsx | 41 | ||||
-rw-r--r-- | src/state/shell/tick-every-minute.tsx | 20 |
11 files changed, 376 insertions, 30 deletions
diff --git a/src/state/shell/color-mode.tsx b/src/state/shell/color-mode.tsx index 74379da37..c6a4b8a18 100644 --- a/src/state/shell/color-mode.tsx +++ b/src/state/shell/color-mode.tsx @@ -27,7 +27,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { setState(persisted.get('colorMode')) updateDocument(persisted.get('colorMode')) }) - }, [setStateWrapped]) + }, [setState]) return ( <stateContext.Provider value={state}> diff --git a/src/state/shell/composer.tsx b/src/state/shell/composer.tsx new file mode 100644 index 000000000..bdf5e4a7a --- /dev/null +++ b/src/state/shell/composer.tsx @@ -0,0 +1,85 @@ +import React from 'react' +import {AppBskyEmbedRecord} from '@atproto/api' +import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' + +export interface ComposerOptsPostRef { + uri: string + cid: string + text: string + author: { + handle: string + displayName?: string + avatar?: string + } +} +export interface ComposerOptsQuote { + uri: string + cid: string + text: string + indexedAt: string + author: { + did: string + handle: string + displayName?: string + avatar?: string + } + embeds?: AppBskyEmbedRecord.ViewRecord['embeds'] +} +export interface ComposerOpts { + replyTo?: ComposerOptsPostRef + onPost?: () => void + quote?: ComposerOptsQuote + mention?: string // handle of user to mention +} + +type StateContext = ComposerOpts | undefined +type ControlsContext = { + openComposer: (opts: ComposerOpts) => void + closeComposer: () => boolean +} + +const stateContext = React.createContext<StateContext>(undefined) +const controlsContext = React.createContext<ControlsContext>({ + openComposer(_opts: ComposerOpts) {}, + closeComposer() { + return false + }, +}) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [state, setState] = React.useState<StateContext>() + + const openComposer = useNonReactiveCallback((opts: ComposerOpts) => { + setState(opts) + }) + + const closeComposer = useNonReactiveCallback(() => { + let wasOpen = !!state + setState(undefined) + return wasOpen + }) + + const api = React.useMemo( + () => ({ + openComposer, + closeComposer, + }), + [openComposer, closeComposer], + ) + + return ( + <stateContext.Provider value={state}> + <controlsContext.Provider value={api}> + {children} + </controlsContext.Provider> + </stateContext.Provider> + ) +} + +export function useComposerState() { + return React.useContext(stateContext) +} + +export function useComposerControls() { + return React.useContext(controlsContext) +} diff --git a/src/state/shell/drawer-open.tsx b/src/state/shell/drawer-open.tsx index a2322f680..061ff53d7 100644 --- a/src/state/shell/drawer-open.tsx +++ b/src/state/shell/drawer-open.tsx @@ -8,6 +8,7 @@ const setContext = React.createContext<SetContext>((_: boolean) => {}) export function Provider({children}: React.PropsWithChildren<{}>) { const [state, setState] = React.useState(false) + return ( <stateContext.Provider value={state}> <setContext.Provider value={setState}>{children}</setContext.Provider> diff --git a/src/state/shell/index.tsx b/src/state/shell/index.tsx index 1e01a4e7d..53f05055c 100644 --- a/src/state/shell/index.tsx +++ b/src/state/shell/index.tsx @@ -1,8 +1,12 @@ import React from 'react' +import {Provider as ShellLayoutProvder} from './shell-layout' import {Provider as DrawerOpenProvider} from './drawer-open' import {Provider as DrawerSwipableProvider} from './drawer-swipe-disabled' import {Provider as MinimalModeProvider} from './minimal-mode' import {Provider as ColorModeProvider} from './color-mode' +import {Provider as OnboardingProvider} from './onboarding' +import {Provider as ComposerProvider} from './composer' +import {Provider as TickEveryMinuteProvider} from './tick-every-minute' export {useIsDrawerOpen, useSetDrawerOpen} from './drawer-open' export { @@ -11,15 +15,26 @@ export { } from './drawer-swipe-disabled' export {useMinimalShellMode, useSetMinimalShellMode} from './minimal-mode' export {useColorMode, useSetColorMode} from './color-mode' +export {useOnboardingState, useOnboardingDispatch} from './onboarding' +export {useComposerState, useComposerControls} from './composer' +export {useTickEveryMinute} from './tick-every-minute' export function Provider({children}: React.PropsWithChildren<{}>) { return ( - <DrawerOpenProvider> - <DrawerSwipableProvider> - <MinimalModeProvider> - <ColorModeProvider>{children}</ColorModeProvider> - </MinimalModeProvider> - </DrawerSwipableProvider> - </DrawerOpenProvider> + <ShellLayoutProvder> + <DrawerOpenProvider> + <DrawerSwipableProvider> + <MinimalModeProvider> + <ColorModeProvider> + <OnboardingProvider> + <ComposerProvider> + <TickEveryMinuteProvider>{children}</TickEveryMinuteProvider> + </ComposerProvider> + </OnboardingProvider> + </ColorModeProvider> + </MinimalModeProvider> + </DrawerSwipableProvider> + </DrawerOpenProvider> + </ShellLayoutProvder> ) } diff --git a/src/state/shell/logged-out.tsx b/src/state/shell/logged-out.tsx new file mode 100644 index 000000000..19eaee76b --- /dev/null +++ b/src/state/shell/logged-out.tsx @@ -0,0 +1,37 @@ +import React from 'react' + +type StateContext = { + showLoggedOut: boolean +} + +const StateContext = React.createContext<StateContext>({ + showLoggedOut: false, +}) +const ControlsContext = React.createContext<{ + setShowLoggedOut: (show: boolean) => void +}>({ + setShowLoggedOut: () => {}, +}) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [showLoggedOut, setShowLoggedOut] = React.useState(false) + + const state = React.useMemo(() => ({showLoggedOut}), [showLoggedOut]) + const controls = React.useMemo(() => ({setShowLoggedOut}), [setShowLoggedOut]) + + return ( + <StateContext.Provider value={state}> + <ControlsContext.Provider value={controls}> + {children} + </ControlsContext.Provider> + </StateContext.Provider> + ) +} + +export function useLoggedOutView() { + return React.useContext(StateContext) +} + +export function useLoggedOutViewControls() { + return React.useContext(ControlsContext) +} diff --git a/src/state/shell/minimal-mode.tsx b/src/state/shell/minimal-mode.tsx index 4909a9a65..2c2f60b52 100644 --- a/src/state/shell/minimal-mode.tsx +++ b/src/state/shell/minimal-mode.tsx @@ -1,16 +1,37 @@ import React from 'react' +import { + Easing, + SharedValue, + useSharedValue, + withTiming, +} from 'react-native-reanimated' -type StateContext = boolean +type StateContext = SharedValue<number> type SetContext = (v: boolean) => void -const stateContext = React.createContext<StateContext>(false) +const stateContext = React.createContext<StateContext>({ + value: 0, + addListener() {}, + removeListener() {}, + modify() {}, +}) const setContext = React.createContext<SetContext>((_: boolean) => {}) export function Provider({children}: React.PropsWithChildren<{}>) { - const [state, setState] = React.useState(false) + const mode = useSharedValue(0) + const setMode = React.useCallback( + (v: boolean) => { + 'worklet' + mode.value = withTiming(v ? 1 : 0, { + duration: 400, + easing: Easing.bezier(0.25, 0.1, 0.25, 1), + }) + }, + [mode], + ) return ( - <stateContext.Provider value={state}> - <setContext.Provider value={setState}>{children}</setContext.Provider> + <stateContext.Provider value={mode}> + <setContext.Provider value={setMode}>{children}</setContext.Provider> </stateContext.Provider> ) } diff --git a/src/state/shell/onboarding.tsx b/src/state/shell/onboarding.tsx new file mode 100644 index 000000000..6a18b461f --- /dev/null +++ b/src/state/shell/onboarding.tsx @@ -0,0 +1,123 @@ +import React from 'react' +import * as persisted from '#/state/persisted' +import {track} from '#/lib/analytics/analytics' + +export const OnboardingScreenSteps = { + Welcome: 'Welcome', + RecommendedFeeds: 'RecommendedFeeds', + RecommendedFollows: 'RecommendedFollows', + Home: 'Home', +} as const + +type OnboardingStep = + (typeof OnboardingScreenSteps)[keyof typeof OnboardingScreenSteps] +const OnboardingStepsArray = Object.values(OnboardingScreenSteps) + +type Action = + | {type: 'set'; step: OnboardingStep} + | {type: 'next'; currentStep?: OnboardingStep} + | {type: 'start'} + | {type: 'finish'} + | {type: 'skip'} + +export type StateContext = persisted.Schema['onboarding'] & { + isComplete: boolean + isActive: boolean +} +export type DispatchContext = (action: Action) => void + +const stateContext = React.createContext<StateContext>( + compute(persisted.defaults.onboarding), +) +const dispatchContext = React.createContext<DispatchContext>((_: Action) => {}) + +function reducer(state: StateContext, action: Action): StateContext { + switch (action.type) { + case 'set': { + if (OnboardingStepsArray.includes(action.step)) { + persisted.write('onboarding', {step: action.step}) + return compute({...state, step: action.step}) + } + return state + } + case 'next': { + const currentStep = action.currentStep || state.step + let nextStep = 'Home' + if (currentStep === 'Welcome') { + nextStep = 'RecommendedFeeds' + } else if (currentStep === 'RecommendedFeeds') { + nextStep = 'RecommendedFollows' + } else if (currentStep === 'RecommendedFollows') { + nextStep = 'Home' + } + persisted.write('onboarding', {step: nextStep}) + return compute({...state, step: nextStep}) + } + case 'start': { + track('Onboarding:Begin') + persisted.write('onboarding', {step: 'Welcome'}) + return compute({...state, step: 'Welcome'}) + } + case 'finish': { + track('Onboarding:Complete') + persisted.write('onboarding', {step: 'Home'}) + return compute({...state, step: 'Home'}) + } + case 'skip': { + track('Onboarding:Skipped') + persisted.write('onboarding', {step: 'Home'}) + return compute({...state, step: 'Home'}) + } + default: { + throw new Error('Invalid action') + } + } +} + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [state, dispatch] = React.useReducer( + reducer, + compute(persisted.get('onboarding')), + ) + + React.useEffect(() => { + return persisted.onUpdate(() => { + const next = persisted.get('onboarding').step + // TODO we've introduced a footgun + if (state.step !== next) { + dispatch({ + type: 'set', + step: persisted.get('onboarding').step as OnboardingStep, + }) + } + }) + }, [state, dispatch]) + + return ( + <stateContext.Provider value={state}> + <dispatchContext.Provider value={dispatch}> + {children} + </dispatchContext.Provider> + </stateContext.Provider> + ) +} + +export function useOnboardingState() { + return React.useContext(stateContext) +} + +export function useOnboardingDispatch() { + return React.useContext(dispatchContext) +} + +export function isOnboardingActive() { + return compute(persisted.get('onboarding')).isActive +} + +function compute(state: persisted.Schema['onboarding']): StateContext { + return { + ...state, + isActive: state.step !== 'Home', + isComplete: state.step === 'Home', + } +} diff --git a/src/state/shell/reminders.e2e.ts b/src/state/shell/reminders.e2e.ts index 6238ffa29..e8c12792a 100644 --- a/src/state/shell/reminders.e2e.ts +++ b/src/state/shell/reminders.e2e.ts @@ -1,10 +1,6 @@ -import {OnboardingModel} from '../models/discovery/onboarding' -import {SessionModel} from '../models/session' +export function init() {} -export function shouldRequestEmailConfirmation( - _session: SessionModel, - _onboarding: OnboardingModel, -) { +export function shouldRequestEmailConfirmation() { return false } diff --git a/src/state/shell/reminders.ts b/src/state/shell/reminders.ts index d68a272ac..88d0a5d85 100644 --- a/src/state/shell/reminders.ts +++ b/src/state/shell/reminders.ts @@ -1,20 +1,27 @@ import * as persisted from '#/state/persisted' -import {OnboardingModel} from '../models/discovery/onboarding' -import {SessionModel} from '../models/session' import {toHashCode} from 'lib/strings/helpers' +import {isOnboardingActive} from './onboarding' +import {SessionAccount} from '../session' +import {listenSessionLoaded} from '../events' +import {unstable__openModal} from '../modals' -export function shouldRequestEmailConfirmation( - session: SessionModel, - onboarding: OnboardingModel, -) { - const sess = session.currentSession - if (!sess) { +export function init() { + listenSessionLoaded(account => { + if (shouldRequestEmailConfirmation(account)) { + unstable__openModal({name: 'verify-email', showReminder: true}) + setEmailConfirmationRequested() + } + }) +} + +export function shouldRequestEmailConfirmation(account: SessionAccount) { + if (!account) { return false } - if (sess.emailConfirmed) { + if (account.emailConfirmed) { return false } - if (onboarding.isActive) { + if (isOnboardingActive()) { return false } // only prompt once @@ -25,7 +32,7 @@ export function shouldRequestEmailConfirmation( // shard the users into 2 day of the week buckets // (this is to avoid a sudden influx of email updates when // this feature rolls out) - const code = toHashCode(sess.did) % 7 + const code = toHashCode(account.did) % 7 if (code !== today.getDay() && code !== (today.getDay() + 1) % 7) { return false } diff --git a/src/state/shell/shell-layout.tsx b/src/state/shell/shell-layout.tsx new file mode 100644 index 000000000..a58ba851c --- /dev/null +++ b/src/state/shell/shell-layout.tsx @@ -0,0 +1,41 @@ +import React from 'react' +import {SharedValue, useSharedValue} from 'react-native-reanimated' + +type StateContext = { + headerHeight: SharedValue<number> + footerHeight: SharedValue<number> +} + +const stateContext = React.createContext<StateContext>({ + headerHeight: { + value: 0, + addListener() {}, + removeListener() {}, + modify() {}, + }, + footerHeight: { + value: 0, + addListener() {}, + removeListener() {}, + modify() {}, + }, +}) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const headerHeight = useSharedValue(0) + const footerHeight = useSharedValue(0) + + const value = React.useMemo( + () => ({ + headerHeight, + footerHeight, + }), + [headerHeight, footerHeight], + ) + + return <stateContext.Provider value={value}>{children}</stateContext.Provider> +} + +export function useShellLayout() { + return React.useContext(stateContext) +} diff --git a/src/state/shell/tick-every-minute.tsx b/src/state/shell/tick-every-minute.tsx new file mode 100644 index 000000000..c37221c90 --- /dev/null +++ b/src/state/shell/tick-every-minute.tsx @@ -0,0 +1,20 @@ +import React from 'react' + +type StateContext = number + +const stateContext = React.createContext<StateContext>(0) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [tick, setTick] = React.useState(Date.now()) + React.useEffect(() => { + const i = setInterval(() => { + setTick(Date.now()) + }, 60_000) + return () => clearInterval(i) + }, []) + return <stateContext.Provider value={tick}>{children}</stateContext.Provider> +} + +export function useTickEveryMinute() { + return React.useContext(stateContext) +} |