Add persistent bottom-right product marketing window#96141
Conversation
|
Hey, I noticed you changed If you want to automatically generate translations for other locales, an Expensify employee will have to:
Alternatively, if you are an external contributor, you can run the translation script locally with your own OpenAI API key. To learn more, try running: npx ts-node ./scripts/generateTranslations.ts --helpTypically, you'd want to translate only what you changed by running |
8546c9a to
292fa34
Compare
Codecov Report✅ Changes either increased or maintained existing code coverage, great job!
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 292fa34b95
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| }); | ||
| // The centered modal always takes precedence over the marketing window, so the window is hidden while | ||
| // a covering (non-popover) modal is opening or visible and shows again once the modal state clears. | ||
| const [isModalCovering = false] = useOnyx(ONYXKEYS.MODAL, {selector: isModalCoveringSelector}); |
There was a problem hiding this comment.
Hide behind bottom-docked confirm modals
On narrow screens ConfirmModal renders as CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED, and BaseModal reports bottom-docked modals with isPopover: true, so this selector returns false for them. Because the marketing window is also bottom-anchored on narrow layouts, opening any confirm/bottom-docked modal (for example the delete-comment confirmation on mWeb/native narrow) leaves the marketing card mounted instead of suppressing it; treat bottom-docked modal visibility as covering here rather than relying on the popover-filtered selector.
Useful? React with 👍 / 👎.
Explanation of Change
This PR adds the persistent, dismissible bottom-right product marketing window described in the linked issue:
ProductMarketingWindowManageris mounted in both platform variants ofRootNavigatorExtraContent, the extra content slot of the authenticated root stack navigator. Because this host renders outside the changing route screens, the window stays mounted while the user navigates between Home, Reports, Workspaces, etc. It never renders for logged-out users (the host only exists inAuthScreens), for anonymous public-room sessions (explicitauthTokenTypeguard), or while acting as a copilot (delegates must not see or permanently dismiss the account owner's announcement, matching the other product training surfaces).ACTIVE_PRODUCT_MARKETING_ANNOUNCEMENT(inProductMarketingWindowUtils) holds exactly one marketing-authored announcement (ornullfor none), with a stableannouncementIDand separateadmin/membercontent variants (illustration, heading, body, CTA label, CTA route). There is no list scanning, stacking, or queueing: when the active announcement is dismissed, nothing is shown until a future release ships a new announcement ID.src/languages(all supported locales), and the variant config carries translation paths, following theagentsPromoBannerprecedent.hasActiveAdminPoliciesSelector(the same selector behinduseHasActiveAdminPolicies) — users who administer at least one active workspace get the admin variant (so admin prevails for users who are both member and admin), everyone else gets the member variant. The selector is subscribed directly so its loading state is available (see below).Dismissbutton and completing the primary CTA store a namespaced key (productMarketingWindow:<announcementID>) in the existing dismissed product training NVP viadismissProductTraining(). TheDismissedProductTrainingOnyx type is extended to accept that key pattern, and the dismissal check reuses the existingisProductTrainingElementDismissedhelper. The CTA records the dismissal before navigating so the window doesn't flash during navigation. A future announcement has a different ID, so it remains eligible even if a prior one was dismissed.isLoadingOnyxValue) and for the initial OpenApp fetch to finish (IS_LOADING_APP, defaulting to true, mirroringuseAIFeaturesPromoModal) before rendering. A dismissed window therefore doesn't flash on refresh/app start or when signing in on a new device, and admins don't briefly see the member variant while policies arrive. Both variants' illustrations are resolved up front so an audience flip mid-session never shows a blank illustration.ONYXKEYS.MODALwith the existingisModalCoveringSelector()(covers allreact-native-modal-based centered modals viaBaseModal) and additionally hides while a screen-based centered modal navigator (onboarding, feature training, migrated-user welcome, submit plan, AI features promo, test drive demo, test tools) is the topmost root route. The window and a centered modal are therefore never shown at the same time, and the still-mounted manager shows the window again when the modal closes.The initial active announcement ships with the two content variants shown in the issue's design mock ("Upload your expense policy PDF" for admins, "Attach multiple receipts to one expense" for members).
Unit tests cover the single-active/dismissal/audience helpers, and component tests cover audience resolution, namespaced dismissal, dismissal persistence after pressing either button, dismiss-before-CTA-navigation ordering, centered-modal suppression/restore (both Onyx-modal and screen-based), anonymous-session exclusion, wide/narrow layout styles, and (via the real root stack navigator) persistence across route changes.
Fixed Issues
$ #95791
PROPOSAL: #95791 (comment)
Tests
Member variant, persistence, and CTA (account A: not an admin on any workspace)
Member dismissal (account B: another account that is not an admin on any workspace)
Admin variant and CTA (account C: admin on at least one active workspace)
Offline tests
Dismissal is written optimistically to Onyx, so it works offline:
QA Steps
Same as the Tests section above, on staging. This requires two accounts with no admin role on any workspace (the CTA leg dismisses the announcement for the first account) and one account that is an admin on at least one active workspace.
PR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectionAvatar, I verified the components usingAvatarare working as expected)StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))npm run compress-svg)Avataris modified, I verified thatAvataris working as expected in all cases)Designlabel and/or tagged@Expensify/designso the design team can review the changes.mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps.Screenshots/Videos
Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari (Admin variant and CTA)
Screen.Recording.2026-07-16.at.3.36.25.AM-compressed.mp4
MacOS: Chrome / Safari (Member CTA)
Screen.Recording.2026-07-16.at.3.40.55.AM.mov
MacOS: Chrome / Safari (Member dismissal)
Screen.Recording.2026-07-16.at.3.44.27.AM.mov