From 8ab5eb6583b6ddd4ed03ef2b1a55ef83fa0c0625 Mon Sep 17 00:00:00 2001 From: Ansh Date: Wed, 23 Aug 2023 16:28:51 -0700 Subject: [PATCH] [APP-786] Native notifications (#1095) * move `notifee.ts` to notifications folder * install expo notifications * add UIBackgroundMode `remote-notifications` to app.json * fix notifee import in Debug.tsx * add `google-services.json` * add `development-device` class to eas.json * Add `notifications.ts` for native notification handling * send push token to server * update `@atproto/api` * fix putting notif token to server * fix how push token is uploaded * fix lint * enable debug appview proxy header on all platforms * setup `notifications.ts` to work with app view notifs * clean up notification handler * add comments * update packages to correct versions * remove notifee * clean up code a lil * rename push token endpoint * remove unnecessary comments * fix comments * Remove old background scheduler * Fixes to push notifications API use * Bump @atproto/api@0.6.6 --------- Co-authored-by: Paul Frazee --- .eslintrc.js | 1 + .gitignore | 5 +- app.json | 8 +- eas.json | 14 ++- package.json | 5 +- src/App.native.tsx | 4 +- src/lib/api/debug-appview-proxy-header.ts | 39 +++---- src/lib/bg-scheduler.ts | 18 ---- src/lib/bg-scheduler.web.ts | 13 --- src/lib/notifee.ts | 82 --------------- src/lib/notifications/notifications.ts | 101 ++++++++++++++++++ src/platform/detection.ts | 1 + src/state/models/feeds/notifications.ts | 30 ------ src/state/models/root-store.ts | 59 ----------- src/view/screens/Debug.tsx | 7 +- src/view/screens/Settings.tsx | 2 +- tsconfig.check.json | 2 +- yarn.lock | 122 +++++++++++++++++++--- 18 files changed, 253 insertions(+), 260 deletions(-) delete mode 100644 src/lib/bg-scheduler.ts delete mode 100644 src/lib/bg-scheduler.web.ts delete mode 100644 src/lib/notifee.ts create mode 100644 src/lib/notifications/notifications.ts diff --git a/.eslintrc.js b/.eslintrc.js index c5b7e861..a6881033 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -17,6 +17,7 @@ module.exports = { '.husky', 'patches', '*.html', + 'bskyweb', ], overrides: [ { diff --git a/.gitignore b/.gitignore index 2fa850bf..66658f8e 100644 --- a/.gitignore +++ b/.gitignore @@ -96,4 +96,7 @@ ios/ # environment variables .env -.env.* \ No newline at end of file +.env.* + +# Firebase (Android) Google services +google-services.json \ No newline at end of file diff --git a/app.json b/app.json index 4ee73752..55250fb7 100644 --- a/app.json +++ b/app.json @@ -25,11 +25,7 @@ }, "infoPlist": { "UIBackgroundModes": [ - "fetch", - "processing" - ], - "BGTaskSchedulerPermittedIdentifiers": [ - "com.transistorsoft.fetch" + "remote-notification" ], "NSCameraUsageDescription": "Used for profile pictures, posts, and other kinds of content.", "NSMicrophoneUsageDescription": "Used for posts and other kinds of content.", @@ -48,6 +44,7 @@ "foregroundImage": "./assets/adaptive-icon.png", "backgroundColor": "#ffffff" }, + "googleServicesFile": "./google-services.json", "package": "xyz.blueskyweb.app", "intentFilters": [ { @@ -73,7 +70,6 @@ }, "plugins": [ "expo-localization", - "react-native-background-fetch", "sentry-expo", [ "expo-build-properties", diff --git a/eas.json b/eas.json index 60c8be37..30c3cce4 100644 --- a/eas.json +++ b/eas.json @@ -9,20 +9,28 @@ "distribution": "internal", "ios": { "simulator": true, - "resourceClass": "medium" + "resourceClass": "large" + }, + "channel": "development" + }, + "development-device": { + "developmentClient": true, + "distribution": "internal", + "ios": { + "resourceClass": "large" }, "channel": "development" }, "preview": { "distribution": "internal", "ios": { - "resourceClass": "medium" + "resourceClass": "large" }, "channel": "preview" }, "production": { "ios": { - "resourceClass": "medium" + "resourceClass": "large" }, "channel": "production" } diff --git a/package.json b/package.json index 5eb4780a..57ad831a 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all" }, "dependencies": { - "@atproto/api": "^0.6.5", + "@atproto/api": "^0.6.6", "@bam.tech/react-native-image-resizer": "^3.0.4", "@braintree/sanitize-url": "^6.0.2", "@expo/html-elements": "^0.4.2", @@ -36,7 +36,6 @@ "@gorhom/bottom-sheet": "^4.4.7", "@mattermost/react-native-paste-input": "^0.6.4", "@miblanchard/react-native-slider": "^2.2.0", - "@notifee/react-native": "^7.4.0", "@react-native-async-storage/async-storage": "^1.17.6", "@react-native-camera-roll/camera-roll": "^5.2.2", "@react-native-clipboard/clipboard": "^1.10.0", @@ -83,6 +82,7 @@ "expo-image-picker": "^14.1.1", "expo-localization": "~14.1.1", "expo-media-library": "~15.2.3", + "expo-notifications": "~0.18.1", "expo-sharing": "~11.2.2", "expo-splash-screen": "~0.18.2", "expo-status-bar": "~1.4.4", @@ -114,7 +114,6 @@ "react-dom": "^18.2.0", "react-native": "0.71.8", "react-native-appstate-hook": "^1.0.6", - "react-native-background-fetch": "^4.1.8", "react-native-draggable-flatlist": "^4.0.1", "react-native-drawer-layout": "^3.2.0", "react-native-fs": "^2.20.0", diff --git a/src/App.native.tsx b/src/App.native.tsx index a02ca62c..ad37aa09 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -12,7 +12,7 @@ import {s} from 'lib/styles' import * as view from './view/index' import {RootStoreModel, setupState, RootStoreProvider} from './state' import {Shell} from './view/shell' -import * as notifee from 'lib/notifee' +import * as notifications from 'lib/notifications/notifications' import * as analytics from 'lib/analytics/analytics' import * as Toast from './view/com/util/Toast' import {handleLink} from './Navigation' @@ -30,7 +30,7 @@ const App = observer(() => { setupState().then(store => { setRootStore(store) analytics.init(store) - notifee.init(store) + notifications.init(store) SplashScreen.hideAsync() Linking.getInitialURL().then((url: string | null) => { if (url) { diff --git a/src/lib/api/debug-appview-proxy-header.ts b/src/lib/api/debug-appview-proxy-header.ts index 39890b7c..7571bd37 100644 --- a/src/lib/api/debug-appview-proxy-header.ts +++ b/src/lib/api/debug-appview-proxy-header.ts @@ -8,23 +8,30 @@ * version of the app. */ -import {useState, useCallback} from 'react' +import {useState, useCallback, useEffect} from 'react' import {BskyAgent} from '@atproto/api' import {isWeb} from 'platform/detection' +import * as Storage from 'lib/storage' export function useDebugHeaderSetting(agent: BskyAgent): [boolean, () => void] { - const [enabled, setEnabled] = useState(isEnabled()) + const [enabled, setEnabled] = useState(false) + + useEffect(() => { + async function check() { + if (await isEnabled()) { + setEnabled(true) + } + } + check() + }, []) const toggle = useCallback(() => { - if (!isWeb || typeof window === 'undefined') { - return - } if (!enabled) { - localStorage.setItem('set-header-x-appview-proxy', 'yes') + Storage.saveString('set-header-x-appview-proxy', 'yes') agent.api.xrpc.setHeader('x-appview-proxy', 'true') setEnabled(true) } else { - localStorage.removeItem('set-header-x-appview-proxy') + Storage.remove('set-header-x-appview-proxy') agent.api.xrpc.unsetHeader('x-appview-proxy') setEnabled(false) } @@ -34,30 +41,24 @@ export function useDebugHeaderSetting(agent: BskyAgent): [boolean, () => void] { } export function setDebugHeader(agent: BskyAgent, enabled: boolean) { - if (!isWeb || typeof window === 'undefined') { - return - } if (enabled) { - localStorage.setItem('set-header-x-appview-proxy', 'yes') + Storage.saveString('set-header-x-appview-proxy', 'yes') agent.api.xrpc.setHeader('x-appview-proxy', 'true') } else { - localStorage.removeItem('set-header-x-appview-proxy') + Storage.remove('set-header-x-appview-proxy') agent.api.xrpc.unsetHeader('x-appview-proxy') } } -export function applyDebugHeader(agent: BskyAgent) { +export async function applyDebugHeader(agent: BskyAgent) { if (!isWeb) { return } - if (isEnabled()) { + if (await isEnabled()) { agent.api.xrpc.setHeader('x-appview-proxy', 'true') } } -function isEnabled() { - if (!isWeb || typeof window === 'undefined') { - return false - } - return localStorage.getItem('set-header-x-appview-proxy') === 'yes' +async function isEnabled() { + return (await Storage.loadString('set-header-x-appview-proxy')) === 'yes' } diff --git a/src/lib/bg-scheduler.ts b/src/lib/bg-scheduler.ts deleted file mode 100644 index db3f2d7f..00000000 --- a/src/lib/bg-scheduler.ts +++ /dev/null @@ -1,18 +0,0 @@ -import BackgroundFetch, { - BackgroundFetchStatus, -} from 'react-native-background-fetch' - -export function configure( - handler: (taskId: string) => Promise, - timeoutHandler: (taskId: string) => void, -): Promise { - return BackgroundFetch.configure( - {minimumFetchInterval: 15}, - handler, - timeoutHandler, - ) -} - -export function finish(taskId: string) { - return BackgroundFetch.finish(taskId) -} diff --git a/src/lib/bg-scheduler.web.ts b/src/lib/bg-scheduler.web.ts deleted file mode 100644 index 91ec9428..00000000 --- a/src/lib/bg-scheduler.web.ts +++ /dev/null @@ -1,13 +0,0 @@ -type BackgroundFetchStatus = 0 | 1 | 2 - -export async function configure( - _handler: (taskId: string) => Promise, - _timeoutHandler: (taskId: string) => Promise, -): Promise { - // TODO - return 0 -} - -export function finish(_taskId: string) { - // TODO -} diff --git a/src/lib/notifee.ts b/src/lib/notifee.ts deleted file mode 100644 index 485d79ae..00000000 --- a/src/lib/notifee.ts +++ /dev/null @@ -1,82 +0,0 @@ -import notifee, {EventType} from '@notifee/react-native' -import {AppBskyEmbedImages, AtUri} from '@atproto/api' -import {RootStoreModel} from 'state/models/root-store' -import {NotificationsFeedItemModel} from 'state/models/feeds/notifications' -import {enforceLen} from 'lib/strings/helpers' -import {sanitizeDisplayName} from './strings/display-names' -import {resetToTab} from '../Navigation' - -export function init(store: RootStoreModel) { - store.onUnreadNotifications(count => notifee.setBadgeCount(count)) - store.onPushNotification(displayNotificationFromModel) - store.onSessionLoaded(() => { - // request notifications permission once the user has logged in - notifee.requestPermission() - }) - 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') - resetToTab('NotificationsTab') - } - }) - notifee.onBackgroundEvent(async _e => {}) // notifee requires this but we handle it with onForegroundEvent -} - -export function displayNotification( - title: string, - body?: string, - image?: string, -) { - const opts: {title: string; body?: string; ios?: any} = {title} - if (body) { - opts.body = enforceLen(body, 70, true) - } - if (image) { - opts.ios = { - attachments: [{url: image}], - } - } - return notifee.displayNotification(opts) -} - -export function displayNotificationFromModel( - notification: NotificationsFeedItemModel, -) { - let author = sanitizeDisplayName( - notification.author.displayName || notification.author.handle, - ) - let title: string - let body: string = '' - if (notification.isLike) { - title = `${author} liked your post` - body = notification.additionalPost?.thread?.postRecord?.text || '' - } else if (notification.isRepost) { - title = `${author} reposted your post` - body = notification.additionalPost?.thread?.postRecord?.text || '' - } else if (notification.isMention) { - title = `${author} mentioned you` - body = notification.additionalPost?.thread?.postRecord?.text || '' - } else if (notification.isReply) { - title = `${author} replied to your post` - body = notification.additionalPost?.thread?.postRecord?.text || '' - } else if (notification.isFollow) { - title = 'New follower!' - body = `${author} has followed you` - } else if (notification.isCustomFeedLike) { - title = `${author} liked your custom feed` - body = `${new AtUri(notification.subjectUri).rkey}` - } else { - return - } - let image - if ( - AppBskyEmbedImages.isView( - notification.additionalPost?.thread?.post.embed, - ) && - notification.additionalPost?.thread?.post.embed.images[0]?.thumb - ) { - image = notification.additionalPost.thread.post.embed.images[0].thumb - } - return displayNotification(title, body, image) -} diff --git a/src/lib/notifications/notifications.ts b/src/lib/notifications/notifications.ts new file mode 100644 index 00000000..b517b40b --- /dev/null +++ b/src/lib/notifications/notifications.ts @@ -0,0 +1,101 @@ +import * as Notifications from 'expo-notifications' +import {RootStoreModel} from '../../state' +import {resetToTab} from '../../Navigation' +import {devicePlatform, isIOS} from 'platform/detection' + +// TODO prod did = did:web:api.bsky.app + +export function init(store: RootStoreModel) { + store.onUnreadNotifications(count => Notifications.setBadgeCountAsync(count)) + + store.onSessionLoaded(async () => { + // request notifications permission once the user has logged in + const perms = await Notifications.getPermissionsAsync() + if (!perms.granted) { + await Notifications.requestPermissionsAsync() + } + + // register the push token with the server + const token = await getPushToken() + if (token) { + try { + await store.agent.api.app.bsky.notification.registerPush({ + serviceDid: 'did:web:api.staging.bsky.dev', + platform: devicePlatform, + token: token.data, + appId: 'xyz.blueskyweb.app', + }) + store.log.debug('Notifications: Sent push token (init)', { + type: token.type, + token: token.data, + }) + } catch (error) { + store.log.error('Notifications: Failed to set push token', error) + } + } + + // listens for new changes to the push token + // In rare situations, a push token may be changed by the push notification service while the app is running. When a token is rolled, the old one becomes invalid and sending notifications to it will fail. A push token listener will let you handle this situation gracefully by registering the new token with your backend right away. + Notifications.addPushTokenListener(async ({data: t, type}) => { + store.log.debug('Notifications: Push token changed', {t, type}) + if (t) { + try { + await store.agent.api.app.bsky.notification.registerPush({ + serviceDid: 'did:web:api.staging.bsky.dev', + platform: devicePlatform, + token: t, + appId: 'xyz.blueskyweb.app', + }) + store.log.debug('Notifications: Sent push token (event)', { + type, + token: t, + }) + } catch (error) { + store.log.error('Notifications: Failed to set push token', error) + } + } + }) + }) + + // handle notifications that are tapped on, regardless of whether the app is in the foreground or background + Notifications.addNotificationReceivedListener(event => { + store.log.debug('Notifications: received', event) + if (event.request.trigger.type === 'push') { + let payload + if (isIOS) { + payload = event.request.trigger.payload + } else { + // TODO: handle android payload deeplink + } + if (payload) { + store.log.debug('Notifications: received payload', payload) + // TODO: deeplink notif here + } + } + }) + + const sub = Notifications.addNotificationResponseReceivedListener( + response => { + store.log.debug( + 'Notifications: response received', + response.actionIdentifier, + ) + if ( + response.actionIdentifier === Notifications.DEFAULT_ACTION_IDENTIFIER + ) { + store.log.debug( + 'User pressed a notification, opening notifications tab', + ) + resetToTab('NotificationsTab') + } + }, + ) + + return () => { + sub.remove() + } +} + +export function getPushToken() { + return Notifications.getDevicePushTokenAsync() +} diff --git a/src/platform/detection.ts b/src/platform/detection.ts index 3069c9be..41ca20e5 100644 --- a/src/platform/detection.ts +++ b/src/platform/detection.ts @@ -5,6 +5,7 @@ import {dedupArray} from 'lib/functions' export const isIOS = Platform.OS === 'ios' export const isAndroid = Platform.OS === 'android' export const isNative = isIOS || isAndroid +export const devicePlatform = isIOS ? 'ios' : isAndroid ? 'android' : 'web' export const isWeb = !isNative export const isMobileWebMediaQuery = 'only screen and (max-width: 1230px)' export const isMobileWeb = diff --git a/src/state/models/feeds/notifications.ts b/src/state/models/feeds/notifications.ts index f5285307..a9836d38 100644 --- a/src/state/models/feeds/notifications.ts +++ b/src/state/models/feeds/notifications.ts @@ -478,36 +478,6 @@ export class NotificationsFeedModel { } } - /** - * Used in background fetch to trigger notifications - */ - async getNewMostRecent(): Promise { - let old = this.mostRecentNotificationUri - const res = await this.rootStore.agent.listNotifications({ - limit: 1, - }) - if (!res.data.notifications[0] || old === res.data.notifications[0].uri) { - return - } - this.mostRecentNotificationUri = res.data.notifications[0].uri - const notif = new NotificationsFeedItemModel( - this.rootStore, - 'mostRecent', - res.data.notifications[0], - ) - const addedUri = notif.additionalDataUri - if (addedUri) { - const postsRes = await this.rootStore.agent.app.bsky.feed.getPosts({ - uris: [addedUri], - }) - const post = postsRes.data.posts[0] - notif.setAdditionalData(post) - this.rootStore.posts.set(post.uri, post) - } - const filtered = this._filterNotifications([notif]) - return filtered[0] - } - // state transitions // = diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index 6ced8090..1d6d3a0d 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -6,7 +6,6 @@ import {makeAutoObservable} from 'mobx' import {BskyAgent} from '@atproto/api' import {createContext, useContext} from 'react' import {DeviceEventEmitter, EmitterSubscription} from 'react-native' -import * as BgScheduler from 'lib/bg-scheduler' import {z} from 'zod' import {isObj, hasProp} from 'lib/type-guards' import {LogModel} from './log' @@ -16,7 +15,6 @@ import {HandleResolutionsCache} from './cache/handle-resolutions' import {ProfilesCache} from './cache/profiles-view' import {PostsCache} from './cache/posts' import {LinkMetasCache} from './cache/link-metas' -import {NotificationsFeedItemModel} from './feeds/notifications' import {MeModel} from './me' import {InvitedUsers} from './invited-users' import {PreferencesModel} from './ui/preferences' @@ -61,7 +59,6 @@ export class RootStoreModel { serialize: false, hydrate: false, }) - this.initBgFetch() } setAppInfo(info: AppInfo) { @@ -249,62 +246,6 @@ export class RootStoreModel { emitUnreadNotifications(count: number) { DeviceEventEmitter.emit('unread-notifications', count) } - - // a notification has been queued for push - onPushNotification( - handler: (notif: NotificationsFeedItemModel) => void, - ): EmitterSubscription { - return DeviceEventEmitter.addListener('push-notification', handler) - } - emitPushNotification(notif: NotificationsFeedItemModel) { - DeviceEventEmitter.emit('push-notification', notif) - } - - // 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 - BgScheduler.configure( - 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) { - const res = await this.agent.countUnreadNotifications() - const hasNewNotifs = this.me.notifications.unreadCount !== res.data.count - this.emitUnreadNotifications(res.data.count) - this.log.debug( - `Background fetch received unread count = ${res.data.count}`, - ) - if (hasNewNotifs) { - this.log.debug( - 'Background fetch detected potentially a new notification', - ) - const mostRecent = await this.me.notifications.getNewMostRecent() - if (mostRecent) { - this.log.debug('Got the notification, triggering a push') - this.emitPushNotification(mostRecent) - } - } - } - BgScheduler.finish(taskId) - } - - onBgFetchTimeout(taskId: string) { - this.log.debug(`Background fetch timed out for task ${taskId}`) - BgScheduler.finish(taskId) - } } const throwawayInst = new RootStoreModel( diff --git a/src/view/screens/Debug.tsx b/src/view/screens/Debug.tsx index 85202532..0e046420 100644 --- a/src/view/screens/Debug.tsx +++ b/src/view/screens/Debug.tsx @@ -5,9 +5,7 @@ import {ViewHeader} from '../com/util/ViewHeader' import {ThemeProvider, PaletteColorName} from 'lib/ThemeContext' import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' -import {displayNotification} from 'lib/notifee' import * as Toast from 'view/com/util/Toast' - import {Text} from '../com/util/text/Text' import {ViewSelector} from '../com/util/ViewSelector' import {EmptyState} from '../com/util/EmptyState' @@ -177,10 +175,7 @@ function ErrorView() { function NotifsView() { const triggerPush = () => { - displayNotification( - 'Paul Frazee liked your post', - "Hello world! This is a test of the notifications card. The text is long to see how that's handled.", - ) + // TODO: implement local notification for testing } const triggerToast = () => { Toast.show('The task has been completed') diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index 478fcaa0..f1d4767f 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -505,7 +505,7 @@ export const SettingsScreen = withAuthRequired( System log - {isDesktopWeb ? ( + {isDesktopWeb || __DEV__ ? (