tangled mirror of catsky-🐱 Soothing soft social-app fork with all the niche toggles! (Unofficial); for issues and PRs please put them on github:NekoDrone/catsky-social

Initial pass at push notifications + some fixes to the session management (#91)

* Fix: test the session during resume to ensure it's valid

* Don't delete sessions for now

* Add notifee and request notif permissions on first login

* Set unread notifications badge on app icon

* Trigger a notifee card on new notifications

* Experimental: use react-native-background-fetch to check for notifications

* Add missing mocks

* Fix to resumeSession()

authored by Paul Frazee and committed by GitHub 869f6c4e 21f5f4de

+6
__mocks__/@notifee/react-native.ts
··· 1 + export default { 2 + requestPermission: jest.fn(), 3 + onForegroundEvent: jest.fn(), 4 + setBadgeCount: jest.fn(), 5 + displayNotification: jest.fn(), 6 + }
+4
__mocks__/react-native-background-fetch.ts
··· 1 + export default { 2 + configure: jest.fn().mockResolvedValue(0), 3 + finish: jest.fn(), 4 + }
+11 -10
__mocks__/state-mock.ts
··· 64 64 isUser: true, 65 65 isScene: false, 66 66 setup: jest.fn().mockResolvedValue({aborted: false}), 67 - refresh: jest.fn(), 67 + refresh: jest.fn().mockResolvedValue({}), 68 68 toggleFollowing: jest.fn().mockResolvedValue({}), 69 69 updateProfile: jest.fn(), 70 70 // unknown required because of the missing private methods: _xLoading, _xIdle, _load, _replaceAll ··· 106 106 isEmpty: false, 107 107 isMember: jest.fn(), 108 108 setup: jest.fn().mockResolvedValue({aborted: false}), 109 - refresh: jest.fn(), 109 + refresh: jest.fn().mockResolvedValue({}), 110 110 loadMore: jest.fn(), 111 111 removeMember: jest.fn(), 112 112 // unknown required because of the missing private methods: _xLoading, _xIdle, _fetch, _replaceAll, _append ··· 149 149 isEmpty: false, 150 150 isMemberOf: jest.fn(), 151 151 setup: jest.fn().mockResolvedValue({aborted: false}), 152 - refresh: jest.fn(), 152 + refresh: jest.fn().mockResolvedValue({}), 153 153 loadMore: jest.fn(), 154 154 // unknown required because of the missing private methods: _xLoading, _xIdle, _fetch, _replaceAll, _append 155 155 } as unknown as MembershipsViewModel ··· 413 413 createdAt: '', 414 414 }), 415 415 fetchAdditionalData: jest.fn(), 416 + toNotifeeOpts: jest.fn(), 416 417 } as NotificationsViewItemModel 417 418 418 419 export const mockedNotificationsStore = { ··· 510 511 }, 511 512 ], 512 513 navigate: jest.fn(), 513 - refresh: jest.fn(), 514 + refresh: jest.fn().mockResolvedValue({}), 514 515 goBack: jest.fn(), 515 516 fixedTabReset: jest.fn(), 516 517 goForward: jest.fn(), ··· 539 540 tabCount: 1, 540 541 isCurrentScreen: jest.fn(), 541 542 navigate: jest.fn(), 542 - refresh: jest.fn(), 543 + refresh: jest.fn().mockResolvedValue({}), 543 544 setTitle: jest.fn(), 544 545 handleLink: jest.fn(), 545 546 switchTo: jest.fn(), ··· 587 588 clear: jest.fn(), 588 589 load: jest.fn(), 589 590 clearNotificationCount: jest.fn(), 590 - fetchStateUpdate: jest.fn(), 591 + fetchNotifications: jest.fn(), 591 592 refreshMemberships: jest.fn(), 592 593 } as MeModel 593 594 ··· 679 680 setSelectedViewIndex: jest.fn(), 680 681 setup: jest.fn().mockResolvedValue({aborted: false}), 681 682 update: jest.fn(), 682 - refresh: jest.fn(), 683 + refresh: jest.fn().mockResolvedValue({}), 683 684 loadMore: jest.fn(), 684 685 } as ProfileUiModel 685 686 ··· 788 789 hasError: false, 789 790 isEmpty: false, 790 791 setup: jest.fn().mockResolvedValue(null), 791 - refresh: jest.fn(), 792 + refresh: jest.fn().mockResolvedValue({}), 792 793 // unknown required because of the missing private methods: _xLoading, _xIdle, _fetch, _appendAll, _append 793 794 } as unknown as SuggestedActorsViewModel 794 795 ··· 828 829 hasError: false, 829 830 isEmpty: false, 830 831 setup: jest.fn(), 831 - refresh: jest.fn(), 832 + refresh: jest.fn().mockResolvedValue({}), 832 833 loadMore: jest.fn(), 833 834 // unknown required because of the missing private methods: _xIdle, _xLoading, _fetch, _replaceAll, _append 834 835 } as unknown as UserFollowersViewModel ··· 869 870 hasError: false, 870 871 isEmpty: false, 871 872 setup: jest.fn(), 872 - refresh: jest.fn(), 873 + refresh: jest.fn().mockResolvedValue({}), 873 874 loadMore: jest.fn(), 874 875 // unknown required because of the missing private methods: _xIdle, _xLoading, _fetch, _replaceAll, _append 875 876 } as unknown as UserFollowsViewModel
+2 -2
__tests__/state/models/me.test.ts
··· 160 160 161 161 it('should update notifs count with fetchStateUpdate()', async () => { 162 162 meModel.notifications = { 163 - refresh: jest.fn(), 163 + refresh: jest.fn().mockResolvedValue({}), 164 164 } as unknown as NotificationsViewModel 165 165 166 166 jest ··· 173 173 }) 174 174 }) 175 175 176 - await meModel.fetchStateUpdate() 176 + await meModel.fetchNotifications() 177 177 expect(meModel.notificationCount).toBe(1) 178 178 expect(meModel.notifications.refresh).toHaveBeenCalled() 179 179 })
+15
ios/Podfile.lock
··· 340 340 - React-perflogger (= 0.71.0) 341 341 - rn-fetch-blob (0.12.0): 342 342 - React-Core 343 + - RNBackgroundFetch (4.1.8): 344 + - React-Core 343 345 - RNCAsyncStorage (1.17.11): 344 346 - React-Core 345 347 - RNCClipboard (1.11.1): ··· 358 360 - React-RCTImage 359 361 - TOCropViewController 360 362 - RNInAppBrowser (3.7.0): 363 + - React-Core 364 + - RNNotifee (7.4.0): 365 + - React-Core 366 + - RNNotifee/NotifeeCore (= 7.4.0) 367 + - RNNotifee/NotifeeCore (7.4.0): 361 368 - React-Core 362 369 - RNReactNativeHapticFeedback (1.14.0): 363 370 - React-Core ··· 448 455 - React-runtimeexecutor (from `../node_modules/react-native/ReactCommon/runtimeexecutor`) 449 456 - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`) 450 457 - rn-fetch-blob (from `../node_modules/rn-fetch-blob`) 458 + - RNBackgroundFetch (from `../node_modules/react-native-background-fetch`) 451 459 - "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)" 452 460 - "RNCClipboard (from `../node_modules/@react-native-clipboard/clipboard`)" 453 461 - RNFS (from `../node_modules/react-native-fs`) 454 462 - RNGestureHandler (from `../node_modules/react-native-gesture-handler`) 455 463 - RNImageCropPicker (from `../node_modules/react-native-image-crop-picker`) 456 464 - RNInAppBrowser (from `../node_modules/react-native-inappbrowser-reborn`) 465 + - "RNNotifee (from `../node_modules/@notifee/react-native`)" 457 466 - RNReactNativeHapticFeedback (from `../node_modules/react-native-haptic-feedback`) 458 467 - RNReanimated (from `../node_modules/react-native-reanimated`) 459 468 - RNScreens (from `../node_modules/react-native-screens`) ··· 556 565 :path: "../node_modules/react-native/ReactCommon" 557 566 rn-fetch-blob: 558 567 :path: "../node_modules/rn-fetch-blob" 568 + RNBackgroundFetch: 569 + :path: "../node_modules/react-native-background-fetch" 559 570 RNCAsyncStorage: 560 571 :path: "../node_modules/@react-native-async-storage/async-storage" 561 572 RNCClipboard: ··· 568 579 :path: "../node_modules/react-native-image-crop-picker" 569 580 RNInAppBrowser: 570 581 :path: "../node_modules/react-native-inappbrowser-reborn" 582 + RNNotifee: 583 + :path: "../node_modules/@notifee/react-native" 571 584 RNReactNativeHapticFeedback: 572 585 :path: "../node_modules/react-native-haptic-feedback" 573 586 RNReanimated: ··· 629 642 React-runtimeexecutor: ac80782d9d76ba2b0f709f4de0c427fe33c352dc 630 643 ReactCommon: 20e38a9be5fe1341b5e422220877cc94034776ba 631 644 rn-fetch-blob: f065bb7ab7fb48dd002629f8bdcb0336602d3cba 645 + RNBackgroundFetch: 8e16176ff415daac743a6eb57afc8e9e14dbe623 632 646 RNCAsyncStorage: 8616bd5a58af409453ea4e1b246521bb76578d60 633 647 RNCClipboard: 2834e1c4af68697089cdd455ee4a4cdd198fa7dd 634 648 RNFS: 4ac0f0ea233904cb798630b3c077808c06931688 635 649 RNGestureHandler: 62232ba8f562f7dea5ba1b3383494eb5bf97a4d3 636 650 RNImageCropPicker: 648356d68fbf9911a1016b3e3723885d28373eda 637 651 RNInAppBrowser: e36d6935517101ccba0e875bac8ad7b0cb655364 652 + RNNotifee: da8dcf09f079ea22f46e239d7c406e10d4525a5f 638 653 RNReactNativeHapticFeedback: 1e3efeca9628ff9876ee7cdd9edec1b336913f8c 639 654 RNReanimated: d8d9d3d3801bda5e35e85cdffc871577d044dc2e 640 655 RNScreens: 34cc502acf1b916c582c60003dc3089fa01dc66d
+2
package.json
··· 25 25 "@fortawesome/react-native-fontawesome": "^0.3.0", 26 26 "@gorhom/bottom-sheet": "^4", 27 27 "@mattermost/react-native-paste-input": "^0.6.0", 28 + "@notifee/react-native": "^7.4.0", 28 29 "@react-native-async-storage/async-storage": "^1.17.6", 29 30 "@react-native-camera-roll/camera-roll": "^5.1.0", 30 31 "@react-native-clipboard/clipboard": "^1.10.0", ··· 45 46 "react-dom": "17.0.2", 46 47 "react-native": "0.71.0", 47 48 "react-native-appstate-hook": "^1.0.6", 49 + "react-native-background-fetch": "^4.1.8", 48 50 "react-native-fs": "^2.20.0", 49 51 "react-native-gesture-handler": "^2.5.0", 50 52 "react-native-haptic-feedback": "^1.14.0",
+8
src/App.native.tsx
··· 16 16 import {RootStoreModel, setupState, RootStoreProvider} from './state' 17 17 import {MobileShell} from './view/shell/mobile' 18 18 import {s} from './view/lib/styles' 19 + import notifee, {EventType} from '@notifee/react-native' 19 20 20 21 const App = observer(() => { 21 22 const [rootStore, setRootStore] = useState<RootStoreModel | undefined>( ··· 42 43 }) 43 44 Linking.addEventListener('url', ({url}) => { 44 45 store.nav.handleLink(url) 46 + }) 47 + notifee.onForegroundEvent(async ({type}: {type: EventType}) => { 48 + store.log.debug('Notifee foreground event', {type}) 49 + if (type === EventType.PRESS) { 50 + store.log.debug('User pressed a notifee, opening notifications') 51 + store.nav.switchTo(1, true) 52 + } 45 53 }) 46 54 }) 47 55 }, [])
+18 -2
src/state/models/me.ts
··· 1 1 import {makeAutoObservable, runInAction} from 'mobx' 2 + import notifee from '@notifee/react-native' 2 3 import {RootStoreModel} from './root-store' 3 4 import {FeedModel} from './feed-view' 4 5 import {NotificationsViewModel} from './notifications-view' ··· 104 105 this.rootStore.log.error('Failed to setup notifications model', e) 105 106 }), 106 107 ]) 108 + 109 + // request notifications permission once the user has logged in 110 + notifee.requestPermission() 107 111 } else { 108 112 this.clear() 109 113 } ··· 111 115 112 116 clearNotificationCount() { 113 117 this.notificationCount = 0 118 + notifee.setBadgeCount(0) 114 119 } 115 120 116 - async fetchStateUpdate() { 121 + async fetchNotifications() { 117 122 const res = await this.rootStore.api.app.bsky.notification.getCount() 118 123 runInAction(() => { 119 124 const newNotifications = this.notificationCount !== res.data.count 120 125 this.notificationCount = res.data.count 126 + notifee.setBadgeCount(this.notificationCount) 121 127 if (newNotifications) { 122 128 // trigger pre-emptive fetch on new notifications 123 - this.notifications.refresh() 129 + let oldMostRecent = this.notifications.mostRecentNotification 130 + this.notifications.refresh().then(() => { 131 + // if a new most recent notification is found, trigger a notification card 132 + const mostRecent = this.notifications.mostRecentNotification 133 + if (mostRecent && oldMostRecent?.uri !== mostRecent?.uri) { 134 + const notifeeOpts = mostRecent.toNotifeeOpts() 135 + if (notifeeOpts) { 136 + notifee.displayNotification(notifeeOpts) 137 + } 138 + } 139 + }) 124 140 } 125 141 }) 126 142 }
+50
src/state/models/notifications-view.ts
··· 7 7 AppBskyFeedVote, 8 8 AppBskyGraphAssertion, 9 9 AppBskyGraphFollow, 10 + AppBskyEmbedImages, 10 11 } from '@atproto/api' 11 12 import {RootStoreModel} from './root-store' 12 13 import {PostThreadViewModel} from './post-thread-view' ··· 179 180 }) 180 181 } 181 182 } 183 + 184 + toNotifeeOpts() { 185 + let author = this.author.displayName || this.author.handle 186 + let title: string 187 + let body: string = '' 188 + if (this.isUpvote) { 189 + title = `${author} liked your post` 190 + body = this.additionalPost?.thread?.postRecord?.text || '' 191 + } else if (this.isRepost) { 192 + title = `${author} reposted your post` 193 + body = this.additionalPost?.thread?.postRecord?.text || '' 194 + } else if (this.isReply) { 195 + title = `${author} replied to your post` 196 + body = this.additionalPost?.thread?.postRecord?.text || '' 197 + } else if (this.isFollow) { 198 + title = `${author} followed you` 199 + } else { 200 + return undefined 201 + } 202 + let ios 203 + if ( 204 + AppBskyEmbedImages.isPresented(this.additionalPost?.thread?.post.embed) && 205 + this.additionalPost?.thread?.post.embed.images[0]?.thumb 206 + ) { 207 + ios = { 208 + attachments: [ 209 + {url: this.additionalPost.thread.post.embed.images[0].thumb}, 210 + ], 211 + } 212 + } 213 + return { 214 + title, 215 + body, 216 + ios, 217 + } 218 + } 182 219 } 183 220 184 221 export class NotificationsViewModel { ··· 196 233 197 234 // data 198 235 notifications: NotificationsViewItemModel[] = [] 236 + 237 + // this is used to trigger push notifications 238 + mostRecentNotification: NotificationsViewItemModel | undefined 199 239 200 240 constructor( 201 241 public rootStore: RootStoreModel, ··· 388 428 } 389 429 390 430 private async _replaceAll(res: ListNotifications.Response) { 431 + if (res.data.notifications[0]) { 432 + this.mostRecentNotification = new NotificationsViewItemModel( 433 + this.rootStore, 434 + 'mostRecent', 435 + res.data.notifications[0], 436 + ) 437 + await this.mostRecentNotification.fetchAdditionalData() 438 + } else { 439 + this.mostRecentNotification = undefined 440 + } 391 441 return this._appendAll(res, true) 392 442 } 393 443
+36 -2
src/state/models/root-store.ts
··· 6 6 import {sessionClient as AtpApi, SessionServiceClient} from '@atproto/api' 7 7 import {createContext, useContext} from 'react' 8 8 import {DeviceEventEmitter, EmitterSubscription} from 'react-native' 9 + import BackgroundFetch from 'react-native-background-fetch' 9 10 import {isObj, hasProp} from '../lib/type-guards' 10 11 import {LogModel} from './log' 11 12 import {SessionModel} from './session' ··· 34 35 serialize: false, 35 36 hydrate: false, 36 37 }) 38 + this.initBgFetch() 37 39 } 38 40 39 41 async resolveName(didOrHandle: string) { ··· 55 57 if (!this.session.online) { 56 58 await this.session.connect() 57 59 } 58 - await this.me.fetchStateUpdate() 60 + await this.me.fetchNotifications() 59 61 } catch (e: any) { 60 62 if (isNetworkError(e)) { 61 63 this.session.setOnline(false) // connection lost ··· 109 111 } 110 112 111 113 emitPostDeleted(uri: string) { 112 - console.log('emit') 113 114 DeviceEventEmitter.emit('post-deleted', uri) 115 + } 116 + 117 + // background fetch 118 + // = 119 + // - we use this to poll for unread notifications, which is not "ideal" behavior but 120 + // gives us a solution for push-notifications that work against any pds 121 + 122 + initBgFetch() { 123 + // NOTE 124 + // background fetch runs every 15 minutes *at most* and will get slowed down 125 + // based on some heuristics run by iOS, meaning it is not a reliable form of delivery 126 + // -prf 127 + BackgroundFetch.configure( 128 + {minimumFetchInterval: 15}, 129 + this.onBgFetch.bind(this), 130 + this.onBgFetchTimeout.bind(this), 131 + ).then(status => { 132 + this.log.debug(`Background fetch initiated, status: ${status}`) 133 + }) 134 + } 135 + 136 + async onBgFetch(taskId: string) { 137 + this.log.debug(`Background fetch fired for task ${taskId}`) 138 + if (this.session.hasSession) { 139 + // grab notifications 140 + await this.me.fetchNotifications() 141 + } 142 + BackgroundFetch.finish(taskId) 143 + } 144 + 145 + onBgFetchTimeout(taskId: string) { 146 + this.log.debug(`Background fetch timed out for task ${taskId}`) 147 + BackgroundFetch.finish(taskId) 114 148 } 115 149 } 116 150
+27 -11
src/state/models/session.ts
··· 286 286 * Attempt to resume a session that we still have access tokens for. 287 287 */ 288 288 async resumeSession(account: AccountData): Promise<boolean> { 289 - if (account.accessJwt && account.refreshJwt) { 290 - this.setState({ 291 - service: account.service, 292 - accessJwt: account.accessJwt, 293 - refreshJwt: account.refreshJwt, 294 - handle: account.handle, 295 - did: account.did, 296 - }) 297 - } else { 289 + if (!(account.accessJwt && account.refreshJwt && account.service)) { 298 290 return false 299 291 } 292 + 293 + // test that the session is good 294 + const api = AtpApi.service(account.service) 295 + api.sessionManager.set({ 296 + refreshJwt: account.refreshJwt, 297 + accessJwt: account.accessJwt, 298 + }) 299 + try { 300 + const sess = await api.com.atproto.session.get() 301 + if (!sess.success || sess.data.did !== account.did) { 302 + return false 303 + } 304 + } catch (_e) { 305 + return false 306 + } 307 + 308 + // session is good, connect 309 + this.setState({ 310 + service: account.service, 311 + accessJwt: account.accessJwt, 312 + refreshJwt: account.refreshJwt, 313 + handle: account.handle, 314 + did: account.did, 315 + }) 300 316 return this.connect() 301 317 } 302 318 ··· 345 361 * Close all sessions across all accounts. 346 362 */ 347 363 async logout() { 348 - if (this.hasSession) { 364 + /*if (this.hasSession) { 349 365 this.rootStore.api.com.atproto.session.delete().catch((e: any) => { 350 366 this.rootStore.log.warn( 351 367 '(Minor issue) Failed to delete session on the server', 352 368 e, 353 369 ) 354 370 }) 355 - } 371 + }*/ 356 372 this.clearSessionTokensFromAccounts() 357 373 this.rootStore.clearAll() 358 374 }
+10
yarn.lock
··· 2109 2109 "@nodelib/fs.scandir" "2.1.5" 2110 2110 fastq "^1.6.0" 2111 2111 2112 + "@notifee/react-native@^7.4.0": 2113 + version "7.4.0" 2114 + resolved "https://registry.yarnpkg.com/@notifee/react-native/-/react-native-7.4.0.tgz#0f20744307bf3b800f7b56eb2d0bbdd474748d09" 2115 + integrity sha512-c8pkxDQFRbw0JlUmTb07OTG/4LQHRj8MBodMLwEcO+SvqIxK8ya8zSUEzfdcdWsSVqdoym0v3zpSNroR3Quj/w== 2116 + 2112 2117 "@pmmmwh/react-refresh-webpack-plugin@^0.5.3": 2113 2118 version "0.5.10" 2114 2119 resolved "https://registry.yarnpkg.com/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.10.tgz#2eba163b8e7dbabb4ce3609ab5e32ab63dda3ef8" ··· 11163 11168 version "1.0.6" 11164 11169 resolved "https://registry.yarnpkg.com/react-native-appstate-hook/-/react-native-appstate-hook-1.0.6.tgz#cbc16e7b89cfaea034cabd999f00e99053cabd06" 11165 11170 integrity sha512-0hPVyf5yLxCSVrrNEuGqN1ZnSSj3Ye2gZex0NtcK/AHYwMc0rXWFNZjBKOoZSouspqu3hXBbQ6NOUSTzrME1AQ== 11171 + 11172 + react-native-background-fetch@^4.1.8: 11173 + version "4.1.8" 11174 + resolved "https://registry.yarnpkg.com/react-native-background-fetch/-/react-native-background-fetch-4.1.8.tgz#a21858e5d876de8d9d15a37f40714b244f73713c" 11175 + integrity sha512-/qe86laa0n4AbD6mrLL8SCGR+K5693URX95e02/bTJh3UkdS3+sU1Jyc/XTlz4MQwlquI929/lm5EZh8AOUqzQ== 11166 11176 11167 11177 react-native-codegen@^0.71.3: 11168 11178 version "0.71.3"