about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorPaul Frazee <pfrazee@gmail.com>2023-01-24 19:32:24 -0600
committerGitHub <noreply@github.com>2023-01-24 19:32:24 -0600
commit869f6c4e0e464b7f5be9ef5676210ae8844bd834 (patch)
treea9a823723099129bb25c4b57435925b481d54266 /src
parent21f5f4de157a73b3c4406461b2a36555b1bff228 (diff)
downloadvoidsky-869f6c4e0e464b7f5be9ef5676210ae8844bd834.tar.zst
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()
Diffstat (limited to 'src')
-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
5 files changed, 139 insertions, 15 deletions
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()
   }