Skip to content
Merged
19 changes: 19 additions & 0 deletions src/hooks/useOneTransactionThreadReportID.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import {getOneTransactionThreadReportID} from '@libs/ReportActionsUtils';

import ONYXKEYS from '@src/ONYXKEYS';

import useNetwork from './useNetwork';
import useOnyx from './useOnyx';

function useOneTransactionThreadReportID(reportID: string | undefined) {
const {isOffline} = useNetwork();
const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`);
const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`);
const [oneTransactionThreadReportID] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {
selector: (actions) => getOneTransactionThreadReportID(report, chatReport, actions, isOffline),
});

return oneTransactionThreadReportID;
}

export default useOneTransactionThreadReportID;
31 changes: 26 additions & 5 deletions src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'
import useHasActiveAdminPolicies from '@hooks/useHasActiveAdminPolicies';
import useLastWorkspaceNumber from '@hooks/useLastWorkspaceNumber';
import useLocalize from '@hooks/useLocalize';
import useOneTransactionThreadReportID from '@hooks/useOneTransactionThreadReportID';
import useOnyx from '@hooks/useOnyx';
import useReconcileHighContrastIntent from '@hooks/useReconcileHighContrastIntent';
import useReportAttributes from '@hooks/useReportAttributes';
import useRootNavigationState from '@hooks/useRootNavigationState';

import {init, isClientTheLeader} from '@libs/ActiveClientManager';
import Log from '@libs/Log';
Expand Down Expand Up @@ -38,13 +40,18 @@ import type {ReportAttributesDerivedValue} from '@src/types/onyx';
import {hasSeenTourSelector} from '@selectors/Onboarding';
import {useEffect, useRef} from 'react';

function initializePusher(currentUserAccountID?: number, currentUserEmail?: string, getReportAttributes?: () => ReportAttributesDerivedValue['reports'] | undefined) {
function initializePusher(
currentUserAccountID: number | undefined,
currentUserEmail: string | undefined,
getTopmostOneTransactionThreadReportID: () => string | undefined,
getReportAttributes: () => ReportAttributesDerivedValue['reports'] | undefined,
) {
return Pusher.init({
appKey: CONFIG.PUSHER.APP_KEY,
cluster: CONFIG.PUSHER.CLUSTER,
authEndpoint: `${CONFIG.EXPENSIFY.DEFAULT_API_ROOT}api/AuthenticatePusher?`,
}).then(() => {
User.subscribeToUserEvents(currentUserAccountID ?? CONST.DEFAULT_NUMBER_ID, currentUserEmail ?? '', getReportAttributes);
User.subscribeToUserEvents(currentUserAccountID ?? CONST.DEFAULT_NUMBER_ID, currentUserEmail ?? '', getTopmostOneTransactionThreadReportID, getReportAttributes);
});
}

Expand Down Expand Up @@ -82,6 +89,15 @@ function AuthScreensInitHandler() {

useReconcileHighContrastIntent();

const topmostReportID = useRootNavigationState(Navigation.getTopmostReportId);
const topmostOneTransactionThreadReportID = useOneTransactionThreadReportID(topmostReportID);
// We use a ref so the Pusher callback (registered once on mount) always reads the latest value without re-subscribing.
const topmostOneTransactionThreadReportIDRef = useRef(topmostOneTransactionThreadReportID);

useEffect(() => {
Comment thread
bernhardoj marked this conversation as resolved.
topmostOneTransactionThreadReportIDRef.current = topmostOneTransactionThreadReportID;
}, [topmostOneTransactionThreadReportID]);

useEffect(() => {
registerPusherReinitializeHandler(({accountID, email}: PusherReinitializeHandlerParams = {}) => {
const currentAccountID = accountID ?? session?.accountID;
Expand All @@ -91,7 +107,12 @@ function AuthScreensInitHandler() {
return Promise.resolve();
}

return initializePusher(currentAccountID, currentEmail, () => reportAttributesRef.current);
return initializePusher(
currentAccountID,
currentEmail,
() => topmostOneTransactionThreadReportIDRef.current,
() => reportAttributesRef.current,
);
});

return () => {
Expand All @@ -104,7 +125,7 @@ function AuthScreensInitHandler() {
return;
}
// This means sign in in RHP was successful, so we can subscribe to user events
initializePusher(session?.accountID, session?.email, () => reportAttributesRef.current);
initializePusher(session?.accountID, session?.email, () => topmostOneTransactionThreadReportIDRef.current, () => reportAttributesRef.current);
}, [session?.accountID, session?.email]);

useEffect(() => {
Expand All @@ -127,7 +148,7 @@ function AuthScreensInitHandler() {
});
PusherConnectionManager.init();

initializePusher(session?.accountID, session?.email, () => reportAttributesRef.current).finally(() => {
initializePusher(session?.accountID, session?.email, () => topmostOneTransactionThreadReportIDRef.current, () => reportAttributesRef.current).finally(() => {
endSpan(CONST.TELEMETRY.SPAN_NAVIGATION.PUSHER_INIT);
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
import Log from '@libs/Log';
import Navigation from '@libs/Navigation/Navigation';
import {getIsOffline} from '@libs/NetworkState';
import * as ReportActionUtils from '@libs/ReportActionsUtils';

import * as Report from '@userActions/Report';

import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Report as ReportType, ReportActions} from '@src/types/onyx';

import type {PushPayload} from '@ua/react-native-airship';
import type {OnyxCollection} from 'react-native-onyx';

import Onyx from 'react-native-onyx';

import parsePushNotificationPayload from './parsePushNotificationPayload';

// We do not depend on updates on the UI for notifications, so we can use `connectWithoutView` here.
// Push notifications aren't rendered using React, so it's impossible to access Onyx data with useOnyx(), therefore, it's OK to use connectWithoutView() here.

let currentUserAccountID = -1;
Onyx.connectWithoutView({
key: ONYXKEYS.SESSION,
Expand All @@ -21,6 +26,20 @@ Onyx.connectWithoutView({
},
});

let allReportActions: OnyxCollection<ReportActions>;
Onyx.connectWithoutView({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a comment to indicate why it's OK to use this instead of using useOnyx()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reuse the comment from the above one

// We do not depend on updates on the UI for notifications, so we can use connectWithoutView here.

@tgolen tgolen Jul 7, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, that comment is a little confusing. How about this?

Push notifications aren't rendered using React, so it's impossible to access Onyx data with useOnyx(), therefore, it's OK to use connectWithoutView() here.

key: ONYXKEYS.COLLECTION.REPORT_ACTIONS,
waitForCollectionCallback: true,
callback: (value) => (allReportActions = value),
});

let allReports: OnyxCollection<ReportType>;
Onyx.connectWithoutView({
key: ONYXKEYS.COLLECTION.REPORT,
waitForCollectionCallback: true,
callback: (value) => (allReports = value),
});

/**
* Returns whether the given Airship notification should be shown depending on the current state of the app
*/
Expand All @@ -37,7 +56,12 @@ export default function shouldShowPushNotification(pushPayload: PushPayload): bo
shouldShow = true;
} else {
const reportAction = ReportActionUtils.getLatestReportActionFromOnyxData(data.onyxData ?? null);
shouldShow = Report.shouldShowReportActionNotification(String(data.reportID), currentUserAccountID, reportAction, true);
const topmostReportID = Navigation.getTopmostReportId();
const topmostReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${topmostReportID}`];
const topmostChatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${topmostReport?.chatReportID}`];
const topmostReportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${topmostReportID}`];
const topmostOneTransactionThreadReportID = ReportActionUtils.getOneTransactionThreadReportID(topmostReport, topmostChatReport, topmostReportActions, getIsOffline());
shouldShow = Report.shouldShowReportActionNotification(String(data.reportID), topmostOneTransactionThreadReportID, currentUserAccountID, reportAction, true);
}

Log.info(`[PushNotification] ${shouldShow ? 'Showing' : 'Not showing'} notification`);
Expand Down
20 changes: 10 additions & 10 deletions src/libs/actions/Report/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4566,7 +4566,13 @@ function setIsComposerFullSize(reportID: string, isComposerFullSize: boolean) {
* @param isRemote whether or not this notification is a remote push notification
* @param currentUserAccountID the current user's account ID
*/
function shouldShowReportActionNotification(reportID: string, currentUserAccountID: number, action: ReportAction | null = null, isRemote = false): boolean {
function shouldShowReportActionNotification(
reportID: string,
topmostOneTransactionThreadReportID: string | undefined,
currentUserAccountID: number,
action: ReportAction | null = null,
isRemote = false,
): boolean {
const tag = isRemote ? '[PushNotification]' : '[LocalNotification]';
const topmostReportID = Navigation.getTopmostReportId();

Expand Down Expand Up @@ -4602,14 +4608,7 @@ function shouldShowReportActionNotification(reportID: string, currentUserAccount
}

// If the report is a transaction thread and we are currently viewing the associated one-transaction report do no show a notification.
const topmostReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${topmostReportID}`];
const topmostReportActions = allReportActions?.[`${topmostReport?.reportID}`];
const chatTopmostReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${topmostReport?.chatReportID}`];
if (
reportID === ReportActionsUtils.getOneTransactionThreadReportID(topmostReport, chatTopmostReport, topmostReportActions, isOfflineNetwork()) &&
Visibility.isVisible() &&
Visibility.hasFocus()
) {
if (reportID === topmostOneTransactionThreadReportID && Visibility.isVisible() && Visibility.hasFocus()) {
Log.info(`${tag} No notification because the report is a transaction thread associated with the current one-transaction report`);
return false;
}
Expand Down Expand Up @@ -4638,11 +4637,12 @@ function shouldShowReportActionNotification(reportID: string, currentUserAccount
function showReportActionNotification(
reportID: string,
reportAction: ReportAction,
topmostOneTransactionThreadReportID: string | undefined,
currentUserAccountID: number,
currentUserLogin: string,
reportAttributes?: ReportAttributesDerivedValue['reports'],
) {
if (!shouldShowReportActionNotification(reportID, currentUserAccountID, reportAction)) {
if (!shouldShowReportActionNotification(reportID, topmostOneTransactionThreadReportID, currentUserAccountID, reportAction)) {
return;
}

Expand Down
12 changes: 9 additions & 3 deletions src/libs/actions/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,7 @@ function triggerNotifications<TKey extends OnyxKey>(
onyxUpdates: Array<OnyxServerUpdate<TKey>>,
currentUserAccountID: number,
currentUserEmail: string,
topmostOneTransactionThreadReportID: string | undefined,
reportAttributes?: ReportAttributesDerivedValue['reports'],
) {
for (const update of onyxUpdates) {
Expand All @@ -688,7 +689,7 @@ function triggerNotifications<TKey extends OnyxKey>(
for (const action of reportActions) {
if (action) {
// They aren't connected to a UI anywhere, it's OK to use currentUserEmail
showReportActionNotification(reportID, action, currentUserAccountID, currentUserEmail, reportAttributes);
showReportActionNotification(reportID, action, topmostOneTransactionThreadReportID, currentUserAccountID, currentUserEmail, reportAttributes);
}
}
}
Expand Down Expand Up @@ -915,7 +916,12 @@ function initializePusherPingPong(currentUserAccountID: number) {
* Handles the newest events from Pusher where a single mega multipleEvents contains
* an array of singular events all in one event
*/
function subscribeToUserEvents(currentUserAccountID: number, currentUserEmail: string, getReportAttributes?: () => ReportAttributesDerivedValue['reports'] | undefined) {
function subscribeToUserEvents(
currentUserAccountID: number,
currentUserEmail: string,
getTopmostOneTransactionThreadReportID: () => string | undefined,
getReportAttributes?: () => ReportAttributesDerivedValue['reports'] | undefined,
) {
// If we don't have the user's accountID yet (because the app isn't fully setup yet) we can't subscribe so return early
if (!currentUserAccountID) {
return;
Expand Down Expand Up @@ -970,7 +976,7 @@ function subscribeToUserEvents(currentUserAccountID: number, currentUserEmail: s
}

const onyxUpdatePromise = Onyx.update(pushJSON).then(() => {
triggerNotifications(pushJSON, currentUserAccountID, currentUserEmail, getReportAttributes?.());
triggerNotifications(pushJSON, currentUserAccountID, currentUserEmail, getTopmostOneTransactionThreadReportID(), getReportAttributes?.());
});

// Return a promise when Onyx is done updating so that the OnyxUpdatesManager can properly apply all
Expand Down
2 changes: 1 addition & 1 deletion tests/actions/IOU/RequestMoneyTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1847,7 +1847,7 @@ describe('actions/IOU', () => {

// Given a test user is signed in with Onyx setup and some initial data
await signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN);
subscribeToUserEvents(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN, undefined);
subscribeToUserEvents(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN, () => {}, undefined);
await waitForBatchedUpdates();
await setPersonalDetails(TEST_USER_LOGIN, TEST_USER_ACCOUNT_ID);

Expand Down
2 changes: 1 addition & 1 deletion tests/actions/IOUTest/DeleteMoneyRequestTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ describe('actions/IOU/DeleteMoneyRequest', () => {

// Given a test user is signed in with Onyx setup and some initial data
await signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN);
subscribeToUserEvents(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN, undefined);
subscribeToUserEvents(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN, () => {}, undefined);
await waitForBatchedUpdates();
await setPersonalDetails(TEST_USER_LOGIN, TEST_USER_ACCOUNT_ID);

Expand Down
4 changes: 2 additions & 2 deletions tests/actions/IOUTest/TrackExpenseTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2216,7 +2216,7 @@ describe('actions/IOU/TrackExpense', () => {

// Given a test user is signed in with Onyx setup and some initial data
await signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN);
subscribeToUserEvents(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN, undefined);
subscribeToUserEvents(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN, () => {}, undefined);
await waitForBatchedUpdates();
await setPersonalDetails(TEST_USER_LOGIN, TEST_USER_ACCOUNT_ID);

Expand Down Expand Up @@ -2520,7 +2520,7 @@ describe('actions/IOU/TrackExpense', () => {
PusherHelper.setup();

await signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN);
subscribeToUserEvents(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN, undefined);
subscribeToUserEvents(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN, () => {}, undefined);
await waitForBatchedUpdates();
await setPersonalDetails(TEST_USER_LOGIN, TEST_USER_ACCOUNT_ID);

Expand Down
12 changes: 6 additions & 6 deletions tests/actions/ReportTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ describe('actions/Report', () => {
// Set up Onyx with some test user data
return TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN)
.then(() => {
User.subscribeToUserEvents(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN, undefined);
User.subscribeToUserEvents(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN, () => {}, undefined);
return waitForBatchedUpdates();
})
.then(() => TestHelper.setPersonalDetails(TEST_USER_LOGIN, TEST_USER_ACCOUNT_ID))
Expand Down Expand Up @@ -560,7 +560,7 @@ describe('actions/Report', () => {
.then(waitForNetworkPromises)
.then(() => {
// Given a test user that is subscribed to Pusher events
User.subscribeToUserEvents(USER_1_ACCOUNT_ID, USER_1_LOGIN, undefined);
User.subscribeToUserEvents(USER_1_ACCOUNT_ID, USER_1_LOGIN, () => {}, undefined);
return waitForBatchedUpdates();
})
.then(() => TestHelper.setPersonalDetails(USER_1_LOGIN, USER_1_ACCOUNT_ID))
Expand Down Expand Up @@ -914,7 +914,7 @@ describe('actions/Report', () => {
return TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN)
.then(waitForBatchedUpdates)
.then(() => {
User.subscribeToUserEvents(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN, undefined);
User.subscribeToUserEvents(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN, () => {}, undefined);
return waitForBatchedUpdates();
})
.then(() => {
Expand All @@ -933,7 +933,7 @@ describe('actions/Report', () => {
})
.then(() => {
// Ensure we show a notification for this new report action
expect(Report.showReportActionNotification).toBeCalledWith(REPORT_ID, REPORT_ACTION, TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN, undefined);
expect(Report.showReportActionNotification).toBeCalledWith(REPORT_ID, REPORT_ACTION, undefined, TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN, undefined);
});
});

Expand Down Expand Up @@ -972,7 +972,7 @@ describe('actions/Report', () => {
// Set up Onyx with some test user data
return TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN)
.then(() => {
User.subscribeToUserEvents(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN, undefined);
User.subscribeToUserEvents(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN, () => {}, undefined);
return waitForBatchedUpdates();
})
.then(() => TestHelper.setPersonalDetails(TEST_USER_LOGIN, TEST_USER_ACCOUNT_ID))
Expand Down Expand Up @@ -1110,7 +1110,7 @@ describe('actions/Report', () => {
// Set up Onyx with some test user data
return TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN)
.then(() => {
User.subscribeToUserEvents(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN, undefined);
User.subscribeToUserEvents(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN, () => {}, undefined);
return waitForBatchedUpdates();
})
.then(() => TestHelper.setPersonalDetails(TEST_USER_LOGIN, TEST_USER_ACCOUNT_ID))
Expand Down
9 changes: 5 additions & 4 deletions tests/ui/AuthScreensInitHandlerTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ jest.mock('@libs/Navigation/Navigation', () => ({
isActiveRoute: jest.fn(() => false),
navigate: jest.fn(),
getActiveRouteWithoutParams: jest.fn(() => ''),
getTopmostReportId: jest.fn(() => undefined),
isNavigationReady: jest.fn(() => Promise.resolve()),
setNavigationActionToMicrotaskQueue: jest.fn(() => Promise.resolve()),
},
Expand Down Expand Up @@ -156,7 +157,7 @@ describe('AuthScreensInitHandler', () => {
await waitForBatchedUpdatesWithAct();

expect(mockedPusherInit).toHaveBeenCalled();
expect(subscribeToUserEvents).toHaveBeenCalledWith(TEST_ACCOUNT_ID, 'test@test.com', expect.any(Function));
expect(subscribeToUserEvents).toHaveBeenCalledWith(TEST_ACCOUNT_ID, 'test@test.com', expect.any(Function), expect.any(Function));
});

it('calls subscribeToUserEvents from sign-in modal effect when SIGN_IN_MODAL is active', async () => {
Expand All @@ -170,7 +171,7 @@ describe('AuthScreensInitHandler', () => {

// Both mount effect AND sign-in modal effect fire → 2 calls
expect(subscribeToUserEvents).toHaveBeenCalledTimes(2);
expect(subscribeToUserEvents).toHaveBeenCalledWith(TEST_ACCOUNT_ID, 'test@test.com', expect.any(Function));
expect(subscribeToUserEvents).toHaveBeenCalledWith(TEST_ACCOUNT_ID, 'test@test.com', expect.any(Function), expect.any(Function));
});

it('getter passed to subscribeToUserEvents returns report attributes when available', async () => {
Expand All @@ -185,7 +186,7 @@ describe('AuthScreensInitHandler', () => {

const mockCalls = (subscribeToUserEvents as jest.Mock).mock.calls;
const firstCallArgs = mockCalls.at(0) as unknown[];
const getter = firstCallArgs.at(2) as () => unknown;
const getter = firstCallArgs.at(3) as () => unknown;
expect(getter()).toEqual(mockReports);
});

Expand All @@ -199,7 +200,7 @@ describe('AuthScreensInitHandler', () => {

const mockCalls = (subscribeToUserEvents as jest.Mock).mock.calls;
const firstCallArgs = mockCalls.at(0) as unknown[];
const getter = firstCallArgs.at(2) as () => unknown;
const getter = firstCallArgs.at(3) as () => unknown;
expect(getter()).toBeUndefined();
});

Expand Down
Loading
Loading