Skip to content
Open
1 change: 1 addition & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -1050,6 +1050,7 @@
"Prefetcher",
"knip",
"lottiefiles",
"Refetched",
"macrotask",
"Macrotask"
],
Expand Down
42 changes: 40 additions & 2 deletions src/DeepLinkHandler.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type {NativeEventSubscription} from 'react-native';

import {useEffect, useRef} from 'react';
import {useCallback, useEffect, useRef} from 'react';
import {Linking} from 'react-native';

import type {Route} from './ROUTES';
Expand All @@ -10,8 +10,9 @@ import useIsAuthenticated from './hooks/useIsAuthenticated';
import useOnyx from './hooks/useOnyx';
import {openReportFromDeepLink} from './libs/actions/Link';
import * as Report from './libs/actions/Report';
import {hasAuthToken} from './libs/actions/Session';
import {hasAuthToken, isAnonymousUser} from './libs/actions/Session';
import Log from './libs/Log';
import {getReportIDFromLink} from './libs/ReportUtils';
import {endSpan} from './libs/telemetry/activeSpans';
import ONYXKEYS from './ONYXKEYS';
import {hasSeenTourSelector} from './selectors/Onboarding';
Expand All @@ -30,15 +31,29 @@ type DeepLinkHandlerProps = {
function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) {
const linkingChangeListener = useRef<NativeEventSubscription | null>(null);
const initialUrlProcessed = useRef(false);
const pendingPublicRoomReportID = useRef('');
const hasRefetchedPublicRoom = useRef(false);

const [allReports, allReportsMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT);
const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP);
const [, sessionMetadata] = useOnyx(ONYXKEYS.SESSION);
const [conciergeReportID, conciergeReportIDMetadata] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID);
const [introSelected, introSelectedMetadata] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED);
const [isSelfTourViewed, isSelfTourViewedMetadata] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector});
const [betas, betasMetadata] = useOnyx(ONYXKEYS.BETAS);
const isAuthenticated = useIsAuthenticated();

// An anonymous deep link into a public room needs to be re-fetched after OpenApp settles (see the effect
// below). Track the pending reportID so both the initial-URL and the url-change paths stay in sync.
const trackPendingPublicRoomFromDeepLink = useCallback((url: string, isCurrentlyAuthenticated: boolean) => {
const deepLinkReportID = getReportIDFromLink(url);
if (!deepLinkReportID || isCurrentlyAuthenticated) {
return;
}
pendingPublicRoomReportID.current = deepLinkReportID;
hasRefetchedPublicRoom.current = false;
}, []);

useEffect(() => {
if (isLoadingOnyxValue(allReportsMetadata, sessionMetadata, conciergeReportIDMetadata, introSelectedMetadata, isSelfTourViewedMetadata, betasMetadata)) {
return;
Expand Down Expand Up @@ -89,6 +104,7 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) {
Log.info('[Deep link] introSelected is undefined when processing initial URL', false, {url});
}
openReportFromDeepLink(url, allReports, isCurrentlyAuthenticated, conciergeReportID, introSelected, isSelfTourViewed, betas);
trackPendingPublicRoomFromDeepLink(url, isCurrentlyAuthenticated);
} else {
Report.doneCheckingPublicRoom();
}
Expand Down Expand Up @@ -116,6 +132,7 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) {
}
const isCurrentlyAuthenticated = hasAuthToken();
openReportFromDeepLink(state.url, allReports, isCurrentlyAuthenticated, conciergeReportID, introSelected, isSelfTourViewed, betas);
trackPendingPublicRoomFromDeepLink(state.url, isCurrentlyAuthenticated);
});

return () => {
Expand Down Expand Up @@ -149,6 +166,27 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) {
Report.doneCheckingPublicRoom();
}, [isAuthenticated]);

// An anonymous user opening a public room via a cold deep link loads the room (OpenReport), but the
// OpenApp that follows anonymous session creation drops it from Onyx, so it never reaches the LHN.
// Once OpenApp settles, re-fetch the room if it went missing so it shows up in the LHN. See #92672.
useEffect(() => {
const reportID = pendingPublicRoomReportID.current;
if (!reportID || isLoadingApp || !isAnonymousUser()) {
return;
}
// The room made it into Onyx, so the cold-start race is over - stop tracking it.
if (allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]?.reportID) {
pendingPublicRoomReportID.current = '';
hasRefetchedPublicRoom.current = false;
return;
}
if (hasRefetchedPublicRoom.current) {
return;
}
hasRefetchedPublicRoom.current = true;
Comment thread
yusufdeveloper2903 marked this conversation as resolved.
Report.openReport({reportID, introSelected, betas});
}, [isLoadingApp, allReports, introSelected, betas]);

return null;
}

Expand Down
8 changes: 8 additions & 0 deletions src/libs/Navigation/linkingConfig/subscribe.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {hasAuthToken} from '@libs/actions/Session';
import continuePlaidOAuth from '@libs/continuePlaidOAuth';
import navigationRef from '@libs/Navigation/navigationRef';
import type {RootNavigatorParamList} from '@libs/Navigation/types';
Expand Down Expand Up @@ -50,6 +51,13 @@ const subscribe: LinkingOptions<RootNavigatorParamList>['subscribe'] = (listener
continuePlaidOAuth(url);
return;
}
// For an unauthenticated session, a report deep link (`/r/<reportID>`) targets the Report screen,
// which lives in AuthScreens and is not mounted while PublicScreens is showing. Dispatching it here
// throws "NAVIGATE ... was not handled by any navigator". openReportFromDeepLink() already opens the
// public room as an anonymous user and handles navigation, so defer to it instead. See #92672.
if (!hasAuthToken() && url.includes(`/${ROUTES.REPORT}/`)) {
return;
}
listener(url);
});
return () => subscription.remove();
Expand Down
6 changes: 6 additions & 0 deletions src/libs/SidebarUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {Str} from 'expensify-common';

import type {OptionData} from './ReportUtils';

import {isAnonymousUser} from './actions/Session';
import {formatPhoneNumber as formatPhoneNumberPhoneUtils} from './LocalePhoneNumber';
import {formatList} from './Localize';
import {
Expand Down Expand Up @@ -196,6 +197,7 @@ import {
isOneOnOneChat,
isOneTransactionThread,
isPolicyExpenseChat,
isPublicRoom,
isSelfDM,
isSystemChat as isSystemChatUtil,
isTaskReport,
Expand Down Expand Up @@ -343,6 +345,10 @@ function shouldDisplayReportInLHN({
!!draftComment ||
hasErrorsOtherThanFailedReceipt ||
isFocused ||
// An anonymous user can only access public rooms, and such a room's notification preference
// defaults to `hidden`. Without this, opening a thread inside the room (which steals focus)
// drops the room from the LHN, leaving the anon user unable to return to it. See #92672.
(isPublicRoom(report) && isAnonymousUser()) ||
isSystemChat ||
!!report.isPinned ||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
Expand Down
121 changes: 121 additions & 0 deletions tests/unit/DeepLinkHandlerTest.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import {act, render} from '@testing-library/react-native';

import type * as Link from '@libs/actions/Link';
import * as Report from '@libs/actions/Report';

import CONST from '@src/CONST';
import DeepLinkHandler from '@src/DeepLinkHandler';
import ONYXKEYS from '@src/ONYXKEYS';

import {Linking} from 'react-native';
import Onyx from 'react-native-onyx';

import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct';

jest.mock('@libs/actions/Link', () => ({
...jest.requireActual<typeof Link>('@libs/actions/Link'),
openReportFromDeepLink: jest.fn(),
}));

jest.mock('@libs/actions/Report', () => ({
...jest.requireActual<typeof Report>('@libs/actions/Report'),
openReport: jest.fn(),
doneCheckingPublicRoom: jest.fn(),
}));

const PUBLIC_ROOM_ID = '987654321';
const DEFAULT_URL = 'https://new.expensify.com/';

describe('DeepLinkHandler', () => {
beforeAll(() => {
Onyx.init({keys: ONYXKEYS});
});

beforeEach(async () => {
await act(async () => {
await Onyx.clear();
});
jest.clearAllMocks();
Linking.setInitialURL(DEFAULT_URL);
});

it('re-fetches a public room that is missing from Onyx after OpenApp settles for an anonymous user (#92672)', async () => {
// An anonymous session has no authToken, so hasAuthToken() is false and isAnonymousUser() is true.
await act(async () => {
await Onyx.multiSet({
[ONYXKEYS.SESSION]: {authTokenType: CONST.AUTH_TOKEN_TYPES.ANONYMOUS},
[ONYXKEYS.IS_LOADING_APP]: true,
[ONYXKEYS.CONCIERGE_REPORT_ID]: '',
[ONYXKEYS.NVP_INTRO_SELECTED]: {},
[ONYXKEYS.BETAS]: [],
});
});

// Cold deep link straight into the public room.
Linking.setInitialURL(`new-expensify://r/${PUBLIC_ROOM_ID}`);

render(<DeepLinkHandler onInitialUrl={jest.fn()} />);
await waitForBatchedUpdatesWithAct();

// While OpenApp is still loading, we should not refetch yet.
expect(Report.openReport).not.toHaveBeenCalled();

// OpenApp settles but the public room is still absent from Onyx -> refetch it so it reaches the LHN.
await act(async () => {
await Onyx.merge(ONYXKEYS.IS_LOADING_APP, false);
});
await waitForBatchedUpdatesWithAct();

expect(Report.openReport).toHaveBeenCalledWith(expect.objectContaining({reportID: PUBLIC_ROOM_ID}));
});

it('does not refetch when the public room is already present in Onyx', async () => {
await act(async () => {
await Onyx.multiSet({
[ONYXKEYS.SESSION]: {authTokenType: CONST.AUTH_TOKEN_TYPES.ANONYMOUS},
[ONYXKEYS.IS_LOADING_APP]: true,
[ONYXKEYS.CONCIERGE_REPORT_ID]: '',
[ONYXKEYS.NVP_INTRO_SELECTED]: {},
[ONYXKEYS.BETAS]: [],
[`${ONYXKEYS.COLLECTION.REPORT}${PUBLIC_ROOM_ID}` as const]: {reportID: PUBLIC_ROOM_ID},
});
});

Linking.setInitialURL(`new-expensify://r/${PUBLIC_ROOM_ID}`);

render(<DeepLinkHandler onInitialUrl={jest.fn()} />);
await waitForBatchedUpdatesWithAct();

await act(async () => {
await Onyx.merge(ONYXKEYS.IS_LOADING_APP, false);
});
await waitForBatchedUpdatesWithAct();

expect(Report.openReport).not.toHaveBeenCalled();
});

it('does not refetch for an authenticated user', async () => {
await act(async () => {
await Onyx.multiSet({
// An authToken means hasAuthToken() is true, so the deep link is not tracked as a pending public room.
[ONYXKEYS.SESSION]: {authToken: 'token', accountID: 1},
[ONYXKEYS.IS_LOADING_APP]: true,
[ONYXKEYS.CONCIERGE_REPORT_ID]: '',
[ONYXKEYS.NVP_INTRO_SELECTED]: {},
[ONYXKEYS.BETAS]: [],
});
});

Linking.setInitialURL(`new-expensify://r/${PUBLIC_ROOM_ID}`);

render(<DeepLinkHandler onInitialUrl={jest.fn()} />);
await waitForBatchedUpdatesWithAct();

await act(async () => {
await Onyx.merge(ONYXKEYS.IS_LOADING_APP, false);
});
await waitForBatchedUpdatesWithAct();

expect(Report.openReport).not.toHaveBeenCalled();
});
});
109 changes: 109 additions & 0 deletions tests/unit/SidebarUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1117,6 +1117,115 @@
expect(typeof result.shouldDisplay).toBe('boolean');
});

describe('anonymous user public room (#92672)', () => {
const PUBLIC_ROOM_ID = '92672';
const OTHER_FOCUSED_REPORT_ID = '92673';

// The room has no notification preference for the current user, so `isHiddenForCurrentUser`
// treats it as `hidden` - exactly the state an anonymous user lands in for a public room.
const buildPublicRoom = (visibility: Report['visibility']): Report => ({
reportID: PUBLIC_ROOM_ID,
type: CONST.REPORT.TYPE.CHAT,
chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM,
visibility,
reportName: '#public-room',
policyID: '1',
lastMessageText: 'hello',
lastVisibleActionCreated: DateUtils.getDBTime(),
participants: {},
});

it('keeps a hidden public room in the LHN when it is not the focused report', async () => {
const report = buildPublicRoom(CONST.REPORT.VISIBILITY.PUBLIC);
const reportKey = `${ONYXKEYS.COLLECTION.REPORT}${PUBLIC_ROOM_ID}` as const;
const reports: OnyxCollection<Report> = {[reportKey]: report};

await act(async () => {
await Onyx.multiSet({
[ONYXKEYS.SESSION]: {authTokenType: CONST.AUTH_TOKEN_TYPES.ANONYMOUS},
[reportKey]: report,
});
});
await waitForBatchedUpdates();

const result = SidebarUtils.shouldDisplayReportInLHN({

Check failure on line 1151 in tests/unit/SidebarUtilsTest.ts

View workflow job for this annotation

GitHub Actions / typecheck

Argument of type '{ report: { avatarUrl?: string; created?: string; submitted?: string; approved?: string; submitterUserID?: string; submitterPayrollID?: string; orderDealNumbers?: string; chatType?: ValueOf<typeof CONST.REPORT.CHAT_TYPE>; ... 57 more ...; nextStep?: ReportNextStep; } & OfflineFeedback<...>; ... 9 more ...; currentUs...' is not assignable to parameter of type 'ShouldDisplayReportInLHNParams'.
report,
reports,
currentReportId: OTHER_FOCUSED_REPORT_ID,
isInFocusMode: false,
betas: [],
transactionViolations: {},
draftComment: undefined,
transactions: {},
isOffline: false,
currentUserLogin: CURRENT_USER_LOGIN,
currentUserAccountID: CURRENT_USER_ACCOUNT_ID,
});

expect(result.shouldDisplay).toBe(true);
});

it('drops the same hidden public room for an authenticated (non-anonymous) user', async () => {
const report = buildPublicRoom(CONST.REPORT.VISIBILITY.PUBLIC);
const reportKey = `${ONYXKEYS.COLLECTION.REPORT}${PUBLIC_ROOM_ID}` as const;
const reports: OnyxCollection<Report> = {[reportKey]: report};

await act(async () => {
await Onyx.multiSet({
[ONYXKEYS.SESSION]: {accountID: CURRENT_USER_ACCOUNT_ID, email: CURRENT_USER_LOGIN},
[reportKey]: report,
});
});
await waitForBatchedUpdates();

const result = SidebarUtils.shouldDisplayReportInLHN({

Check failure on line 1181 in tests/unit/SidebarUtilsTest.ts

View workflow job for this annotation

GitHub Actions / typecheck

Argument of type '{ report: { avatarUrl?: string; created?: string; submitted?: string; approved?: string; submitterUserID?: string; submitterPayrollID?: string; orderDealNumbers?: string; chatType?: ValueOf<typeof CONST.REPORT.CHAT_TYPE>; ... 57 more ...; nextStep?: ReportNextStep; } & OfflineFeedback<...>; ... 9 more ...; currentUs...' is not assignable to parameter of type 'ShouldDisplayReportInLHNParams'.
report,
reports,
currentReportId: OTHER_FOCUSED_REPORT_ID,
isInFocusMode: false,
betas: [],
transactionViolations: {},
draftComment: undefined,
transactions: {},
isOffline: false,
currentUserLogin: CURRENT_USER_LOGIN,
currentUserAccountID: CURRENT_USER_ACCOUNT_ID,
});

expect(result.shouldDisplay).toBe(false);
});

it('does not apply the override to a non-public hidden room for an anonymous user', async () => {
const report = buildPublicRoom(undefined);
const reportKey = `${ONYXKEYS.COLLECTION.REPORT}${PUBLIC_ROOM_ID}` as const;
const reports: OnyxCollection<Report> = {[reportKey]: report};

await act(async () => {
await Onyx.multiSet({
[ONYXKEYS.SESSION]: {authTokenType: CONST.AUTH_TOKEN_TYPES.ANONYMOUS},
[reportKey]: report,
});
});
await waitForBatchedUpdates();

const result = SidebarUtils.shouldDisplayReportInLHN({

Check failure on line 1211 in tests/unit/SidebarUtilsTest.ts

View workflow job for this annotation

GitHub Actions / typecheck

Argument of type '{ report: { avatarUrl?: string; created?: string; submitted?: string; approved?: string; submitterUserID?: string; submitterPayrollID?: string; orderDealNumbers?: string; chatType?: ValueOf<typeof CONST.REPORT.CHAT_TYPE>; ... 57 more ...; nextStep?: ReportNextStep; } & OfflineFeedback<...>; ... 9 more ...; currentUs...' is not assignable to parameter of type 'ShouldDisplayReportInLHNParams'.
report,
reports,
currentReportId: OTHER_FOCUSED_REPORT_ID,
isInFocusMode: false,
betas: [],
transactionViolations: {},
draftComment: undefined,
transactions: {},
isOffline: false,
currentUserLogin: CURRENT_USER_LOGIN,
currentUserAccountID: CURRENT_USER_ACCOUNT_ID,
});

expect(result.shouldDisplay).toBe(false);
});
});

/**
* conciergeReportID only changes the outcome for an *empty* chat: an empty Concierge chat must stay in the LHN,
* whereas an equivalent empty non-Concierge chat is hidden. These two tests hold everything else constant and
Expand Down
Loading