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