Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import useShouldShowRequire2FAPage from '@hooks/useShouldShowRequire2FAPage';

import {setNameValuePair} from '@libs/actions/User';
import Navigation, {getDeepestFocusedScreen, isTwoFactorSetupScreen} from '@libs/Navigation/Navigation';
import {ACTIVE_PRODUCT_MARKETING_ANNOUNCEMENT, getProductMarketingAnnouncementVariant} from '@libs/ProductMarketingWindowUtils';
import {ACTIVE_PRODUCT_MARKETING_ANNOUNCEMENT, getProductMarketingAnnouncementVariant, isBrandNewUser} from '@libs/ProductMarketingWindowUtils';

import CONST from '@src/CONST';
import NAVIGATORS from '@src/NAVIGATORS';
Expand Down Expand Up @@ -59,6 +59,8 @@ function ProductMarketingWindowManager({topmostRouteName}: ProductMarketingWindo
const [isAnonymousSession = false] = useOnyx(ONYXKEYS.SESSION, {selector: isAnonymousSessionSelector});
const [isActingAsDelegate = false, accountMetadata] = useOnyx(ONYXKEYS.ACCOUNT, {selector: isActingAsDelegateSelector});
const [lastDismissedMarketingWindow, lastDismissedMarketingWindowMetadata] = useOnyx(ONYXKEYS.NVP_LAST_DISMISSED_MARKETING_WINDOW);
// Distinguishes returning users from brand-new ones so a "new feature" window isn't shown to users who joined after it shipped.
const [firstPolicyCreatedDate, firstPolicyCreatedDateMetadata] = useOnyx(ONYXKEYS.NVP_PRIVATE_FIRST_POLICY_CREATED_DATE);
// OpenApp provides the dismissal and targeting data; wait for it to avoid a startup flash or a wrong CTA destination.
const [isLoadingApp = true, isLoadingAppMetadata] = useOnyx(ONYXKEYS.IS_LOADING_APP);

Expand All @@ -67,10 +69,20 @@ function ProductMarketingWindowManager({topmostRouteName}: ProductMarketingWindo
// assets when the requested names change after mount (e.g. when the audience flips after policies arrive).
const illustrationNames = announcement ? [announcement.admin.visual, announcement.member?.visual].flatMap((visual) => (visual?.type === 'illustration' ? [visual.name] : [])) : [];
const illustrations = useMemoizedLazyIllustrations(illustrationNames);
const targetAdminPolicyID = activeAdminPolicies?.find((policy) => policy.id === activePolicyID)?.id ?? activeAdminPolicies?.at(0)?.id;
// The admin window targets Collect/Control admins only, so Submit admins fall through to the (absent) member variant and see nothing.
const eligibleAdminPolicies = activeAdminPolicies?.filter((policy) => policy.type === CONST.POLICY.TYPE.TEAM || policy.type === CONST.POLICY.TYPE.CORPORATE);
const targetAdminPolicyID = eligibleAdminPolicies?.find((policy) => policy.id === activePolicyID)?.id ?? eligibleAdminPolicies?.at(0)?.id;
const variant = getProductMarketingAnnouncementVariant(announcement, !!targetAdminPolicyID, lastDismissedMarketingWindow);
const isCoveredByCenteredModalScreen = !!topmostRouteName && CENTERED_MODAL_SCREEN_NAVIGATORS.has(topmostRouteName);
const isLoading = isLoadingOnyxValue(lastDismissedMarketingWindowMetadata, activeAdminPoliciesMetadata, activePolicyIDMetadata, isLoadingAppMetadata, accountMetadata) || isLoadingApp;
const isLoading =
isLoadingOnyxValue(
lastDismissedMarketingWindowMetadata,
activeAdminPoliciesMetadata,
activePolicyIDMetadata,
firstPolicyCreatedDateMetadata,
isLoadingAppMetadata,
accountMetadata,
) || isLoadingApp;
const shouldShowRequire2FAPage = useShouldShowRequire2FAPage();
const navigation = useNavigation();
const isIn2FASetupFlow = useRootNavigationState((state) => {
Expand All @@ -83,6 +95,7 @@ function ProductMarketingWindowManager({topmostRouteName}: ProductMarketingWindo
!announcement ||
!variant ||
isLoading ||
isBrandNewUser(announcement, firstPolicyCreatedDate) ||
isProductMarketingWindowCovered ||
isAnonymousSession ||
isActingAsDelegate ||
Expand Down
26 changes: 25 additions & 1 deletion src/libs/ProductMarketingWindowUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import type {Route} from '@src/ROUTES';
import type {ImageSourcePropType} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';

import {differenceInDays} from 'date-fns';

type ProductMarketingAnnouncementVisual =
| {
type: 'image';
Expand Down Expand Up @@ -42,6 +44,13 @@ type ProductMarketingAnnouncement = {
/** Stable key shared by every audience variant of this product update. A later update must use a new key. */
updateKey: string;

/**
* Optional cutoff for the "returning user" audience. Users whose first workspace was created on or after this
* date are treated as brand-new and never shown the announcement — a "new feature" update is only relevant to
* users who were around before it shipped. Omit to show regardless of how long the user has been around.
*/
returningUserCutoffDate?: Date;

/** Variant shown to users who are an admin on at least one active workspace. Admin prevails when a user is both member and admin. */
admin: ProductMarketingAnnouncementVariant;

Expand All @@ -56,6 +65,9 @@ type ProductMarketingAnnouncement = {
*/
const ACTIVE_PRODUCT_MARKETING_ANNOUNCEMENT: ProductMarketingAnnouncement | null = {
updateKey: 'productUpdateJuly2026',
// The new admin role types shipped with this update, so only users who had a workspace before it should be told
// about them. Users whose first workspace was created on or after this date are brand-new and skip the window.
returningUserCutoffDate: new Date(2026, 6, 1),
admin: {
visual: {type: 'image', source: July26PromoImage},
heading: 'productMarketingWindow.roleTypes.admin.heading',
Expand All @@ -70,6 +82,18 @@ function isProductMarketingAnnouncementDismissed(announcement: ProductMarketingA
return !!announcement && announcement.updateKey === lastDismissedMarketingWindow;
}

/**
* Whether the user is too new to be shown the announcement. A user is brand-new when the announcement defines a
* returningUserCutoffDate and the user's first workspace was created on or after it. When the creation date is
* unknown we treat the user as returning so established admins are never wrongly excluded.
*/
function isBrandNewUser(announcement: ProductMarketingAnnouncement | null, firstPolicyCreatedDate: OnyxEntry<string>): boolean {
if (!announcement?.returningUserCutoffDate || !firstPolicyCreatedDate) {
return false;
}
return differenceInDays(firstPolicyCreatedDate, announcement.returningUserCutoffDate) >= 0;
}

/**
* Resolves the content variant of the announcement the user should see, or undefined when no window should be shown.
* Dismissal never falls through to another announcement — when the active announcement is dismissed, nothing is shown.
Expand All @@ -85,5 +109,5 @@ function getProductMarketingAnnouncementVariant(
return hasActiveAdminPolicies ? announcement.admin : announcement.member;
}

export {ACTIVE_PRODUCT_MARKETING_ANNOUNCEMENT, isProductMarketingAnnouncementDismissed, getProductMarketingAnnouncementVariant};
export {ACTIVE_PRODUCT_MARKETING_ANNOUNCEMENT, isProductMarketingAnnouncementDismissed, isBrandNewUser, getProductMarketingAnnouncementVariant};
export type {ProductMarketingAnnouncement, ProductMarketingAnnouncementVariant};
31 changes: 30 additions & 1 deletion tests/unit/ProductMarketingWindowUtilsTest.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import {getProductMarketingAnnouncementVariant, isProductMarketingAnnouncementDismissed} from '@libs/ProductMarketingWindowUtils';
import {getProductMarketingAnnouncementVariant, isBrandNewUser, isProductMarketingAnnouncementDismissed} from '@libs/ProductMarketingWindowUtils';
import type {ProductMarketingAnnouncement} from '@libs/ProductMarketingWindowUtils';

import ROUTES from '@src/ROUTES';

const activeAnnouncement: ProductMarketingAnnouncement = {
updateKey: 'productUpdateJuly2026',
returningUserCutoffDate: new Date(2026, 6, 1),
admin: {
visual: {type: 'illustration', name: 'Rules'},
heading: 'productMarketingWindow.roleTypes.admin.heading',
Expand All @@ -24,6 +25,10 @@ const adminOnlyAnnouncement: ProductMarketingAnnouncement = {
updateKey: activeAnnouncement.updateKey,
admin: activeAnnouncement.admin,
};
const announcementWithoutCutoff: ProductMarketingAnnouncement = {
updateKey: activeAnnouncement.updateKey,
admin: activeAnnouncement.admin,
};

const OLDER_UPDATE_KEY = 'productUpdateJune2026';

Expand All @@ -47,6 +52,30 @@ describe('ProductMarketingWindowUtils', () => {
});
});

describe('isBrandNewUser', () => {
it('returns false when there is no announcement', () => {
expect(isBrandNewUser(null, '2026-08-01')).toBe(false);
});

it('returns false when the announcement has no returning-user cutoff', () => {
expect(isBrandNewUser(announcementWithoutCutoff, '2026-08-01')).toBe(false);
});

it('returns false when the first workspace creation date is unknown', () => {
expect(isBrandNewUser(activeAnnouncement, undefined)).toBe(false);
expect(isBrandNewUser(activeAnnouncement, '')).toBe(false);
});

it('returns false for returning users whose first workspace predates the cutoff', () => {
expect(isBrandNewUser(activeAnnouncement, '2026-06-15')).toBe(false);
});

it('returns true for brand-new users whose first workspace was created on or after the cutoff', () => {
expect(isBrandNewUser(activeAnnouncement, '2026-07-01')).toBe(true);
expect(isBrandNewUser(activeAnnouncement, '2026-08-15')).toBe(true);
});
});

describe('getProductMarketingAnnouncementVariant', () => {
it('returns undefined when no announcement is active', () => {
expect(getProductMarketingAnnouncementVariant(null, true, undefined)).toBeUndefined();
Expand Down
Loading