Handle push notifications for DMs (#3895)

* add some better handling for notifications

prep merge

move `useNotificationsListener` into shell

progress

better structure

only show messages notifications while using app if it is the current account

progress

only emit on native

current chat emitter

only show alerts for the current chat

type

add logs

setup handlers

* remove event emitter

* just needs cleanup

* oops

* remove unnecessary `queryClient` param

* few fixes

* cleanup

* nit

* remove folds

* remove comment

* simplify if

* add back invalidate

* comment out other navigations for now

* rename type

* handle various navigation cases

* push to conversation from notification

* update badge in all cases except `chat-message`

* ensure no duplicate notifications

* rm unused `animationOnReplace`

* revert to using `goBack` in the conversation header

* add todo comment
zio/stable
Hailey 2024-05-09 10:04:05 -07:00 committed by GitHub
parent 1341845537
commit 17e3b946cb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 274 additions and 109 deletions

View File

@ -12,7 +12,6 @@ import {
import * as SplashScreen from 'expo-splash-screen' import * as SplashScreen from 'expo-splash-screen'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {Provider as StatsigProvider} from '#/lib/statsig/statsig' import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
import {logger} from '#/logger' import {logger} from '#/logger'
@ -22,7 +21,6 @@ import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts' import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
import {readLastActiveAccount} from '#/state/session/util' import {readLastActiveAccount} from '#/state/session/util'
import {useIntentHandler} from 'lib/hooks/useIntentHandler' import {useIntentHandler} from 'lib/hooks/useIntentHandler'
import {useNotificationsListener} from 'lib/notifications/notifications'
import {QueryProvider} from 'lib/react-query' import {QueryProvider} from 'lib/react-query'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {ThemeProvider} from 'lib/ThemeContext' import {ThemeProvider} from 'lib/ThemeContext'
@ -96,27 +94,25 @@ function InnerApp() {
// Resets the entire tree below when it changes: // Resets the entire tree below when it changes:
key={currentAccount?.did}> key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}> <QueryProvider currentDid={currentAccount?.did}>
<PushNotificationsListener> <StatsigProvider>
<StatsigProvider> <MessagesProvider>
<MessagesProvider> {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
{/* LabelDefsProvider MUST come before ModerationOptsProvider */} <LabelDefsProvider>
<LabelDefsProvider> <ModerationOptsProvider>
<ModerationOptsProvider> <LoggedOutViewProvider>
<LoggedOutViewProvider> <SelectedFeedProvider>
<SelectedFeedProvider> <UnreadNotifsProvider>
<UnreadNotifsProvider> <GestureHandlerRootView style={s.h100pct}>
<GestureHandlerRootView style={s.h100pct}> <TestCtrls />
<TestCtrls /> <Shell />
<Shell /> </GestureHandlerRootView>
</GestureHandlerRootView> </UnreadNotifsProvider>
</UnreadNotifsProvider> </SelectedFeedProvider>
</SelectedFeedProvider> </LoggedOutViewProvider>
</LoggedOutViewProvider> </ModerationOptsProvider>
</ModerationOptsProvider> </LabelDefsProvider>
</LabelDefsProvider> </MessagesProvider>
</MessagesProvider> </StatsigProvider>
</StatsigProvider>
</PushNotificationsListener>
</QueryProvider> </QueryProvider>
</React.Fragment> </React.Fragment>
</RootSiblingParent> </RootSiblingParent>
@ -127,12 +123,6 @@ function InnerApp() {
) )
} }
function PushNotificationsListener({children}: {children: React.ReactNode}) {
const queryClient = useQueryClient()
useNotificationsListener(queryClient)
return children
}
function App() { function App() {
const [isReady, setReady] = useState(false) const [isReady, setReady] = useState(false)

View File

@ -0,0 +1,225 @@
import React from 'react'
import * as Notifications from 'expo-notifications'
import {CommonActions, useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
import {logger} from '#/logger'
import {track} from 'lib/analytics/analytics'
import {useAccountSwitcher} from 'lib/hooks/useAccountSwitcher'
import {NavigationProp} from 'lib/routes/types'
import {logEvent} from 'lib/statsig/statsig'
import {useCurrentConvoId} from 'state/messages/current-convo-id'
import {RQKEY as RQKEY_NOTIFS} from 'state/queries/notifications/feed'
import {invalidateCachedUnreadPage} from 'state/queries/notifications/unread'
import {truncateAndInvalidate} from 'state/queries/util'
import {useSession} from 'state/session'
import {useLoggedOutViewControls} from 'state/shell/logged-out'
import {useCloseAllActiveElements} from 'state/util'
import {resetToTab} from '#/Navigation'
type NotificationReason =
| 'like'
| 'repost'
| 'follow'
| 'mention'
| 'reply'
| 'quote'
| 'chat-message'
type NotificationPayload =
| {
reason: Exclude<NotificationReason, 'chat-message'>
uri: string
subject: string
}
| {
reason: 'chat-message'
convoId: string
messageId: string
recipientDid: string
}
const DEFAULT_HANDLER_OPTIONS = {
shouldShowAlert: false,
shouldPlaySound: false,
shouldSetBadge: true,
}
// This needs to stay outside the hook to persist between account switches
let storedPayload: NotificationPayload | undefined
export function useNotificationsHandler() {
const queryClient = useQueryClient()
const {currentAccount, accounts} = useSession()
const {onPressSwitchAccount} = useAccountSwitcher()
const navigation = useNavigation<NavigationProp>()
const {currentConvoId} = useCurrentConvoId()
const {setShowLoggedOut} = useLoggedOutViewControls()
const closeAllActiveElements = useCloseAllActiveElements()
// Safety to prevent double handling of the same notification
const prevIdentifier = React.useRef('')
React.useEffect(() => {
const handleNotification = (payload?: NotificationPayload) => {
if (!payload) return
if (payload.reason === 'chat-message') {
if (payload.recipientDid !== currentAccount?.did && !storedPayload) {
storedPayload = payload
closeAllActiveElements()
const account = accounts.find(a => a.did === payload.recipientDid)
if (account) {
onPressSwitchAccount(account, 'Notification')
} else {
setShowLoggedOut(true)
}
} else {
navigation.dispatch(state => {
if (state.routes[0].name === 'Messages') {
return CommonActions.navigate('MessagesConversation', {
conversation: payload.convoId,
})
} else {
return CommonActions.navigate('MessagesTab', {
screen: 'Messages',
params: {
pushToConversation: payload.convoId,
},
})
}
})
}
} else {
switch (payload.reason) {
case 'like':
case 'repost':
case 'follow':
case 'mention':
case 'quote':
case 'reply':
resetToTab('NotificationsTab')
break
// TODO implement these after we have an idea of how to handle each individual case
// case 'follow':
// const uri = new AtUri(payload.uri)
// setTimeout(() => {
// // @ts-expect-error types are weird here
// navigation.navigate('HomeTab', {
// screen: 'Profile',
// params: {
// name: uri.host,
// },
// })
// }, 500)
// break
// case 'mention':
// case 'reply':
// const urip = new AtUri(payload.uri)
// setTimeout(() => {
// // @ts-expect-error types are weird here
// navigation.navigate('HomeTab', {
// screen: 'PostThread',
// params: {
// name: urip.host,
// rkey: urip.rkey,
// },
// })
// }, 500)
}
}
}
Notifications.setNotificationHandler({
handleNotification: async e => {
if (e.request.trigger.type !== 'push') return DEFAULT_HANDLER_OPTIONS
logger.debug(
'Notifications: received',
{e},
logger.DebugContext.notifications,
)
const payload = e.request.trigger.payload as NotificationPayload
if (
payload.reason === 'chat-message' &&
payload.recipientDid === currentAccount?.did
) {
return {
shouldShowAlert: payload.convoId !== currentConvoId,
shouldPlaySound: false,
shouldSetBadge: false,
}
}
// Any notification other than a chat message should invalidate the unread page
invalidateCachedUnreadPage()
return DEFAULT_HANDLER_OPTIONS
},
})
const responseReceivedListener =
Notifications.addNotificationResponseReceivedListener(e => {
if (e.notification.request.identifier === prevIdentifier.current) {
return
}
prevIdentifier.current = e.notification.request.identifier
logger.debug(
'Notifications: response received',
{
actionIdentifier: e.actionIdentifier,
},
logger.DebugContext.notifications,
)
if (
e.actionIdentifier === Notifications.DEFAULT_ACTION_IDENTIFIER &&
e.notification.request.trigger.type === 'push'
) {
logger.debug(
'User pressed a notification, opening notifications tab',
{},
logger.DebugContext.notifications,
)
track('Notificatons:OpenApp')
logEvent('notifications:openApp', {})
invalidateCachedUnreadPage()
truncateAndInvalidate(queryClient, RQKEY_NOTIFS())
logger.debug('Notifications: handleNotification', {
content: e.notification.request.content,
payload: e.notification.request.trigger.payload,
})
handleNotification(
e.notification.request.trigger.payload as NotificationPayload,
)
Notifications.dismissAllNotificationsAsync()
}
})
// Whenever there's a stored payload, that means we had to switch accounts before handling the notification.
// Whenever currentAccount changes, we should try to handle it again.
if (
storedPayload?.reason === 'chat-message' &&
currentAccount?.did === storedPayload.recipientDid
) {
handleNotification(storedPayload)
storedPayload = undefined
}
return () => {
responseReceivedListener.remove()
}
}, [
queryClient,
currentAccount,
currentConvoId,
accounts,
closeAllActiveElements,
currentAccount?.did,
navigation,
onPressSwitchAccount,
setShowLoggedOut,
])
}

View File

@ -1,17 +1,9 @@
import {useEffect} from 'react'
import * as Notifications from 'expo-notifications' import * as Notifications from 'expo-notifications'
import {BskyAgent} from '@atproto/api' import {BskyAgent} from '@atproto/api'
import {QueryClient} from '@tanstack/react-query'
import {logger} from '#/logger' import {logger} from '#/logger'
import {RQKEY as RQKEY_NOTIFS} from '#/state/queries/notifications/feed'
import {invalidateCachedUnreadPage} from '#/state/queries/notifications/unread'
import {truncateAndInvalidate} from '#/state/queries/util'
import {SessionAccount} from '#/state/session' import {SessionAccount} from '#/state/session'
import {track} from 'lib/analytics/analytics' import {devicePlatform} from 'platform/detection'
import {devicePlatform, isIOS} from 'platform/detection'
import {resetToTab} from '../../Navigation'
import {logEvent} from '../statsig/statsig'
const SERVICE_DID = (serviceUrl?: string) => const SERVICE_DID = (serviceUrl?: string) =>
serviceUrl?.includes('staging') serviceUrl?.includes('staging')
@ -85,67 +77,3 @@ export function registerTokenChangeHandler(
sub.remove() sub.remove()
} }
} }
export function useNotificationsListener(queryClient: QueryClient) {
useEffect(() => {
// handle notifications that are received, both in the foreground or background
// NOTE: currently just here for debug logging
const sub1 = Notifications.addNotificationReceivedListener(event => {
invalidateCachedUnreadPage()
logger.debug(
'Notifications: received',
{event},
logger.DebugContext.notifications,
)
if (event.request.trigger.type === 'push') {
// handle payload-based deeplinks
let payload
if (isIOS) {
payload = event.request.trigger.payload
} else {
// TODO: handle android payload deeplink
}
if (payload) {
logger.debug(
'Notifications: received payload',
payload,
logger.DebugContext.notifications,
)
// TODO: deeplink notif here
}
}
})
// handle notifications that are tapped on
const sub2 = Notifications.addNotificationResponseReceivedListener(
response => {
logger.debug(
'Notifications: response received',
{
actionIdentifier: response.actionIdentifier,
},
logger.DebugContext.notifications,
)
if (
response.actionIdentifier === Notifications.DEFAULT_ACTION_IDENTIFIER
) {
logger.debug(
'User pressed a notification, opening notifications tab',
{},
logger.DebugContext.notifications,
)
track('Notificatons:OpenApp')
logEvent('notifications:openApp', {})
invalidateCachedUnreadPage()
truncateAndInvalidate(queryClient, RQKEY_NOTIFS())
resetToTab('NotificationsTab') // open notifications tab
}
},
)
return () => {
sub1.remove()
sub2.remove()
}
}, [queryClient])
}

View File

@ -72,7 +72,7 @@ export type MyProfileTabNavigatorParams = CommonNavigatorParams & {
} }
export type MessagesTabNavigatorParams = CommonNavigatorParams & { export type MessagesTabNavigatorParams = CommonNavigatorParams & {
Messages: undefined Messages: {pushToConversation?: string}
} }
export type FlatNavigatorParams = CommonNavigatorParams & { export type FlatNavigatorParams = CommonNavigatorParams & {
@ -81,7 +81,7 @@ export type FlatNavigatorParams = CommonNavigatorParams & {
Feeds: undefined Feeds: undefined
Notifications: undefined Notifications: undefined
Hashtag: {tag: string; author?: string} Hashtag: {tag: string; author?: string}
Messages: undefined Messages: {pushToConversation?: string}
} }
export type AllNavigatorParams = CommonNavigatorParams & { export type AllNavigatorParams = CommonNavigatorParams & {

View File

@ -4,7 +4,12 @@ export type LogEvents = {
initMs: number initMs: number
} }
'account:loggedIn': { 'account:loggedIn': {
logContext: 'LoginForm' | 'SwitchAccount' | 'ChooseAccountForm' | 'Settings' logContext:
| 'LoginForm'
| 'SwitchAccount'
| 'ChooseAccountForm'
| 'Settings'
| 'Notification'
withPassword: boolean withPassword: boolean
} }
'account:loggedOut': { 'account:loggedOut': {

View File

@ -110,7 +110,7 @@ let Header = ({
if (isWeb) { if (isWeb) {
navigation.replace('Messages') navigation.replace('Messages')
} else { } else {
navigation.pop() navigation.goBack()
} }
}, [navigation]) }, [navigation])

View File

@ -40,11 +40,12 @@ import {ClipClopGate} from '../gate'
import {useDmServiceUrlStorage} from '../Temp/useDmServiceUrlStorage' import {useDmServiceUrlStorage} from '../Temp/useDmServiceUrlStorage'
type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'Messages'> type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'Messages'>
export function MessagesScreen({navigation}: Props) { export function MessagesScreen({navigation, route}: Props) {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
const newChatControl = useDialogControl() const newChatControl = useDialogControl()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const pushToConversation = route.params?.pushToConversation
// TEMP // TEMP
const {serviceUrl, setServiceUrl} = useDmServiceUrlStorage() const {serviceUrl, setServiceUrl} = useDmServiceUrlStorage()
@ -57,6 +58,19 @@ export function MessagesScreen({navigation}: Props) {
) )
}, [serviceUrl]) }, [serviceUrl])
// Whenever we have `pushToConversation` set, it means we pressed a notification for a chat without being on
// this tab. We should immediately push to the conversation after pressing the notification.
// After we push, reset with `setParams` so that this effect will fire next time we press a notification, even if
// the conversation is the same as before
React.useEffect(() => {
if (pushToConversation) {
navigation.navigate('MessagesConversation', {
conversation: pushToConversation,
})
navigation.setParams({pushToConversation: undefined})
}
}, [navigation, pushToConversation])
const renderButton = useCallback(() => { const renderButton = useCallback(() => {
return ( return (
<Link <Link

View File

@ -3,15 +3,15 @@ import {AtpSessionEvent} from '@atproto-labs/api'
import {networkRetry} from '#/lib/async/retry' import {networkRetry} from '#/lib/async/retry'
import {PUBLIC_BSKY_SERVICE} from '#/lib/constants' import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
import {IS_PROD_SERVICE} from '#/lib/constants'
import {tryFetchGates} from '#/lib/statsig/statsig' import {tryFetchGates} from '#/lib/statsig/statsig'
import {DEFAULT_PROD_FEEDS} from '../queries/preferences'
import { import {
configureModerationForAccount, configureModerationForAccount,
configureModerationForGuest, configureModerationForGuest,
} from './moderation' } from './moderation'
import {SessionAccount} from './types' import {SessionAccount} from './types'
import {isSessionDeactivated, isSessionExpired} from './util' import {isSessionDeactivated, isSessionExpired} from './util'
import {IS_PROD_SERVICE} from '#/lib/constants'
import {DEFAULT_PROD_FEEDS} from '../queries/preferences'
export function createPublicAgent() { export function createPublicAgent() {
configureModerationForGuest() // Side effect but only relevant for tests configureModerationForGuest() // Side effect but only relevant for tests

View File

@ -20,6 +20,7 @@ import {
useSetDrawerOpen, useSetDrawerOpen,
} from '#/state/shell' } from '#/state/shell'
import {useCloseAnyActiveElement} from '#/state/util' import {useCloseAnyActiveElement} from '#/state/util'
import {useNotificationsHandler} from 'lib/hooks/useNotificationHandler'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import * as notifications from 'lib/notifications/notifications' import * as notifications from 'lib/notifications/notifications'
import {isStateAtTabRoot} from 'lib/routes/helpers' import {isStateAtTabRoot} from 'lib/routes/helpers'
@ -63,6 +64,8 @@ function ShellInner() {
// start undefined // start undefined
const currentAccountDid = React.useRef<string | undefined>(undefined) const currentAccountDid = React.useRef<string | undefined>(undefined)
useNotificationsHandler()
React.useEffect(() => { React.useEffect(() => {
let listener = {remove() {}} let listener = {remove() {}}
if (isAndroid) { if (isAndroid) {