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()
This commit is contained in:
Paul Frazee 2023-01-24 19:32:24 -06:00 committed by GitHub
parent 21f5f4de15
commit 869f6c4e0e
12 changed files with 189 additions and 27 deletions

View file

@ -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)
}
})
})
}, [])

View file

@ -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)
}
}
})
}
})
}

View file

@ -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)
}

View file

@ -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

View file

@ -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()
}