From ae0d3ca8cf5b9e3285a60c9670fd6b61d84f2ddf Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Tue, 6 Oct 2026 15:29:43 +0200 Subject: [PATCH 1/7] Read isAnonymousUser in ReportUtils from CurrentUserStore instead of actions/Session --- src/libs/CurrentUserStore.ts | 12 ++++++++-- src/libs/ReportUtils.ts | 8 +++---- tests/unit/CurrentUserStoreTest.ts | 35 +++++++++++++++++++++++++++++- tests/unit/ReportUtilsTest.ts | 28 ++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 7 deletions(-) diff --git a/src/libs/CurrentUserStore.ts b/src/libs/CurrentUserStore.ts index d44cb3410fc3..fbff9c3f857b 100644 --- a/src/libs/CurrentUserStore.ts +++ b/src/libs/CurrentUserStore.ts @@ -1,7 +1,9 @@ +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {Session} from '@src/types/onyx'; /** - * Session email/auth-token mirror with no imports beyond Onyx. Keeps Log and light + * Session email, auth token and auth token type mirror with no imports beyond Onyx and CONST. Keeps Log and light * consumers away from NetworkStore, which imports Log, and from * actions/Session, which drags the whole session layer into their import graphs. */ @@ -9,12 +11,14 @@ import Onyx from 'react-native-onyx'; let currentUserEmail: string | null = null; let sessionAuthToken: string | null = null; +let sessionAuthTokenType: Session['authTokenType'] | null = null; Onyx.connectWithoutView({ key: ONYXKEYS.SESSION, callback: (val) => { currentUserEmail = val?.email ?? null; sessionAuthToken = val?.authToken ?? null; + sessionAuthTokenType = val?.authTokenType ?? null; }, }); @@ -26,4 +30,8 @@ function hasAuthToken(): boolean { return !!sessionAuthToken; } -export {getCurrentUserEmail, hasAuthToken}; +function isAnonymousUser(): boolean { + return sessionAuthTokenType === CONST.AUTH_TOKEN_TYPES.ANONYMOUS; +} + +export {getCurrentUserEmail, hasAuthToken, isAnonymousUser}; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 7f87713d7b42..3cfd80f7ac96 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -110,12 +110,12 @@ import {getBankAccountFromID} from './actions/BankAccounts'; import {unholdRequest} from './actions/IOU/Hold'; import {canApproveIOU, canIOUBePaid, canSubmitReport, getBadgeFromIOUReport, getIOUReportActionWithBadge} from './actions/IOU/ReportWorkflow'; import hasCreditBankAccount from './actions/ReimbursementAccount/hasCreditBankAccount'; -import {isAnonymousUser as isAnonymousUserSession} from './actions/Session'; import {getOnboardingMessages} from './actions/Welcome/OnboardingFlow'; import {convertAttendeesToArray, normalizeAttendees} from './AttendeeUtils'; import {isCardWithPotentialFraud} from './CardUtils'; import {getCategoryGLCode} from './CategoryUtils'; import {convertToDisplayStringEnLocale} from './CurrencyUtils'; +import {isAnonymousUser} from './CurrentUserStore'; import DateUtils from './DateUtils'; import {getEnvironmentURL} from './Environment/Environment'; import {getMicroSecondOnyxErrorWithTranslationKey, isReceiptError} from './ErrorUtils'; @@ -2509,7 +2509,7 @@ function hasExpensifyGuidesEmails(accountIDs: number[], guideAccountIDs: GuideAc function getMostRecentlyVisitedReport(reports: Array>, lastVisitTimes: Record): OnyxEntry { const filteredReports = reports.filter((report) => { - if (!report?.isPinned && isHiddenForCurrentUser(report) && !(isPublicRoom(report) && isAnonymousUserSession())) { + if (!report?.isPinned && isHiddenForCurrentUser(report) && !(isPublicRoom(report) && isAnonymousUser())) { return false; } return !!report?.reportID && !!(lastVisitTimes?.[report.reportID] ?? report?.lastReadTime); @@ -2584,7 +2584,7 @@ function findLastAccessedReport( // Return the most recently visited report. Get the last read report from the last-visit-times map. // If we have no visit data we'll return most recent report owned by user. if (isEmptyObject(allReportLastVisitTimes)) { - const visibleReports = reportsValues.filter((report) => !!report?.isPinned || !isHiddenForCurrentUser(report) || (isPublicRoom(report) && isAnonymousUserSession())); + const visibleReports = reportsValues.filter((report) => !!report?.isPinned || !isHiddenForCurrentUser(report) || (isPublicRoom(report) && isAnonymousUser())); const ownedReports = visibleReports.filter((report) => report?.ownerAccountID === deprecatedCurrentUserAccountID); if (ownedReports.length > 0) { return toLastAccessedReport(lodashMaxBy(ownedReports, (a) => a?.lastReadTime ?? '')); @@ -12424,7 +12424,7 @@ function canLeaveChat(report: OnyxEntry, policy: OnyxEntry, curr return true; } - if (isPublicRoom(report) && isAnonymousUserSession()) { + if (isPublicRoom(report) && isAnonymousUser()) { return false; } diff --git a/tests/unit/CurrentUserStoreTest.ts b/tests/unit/CurrentUserStoreTest.ts index b6ad7dfbe59c..63ec7fa86caa 100644 --- a/tests/unit/CurrentUserStoreTest.ts +++ b/tests/unit/CurrentUserStoreTest.ts @@ -1,5 +1,6 @@ -import {hasAuthToken} from '@libs/CurrentUserStore'; +import {hasAuthToken, isAnonymousUser} from '@libs/CurrentUserStore'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import Onyx from 'react-native-onyx'; @@ -28,3 +29,35 @@ describe('hasAuthToken', () => { expect(hasAuthToken()).toBe(false); })); }); + +describe('isAnonymousUser', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(() => Onyx.clear()); + + it('returns false while the session has no auth token type', () => + // Given a session without an auth token type, as before sign-in + Onyx.merge(ONYXKEYS.SESSION, {email: 'user@test.com'}).then(() => { + // When / Then: ReportUtils hides public rooms unless the user is anonymous, so this must be false + expect(isAnonymousUser()).toBe(false); + })); + + it('returns true once the session holds an anonymous auth token type', () => + // Given a session opened as an anonymous user, e.g. a signed-out visitor of a public room + Onyx.merge(ONYXKEYS.SESSION, {authToken: 'abc123', authTokenType: CONST.AUTH_TOKEN_TYPES.ANONYMOUS}).then(() => { + // When / Then: the mirror reports the anonymous session so ReportUtils keeps public rooms visible + expect(isAnonymousUser()).toBe(true); + })); + + it('returns false again once the anonymous token type is cleared', () => + // Given an anonymous session + Onyx.merge(ONYXKEYS.SESSION, {authTokenType: CONST.AUTH_TOKEN_TYPES.ANONYMOUS}) + // When the user signs in with a regular account, which carries no auth token type + .then(() => Onyx.merge(ONYXKEYS.SESSION, {authTokenType: null})) + .then(() => { + // Then the mirror no longer treats the user as anonymous + expect(isAnonymousUser()).toBe(false); + })); +}); diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 89e061e3ecdd..07475a4627b4 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -14655,6 +14655,34 @@ describe('ReportUtils', () => { expect(canLeaveChat(report, undefined, currentUserAccountID, false)).toBe(false); }); + it('should return true for the same public room when the user is not anonymous', async () => { + // Given a public policy room the current user participates in, with a regular (non-anonymous) session + const report: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.POLICY_ROOM), + visibility: CONST.REPORT.VISIBILITY.PUBLIC, + participants: buildParticipantsFromAccountIDs([currentUserAccountID]), + }; + await Onyx.set(ONYXKEYS.SESSION, {email: currentUserEmail, accountID: currentUserAccountID}); + + // When checking whether the user can leave it + // Then the user can leave it, which is what makes the anonymous-guest case meaningful + expect(canLeaveChat(report, undefined, currentUserAccountID, false)).toBe(true); + }); + + it('should return false for that public room once the session is anonymous', async () => { + // Given the same public room, but the session is anonymous (signed-out visitor) + const report: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.POLICY_ROOM), + visibility: CONST.REPORT.VISIBILITY.PUBLIC, + participants: buildParticipantsFromAccountIDs([currentUserAccountID]), + }; + await Onyx.set(ONYXKEYS.SESSION, {email: currentUserEmail, accountID: currentUserAccountID, authTokenType: CONST.AUTH_TOKEN_TYPES.ANONYMOUS}); + + // When checking whether the user can leave it + // Then the anonymous check blocks leaving, read from the session mirror in CurrentUserStore + expect(canLeaveChat(report, undefined, currentUserAccountID, false)).toBe(false); + }); + it('should return false if the report is hidden for the current user', async () => { const report: Report = { ...createRandomReport(1, undefined), From be4c6bae6a0fe3496ec519268d4bb0596a0a9499 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Wed, 7 Oct 2026 13:23:31 +0200 Subject: [PATCH 2/7] Pass isAnonymousUser explicitly to ReportUtils instead of reading module session Thread the anonymous-user flag through findLastAccessedReport, getMostRecentlyVisitedReport, canLeaveChat and navigateAfterOnboarding from their callers (via useIsAnonymousUser hook) instead of importing the anonymous-session check from actions/Session. Removes the now-unused isAnonymousUser helper from CurrentUserStore. --- src/hooks/useAutoCreateTrackWorkspace.ts | 4 + src/hooks/useCompleteOnboarding.ts | 3 + src/hooks/useFindLastAccessedReport.ts | 5 +- src/libs/CurrentUserStore.ts | 12 +-- src/libs/ReportUtils.ts | 14 ++-- src/libs/actions/Link.ts | 2 +- src/libs/navigateAfterOnboarding.ts | 14 +++- .../ReportDetailsMenuLeaveItem.tsx | 6 +- .../ReportDetailsTaskDeleteAction.tsx | 4 +- .../BaseOnboardingPersonalDetails.tsx | 4 + .../BaseOnboardingPrivateDomain.tsx | 4 + .../BaseOnboardingPurpose.tsx | 3 + .../BaseOnboardingWorkspaces.tsx | 14 +++- tests/perf-test/ReportUtils.perf-test.ts | 2 +- tests/unit/CurrentUserStoreTest.ts | 35 +-------- tests/unit/ReportUtilsTest.ts | 78 +++++++++++-------- tests/unit/navigateAfterOnboardingTest.ts | 39 ++++++---- 17 files changed, 134 insertions(+), 109 deletions(-) diff --git a/src/hooks/useAutoCreateTrackWorkspace.ts b/src/hooks/useAutoCreateTrackWorkspace.ts index c79150251df5..b11e4c97a1fb 100644 --- a/src/hooks/useAutoCreateTrackWorkspace.ts +++ b/src/hooks/useAutoCreateTrackWorkspace.ts @@ -19,6 +19,7 @@ import type {OnyxCollection} from 'react-native-onyx'; import {useCallback, useMemo} from 'react'; import useDelegateAccountID from './useDelegateAccountID'; +import useIsAnonymousUser from './useIsAnonymousUser'; import useOnboardingWorkspaceCreationState from './useOnboardingWorkspaceCreationState'; import useOnyx from './useOnyx'; import usePermissions from './usePermissions'; @@ -62,6 +63,7 @@ function useAutoCreateTrackWorkspace() { const [selfDMReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReportID}`); const [onboardingValues] = useOnyx(ONYXKEYS.NVP_ONBOARDING); const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); + const isAnonymousUser = useIsAnonymousUser(); const {isBetaEnabled} = usePermissions(); const delegateAccountID = useDelegateAccountID(); @@ -143,6 +145,7 @@ function useAutoCreateTrackWorkspace() { isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), conciergeChatReportID, reportNameValuePairs, + isAnonymousUser, newPolicyID, mergedAccountConciergeReportID, false, @@ -172,6 +175,7 @@ function useAutoCreateTrackWorkspace() { isBetaEnabled, conciergeChatReportID, reportNameValuePairs, + isAnonymousUser, mergedAccountConciergeReportID, conciergeChat, selfDMReport, diff --git a/src/hooks/useCompleteOnboarding.ts b/src/hooks/useCompleteOnboarding.ts index 0d432a3b4f92..bb8943332ccc 100644 --- a/src/hooks/useCompleteOnboarding.ts +++ b/src/hooks/useCompleteOnboarding.ts @@ -21,6 +21,7 @@ import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useDelegateAccountID from './useDelegateAccountID'; import useHasActiveAdminPolicies from './useHasActiveAdminPolicies'; import useHasOwnedPaidPolicy from './useHasOwnedPaidPolicy'; +import useIsAnonymousUser from './useIsAnonymousUser'; import useLastWorkspaceNumber from './useLastWorkspaceNumber'; import useLocalize from './useLocalize'; import useOnboardingMessages from './useOnboardingMessages'; @@ -59,6 +60,7 @@ function useCompleteOnboarding() { const [adminsChatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${onboardingAdminsChatReportID}`); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); + const isAnonymousUser = useIsAnonymousUser(); const [isLoading, setIsLoading] = useState(false); const groupPolicy = Object.values(allPolicies ?? {}).find((policy) => isGroupPolicy(policy) && isPolicyAdmin(policy, currentUserPersonalDetails.email)); @@ -143,6 +145,7 @@ function useCompleteOnboarding() { isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), conciergeReportID, reportNameValuePairs, + isAnonymousUser, policyID, adminsChatReportID, (currentUserPersonalDetails.email ?? '').includes('+'), diff --git a/src/hooks/useFindLastAccessedReport.ts b/src/hooks/useFindLastAccessedReport.ts index c371cb4d6ac0..cb5e767acbfb 100644 --- a/src/hooks/useFindLastAccessedReport.ts +++ b/src/hooks/useFindLastAccessedReport.ts @@ -4,6 +4,7 @@ import {findLastAccessedReport} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import useIsAnonymousUser from './useIsAnonymousUser'; import useOnyx from './useOnyx'; import usePermissions from './usePermissions'; @@ -20,9 +21,11 @@ function useFindLastAccessedReport({openOnAdminRoom, enabled = true}: UseFindLas const ignoreDomainRooms = !isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS); const shouldPreferAdminRoom = openOnAdminRoom ?? shouldOpenOnAdminRoom(); const [guideAccountIDs] = useOnyx(ONYXKEYS.DERIVED.GUIDE_ACCOUNT_IDS); + const isAnonymousUser = useIsAnonymousUser(); const [reportNameValuePairs, reportNameValuePairsMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); const [lastAccessedReportID, reportsMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT, { - selector: (reports) => (enabled ? findLastAccessedReport(ignoreDomainRooms, guideAccountIDs, shouldPreferAdminRoom, undefined, reportNameValuePairs, reports)?.reportID : undefined), + selector: (reports) => + enabled ? findLastAccessedReport(ignoreDomainRooms, guideAccountIDs, isAnonymousUser, shouldPreferAdminRoom, undefined, reportNameValuePairs, reports)?.reportID : undefined, }); return {lastAccessedReportID, reportsMetadata, reportNameValuePairsMetadata}; } diff --git a/src/libs/CurrentUserStore.ts b/src/libs/CurrentUserStore.ts index fbff9c3f857b..d44cb3410fc3 100644 --- a/src/libs/CurrentUserStore.ts +++ b/src/libs/CurrentUserStore.ts @@ -1,9 +1,7 @@ -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Session} from '@src/types/onyx'; /** - * Session email, auth token and auth token type mirror with no imports beyond Onyx and CONST. Keeps Log and light + * Session email/auth-token mirror with no imports beyond Onyx. Keeps Log and light * consumers away from NetworkStore, which imports Log, and from * actions/Session, which drags the whole session layer into their import graphs. */ @@ -11,14 +9,12 @@ import Onyx from 'react-native-onyx'; let currentUserEmail: string | null = null; let sessionAuthToken: string | null = null; -let sessionAuthTokenType: Session['authTokenType'] | null = null; Onyx.connectWithoutView({ key: ONYXKEYS.SESSION, callback: (val) => { currentUserEmail = val?.email ?? null; sessionAuthToken = val?.authToken ?? null; - sessionAuthTokenType = val?.authTokenType ?? null; }, }); @@ -30,8 +26,4 @@ function hasAuthToken(): boolean { return !!sessionAuthToken; } -function isAnonymousUser(): boolean { - return sessionAuthTokenType === CONST.AUTH_TOKEN_TYPES.ANONYMOUS; -} - -export {getCurrentUserEmail, hasAuthToken, isAnonymousUser}; +export {getCurrentUserEmail, hasAuthToken}; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 3cfd80f7ac96..b7267159132f 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -115,7 +115,6 @@ import {convertAttendeesToArray, normalizeAttendees} from './AttendeeUtils'; import {isCardWithPotentialFraud} from './CardUtils'; import {getCategoryGLCode} from './CategoryUtils'; import {convertToDisplayStringEnLocale} from './CurrencyUtils'; -import {isAnonymousUser} from './CurrentUserStore'; import DateUtils from './DateUtils'; import {getEnvironmentURL} from './Environment/Environment'; import {getMicroSecondOnyxErrorWithTranslationKey, isReceiptError} from './ErrorUtils'; @@ -2507,9 +2506,9 @@ function hasExpensifyGuidesEmails(accountIDs: number[], guideAccountIDs: GuideAc return accountIDs.some((accountID) => Str.extractEmailDomain(getPersonalDetail(accountID)?.login ?? '') === CONST.EMAIL.GUIDES_DOMAIN); } -function getMostRecentlyVisitedReport(reports: Array>, lastVisitTimes: Record): OnyxEntry { +function getMostRecentlyVisitedReport(reports: Array>, lastVisitTimes: Record, isAnonymousUser: boolean): OnyxEntry { const filteredReports = reports.filter((report) => { - if (!report?.isPinned && isHiddenForCurrentUser(report) && !(isPublicRoom(report) && isAnonymousUser())) { + if (!report?.isPinned && isHiddenForCurrentUser(report) && !(isPublicRoom(report) && isAnonymousUser)) { return false; } return !!report?.reportID && !!(lastVisitTimes?.[report.reportID] ?? report?.lastReadTime); @@ -2536,6 +2535,7 @@ function toLastAccessedReport(report: OnyxEntry): LastAccessedReport | u function findLastAccessedReport( ignoreDomainRooms: boolean, guideAccountIDs: GuideAccountIDsDerivedValue | undefined, + isAnonymousUser: boolean, openOnAdminRoom = false, excludeReportID?: string, reportNameValuePairs?: OnyxCollection, @@ -2584,14 +2584,14 @@ function findLastAccessedReport( // Return the most recently visited report. Get the last read report from the last-visit-times map. // If we have no visit data we'll return most recent report owned by user. if (isEmptyObject(allReportLastVisitTimes)) { - const visibleReports = reportsValues.filter((report) => !!report?.isPinned || !isHiddenForCurrentUser(report) || (isPublicRoom(report) && isAnonymousUser())); + const visibleReports = reportsValues.filter((report) => !!report?.isPinned || !isHiddenForCurrentUser(report) || (isPublicRoom(report) && isAnonymousUser)); const ownedReports = visibleReports.filter((report) => report?.ownerAccountID === deprecatedCurrentUserAccountID); if (ownedReports.length > 0) { return toLastAccessedReport(lodashMaxBy(ownedReports, (a) => a?.lastReadTime ?? '')); } return toLastAccessedReport(lodashMaxBy(reportsValues, (a) => a?.lastReadTime ?? '')); } - return toLastAccessedReport(getMostRecentlyVisitedReport(reportsValues, allReportLastVisitTimes)); + return toLastAccessedReport(getMostRecentlyVisitedReport(reportsValues, allReportLastVisitTimes, isAnonymousUser)); } /** @@ -12415,7 +12415,7 @@ function canJoinChat( /** * Whether the user can leave a report */ -function canLeaveChat(report: OnyxEntry, policy: OnyxEntry, currentUserAccountID: number | undefined, isReportArchived = false): boolean { +function canLeaveChat(report: OnyxEntry, policy: OnyxEntry, currentUserAccountID: number | undefined, isAnonymousUser: boolean, isReportArchived = false): boolean { if (isRootGroupChat(report, isReportArchived)) { return true; } @@ -12424,7 +12424,7 @@ function canLeaveChat(report: OnyxEntry, policy: OnyxEntry, curr return true; } - if (isPublicRoom(report) && isAnonymousUser()) { + if (isPublicRoom(report) && isAnonymousUser) { return false; } diff --git a/src/libs/actions/Link.ts b/src/libs/actions/Link.ts index bacb1ac64eff..035531b3d063 100644 --- a/src/libs/actions/Link.ts +++ b/src/libs/actions/Link.ts @@ -605,7 +605,7 @@ function openReportFromDeepLink( // If the report does not exist, navigate to the last accessed report or Concierge chat if (reportID && (!report?.reportID || report.errorFields?.notFound)) { // TODO: Pass guideAccountIDs once callers are fully migrated — PR 33 (https://github.com/Expensify/App/issues/66413); findLastAccessedReport falls back to hasExpensifyGuidesEmails → allPersonalDetails - const lastAccessedReportID = findLastAccessedReport(false, undefined, shouldOpenOnAdminRoom(), reportID, reportNameValuePairs)?.reportID; + const lastAccessedReportID = findLastAccessedReport(false, undefined, isAnonymousUser(), shouldOpenOnAdminRoom(), reportID, reportNameValuePairs)?.reportID; if (lastAccessedReportID) { const lastAccessedReportRoute = ROUTES.REPORT_WITH_ID.getRoute(lastAccessedReportID); Navigation.navigate(lastAccessedReportRoute, {forceReplace: Navigation.getTopmostReportId() === reportID, waitForTransition: true}); diff --git a/src/libs/navigateAfterOnboarding.ts b/src/libs/navigateAfterOnboarding.ts index 7e64fb96331e..22d3e3a95139 100644 --- a/src/libs/navigateAfterOnboarding.ts +++ b/src/libs/navigateAfterOnboarding.ts @@ -42,6 +42,7 @@ function getReportIDAfterOnboarding( canUseDefaultRooms: boolean | undefined, conciergeReportID: string | undefined, reportNameValuePairs: OnyxCollection, + isAnonymousUser: boolean, onboardingPolicyID?: string, onboardingAdminsChatReportID?: string, shouldPreventOpenAdminRoom = false, @@ -57,7 +58,14 @@ function getReportIDAfterOnboarding( } // TODO: Pass guideAccountIDs once callers are fully migrated — PR 33 (https://github.com/Expensify/App/issues/66413); findLastAccessedReport falls back to hasExpensifyGuidesEmails → allPersonalDetails - const lastAccessedReport = findLastAccessedReport(!canUseDefaultRooms, undefined, shouldOpenOnAdminRoom() && !shouldPreventOpenAdminRoom, undefined, reportNameValuePairs); + const lastAccessedReport = findLastAccessedReport( + !canUseDefaultRooms, + undefined, + isAnonymousUser, + shouldOpenOnAdminRoom() && !shouldPreventOpenAdminRoom, + undefined, + reportNameValuePairs, + ); const lastAccessedReportID = lastAccessedReport?.reportID; // When the user goes through the onboarding flow, a workspace can be created if the user selects specific options. The user should be taken to the #admins room for that workspace because it is the most natural place for them to start their experience in the app. @@ -79,6 +87,7 @@ function navigateAfterOnboarding( canUseDefaultRooms: boolean | undefined, conciergeReportID: string | undefined, reportNameValuePairs: OnyxCollection, + isAnonymousUser: boolean, onboardingPolicyID?: string, onboardingAdminsChatReportID?: string, shouldPreventOpenAdminRoom = false, @@ -118,6 +127,7 @@ function navigateAfterOnboarding( canUseDefaultRooms, conciergeReportID, reportNameValuePairs, + isAnonymousUser, onboardingPolicyID, onboardingAdminsChatReportID, shouldPreventOpenAdminRoom, @@ -135,6 +145,7 @@ function navigateAfterOnboardingWithMicrotaskQueue( canUseDefaultRooms: boolean | undefined, conciergeReportID: string | undefined, reportNameValuePairs: OnyxCollection, + isAnonymousUser: boolean, onboardingPolicyID?: string, onboardingAdminsChatReportID?: string, shouldPreventOpenAdminRoom = false, @@ -147,6 +158,7 @@ function navigateAfterOnboardingWithMicrotaskQueue( canUseDefaultRooms, conciergeReportID, reportNameValuePairs, + isAnonymousUser, onboardingPolicyID, onboardingAdminsChatReportID, shouldPreventOpenAdminRoom, diff --git a/src/pages/DynamicReportDetailsPage/ReportDetailsMenuLeaveItem.tsx b/src/pages/DynamicReportDetailsPage/ReportDetailsMenuLeaveItem.tsx index 754fd05565e4..2b43f536e6e2 100644 --- a/src/pages/DynamicReportDetailsPage/ReportDetailsMenuLeaveItem.tsx +++ b/src/pages/DynamicReportDetailsPage/ReportDetailsMenuLeaveItem.tsx @@ -3,6 +3,7 @@ import {ModalActions} from '@components/Modal/Global/ModalContext'; import useConfirmModal from '@hooks/useConfirmModal'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useIsAnonymousUser from '@hooks/useIsAnonymousUser'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -44,10 +45,11 @@ function ReportDetailsMenuLeaveItem({reportID}: ReportDetailsMenuLeaveItemProps) const [personalDetails] = useAllPersonalDetails(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUserAccountID = currentUserPersonalDetails?.accountID; + const isAnonymousUser = useIsAnonymousUser(); const isReportArchived = useReportIsArchived(reportID); const {showConfirmModal} = useConfirmModal(); - const shouldShowLeaveButton = canLeaveChat(report, policy, currentUserAccountID, !!reportNameValuePairs?.private_isArchived); + const shouldShowLeaveButton = canLeaveChat(report, policy, currentUserAccountID, isAnonymousUser, !!reportNameValuePairs?.private_isArchived); if (!report || !shouldShowLeaveButton) { return null; @@ -59,7 +61,7 @@ function ReportDetailsMenuLeaveItem({reportID}: ReportDetailsMenuLeaveItemProps) const leaveChat = () => { // Resolve on tap from the module-scoped copies so this large page does not subscribe to whole collections. - const lastAccessedReportID = findLastAccessedReport(false, guideAccountIDs, false, report.reportID)?.reportID; + const lastAccessedReportID = findLastAccessedReport(false, guideAccountIDs, isAnonymousUser, false, report.reportID)?.reportID; if (isRootGroupChat) { leaveGroupChat( report, diff --git a/src/pages/DynamicReportDetailsPage/ReportDetailsTaskDeleteAction.tsx b/src/pages/DynamicReportDetailsPage/ReportDetailsTaskDeleteAction.tsx index b3431ff7321a..84dca265feb7 100644 --- a/src/pages/DynamicReportDetailsPage/ReportDetailsTaskDeleteAction.tsx +++ b/src/pages/DynamicReportDetailsPage/ReportDetailsTaskDeleteAction.tsx @@ -3,6 +3,7 @@ import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; import useAncestors from '@hooks/useAncestors'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useHasOutstandingChildTask from '@hooks/useHasOutstandingChildTask'; +import useIsAnonymousUser from '@hooks/useIsAnonymousUser'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -49,6 +50,7 @@ function ReportDetailsTaskDeleteActionContent({report, parentReport, parentRepor const taskDeleteBackTo = Navigation.getTopmostSearchReportRouteParams()?.backTo; const hasOutstandingChildTask = useHasOutstandingChildTask(report); const [guideAccountIDs] = useOnyx(ONYXKEYS.DERIVED.GUIDE_ACCOUNT_IDS); + const isAnonymousUser = useIsAnonymousUser(); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [reportActionsForOriginalReportID] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`); const [delegateEmail] = useOnyx(ONYXKEYS.ACCOUNT, {selector: delegateEmailSelector}); @@ -69,7 +71,7 @@ function ReportDetailsTaskDeleteActionContent({report, parentReport, parentRepor { ancestors, shouldNavigateBack: !taskDeleteBackTo, - lastAccessedReportID: findLastAccessedReport(false, guideAccountIDs, false, report.reportID)?.reportID, + lastAccessedReportID: findLastAccessedReport(false, guideAccountIDs, isAnonymousUser, false, report.reportID)?.reportID, }, ); }; diff --git a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx index c19a21a3c89a..5535a4a5d9bd 100644 --- a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx +++ b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx @@ -11,6 +11,7 @@ import useAutoCreateSubmitWorkspace from '@hooks/useAutoCreateSubmitWorkspace'; import useAutoCreateTrackWorkspace from '@hooks/useAutoCreateTrackWorkspace'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; +import useIsAnonymousUser from '@hooks/useIsAnonymousUser'; import useLocalize from '@hooks/useLocalize'; import useOnboardingMessages from '@hooks/useOnboardingMessages'; import useOnyx from '@hooks/useOnyx'; @@ -56,6 +57,7 @@ function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNat const delegateAccountID = useDelegateAccountID(); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); + const isAnonymousUser = useIsAnonymousUser(); const [loginList] = useOnyx(ONYXKEYS.LOGINS, {selector: expensifyLoginsSelector}); const [onboardingValues] = useOnyx(ONYXKEYS.NVP_ONBOARDING); const [conciergeChatReportID = ''] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); @@ -135,6 +137,7 @@ function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNat isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), conciergeChatReportID, reportNameValuePairs, + isAnonymousUser, onboardingPurposeSelected === CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE && !onboardingPolicy ? undefined : onboardingPolicyID, mergedAccountConciergeReportID, false, @@ -161,6 +164,7 @@ function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNat onboardingPolicy, isBetaEnabled, reportNameValuePairs, + isAnonymousUser, isSmallScreenWidth, mergedAccountConciergeReportID, conciergeChatReportID, diff --git a/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx b/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx index fda0d3c4b487..203a57335eff 100644 --- a/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx +++ b/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx @@ -6,6 +6,7 @@ import ValidateCodeForm from '@components/ValidateCodeActionModal/ValidateCodeFo import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; +import useIsAnonymousUser from '@hooks/useIsAnonymousUser'; import useLocalize from '@hooks/useLocalize'; import useOnboardingIntent from '@hooks/useOnboardingIntent'; import useOnboardingMessages from '@hooks/useOnboardingMessages'; @@ -73,6 +74,7 @@ function BaseOnboardingPrivateDomain({shouldUseNativeStyles, route}: BaseOnboard const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); + const isAnonymousUser = useIsAnonymousUser(); const {joinWorkspaceMessages} = useOnboardingMessages(); const {isBetaEnabled} = usePermissions(); const isVsb = onboardingValues?.signupQualifier === CONST.ONBOARDING_SIGNUP_QUALIFIERS.VSB; @@ -166,6 +168,7 @@ function BaseOnboardingPrivateDomain({shouldUseNativeStyles, route}: BaseOnboard isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), conciergeReportID, reportNameValuePairs, + isAnonymousUser, onboardingPolicyID, onboardingAdminsChatReportID, false, @@ -183,6 +186,7 @@ function BaseOnboardingPrivateDomain({shouldUseNativeStyles, route}: BaseOnboard email, introSelected, isBetaEnabled, + isAnonymousUser, firstName, isCompletingOnboarding, isSelfTourViewed, diff --git a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx index 435e2fbaf273..224b4da13f89 100644 --- a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx +++ b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx @@ -9,6 +9,7 @@ import useAutoCreateSubmitWorkspace from '@hooks/useAutoCreateSubmitWorkspace'; import useAutoCreateTrackWorkspace from '@hooks/useAutoCreateTrackWorkspace'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; +import useIsAnonymousUser from '@hooks/useIsAnonymousUser'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnboardingMessages from '@hooks/useOnboardingMessages'; @@ -78,6 +79,7 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, ro const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); const {onboardingMessages} = useOnboardingMessages(); const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); + const isAnonymousUser = useIsAnonymousUser(); const isPrivateDomainAndHasAccessiblePolicies = !account?.isFromPublicDomain && !!account?.hasAccessibleDomainPolicies; @@ -188,6 +190,7 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, ro isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), conciergeReportID, reportNameValuePairs, + isAnonymousUser, onboardingPolicyID, onboardingAdminsChatReportID, false, diff --git a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx index 9429a15ed586..2bb661123bcd 100644 --- a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx +++ b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx @@ -11,6 +11,7 @@ import useAutoCreateSubmitWorkspace from '@hooks/useAutoCreateSubmitWorkspace'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; +import useIsAnonymousUser from '@hooks/useIsAnonymousUser'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnboardingIntent from '@hooks/useOnboardingIntent'; @@ -84,6 +85,7 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding selector: hasSeenTourSelector, }); const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); + const isAnonymousUser = useIsAnonymousUser(); const {accountID: currentUserAccountID, email: currentUserEmail} = useCurrentUserPersonalDetails(); const isValidated = isCurrentUserValidated(loginList, currentUserEmail); @@ -188,6 +190,7 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), conciergeReportID, reportNameValuePairs, + isAnonymousUser, policy.automaticJoiningEnabled ? policy.policyID : undefined, undefined, false, @@ -388,7 +391,16 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding }); setOnboardingAdminsChatReportID(); - navigateAfterOnboardingWithMicrotaskQueue(isSmallScreenWidth, isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), conciergeReportID, reportNameValuePairs, undefined, undefined, false); + navigateAfterOnboardingWithMicrotaskQueue( + isSmallScreenWidth, + isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), + conciergeReportID, + reportNameValuePairs, + isAnonymousUser, + undefined, + undefined, + false, + ); return; } diff --git a/tests/perf-test/ReportUtils.perf-test.ts b/tests/perf-test/ReportUtils.perf-test.ts index bcafd512bebe..ca601e949bc9 100644 --- a/tests/perf-test/ReportUtils.perf-test.ts +++ b/tests/perf-test/ReportUtils.perf-test.ts @@ -96,7 +96,7 @@ describe('ReportUtils', () => { }); await waitForBatchedUpdates(); - await measureFunction(() => findLastAccessedReport(ignoreDomainRooms, undefined, openOnAdminRoom)); + await measureFunction(() => findLastAccessedReport(ignoreDomainRooms, undefined, false, openOnAdminRoom)); }); test('[ReportUtils] canDeleteReportAction on 1k reports and policies', async () => { diff --git a/tests/unit/CurrentUserStoreTest.ts b/tests/unit/CurrentUserStoreTest.ts index 63ec7fa86caa..b6ad7dfbe59c 100644 --- a/tests/unit/CurrentUserStoreTest.ts +++ b/tests/unit/CurrentUserStoreTest.ts @@ -1,6 +1,5 @@ -import {hasAuthToken, isAnonymousUser} from '@libs/CurrentUserStore'; +import {hasAuthToken} from '@libs/CurrentUserStore'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import Onyx from 'react-native-onyx'; @@ -29,35 +28,3 @@ describe('hasAuthToken', () => { expect(hasAuthToken()).toBe(false); })); }); - -describe('isAnonymousUser', () => { - beforeAll(() => { - Onyx.init({keys: ONYXKEYS}); - }); - - beforeEach(() => Onyx.clear()); - - it('returns false while the session has no auth token type', () => - // Given a session without an auth token type, as before sign-in - Onyx.merge(ONYXKEYS.SESSION, {email: 'user@test.com'}).then(() => { - // When / Then: ReportUtils hides public rooms unless the user is anonymous, so this must be false - expect(isAnonymousUser()).toBe(false); - })); - - it('returns true once the session holds an anonymous auth token type', () => - // Given a session opened as an anonymous user, e.g. a signed-out visitor of a public room - Onyx.merge(ONYXKEYS.SESSION, {authToken: 'abc123', authTokenType: CONST.AUTH_TOKEN_TYPES.ANONYMOUS}).then(() => { - // When / Then: the mirror reports the anonymous session so ReportUtils keeps public rooms visible - expect(isAnonymousUser()).toBe(true); - })); - - it('returns false again once the anonymous token type is cleared', () => - // Given an anonymous session - Onyx.merge(ONYXKEYS.SESSION, {authTokenType: CONST.AUTH_TOKEN_TYPES.ANONYMOUS}) - // When the user signs in with a regular account, which carries no auth token type - .then(() => Onyx.merge(ONYXKEYS.SESSION, {authTokenType: null})) - .then(() => { - // Then the mirror no longer treats the user as anonymous - expect(isAnonymousUser()).toBe(false); - })); -}); diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 07475a4627b4..a073e5113581 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -5477,7 +5477,23 @@ describe('ReportUtils', () => { lastReadTime: '2023-07-08 07:15:44.030', participants: {[currentUserAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}}, }; - expect(getMostRecentlyVisitedReport(reports, {})).toEqual(latestReport); + expect(getMostRecentlyVisitedReport(reports, {}, false)).toEqual(latestReport); + }); + + it('should keep a hidden public room only when the user is anonymous', () => { + // Given a public room the user does not participate in, so it is hidden for them + const publicRoom: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.POLICY_ROOM), + visibility: CONST.REPORT.VISIBILITY.PUBLIC, + isPinned: false, + participants: {}, + lastReadTime: '2023-07-08 07:15:44.030', + }; + + // When a regular user and an anonymous visitor look for the most recently visited report + // Then only the anonymous visitor, who opened the room from a public link, gets it back + expect(getMostRecentlyVisitedReport([publicRoom], {}, false)).toBeUndefined(); + expect(getMostRecentlyVisitedReport([publicRoom], {}, true)).toEqual(publicRoom); }); }); @@ -6183,9 +6199,9 @@ describe('ReportUtils', () => { participants: buildParticipantsFromAccountIDs([currentUserAccountID, ownerAccountID]), }; - expect(canLeaveChat(report, undefined, unrelatedAccountID)).toBe(true); - expect(canLeaveChat(report, undefined, ownerAccountID)).toBe(false); - expect(canLeaveChat(report, undefined, managerAccountID)).toBe(false); + expect(canLeaveChat(report, undefined, unrelatedAccountID, false, false)).toBe(true); + expect(canLeaveChat(report, undefined, ownerAccountID, false, false)).toBe(false); + expect(canLeaveChat(report, undefined, managerAccountID, false, false)).toBe(false); }); }); @@ -10666,7 +10682,7 @@ describe('ReportUtils', () => { const reportNameValuePairsCollection = { [`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${archivedReport.reportID}`]: {private_isArchived: DateUtils.getDBTime()}, }; - const result = findLastAccessedReport(false, undefined, false, undefined, reportNameValuePairsCollection); + const result = findLastAccessedReport(false, undefined, false, false, undefined, reportNameValuePairsCollection); // Even though the archived report has a more recent lastVisitTime, // the function should filter it out and return the normal report @@ -10719,7 +10735,7 @@ describe('ReportUtils', () => { }); it('findLastAccessedReport should return owned report if no reports was accessed before', () => { - const result = findLastAccessedReport(false, undefined); + const result = findLastAccessedReport(false, undefined, false); // Even though the archived report has a more recent lastVisitTime, // the function should filter it out and return the normal report @@ -10758,33 +10774,33 @@ describe('ReportUtils', () => { it('should resolve a report from the passed collection while the stored reports are still empty', () => { // Nothing is in Onyx yet, so the copy the function reads by default holds no reports. - expect(findLastAccessedReport(false, undefined)).toBeUndefined(); + expect(findLastAccessedReport(false, undefined, false)).toBeUndefined(); const reports: OnyxCollection = { [`${ONYXKEYS.COLLECTION.REPORT}${providedReport.reportID}`]: providedReport, }; - expect(findLastAccessedReport(false, undefined, false, undefined, undefined, reports)?.reportID).toBe(providedReport.reportID); + expect(findLastAccessedReport(false, undefined, false, false, undefined, undefined, reports)?.reportID).toBe(providedReport.reportID); }); it('should prefer the passed collection over the stored reports', async () => { await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${storedReport.reportID}`, storedReport); await waitForBatchedUpdates(); - expect(findLastAccessedReport(false, undefined)?.reportID).toBe(storedReport.reportID); + expect(findLastAccessedReport(false, undefined, false)?.reportID).toBe(storedReport.reportID); const reports: OnyxCollection = { [`${ONYXKEYS.COLLECTION.REPORT}${providedReport.reportID}`]: providedReport, }; - expect(findLastAccessedReport(false, undefined, false, undefined, undefined, reports)?.reportID).toBe(providedReport.reportID); + expect(findLastAccessedReport(false, undefined, false, false, undefined, undefined, reports)?.reportID).toBe(providedReport.reportID); }); it('should fall back to the stored reports when no collection is passed', async () => { await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${storedReport.reportID}`, storedReport); await waitForBatchedUpdates(); - expect(findLastAccessedReport(false, undefined)?.reportID).toBe(storedReport.reportID); + expect(findLastAccessedReport(false, undefined, false)?.reportID).toBe(storedReport.reportID); }); }); @@ -14614,7 +14630,7 @@ describe('ReportUtils', () => { it('should return true for root group chat', () => { const report: Report = createRandomReport(1, CONST.REPORT.CHAT_TYPE.GROUP); - expect(canLeaveChat(report, undefined, currentUserAccountID, false)).toBe(true); + expect(canLeaveChat(report, undefined, currentUserAccountID, false, false)).toBe(true); }); it('should return false for a task off a group chat', () => { @@ -14626,7 +14642,7 @@ describe('ReportUtils', () => { participants: buildParticipantsFromAccountIDs([currentUserAccountID]), }; - expect(canLeaveChat(report, undefined, currentUserAccountID, false)).toBe(false); + expect(canLeaveChat(report, undefined, currentUserAccountID, false, false)).toBe(false); }); it('should return true for policy expense chat if the user is not the owner and the user is not an admin', () => { @@ -14641,46 +14657,42 @@ describe('ReportUtils', () => { role: CONST.POLICY.ROLE.USER, }; - expect(canLeaveChat(report, reportPolicy, currentUserAccountID, false)).toBe(true); + expect(canLeaveChat(report, reportPolicy, currentUserAccountID, false, false)).toBe(true); }); - it('should return false if the chat is public room and the user is the guest', async () => { + it('should return false if the chat is public room and the user is the guest', () => { const report: Report = { ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.POLICY_ROOM), visibility: CONST.REPORT.VISIBILITY.PUBLIC, }; - await Onyx.set(ONYXKEYS.SESSION, {email: currentUserEmail, accountID: currentUserAccountID, authTokenType: CONST.AUTH_TOKEN_TYPES.ANONYMOUS}); - - expect(canLeaveChat(report, undefined, currentUserAccountID, false)).toBe(false); + expect(canLeaveChat(report, undefined, currentUserAccountID, true, false)).toBe(false); }); - it('should return true for the same public room when the user is not anonymous', async () => { - // Given a public policy room the current user participates in, with a regular (non-anonymous) session + it('should return true for the same public room when the user is not anonymous', () => { + // Given a public policy room the current user participates in, viewed by a regular (non-anonymous) user const report: Report = { ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.POLICY_ROOM), visibility: CONST.REPORT.VISIBILITY.PUBLIC, participants: buildParticipantsFromAccountIDs([currentUserAccountID]), }; - await Onyx.set(ONYXKEYS.SESSION, {email: currentUserEmail, accountID: currentUserAccountID}); // When checking whether the user can leave it // Then the user can leave it, which is what makes the anonymous-guest case meaningful - expect(canLeaveChat(report, undefined, currentUserAccountID, false)).toBe(true); + expect(canLeaveChat(report, undefined, currentUserAccountID, false, false)).toBe(true); }); - it('should return false for that public room once the session is anonymous', async () => { - // Given the same public room, but the session is anonymous (signed-out visitor) + it('should return false for that public room when the user is anonymous', () => { + // Given the same public room, but viewed by an anonymous (signed-out) visitor const report: Report = { ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.POLICY_ROOM), visibility: CONST.REPORT.VISIBILITY.PUBLIC, participants: buildParticipantsFromAccountIDs([currentUserAccountID]), }; - await Onyx.set(ONYXKEYS.SESSION, {email: currentUserEmail, accountID: currentUserAccountID, authTokenType: CONST.AUTH_TOKEN_TYPES.ANONYMOUS}); // When checking whether the user can leave it - // Then the anonymous check blocks leaving, read from the session mirror in CurrentUserStore - expect(canLeaveChat(report, undefined, currentUserAccountID, false)).toBe(false); + // Then the anonymous flag passed by the caller blocks leaving + expect(canLeaveChat(report, undefined, currentUserAccountID, true, false)).toBe(false); }); it('should return false if the report is hidden for the current user', async () => { @@ -14697,7 +14709,7 @@ describe('ReportUtils', () => { await Onyx.set(ONYXKEYS.SESSION, {email: currentUserEmail, accountID: currentUserAccountID}); - expect(canLeaveChat(report, undefined, currentUserAccountID, false)).toBe(false); + expect(canLeaveChat(report, undefined, currentUserAccountID, false, false)).toBe(false); }); it('should return false for selfDM reports', () => { @@ -14706,7 +14718,7 @@ describe('ReportUtils', () => { type: CONST.REPORT.TYPE.CHAT, }; - expect(canLeaveChat(report, undefined, currentUserAccountID, false)).toBe(false); + expect(canLeaveChat(report, undefined, currentUserAccountID, false, false)).toBe(false); }); it('should return false for the public announce room if the user is a member of the policy', () => { @@ -14720,7 +14732,7 @@ describe('ReportUtils', () => { role: CONST.POLICY.ROLE.USER, }; - expect(canLeaveChat(report, reportPolicy, currentUserAccountID, false)).toBe(false); + expect(canLeaveChat(report, reportPolicy, currentUserAccountID, false, false)).toBe(false); }); it('should return true for the invoice room if the user is not the sender or receiver', async () => { @@ -14741,7 +14753,7 @@ describe('ReportUtils', () => { role: CONST.POLICY.ROLE.USER, }; - expect(canLeaveChat(report, reportPolicy, currentUserAccountID, false)).toBe(true); + expect(canLeaveChat(report, reportPolicy, currentUserAccountID, false, false)).toBe(true); }); it('should return true for chat thread if the user is joined', async () => { @@ -14755,7 +14767,7 @@ describe('ReportUtils', () => { await Onyx.set(ONYXKEYS.SESSION, {email: currentUserEmail, accountID: currentUserAccountID}); - expect(canLeaveChat(report, undefined, currentUserAccountID, false)).toBe(true); + expect(canLeaveChat(report, undefined, currentUserAccountID, false, false)).toBe(true); }); it('should return true for user created policy room', async () => { @@ -14771,7 +14783,7 @@ describe('ReportUtils', () => { role: CONST.POLICY.ROLE.USER, }; - expect(canLeaveChat(report, reportPolicy, currentUserAccountID, false)).toBe(true); + expect(canLeaveChat(report, reportPolicy, currentUserAccountID, false, false)).toBe(true); }); }); diff --git a/tests/unit/navigateAfterOnboardingTest.ts b/tests/unit/navigateAfterOnboardingTest.ts index dc53e8312e37..735c34481ec1 100644 --- a/tests/unit/navigateAfterOnboardingTest.ts +++ b/tests/unit/navigateAfterOnboardingTest.ts @@ -101,14 +101,14 @@ describe('navigateAfterOnboarding', () => { const navigate = jest.spyOn(Navigation, 'navigate'); const testSession = {email: 'realaccount@gmail.com'}; - navigateAfterOnboarding(false, true, '', {}, undefined, ONBOARDING_ADMINS_CHAT_REPORT_ID, (testSession?.email ?? '').includes('+')); + navigateAfterOnboarding(false, true, '', {}, false, undefined, ONBOARDING_ADMINS_CHAT_REPORT_ID, (testSession?.email ?? '').includes('+')); expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(ONBOARDING_ADMINS_CHAT_REPORT_ID), undefined); }); it('should navigate to home if onboardingAdminsChatReportID is not provided on larger screens and no report is topmost', () => { const navigate = jest.spyOn(Navigation, 'navigate'); - navigateAfterOnboarding(false, true, '', {}, undefined, undefined); + navigateAfterOnboarding(false, true, '', {}, false, undefined, undefined); // Without an admins chat report, we fall back to HOME to trigger guard evaluation instead of opening a report. expect(navigate).not.toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(ONBOARDING_ADMINS_CHAT_REPORT_ID)); expect(navigate).toHaveBeenCalledWith(ROUTES.HOME, undefined); @@ -122,7 +122,7 @@ describe('navigateAfterOnboarding', () => { finishDismissal = afterTransition; }); - navigateAfterOnboardingWithMicrotaskQueue(false, true, '', {}); + navigateAfterOnboardingWithMicrotaskQueue(false, true, '', {}, false); expect(dismissOnboardingModalBeforeExit).toHaveBeenCalledTimes(1); expect(navigate).not.toHaveBeenCalled(); @@ -136,7 +136,7 @@ describe('navigateAfterOnboarding', () => { const navigate = jest.spyOn(Navigation, 'navigate'); mockIsReportTopmostSplitNavigator.mockReturnValue(true); - navigateAfterOnboarding(false, true, '', {}, undefined, undefined); + navigateAfterOnboarding(false, true, '', {}, false, undefined, undefined); expect(navigate).not.toHaveBeenCalled(); }); @@ -156,7 +156,7 @@ describe('navigateAfterOnboarding', () => { mockFindLastAccessedReport.mockReturnValue(lastAccessedReport); mockShouldOpenOnAdminRoom.mockReturnValue(false); - navigateAfterOnboarding(true, true, REPORT_ID, {}, ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID); + navigateAfterOnboarding(true, true, REPORT_ID, {}, false, ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID); expect(navigate).not.toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); }); @@ -165,7 +165,7 @@ describe('navigateAfterOnboarding', () => { mockFindLastAccessedReport.mockReturnValue(lastAccessedReport); mockShouldOpenOnAdminRoom.mockReturnValue(false); - navigateAfterOnboarding(true, true, '', {}, ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID); + navigateAfterOnboarding(true, true, '', {}, false, ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID); expect(Navigation.navigate).not.toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); }); @@ -174,7 +174,7 @@ describe('navigateAfterOnboarding', () => { mockFindLastAccessedReport.mockReturnValue(lastAccessedReport); mockShouldOpenOnAdminRoom.mockReturnValue(false); - navigateAfterOnboarding(true, true, '', {}, ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID); + navigateAfterOnboarding(true, true, '', {}, false, ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID); expect(Navigation.navigate).not.toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); }); @@ -184,18 +184,21 @@ describe('navigateAfterOnboarding', () => { mockFindLastAccessedReport.mockReturnValue(lastAccessedReport); mockShouldOpenOnAdminRoom.mockReturnValue(true); - navigateAfterOnboarding(true, true, '', {}, ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID); + navigateAfterOnboarding(true, true, '', {}, false, ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID); expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID), undefined); }); - it('should pass reportNameValuePairs when looking up last accessed report', () => { + it('should pass reportNameValuePairs and the anonymous flag when looking up last accessed report', () => { + // Given archived report data and an anonymous caller const reportNameValuePairs = {[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${REPORT_ID}`]: {private_isArchived: '2024-02-01 04:56:47.233'}}; mockFindLastAccessedReport.mockReturnValue(undefined); mockShouldOpenOnAdminRoom.mockReturnValue(false); - navigateAfterOnboarding(true, true, '', reportNameValuePairs, ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID); + // When navigating after onboarding on a small screen + navigateAfterOnboarding(true, true, '', reportNameValuePairs, true, ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID); - expect(mockFindLastAccessedReport).toHaveBeenCalledWith(false, undefined, false, undefined, reportNameValuePairs); + // Then both values reach findLastAccessedReport instead of being read from module state + expect(mockFindLastAccessedReport).toHaveBeenCalledWith(false, undefined, true, false, undefined, reportNameValuePairs); }); it('should navigate to Concierge room if user uses a test email', () => { @@ -205,13 +208,13 @@ describe('navigateAfterOnboarding', () => { mockShouldOpenOnAdminRoom.mockReturnValue(true); const testSession = {email: 'test+account@gmail.com'}; - navigateAfterOnboarding(true, true, '', {}, ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID, (testSession?.email ?? '').includes('+')); + navigateAfterOnboarding(true, true, '', {}, false, ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID, (testSession?.email ?? '').includes('+')); expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID), undefined); }); it('should navigate to the admin room when the inboxAdminsBespoke variant is assigned', () => { const navigate = jest.spyOn(Navigation, 'navigate'); - navigateAfterOnboarding(false, true, '', {}, undefined, ONBOARDING_ADMINS_CHAT_REPORT_ID, false, {variantOverride: CONST.ONBOARDING_RHP_VARIANT.INBOX_ADMINS_BESPOKE}); + navigateAfterOnboarding(false, true, '', {}, false, undefined, ONBOARDING_ADMINS_CHAT_REPORT_ID, false, {variantOverride: CONST.ONBOARDING_RHP_VARIANT.INBOX_ADMINS_BESPOKE}); expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(ONBOARDING_ADMINS_CHAT_REPORT_ID), undefined); }); @@ -220,7 +223,7 @@ describe('navigateAfterOnboarding', () => { const navigate = jest.spyOn(Navigation, 'navigate'); // When onboarding finishes with the homePageNoRHP arm of the experiment - navigateAfterOnboarding(false, true, '', {}, ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID, false, {variantOverride: CONST.ONBOARDING_RHP_VARIANT.HOME_PAGE_NO_RHP}); + navigateAfterOnboarding(false, true, '', {}, false, ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID, false, {variantOverride: CONST.ONBOARDING_RHP_VARIANT.HOME_PAGE_NO_RHP}); // Then the user starts on Home, so Home's Getting started is the only onboarding surface they see expect(navigate).toHaveBeenCalledWith(ROUTES.HOME, undefined); @@ -236,7 +239,9 @@ describe('navigateAfterOnboarding', () => { await Onyx.set(ONYXKEYS.ONBOARDING_COMPANY_SIZE, companySize); // When onboarding finishes with the homePageNoRHP arm - navigateAfterOnboarding(false, true, '', {}, ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID, false, {variantOverride: CONST.ONBOARDING_RHP_VARIANT.HOME_PAGE_NO_RHP}); + navigateAfterOnboarding(false, true, '', {}, false, ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID, false, { + variantOverride: CONST.ONBOARDING_RHP_VARIANT.HOME_PAGE_NO_RHP, + }); await waitForBatchedUpdates(); // Then the arm behaves the same at every company size: Home, and the side panel is never opened @@ -255,7 +260,7 @@ describe('navigateAfterOnboarding', () => { mockIsReportTopmostSplitNavigator.mockReturnValue(true); // When onboarding finishes with the homePageNoRHP arm - navigateAfterOnboarding(false, true, '', {}, ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID, false, {variantOverride: CONST.ONBOARDING_RHP_VARIANT.HOME_PAGE_NO_RHP}); + navigateAfterOnboarding(false, true, '', {}, false, ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID, false, {variantOverride: CONST.ONBOARDING_RHP_VARIANT.HOME_PAGE_NO_RHP}); await waitForBatchedUpdates(); // Then the report stays where it is, and the side panel is not opened over it @@ -269,7 +274,7 @@ describe('navigateAfterOnboarding', () => { await Onyx.set(ONYXKEYS.NVP_ONBOARDING_RHP_VARIANT, CONST.ONBOARDING_RHP_VARIANT.HOME_PAGE_NO_RHP); // When onboarding finishes without a variant override - navigateAfterOnboarding(false, true, '', {}, ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID); + navigateAfterOnboarding(false, true, '', {}, false, ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID); // Then the stored variant still applies and the user lands on Home rather than the admin room expect(navigate).toHaveBeenCalledWith(ROUTES.HOME, undefined); From 657b6bd4d3f482d65e8ecf8e613eb1622a06dd3b Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Wed, 7 Oct 2026 14:06:15 +0200 Subject: [PATCH 3/7] Make public room guest canLeaveChat test guard the anonymous check --- tests/unit/ReportUtilsTest.ts | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index a073e5113581..a1cdb8bb3afc 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -14660,15 +14660,6 @@ describe('ReportUtils', () => { expect(canLeaveChat(report, reportPolicy, currentUserAccountID, false, false)).toBe(true); }); - it('should return false if the chat is public room and the user is the guest', () => { - const report: Report = { - ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.POLICY_ROOM), - visibility: CONST.REPORT.VISIBILITY.PUBLIC, - }; - - expect(canLeaveChat(report, undefined, currentUserAccountID, true, false)).toBe(false); - }); - it('should return true for the same public room when the user is not anonymous', () => { // Given a public policy room the current user participates in, viewed by a regular (non-anonymous) user const report: Report = { @@ -14682,15 +14673,15 @@ describe('ReportUtils', () => { expect(canLeaveChat(report, undefined, currentUserAccountID, false, false)).toBe(true); }); - it('should return false for that public room when the user is anonymous', () => { - // Given the same public room, but viewed by an anonymous (signed-out) visitor + it('should return false if the chat is public room and the user is the guest', () => { + // Given the same public room, but viewed by an anonymous (signed-out) guest const report: Report = { ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.POLICY_ROOM), visibility: CONST.REPORT.VISIBILITY.PUBLIC, participants: buildParticipantsFromAccountIDs([currentUserAccountID]), }; - // When checking whether the user can leave it + // When checking whether the guest can leave it // Then the anonymous flag passed by the caller blocks leaving expect(canLeaveChat(report, undefined, currentUserAccountID, true, false)).toBe(false); }); From 57331ac4c71234613e5a3e1bcc532e9716db4dec Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Thu, 15 Oct 2026 10:01:24 +0200 Subject: [PATCH 4/7] chore: empty commit to retrigger CI (flaky IOURequestStepConfirmationPageTest) From 383f24f1cdf2f709816555d891193c4bf28e8103 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Mon, 19 Oct 2026 08:47:10 +0200 Subject: [PATCH 5/7] Add agent skills and Codex tooling config Add .agents/skills (AGENTS-format copies of the repo's coding-standard, Onyx, Sentry, agent-device and review skills) and .codex tooling config (MCP servers for Playwright and Sentry, agent definitions, and an oxfmt post-edit hook). --- .agents/skills/agent-device-evidence/SKILL.md | 236 +++++++++ .../references/error-handling.md | 17 + .../references/manifest-schema.md | 56 ++ .../references/steps-parsing.md | 56 ++ .agents/skills/agent-device/SKILL.md | 126 +++++ .agents/skills/agent-device/flows/README.md | 118 +++++ .../macros/android/complete-onboarding.ad | 14 + .../flows/macros/android/send-message.ad | 9 + .../flows/macros/android/sign-in.ad | 10 + .../flows/macros/complete-onboarding.ad | 12 + .../agent-device/flows/macros/go-back.ad | 3 + .../skills/agent-device/flows/macros/join.ad | 6 + .../flows/macros/return-to-inbox.ad | 8 + .../agent-device/flows/macros/send-message.ad | 8 + .../flows/macros/switch-to-home.ad | 6 + .../flows/macros/web/complete-onboarding.ad | 13 + .../flows/macros/web/send-message.ad | 9 + .../agent-device/flows/macros/web/sign-in.ad | 10 + .agents/skills/app-coding-standards/SKILL.md | 87 ++++ .../rules/clean-react-0-compiler.md | 129 +++++ .../clean-react-1-composition-over-config.md | 481 ++++++++++++++++++ .../rules/clean-react-2-own-behavior.md | 125 +++++ .../clean-react-3-context-free-contracts.md | 135 +++++ .../clean-react-4-no-side-effect-spaghetti.md | 227 +++++++++ .../rules/clean-react-5-narrow-state.md | 119 +++++ .../rules/clean-react-6-no-componentprops.md | 51 ++ .../clean-react-7-no-inline-prop-types.md | 57 +++ .../clean-react-8-no-class-components.md | 47 ++ .../rules/clean-react-9-no-proptypes.md | 60 +++ .../rules/consistency-1-no-platform-checks.md | 57 +++ .../rules/consistency-10-jsdoc.md | 107 ++++ .../rules/consistency-11-no-todo-comments.md | 46 ++ ...onsistency-12-callback-named-for-action.md | 51 ++ .../rules/consistency-13-new-file-header.md | 55 ++ .../rules/consistency-14-comment-why.md | 41 ++ .../consistency-15-plain-comment-style.md | 66 +++ .../rules/consistency-16-no-ai-jargon.md | 56 ++ .../rules/consistency-17-plural-form.md | 148 ++++++ ...consistency-18-comment-non-obvious-code.md | 51 ++ .../consistency-19-copy-grammar-and-case.md | 61 +++ .../rules/consistency-2-no-magic-values.md | 53 ++ .../rules/consistency-20-function-names.md | 48 ++ .../consistency-3-no-code-duplication.md | 74 +++ .../rules/consistency-4-no-unused-props.md | 68 +++ .../consistency-5-justify-eslint-disable.md | 55 ++ .../consistency-6-proper-error-handling.md | 60 +++ .../rules/consistency-7-localize-copy.md | 65 +++ .../consistency-8-localize-numbers-dates.md | 60 +++ .../rules/consistency-9-file-naming.md | 58 +++ .../rules/perf-1-no-spread-in-renderitem.md | 54 ++ .../rules/perf-10-no-useeffect-parent-comm.md | 60 +++ .../rules/perf-11-optimize-data-selection.md | 153 ++++++ .../rules/perf-12-prevent-memory-leaks.md | 67 +++ .../rules/perf-13-hoist-iterator-calls.md | 75 +++ .../rules/perf-14-use-sync-external-store.md | 95 ++++ .../rules/perf-15-cleanup-async-effects.md | 90 ++++ .../rules/perf-16-guard-double-init.md | 75 +++ .../rules/perf-17-pass-raw-index-on-demand.md | 91 ++++ .../perf-18-use-pre-mount-destination.md | 136 +++++ .../perf-19-scalar-collection-selectors.md | 114 +++++ .../rules/perf-2-early-return.md | 56 ++ .../rules/perf-3-onyx-list-item-provider.md | 42 ++ .../rules/perf-5-shallow-comparison.md | 49 ++ .../rules/perf-6-derive-state-from-props.md | 57 +++ .../rules/perf-7-reset-via-key-prop.md | 62 +++ .../rules/perf-8-events-in-handlers.md | 62 +++ .../rules/perf-9-no-useeffect-chains.md | 75 +++ .../rules/ui-1-correct-loading-indicator.md | 96 ++++ .../rules/ui-2-new-page-scrollview.md | 63 +++ .../rules/ui-3-no-inline-styles.md | 46 ++ .../rules/ui-4-layout-spacing-tokens.md | 59 +++ .../rules/ui-5-reuse-existing-styles.md | 54 ++ .../skills/measure-telemetry-span/SKILL.md | 112 ++++ .../flows/create-expense-manual.ad | 29 ++ .../flows/open-create-expense.ad | 12 + .../flows/open-report.ad | 12 + .../flows/open-search-router.ad | 10 + .../flows/scan-receipt-init.ad | 14 + .../flows/send-message.ad | 16 + .../flows/submit-expense.ad | 12 + .../flows/switch-home-to-inbox.ad | 10 + .../flows/switch-home-to-reports.ad | 10 + .../skills/measure-telemetry-span/measure.sh | 334 ++++++++++++ .../scripts/replay-with-deadline.mjs | 141 +++++ .../scripts/replay-with-deadline.test.mjs | 136 +++++ .agents/skills/onyx/SKILL.md | 146 ++++++ .agents/skills/onyx/offline-patterns.md | 122 +++++ .../skills/playwright-app-testing/SKILL.md | 80 +++ .agents/skills/sentry/SKILL.md | 48 ++ .../SKILL.md | 18 + .../source-command-review-code-pr/SKILL.md | 27 + .../source-command-review-helpdot-pr/SKILL.md | 34 ++ .codex/agents/code-inline-reviewer.toml | 65 +++ .../agents/deploy-blocker-investigator.toml | 237 +++++++++ .codex/agents/helpdot-inline-reviewer.toml | 84 +++ .codex/agents/helpdot-summary-reviewer.toml | 69 +++ .codex/config.toml | 11 + .codex/hooks.json | 15 + 98 files changed, 7050 insertions(+) create mode 100644 .agents/skills/agent-device-evidence/SKILL.md create mode 100644 .agents/skills/agent-device-evidence/references/error-handling.md create mode 100644 .agents/skills/agent-device-evidence/references/manifest-schema.md create mode 100644 .agents/skills/agent-device-evidence/references/steps-parsing.md create mode 100644 .agents/skills/agent-device/SKILL.md create mode 100644 .agents/skills/agent-device/flows/README.md create mode 100644 .agents/skills/agent-device/flows/macros/android/complete-onboarding.ad create mode 100644 .agents/skills/agent-device/flows/macros/android/send-message.ad create mode 100644 .agents/skills/agent-device/flows/macros/android/sign-in.ad create mode 100644 .agents/skills/agent-device/flows/macros/complete-onboarding.ad create mode 100644 .agents/skills/agent-device/flows/macros/go-back.ad create mode 100644 .agents/skills/agent-device/flows/macros/join.ad create mode 100644 .agents/skills/agent-device/flows/macros/return-to-inbox.ad create mode 100644 .agents/skills/agent-device/flows/macros/send-message.ad create mode 100644 .agents/skills/agent-device/flows/macros/switch-to-home.ad create mode 100644 .agents/skills/agent-device/flows/macros/web/complete-onboarding.ad create mode 100644 .agents/skills/agent-device/flows/macros/web/send-message.ad create mode 100644 .agents/skills/agent-device/flows/macros/web/sign-in.ad create mode 100644 .agents/skills/app-coding-standards/SKILL.md create mode 100644 .agents/skills/app-coding-standards/rules/clean-react-0-compiler.md create mode 100644 .agents/skills/app-coding-standards/rules/clean-react-1-composition-over-config.md create mode 100644 .agents/skills/app-coding-standards/rules/clean-react-2-own-behavior.md create mode 100644 .agents/skills/app-coding-standards/rules/clean-react-3-context-free-contracts.md create mode 100644 .agents/skills/app-coding-standards/rules/clean-react-4-no-side-effect-spaghetti.md create mode 100644 .agents/skills/app-coding-standards/rules/clean-react-5-narrow-state.md create mode 100644 .agents/skills/app-coding-standards/rules/clean-react-6-no-componentprops.md create mode 100644 .agents/skills/app-coding-standards/rules/clean-react-7-no-inline-prop-types.md create mode 100644 .agents/skills/app-coding-standards/rules/clean-react-8-no-class-components.md create mode 100644 .agents/skills/app-coding-standards/rules/clean-react-9-no-proptypes.md create mode 100644 .agents/skills/app-coding-standards/rules/consistency-1-no-platform-checks.md create mode 100644 .agents/skills/app-coding-standards/rules/consistency-10-jsdoc.md create mode 100644 .agents/skills/app-coding-standards/rules/consistency-11-no-todo-comments.md create mode 100644 .agents/skills/app-coding-standards/rules/consistency-12-callback-named-for-action.md create mode 100644 .agents/skills/app-coding-standards/rules/consistency-13-new-file-header.md create mode 100644 .agents/skills/app-coding-standards/rules/consistency-14-comment-why.md create mode 100644 .agents/skills/app-coding-standards/rules/consistency-15-plain-comment-style.md create mode 100644 .agents/skills/app-coding-standards/rules/consistency-16-no-ai-jargon.md create mode 100644 .agents/skills/app-coding-standards/rules/consistency-17-plural-form.md create mode 100644 .agents/skills/app-coding-standards/rules/consistency-18-comment-non-obvious-code.md create mode 100644 .agents/skills/app-coding-standards/rules/consistency-19-copy-grammar-and-case.md create mode 100644 .agents/skills/app-coding-standards/rules/consistency-2-no-magic-values.md create mode 100644 .agents/skills/app-coding-standards/rules/consistency-20-function-names.md create mode 100644 .agents/skills/app-coding-standards/rules/consistency-3-no-code-duplication.md create mode 100644 .agents/skills/app-coding-standards/rules/consistency-4-no-unused-props.md create mode 100644 .agents/skills/app-coding-standards/rules/consistency-5-justify-eslint-disable.md create mode 100644 .agents/skills/app-coding-standards/rules/consistency-6-proper-error-handling.md create mode 100644 .agents/skills/app-coding-standards/rules/consistency-7-localize-copy.md create mode 100644 .agents/skills/app-coding-standards/rules/consistency-8-localize-numbers-dates.md create mode 100644 .agents/skills/app-coding-standards/rules/consistency-9-file-naming.md create mode 100644 .agents/skills/app-coding-standards/rules/perf-1-no-spread-in-renderitem.md create mode 100644 .agents/skills/app-coding-standards/rules/perf-10-no-useeffect-parent-comm.md create mode 100644 .agents/skills/app-coding-standards/rules/perf-11-optimize-data-selection.md create mode 100644 .agents/skills/app-coding-standards/rules/perf-12-prevent-memory-leaks.md create mode 100644 .agents/skills/app-coding-standards/rules/perf-13-hoist-iterator-calls.md create mode 100644 .agents/skills/app-coding-standards/rules/perf-14-use-sync-external-store.md create mode 100644 .agents/skills/app-coding-standards/rules/perf-15-cleanup-async-effects.md create mode 100644 .agents/skills/app-coding-standards/rules/perf-16-guard-double-init.md create mode 100644 .agents/skills/app-coding-standards/rules/perf-17-pass-raw-index-on-demand.md create mode 100644 .agents/skills/app-coding-standards/rules/perf-18-use-pre-mount-destination.md create mode 100644 .agents/skills/app-coding-standards/rules/perf-19-scalar-collection-selectors.md create mode 100644 .agents/skills/app-coding-standards/rules/perf-2-early-return.md create mode 100644 .agents/skills/app-coding-standards/rules/perf-3-onyx-list-item-provider.md create mode 100644 .agents/skills/app-coding-standards/rules/perf-5-shallow-comparison.md create mode 100644 .agents/skills/app-coding-standards/rules/perf-6-derive-state-from-props.md create mode 100644 .agents/skills/app-coding-standards/rules/perf-7-reset-via-key-prop.md create mode 100644 .agents/skills/app-coding-standards/rules/perf-8-events-in-handlers.md create mode 100644 .agents/skills/app-coding-standards/rules/perf-9-no-useeffect-chains.md create mode 100644 .agents/skills/app-coding-standards/rules/ui-1-correct-loading-indicator.md create mode 100644 .agents/skills/app-coding-standards/rules/ui-2-new-page-scrollview.md create mode 100644 .agents/skills/app-coding-standards/rules/ui-3-no-inline-styles.md create mode 100644 .agents/skills/app-coding-standards/rules/ui-4-layout-spacing-tokens.md create mode 100644 .agents/skills/app-coding-standards/rules/ui-5-reuse-existing-styles.md create mode 100644 .agents/skills/measure-telemetry-span/SKILL.md create mode 100644 .agents/skills/measure-telemetry-span/flows/create-expense-manual.ad create mode 100644 .agents/skills/measure-telemetry-span/flows/open-create-expense.ad create mode 100644 .agents/skills/measure-telemetry-span/flows/open-report.ad create mode 100644 .agents/skills/measure-telemetry-span/flows/open-search-router.ad create mode 100644 .agents/skills/measure-telemetry-span/flows/scan-receipt-init.ad create mode 100644 .agents/skills/measure-telemetry-span/flows/send-message.ad create mode 100644 .agents/skills/measure-telemetry-span/flows/submit-expense.ad create mode 100644 .agents/skills/measure-telemetry-span/flows/switch-home-to-inbox.ad create mode 100644 .agents/skills/measure-telemetry-span/flows/switch-home-to-reports.ad create mode 100755 .agents/skills/measure-telemetry-span/measure.sh create mode 100644 .agents/skills/measure-telemetry-span/scripts/replay-with-deadline.mjs create mode 100644 .agents/skills/measure-telemetry-span/scripts/replay-with-deadline.test.mjs create mode 100644 .agents/skills/onyx/SKILL.md create mode 100644 .agents/skills/onyx/offline-patterns.md create mode 100644 .agents/skills/playwright-app-testing/SKILL.md create mode 100644 .agents/skills/sentry/SKILL.md create mode 100644 .agents/skills/source-command-investigate-deploy-blocker/SKILL.md create mode 100644 .agents/skills/source-command-review-code-pr/SKILL.md create mode 100644 .agents/skills/source-command-review-helpdot-pr/SKILL.md create mode 100644 .codex/agents/code-inline-reviewer.toml create mode 100644 .codex/agents/deploy-blocker-investigator.toml create mode 100644 .codex/agents/helpdot-inline-reviewer.toml create mode 100644 .codex/agents/helpdot-summary-reviewer.toml create mode 100644 .codex/config.toml create mode 100644 .codex/hooks.json diff --git a/.agents/skills/agent-device-evidence/SKILL.md b/.agents/skills/agent-device-evidence/SKILL.md new file mode 100644 index 000000000000..448f0e9180fa --- /dev/null +++ b/.agents/skills/agent-device-evidence/SKILL.md @@ -0,0 +1,236 @@ +--- +name: agent-device-evidence +description: Records iOS/Android native MP4 evidence for test/repro flows extracted from an Expensify GitHub PR or issue. Use when the user asks to "record the flow for PR #X", "capture mobile evidence for issue #Y", or "produce screenshots/videos for ". Mobile-native only - declines mWeb and Desktop. +allowed-tools: Bash(agent-device *) Bash(gh pr view *) Bash(gh issue view *) Bash(mkdir -p *) Bash(file *) Bash(test *) Bash(date *) Read Write +--- + +# agent-device-evidence + +Records `iOS: Native` and `Android: Native` MP4 evidence for the test or repro steps declared in an Expensify GitHub **PR or issue**. The source of truth is the test/repro steps themselves, not the surrounding code or context - the skill works equally well on a PR's `### Tests` section, an issue's `## Action Performed:` block, or any future Markdown body where steps are clearly authored. + +Specializes the [`agent-device`](../agent-device/SKILL.md) skill: delegates device lifecycle (bundle ID, Metro, device pick, session, open) to its [Bring-up](../agent-device/SKILL.md#bring-up), then captures one artifact per declared flow per platform, writes a JSON manifest, and surfaces local file paths. + +The skill is **autonomous and non-interactive**. It never pauses for user input mid-run. All inputs are provided at invocation time; all failures surface as structured errors with exit codes. + +HybridApp-only (the parent skill's pre-flight enforces this). Standalone (non-HybridApp) builds are out of scope - production mobile evidence runs against HybridApp. + +## Scope + +**In scope:** `iOS: Native` (iOS Simulator), `Android: Native` (Android Emulator), HybridApp dev build only. Inputs may come from PRs or issues - the skill does not gate on code changes. + +**Out of scope:** `Android: mWeb Chrome`, `iOS: mWeb Safari`, `iOS: mWeb Chrome`, `Windows: Chrome`, `MacOS: Chrome / Safari`. Decline with `EXIT 4` and point to a browser-driver skill (`playwright-app-testing`). Standalone (non-HybridApp) builds. Decline with `EXIT 7 BRING_UP_FAILED` per the parent skill's gate. + +## Inputs + +| Input | Source | Required | +| --- | --- | --- | +| Source URL (PR or issue) | First positional arg, e.g. `https://github.com/Expensify/App/pull/89475` or `.../issues/89855` | Yes | +| `--platforms ios,android` | Flag | No (default: derived) | +| `-e KEY=VALUE` step-param overrides | Repeatable | No | + +Bare numbers are rejected (PRs and issues share the GitHub number namespace; the URL path is the safe disambiguator). No interactive prompts. + +## Triage gates (run in order, before any device work) + +1. **Detect source kind** from the URL: `/pull/N` → PR, `/issues/N` → issue. Anything else → exit `8 BAD_INPUT`. +2. **Fetch the source body**: + - PR: `gh pr view --json title,body` + - Issue: `gh issue view --json title,body,labels` +3. **Platform resolution** - in priority order: + 1. `--platforms` arg (CSV, wins all). + 2. **PR source**: explicit prose markers in title or `### Tests` body - `iOS only`, `Android only`, `On iOS:`, `On Android:`. + 3. **Issue source**: the `## Platforms:` checkbox list. Filled boxes denote where the bug reproduces; restrict to the matching native platforms. + 4. Default: both `ios` and `android`. + + Aliases: `iOS: Native` ≡ `iOS: App` (both → `ios`); `Android: Native` ≡ `Android: App` (both → `android`). All mWeb / Windows / MacOS variants are out of scope. + + If the only platforms matched are out of scope (e.g. an issue checks only `MacOS: Chrome / Safari`), **exit `4 PLATFORM_UNSUPPORTED`**. +4. **Steps parsing** - extract the steps section and produce a flow list (see below). If the flow list is empty, **exit `3 NO_FLOWS`**. + +## Steps parsing + +See [`references/steps-parsing.md`](references/steps-parsing.md). + +## Phase 1 cache + +Simple map: flow steps → `.ad` script. If the steps haven't changed, reuse the cached script and skip the warm-up. + +- Path: `~/.cache/agent-device-evidence/.ad-cache/.ad` +- Fingerprint: `sha256(precondition + json(steps) + platform)`. Platform is included so iOS and Android don't share an entry (different selectors). +- Hit → copy to `$TEST_FLOW.ad`, mark `cached: true` in the manifest, skip Phase 1, proceed to Phase 2. +- Miss → run Phase 1, write the script to the cache on success. + +The skill does not delete, invalidate, or retry cache entries. If a cached `.ad` is stale, the flow is marked `phase2_failed`. To recover, edit the steps (which changes the fingerprint) or wipe `~/.cache/agent-device-evidence/.ad-cache/` externally. + +## Capture loop (per flow per platform) + +Two phases per flow. Lifecycle delegated to the parent skill's bring-up. Phase 1 is skipped on cache hit (see above). + +### Shared setup (run once per platform, before the first flow) + +1. **Run the [agent-device bring-up](../agent-device/SKILL.md#bring-up)** for the target platform. The parent skill resolves bundle ID, starts Metro, picks/confirms the device, manages session, and opens the app for sanity verification. Capture the resolved `$APP_ID` (bundle ID) and `$DEVICE_NAME` for re-opens in Phases 1 and 2. + - If bring-up fails for any reason (HybridApp gate, missing dev build, Metro start, simulator boot, etc.), **exit `7 BRING_UP_FAILED`** and surface the parent skill's error verbatim. + - Selector discipline (id > role+label, no coordinate fallback unless 0 a11y nodes) follows the parent skill's [`flows/README.md`](../agent-device/flows/README.md). + - **Non-interactive overrides for the parent bring-up** (this skill never prompts): + - Device pick (parent step 5, "If multiple are booted, ask the user which"): pick the **first booted device** in `agent-device devices --json` order, deterministically. Log the choice in the manifest under `device_selected`. + - Session reuse vs reset (parent step 6, line 73): **always `reset`** for sessions not created in the current invocation - run `agent-device close --shutdown --session ` without prompting. Phase 1 and Phase 2 both rely on cold starts, so reuse of stale sessions is never desired here. + +2. **Close the bring-up session** so each phase starts cold: + ```bash + agent-device close + ``` + +3. **Set up run directory** - persistent, append-only: + ```bash + SOURCE_KIND=; SOURCE_NUM=; RUN_TS=$(date -u +%Y%m%dT%H%M%SZ) + RUN_DIR="$HOME/.cache/agent-device-evidence/$SOURCE_KIND-$SOURCE_NUM/$RUN_TS" + mkdir -p "$RUN_DIR/ios" "$RUN_DIR/android" + ``` + +### Phase 1 - Warm-up (per flow, no camera) + +Goal: produce a deterministic `.ad` script of the successful command sequence, plus per-step still candidates. Drives autonomously from cold start. No recording. + +**Skip if cached.** Before any device work, consult the [Phase 1 cache](#phase-1-cache). On hit, copy the cached `.ad` to `$TEST_FLOW.ad`, mark `cached: true` in the manifest, and proceed straight to Phase 2. + +On cache miss: + +1. **Open the app** with the bring-up's resolved values: + ```bash + agent-device open "$APP_ID" --device "$DEVICE_NAME" + ``` + +2. **Drive setup actions** based on the flow's `Precondition:` block (if any) and what the steps imply. Setup actions go into the `.ad` script up to the marker; everything after the marker is what Phase 2 records. + +3. **Drive the test flow** - one numbered step at a time. For each step: + - Send the step text verbatim to the agent-device LLM driver. + - On success, append the **final, successful** action to `$TEST_FLOW.ad`. Do not append actions that needed retries on different selectors. + - **If a value is explicit in the step** (e.g. "Enter $42.50"), pass it through verbatim. **If not**, the LLM picks a context-appropriate value and the chosen value is recorded in `params:` in the manifest. + - The post-action `agent-device snapshot` (taken for selector matching) is **saved as a candidate still** - `flow--step--