[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:
parent
32b9648931
commit
8ab5eb6583
18 changed files with 253 additions and 260 deletions
|
@ -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'
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
101
src/lib/notifications/notifications.ts
Normal file
101
src/lib/notifications/notifications.ts
Normal 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()
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue