diff --git a/cspell.json b/cspell.json index df7c6ef86736..a3d626a9ca8b 100644 --- a/cspell.json +++ b/cspell.json @@ -1050,6 +1050,7 @@ "Prefetcher", "knip", "lottiefiles", + "Refetched", "macrotask", "Macrotask" ], diff --git a/src/DeepLinkHandler.tsx b/src/DeepLinkHandler.tsx index a97974d2cd15..8c7ebbdb80a3 100644 --- a/src/DeepLinkHandler.tsx +++ b/src/DeepLinkHandler.tsx @@ -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'; @@ -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'; @@ -30,8 +31,11 @@ type DeepLinkHandlerProps = { function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { const linkingChangeListener = useRef(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); @@ -39,6 +43,17 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { 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; @@ -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(); } @@ -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 () => { @@ -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; + Report.openReport({reportID, introSelected, betas}); + }, [isLoadingApp, allReports, introSelected, betas]); + return null; } diff --git a/src/libs/Navigation/linkingConfig/subscribe.ts b/src/libs/Navigation/linkingConfig/subscribe.ts index cda85151f825..cafdecca095b 100644 --- a/src/libs/Navigation/linkingConfig/subscribe.ts +++ b/src/libs/Navigation/linkingConfig/subscribe.ts @@ -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'; @@ -50,6 +51,13 @@ const subscribe: LinkingOptions['subscribe'] = (listener continuePlaidOAuth(url); return; } + // For an unauthenticated session, a report deep link (`/r/`) 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(); diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 4ab29df3ec18..f36689230f4b 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -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 { @@ -196,6 +197,7 @@ import { isOneOnOneChat, isOneTransactionThread, isPolicyExpenseChat, + isPublicRoom, isSelfDM, isSystemChat as isSystemChatUtil, isTaskReport, @@ -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 diff --git a/tests/unit/DeepLinkHandlerTest.tsx b/tests/unit/DeepLinkHandlerTest.tsx new file mode 100644 index 000000000000..77ab38769ca5 --- /dev/null +++ b/tests/unit/DeepLinkHandlerTest.tsx @@ -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('@libs/actions/Link'), + openReportFromDeepLink: jest.fn(), +})); + +jest.mock('@libs/actions/Report', () => ({ + ...jest.requireActual('@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(); + 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(); + 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(); + await waitForBatchedUpdatesWithAct(); + + await act(async () => { + await Onyx.merge(ONYXKEYS.IS_LOADING_APP, false); + }); + await waitForBatchedUpdatesWithAct(); + + expect(Report.openReport).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/SidebarUtilsTest.ts b/tests/unit/SidebarUtilsTest.ts index ae24aded2ade..da35d7af09c6 100644 --- a/tests/unit/SidebarUtilsTest.ts +++ b/tests/unit/SidebarUtilsTest.ts @@ -1117,6 +1117,115 @@ describe('SidebarUtils', () => { 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 = {[reportKey]: report}; + + await act(async () => { + await Onyx.multiSet({ + [ONYXKEYS.SESSION]: {authTokenType: CONST.AUTH_TOKEN_TYPES.ANONYMOUS}, + [reportKey]: report, + }); + }); + await waitForBatchedUpdates(); + + const result = SidebarUtils.shouldDisplayReportInLHN({ + 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 = {[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({ + 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 = {[reportKey]: report}; + + await act(async () => { + await Onyx.multiSet({ + [ONYXKEYS.SESSION]: {authTokenType: CONST.AUTH_TOKEN_TYPES.ANONYMOUS}, + [reportKey]: report, + }); + }); + await waitForBatchedUpdates(); + + const result = SidebarUtils.shouldDisplayReportInLHN({ + 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