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
zio/stable
Hailey 2024-05-13 09:19:35 -07:00 committed by GitHub
parent 63b38b413d
commit d3406c89cf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 105 additions and 67 deletions

View File

@ -1,27 +1,22 @@
import React from 'react'
import * as Notifications from 'expo-notifications' import * as Notifications from 'expo-notifications'
import {BskyAgent} from '@atproto/api' import {BskyAgent} from '@atproto/api'
import {logger} from '#/logger' import {logger} from '#/logger'
import {SessionAccount} from '#/state/session' import {SessionAccount, useAgent, useSession} from '#/state/session'
import {devicePlatform} from 'platform/detection' import {logEvent, useGate} from 'lib/statsig/statsig'
import {devicePlatform, isNative} from 'platform/detection'
const SERVICE_DID = (serviceUrl?: string) => const SERVICE_DID = (serviceUrl?: string) =>
serviceUrl?.includes('staging') serviceUrl?.includes('staging')
? 'did:web:api.staging.bsky.dev' ? 'did:web:api.staging.bsky.dev'
: 'did:web:api.bsky.app' : 'did:web:api.bsky.app'
export async function requestPermissionsAndRegisterToken( async function registerPushToken(
getAgent: () => BskyAgent, getAgent: () => BskyAgent,
account: SessionAccount, account: SessionAccount,
token: Notifications.DevicePushToken,
) { ) {
// request notifications permission once the user has logged in
const perms = await Notifications.getPermissionsAsync()
if (!perms.granted) {
await Notifications.requestPermissionsAsync()
}
// register the push token with the server
const token = await Notifications.getDevicePushTokenAsync()
try { try {
await getAgent().api.app.bsky.notification.registerPush({ await getAgent().api.app.bsky.notification.registerPush({
serviceDid: SERVICE_DID(account.service), serviceDid: SERVICE_DID(account.service),
@ -42,38 +37,63 @@ export async function requestPermissionsAndRegisterToken(
} }
} }
export function registerTokenChangeHandler( export function useNotificationsRegistration() {
getAgent: () => BskyAgent, const [currentPermissions] = Notifications.usePermissions()
account: SessionAccount, const {getAgent} = useAgent()
): () => void { const {currentAccount} = useSession()
// listens for new changes to the push token
// 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. React.useEffect(() => {
const sub = Notifications.addPushTokenListener(async newToken => { if (!currentAccount || !currentPermissions?.granted) {
logger.debug( return
'Notifications: Push token changed',
{tokenType: newToken.data, token: newToken.type},
logger.DebugContext.notifications,
)
try {
await getAgent().api.app.bsky.notification.registerPush({
serviceDid: SERVICE_DID(account.service),
platform: devicePlatform,
token: newToken.data,
appId: 'xyz.blueskyweb.app',
})
logger.debug(
'Notifications: Sent push token (event)',
{
tokenType: newToken.type,
token: newToken.data,
},
logger.DebugContext.notifications,
)
} catch (error) {
logger.error('Notifications: Failed to set push token', {message: error})
} }
})
return () => { // Whenever we all `getDevicePushTokenAsync()`, a change event will be fired below
sub.remove() Notifications.getDevicePushTokenAsync()
}
// According to the Expo docs, there is a chance that the token will change while the app is open in some rare
// cases. This will fire `registerPushToken` whenever that happens.
const subscription = Notifications.addPushTokenListener(async newToken => {
registerPushToken(getAgent, currentAccount, newToken)
})
return () => {
subscription.remove()
}
}, [currentAccount, currentPermissions?.granted, getAgent])
}
export function useRequestNotificationsPermission() {
const gate = useGate()
const [currentPermissions] = Notifications.usePermissions()
return React.useCallback(
async (context: 'StartOnboarding' | 'AfterOnboarding') => {
if (
!isNative ||
currentPermissions?.status === 'granted' ||
(currentPermissions?.status === 'denied' &&
!currentPermissions?.canAskAgain)
) {
return
}
if (
context === 'StartOnboarding' &&
gate('request_notifications_permission_after_onboarding')
) {
return
}
if (
context === 'AfterOnboarding' &&
!gate('request_notifications_permission_after_onboarding')
) {
return
}
const res = await Notifications.requestPermissionsAsync()
logEvent('notifications:request', {
status: res.status,
})
},
[currentPermissions?.canAskAgain, currentPermissions?.status, gate],
)
} }

View File

@ -16,6 +16,9 @@ export type LogEvents = {
logContext: 'SwitchAccount' | 'Settings' | 'Deactivated' logContext: 'SwitchAccount' | 'Settings' | 'Deactivated'
} }
'notifications:openApp': {} 'notifications:openApp': {}
'notifications:request': {
status: 'granted' | 'denied' | 'undetermined'
}
'state:background': { 'state:background': {
secondsActive: number secondsActive: number
} }

View File

@ -5,6 +5,7 @@ export type Gate =
| 'disable_poll_on_discover_v2' | 'disable_poll_on_discover_v2'
| 'dms' | 'dms'
| 'reduced_onboarding_and_home_algo' | 'reduced_onboarding_and_home_algo'
| 'request_notifications_permission_after_onboarding'
| 'show_follow_back_label_v2' | 'show_follow_back_label_v2'
| 'start_session_with_following_v2' | 'start_session_with_following_v2'
| 'test_gate_1' | 'test_gate_1'

View File

@ -5,11 +5,12 @@ import {useLingui} from '@lingui/react'
import {useQuery} from '@tanstack/react-query' import {useQuery} from '@tanstack/react-query'
import {useAnalytics} from '#/lib/analytics/analytics' import {useAnalytics} from '#/lib/analytics/analytics'
import {logEvent} from '#/lib/statsig/statsig' import {logEvent, useGate} from '#/lib/statsig/statsig'
import {capitalize} from '#/lib/strings/capitalize' import {capitalize} from '#/lib/strings/capitalize'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {useOnboardingDispatch} from '#/state/shell' import {useOnboardingDispatch} from '#/state/shell'
import {useRequestNotificationsPermission} from 'lib/notifications/notifications'
import { import {
DescriptionText, DescriptionText,
OnboardingControls, OnboardingControls,
@ -33,6 +34,9 @@ export function StepInterests() {
const t = useTheme() const t = useTheme()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const {track} = useAnalytics() const {track} = useAnalytics()
const gate = useGate()
const requestNotificationsPermission = useRequestNotificationsPermission()
const {state, dispatch, interestsDisplayNames} = React.useContext(Context) const {state, dispatch, interestsDisplayNames} = React.useContext(Context)
const [saving, setSaving] = React.useState(false) const [saving, setSaving] = React.useState(false)
const [interests, setInterests] = React.useState<string[]>( const [interests, setInterests] = React.useState<string[]>(
@ -129,6 +133,12 @@ export function StepInterests() {
track('OnboardingV2:StepInterests:Start') track('OnboardingV2:StepInterests:Start')
}, [track]) }, [track])
React.useEffect(() => {
if (!gate('reduced_onboarding_and_home_algo')) {
requestNotificationsPermission('StartOnboarding')
}
}, [gate, requestNotificationsPermission])
const title = isError ? ( const title = isError ? (
<Trans>Oh no! Something went wrong.</Trans> <Trans>Oh no! Something went wrong.</Trans>
) : ( ) : (

View File

@ -10,11 +10,12 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useAnalytics} from '#/lib/analytics/analytics' import {useAnalytics} from '#/lib/analytics/analytics'
import {logEvent} from '#/lib/statsig/statsig' import {logEvent, useGate} from '#/lib/statsig/statsig'
import {usePhotoLibraryPermission} from 'lib/hooks/usePermissions' import {usePhotoLibraryPermission} from 'lib/hooks/usePermissions'
import {compressIfNeeded} from 'lib/media/manip' import {compressIfNeeded} from 'lib/media/manip'
import {openCropper} from 'lib/media/picker' import {openCropper} from 'lib/media/picker'
import {getDataUriSize} from 'lib/media/util' import {getDataUriSize} from 'lib/media/util'
import {useRequestNotificationsPermission} from 'lib/notifications/notifications'
import {isNative, isWeb} from 'platform/detection' import {isNative, isWeb} from 'platform/detection'
import { import {
DescriptionText, DescriptionText,
@ -69,6 +70,9 @@ export function StepProfile() {
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const {track} = useAnalytics() const {track} = useAnalytics()
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
const gate = useGate()
const requestNotificationsPermission = useRequestNotificationsPermission()
const creatorControl = Dialog.useDialogControl() const creatorControl = Dialog.useDialogControl()
const [error, setError] = React.useState('') const [error, setError] = React.useState('')
@ -86,6 +90,14 @@ export function StepProfile() {
track('OnboardingV2:StepProfile:Start') track('OnboardingV2:StepProfile:Start')
}, [track]) }, [track])
React.useEffect(() => {
// We have an experiment running for redueced onboarding, where this screen shows up as the first in onboarding.
// We only want to request permissions when that gate is actually active to prevent pollution
if (gate('reduced_onboarding_and_home_algo')) {
requestNotificationsPermission('StartOnboarding')
}
}, [gate, requestNotificationsPermission])
const openPicker = React.useCallback( const openPicker = React.useCallback(
async (opts?: ImagePickerOptions) => { async (opts?: ImagePickerOptions) => {
const response = await launchImageLibraryAsync({ const response = await launchImageLibraryAsync({

View File

@ -20,6 +20,7 @@ import {
} from '#/state/shell' } from '#/state/shell'
import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed' import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed'
import {useOTAUpdates} from 'lib/hooks/useOTAUpdates' import {useOTAUpdates} from 'lib/hooks/useOTAUpdates'
import {useRequestNotificationsPermission} from 'lib/notifications/notifications'
import {HomeTabNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' import {HomeTabNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
import {FeedPage} from 'view/com/feeds/FeedPage' import {FeedPage} from 'view/com/feeds/FeedPage'
import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager' import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager'
@ -58,7 +59,9 @@ function HomeScreenReady({
preferences: UsePreferencesQueryResponse preferences: UsePreferencesQueryResponse
pinnedFeedInfos: SavedFeedSourceInfo[] pinnedFeedInfos: SavedFeedSourceInfo[]
}) { }) {
useOTAUpdates() const gate = useGate()
const requestNotificationsPermission = useRequestNotificationsPermission()
const allFeeds = React.useMemo( const allFeeds = React.useMemo(
() => pinnedFeedInfos.map(f => f.feedDescriptor), () => pinnedFeedInfos.map(f => f.feedDescriptor),
[pinnedFeedInfos], [pinnedFeedInfos],
@ -70,6 +73,11 @@ function HomeScreenReady({
const selectedFeed = allFeeds[selectedIndex] const selectedFeed = allFeeds[selectedIndex]
useSetTitle(pinnedFeedInfos[selectedIndex]?.displayName) useSetTitle(pinnedFeedInfos[selectedIndex]?.displayName)
useOTAUpdates()
React.useEffect(() => {
requestNotificationsPermission('AfterOnboarding')
}, [requestNotificationsPermission])
const pagerRef = React.useRef<PagerRef>(null) const pagerRef = React.useRef<PagerRef>(null)
const lastPagerReportedIndexRef = React.useRef(selectedIndex) const lastPagerReportedIndexRef = React.useRef(selectedIndex)
@ -109,7 +117,6 @@ function HomeScreenReady({
}), }),
) )
const gate = useGate()
const mode = useMinimalShellMode() const mode = useMinimalShellMode()
const {isMobile} = useWebMediaQueries() const {isMobile} = useWebMediaQueries()
useFocusEffect( useFocusEffect(

View File

@ -13,7 +13,7 @@ import * as NavigationBar from 'expo-navigation-bar'
import {StatusBar} from 'expo-status-bar' import {StatusBar} from 'expo-status-bar'
import {useNavigationState} from '@react-navigation/native' import {useNavigationState} from '@react-navigation/native'
import {useAgent, useSession} from '#/state/session' import {useSession} from '#/state/session'
import { import {
useIsDrawerOpen, useIsDrawerOpen,
useIsDrawerSwipeDisabled, useIsDrawerSwipeDisabled,
@ -22,7 +22,7 @@ import {
import {useCloseAnyActiveElement} from '#/state/util' import {useCloseAnyActiveElement} from '#/state/util'
import {useNotificationsHandler} from 'lib/hooks/useNotificationHandler' import {useNotificationsHandler} from 'lib/hooks/useNotificationHandler'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import * as notifications from 'lib/notifications/notifications' import {useNotificationsRegistration} from 'lib/notifications/notifications'
import {isStateAtTabRoot} from 'lib/routes/helpers' import {isStateAtTabRoot} from 'lib/routes/helpers'
import {useTheme} from 'lib/ThemeContext' import {useTheme} from 'lib/ThemeContext'
import {isAndroid} from 'platform/detection' import {isAndroid} from 'platform/detection'
@ -57,13 +57,11 @@ function ShellInner() {
[setIsDrawerOpen], [setIsDrawerOpen],
) )
const canGoBack = useNavigationState(state => !isStateAtTabRoot(state)) const canGoBack = useNavigationState(state => !isStateAtTabRoot(state))
const {hasSession, currentAccount} = useSession() const {hasSession} = useSession()
const {getAgent} = useAgent()
const closeAnyActiveElement = useCloseAnyActiveElement() const closeAnyActiveElement = useCloseAnyActiveElement()
const {importantForAccessibility} = useDialogStateContext() const {importantForAccessibility} = useDialogStateContext()
// start undefined
const currentAccountDid = React.useRef<string | undefined>(undefined)
useNotificationsRegistration()
useNotificationsHandler() useNotificationsHandler()
React.useEffect(() => { React.useEffect(() => {
@ -78,19 +76,6 @@ function ShellInner() {
} }
}, [closeAnyActiveElement]) }, [closeAnyActiveElement])
React.useEffect(() => {
// only runs when did changes
if (currentAccount && currentAccountDid.current !== currentAccount.did) {
currentAccountDid.current = currentAccount.did
notifications.requestPermissionsAndRegisterToken(getAgent, currentAccount)
const unsub = notifications.registerTokenChangeHandler(
getAgent,
currentAccount,
)
return unsub
}
}, [currentAccount, getAgent])
return ( return (
<> <>
<Animated.View <Animated.View