Bluesky app fork with some witchin' additions 💫

[APP-775] Add Welcome screen after account creation (#1038)

* add comments to step 1-3

* add onboarding screen

* add analytics for onboarding tracking

* fix useEffect

* change text

* change icon size

* put onboarding into bottom sheet modal instead of react navigation

* wip

* Simplify the type validation

* Fix: only trigger onboarding modal when account creation succeeds

* Add the 'session-ready' event which fires when the new session is stable

* Use the 'session-ready' event to trigger the onboarding modal

* update copy

* update copy

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>

authored by

Ansh
Paul Frazee
and committed by
GitHub
30ac9259 3517d9fa

+231 -4
+15 -1
src/Navigation.tsx
··· 66 66 import {getRoutingInstrumentation} from 'lib/sentry' 67 67 import {bskyTitle} from 'lib/strings/headings' 68 68 import {JSX} from 'react/jsx-runtime' 69 + import {timeout} from 'lib/async/timeout' 69 70 70 71 const navigationRef = createNavigationContainerRef<AllNavigatorParams>() 71 72 ··· 478 479 } 479 480 } 480 481 481 - function reset() { 482 + // returns a promise that resolves after the state reset is complete 483 + function reset(): Promise<void> { 482 484 if (navigationRef.isReady()) { 483 485 navigationRef.dispatch( 484 486 CommonActions.reset({ ··· 486 488 routes: [{name: isNative ? 'HomeTab' : 'Home'}], 487 489 }), 488 490 ) 491 + return Promise.race([ 492 + timeout(1e3), 493 + new Promise<void>(resolve => { 494 + const handler = () => { 495 + resolve() 496 + navigationRef.removeListener('state', handler) 497 + } 498 + navigationRef.addListener('state', handler) 499 + }), 500 + ]) 501 + } else { 502 + return Promise.resolve() 489 503 } 490 504 } 491 505
+3
src/lib/analytics/types.ts
··· 117 117 'MultiFeed:onRefresh': {} 118 118 // MODERATION events 119 119 'Moderation:ContentfilteringButtonClicked': {} 120 + // ONBOARDING events 121 + 'Onboarding:Begin': {} 122 + 'Onboarding:Complete': {} 120 123 } 121 124 122 125 interface ScreenPropertiesMap {
+3
src/lib/async/timeout.ts
··· 1 + export function timeout(ms: number): Promise<void> { 2 + return new Promise(r => setTimeout(r, ms)) 3 + }
+10 -1
src/state/models/root-store.ts
··· 135 135 /* dont await */ this.preferences.sync() 136 136 await this.me.load() 137 137 if (!hadSession) { 138 - resetNavigation() 138 + await resetNavigation() 139 139 } 140 + this.emitSessionReady() 140 141 } 141 142 142 143 /** ··· 193 194 } 194 195 emitSessionLoaded() { 195 196 DeviceEventEmitter.emit('session-loaded') 197 + } 198 + 199 + // the session has completed all setup; good for post-initialization behaviors like triggering modals 200 + onSessionReady(handler: () => void): EmitterSubscription { 201 + return DeviceEventEmitter.addListener('session-ready', handler) 202 + } 203 + emitSessionReady() { 204 + DeviceEventEmitter.emit('session-ready') 196 205 } 197 206 198 207 // the session was dropped due to bad/expired refresh tokens
+9 -2
src/state/models/ui/create-account.ts
··· 108 108 } 109 109 this.setError('') 110 110 this.setIsProcessing(true) 111 + 112 + // open the onboarding modal after the session is created 113 + const sessionReadySub = this.rootStore.onSessionReady(() => { 114 + sessionReadySub.remove() 115 + this.rootStore.shell.openModal({name: 'onboarding'}) 116 + }) 117 + 111 118 try { 112 119 await this.rootStore.session.createAccount({ 113 120 service: this.serviceUrl, ··· 116 123 password: this.password, 117 124 inviteCode: this.inviteCode, 118 125 }) 126 + track('Create Account') 119 127 } catch (e: any) { 128 + sessionReadySub.remove() 120 129 let errMsg = e.toString() 121 130 if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) { 122 131 errMsg = ··· 126 135 this.setIsProcessing(false) 127 136 this.setError(cleanError(errMsg)) 128 137 throw e 129 - } finally { 130 - track('Create Account') 131 138 } 132 139 } 133 140
+7
src/state/models/ui/shell.ts
··· 127 127 name: 'preferences-home-feed' 128 128 } 129 129 130 + export interface OnboardingModal { 131 + name: 'onboarding' 132 + } 133 + 130 134 export type Modal = 131 135 // Account 132 136 | AddAppPasswordModal ··· 157 161 // Bluesky access 158 162 | WaitlistModal 159 163 | InviteCodesModal 164 + 165 + // Onboarding 166 + | OnboardingModal 160 167 161 168 // Generic 162 169 | ConfirmModal
+4
src/view/com/auth/create/Step1.tsx
··· 16 16 import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'state/index' 17 17 import {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags' 18 18 19 + /** STEP 1: Your hosting provider 20 + * @field Bluesky (default) 21 + * @field Other (staging, local dev, your own PDS, etc.) 22 + */ 19 23 export const Step1 = observer(({model}: {model: CreateAccountModel}) => { 20 24 const pal = usePalette('default') 21 25 const [isDefaultSelected, setIsDefaultSelected] = React.useState(true)
+9
src/view/com/auth/create/Step2.tsx
··· 12 12 import {ErrorMessage} from 'view/com/util/error/ErrorMessage' 13 13 import {useStores} from 'state/index' 14 14 15 + /** STEP 2: Your account 16 + * @field Invite code or waitlist 17 + * @field Email address 18 + * @field Email address 19 + * @field Email address 20 + * @field Password 21 + * @field Birth date 22 + * @readonly Terms of service & privacy policy 23 + */ 15 24 export const Step2 = observer(({model}: {model: CreateAccountModel}) => { 16 25 const pal = usePalette('default') 17 26 const store = useStores()
+3
src/view/com/auth/create/Step3.tsx
··· 10 10 import {usePalette} from 'lib/hooks/usePalette' 11 11 import {ErrorMessage} from 'view/com/util/error/ErrorMessage' 12 12 13 + /** STEP 3: Your user handle 14 + * @field User handle 15 + */ 13 16 export const Step3 = observer(({model}: {model: CreateAccountModel}) => { 14 17 const pal = usePalette('default') 15 18 return (
+66
src/view/com/auth/onboarding/Onboarding.tsx
··· 1 + import React from 'react' 2 + import {StyleSheet, View} from 'react-native' 3 + import {usePalette} from 'lib/hooks/usePalette' 4 + import {Welcome} from './Welcome' 5 + import {useStores} from 'state/index' 6 + import {track} from 'lib/analytics/analytics' 7 + 8 + enum OnboardingStep { 9 + WELCOME = 'WELCOME', 10 + // SELECT_INTERESTS = 'SELECT_INTERESTS', 11 + COMPLETE = 'COMPLETE', 12 + } 13 + type OnboardingState = { 14 + currentStep: OnboardingStep 15 + } 16 + type Action = {type: 'NEXT_STEP'} 17 + const initialState: OnboardingState = { 18 + currentStep: OnboardingStep.WELCOME, 19 + } 20 + const reducer = (state: OnboardingState, action: Action): OnboardingState => { 21 + switch (action.type) { 22 + case 'NEXT_STEP': 23 + switch (state.currentStep) { 24 + case OnboardingStep.WELCOME: 25 + track('Onboarding:Begin') 26 + return {...state, currentStep: OnboardingStep.COMPLETE} 27 + case OnboardingStep.COMPLETE: 28 + track('Onboarding:Complete') 29 + return state 30 + default: 31 + return state 32 + } 33 + default: 34 + return state 35 + } 36 + } 37 + 38 + export const Onboarding = () => { 39 + const pal = usePalette('default') 40 + const rootStore = useStores() 41 + const [state, dispatch] = React.useReducer(reducer, initialState) 42 + const next = React.useCallback( 43 + () => dispatch({type: 'NEXT_STEP'}), 44 + [dispatch], 45 + ) 46 + 47 + React.useEffect(() => { 48 + if (state.currentStep === OnboardingStep.COMPLETE) { 49 + // navigate to home 50 + rootStore.shell.closeModal() 51 + } 52 + }, [state.currentStep, rootStore.shell]) 53 + 54 + return ( 55 + <View style={[pal.view, styles.container]}> 56 + {state.currentStep === OnboardingStep.WELCOME && <Welcome next={next} />} 57 + </View> 58 + ) 59 + } 60 + 61 + const styles = StyleSheet.create({ 62 + container: { 63 + flex: 1, 64 + paddingHorizontal: 20, 65 + }, 66 + })
+87
src/view/com/auth/onboarding/Welcome.tsx
··· 1 + import React from 'react' 2 + import {StyleSheet, View} from 'react-native' 3 + import {Text} from 'view/com/util/text/Text' 4 + import {s} from 'lib/styles' 5 + import {usePalette} from 'lib/hooks/usePalette' 6 + import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 7 + import {Button} from 'view/com/util/forms/Button' 8 + 9 + export const Welcome = ({next}: {next: () => void}) => { 10 + const pal = usePalette('default') 11 + return ( 12 + <View style={[styles.container]}> 13 + <View> 14 + <Text style={[pal.text, styles.title]}>Welcome to </Text> 15 + <Text style={[pal.text, pal.link, styles.title]}>Bluesky</Text> 16 + 17 + <View style={styles.spacer} /> 18 + 19 + <View style={[styles.row]}> 20 + <FontAwesomeIcon icon={'globe'} size={36} color={pal.colors.link} /> 21 + <View style={[styles.rowText]}> 22 + <Text type="lg-bold" style={[pal.text]}> 23 + Bluesky is public. 24 + </Text> 25 + <Text type="lg-thin" style={[pal.text, s.pt2]}> 26 + Your posts, likes, and blocks are public. Mutes are private. 27 + </Text> 28 + </View> 29 + </View> 30 + <View style={[styles.row]}> 31 + <FontAwesomeIcon icon={'at'} size={36} color={pal.colors.link} /> 32 + <View style={[styles.rowText]}> 33 + <Text type="lg-bold" style={[pal.text]}> 34 + Bluesky is open. 35 + </Text> 36 + <Text type="lg-thin" style={[pal.text, s.pt2]}> 37 + Never lose access to your followers and data. 38 + </Text> 39 + </View> 40 + </View> 41 + <View style={[styles.row]}> 42 + <FontAwesomeIcon icon={'gear'} size={36} color={pal.colors.link} /> 43 + <View style={[styles.rowText]}> 44 + <Text type="lg-bold" style={[pal.text]}> 45 + Bluesky is flexible. 46 + </Text> 47 + <Text type="lg-thin" style={[pal.text, s.pt2]}> 48 + Choose the algorithms that power your experience with custom 49 + feeds. 50 + </Text> 51 + </View> 52 + </View> 53 + </View> 54 + 55 + <Button onPress={next} label="Continue" labelStyle={styles.buttonText} /> 56 + </View> 57 + ) 58 + } 59 + 60 + const styles = StyleSheet.create({ 61 + container: { 62 + flex: 1, 63 + marginVertical: 60, 64 + justifyContent: 'space-between', 65 + }, 66 + title: { 67 + fontSize: 48, 68 + fontWeight: '800', 69 + }, 70 + row: { 71 + flexDirection: 'row', 72 + columnGap: 20, 73 + alignItems: 'center', 74 + marginVertical: 20, 75 + }, 76 + rowText: { 77 + flex: 1, 78 + }, 79 + spacer: { 80 + height: 20, 81 + }, 82 + buttonText: { 83 + textAlign: 'center', 84 + fontSize: 18, 85 + marginVertical: 4, 86 + }, 87 + })
+4
src/view/com/modals/Modal.tsx
··· 27 27 import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' 28 28 import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' 29 29 import * as PreferencesHomeFeed from './PreferencesHomeFeed' 30 + import * as OnboardingModal from './OnboardingModal' 30 31 31 32 const DEFAULT_SNAPPOINTS = ['90%'] 32 33 ··· 117 118 } else if (activeModal?.name === 'preferences-home-feed') { 118 119 snapPoints = PreferencesHomeFeed.snapPoints 119 120 element = <PreferencesHomeFeed.Component /> 121 + } else if (activeModal?.name === 'onboarding') { 122 + snapPoints = OnboardingModal.snapPoints 123 + element = <OnboardingModal.Component /> 120 124 } else { 121 125 return null 122 126 }
+3
src/view/com/modals/Modal.web.tsx
··· 26 26 import * as ContentFilteringSettingsModal from './ContentFilteringSettings' 27 27 import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' 28 28 import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' 29 + import * as OnboardingModal from './OnboardingModal' 29 30 30 31 import * as PreferencesHomeFeed from './PreferencesHomeFeed' 31 32 ··· 107 108 element = <EditImageModal.Component {...modal} /> 108 109 } else if (modal.name === 'preferences-home-feed') { 109 110 element = <PreferencesHomeFeed.Component /> 111 + } else if (modal.name === 'onboarding') { 112 + element = <OnboardingModal.Component /> 110 113 } else { 111 114 return null 112 115 }
+8
src/view/com/modals/OnboardingModal.tsx
··· 1 + import React from 'react' 2 + import {Onboarding} from '../auth/onboarding/Onboarding' 3 + 4 + export const snapPoints = ['90%'] 5 + 6 + export function Component() { 7 + return <Onboarding /> 8 + }