Files
bsky-social-app/src/lib/notifications/notifications.ts
T
Eric Bailey 25a1ed1209 [APP-1782] Analytics migration (#9734)
* WIP

* Clean up growthbook code, integrate into init and sessions

* Move everything out of React

* Add metrics client

* Move to separate file

* Shared metadata cache

* Ensure we update metadata when session ID changes

* Ensure userMetadata is cleared when logging out

* WIP revamp

* Integrate feature gates into analytics context

* Clean up old code

* Fix useMeta util

* Some comments and cleanup

* Add logger to base analytics context

* Refactor current route handling

* Rip out LogEvent from navigation

* Update tracking endpoint

* Migrate toClout

* Clear out statsig client

* Add todo, reset logger readme

* Ope fix statsig noop

* Refactor logging in feed-feedback, add debug logging to metrics client

* Remove LogEvents alias for Metrics

* Prefer root package export

* Remove Metrics alias from logger

* [APP-1782] Migrate to new analytics APIs (#9735)

* Migrate logEvent to useAnalytics

* Migrate logger.metric to useAnalytics

* Migrate tricky spot, fix types

* Migrate remaining tricky spot

* Missed one

* Remove metric() from logger

* Migrate useGate to useAnalytics

* Remove all other StatSig mentions

* Update event payload

* Update logger tests

* Mock expo method

* Fix session ID bug

* Add session ID test

* Add test for metrics client

* Clarify intent

* Clean up core analytics file

* Clean up the call once utils

* Fix TODO

* Fix TODO

* Fix TODO

* Fix TODO

* Fix TODO

* Remove debug code

* Fix navigation context

* OK nav context is not working, todo

* Checkpoint: works but feels hacky

* Fix navigation context issue

* Improve feature API

* Improve metric logging

* Update logger tests
2026-01-22 15:33:45 -06:00

326 lines
9.5 KiB
TypeScript

import {useCallback, useEffect} from 'react'
import {Platform} from 'react-native'
import * as Notifications from 'expo-notifications'
import {getBadgeCountAsync, setBadgeCountAsync} from 'expo-notifications'
import {type AppBskyNotificationRegisterPush, type AtpAgent} from '@atproto/api'
import debounce from 'lodash.debounce'
import {
BLUESKY_NOTIF_SERVICE_HEADERS,
PUBLIC_APPVIEW_DID,
PUBLIC_STAGING_APPVIEW_DID,
} from '#/lib/constants'
import {logger as notyLogger} from '#/lib/notifications/util'
import {isNetworkError} from '#/lib/strings/errors'
import {type SessionAccount, useAgent, useSession} from '#/state/session'
import BackgroundNotificationHandler from '#/../modules/expo-background-notification-handler'
import {useAgeAssurance} from '#/ageAssurance'
import {useAnalytics} from '#/analytics'
import {IS_DEV, IS_NATIVE} from '#/env'
/**
* @private
* Registers the device's push notification token with the Bluesky server.
*/
async function _registerPushToken({
agent,
currentAccount,
token,
extra = {},
}: {
agent: AtpAgent
currentAccount: SessionAccount
token: Notifications.DevicePushToken
extra?: {
ageRestricted?: boolean
}
}) {
try {
const payload: AppBskyNotificationRegisterPush.InputSchema = {
serviceDid: currentAccount.service?.includes('staging')
? PUBLIC_STAGING_APPVIEW_DID
: PUBLIC_APPVIEW_DID,
platform: Platform.OS,
token: token.data,
appId: 'xyz.blueskyweb.app',
ageRestricted: extra.ageRestricted ?? false,
}
notyLogger.debug(`registerPushToken: registering`, {...payload})
await agent.app.bsky.notification.registerPush(payload, {
headers: BLUESKY_NOTIF_SERVICE_HEADERS,
})
notyLogger.debug(`registerPushToken: success`)
} catch (error) {
if (!isNetworkError(error)) {
notyLogger.error(`registerPushToken: failed`, {safeMessage: error})
}
}
}
/**
* @private
* Debounced version of `_registerPushToken` to prevent multiple calls.
*/
const _registerPushTokenDebounced = debounce(_registerPushToken, 100)
/**
* Hook to register the device's push notification token with the Bluesky. If
* the user is not logged in, this will do nothing.
*
* Use this instead of using `_registerPushToken` or
* `_registerPushTokenDebounced` directly.
*/
export function useRegisterPushToken() {
const agent = useAgent()
const {currentAccount} = useSession()
return useCallback(
({
token,
isAgeRestricted,
}: {
token: Notifications.DevicePushToken
isAgeRestricted: boolean
}) => {
if (!currentAccount) return
return _registerPushTokenDebounced({
agent,
currentAccount,
token,
extra: {
ageRestricted: isAgeRestricted,
},
})
},
[agent, currentAccount],
)
}
/**
* Retreive the device's push notification token, if permissions are granted.
*/
async function getPushToken() {
const granted = (await Notifications.getPermissionsAsync()).granted
notyLogger.debug(`getPushToken`, {granted})
if (granted) {
return Notifications.getDevicePushTokenAsync()
}
}
/**
* Hook to get the device push token and register it with the Bluesky server.
* Should only be called after a user has logged-in, since registration is an
* authed endpoint.
*
* N.B. A previous regression in `expo-notifications` caused
* `addPushTokenListener` to not fire on Android after calling
* `getPushToken()`. Therefore, as insurance, we also call
* `registerPushToken` here.
*
* Because `registerPushToken` is debounced, even if the the listener _does_
* fire, it's OK to also call `registerPushToken` below since only a single
* call will be made to the server (ideally). This does race the listener (if
* it fires), so there's a possibility that multiple calls will be made, but
* that is acceptable.
*
* @see https://github.com/expo/expo/issues/28656
* @see https://github.com/expo/expo/issues/29909
* @see https://github.com/bluesky-social/social-app/pull/4467
*/
export function useGetAndRegisterPushToken() {
const aa = useAgeAssurance()
const registerPushToken = useRegisterPushToken()
return useCallback(
async ({
isAgeRestricted: isAgeRestrictedOverride,
}: {
isAgeRestricted?: boolean
} = {}) => {
if (!IS_NATIVE || IS_DEV) return
/**
* This will also fire the listener added via `addPushTokenListener`. That
* listener also handles registration.
*/
const token = await getPushToken()
notyLogger.debug(`useGetAndRegisterPushToken`, {
token: token ?? 'undefined',
})
if (token) {
/**
* The listener should have registered the token already, but just in
* case, call the debounced function again.
*/
registerPushToken({
token,
isAgeRestricted:
isAgeRestrictedOverride ?? aa.state.access !== aa.Access.Full,
})
}
return token
},
[registerPushToken, aa],
)
}
/**
* Hook to register the device's push notification token with the Bluesky
* server, as well as listen for push token updates, should they occurr.
*
* Registered via the shell, which wraps the navigation stack, meaning if we
* have a current account, this handling will be registered and ready to go.
*/
export function useNotificationsRegistration() {
const {currentAccount} = useSession()
const registerPushToken = useRegisterPushToken()
const getAndRegisterPushToken = useGetAndRegisterPushToken()
const aa = useAgeAssurance()
useEffect(() => {
/**
* We want this to init right away _after_ we have a logged in user, and
* _after_ we've loaded their age assurance state.
*/
if (!currentAccount) return
notyLogger.debug(`useNotificationsRegistration`)
/**
* Init push token, if permissions are granted already. If they weren't,
* they'll be requested by the `useRequestNotificationsPermission` hook
* below.
*/
getAndRegisterPushToken()
/**
* Register the push token with the Bluesky server, whenever it changes.
* This is also fired any time `getDevicePushTokenAsync` is called.
*
* Since this is registered immediately after `getAndRegisterPushToken`, it
* should also detect that getter and be fired almost immediately after this.
*
* 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.
*
* @see https://docs.expo.dev/versions/latest/sdk/notifications/#addpushtokenlistenerlistener
*/
const subscription = Notifications.addPushTokenListener(async token => {
registerPushToken({
token,
isAgeRestricted: aa.state.access !== aa.Access.Full,
})
notyLogger.debug(`addPushTokenListener callback`, {token})
})
return () => {
subscription.remove()
}
}, [currentAccount, getAndRegisterPushToken, registerPushToken, aa])
}
export function useRequestNotificationsPermission() {
const ax = useAnalytics()
const {currentAccount} = useSession()
const getAndRegisterPushToken = useGetAndRegisterPushToken()
return async (
context: 'StartOnboarding' | 'AfterOnboarding' | 'Login' | 'Home',
) => {
const permissions = await Notifications.getPermissionsAsync()
if (
!IS_NATIVE ||
permissions?.status === 'granted' ||
(permissions?.status === 'denied' && !permissions.canAskAgain)
) {
return
}
if (context === 'AfterOnboarding') {
return
}
if (context === 'Home' && !currentAccount) {
return
}
const res = await Notifications.requestPermissionsAsync()
ax.metric(`notifications:request`, {
context: context,
status: res.status,
})
if (res.granted) {
if (currentAccount) {
/**
* If we have an account in scope, we can safely call
* `getAndRegisterPushToken`.
*/
getAndRegisterPushToken()
} else {
/**
* Right after login, `currentAccount` in this scope will be undefined,
* but calling `getPushToken` will result in `addPushTokenListener`
* listeners being called, which will handle the registration with the
* Bluesky server.
*/
getPushToken()
}
}
}
}
export async function decrementBadgeCount(by: number) {
if (!IS_NATIVE) return
let count = await getBadgeCountAsync()
count -= by
if (count < 0) {
count = 0
}
await BackgroundNotificationHandler.setBadgeCountAsync(count)
await setBadgeCountAsync(count)
}
export async function resetBadgeCount() {
await BackgroundNotificationHandler.setBadgeCountAsync(0)
await setBadgeCountAsync(0)
}
export async function unregisterPushToken(agents: AtpAgent[]) {
if (!IS_NATIVE) return
try {
const token = await getPushToken()
if (token) {
for (const agent of agents) {
await agent.app.bsky.notification.unregisterPush(
{
serviceDid: agent.serviceUrl.hostname.includes('staging')
? PUBLIC_STAGING_APPVIEW_DID
: PUBLIC_APPVIEW_DID,
platform: Platform.OS,
token: token.data,
appId: 'xyz.blueskyweb.app',
},
{
headers: BLUESKY_NOTIF_SERVICE_HEADERS,
},
)
notyLogger.debug(`Push token unregistered for ${agent.session?.handle}`)
}
} else {
notyLogger.debug('Tried to unregister push token, but could not find one')
}
} catch (error) {
notyLogger.debug('Failed to unregister push token', {message: error})
}
}