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 * nitzio/stable
parent
63b38b413d
commit
d3406c89cf
|
@ -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],
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue