mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import {useCallback, useEffect} from 'react'
2import {Platform} from 'react-native'
3import * as Notifications from 'expo-notifications'
4import {getBadgeCountAsync, setBadgeCountAsync} from 'expo-notifications'
5import {type AppBskyNotificationRegisterPush, type AtpAgent} from '@atproto/api'
6import debounce from 'lodash.debounce'
7
8import {PUBLIC_APPVIEW_DID, PUBLIC_STAGING_APPVIEW_DID} from '#/lib/constants'
9import {logger as notyLogger} from '#/lib/notifications/util'
10import {isNative} from '#/platform/detection'
11import {useAgeAssuranceContext} from '#/state/ageAssurance'
12import {type SessionAccount, useAgent, useSession} from '#/state/session'
13import BackgroundNotificationHandler from '#/../modules/expo-background-notification-handler'
14
15/**
16 * @private
17 * Registers the device's push notification token with the Bluesky server.
18 */
19async function _registerPushToken({
20 agent,
21 currentAccount,
22 token,
23 extra = {},
24}: {
25 agent: AtpAgent
26 currentAccount: SessionAccount
27 token: Notifications.DevicePushToken
28 extra?: {
29 ageRestricted?: boolean
30 }
31}) {
32 try {
33 const payload: AppBskyNotificationRegisterPush.InputSchema = {
34 serviceDid: currentAccount.service?.includes('staging')
35 ? PUBLIC_STAGING_APPVIEW_DID
36 : PUBLIC_APPVIEW_DID,
37 platform: Platform.OS,
38 token: token.data,
39 appId: 'xyz.blueskyweb.app',
40 ageRestricted: extra.ageRestricted ?? false,
41 }
42
43 notyLogger.debug(`registerPushToken: registering`, {...payload})
44
45 await agent.app.bsky.notification.registerPush(payload)
46
47 notyLogger.debug(`registerPushToken: success`)
48 } catch (error) {
49 notyLogger.error(`registerPushToken: failed`, {safeMessage: error})
50 }
51}
52
53/**
54 * @private
55 * Debounced version of `_registerPushToken` to prevent multiple calls.
56 */
57const _registerPushTokenDebounced = debounce(_registerPushToken, 100)
58
59/**
60 * Hook to register the device's push notification token with the Bluesky. If
61 * the user is not logged in, this will do nothing.
62 *
63 * Use this instead of using `_registerPushToken` or
64 * `_registerPushTokenDebounced` directly.
65 */
66export function useRegisterPushToken() {
67 const agent = useAgent()
68 const {currentAccount} = useSession()
69
70 return useCallback(
71 ({
72 token,
73 isAgeRestricted,
74 }: {
75 token: Notifications.DevicePushToken
76 isAgeRestricted: boolean
77 }) => {
78 if (!currentAccount) return
79 return _registerPushTokenDebounced({
80 agent,
81 currentAccount,
82 token,
83 extra: {
84 ageRestricted: isAgeRestricted,
85 },
86 })
87 },
88 [agent, currentAccount],
89 )
90}
91
92/**
93 * Retreive the device's push notification token, if permissions are granted.
94 */
95async function getPushToken() {
96 const granted = (await Notifications.getPermissionsAsync()).granted
97 notyLogger.debug(`getPushToken`, {granted})
98 if (granted) {
99 return Notifications.getDevicePushTokenAsync()
100 }
101}
102
103/**
104 * Hook to get the device push token and register it with the Bluesky server.
105 * Should only be called after a user has logged-in, since registration is an
106 * authed endpoint.
107 *
108 * N.B. A previous regression in `expo-notifications` caused
109 * `addPushTokenListener` to not fire on Android after calling
110 * `getPushToken()`. Therefore, as insurance, we also call
111 * `registerPushToken` here.
112 *
113 * Because `registerPushToken` is debounced, even if the the listener _does_
114 * fire, it's OK to also call `registerPushToken` below since only a single
115 * call will be made to the server (ideally). This does race the listener (if
116 * it fires), so there's a possibility that multiple calls will be made, but
117 * that is acceptable.
118 *
119 * @see https://github.com/expo/expo/issues/28656
120 * @see https://github.com/expo/expo/issues/29909
121 * @see https://github.com/bluesky-social/social-app/pull/4467
122 */
123export function useGetAndRegisterPushToken() {
124 const {isAgeRestricted} = useAgeAssuranceContext()
125 const registerPushToken = useRegisterPushToken()
126 return useCallback(
127 async ({
128 isAgeRestricted: isAgeRestrictedOverride,
129 }: {
130 isAgeRestricted?: boolean
131 } = {}) => {
132 if (!isNative) return
133
134 /**
135 * This will also fire the listener added via `addPushTokenListener`. That
136 * listener also handles registration.
137 */
138 const token = await getPushToken()
139
140 notyLogger.debug(`useGetAndRegisterPushToken`, {
141 token: token ?? 'undefined',
142 })
143
144 if (token) {
145 /**
146 * The listener should have registered the token already, but just in
147 * case, call the debounced function again.
148 */
149 registerPushToken({
150 token,
151 isAgeRestricted: isAgeRestrictedOverride ?? isAgeRestricted,
152 })
153 }
154
155 return token
156 },
157 [registerPushToken, isAgeRestricted],
158 )
159}
160
161/**
162 * Hook to register the device's push notification token with the Bluesky
163 * server, as well as listen for push token updates, should they occurr.
164 *
165 * Registered via the shell, which wraps the navigation stack, meaning if we
166 * have a current account, this handling will be registered and ready to go.
167 */
168export function useNotificationsRegistration() {
169 const {currentAccount} = useSession()
170 const registerPushToken = useRegisterPushToken()
171 const getAndRegisterPushToken = useGetAndRegisterPushToken()
172 const {isReady: isAgeRestrictionReady, isAgeRestricted} =
173 useAgeAssuranceContext()
174
175 useEffect(() => {
176 /**
177 * We want this to init right away _after_ we have a logged in user, and
178 * _after_ we've loaded their age assurance state.
179 */
180 if (!currentAccount || !isAgeRestrictionReady) return
181
182 notyLogger.debug(`useNotificationsRegistration`)
183
184 /**
185 * Init push token, if permissions are granted already. If they weren't,
186 * they'll be requested by the `useRequestNotificationsPermission` hook
187 * below.
188 */
189 getAndRegisterPushToken()
190
191 /**
192 * Register the push token with the Bluesky server, whenever it changes.
193 * This is also fired any time `getDevicePushTokenAsync` is called.
194 *
195 * Since this is registered immediately after `getAndRegisterPushToken`, it
196 * should also detect that getter and be fired almost immediately after this.
197 *
198 * According to the Expo docs, there is a chance that the token will change
199 * while the app is open in some rare cases. This will fire
200 * `registerPushToken` whenever that happens.
201 *
202 * @see https://docs.expo.dev/versions/latest/sdk/notifications/#addpushtokenlistenerlistener
203 */
204 const subscription = Notifications.addPushTokenListener(async token => {
205 registerPushToken({token, isAgeRestricted: isAgeRestricted})
206 notyLogger.debug(`addPushTokenListener callback`, {token})
207 })
208
209 return () => {
210 subscription.remove()
211 }
212 }, [
213 currentAccount,
214 getAndRegisterPushToken,
215 registerPushToken,
216 isAgeRestrictionReady,
217 isAgeRestricted,
218 ])
219}
220
221export function useRequestNotificationsPermission() {
222 const {currentAccount} = useSession()
223 const getAndRegisterPushToken = useGetAndRegisterPushToken()
224
225 return async (
226 context: 'StartOnboarding' | 'AfterOnboarding' | 'Login' | 'Home',
227 ) => {
228 const permissions = await Notifications.getPermissionsAsync()
229
230 if (
231 !isNative ||
232 permissions?.status === 'granted' ||
233 (permissions?.status === 'denied' && !permissions.canAskAgain)
234 ) {
235 return
236 }
237 if (context === 'AfterOnboarding') {
238 return
239 }
240 if (context === 'Home' && !currentAccount) {
241 return
242 }
243
244 const res = await Notifications.requestPermissionsAsync()
245
246 notyLogger.metric(`notifications:request`, {
247 context: context,
248 status: res.status,
249 })
250
251 if (res.granted) {
252 if (currentAccount) {
253 /**
254 * If we have an account in scope, we can safely call
255 * `getAndRegisterPushToken`.
256 */
257 getAndRegisterPushToken()
258 } else {
259 /**
260 * Right after login, `currentAccount` in this scope will be undefined,
261 * but calling `getPushToken` will result in `addPushTokenListener`
262 * listeners being called, which will handle the registration with the
263 * Bluesky server.
264 */
265 getPushToken()
266 }
267 }
268 }
269}
270
271export async function decrementBadgeCount(by: number) {
272 if (!isNative) return
273
274 let count = await getBadgeCountAsync()
275 count -= by
276 if (count < 0) {
277 count = 0
278 }
279
280 await BackgroundNotificationHandler.setBadgeCountAsync(count)
281 await setBadgeCountAsync(count)
282}
283
284export async function resetBadgeCount() {
285 await BackgroundNotificationHandler.setBadgeCountAsync(0)
286 await setBadgeCountAsync(0)
287}