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 commentzio/stable
parent
1341845537
commit
17e3b946cb
|
@ -12,7 +12,6 @@ import {
|
|||
import * as SplashScreen from 'expo-splash-screen'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
|
||||
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 {readLastActiveAccount} from '#/state/session/util'
|
||||
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
|
||||
import {useNotificationsListener} from 'lib/notifications/notifications'
|
||||
import {QueryProvider} from 'lib/react-query'
|
||||
import {s} from 'lib/styles'
|
||||
import {ThemeProvider} from 'lib/ThemeContext'
|
||||
|
@ -96,27 +94,25 @@ function InnerApp() {
|
|||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<PushNotificationsListener>
|
||||
<StatsigProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<GestureHandlerRootView style={s.h100pct}>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
</GestureHandlerRootView>
|
||||
</UnreadNotifsProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</StatsigProvider>
|
||||
</PushNotificationsListener>
|
||||
<StatsigProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<GestureHandlerRootView style={s.h100pct}>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
</GestureHandlerRootView>
|
||||
</UnreadNotifsProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</StatsigProvider>
|
||||
</QueryProvider>
|
||||
</React.Fragment>
|
||||
</RootSiblingParent>
|
||||
|
@ -127,12 +123,6 @@ function InnerApp() {
|
|||
)
|
||||
}
|
||||
|
||||
function PushNotificationsListener({children}: {children: React.ReactNode}) {
|
||||
const queryClient = useQueryClient()
|
||||
useNotificationsListener(queryClient)
|
||||
return children
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [isReady, setReady] = useState(false)
|
||||
|
||||
|
|
|
@ -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,
|
||||
])
|
||||
}
|
|
@ -1,17 +1,9 @@
|
|||
import {useEffect} from 'react'
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import {BskyAgent} from '@atproto/api'
|
||||
import {QueryClient} from '@tanstack/react-query'
|
||||
|
||||
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 {track} from 'lib/analytics/analytics'
|
||||
import {devicePlatform, isIOS} from 'platform/detection'
|
||||
import {resetToTab} from '../../Navigation'
|
||||
import {logEvent} from '../statsig/statsig'
|
||||
import {devicePlatform} from 'platform/detection'
|
||||
|
||||
const SERVICE_DID = (serviceUrl?: string) =>
|
||||
serviceUrl?.includes('staging')
|
||||
|
@ -85,67 +77,3 @@ export function registerTokenChangeHandler(
|
|||
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])
|
||||
}
|
||||
|
|
|
@ -72,7 +72,7 @@ export type MyProfileTabNavigatorParams = CommonNavigatorParams & {
|
|||
}
|
||||
|
||||
export type MessagesTabNavigatorParams = CommonNavigatorParams & {
|
||||
Messages: undefined
|
||||
Messages: {pushToConversation?: string}
|
||||
}
|
||||
|
||||
export type FlatNavigatorParams = CommonNavigatorParams & {
|
||||
|
@ -81,7 +81,7 @@ export type FlatNavigatorParams = CommonNavigatorParams & {
|
|||
Feeds: undefined
|
||||
Notifications: undefined
|
||||
Hashtag: {tag: string; author?: string}
|
||||
Messages: undefined
|
||||
Messages: {pushToConversation?: string}
|
||||
}
|
||||
|
||||
export type AllNavigatorParams = CommonNavigatorParams & {
|
||||
|
|
|
@ -4,7 +4,12 @@ export type LogEvents = {
|
|||
initMs: number
|
||||
}
|
||||
'account:loggedIn': {
|
||||
logContext: 'LoginForm' | 'SwitchAccount' | 'ChooseAccountForm' | 'Settings'
|
||||
logContext:
|
||||
| 'LoginForm'
|
||||
| 'SwitchAccount'
|
||||
| 'ChooseAccountForm'
|
||||
| 'Settings'
|
||||
| 'Notification'
|
||||
withPassword: boolean
|
||||
}
|
||||
'account:loggedOut': {
|
||||
|
|
|
@ -110,7 +110,7 @@ let Header = ({
|
|||
if (isWeb) {
|
||||
navigation.replace('Messages')
|
||||
} else {
|
||||
navigation.pop()
|
||||
navigation.goBack()
|
||||
}
|
||||
}, [navigation])
|
||||
|
||||
|
|
|
@ -40,11 +40,12 @@ import {ClipClopGate} from '../gate'
|
|||
import {useDmServiceUrlStorage} from '../Temp/useDmServiceUrlStorage'
|
||||
|
||||
type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'Messages'>
|
||||
export function MessagesScreen({navigation}: Props) {
|
||||
export function MessagesScreen({navigation, route}: Props) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const newChatControl = useDialogControl()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const pushToConversation = route.params?.pushToConversation
|
||||
|
||||
// TEMP
|
||||
const {serviceUrl, setServiceUrl} = useDmServiceUrlStorage()
|
||||
|
@ -57,6 +58,19 @@ export function MessagesScreen({navigation}: Props) {
|
|||
)
|
||||
}, [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(() => {
|
||||
return (
|
||||
<Link
|
||||
|
|
|
@ -3,15 +3,15 @@ import {AtpSessionEvent} from '@atproto-labs/api'
|
|||
|
||||
import {networkRetry} from '#/lib/async/retry'
|
||||
import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
|
||||
import {IS_PROD_SERVICE} from '#/lib/constants'
|
||||
import {tryFetchGates} from '#/lib/statsig/statsig'
|
||||
import {DEFAULT_PROD_FEEDS} from '../queries/preferences'
|
||||
import {
|
||||
configureModerationForAccount,
|
||||
configureModerationForGuest,
|
||||
} from './moderation'
|
||||
import {SessionAccount} from './types'
|
||||
import {isSessionDeactivated, isSessionExpired} from './util'
|
||||
import {IS_PROD_SERVICE} from '#/lib/constants'
|
||||
import {DEFAULT_PROD_FEEDS} from '../queries/preferences'
|
||||
|
||||
export function createPublicAgent() {
|
||||
configureModerationForGuest() // Side effect but only relevant for tests
|
||||
|
|
|
@ -20,6 +20,7 @@ import {
|
|||
useSetDrawerOpen,
|
||||
} from '#/state/shell'
|
||||
import {useCloseAnyActiveElement} from '#/state/util'
|
||||
import {useNotificationsHandler} from 'lib/hooks/useNotificationHandler'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import * as notifications from 'lib/notifications/notifications'
|
||||
import {isStateAtTabRoot} from 'lib/routes/helpers'
|
||||
|
@ -63,6 +64,8 @@ function ShellInner() {
|
|||
// start undefined
|
||||
const currentAccountDid = React.useRef<string | undefined>(undefined)
|
||||
|
||||
useNotificationsHandler()
|
||||
|
||||
React.useEffect(() => {
|
||||
let listener = {remove() {}}
|
||||
if (isAndroid) {
|
||||
|
|
Loading…
Reference in New Issue