diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/App.native.tsx | 48 | ||||
-rw-r--r-- | src/lib/hooks/useNotificationHandler.ts | 225 | ||||
-rw-r--r-- | src/lib/notifications/notifications.ts | 74 | ||||
-rw-r--r-- | src/lib/routes/types.ts | 4 | ||||
-rw-r--r-- | src/lib/statsig/events.ts | 7 | ||||
-rw-r--r-- | src/screens/Messages/Conversation/index.tsx | 2 | ||||
-rw-r--r-- | src/screens/Messages/List/index.tsx | 16 | ||||
-rw-r--r-- | src/state/session/agent.ts | 4 | ||||
-rw-r--r-- | src/view/shell/index.tsx | 3 |
9 files changed, 274 insertions, 109 deletions
diff --git a/src/App.native.tsx b/src/App.native.tsx index 79104f17c..9356be7a7 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -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) diff --git a/src/lib/hooks/useNotificationHandler.ts b/src/lib/hooks/useNotificationHandler.ts new file mode 100644 index 000000000..12e150572 --- /dev/null +++ b/src/lib/hooks/useNotificationHandler.ts @@ -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, + ]) +} diff --git a/src/lib/notifications/notifications.ts b/src/lib/notifications/notifications.ts index b0bbc1bf9..38c18bf3f 100644 --- a/src/lib/notifications/notifications.ts +++ b/src/lib/notifications/notifications.ts @@ -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]) -} diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index f7e8544b8..31133cb1b 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -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 & { diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts index 1cfdcbb6a..3371cd140 100644 --- a/src/lib/statsig/events.ts +++ b/src/lib/statsig/events.ts @@ -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': { diff --git a/src/screens/Messages/Conversation/index.tsx b/src/screens/Messages/Conversation/index.tsx index c6cbf989c..af9064cc3 100644 --- a/src/screens/Messages/Conversation/index.tsx +++ b/src/screens/Messages/Conversation/index.tsx @@ -110,7 +110,7 @@ let Header = ({ if (isWeb) { navigation.replace('Messages') } else { - navigation.pop() + navigation.goBack() } }, [navigation]) diff --git a/src/screens/Messages/List/index.tsx b/src/screens/Messages/List/index.tsx index 4d218bda8..6c07073a8 100644 --- a/src/screens/Messages/List/index.tsx +++ b/src/screens/Messages/List/index.tsx @@ -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 diff --git a/src/state/session/agent.ts b/src/state/session/agent.ts index ab7ebc790..9dacf543e 100644 --- a/src/state/session/agent.ts +++ b/src/state/session/agent.ts @@ -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 diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx index f13a8d7df..425c1b3f8 100644 --- a/src/view/shell/index.tsx +++ b/src/view/shell/index.tsx @@ -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) { |