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