Bluesky app fork with some witchin' additions 馃挮
witchsky.app
bluesky
fork
client
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 {
9 BLUESKY_NOTIF_SERVICE_HEADERS,
10 PUBLIC_APPVIEW_DID,
11 PUBLIC_STAGING_APPVIEW_DID,
12} from '#/lib/constants'
13import {logger as notyLogger} from '#/lib/notifications/util'
14import {isNetworkError} from '#/lib/strings/errors'
15import {type SessionAccount, useAgent, useSession} from '#/state/session'
16import BackgroundNotificationHandler from '#/../modules/expo-background-notification-handler'
17import {useAgeAssurance} from '#/ageAssurance'
18import {useAnalytics} from '#/analytics'
19import {IS_DEV, IS_NATIVE} from '#/env'
20
21/**
22 * @private
23 * Registers the device's push notification token with the Bluesky server.
24 */
25async function _registerPushToken({
26 agent,
27 currentAccount,
28 token,
29 // extra = {},
30}: {
31 agent: AtpAgent
32 currentAccount: SessionAccount
33 token: Notifications.DevicePushToken
34 extra?: {
35 ageRestricted?: boolean
36 }
37}) {
38 try {
39 const payload: AppBskyNotificationRegisterPush.InputSchema = {
40 serviceDid: currentAccount.service?.includes('staging')
41 ? PUBLIC_STAGING_APPVIEW_DID
42 : PUBLIC_APPVIEW_DID,
43 platform: Platform.OS,
44 token: token.data,
45 appId: 'app.witchsky',
46 // ageRestricted: extra.ageRestricted ?? false,
47 }
48
49 notyLogger.debug(`registerPushToken: registering`, {...payload})
50
51 await agent.app.bsky.notification.registerPush(payload, {
52 headers: BLUESKY_NOTIF_SERVICE_HEADERS,
53 })
54
55 notyLogger.debug(`registerPushToken: success`)
56 } catch (error) {
57 if (!isNetworkError(error)) {
58 notyLogger.error(`registerPushToken: failed`, {safeMessage: error})
59 }
60 }
61}
62
63/**
64 * @private
65 * Debounced version of `_registerPushToken` to prevent multiple calls.
66 */
67const _registerPushTokenDebounced = debounce(_registerPushToken, 100)
68
69/**
70 * Hook to register the device's push notification token with the Bluesky. If
71 * the user is not logged in, this will do nothing.
72 *
73 * Use this instead of using `_registerPushToken` or
74 * `_registerPushTokenDebounced` directly.
75 */
76export function useRegisterPushToken() {
77 const agent = useAgent()
78 const {currentAccount} = useSession()
79
80 return useCallback(
81 ({
82 token,
83 isAgeRestricted,
84 }: {
85 token: Notifications.DevicePushToken
86 isAgeRestricted: boolean
87 }) => {
88 if (!currentAccount) return
89 return _registerPushTokenDebounced({
90 agent,
91 currentAccount,
92 token,
93 extra: {
94 ageRestricted: isAgeRestricted,
95 },
96 })
97 },
98 [agent, currentAccount],
99 )
100}
101
102/**
103 * Retreive the device's push notification token, if permissions are granted.
104 */
105async function getPushToken() {
106 const granted = (await Notifications.getPermissionsAsync()).granted
107 notyLogger.debug(`getPushToken`, {granted})
108 if (granted) {
109 return Notifications.getDevicePushTokenAsync()
110 }
111}
112
113/**
114 * Hook to get the device push token and register it with the Bluesky server.
115 * Should only be called after a user has logged-in, since registration is an
116 * authed endpoint.
117 *
118 * N.B. A previous regression in `expo-notifications` caused
119 * `addPushTokenListener` to not fire on Android after calling
120 * `getPushToken()`. Therefore, as insurance, we also call
121 * `registerPushToken` here.
122 *
123 * Because `registerPushToken` is debounced, even if the the listener _does_
124 * fire, it's OK to also call `registerPushToken` below since only a single
125 * call will be made to the server (ideally). This does race the listener (if
126 * it fires), so there's a possibility that multiple calls will be made, but
127 * that is acceptable.
128 *
129 * @see https://github.com/expo/expo/issues/28656
130 * @see https://github.com/expo/expo/issues/29909
131 * @see https://github.com/bluesky-social/social-app/pull/4467
132 */
133export function useGetAndRegisterPushToken() {
134 const aa = useAgeAssurance()
135 const registerPushToken = useRegisterPushToken()
136 return useCallback(
137 async ({
138 isAgeRestricted: isAgeRestrictedOverride,
139 }: {
140 isAgeRestricted?: boolean
141 } = {}) => {
142 if (!IS_NATIVE || IS_DEV) return
143
144 /**
145 * This will also fire the listener added via `addPushTokenListener`. That
146 * listener also handles registration.
147 */
148 const token = await getPushToken()
149
150 notyLogger.debug(`useGetAndRegisterPushToken`, {
151 token: token ?? 'undefined',
152 })
153
154 if (token) {
155 /**
156 * The listener should have registered the token already, but just in
157 * case, call the debounced function again.
158 */
159 registerPushToken({
160 token,
161 isAgeRestricted:
162 isAgeRestrictedOverride ?? aa.state.access !== aa.Access.Full,
163 })
164 }
165
166 return token
167 },
168 [registerPushToken, aa],
169 )
170}
171
172/**
173 * Hook to register the device's push notification token with the Bluesky
174 * server, as well as listen for push token updates, should they occurr.
175 *
176 * Registered via the shell, which wraps the navigation stack, meaning if we
177 * have a current account, this handling will be registered and ready to go.
178 */
179export function useNotificationsRegistration() {
180 const {currentAccount} = useSession()
181 const registerPushToken = useRegisterPushToken()
182 const getAndRegisterPushToken = useGetAndRegisterPushToken()
183 const aa = useAgeAssurance()
184
185 useEffect(() => {
186 /**
187 * We want this to init right away _after_ we have a logged in user, and
188 * _after_ we've loaded their age assurance state.
189 */
190 if (!currentAccount) return
191
192 notyLogger.debug(`useNotificationsRegistration`)
193
194 /**
195 * Init push token, if permissions are granted already. If they weren't,
196 * they'll be requested by the `useRequestNotificationsPermission` hook
197 * below.
198 */
199 getAndRegisterPushToken()
200
201 /**
202 * Register the push token with the Bluesky server, whenever it changes.
203 * This is also fired any time `getDevicePushTokenAsync` is called.
204 *
205 * Since this is registered immediately after `getAndRegisterPushToken`, it
206 * should also detect that getter and be fired almost immediately after this.
207 *
208 * According to the Expo docs, there is a chance that the token will change
209 * while the app is open in some rare cases. This will fire
210 * `registerPushToken` whenever that happens.
211 *
212 * @see https://docs.expo.dev/versions/latest/sdk/notifications/#addpushtokenlistenerlistener
213 */
214 const subscription = Notifications.addPushTokenListener(async token => {
215 registerPushToken({
216 token,
217 isAgeRestricted: aa.state.access !== aa.Access.Full,
218 })
219 notyLogger.debug(`addPushTokenListener callback`, {token})
220 })
221
222 return () => {
223 subscription.remove()
224 }
225 }, [currentAccount, getAndRegisterPushToken, registerPushToken, aa])
226}
227
228export function useRequestNotificationsPermission() {
229 const ax = useAnalytics()
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 ax.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}