about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--__mocks__/@notifee/react-native.ts6
-rw-r--r--__mocks__/react-native-background-fetch.ts4
-rw-r--r--__mocks__/state-mock.ts21
-rw-r--r--__tests__/state/models/me.test.ts4
-rw-r--r--ios/Podfile.lock15
-rw-r--r--package.json2
-rw-r--r--src/App.native.tsx8
-rw-r--r--src/state/models/me.ts20
-rw-r--r--src/state/models/notifications-view.ts50
-rw-r--r--src/state/models/root-store.ts38
-rw-r--r--src/state/models/session.ts38
-rw-r--r--yarn.lock10
12 files changed, 189 insertions, 27 deletions
diff --git a/__mocks__/@notifee/react-native.ts b/__mocks__/@notifee/react-native.ts
new file mode 100644
index 000000000..7e5ccec93
--- /dev/null
+++ b/__mocks__/@notifee/react-native.ts
@@ -0,0 +1,6 @@
+export default {
+  requestPermission: jest.fn(),
+  onForegroundEvent: jest.fn(),
+  setBadgeCount: jest.fn(),
+  displayNotification: jest.fn(),
+}
diff --git a/__mocks__/react-native-background-fetch.ts b/__mocks__/react-native-background-fetch.ts
new file mode 100644
index 000000000..0cb644c4d
--- /dev/null
+++ b/__mocks__/react-native-background-fetch.ts
@@ -0,0 +1,4 @@
+export default {
+  configure: jest.fn().mockResolvedValue(0),
+  finish: jest.fn(),
+}
diff --git a/__mocks__/state-mock.ts b/__mocks__/state-mock.ts
index 129f9c859..f5676288f 100644
--- a/__mocks__/state-mock.ts
+++ b/__mocks__/state-mock.ts
@@ -64,7 +64,7 @@ export const mockedProfileStore = {
   isUser: true,
   isScene: false,
   setup: jest.fn().mockResolvedValue({aborted: false}),
-  refresh: jest.fn(),
+  refresh: jest.fn().mockResolvedValue({}),
   toggleFollowing: jest.fn().mockResolvedValue({}),
   updateProfile: jest.fn(),
   // unknown required because of the missing private methods: _xLoading, _xIdle, _load, _replaceAll
@@ -106,7 +106,7 @@ export const mockedMembersStore = {
   isEmpty: false,
   isMember: jest.fn(),
   setup: jest.fn().mockResolvedValue({aborted: false}),
-  refresh: jest.fn(),
+  refresh: jest.fn().mockResolvedValue({}),
   loadMore: jest.fn(),
   removeMember: jest.fn(),
   // unknown required because of the missing private methods: _xLoading, _xIdle, _fetch, _replaceAll, _append
@@ -149,7 +149,7 @@ export const mockedMembershipsStore = {
   isEmpty: false,
   isMemberOf: jest.fn(),
   setup: jest.fn().mockResolvedValue({aborted: false}),
-  refresh: jest.fn(),
+  refresh: jest.fn().mockResolvedValue({}),
   loadMore: jest.fn(),
   // unknown required because of the missing private methods: _xLoading, _xIdle, _fetch, _replaceAll, _append
 } as unknown as MembershipsViewModel
@@ -413,6 +413,7 @@ export const mockedNotificationsViewItemStore = {
     createdAt: '',
   }),
   fetchAdditionalData: jest.fn(),
+  toNotifeeOpts: jest.fn(),
 } as NotificationsViewItemModel
 
 export const mockedNotificationsStore = {
@@ -510,7 +511,7 @@ export const mockedNavigationTabStore = {
     },
   ],
   navigate: jest.fn(),
-  refresh: jest.fn(),
+  refresh: jest.fn().mockResolvedValue({}),
   goBack: jest.fn(),
   fixedTabReset: jest.fn(),
   goForward: jest.fn(),
@@ -539,7 +540,7 @@ export const mockedNavigationStore = {
   tabCount: 1,
   isCurrentScreen: jest.fn(),
   navigate: jest.fn(),
-  refresh: jest.fn(),
+  refresh: jest.fn().mockResolvedValue({}),
   setTitle: jest.fn(),
   handleLink: jest.fn(),
   switchTo: jest.fn(),
@@ -587,7 +588,7 @@ export const mockedMeStore = {
   clear: jest.fn(),
   load: jest.fn(),
   clearNotificationCount: jest.fn(),
-  fetchStateUpdate: jest.fn(),
+  fetchNotifications: jest.fn(),
   refreshMemberships: jest.fn(),
 } as MeModel
 
@@ -679,7 +680,7 @@ export const mockedProfileUiStore = {
   setSelectedViewIndex: jest.fn(),
   setup: jest.fn().mockResolvedValue({aborted: false}),
   update: jest.fn(),
-  refresh: jest.fn(),
+  refresh: jest.fn().mockResolvedValue({}),
   loadMore: jest.fn(),
 } as ProfileUiModel
 
@@ -788,7 +789,7 @@ export const mockedSuggestedActorsStore = {
   hasError: false,
   isEmpty: false,
   setup: jest.fn().mockResolvedValue(null),
-  refresh: jest.fn(),
+  refresh: jest.fn().mockResolvedValue({}),
   // unknown required because of the missing private methods: _xLoading, _xIdle, _fetch, _appendAll, _append
 } as unknown as SuggestedActorsViewModel
 
@@ -828,7 +829,7 @@ export const mockedUserFollowersStore = {
   hasError: false,
   isEmpty: false,
   setup: jest.fn(),
-  refresh: jest.fn(),
+  refresh: jest.fn().mockResolvedValue({}),
   loadMore: jest.fn(),
   // unknown required because of the missing private methods: _xIdle, _xLoading, _fetch, _replaceAll, _append
 } as unknown as UserFollowersViewModel
@@ -869,7 +870,7 @@ export const mockedUserFollowsStore = {
   hasError: false,
   isEmpty: false,
   setup: jest.fn(),
-  refresh: jest.fn(),
+  refresh: jest.fn().mockResolvedValue({}),
   loadMore: jest.fn(),
   // unknown required because of the missing private methods: _xIdle, _xLoading, _fetch, _replaceAll, _append
 } as unknown as UserFollowsViewModel
diff --git a/__tests__/state/models/me.test.ts b/__tests__/state/models/me.test.ts
index fa8d49601..b69e43477 100644
--- a/__tests__/state/models/me.test.ts
+++ b/__tests__/state/models/me.test.ts
@@ -160,7 +160,7 @@ describe('MeModel', () => {
 
   it('should update notifs count with fetchStateUpdate()', async () => {
     meModel.notifications = {
-      refresh: jest.fn(),
+      refresh: jest.fn().mockResolvedValue({}),
     } as unknown as NotificationsViewModel
 
     jest
@@ -173,7 +173,7 @@ describe('MeModel', () => {
         })
       })
 
-    await meModel.fetchStateUpdate()
+    await meModel.fetchNotifications()
     expect(meModel.notificationCount).toBe(1)
     expect(meModel.notifications.refresh).toHaveBeenCalled()
   })
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index a7e9549e4..c85b74313 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -340,6 +340,8 @@ PODS:
     - React-perflogger (= 0.71.0)
   - rn-fetch-blob (0.12.0):
     - React-Core
+  - RNBackgroundFetch (4.1.8):
+    - React-Core
   - RNCAsyncStorage (1.17.11):
     - React-Core
   - RNCClipboard (1.11.1):
@@ -359,6 +361,11 @@ PODS:
     - TOCropViewController
   - RNInAppBrowser (3.7.0):
     - React-Core
+  - RNNotifee (7.4.0):
+    - React-Core
+    - RNNotifee/NotifeeCore (= 7.4.0)
+  - RNNotifee/NotifeeCore (7.4.0):
+    - React-Core
   - RNReactNativeHapticFeedback (1.14.0):
     - React-Core
   - RNReanimated (2.13.0):
@@ -448,12 +455,14 @@ DEPENDENCIES:
   - React-runtimeexecutor (from `../node_modules/react-native/ReactCommon/runtimeexecutor`)
   - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`)
   - rn-fetch-blob (from `../node_modules/rn-fetch-blob`)
+  - RNBackgroundFetch (from `../node_modules/react-native-background-fetch`)
   - "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)"
   - "RNCClipboard (from `../node_modules/@react-native-clipboard/clipboard`)"
   - RNFS (from `../node_modules/react-native-fs`)
   - RNGestureHandler (from `../node_modules/react-native-gesture-handler`)
   - RNImageCropPicker (from `../node_modules/react-native-image-crop-picker`)
   - RNInAppBrowser (from `../node_modules/react-native-inappbrowser-reborn`)
+  - "RNNotifee (from `../node_modules/@notifee/react-native`)"
   - RNReactNativeHapticFeedback (from `../node_modules/react-native-haptic-feedback`)
   - RNReanimated (from `../node_modules/react-native-reanimated`)
   - RNScreens (from `../node_modules/react-native-screens`)
@@ -556,6 +565,8 @@ EXTERNAL SOURCES:
     :path: "../node_modules/react-native/ReactCommon"
   rn-fetch-blob:
     :path: "../node_modules/rn-fetch-blob"
+  RNBackgroundFetch:
+    :path: "../node_modules/react-native-background-fetch"
   RNCAsyncStorage:
     :path: "../node_modules/@react-native-async-storage/async-storage"
   RNCClipboard:
@@ -568,6 +579,8 @@ EXTERNAL SOURCES:
     :path: "../node_modules/react-native-image-crop-picker"
   RNInAppBrowser:
     :path: "../node_modules/react-native-inappbrowser-reborn"
+  RNNotifee:
+    :path: "../node_modules/@notifee/react-native"
   RNReactNativeHapticFeedback:
     :path: "../node_modules/react-native-haptic-feedback"
   RNReanimated:
@@ -629,12 +642,14 @@ SPEC CHECKSUMS:
   React-runtimeexecutor: ac80782d9d76ba2b0f709f4de0c427fe33c352dc
   ReactCommon: 20e38a9be5fe1341b5e422220877cc94034776ba
   rn-fetch-blob: f065bb7ab7fb48dd002629f8bdcb0336602d3cba
+  RNBackgroundFetch: 8e16176ff415daac743a6eb57afc8e9e14dbe623
   RNCAsyncStorage: 8616bd5a58af409453ea4e1b246521bb76578d60
   RNCClipboard: 2834e1c4af68697089cdd455ee4a4cdd198fa7dd
   RNFS: 4ac0f0ea233904cb798630b3c077808c06931688
   RNGestureHandler: 62232ba8f562f7dea5ba1b3383494eb5bf97a4d3
   RNImageCropPicker: 648356d68fbf9911a1016b3e3723885d28373eda
   RNInAppBrowser: e36d6935517101ccba0e875bac8ad7b0cb655364
+  RNNotifee: da8dcf09f079ea22f46e239d7c406e10d4525a5f
   RNReactNativeHapticFeedback: 1e3efeca9628ff9876ee7cdd9edec1b336913f8c
   RNReanimated: d8d9d3d3801bda5e35e85cdffc871577d044dc2e
   RNScreens: 34cc502acf1b916c582c60003dc3089fa01dc66d
diff --git a/package.json b/package.json
index efd8aa40f..cb30b5711 100644
--- a/package.json
+++ b/package.json
@@ -25,6 +25,7 @@
     "@fortawesome/react-native-fontawesome": "^0.3.0",
     "@gorhom/bottom-sheet": "^4",
     "@mattermost/react-native-paste-input": "^0.6.0",
+    "@notifee/react-native": "^7.4.0",
     "@react-native-async-storage/async-storage": "^1.17.6",
     "@react-native-camera-roll/camera-roll": "^5.1.0",
     "@react-native-clipboard/clipboard": "^1.10.0",
@@ -45,6 +46,7 @@
     "react-dom": "17.0.2",
     "react-native": "0.71.0",
     "react-native-appstate-hook": "^1.0.6",
+    "react-native-background-fetch": "^4.1.8",
     "react-native-fs": "^2.20.0",
     "react-native-gesture-handler": "^2.5.0",
     "react-native-haptic-feedback": "^1.14.0",
diff --git a/src/App.native.tsx b/src/App.native.tsx
index 30747dbfc..f00e3cad1 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -16,6 +16,7 @@ import * as view from './view/index'
 import {RootStoreModel, setupState, RootStoreProvider} from './state'
 import {MobileShell} from './view/shell/mobile'
 import {s} from './view/lib/styles'
+import notifee, {EventType} from '@notifee/react-native'
 
 const App = observer(() => {
   const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
@@ -43,6 +44,13 @@ const App = observer(() => {
       Linking.addEventListener('url', ({url}) => {
         store.nav.handleLink(url)
       })
+      notifee.onForegroundEvent(async ({type}: {type: EventType}) => {
+        store.log.debug('Notifee foreground event', {type})
+        if (type === EventType.PRESS) {
+          store.log.debug('User pressed a notifee, opening notifications')
+          store.nav.switchTo(1, true)
+        }
+      })
     })
   }, [])
 
diff --git a/src/state/models/me.ts b/src/state/models/me.ts
index 201ce04c7..da46c60cf 100644
--- a/src/state/models/me.ts
+++ b/src/state/models/me.ts
@@ -1,4 +1,5 @@
 import {makeAutoObservable, runInAction} from 'mobx'
+import notifee from '@notifee/react-native'
 import {RootStoreModel} from './root-store'
 import {FeedModel} from './feed-view'
 import {NotificationsViewModel} from './notifications-view'
@@ -104,6 +105,9 @@ export class MeModel {
           this.rootStore.log.error('Failed to setup notifications model', e)
         }),
       ])
+
+      // request notifications permission once the user has logged in
+      notifee.requestPermission()
     } else {
       this.clear()
     }
@@ -111,16 +115,28 @@ export class MeModel {
 
   clearNotificationCount() {
     this.notificationCount = 0
+    notifee.setBadgeCount(0)
   }
 
-  async fetchStateUpdate() {
+  async fetchNotifications() {
     const res = await this.rootStore.api.app.bsky.notification.getCount()
     runInAction(() => {
       const newNotifications = this.notificationCount !== res.data.count
       this.notificationCount = res.data.count
+      notifee.setBadgeCount(this.notificationCount)
       if (newNotifications) {
         // trigger pre-emptive fetch on new notifications
-        this.notifications.refresh()
+        let oldMostRecent = this.notifications.mostRecentNotification
+        this.notifications.refresh().then(() => {
+          // if a new most recent notification is found, trigger a notification card
+          const mostRecent = this.notifications.mostRecentNotification
+          if (mostRecent && oldMostRecent?.uri !== mostRecent?.uri) {
+            const notifeeOpts = mostRecent.toNotifeeOpts()
+            if (notifeeOpts) {
+              notifee.displayNotification(notifeeOpts)
+            }
+          }
+        })
       }
     })
   }
diff --git a/src/state/models/notifications-view.ts b/src/state/models/notifications-view.ts
index 32294ef33..34bb57f6e 100644
--- a/src/state/models/notifications-view.ts
+++ b/src/state/models/notifications-view.ts
@@ -7,6 +7,7 @@ import {
   AppBskyFeedVote,
   AppBskyGraphAssertion,
   AppBskyGraphFollow,
+  AppBskyEmbedImages,
 } from '@atproto/api'
 import {RootStoreModel} from './root-store'
 import {PostThreadViewModel} from './post-thread-view'
@@ -179,6 +180,42 @@ export class NotificationsViewItemModel {
       })
     }
   }
+
+  toNotifeeOpts() {
+    let author = this.author.displayName || this.author.handle
+    let title: string
+    let body: string = ''
+    if (this.isUpvote) {
+      title = `${author} liked your post`
+      body = this.additionalPost?.thread?.postRecord?.text || ''
+    } else if (this.isRepost) {
+      title = `${author} reposted your post`
+      body = this.additionalPost?.thread?.postRecord?.text || ''
+    } else if (this.isReply) {
+      title = `${author} replied to your post`
+      body = this.additionalPost?.thread?.postRecord?.text || ''
+    } else if (this.isFollow) {
+      title = `${author} followed you`
+    } else {
+      return undefined
+    }
+    let ios
+    if (
+      AppBskyEmbedImages.isPresented(this.additionalPost?.thread?.post.embed) &&
+      this.additionalPost?.thread?.post.embed.images[0]?.thumb
+    ) {
+      ios = {
+        attachments: [
+          {url: this.additionalPost.thread.post.embed.images[0].thumb},
+        ],
+      }
+    }
+    return {
+      title,
+      body,
+      ios,
+    }
+  }
 }
 
 export class NotificationsViewModel {
@@ -197,6 +234,9 @@ export class NotificationsViewModel {
   // data
   notifications: NotificationsViewItemModel[] = []
 
+  // this is used to trigger push notifications
+  mostRecentNotification: NotificationsViewItemModel | undefined
+
   constructor(
     public rootStore: RootStoreModel,
     params: ListNotifications.QueryParams,
@@ -388,6 +428,16 @@ export class NotificationsViewModel {
   }
 
   private async _replaceAll(res: ListNotifications.Response) {
+    if (res.data.notifications[0]) {
+      this.mostRecentNotification = new NotificationsViewItemModel(
+        this.rootStore,
+        'mostRecent',
+        res.data.notifications[0],
+      )
+      await this.mostRecentNotification.fetchAdditionalData()
+    } else {
+      this.mostRecentNotification = undefined
+    }
     return this._appendAll(res, true)
   }
 
diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts
index 73f1c452f..55dbbcfee 100644
--- a/src/state/models/root-store.ts
+++ b/src/state/models/root-store.ts
@@ -6,6 +6,7 @@ import {makeAutoObservable} from 'mobx'
 import {sessionClient as AtpApi, SessionServiceClient} from '@atproto/api'
 import {createContext, useContext} from 'react'
 import {DeviceEventEmitter, EmitterSubscription} from 'react-native'
+import BackgroundFetch from 'react-native-background-fetch'
 import {isObj, hasProp} from '../lib/type-guards'
 import {LogModel} from './log'
 import {SessionModel} from './session'
@@ -34,6 +35,7 @@ export class RootStoreModel {
       serialize: false,
       hydrate: false,
     })
+    this.initBgFetch()
   }
 
   async resolveName(didOrHandle: string) {
@@ -55,7 +57,7 @@ export class RootStoreModel {
       if (!this.session.online) {
         await this.session.connect()
       }
-      await this.me.fetchStateUpdate()
+      await this.me.fetchNotifications()
     } catch (e: any) {
       if (isNetworkError(e)) {
         this.session.setOnline(false) // connection lost
@@ -109,9 +111,41 @@ export class RootStoreModel {
   }
 
   emitPostDeleted(uri: string) {
-    console.log('emit')
     DeviceEventEmitter.emit('post-deleted', uri)
   }
+
+  // background fetch
+  // =
+  // - we use this to poll for unread notifications, which is not "ideal" behavior but
+  //   gives us a solution for push-notifications that work against any pds
+
+  initBgFetch() {
+    // NOTE
+    // background fetch runs every 15 minutes *at most* and will get slowed down
+    // based on some heuristics run by iOS, meaning it is not a reliable form of delivery
+    // -prf
+    BackgroundFetch.configure(
+      {minimumFetchInterval: 15},
+      this.onBgFetch.bind(this),
+      this.onBgFetchTimeout.bind(this),
+    ).then(status => {
+      this.log.debug(`Background fetch initiated, status: ${status}`)
+    })
+  }
+
+  async onBgFetch(taskId: string) {
+    this.log.debug(`Background fetch fired for task ${taskId}`)
+    if (this.session.hasSession) {
+      // grab notifications
+      await this.me.fetchNotifications()
+    }
+    BackgroundFetch.finish(taskId)
+  }
+
+  onBgFetchTimeout(taskId: string) {
+    this.log.debug(`Background fetch timed out for task ${taskId}`)
+    BackgroundFetch.finish(taskId)
+  }
 }
 
 const throwawayInst = new RootStoreModel(AtpApi.service('http://localhost')) // this will be replaced by the loader, we just need to supply a value at init
diff --git a/src/state/models/session.ts b/src/state/models/session.ts
index 89347af9a..77c1fb595 100644
--- a/src/state/models/session.ts
+++ b/src/state/models/session.ts
@@ -286,17 +286,33 @@ export class SessionModel {
    * Attempt to resume a session that we still have access tokens for.
    */
   async resumeSession(account: AccountData): Promise<boolean> {
-    if (account.accessJwt && account.refreshJwt) {
-      this.setState({
-        service: account.service,
-        accessJwt: account.accessJwt,
-        refreshJwt: account.refreshJwt,
-        handle: account.handle,
-        did: account.did,
-      })
-    } else {
+    if (!(account.accessJwt && account.refreshJwt && account.service)) {
       return false
     }
+
+    // test that the session is good
+    const api = AtpApi.service(account.service)
+    api.sessionManager.set({
+      refreshJwt: account.refreshJwt,
+      accessJwt: account.accessJwt,
+    })
+    try {
+      const sess = await api.com.atproto.session.get()
+      if (!sess.success || sess.data.did !== account.did) {
+        return false
+      }
+    } catch (_e) {
+      return false
+    }
+
+    // session is good, connect
+    this.setState({
+      service: account.service,
+      accessJwt: account.accessJwt,
+      refreshJwt: account.refreshJwt,
+      handle: account.handle,
+      did: account.did,
+    })
     return this.connect()
   }
 
@@ -345,14 +361,14 @@ export class SessionModel {
    * Close all sessions across all accounts.
    */
   async logout() {
-    if (this.hasSession) {
+    /*if (this.hasSession) {
       this.rootStore.api.com.atproto.session.delete().catch((e: any) => {
         this.rootStore.log.warn(
           '(Minor issue) Failed to delete session on the server',
           e,
         )
       })
-    }
+    }*/
     this.clearSessionTokensFromAccounts()
     this.rootStore.clearAll()
   }
diff --git a/yarn.lock b/yarn.lock
index 248065890..27b2f1f19 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2109,6 +2109,11 @@
     "@nodelib/fs.scandir" "2.1.5"
     fastq "^1.6.0"
 
+"@notifee/react-native@^7.4.0":
+  version "7.4.0"
+  resolved "https://registry.yarnpkg.com/@notifee/react-native/-/react-native-7.4.0.tgz#0f20744307bf3b800f7b56eb2d0bbdd474748d09"
+  integrity sha512-c8pkxDQFRbw0JlUmTb07OTG/4LQHRj8MBodMLwEcO+SvqIxK8ya8zSUEzfdcdWsSVqdoym0v3zpSNroR3Quj/w==
+
 "@pmmmwh/react-refresh-webpack-plugin@^0.5.3":
   version "0.5.10"
   resolved "https://registry.yarnpkg.com/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.10.tgz#2eba163b8e7dbabb4ce3609ab5e32ab63dda3ef8"
@@ -11164,6 +11169,11 @@ react-native-appstate-hook@^1.0.6:
   resolved "https://registry.yarnpkg.com/react-native-appstate-hook/-/react-native-appstate-hook-1.0.6.tgz#cbc16e7b89cfaea034cabd999f00e99053cabd06"
   integrity sha512-0hPVyf5yLxCSVrrNEuGqN1ZnSSj3Ye2gZex0NtcK/AHYwMc0rXWFNZjBKOoZSouspqu3hXBbQ6NOUSTzrME1AQ==
 
+react-native-background-fetch@^4.1.8:
+  version "4.1.8"
+  resolved "https://registry.yarnpkg.com/react-native-background-fetch/-/react-native-background-fetch-4.1.8.tgz#a21858e5d876de8d9d15a37f40714b244f73713c"
+  integrity sha512-/qe86laa0n4AbD6mrLL8SCGR+K5693URX95e02/bTJh3UkdS3+sU1Jyc/XTlz4MQwlquI929/lm5EZh8AOUqzQ==
+
 react-native-codegen@^0.71.3:
   version "0.71.3"
   resolved "https://registry.yarnpkg.com/react-native-codegen/-/react-native-codegen-0.71.3.tgz#75fbc591819050791319ebdb9fe341ee4df5c288"