Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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

authored by

Hailey and committed by
GitHub
17e3b946 13418455

+274 -109
+19 -29
src/App.native.tsx
··· 12 12 import * as SplashScreen from 'expo-splash-screen' 13 13 import {msg} from '@lingui/macro' 14 14 import {useLingui} from '@lingui/react' 15 - import {useQueryClient} from '@tanstack/react-query' 16 15 17 16 import {Provider as StatsigProvider} from '#/lib/statsig/statsig' 18 17 import {logger} from '#/logger' ··· 22 21 import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts' 23 22 import {readLastActiveAccount} from '#/state/session/util' 24 23 import {useIntentHandler} from 'lib/hooks/useIntentHandler' 25 - import {useNotificationsListener} from 'lib/notifications/notifications' 26 24 import {QueryProvider} from 'lib/react-query' 27 25 import {s} from 'lib/styles' 28 26 import {ThemeProvider} from 'lib/ThemeContext' ··· 96 94 // Resets the entire tree below when it changes: 97 95 key={currentAccount?.did}> 98 96 <QueryProvider currentDid={currentAccount?.did}> 99 - <PushNotificationsListener> 100 - <StatsigProvider> 101 - <MessagesProvider> 102 - {/* LabelDefsProvider MUST come before ModerationOptsProvider */} 103 - <LabelDefsProvider> 104 - <ModerationOptsProvider> 105 - <LoggedOutViewProvider> 106 - <SelectedFeedProvider> 107 - <UnreadNotifsProvider> 108 - <GestureHandlerRootView style={s.h100pct}> 109 - <TestCtrls /> 110 - <Shell /> 111 - </GestureHandlerRootView> 112 - </UnreadNotifsProvider> 113 - </SelectedFeedProvider> 114 - </LoggedOutViewProvider> 115 - </ModerationOptsProvider> 116 - </LabelDefsProvider> 117 - </MessagesProvider> 118 - </StatsigProvider> 119 - </PushNotificationsListener> 97 + <StatsigProvider> 98 + <MessagesProvider> 99 + {/* LabelDefsProvider MUST come before ModerationOptsProvider */} 100 + <LabelDefsProvider> 101 + <ModerationOptsProvider> 102 + <LoggedOutViewProvider> 103 + <SelectedFeedProvider> 104 + <UnreadNotifsProvider> 105 + <GestureHandlerRootView style={s.h100pct}> 106 + <TestCtrls /> 107 + <Shell /> 108 + </GestureHandlerRootView> 109 + </UnreadNotifsProvider> 110 + </SelectedFeedProvider> 111 + </LoggedOutViewProvider> 112 + </ModerationOptsProvider> 113 + </LabelDefsProvider> 114 + </MessagesProvider> 115 + </StatsigProvider> 120 116 </QueryProvider> 121 117 </React.Fragment> 122 118 </RootSiblingParent> ··· 125 121 </Alf> 126 122 </SafeAreaProvider> 127 123 ) 128 - } 129 - 130 - function PushNotificationsListener({children}: {children: React.ReactNode}) { 131 - const queryClient = useQueryClient() 132 - useNotificationsListener(queryClient) 133 - return children 134 124 } 135 125 136 126 function App() {
+225
src/lib/hooks/useNotificationHandler.ts
··· 1 + import React from 'react' 2 + import * as Notifications from 'expo-notifications' 3 + import {CommonActions, useNavigation} from '@react-navigation/native' 4 + import {useQueryClient} from '@tanstack/react-query' 5 + 6 + import {logger} from '#/logger' 7 + import {track} from 'lib/analytics/analytics' 8 + import {useAccountSwitcher} from 'lib/hooks/useAccountSwitcher' 9 + import {NavigationProp} from 'lib/routes/types' 10 + import {logEvent} from 'lib/statsig/statsig' 11 + import {useCurrentConvoId} from 'state/messages/current-convo-id' 12 + import {RQKEY as RQKEY_NOTIFS} from 'state/queries/notifications/feed' 13 + import {invalidateCachedUnreadPage} from 'state/queries/notifications/unread' 14 + import {truncateAndInvalidate} from 'state/queries/util' 15 + import {useSession} from 'state/session' 16 + import {useLoggedOutViewControls} from 'state/shell/logged-out' 17 + import {useCloseAllActiveElements} from 'state/util' 18 + import {resetToTab} from '#/Navigation' 19 + 20 + type NotificationReason = 21 + | 'like' 22 + | 'repost' 23 + | 'follow' 24 + | 'mention' 25 + | 'reply' 26 + | 'quote' 27 + | 'chat-message' 28 + 29 + type NotificationPayload = 30 + | { 31 + reason: Exclude<NotificationReason, 'chat-message'> 32 + uri: string 33 + subject: string 34 + } 35 + | { 36 + reason: 'chat-message' 37 + convoId: string 38 + messageId: string 39 + recipientDid: string 40 + } 41 + 42 + const DEFAULT_HANDLER_OPTIONS = { 43 + shouldShowAlert: false, 44 + shouldPlaySound: false, 45 + shouldSetBadge: true, 46 + } 47 + 48 + // This needs to stay outside the hook to persist between account switches 49 + let storedPayload: NotificationPayload | undefined 50 + 51 + export function useNotificationsHandler() { 52 + const queryClient = useQueryClient() 53 + const {currentAccount, accounts} = useSession() 54 + const {onPressSwitchAccount} = useAccountSwitcher() 55 + const navigation = useNavigation<NavigationProp>() 56 + const {currentConvoId} = useCurrentConvoId() 57 + const {setShowLoggedOut} = useLoggedOutViewControls() 58 + const closeAllActiveElements = useCloseAllActiveElements() 59 + 60 + // Safety to prevent double handling of the same notification 61 + const prevIdentifier = React.useRef('') 62 + 63 + React.useEffect(() => { 64 + const handleNotification = (payload?: NotificationPayload) => { 65 + if (!payload) return 66 + 67 + if (payload.reason === 'chat-message') { 68 + if (payload.recipientDid !== currentAccount?.did && !storedPayload) { 69 + storedPayload = payload 70 + closeAllActiveElements() 71 + 72 + const account = accounts.find(a => a.did === payload.recipientDid) 73 + if (account) { 74 + onPressSwitchAccount(account, 'Notification') 75 + } else { 76 + setShowLoggedOut(true) 77 + } 78 + } else { 79 + navigation.dispatch(state => { 80 + if (state.routes[0].name === 'Messages') { 81 + return CommonActions.navigate('MessagesConversation', { 82 + conversation: payload.convoId, 83 + }) 84 + } else { 85 + return CommonActions.navigate('MessagesTab', { 86 + screen: 'Messages', 87 + params: { 88 + pushToConversation: payload.convoId, 89 + }, 90 + }) 91 + } 92 + }) 93 + } 94 + } else { 95 + switch (payload.reason) { 96 + case 'like': 97 + case 'repost': 98 + case 'follow': 99 + case 'mention': 100 + case 'quote': 101 + case 'reply': 102 + resetToTab('NotificationsTab') 103 + break 104 + // TODO implement these after we have an idea of how to handle each individual case 105 + // case 'follow': 106 + // const uri = new AtUri(payload.uri) 107 + // setTimeout(() => { 108 + // // @ts-expect-error types are weird here 109 + // navigation.navigate('HomeTab', { 110 + // screen: 'Profile', 111 + // params: { 112 + // name: uri.host, 113 + // }, 114 + // }) 115 + // }, 500) 116 + // break 117 + // case 'mention': 118 + // case 'reply': 119 + // const urip = new AtUri(payload.uri) 120 + // setTimeout(() => { 121 + // // @ts-expect-error types are weird here 122 + // navigation.navigate('HomeTab', { 123 + // screen: 'PostThread', 124 + // params: { 125 + // name: urip.host, 126 + // rkey: urip.rkey, 127 + // }, 128 + // }) 129 + // }, 500) 130 + } 131 + } 132 + } 133 + 134 + Notifications.setNotificationHandler({ 135 + handleNotification: async e => { 136 + if (e.request.trigger.type !== 'push') return DEFAULT_HANDLER_OPTIONS 137 + 138 + logger.debug( 139 + 'Notifications: received', 140 + {e}, 141 + logger.DebugContext.notifications, 142 + ) 143 + 144 + const payload = e.request.trigger.payload as NotificationPayload 145 + if ( 146 + payload.reason === 'chat-message' && 147 + payload.recipientDid === currentAccount?.did 148 + ) { 149 + return { 150 + shouldShowAlert: payload.convoId !== currentConvoId, 151 + shouldPlaySound: false, 152 + shouldSetBadge: false, 153 + } 154 + } 155 + 156 + // Any notification other than a chat message should invalidate the unread page 157 + invalidateCachedUnreadPage() 158 + return DEFAULT_HANDLER_OPTIONS 159 + }, 160 + }) 161 + 162 + const responseReceivedListener = 163 + Notifications.addNotificationResponseReceivedListener(e => { 164 + if (e.notification.request.identifier === prevIdentifier.current) { 165 + return 166 + } 167 + prevIdentifier.current = e.notification.request.identifier 168 + 169 + logger.debug( 170 + 'Notifications: response received', 171 + { 172 + actionIdentifier: e.actionIdentifier, 173 + }, 174 + logger.DebugContext.notifications, 175 + ) 176 + 177 + if ( 178 + e.actionIdentifier === Notifications.DEFAULT_ACTION_IDENTIFIER && 179 + e.notification.request.trigger.type === 'push' 180 + ) { 181 + logger.debug( 182 + 'User pressed a notification, opening notifications tab', 183 + {}, 184 + logger.DebugContext.notifications, 185 + ) 186 + track('Notificatons:OpenApp') 187 + logEvent('notifications:openApp', {}) 188 + invalidateCachedUnreadPage() 189 + truncateAndInvalidate(queryClient, RQKEY_NOTIFS()) 190 + logger.debug('Notifications: handleNotification', { 191 + content: e.notification.request.content, 192 + payload: e.notification.request.trigger.payload, 193 + }) 194 + handleNotification( 195 + e.notification.request.trigger.payload as NotificationPayload, 196 + ) 197 + Notifications.dismissAllNotificationsAsync() 198 + } 199 + }) 200 + 201 + // Whenever there's a stored payload, that means we had to switch accounts before handling the notification. 202 + // Whenever currentAccount changes, we should try to handle it again. 203 + if ( 204 + storedPayload?.reason === 'chat-message' && 205 + currentAccount?.did === storedPayload.recipientDid 206 + ) { 207 + handleNotification(storedPayload) 208 + storedPayload = undefined 209 + } 210 + 211 + return () => { 212 + responseReceivedListener.remove() 213 + } 214 + }, [ 215 + queryClient, 216 + currentAccount, 217 + currentConvoId, 218 + accounts, 219 + closeAllActiveElements, 220 + currentAccount?.did, 221 + navigation, 222 + onPressSwitchAccount, 223 + setShowLoggedOut, 224 + ]) 225 + }
+1 -73
src/lib/notifications/notifications.ts
··· 1 - import {useEffect} from 'react' 2 1 import * as Notifications from 'expo-notifications' 3 2 import {BskyAgent} from '@atproto/api' 4 - import {QueryClient} from '@tanstack/react-query' 5 3 6 4 import {logger} from '#/logger' 7 - import {RQKEY as RQKEY_NOTIFS} from '#/state/queries/notifications/feed' 8 - import {invalidateCachedUnreadPage} from '#/state/queries/notifications/unread' 9 - import {truncateAndInvalidate} from '#/state/queries/util' 10 5 import {SessionAccount} from '#/state/session' 11 - import {track} from 'lib/analytics/analytics' 12 - import {devicePlatform, isIOS} from 'platform/detection' 13 - import {resetToTab} from '../../Navigation' 14 - import {logEvent} from '../statsig/statsig' 6 + import {devicePlatform} from 'platform/detection' 15 7 16 8 const SERVICE_DID = (serviceUrl?: string) => 17 9 serviceUrl?.includes('staging') ··· 85 77 sub.remove() 86 78 } 87 79 } 88 - 89 - export function useNotificationsListener(queryClient: QueryClient) { 90 - useEffect(() => { 91 - // handle notifications that are received, both in the foreground or background 92 - // NOTE: currently just here for debug logging 93 - const sub1 = Notifications.addNotificationReceivedListener(event => { 94 - invalidateCachedUnreadPage() 95 - logger.debug( 96 - 'Notifications: received', 97 - {event}, 98 - logger.DebugContext.notifications, 99 - ) 100 - if (event.request.trigger.type === 'push') { 101 - // handle payload-based deeplinks 102 - let payload 103 - if (isIOS) { 104 - payload = event.request.trigger.payload 105 - } else { 106 - // TODO: handle android payload deeplink 107 - } 108 - if (payload) { 109 - logger.debug( 110 - 'Notifications: received payload', 111 - payload, 112 - logger.DebugContext.notifications, 113 - ) 114 - // TODO: deeplink notif here 115 - } 116 - } 117 - }) 118 - 119 - // handle notifications that are tapped on 120 - const sub2 = Notifications.addNotificationResponseReceivedListener( 121 - response => { 122 - logger.debug( 123 - 'Notifications: response received', 124 - { 125 - actionIdentifier: response.actionIdentifier, 126 - }, 127 - logger.DebugContext.notifications, 128 - ) 129 - if ( 130 - response.actionIdentifier === Notifications.DEFAULT_ACTION_IDENTIFIER 131 - ) { 132 - logger.debug( 133 - 'User pressed a notification, opening notifications tab', 134 - {}, 135 - logger.DebugContext.notifications, 136 - ) 137 - track('Notificatons:OpenApp') 138 - logEvent('notifications:openApp', {}) 139 - invalidateCachedUnreadPage() 140 - truncateAndInvalidate(queryClient, RQKEY_NOTIFS()) 141 - resetToTab('NotificationsTab') // open notifications tab 142 - } 143 - }, 144 - ) 145 - 146 - return () => { 147 - sub1.remove() 148 - sub2.remove() 149 - } 150 - }, [queryClient]) 151 - }
+2 -2
src/lib/routes/types.ts
··· 72 72 } 73 73 74 74 export type MessagesTabNavigatorParams = CommonNavigatorParams & { 75 - Messages: undefined 75 + Messages: {pushToConversation?: string} 76 76 } 77 77 78 78 export type FlatNavigatorParams = CommonNavigatorParams & { ··· 81 81 Feeds: undefined 82 82 Notifications: undefined 83 83 Hashtag: {tag: string; author?: string} 84 - Messages: undefined 84 + Messages: {pushToConversation?: string} 85 85 } 86 86 87 87 export type AllNavigatorParams = CommonNavigatorParams & {
+6 -1
src/lib/statsig/events.ts
··· 4 4 initMs: number 5 5 } 6 6 'account:loggedIn': { 7 - logContext: 'LoginForm' | 'SwitchAccount' | 'ChooseAccountForm' | 'Settings' 7 + logContext: 8 + | 'LoginForm' 9 + | 'SwitchAccount' 10 + | 'ChooseAccountForm' 11 + | 'Settings' 12 + | 'Notification' 8 13 withPassword: boolean 9 14 } 10 15 'account:loggedOut': {
+1 -1
src/screens/Messages/Conversation/index.tsx
··· 110 110 if (isWeb) { 111 111 navigation.replace('Messages') 112 112 } else { 113 - navigation.pop() 113 + navigation.goBack() 114 114 } 115 115 }, [navigation]) 116 116
+15 -1
src/screens/Messages/List/index.tsx
··· 40 40 import {useDmServiceUrlStorage} from '../Temp/useDmServiceUrlStorage' 41 41 42 42 type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'Messages'> 43 - export function MessagesScreen({navigation}: Props) { 43 + export function MessagesScreen({navigation, route}: Props) { 44 44 const {_} = useLingui() 45 45 const t = useTheme() 46 46 const newChatControl = useDialogControl() 47 47 const {gtMobile} = useBreakpoints() 48 + const pushToConversation = route.params?.pushToConversation 48 49 49 50 // TEMP 50 51 const {serviceUrl, setServiceUrl} = useDmServiceUrlStorage() ··· 56 57 'a32318b49dd3fe6aa6a35c66c13fcc4c1cb6202b24f5a852d9a2279acee4169f' 57 58 ) 58 59 }, [serviceUrl]) 60 + 61 + // Whenever we have `pushToConversation` set, it means we pressed a notification for a chat without being on 62 + // this tab. We should immediately push to the conversation after pressing the notification. 63 + // After we push, reset with `setParams` so that this effect will fire next time we press a notification, even if 64 + // the conversation is the same as before 65 + React.useEffect(() => { 66 + if (pushToConversation) { 67 + navigation.navigate('MessagesConversation', { 68 + conversation: pushToConversation, 69 + }) 70 + navigation.setParams({pushToConversation: undefined}) 71 + } 72 + }, [navigation, pushToConversation]) 59 73 60 74 const renderButton = useCallback(() => { 61 75 return (
+2 -2
src/state/session/agent.ts
··· 3 3 4 4 import {networkRetry} from '#/lib/async/retry' 5 5 import {PUBLIC_BSKY_SERVICE} from '#/lib/constants' 6 + import {IS_PROD_SERVICE} from '#/lib/constants' 6 7 import {tryFetchGates} from '#/lib/statsig/statsig' 8 + import {DEFAULT_PROD_FEEDS} from '../queries/preferences' 7 9 import { 8 10 configureModerationForAccount, 9 11 configureModerationForGuest, 10 12 } from './moderation' 11 13 import {SessionAccount} from './types' 12 14 import {isSessionDeactivated, isSessionExpired} from './util' 13 - import {IS_PROD_SERVICE} from '#/lib/constants' 14 - import {DEFAULT_PROD_FEEDS} from '../queries/preferences' 15 15 16 16 export function createPublicAgent() { 17 17 configureModerationForGuest() // Side effect but only relevant for tests
+3
src/view/shell/index.tsx
··· 20 20 useSetDrawerOpen, 21 21 } from '#/state/shell' 22 22 import {useCloseAnyActiveElement} from '#/state/util' 23 + import {useNotificationsHandler} from 'lib/hooks/useNotificationHandler' 23 24 import {usePalette} from 'lib/hooks/usePalette' 24 25 import * as notifications from 'lib/notifications/notifications' 25 26 import {isStateAtTabRoot} from 'lib/routes/helpers' ··· 62 63 const {importantForAccessibility} = useDialogStateContext() 63 64 // start undefined 64 65 const currentAccountDid = React.useRef<string | undefined>(undefined) 66 + 67 + useNotificationsHandler() 65 68 66 69 React.useEffect(() => { 67 70 let listener = {remove() {}}