diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/Navigation.tsx | 23 | ||||
-rw-r--r-- | src/lib/analytics/types.ts | 1 | ||||
-rw-r--r-- | src/lib/routes/types.ts | 5 | ||||
-rw-r--r-- | src/state/models/discovery/onboarding.ts | 82 | ||||
-rw-r--r-- | src/state/models/root-store.ts | 6 | ||||
-rw-r--r-- | src/view/com/auth/onboarding/Onboarding.tsx | 66 | ||||
-rw-r--r-- | src/view/com/auth/onboarding/RecommendedFeeds.tsx | 192 | ||||
-rw-r--r-- | src/view/com/auth/onboarding/Welcome.tsx | 53 | ||||
-rw-r--r-- | src/view/com/modals/Modal.tsx | 4 | ||||
-rw-r--r-- | src/view/com/modals/Modal.web.tsx | 3 | ||||
-rw-r--r-- | src/view/com/modals/OnboardingModal.tsx | 8 | ||||
-rw-r--r-- | src/view/index.ts | 2 | ||||
-rw-r--r-- | src/view/screens/Home.tsx | 8 | ||||
-rw-r--r-- | src/view/screens/Settings.tsx | 15 |
14 files changed, 384 insertions, 84 deletions
diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 48bab182d..058a15fa2 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -67,6 +67,8 @@ import {getRoutingInstrumentation} from 'lib/sentry' import {bskyTitle} from 'lib/strings/headings' import {JSX} from 'react/jsx-runtime' import {timeout} from 'lib/async/timeout' +import {Welcome, WelcomeHeaderRight} from 'view/com/auth/onboarding/Welcome' +import {RecommendedFeeds} from 'view/com/auth/onboarding/RecommendedFeeds' const navigationRef = createNavigationContainerRef<AllNavigatorParams>() @@ -219,6 +221,26 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { component={SavedFeeds} options={{title: title('Edit My Feeds')}} /> + <Stack.Screen + name="Welcome" + component={Welcome} + options={{ + title: title('Welcome'), + presentation: 'card', + headerShown: true, + headerTransparent: true, + headerTitle: '', + headerBackVisible: false, + headerRight: props => <WelcomeHeaderRight {...props} />, + }} + /> + <Stack.Screen + name="RecommendedFeeds" + component={RecommendedFeeds} + options={{ + title: title('Recommended Feeds'), + }} + /> </> ) } @@ -254,6 +276,7 @@ function TabsNavigator() { function HomeTabNavigator() { const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark) + return ( <HomeTab.Navigator screenOptions={{ diff --git a/src/lib/analytics/types.ts b/src/lib/analytics/types.ts index f876c6d53..d56e1b615 100644 --- a/src/lib/analytics/types.ts +++ b/src/lib/analytics/types.ts @@ -122,6 +122,7 @@ interface TrackPropertiesMap { // ONBOARDING events 'Onboarding:Begin': {} 'Onboarding:Complete': {} + 'Onboarding:Skipped': {} } interface ScreenPropertiesMap { diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 4eb5e29d2..633fa57a5 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -1,5 +1,6 @@ import {NavigationState, PartialState} from '@react-navigation/native' import type {NativeStackNavigationProp} from '@react-navigation/native-stack' +import {OnboardingScreenSteps} from 'state/models/discovery/onboarding' export type {NativeStackScreenProps} from '@react-navigation/native-stack' @@ -29,6 +30,10 @@ export type CommonNavigatorParams = { CopyrightPolicy: undefined AppPasswords: undefined SavedFeeds: undefined +} & OnboardingScreenParams + +export type OnboardingScreenParams = { + [K in keyof typeof OnboardingScreenSteps]: undefined } export type BottomTabNavigatorParams = CommonNavigatorParams & { diff --git a/src/state/models/discovery/onboarding.ts b/src/state/models/discovery/onboarding.ts new file mode 100644 index 000000000..9b49beaf4 --- /dev/null +++ b/src/state/models/discovery/onboarding.ts @@ -0,0 +1,82 @@ +import {makeAutoObservable} from 'mobx' +import {RootStoreModel} from '../root-store' +import {hasProp} from 'lib/type-guards' +import {track} from 'lib/analytics/analytics' + +export const OnboardingScreenSteps = { + Welcome: 'Welcome', + RecommendedFeeds: 'RecommendedFeeds', + Home: 'Home', +} as const + +type OnboardingStep = + (typeof OnboardingScreenSteps)[keyof typeof OnboardingScreenSteps] +const OnboardingStepsArray = Object.values(OnboardingScreenSteps) +export class OnboardingModel { + // state + step: OnboardingStep = 'Welcome' + + constructor(public rootStore: RootStoreModel) { + makeAutoObservable(this, { + rootStore: false, + hydrate: false, + serialize: false, + }) + } + + serialize(): unknown { + console.log('serializing onboarding', this.step) + return { + step: this.step, + } + } + + hydrate(v: unknown) { + if (typeof v === 'object' && v !== null) { + if ( + hasProp(v, 'step') && + typeof v.step === 'string' && + OnboardingStepsArray.includes(v.step as OnboardingStep) + ) { + console.log('hydrating onboarding', v.step) + this.step = v.step as OnboardingStep + } + } else { + // if there is no valid state, we'll just reset + this.reset() + } + } + + nextScreenName(currentScreenName?: OnboardingStep) { + if (currentScreenName === 'Welcome' || this.step === 'Welcome') { + this.step = 'RecommendedFeeds' + return this.step + } else if ( + this.step === 'RecommendedFeeds' || + currentScreenName === 'RecommendedFeeds' + ) { + this.step = 'Home' + return this.step + } else { + // if we get here, we're in an invalid state, let's just go Home + return 'Home' + } + } + + reset() { + this.step = 'Welcome' + } + + skip() { + track('Onboarding:Skipped') + this.step = 'Home' + } + + get isComplete() { + return this.step === 'Home' + } + + get isRemaining() { + return !this.isComplete + } +} diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index 1d6d3a0d0..6204e0d10 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -27,6 +27,7 @@ import {reset as resetNavigation} from '../../Navigation' // remove after backend testing finishes // -prf import {applyDebugHeader} from 'lib/api/debug-appview-proxy-header' +import {OnboardingModel} from './discovery/onboarding' export const appInfo = z.object({ build: z.string(), @@ -44,6 +45,7 @@ export class RootStoreModel { shell = new ShellUiModel(this) preferences = new PreferencesModel(this) me = new MeModel(this) + onboarding = new OnboardingModel(this) invitedUsers = new InvitedUsers(this) handleResolutions = new HandleResolutionsCache() profiles = new ProfilesCache(this) @@ -70,6 +72,7 @@ export class RootStoreModel { appInfo: this.appInfo, session: this.session.serialize(), me: this.me.serialize(), + onboarding: this.onboarding.serialize(), shell: this.shell.serialize(), preferences: this.preferences.serialize(), invitedUsers: this.invitedUsers.serialize(), @@ -88,6 +91,9 @@ export class RootStoreModel { if (hasProp(v, 'me')) { this.me.hydrate(v.me) } + if (hasProp(v, 'onboarding')) { + this.onboarding.hydrate(v.onboarding) + } if (hasProp(v, 'session')) { this.session.hydrate(v.session) } diff --git a/src/view/com/auth/onboarding/Onboarding.tsx b/src/view/com/auth/onboarding/Onboarding.tsx deleted file mode 100644 index 28e4419d7..000000000 --- a/src/view/com/auth/onboarding/Onboarding.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import React from 'react' -import {StyleSheet, View} from 'react-native' -import {usePalette} from 'lib/hooks/usePalette' -import {Welcome} from './Welcome' -import {useStores} from 'state/index' -import {track} from 'lib/analytics/analytics' - -enum OnboardingStep { - WELCOME = 'WELCOME', - // SELECT_INTERESTS = 'SELECT_INTERESTS', - COMPLETE = 'COMPLETE', -} -type OnboardingState = { - currentStep: OnboardingStep -} -type Action = {type: 'NEXT_STEP'} -const initialState: OnboardingState = { - currentStep: OnboardingStep.WELCOME, -} -const reducer = (state: OnboardingState, action: Action): OnboardingState => { - switch (action.type) { - case 'NEXT_STEP': - switch (state.currentStep) { - case OnboardingStep.WELCOME: - track('Onboarding:Begin') - return {...state, currentStep: OnboardingStep.COMPLETE} - case OnboardingStep.COMPLETE: - track('Onboarding:Complete') - return state - default: - return state - } - default: - return state - } -} - -export const Onboarding = () => { - const pal = usePalette('default') - const rootStore = useStores() - const [state, dispatch] = React.useReducer(reducer, initialState) - const next = React.useCallback( - () => dispatch({type: 'NEXT_STEP'}), - [dispatch], - ) - - React.useEffect(() => { - if (state.currentStep === OnboardingStep.COMPLETE) { - // navigate to home - rootStore.shell.closeModal() - } - }, [state.currentStep, rootStore.shell]) - - return ( - <View style={[pal.view, styles.container]}> - {state.currentStep === OnboardingStep.WELCOME && <Welcome next={next} />} - </View> - ) -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - paddingHorizontal: 20, - }, -}) diff --git a/src/view/com/auth/onboarding/RecommendedFeeds.tsx b/src/view/com/auth/onboarding/RecommendedFeeds.tsx new file mode 100644 index 000000000..88fb200c6 --- /dev/null +++ b/src/view/com/auth/onboarding/RecommendedFeeds.tsx @@ -0,0 +1,192 @@ +import React from 'react' +import {FlatList, StyleSheet, View} from 'react-native' +import {Text} from 'view/com/util/text/Text' +import {usePalette} from 'lib/hooks/usePalette' +import {Button} from 'view/com/util/forms/Button' +import {NativeStackScreenProps} from '@react-navigation/native-stack' +import {HomeTabNavigatorParams} from 'lib/routes/types' +import {useStores} from 'state/index' +import {observer} from 'mobx-react-lite' +import {CustomFeed} from 'view/com/feeds/CustomFeed' +import {useCustomFeed} from 'lib/hooks/useCustomFeed' +import {makeRecordUri} from 'lib/strings/url-helpers' +import {ViewHeader} from 'view/com/util/ViewHeader' + +const TEMPORARY_RECOMMENDED_FEEDS = [ + { + did: 'did:plc:hsqwcidfez66lwm3gxhfv5in', + rkey: 'aaaf2pqeodmpy', + }, + { + did: 'did:plc:gekdk2nd47gkk3utfz2xf7cn', + rkey: 'aaap4tbjcfe5y', + }, + { + did: 'did:plc:5rw2on4i56btlcajojaxwcat', + rkey: 'aaao6g552b33o', + }, + { + did: 'did:plc:jfhpnnst6flqway4eaeqzj2a', + rkey: 'for-science', + }, + { + did: 'did:plc:7q4nnnxawajbfaq7to5dpbsy', + rkey: 'bsky-news', + }, + { + did: 'did:plc:jcoy7v3a2t4rcfdh6i4kza25', + rkey: 'astro', + }, + { + did: 'did:plc:tenurhgjptubkk5zf5qhi3og', + rkey: 'h-nba', + }, + { + did: 'did:plc:vpkhqolt662uhesyj6nxm7ys', + rkey: 'devfeed', + }, + { + did: 'did:plc:cndfx4udwgvpjaakvxvh7wm5', + rkey: 'flipboard-tech', + }, + { + did: 'did:plc:w4xbfzo7kqfes5zb7r6qv3rw', + rkey: 'blacksky', + }, + { + did: 'did:plc:lptjvw6ut224kwrj7ub3sqbe', + rkey: 'aaaotfjzjplna', + }, + { + did: 'did:plc:gkvpokm7ec5j5yxls6xk4e3z', + rkey: 'formula-one', + }, + { + did: 'did:plc:q6gjnaw2blty4crticxkmujt', + rkey: 'positivifeed', + }, + { + did: 'did:plc:l72uci4styb4jucsgcrrj5ap', + rkey: 'aaao5dzfm36u4', + }, + { + did: 'did:plc:k3jkadxv5kkjgs6boyon7m6n', + rkey: 'aaaavlyvqzst2', + }, + { + did: 'did:plc:nkahctfdi6bxk72umytfwghw', + rkey: 'aaado2uvfsc6w', + }, + { + did: 'did:plc:epihigio3d7un7u3gpqiy5gv', + rkey: 'aaaekwsc7zsvs', + }, + { + did: 'did:plc:qiknc4t5rq7yngvz7g4aezq7', + rkey: 'aaaejxlobe474', + }, + { + did: 'did:plc:mlq4aycufcuolr7ax6sezpc4', + rkey: 'aaaoudweck6uy', + }, + { + did: 'did:plc:rcez5hcvq3vzlu5x7xrjyccg', + rkey: 'aaadzjxbcddzi', + }, + { + did: 'did:plc:lnxbuzaenlwjrncx6sc4cfdr', + rkey: 'aaab2vesjtszc', + }, + { + did: 'did:plc:x3cya3wkt4n6u4ihmvpsc5if', + rkey: 'aaacynbxwimok', + }, + { + did: 'did:plc:abv47bjgzjgoh3yrygwoi36x', + rkey: 'aaagt6amuur5e', + }, + { + did: 'did:plc:ffkgesg3jsv2j7aagkzrtcvt', + rkey: 'aaacjerk7gwek', + }, + { + did: 'did:plc:geoqe3qls5mwezckxxsewys2', + rkey: 'aaai43yetqshu', + }, + { + did: 'did:plc:2wqomm3tjqbgktbrfwgvrw34', + rkey: 'authors', + }, +] + +type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'RecommendedFeeds'> +export const RecommendedFeeds = observer(({navigation}: Props) => { + const pal = usePalette('default') + const store = useStores() + + const next = () => { + const nextScreenName = store.onboarding.nextScreenName('RecommendedFeeds') + if (nextScreenName) { + navigation.navigate(nextScreenName) + } + } + + return ( + <View style={[styles.container]} testID="recommendedFeedsScreen"> + <ViewHeader title="Recommended Feeds" canGoBack /> + <Text type="lg-medium" style={[pal.text, styles.header]}> + Check out some recommended feeds. Click + to add them to your list of + pinned feeds. + </Text> + + <FlatList + data={TEMPORARY_RECOMMENDED_FEEDS} + renderItem={({item}) => <Item item={item} />} + keyExtractor={item => item.did + item.rkey} + style={{flex: 1}} + /> + + <Button + onPress={next} + label="Continue" + testID="continueBtn" + style={styles.button} + labelStyle={styles.buttonText} + /> + </View> + ) +}) + +type ItemProps = { + did: string + rkey: string +} + +const Item = ({item}: {item: ItemProps}) => { + const uri = makeRecordUri(item.did, 'app.bsky.feed.generator', item.rkey) + const data = useCustomFeed(uri) + if (!data) return null + return ( + <CustomFeed item={data} key={uri} showDescription showLikes showSaveBtn /> + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + marginHorizontal: 16, + justifyContent: 'space-between', + }, + header: { + marginBottom: 16, + }, + button: { + marginBottom: 48, + marginTop: 16, + }, + buttonText: { + textAlign: 'center', + fontSize: 18, + paddingVertical: 4, + }, +}) diff --git a/src/view/com/auth/onboarding/Welcome.tsx b/src/view/com/auth/onboarding/Welcome.tsx index 87435c88a..cb3a2307a 100644 --- a/src/view/com/auth/onboarding/Welcome.tsx +++ b/src/view/com/auth/onboarding/Welcome.tsx @@ -1,13 +1,36 @@ import React from 'react' -import {StyleSheet, View} from 'react-native' +import {Pressable, StyleSheet, View} from 'react-native' import {Text} from 'view/com/util/text/Text' import {s} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Button} from 'view/com/util/forms/Button' +import {NativeStackScreenProps} from '@react-navigation/native-stack' +import {HomeTabNavigatorParams} from 'lib/routes/types' +import {useStores} from 'state/index' +import {observer} from 'mobx-react-lite' +import {HeaderButtonProps} from '@react-navigation/native-stack/lib/typescript/src/types' +import {NavigationProp, useNavigation} from '@react-navigation/native' -export const Welcome = ({next}: {next: () => void}) => { +type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Welcome'> +export const Welcome = observer(({navigation}: Props) => { const pal = usePalette('default') + const store = useStores() + + // make sure bottom nav is hidden + React.useEffect(() => { + if (!store.shell.minimalShellMode) { + store.shell.setMinimalShellMode(true) + } + }, [store.shell.minimalShellMode, store]) + + const next = () => { + const nextScreenName = store.onboarding.nextScreenName('Welcome') + if (nextScreenName) { + navigation.navigate(nextScreenName) + } + } + return ( <View style={[styles.container]}> <View testID="welcomeScreen"> @@ -60,12 +83,38 @@ export const Welcome = ({next}: {next: () => void}) => { /> </View> ) +}) + +export const WelcomeHeaderRight = (props: HeaderButtonProps) => { + const {canGoBack} = props + const pal = usePalette('default') + const navigation = useNavigation<NavigationProp<HomeTabNavigatorParams>>() + const store = useStores() + return ( + <Pressable + accessibilityRole="button" + style={[s.flexRow, s.alignCenter]} + onPress={() => { + if (canGoBack) { + store.onboarding.skip() + navigation.goBack() + } + }}> + <Text style={[pal.link]}>Skip</Text> + <FontAwesomeIcon + icon={'chevron-right'} + size={14} + color={pal.colors.link} + /> + </Pressable> + ) } const styles = StyleSheet.create({ container: { flex: 1, marginVertical: 60, + marginHorizontal: 16, justifyContent: 'space-between', }, title: { diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index efd06412d..dd45262be 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -29,7 +29,6 @@ import * as ContentFilteringSettingsModal from './ContentFilteringSettings' import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' import * as PreferencesHomeFeed from './PreferencesHomeFeed' -import * as OnboardingModal from './OnboardingModal' import * as ModerationDetailsModal from './ModerationDetails' const DEFAULT_SNAPPOINTS = ['90%'] @@ -134,9 +133,6 @@ export const ModalsContainer = observer(function ModalsContainer() { } else if (activeModal?.name === 'preferences-home-feed') { snapPoints = PreferencesHomeFeed.snapPoints element = <PreferencesHomeFeed.Component /> - } else if (activeModal?.name === 'onboarding') { - snapPoints = OnboardingModal.snapPoints - element = <OnboardingModal.Component /> } else if (activeModal?.name === 'moderation-details') { snapPoints = ModerationDetailsModal.snapPoints element = <ModerationDetailsModal.Component {...activeModal} /> diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index 687c4fba3..6aef1b71c 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -26,7 +26,6 @@ import * as AddAppPassword from './AddAppPasswords' import * as ContentFilteringSettingsModal from './ContentFilteringSettings' import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' -import * as OnboardingModal from './OnboardingModal' import * as ModerationDetailsModal from './ModerationDetails' import * as PreferencesHomeFeed from './PreferencesHomeFeed' @@ -109,8 +108,6 @@ function Modal({modal}: {modal: ModalIface}) { element = <EditImageModal.Component {...modal} /> } else if (modal.name === 'preferences-home-feed') { element = <PreferencesHomeFeed.Component /> - } else if (modal.name === 'onboarding') { - element = <OnboardingModal.Component /> } else if (modal.name === 'moderation-details') { element = <ModerationDetailsModal.Component {...modal} /> } else { diff --git a/src/view/com/modals/OnboardingModal.tsx b/src/view/com/modals/OnboardingModal.tsx deleted file mode 100644 index c70f4fd62..000000000 --- a/src/view/com/modals/OnboardingModal.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import React from 'react' -import {Onboarding} from '../auth/onboarding/Onboarding' - -export const snapPoints = ['90%'] - -export function Component() { - return <Onboarding /> -} diff --git a/src/view/index.ts b/src/view/index.ts index 1c3dc3937..2e4c08ec7 100644 --- a/src/view/index.ts +++ b/src/view/index.ts @@ -92,6 +92,7 @@ import {faPlay} from '@fortawesome/free-solid-svg-icons/faPlay' import {faPause} from '@fortawesome/free-solid-svg-icons/faPause' import {faThumbtack} from '@fortawesome/free-solid-svg-icons/faThumbtack' import {faList} from '@fortawesome/free-solid-svg-icons/faList' +import {faChevronRight} from '@fortawesome/free-solid-svg-icons/faChevronRight' export function setup() { library.add( @@ -187,5 +188,6 @@ export function setup() { faPlay, faPause, faList, + faChevronRight, ) } diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 9259d4bea..f2aa208c3 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -31,7 +31,7 @@ const POLL_FREQ = 30e3 // 30sec type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'> export const HomeScreen = withAuthRequired( - observer((_opts: Props) => { + observer(({navigation}: Props) => { const store = useStores() const pagerRef = React.useRef<PagerRef>(null) const [selectedPage, setSelectedPage] = React.useState(0) @@ -41,6 +41,12 @@ export const HomeScreen = withAuthRequired( >([]) React.useEffect(() => { + if (store.onboarding.isRemaining) { + navigation.navigate('Welcome') + } + }, [store.onboarding.isRemaining, navigation]) + + React.useEffect(() => { const {pinned} = store.me.savedFeeds if ( diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index f1d4767f3..4a2c1c16a 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -162,6 +162,11 @@ export const SettingsScreen = withAuthRequired( Toast.show('Preferences reset') }, [store]) + const onPressResetOnboarding = React.useCallback(async () => { + store.onboarding.reset() + Toast.show('Onboarding reset') + }, [store]) + const onPressBuildInfo = React.useCallback(() => { Clipboard.setString( `Build version: ${AppInfo.appVersion}; Platform: ${Platform.OS}`, @@ -535,6 +540,16 @@ export const SettingsScreen = withAuthRequired( Reset preferences state </Text> </TouchableOpacity> + <TouchableOpacity + style={[pal.view, styles.linkCardNoIcon]} + onPress={onPressResetOnboarding} + accessibilityRole="button" + accessibilityHint="Reset onboarding" + accessibilityLabel="Resets the onboarding state"> + <Text type="lg" style={pal.text}> + Reset onboarding state + </Text> + </TouchableOpacity> </> ) : null} <View style={[styles.footer]}> |