Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Move request for notifications permissions to `HomeReadyScreen` (#3977)

* cleanup the current logic

* add statsig logs

* implement requests for permissions where needed

* oops

* let `addPushTokenListener` handle the token registration

* place new log event type with the other `notifications` type

* place registration next to handler

* more organization

* only call `gate()` if permission is not yet granted

* be more specific to prevent gate pollution

* nit

* make `token` non-optional in `registerToken`

* remove `prevDid`, move `registerPushToken` into `useEffect`

* keep it outside actually

* nit

authored by

Hailey and committed by
GitHub
d3406c89 63b38b41

+105 -67
+64 -44
src/lib/notifications/notifications.ts
··· 1 + import React from 'react' 1 2 import * as Notifications from 'expo-notifications' 2 3 import {BskyAgent} from '@atproto/api' 3 4 4 5 import {logger} from '#/logger' 5 - import {SessionAccount} from '#/state/session' 6 - import {devicePlatform} from 'platform/detection' 6 + import {SessionAccount, useAgent, useSession} from '#/state/session' 7 + import {logEvent, useGate} from 'lib/statsig/statsig' 8 + import {devicePlatform, isNative} from 'platform/detection' 7 9 8 10 const SERVICE_DID = (serviceUrl?: string) => 9 11 serviceUrl?.includes('staging') 10 12 ? 'did:web:api.staging.bsky.dev' 11 13 : 'did:web:api.bsky.app' 12 14 13 - export async function requestPermissionsAndRegisterToken( 15 + async function registerPushToken( 14 16 getAgent: () => BskyAgent, 15 17 account: SessionAccount, 18 + token: Notifications.DevicePushToken, 16 19 ) { 17 - // request notifications permission once the user has logged in 18 - const perms = await Notifications.getPermissionsAsync() 19 - if (!perms.granted) { 20 - await Notifications.requestPermissionsAsync() 21 - } 22 - 23 - // register the push token with the server 24 - const token = await Notifications.getDevicePushTokenAsync() 25 20 try { 26 21 await getAgent().api.app.bsky.notification.registerPush({ 27 22 serviceDid: SERVICE_DID(account.service), ··· 42 37 } 43 38 } 44 39 45 - export function registerTokenChangeHandler( 46 - getAgent: () => BskyAgent, 47 - account: SessionAccount, 48 - ): () => void { 49 - // listens for new changes to the push token 50 - // In rare situations, a push token may be changed by the push notification service while the app is running. When a token is rolled, the old one becomes invalid and sending notifications to it will fail. A push token listener will let you handle this situation gracefully by registering the new token with your backend right away. 51 - const sub = Notifications.addPushTokenListener(async newToken => { 52 - logger.debug( 53 - 'Notifications: Push token changed', 54 - {tokenType: newToken.data, token: newToken.type}, 55 - logger.DebugContext.notifications, 56 - ) 57 - try { 58 - await getAgent().api.app.bsky.notification.registerPush({ 59 - serviceDid: SERVICE_DID(account.service), 60 - platform: devicePlatform, 61 - token: newToken.data, 62 - appId: 'xyz.blueskyweb.app', 63 - }) 64 - logger.debug( 65 - 'Notifications: Sent push token (event)', 66 - { 67 - tokenType: newToken.type, 68 - token: newToken.data, 69 - }, 70 - logger.DebugContext.notifications, 71 - ) 72 - } catch (error) { 73 - logger.error('Notifications: Failed to set push token', {message: error}) 40 + export function useNotificationsRegistration() { 41 + const [currentPermissions] = Notifications.usePermissions() 42 + const {getAgent} = useAgent() 43 + const {currentAccount} = useSession() 44 + 45 + React.useEffect(() => { 46 + if (!currentAccount || !currentPermissions?.granted) { 47 + return 74 48 } 75 - }) 76 - return () => { 77 - sub.remove() 78 - } 49 + 50 + // Whenever we all `getDevicePushTokenAsync()`, a change event will be fired below 51 + Notifications.getDevicePushTokenAsync() 52 + 53 + // According to the Expo docs, there is a chance that the token will change while the app is open in some rare 54 + // cases. This will fire `registerPushToken` whenever that happens. 55 + const subscription = Notifications.addPushTokenListener(async newToken => { 56 + registerPushToken(getAgent, currentAccount, newToken) 57 + }) 58 + 59 + return () => { 60 + subscription.remove() 61 + } 62 + }, [currentAccount, currentPermissions?.granted, getAgent]) 63 + } 64 + 65 + export function useRequestNotificationsPermission() { 66 + const gate = useGate() 67 + const [currentPermissions] = Notifications.usePermissions() 68 + 69 + return React.useCallback( 70 + async (context: 'StartOnboarding' | 'AfterOnboarding') => { 71 + if ( 72 + !isNative || 73 + currentPermissions?.status === 'granted' || 74 + (currentPermissions?.status === 'denied' && 75 + !currentPermissions?.canAskAgain) 76 + ) { 77 + return 78 + } 79 + if ( 80 + context === 'StartOnboarding' && 81 + gate('request_notifications_permission_after_onboarding') 82 + ) { 83 + return 84 + } 85 + if ( 86 + context === 'AfterOnboarding' && 87 + !gate('request_notifications_permission_after_onboarding') 88 + ) { 89 + return 90 + } 91 + 92 + const res = await Notifications.requestPermissionsAsync() 93 + logEvent('notifications:request', { 94 + status: res.status, 95 + }) 96 + }, 97 + [currentPermissions?.canAskAgain, currentPermissions?.status, gate], 98 + ) 79 99 }
+3
src/lib/statsig/events.ts
··· 16 16 logContext: 'SwitchAccount' | 'Settings' | 'Deactivated' 17 17 } 18 18 'notifications:openApp': {} 19 + 'notifications:request': { 20 + status: 'granted' | 'denied' | 'undetermined' 21 + } 19 22 'state:background': { 20 23 secondsActive: number 21 24 }
+1
src/lib/statsig/gates.ts
··· 5 5 | 'disable_poll_on_discover_v2' 6 6 | 'dms' 7 7 | 'reduced_onboarding_and_home_algo' 8 + | 'request_notifications_permission_after_onboarding' 8 9 | 'show_follow_back_label_v2' 9 10 | 'start_session_with_following_v2' 10 11 | 'test_gate_1'
+11 -1
src/screens/Onboarding/StepInterests/index.tsx
··· 5 5 import {useQuery} from '@tanstack/react-query' 6 6 7 7 import {useAnalytics} from '#/lib/analytics/analytics' 8 - import {logEvent} from '#/lib/statsig/statsig' 8 + import {logEvent, useGate} from '#/lib/statsig/statsig' 9 9 import {capitalize} from '#/lib/strings/capitalize' 10 10 import {logger} from '#/logger' 11 11 import {useAgent} from '#/state/session' 12 12 import {useOnboardingDispatch} from '#/state/shell' 13 + import {useRequestNotificationsPermission} from 'lib/notifications/notifications' 13 14 import { 14 15 DescriptionText, 15 16 OnboardingControls, ··· 33 34 const t = useTheme() 34 35 const {gtMobile} = useBreakpoints() 35 36 const {track} = useAnalytics() 37 + const gate = useGate() 38 + const requestNotificationsPermission = useRequestNotificationsPermission() 39 + 36 40 const {state, dispatch, interestsDisplayNames} = React.useContext(Context) 37 41 const [saving, setSaving] = React.useState(false) 38 42 const [interests, setInterests] = React.useState<string[]>( ··· 128 132 track('OnboardingV2:Begin') 129 133 track('OnboardingV2:StepInterests:Start') 130 134 }, [track]) 135 + 136 + React.useEffect(() => { 137 + if (!gate('reduced_onboarding_and_home_algo')) { 138 + requestNotificationsPermission('StartOnboarding') 139 + } 140 + }, [gate, requestNotificationsPermission]) 131 141 132 142 const title = isError ? ( 133 143 <Trans>Oh no! Something went wrong.</Trans>
+13 -1
src/screens/Onboarding/StepProfile/index.tsx
··· 10 10 import {useLingui} from '@lingui/react' 11 11 12 12 import {useAnalytics} from '#/lib/analytics/analytics' 13 - import {logEvent} from '#/lib/statsig/statsig' 13 + import {logEvent, useGate} from '#/lib/statsig/statsig' 14 14 import {usePhotoLibraryPermission} from 'lib/hooks/usePermissions' 15 15 import {compressIfNeeded} from 'lib/media/manip' 16 16 import {openCropper} from 'lib/media/picker' 17 17 import {getDataUriSize} from 'lib/media/util' 18 + import {useRequestNotificationsPermission} from 'lib/notifications/notifications' 18 19 import {isNative, isWeb} from 'platform/detection' 19 20 import { 20 21 DescriptionText, ··· 69 70 const {gtMobile} = useBreakpoints() 70 71 const {track} = useAnalytics() 71 72 const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() 73 + const gate = useGate() 74 + const requestNotificationsPermission = useRequestNotificationsPermission() 75 + 72 76 const creatorControl = Dialog.useDialogControl() 73 77 const [error, setError] = React.useState('') 74 78 ··· 85 89 React.useEffect(() => { 86 90 track('OnboardingV2:StepProfile:Start') 87 91 }, [track]) 92 + 93 + React.useEffect(() => { 94 + // We have an experiment running for redueced onboarding, where this screen shows up as the first in onboarding. 95 + // We only want to request permissions when that gate is actually active to prevent pollution 96 + if (gate('reduced_onboarding_and_home_algo')) { 97 + requestNotificationsPermission('StartOnboarding') 98 + } 99 + }, [gate, requestNotificationsPermission]) 88 100 89 101 const openPicker = React.useCallback( 90 102 async (opts?: ImagePickerOptions) => {
+9 -2
src/view/screens/Home.tsx
··· 20 20 } from '#/state/shell' 21 21 import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed' 22 22 import {useOTAUpdates} from 'lib/hooks/useOTAUpdates' 23 + import {useRequestNotificationsPermission} from 'lib/notifications/notifications' 23 24 import {HomeTabNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' 24 25 import {FeedPage} from 'view/com/feeds/FeedPage' 25 26 import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager' ··· 58 59 preferences: UsePreferencesQueryResponse 59 60 pinnedFeedInfos: SavedFeedSourceInfo[] 60 61 }) { 61 - useOTAUpdates() 62 + const gate = useGate() 63 + const requestNotificationsPermission = useRequestNotificationsPermission() 64 + 62 65 const allFeeds = React.useMemo( 63 66 () => pinnedFeedInfos.map(f => f.feedDescriptor), 64 67 [pinnedFeedInfos], ··· 70 73 const selectedFeed = allFeeds[selectedIndex] 71 74 72 75 useSetTitle(pinnedFeedInfos[selectedIndex]?.displayName) 76 + useOTAUpdates() 77 + 78 + React.useEffect(() => { 79 + requestNotificationsPermission('AfterOnboarding') 80 + }, [requestNotificationsPermission]) 73 81 74 82 const pagerRef = React.useRef<PagerRef>(null) 75 83 const lastPagerReportedIndexRef = React.useRef(selectedIndex) ··· 109 117 }), 110 118 ) 111 119 112 - const gate = useGate() 113 120 const mode = useMinimalShellMode() 114 121 const {isMobile} = useWebMediaQueries() 115 122 useFocusEffect(
+4 -19
src/view/shell/index.tsx
··· 13 13 import {StatusBar} from 'expo-status-bar' 14 14 import {useNavigationState} from '@react-navigation/native' 15 15 16 - import {useAgent, useSession} from '#/state/session' 16 + import {useSession} from '#/state/session' 17 17 import { 18 18 useIsDrawerOpen, 19 19 useIsDrawerSwipeDisabled, ··· 22 22 import {useCloseAnyActiveElement} from '#/state/util' 23 23 import {useNotificationsHandler} from 'lib/hooks/useNotificationHandler' 24 24 import {usePalette} from 'lib/hooks/usePalette' 25 - import * as notifications from 'lib/notifications/notifications' 25 + import {useNotificationsRegistration} from 'lib/notifications/notifications' 26 26 import {isStateAtTabRoot} from 'lib/routes/helpers' 27 27 import {useTheme} from 'lib/ThemeContext' 28 28 import {isAndroid} from 'platform/detection' ··· 57 57 [setIsDrawerOpen], 58 58 ) 59 59 const canGoBack = useNavigationState(state => !isStateAtTabRoot(state)) 60 - const {hasSession, currentAccount} = useSession() 61 - const {getAgent} = useAgent() 60 + const {hasSession} = useSession() 62 61 const closeAnyActiveElement = useCloseAnyActiveElement() 63 62 const {importantForAccessibility} = useDialogStateContext() 64 - // start undefined 65 - const currentAccountDid = React.useRef<string | undefined>(undefined) 66 63 64 + useNotificationsRegistration() 67 65 useNotificationsHandler() 68 66 69 67 React.useEffect(() => { ··· 77 75 listener.remove() 78 76 } 79 77 }, [closeAnyActiveElement]) 80 - 81 - React.useEffect(() => { 82 - // only runs when did changes 83 - if (currentAccount && currentAccountDid.current !== currentAccount.did) { 84 - currentAccountDid.current = currentAccount.did 85 - notifications.requestPermissionsAndRegisterToken(getAgent, currentAccount) 86 - const unsub = notifications.registerTokenChangeHandler( 87 - getAgent, 88 - currentAccount, 89 - ) 90 - return unsub 91 - } 92 - }, [currentAccount, getAgent]) 93 78 94 79 return ( 95 80 <>