From 869f6c4e0e464b7f5be9ef5676210ae8844bd834 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Tue, 24 Jan 2023 19:32:24 -0600 Subject: [PATCH] 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() --- __mocks__/@notifee/react-native.ts | 6 +++ __mocks__/react-native-background-fetch.ts | 4 ++ __mocks__/state-mock.ts | 21 ++++----- __tests__/state/models/me.test.ts | 4 +- ios/Podfile.lock | 15 +++++++ package.json | 2 + src/App.native.tsx | 8 ++++ src/state/models/me.ts | 20 ++++++++- src/state/models/notifications-view.ts | 50 ++++++++++++++++++++++ src/state/models/root-store.ts | 38 +++++++++++++++- src/state/models/session.ts | 38 +++++++++++----- yarn.lock | 10 +++++ 12 files changed, 189 insertions(+), 27 deletions(-) create mode 100644 __mocks__/@notifee/react-native.ts create mode 100644 __mocks__/react-native-background-fetch.ts diff --git a/__mocks__/@notifee/react-native.ts b/__mocks__/@notifee/react-native.ts new file mode 100644 index 00000000..7e5ccec9 --- /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 00000000..0cb644c4 --- /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 129f9c85..f5676288 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 fa8d4960..b69e4347 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 a7e9549e..c85b7431 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 efd8aa40..cb30b571 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 30747dbf..f00e3cad 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( @@ -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 201ce04c..da46c60c 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 32294ef3..34bb57f6 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 73f1c452..55dbbcfe 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 89347af9..77c1fb59 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 { - 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 24806589..27b2f1f1 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"