An ATproto social media client -- with an independent Appview.

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

+189 -27
+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 isUser: true, 65 isScene: false, 66 setup: jest.fn().mockResolvedValue({aborted: false}), 67 - refresh: jest.fn(), 68 toggleFollowing: jest.fn().mockResolvedValue({}), 69 updateProfile: jest.fn(), 70 // unknown required because of the missing private methods: _xLoading, _xIdle, _load, _replaceAll ··· 106 isEmpty: false, 107 isMember: jest.fn(), 108 setup: jest.fn().mockResolvedValue({aborted: false}), 109 - refresh: jest.fn(), 110 loadMore: jest.fn(), 111 removeMember: jest.fn(), 112 // unknown required because of the missing private methods: _xLoading, _xIdle, _fetch, _replaceAll, _append ··· 149 isEmpty: false, 150 isMemberOf: jest.fn(), 151 setup: jest.fn().mockResolvedValue({aborted: false}), 152 - refresh: jest.fn(), 153 loadMore: jest.fn(), 154 // unknown required because of the missing private methods: _xLoading, _xIdle, _fetch, _replaceAll, _append 155 } as unknown as MembershipsViewModel ··· 413 createdAt: '', 414 }), 415 fetchAdditionalData: jest.fn(), 416 } as NotificationsViewItemModel 417 418 export const mockedNotificationsStore = { ··· 510 }, 511 ], 512 navigate: jest.fn(), 513 - refresh: jest.fn(), 514 goBack: jest.fn(), 515 fixedTabReset: jest.fn(), 516 goForward: jest.fn(), ··· 539 tabCount: 1, 540 isCurrentScreen: jest.fn(), 541 navigate: jest.fn(), 542 - refresh: jest.fn(), 543 setTitle: jest.fn(), 544 handleLink: jest.fn(), 545 switchTo: jest.fn(), ··· 587 clear: jest.fn(), 588 load: jest.fn(), 589 clearNotificationCount: jest.fn(), 590 - fetchStateUpdate: jest.fn(), 591 refreshMemberships: jest.fn(), 592 } as MeModel 593 ··· 679 setSelectedViewIndex: jest.fn(), 680 setup: jest.fn().mockResolvedValue({aborted: false}), 681 update: jest.fn(), 682 - refresh: jest.fn(), 683 loadMore: jest.fn(), 684 } as ProfileUiModel 685 ··· 788 hasError: false, 789 isEmpty: false, 790 setup: jest.fn().mockResolvedValue(null), 791 - refresh: jest.fn(), 792 // unknown required because of the missing private methods: _xLoading, _xIdle, _fetch, _appendAll, _append 793 } as unknown as SuggestedActorsViewModel 794 ··· 828 hasError: false, 829 isEmpty: false, 830 setup: jest.fn(), 831 - refresh: jest.fn(), 832 loadMore: jest.fn(), 833 // unknown required because of the missing private methods: _xIdle, _xLoading, _fetch, _replaceAll, _append 834 } as unknown as UserFollowersViewModel ··· 869 hasError: false, 870 isEmpty: false, 871 setup: jest.fn(), 872 - refresh: jest.fn(), 873 loadMore: jest.fn(), 874 // unknown required because of the missing private methods: _xIdle, _xLoading, _fetch, _replaceAll, _append 875 } as unknown as UserFollowsViewModel
··· 64 isUser: true, 65 isScene: false, 66 setup: jest.fn().mockResolvedValue({aborted: false}), 67 + refresh: jest.fn().mockResolvedValue({}), 68 toggleFollowing: jest.fn().mockResolvedValue({}), 69 updateProfile: jest.fn(), 70 // unknown required because of the missing private methods: _xLoading, _xIdle, _load, _replaceAll ··· 106 isEmpty: false, 107 isMember: jest.fn(), 108 setup: jest.fn().mockResolvedValue({aborted: false}), 109 + refresh: jest.fn().mockResolvedValue({}), 110 loadMore: jest.fn(), 111 removeMember: jest.fn(), 112 // unknown required because of the missing private methods: _xLoading, _xIdle, _fetch, _replaceAll, _append ··· 149 isEmpty: false, 150 isMemberOf: jest.fn(), 151 setup: jest.fn().mockResolvedValue({aborted: false}), 152 + refresh: jest.fn().mockResolvedValue({}), 153 loadMore: jest.fn(), 154 // unknown required because of the missing private methods: _xLoading, _xIdle, _fetch, _replaceAll, _append 155 } as unknown as MembershipsViewModel ··· 413 createdAt: '', 414 }), 415 fetchAdditionalData: jest.fn(), 416 + toNotifeeOpts: jest.fn(), 417 } as NotificationsViewItemModel 418 419 export const mockedNotificationsStore = { ··· 511 }, 512 ], 513 navigate: jest.fn(), 514 + refresh: jest.fn().mockResolvedValue({}), 515 goBack: jest.fn(), 516 fixedTabReset: jest.fn(), 517 goForward: jest.fn(), ··· 540 tabCount: 1, 541 isCurrentScreen: jest.fn(), 542 navigate: jest.fn(), 543 + refresh: jest.fn().mockResolvedValue({}), 544 setTitle: jest.fn(), 545 handleLink: jest.fn(), 546 switchTo: jest.fn(), ··· 588 clear: jest.fn(), 589 load: jest.fn(), 590 clearNotificationCount: jest.fn(), 591 + fetchNotifications: jest.fn(), 592 refreshMemberships: jest.fn(), 593 } as MeModel 594 ··· 680 setSelectedViewIndex: jest.fn(), 681 setup: jest.fn().mockResolvedValue({aborted: false}), 682 update: jest.fn(), 683 + refresh: jest.fn().mockResolvedValue({}), 684 loadMore: jest.fn(), 685 } as ProfileUiModel 686 ··· 789 hasError: false, 790 isEmpty: false, 791 setup: jest.fn().mockResolvedValue(null), 792 + refresh: jest.fn().mockResolvedValue({}), 793 // unknown required because of the missing private methods: _xLoading, _xIdle, _fetch, _appendAll, _append 794 } as unknown as SuggestedActorsViewModel 795 ··· 829 hasError: false, 830 isEmpty: false, 831 setup: jest.fn(), 832 + refresh: jest.fn().mockResolvedValue({}), 833 loadMore: jest.fn(), 834 // unknown required because of the missing private methods: _xIdle, _xLoading, _fetch, _replaceAll, _append 835 } as unknown as UserFollowersViewModel ··· 870 hasError: false, 871 isEmpty: false, 872 setup: jest.fn(), 873 + refresh: jest.fn().mockResolvedValue({}), 874 loadMore: jest.fn(), 875 // unknown required because of the missing private methods: _xIdle, _xLoading, _fetch, _replaceAll, _append 876 } as unknown as UserFollowsViewModel
+2 -2
__tests__/state/models/me.test.ts
··· 160 161 it('should update notifs count with fetchStateUpdate()', async () => { 162 meModel.notifications = { 163 - refresh: jest.fn(), 164 } as unknown as NotificationsViewModel 165 166 jest ··· 173 }) 174 }) 175 176 - await meModel.fetchStateUpdate() 177 expect(meModel.notificationCount).toBe(1) 178 expect(meModel.notifications.refresh).toHaveBeenCalled() 179 })
··· 160 161 it('should update notifs count with fetchStateUpdate()', async () => { 162 meModel.notifications = { 163 + refresh: jest.fn().mockResolvedValue({}), 164 } as unknown as NotificationsViewModel 165 166 jest ··· 173 }) 174 }) 175 176 + await meModel.fetchNotifications() 177 expect(meModel.notificationCount).toBe(1) 178 expect(meModel.notifications.refresh).toHaveBeenCalled() 179 })
+15
ios/Podfile.lock
··· 340 - React-perflogger (= 0.71.0) 341 - rn-fetch-blob (0.12.0): 342 - React-Core 343 - RNCAsyncStorage (1.17.11): 344 - React-Core 345 - RNCClipboard (1.11.1): ··· 358 - React-RCTImage 359 - TOCropViewController 360 - RNInAppBrowser (3.7.0): 361 - React-Core 362 - RNReactNativeHapticFeedback (1.14.0): 363 - React-Core ··· 448 - React-runtimeexecutor (from `../node_modules/react-native/ReactCommon/runtimeexecutor`) 449 - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`) 450 - rn-fetch-blob (from `../node_modules/rn-fetch-blob`) 451 - "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)" 452 - "RNCClipboard (from `../node_modules/@react-native-clipboard/clipboard`)" 453 - RNFS (from `../node_modules/react-native-fs`) 454 - RNGestureHandler (from `../node_modules/react-native-gesture-handler`) 455 - RNImageCropPicker (from `../node_modules/react-native-image-crop-picker`) 456 - RNInAppBrowser (from `../node_modules/react-native-inappbrowser-reborn`) 457 - RNReactNativeHapticFeedback (from `../node_modules/react-native-haptic-feedback`) 458 - RNReanimated (from `../node_modules/react-native-reanimated`) 459 - RNScreens (from `../node_modules/react-native-screens`) ··· 556 :path: "../node_modules/react-native/ReactCommon" 557 rn-fetch-blob: 558 :path: "../node_modules/rn-fetch-blob" 559 RNCAsyncStorage: 560 :path: "../node_modules/@react-native-async-storage/async-storage" 561 RNCClipboard: ··· 568 :path: "../node_modules/react-native-image-crop-picker" 569 RNInAppBrowser: 570 :path: "../node_modules/react-native-inappbrowser-reborn" 571 RNReactNativeHapticFeedback: 572 :path: "../node_modules/react-native-haptic-feedback" 573 RNReanimated: ··· 629 React-runtimeexecutor: ac80782d9d76ba2b0f709f4de0c427fe33c352dc 630 ReactCommon: 20e38a9be5fe1341b5e422220877cc94034776ba 631 rn-fetch-blob: f065bb7ab7fb48dd002629f8bdcb0336602d3cba 632 RNCAsyncStorage: 8616bd5a58af409453ea4e1b246521bb76578d60 633 RNCClipboard: 2834e1c4af68697089cdd455ee4a4cdd198fa7dd 634 RNFS: 4ac0f0ea233904cb798630b3c077808c06931688 635 RNGestureHandler: 62232ba8f562f7dea5ba1b3383494eb5bf97a4d3 636 RNImageCropPicker: 648356d68fbf9911a1016b3e3723885d28373eda 637 RNInAppBrowser: e36d6935517101ccba0e875bac8ad7b0cb655364 638 RNReactNativeHapticFeedback: 1e3efeca9628ff9876ee7cdd9edec1b336913f8c 639 RNReanimated: d8d9d3d3801bda5e35e85cdffc871577d044dc2e 640 RNScreens: 34cc502acf1b916c582c60003dc3089fa01dc66d
··· 340 - React-perflogger (= 0.71.0) 341 - rn-fetch-blob (0.12.0): 342 - React-Core 343 + - RNBackgroundFetch (4.1.8): 344 + - React-Core 345 - RNCAsyncStorage (1.17.11): 346 - React-Core 347 - RNCClipboard (1.11.1): ··· 360 - React-RCTImage 361 - TOCropViewController 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): 368 - React-Core 369 - RNReactNativeHapticFeedback (1.14.0): 370 - React-Core ··· 455 - React-runtimeexecutor (from `../node_modules/react-native/ReactCommon/runtimeexecutor`) 456 - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`) 457 - rn-fetch-blob (from `../node_modules/rn-fetch-blob`) 458 + - RNBackgroundFetch (from `../node_modules/react-native-background-fetch`) 459 - "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)" 460 - "RNCClipboard (from `../node_modules/@react-native-clipboard/clipboard`)" 461 - RNFS (from `../node_modules/react-native-fs`) 462 - RNGestureHandler (from `../node_modules/react-native-gesture-handler`) 463 - RNImageCropPicker (from `../node_modules/react-native-image-crop-picker`) 464 - RNInAppBrowser (from `../node_modules/react-native-inappbrowser-reborn`) 465 + - "RNNotifee (from `../node_modules/@notifee/react-native`)" 466 - RNReactNativeHapticFeedback (from `../node_modules/react-native-haptic-feedback`) 467 - RNReanimated (from `../node_modules/react-native-reanimated`) 468 - RNScreens (from `../node_modules/react-native-screens`) ··· 565 :path: "../node_modules/react-native/ReactCommon" 566 rn-fetch-blob: 567 :path: "../node_modules/rn-fetch-blob" 568 + RNBackgroundFetch: 569 + :path: "../node_modules/react-native-background-fetch" 570 RNCAsyncStorage: 571 :path: "../node_modules/@react-native-async-storage/async-storage" 572 RNCClipboard: ··· 579 :path: "../node_modules/react-native-image-crop-picker" 580 RNInAppBrowser: 581 :path: "../node_modules/react-native-inappbrowser-reborn" 582 + RNNotifee: 583 + :path: "../node_modules/@notifee/react-native" 584 RNReactNativeHapticFeedback: 585 :path: "../node_modules/react-native-haptic-feedback" 586 RNReanimated: ··· 642 React-runtimeexecutor: ac80782d9d76ba2b0f709f4de0c427fe33c352dc 643 ReactCommon: 20e38a9be5fe1341b5e422220877cc94034776ba 644 rn-fetch-blob: f065bb7ab7fb48dd002629f8bdcb0336602d3cba 645 + RNBackgroundFetch: 8e16176ff415daac743a6eb57afc8e9e14dbe623 646 RNCAsyncStorage: 8616bd5a58af409453ea4e1b246521bb76578d60 647 RNCClipboard: 2834e1c4af68697089cdd455ee4a4cdd198fa7dd 648 RNFS: 4ac0f0ea233904cb798630b3c077808c06931688 649 RNGestureHandler: 62232ba8f562f7dea5ba1b3383494eb5bf97a4d3 650 RNImageCropPicker: 648356d68fbf9911a1016b3e3723885d28373eda 651 RNInAppBrowser: e36d6935517101ccba0e875bac8ad7b0cb655364 652 + RNNotifee: da8dcf09f079ea22f46e239d7c406e10d4525a5f 653 RNReactNativeHapticFeedback: 1e3efeca9628ff9876ee7cdd9edec1b336913f8c 654 RNReanimated: d8d9d3d3801bda5e35e85cdffc871577d044dc2e 655 RNScreens: 34cc502acf1b916c582c60003dc3089fa01dc66d
+2
package.json
··· 25 "@fortawesome/react-native-fontawesome": "^0.3.0", 26 "@gorhom/bottom-sheet": "^4", 27 "@mattermost/react-native-paste-input": "^0.6.0", 28 "@react-native-async-storage/async-storage": "^1.17.6", 29 "@react-native-camera-roll/camera-roll": "^5.1.0", 30 "@react-native-clipboard/clipboard": "^1.10.0", ··· 45 "react-dom": "17.0.2", 46 "react-native": "0.71.0", 47 "react-native-appstate-hook": "^1.0.6", 48 "react-native-fs": "^2.20.0", 49 "react-native-gesture-handler": "^2.5.0", 50 "react-native-haptic-feedback": "^1.14.0",
··· 25 "@fortawesome/react-native-fontawesome": "^0.3.0", 26 "@gorhom/bottom-sheet": "^4", 27 "@mattermost/react-native-paste-input": "^0.6.0", 28 + "@notifee/react-native": "^7.4.0", 29 "@react-native-async-storage/async-storage": "^1.17.6", 30 "@react-native-camera-roll/camera-roll": "^5.1.0", 31 "@react-native-clipboard/clipboard": "^1.10.0", ··· 46 "react-dom": "17.0.2", 47 "react-native": "0.71.0", 48 "react-native-appstate-hook": "^1.0.6", 49 + "react-native-background-fetch": "^4.1.8", 50 "react-native-fs": "^2.20.0", 51 "react-native-gesture-handler": "^2.5.0", 52 "react-native-haptic-feedback": "^1.14.0",
+8
src/App.native.tsx
··· 16 import {RootStoreModel, setupState, RootStoreProvider} from './state' 17 import {MobileShell} from './view/shell/mobile' 18 import {s} from './view/lib/styles' 19 20 const App = observer(() => { 21 const [rootStore, setRootStore] = useState<RootStoreModel | undefined>( ··· 42 }) 43 Linking.addEventListener('url', ({url}) => { 44 store.nav.handleLink(url) 45 }) 46 }) 47 }, [])
··· 16 import {RootStoreModel, setupState, RootStoreProvider} from './state' 17 import {MobileShell} from './view/shell/mobile' 18 import {s} from './view/lib/styles' 19 + import notifee, {EventType} from '@notifee/react-native' 20 21 const App = observer(() => { 22 const [rootStore, setRootStore] = useState<RootStoreModel | undefined>( ··· 43 }) 44 Linking.addEventListener('url', ({url}) => { 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 + } 53 }) 54 }) 55 }, [])
+18 -2
src/state/models/me.ts
··· 1 import {makeAutoObservable, runInAction} from 'mobx' 2 import {RootStoreModel} from './root-store' 3 import {FeedModel} from './feed-view' 4 import {NotificationsViewModel} from './notifications-view' ··· 104 this.rootStore.log.error('Failed to setup notifications model', e) 105 }), 106 ]) 107 } else { 108 this.clear() 109 } ··· 111 112 clearNotificationCount() { 113 this.notificationCount = 0 114 } 115 116 - async fetchStateUpdate() { 117 const res = await this.rootStore.api.app.bsky.notification.getCount() 118 runInAction(() => { 119 const newNotifications = this.notificationCount !== res.data.count 120 this.notificationCount = res.data.count 121 if (newNotifications) { 122 // trigger pre-emptive fetch on new notifications 123 - this.notifications.refresh() 124 } 125 }) 126 }
··· 1 import {makeAutoObservable, runInAction} from 'mobx' 2 + import notifee from '@notifee/react-native' 3 import {RootStoreModel} from './root-store' 4 import {FeedModel} from './feed-view' 5 import {NotificationsViewModel} from './notifications-view' ··· 105 this.rootStore.log.error('Failed to setup notifications model', e) 106 }), 107 ]) 108 + 109 + // request notifications permission once the user has logged in 110 + notifee.requestPermission() 111 } else { 112 this.clear() 113 } ··· 115 116 clearNotificationCount() { 117 this.notificationCount = 0 118 + notifee.setBadgeCount(0) 119 } 120 121 + async fetchNotifications() { 122 const res = await this.rootStore.api.app.bsky.notification.getCount() 123 runInAction(() => { 124 const newNotifications = this.notificationCount !== res.data.count 125 this.notificationCount = res.data.count 126 + notifee.setBadgeCount(this.notificationCount) 127 if (newNotifications) { 128 // trigger pre-emptive fetch on new notifications 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 + }) 140 } 141 }) 142 }
+50
src/state/models/notifications-view.ts
··· 7 AppBskyFeedVote, 8 AppBskyGraphAssertion, 9 AppBskyGraphFollow, 10 } from '@atproto/api' 11 import {RootStoreModel} from './root-store' 12 import {PostThreadViewModel} from './post-thread-view' ··· 179 }) 180 } 181 } 182 } 183 184 export class NotificationsViewModel { ··· 196 197 // data 198 notifications: NotificationsViewItemModel[] = [] 199 200 constructor( 201 public rootStore: RootStoreModel, ··· 388 } 389 390 private async _replaceAll(res: ListNotifications.Response) { 391 return this._appendAll(res, true) 392 } 393
··· 7 AppBskyFeedVote, 8 AppBskyGraphAssertion, 9 AppBskyGraphFollow, 10 + AppBskyEmbedImages, 11 } from '@atproto/api' 12 import {RootStoreModel} from './root-store' 13 import {PostThreadViewModel} from './post-thread-view' ··· 180 }) 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 + } 219 } 220 221 export class NotificationsViewModel { ··· 233 234 // data 235 notifications: NotificationsViewItemModel[] = [] 236 + 237 + // this is used to trigger push notifications 238 + mostRecentNotification: NotificationsViewItemModel | undefined 239 240 constructor( 241 public rootStore: RootStoreModel, ··· 428 } 429 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 + } 441 return this._appendAll(res, true) 442 } 443
+36 -2
src/state/models/root-store.ts
··· 6 import {sessionClient as AtpApi, SessionServiceClient} from '@atproto/api' 7 import {createContext, useContext} from 'react' 8 import {DeviceEventEmitter, EmitterSubscription} from 'react-native' 9 import {isObj, hasProp} from '../lib/type-guards' 10 import {LogModel} from './log' 11 import {SessionModel} from './session' ··· 34 serialize: false, 35 hydrate: false, 36 }) 37 } 38 39 async resolveName(didOrHandle: string) { ··· 55 if (!this.session.online) { 56 await this.session.connect() 57 } 58 - await this.me.fetchStateUpdate() 59 } catch (e: any) { 60 if (isNetworkError(e)) { 61 this.session.setOnline(false) // connection lost ··· 109 } 110 111 emitPostDeleted(uri: string) { 112 - console.log('emit') 113 DeviceEventEmitter.emit('post-deleted', uri) 114 } 115 } 116
··· 6 import {sessionClient as AtpApi, SessionServiceClient} from '@atproto/api' 7 import {createContext, useContext} from 'react' 8 import {DeviceEventEmitter, EmitterSubscription} from 'react-native' 9 + import BackgroundFetch from 'react-native-background-fetch' 10 import {isObj, hasProp} from '../lib/type-guards' 11 import {LogModel} from './log' 12 import {SessionModel} from './session' ··· 35 serialize: false, 36 hydrate: false, 37 }) 38 + this.initBgFetch() 39 } 40 41 async resolveName(didOrHandle: string) { ··· 57 if (!this.session.online) { 58 await this.session.connect() 59 } 60 + await this.me.fetchNotifications() 61 } catch (e: any) { 62 if (isNetworkError(e)) { 63 this.session.setOnline(false) // connection lost ··· 111 } 112 113 emitPostDeleted(uri: string) { 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) 148 } 149 } 150
+27 -11
src/state/models/session.ts
··· 286 * Attempt to resume a session that we still have access tokens for. 287 */ 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 { 298 return false 299 } 300 return this.connect() 301 } 302 ··· 345 * Close all sessions across all accounts. 346 */ 347 async logout() { 348 - if (this.hasSession) { 349 this.rootStore.api.com.atproto.session.delete().catch((e: any) => { 350 this.rootStore.log.warn( 351 '(Minor issue) Failed to delete session on the server', 352 e, 353 ) 354 }) 355 - } 356 this.clearSessionTokensFromAccounts() 357 this.rootStore.clearAll() 358 }
··· 286 * Attempt to resume a session that we still have access tokens for. 287 */ 288 async resumeSession(account: AccountData): Promise<boolean> { 289 + if (!(account.accessJwt && account.refreshJwt && account.service)) { 290 return false 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 + }) 316 return this.connect() 317 } 318 ··· 361 * Close all sessions across all accounts. 362 */ 363 async logout() { 364 + /*if (this.hasSession) { 365 this.rootStore.api.com.atproto.session.delete().catch((e: any) => { 366 this.rootStore.log.warn( 367 '(Minor issue) Failed to delete session on the server', 368 e, 369 ) 370 }) 371 + }*/ 372 this.clearSessionTokensFromAccounts() 373 this.rootStore.clearAll() 374 }
+10
yarn.lock
··· 2109 "@nodelib/fs.scandir" "2.1.5" 2110 fastq "^1.6.0" 2111 2112 "@pmmmwh/react-refresh-webpack-plugin@^0.5.3": 2113 version "0.5.10" 2114 resolved "https://registry.yarnpkg.com/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.10.tgz#2eba163b8e7dbabb4ce3609ab5e32ab63dda3ef8" ··· 11163 version "1.0.6" 11164 resolved "https://registry.yarnpkg.com/react-native-appstate-hook/-/react-native-appstate-hook-1.0.6.tgz#cbc16e7b89cfaea034cabd999f00e99053cabd06" 11165 integrity sha512-0hPVyf5yLxCSVrrNEuGqN1ZnSSj3Ye2gZex0NtcK/AHYwMc0rXWFNZjBKOoZSouspqu3hXBbQ6NOUSTzrME1AQ== 11166 11167 react-native-codegen@^0.71.3: 11168 version "0.71.3"
··· 2109 "@nodelib/fs.scandir" "2.1.5" 2110 fastq "^1.6.0" 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 + 2117 "@pmmmwh/react-refresh-webpack-plugin@^0.5.3": 2118 version "0.5.10" 2119 resolved "https://registry.yarnpkg.com/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.10.tgz#2eba163b8e7dbabb4ce3609ab5e32ab63dda3ef8" ··· 11168 version "1.0.6" 11169 resolved "https://registry.yarnpkg.com/react-native-appstate-hook/-/react-native-appstate-hook-1.0.6.tgz#cbc16e7b89cfaea034cabd999f00e99053cabd06" 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== 11176 11177 react-native-codegen@^0.71.3: 11178 version "0.71.3"