[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 <pfrazee@gmail.com>
This commit is contained in:
Ansh 2023-08-23 16:28:51 -07:00 committed by GitHub
parent 32b9648931
commit 8ab5eb6583
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 253 additions and 260 deletions

View file

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

View file

@ -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<boolean>(isEnabled())
const [enabled, setEnabled] = useState<boolean>(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'
}

View file

@ -1,18 +0,0 @@
import BackgroundFetch, {
BackgroundFetchStatus,
} from 'react-native-background-fetch'
export function configure(
handler: (taskId: string) => Promise<void>,
timeoutHandler: (taskId: string) => void,
): Promise<BackgroundFetchStatus> {
return BackgroundFetch.configure(
{minimumFetchInterval: 15},
handler,
timeoutHandler,
)
}
export function finish(taskId: string) {
return BackgroundFetch.finish(taskId)
}

View file

@ -1,13 +0,0 @@
type BackgroundFetchStatus = 0 | 1 | 2
export async function configure(
_handler: (taskId: string) => Promise<void>,
_timeoutHandler: (taskId: string) => Promise<void>,
): Promise<BackgroundFetchStatus> {
// TODO
return 0
}
export function finish(_taskId: string) {
// TODO
}

View file

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

View file

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

View file

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

View file

@ -478,36 +478,6 @@ export class NotificationsFeedModel {
}
}
/**
* Used in background fetch to trigger notifications
*/
async getNewMostRecent(): Promise<NotificationsFeedItemModel | undefined> {
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
// =

View file

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

View file

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

View file

@ -505,7 +505,7 @@ export const SettingsScreen = withAuthRequired(
System log
</Text>
</TouchableOpacity>
{isDesktopWeb ? (
{isDesktopWeb || __DEV__ ? (
<ToggleButton
type="default-light"
label="Experiment: Use AppView Proxy"