diff options
author | Paul Frazee <pfrazee@gmail.com> | 2023-01-24 19:32:24 -0600 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-01-24 19:32:24 -0600 |
commit | 869f6c4e0e464b7f5be9ef5676210ae8844bd834 (patch) | |
tree | a9a823723099129bb25c4b57435925b481d54266 /src | |
parent | 21f5f4de157a73b3c4406461b2a36555b1bff228 (diff) | |
download | voidsky-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.tsx | 8 | ||||
-rw-r--r-- | src/state/models/me.ts | 20 | ||||
-rw-r--r-- | src/state/models/notifications-view.ts | 50 | ||||
-rw-r--r-- | src/state/models/root-store.ts | 38 | ||||
-rw-r--r-- | src/state/models/session.ts | 38 |
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() } |